Compare commits

...

13 Commits

118 changed files with 13268 additions and 168 deletions

View File

@@ -57,6 +57,8 @@ files, err := oss.New(client, bucket, 32<<20)
业务需要临时链接时显式依赖额外接口 `storage.Presigner`。S3/OSS 实现 `PresignGet`;Local 用 `storage.NewSigner(files, downloadBaseURL, secret)` 提供 HTTP 下载 Handler。链接 TTL 为 1 秒到 1 小时,私钥/临时凭据可能让云链接提前失效。签发链接前必须完成业务授权;对象 key 及 bucket 本身不是租户权限。签名链接不要写日志。受限直传和云端分片现已提供可选接口,见 [上传与续传说明](storage/README.md)。生命周期、ACL、CDN 自定义域名和版本管理仍由平台/部署管理。 业务需要临时链接时显式依赖额外接口 `storage.Presigner`。S3/OSS 实现 `PresignGet`;Local 用 `storage.NewSigner(files, downloadBaseURL, secret)` 提供 HTTP 下载 Handler。链接 TTL 为 1 秒到 1 小时,私钥/临时凭据可能让云链接提前失效。签发链接前必须完成业务授权;对象 key 及 bucket 本身不是租户权限。签名链接不要写日志。受限直传和云端分片现已提供可选接口,见 [上传与续传说明](storage/README.md)。生命周期、ACL、CDN 自定义域名和版本管理仍由平台/部署管理。
需要 inline 预览、显示文件名或指定响应 MIME 时,使用可选 `storage.ReadPresigner.PresignGetWithOptions`,返回 URL 和签名内的实际到期时间。Local、S3/R2、OSS 使用相同选项;Local 支持 HEAD 与范围读取,云端由原生存储服务处理。旧下载接口不变,见 [签名读取说明](storage/README.md#签名预览与下载)和[完整示例](examples/signedRead/README.md)。
R2 实现 S3 的子集,配置入口使用标准账户 endpoint;法域专属 endpoint 可显式配置。兼容性依据:[R2 S3 API](https://developers.cloudflare.com/r2/api/s3/api/)、[R2 签名链接](https://developers.cloudflare.com/r2/api/s3/presigned-urls/)。OSS 使用其[官方 Go SDK v2](https://www.alibabacloud.com/help/en/oss/developer-reference/manual-for-go-sdk-v2/),没有假定全部 S3 行为与 OSS 等价。 R2 实现 S3 的子集,配置入口使用标准账户 endpoint;法域专属 endpoint 可显式配置。兼容性依据:[R2 S3 API](https://developers.cloudflare.com/r2/api/s3/api/)、[R2 签名链接](https://developers.cloudflare.com/r2/api/s3/presigned-urls/)。OSS 使用其[官方 Go SDK v2](https://www.alibabacloud.com/help/en/oss/developer-reference/manual-for-go-sdk-v2/),没有假定全部 S3 行为与 OSS 等价。
验证区分:Local 契约、OSS SDK HTTP fixture、S3 SDK fixture 和 R2 签名参数属于本地测试;Linux runner 再跑真实 MinIO 上传/读取/删除与签名拒绝。**MinIO 通过不能代替 OSS/R2 实网验证。**迁移存储平台还需要复制已有对象和调整部署权限,不只是切配置。 验证区分:Local 契约、OSS SDK HTTP fixture、S3 SDK fixture 和 R2 签名参数属于本地测试;Linux runner 再跑真实 MinIO 上传/读取/删除与签名拒绝。**MinIO 通过不能代替 OSS/R2 实网验证。**迁移存储平台还需要复制已有对象和调整部署权限,不只是切配置。
@@ -121,6 +123,20 @@ log:
`telemetry.New` 接受 OTel `sdktrace.SpanExporter` 和 `sdkmetric.Reader`;可注入官方 OTLP exporter、PeriodicReader 或测试用 exporter,基础包不强制选择 collector 协议。`app.Use(runtime.Middleware())` 采集入站,`telemetry.Transport` 采集出站并传播 W3C trace context;Redis 使用 `client.AddHook(telemetry.RedisHook{Runtime: runtime})`,SQL 使用 `telemetry.SQL{DB: db, Runtime: runtime}`。业务操作/事务可用 `runtime.Do(ctx, "fixedOperationName", fn)` 包裹。 `telemetry.New` 接受 OTel `sdktrace.SpanExporter` 和 `sdkmetric.Reader`;可注入官方 OTLP exporter、PeriodicReader 或测试用 exporter,基础包不强制选择 collector 协议。`app.Use(runtime.Middleware())` 采集入站,`telemetry.Transport` 采集出站并传播 W3C trace context;Redis 使用 `client.AddHook(telemetry.RedisHook{Runtime: runtime})`,SQL 使用 `telemetry.SQL{DB: db, Runtime: runtime}`。业务操作/事务可用 `runtime.Do(ctx, "fixedOperationName", fn)` 包裹。
上游网关如果使用自定义 Header,可配置通用兼容传播器,不在业务 Handler 里解析 Header:
```go
propagator, err := telemetry.NewHeaderPropagator(telemetry.HeaderPropagationConfig{
TraceIDHeader: "X-Trace-Id", SpanIDHeader: "X-Span-Id", RequestIDHeader: "X-Request-Id",
})
if err != nil { return err }
runtime, err := telemetry.New(telemetry.Config{
Service: "billing", SampleRatio: 1, Propagator: propagator,
})
```
`HeaderPropagator` 同时支持 W3C `traceparent`;两者同时存在时优先 W3C。有效的兼容 Header 会成为远程父 Span,当前服务仍生成自己的 Span ID,`logx.TraceID` / `RequestID`、访问日志和审计会沿用上下文,出站 `telemetry.Transport` 会同时传播 W3C 和配置的兼容 Header。自定义 Header 不携带采样标志,适配器默认把有效成对 ID 当作已采样;需要保留上游采样决定时使用 W3C。只有受信网关入口才应启用该适配器,Trace/Span ID 不是认证、租户或幂等凭据。完整离线示例见 [telemetryHeaders](examples/telemetryHeaders/README.md)。
创建者负责在停止请求/Worker 后调用 `runtime.Shutdown(ctx)` 刷新并关闭 exporter/reader。指标按注册路由模板和固定方法分类;自动采集不记录 URL query、SQL 正文、Redis key、token 或业务载荷。业务自定义 span 名称同样应固定,避免用用户 ID 或对象 key。 创建者负责在停止请求/Worker 后调用 `runtime.Shutdown(ctx)` 刷新并关闭 exporter/reader。指标按注册路由模板和固定方法分类;自动采集不记录 URL query、SQL 正文、Redis key、token 或业务载荷。业务自定义 span 名称同样应固定,避免用用户 ID 或对象 key。
## 分布式限流与幂等 ## 分布式限流与幂等

4
API.md
View File

@@ -109,7 +109,7 @@ service user-api {
- 来源标签中的 `optional` / `omitempty`、标量 `default`、标量 `options=a|b`、数值 `range=[1:100]`(支持开区间和单边界)。 - 来源标签中的 `optional` / `omitempty`、标量 `default`、标量 `options=a|b`、数值 `range=[1:100]`(支持开区间和单边界)。
- `binding` 的 `required`、`min`、`max`、`len`、`email`。长度按 Unicode 字符或切片元素数;范围边界必须有限且位于 ±2^53 内。 - `binding` 的 `required`、`min`、`max`、`len`、`email`。长度按 Unicode 字符或切片元素数;范围边界必须有限且位于 ±2^53 内。
暂不支持匿名嵌入、别名、递归类型、未声明的外部 Go 类型、未通过 scalar 显式接入的自定义编解码、`[]byte`、泛型、`json:",string"`、任意 validator 标签、go-zero 的 JWT/签名/SSE 声明,以及客户端 SDK 生成。认证验证通过声明关联的命名中间件实现。未列出的语法会明确报错。 暂不支持匿名嵌入、别名、递归请求类型、未声明的外部 Go 类型、未通过 scalar 显式接入的自定义编解码、`[]byte`、泛型、`json:",string"`、任意 validator 标签、go-zero 的 JWT/签名/SSE 声明,以及客户端 SDK 生成。认证验证通过声明关联的命名中间件实现。未列出的语法会明确报错。
业务错误、具名鉴权、多行文档及下述开发命令从 CLI `v0.7.0-preview.1` 提供。公共响应模板从 `v0.8.0-preview` 提供,CLI 与业务项目中的框架依赖需一起升级。 业务错误、具名鉴权、多行文档及下述开发命令从 CLI `v0.7.0-preview.1` 提供。公共响应模板从 `v0.8.0-preview` 提供,CLI 与业务项目中的框架依赖需一起升级。
@@ -331,3 +331,5 @@ python3 scripts/verifyManagedProject.py
Go 测试覆盖契约拒绝规则、多源绑定、零值/缺失、默认/范围/嵌套集合、文档约束、幂等更新、只读检查、业务保留、删除路由、路径边界及生成项目编译。Python 验收会在临时目录生成项目,分别启动 nethttp/native,验证真实 HTTP 请求、中间件、日志 requestId 和优雅退出;结束后清理自己创建的进程和临时文件。结果记录在 `performance/managedProjectLocal.json`;完整记录见 [第一版验收](performance/managedProjectAcceptance.md)。 Go 测试覆盖契约拒绝规则、多源绑定、零值/缺失、默认/范围/嵌套集合、文档约束、幂等更新、只读检查、业务保留、删除路由、路径边界及生成项目编译。Python 验收会在临时目录生成项目,分别启动 nethttp/native,验证真实 HTTP 请求、中间件、日志 requestId 和优雅退出;结束后清理自己创建的进程和临时文件。结果记录在 `performance/managedProjectLocal.json`;完整记录见 [第一版验收](performance/managedProjectAcceptance.md)。
内置健康与 OpenAPI 路由可通过配置禁用或修改路径,`.api` 不再固定保留 `/health/live`、`/health/ready`、`/openapi.json`。启用的内置路径与业务 GET 路由重叠时,生成的 NewApp 在监听前返回配置错误;禁用后可以由 `.api` 自己维护这些路径。见 [内置接口配置](PROJECT.md#配置内置接口)。 内置健康与 OpenAPI 路由可通过配置禁用或修改路径,`.api` 不再固定保留 `/health/live`、`/health/ready`、`/openapi.json`。启用的内置路径与业务 GET 路由重叠时,生成的 NewApp 在监听前返回配置错误;禁用后可以由 `.api` 自己维护这些路径。见 [内置接口配置](PROJECT.md#配置内置接口)。
响应 DTO 自引用与相互引用从 v0.28.2 起支持,保留强类型并生成 OpenAPI `$ref`;见[递归响应说明](DEVELOPMENT.md#递归响应-dto)。直接值循环与字段组合循环仍然非法。

View File

@@ -1,3 +1,31 @@
# v0.31.0-preview — WebSocket(2026-10-29)
- 新增 `ws` 通用 Client/Server/Session:WS/WSS、文本/二进制/JSON、TLS/mTLS、代理、握手认证/Origin/子协议、心跳、有限队列、重连、关闭握手和完整后台完成屏障。
- 新增进程内 `ws/hub` 分组广播、可选 `ws/rpc` 对称调用/事件/取消与显式订阅恢复、`ws/observe` 安全日志与统计快照。消息不自动重放,不承诺离线或跨节点可靠投递。
- App 新增 OnStop/OnDrain,复用 Runtime 的依赖保留规则;ws/omni 适配记录真实 101。API 生成入口支持可选的项目自有 RegisterCustomRoutes 方法。
- 增加真实 TCP/TLS/代理、竞态、安全、模糊测试、性能基准和可运行示例。新增 Gorilla WebSocket 依赖;保持 Go 1.22 语言基线;CLI 默认框架版本同步为 v0.31.0-preview。
- 兼容边界:nethttp HTTP/1.1;native、独立实验 HTTP/2 和 HTTPS 出站代理当前不支持。
- 已知限制:原有 x/net HTTP/2 依赖仍有 4 项可达漏洞;浏览器实测未完成,详见 [验收记录](reports/websocket-validation-2026-10-29.md)。
# v0.30.0-preview
- 新增通用 `telemetry.HeaderPropagator` 与 `HeaderPropagationConfig`,配置化接入 Trace ID、Span ID、Request ID Header;不绑定任何网关名称。
- 入站优先使用标准 W3C `traceparent`;缺少有效 W3C 父链路时,合法的兼容 Header 对成为远程父 Span。日志、审计、访问日志沿用 request/trace 上下文。
- 出站 `telemetry.Transport` 同时传播 W3C 和配置的兼容 Header;旧配置不变,默认仍只使用 W3C。
- 补充自定义 Header、W3C 优先级、非法 ID、父子 Span 和通用离线示例回归;不引入业务类型、网关名称或第三方依赖。
# v0.29.0-preview
- 新增可选 `storage.ReadPresigner` / `ReadOptions` / `ReadLink`,签名读取可选择 inline/attachment、显示文件名、MIME 类型,返回签名内的实际到期时间。原 Store/Presigner 和旧下载链接兼容,TTL 上限仍为一小时。
- Local、S3/R2、OSS 均实现;全部选项参与本地 HMAC 或云端原生 SDK 签名,标准编码中文文件名并拒绝非法响应头。
- 本地读取支持 GET/HEAD、单段与多段 Range、206/416、条件请求,按文件内容真实长度处理,流式读取不暴露本地元数据。顺序读取的第三方 Store 保持完整 200 回退。
- 补充通用 PDF 运行示例、篡改与到期回归、OSS V1/V4 签名校验、真实隔离 MinIO 验证和远程安装检查;不引入业务模型或第三方依赖。
# v0.28.2-preview
- 修复响应 DTO 的自引用/相互引用生成,支持 slice、pointer、map;OpenAPI 使用命名组件与循环引用,保留约束、描述、示例及 scalar 输出 schema。
- 拒绝 Go 非法值循环与递归请求;保留格式化、生成、契约变更和只读检查能力。新增通用树示例与真实生成消费者回归。
# Changelog # Changelog
## v0.27.0-preview(2026-09-29) ## v0.27.0-preview(2026-09-29)

View File

@@ -1,4 +1,4 @@
# 通用业务开发指南(v0.27.0-preview) # 通用业务开发指南(v0.31.0-preview)
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。 从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
@@ -35,6 +35,30 @@ delete /item
基础类型数组还支持 int/int8/int16/int32、uint/uint16/uint32/uint64、float32/float64。nil 根数组都输出 `[]`;`[]any` 在 OpenAPI 中是 `type: array, items: {}`,不限制元素形状,但元素必须可被标准 JSON 编码器序列化。`[]uint8`(Go 的 byte slice)会编码为 base64,故明确拒绝;数字数组可用 `[]int32`,二进制用下载接口。当前不支持根嵌套数组或根自定义 scalar 数组。 基础类型数组还支持 int/int8/int16/int32、uint/uint16/uint32/uint64、float32/float64。nil 根数组都输出 `[]`;`[]any` 在 OpenAPI 中是 `type: array, items: {}`,不限制元素形状,但元素必须可被标准 JSON 编码器序列化。`[]uint8`(Go 的 byte slice)会编码为 base64,故明确拒绝;数字数组可用 `[]int32`,二进制用下载接口。当前不支持根嵌套数组或根自定义 scalar 数组。
## 递归响应 DTO
v0.28.2 起,响应 DTO 可引用自身或相互引用;名字与字段由项目定义:
```text
type Node {
Name string `json:"name"`
Children []Node `json:"children"`
Next *Node `json:"next,omitempty"`
Lookup map[string]Node `json:"lookup,omitempty"`
}
@handler tree
get /tree returns (Node)
```
Go 类型保留 `[]Node`、`*Node`、`map[string]Node`,不降级为 `any`。生成器将递归响应图中的命名 DTO 写入 `components.schemas.DTO_<类型名>`,字段使用 `$ref`;对象、数组、可空根对象、raw 及自定义响应模板都支持。可空引用使用 `anyOf` 的引用与 null 分支,描述和示例通过 `allOf` 保留,遵循 [OpenAPI 3.0.3 引用规则](https://spec.openapis.org/oas/v3.0.3.html#reference-object)。
字段命名、标签、约束、重复字段与示例继续校验,自定义 scalar 使用声明的输出类型。有限 JSON 示例按实际内容检查,仍受 32 层/10000 节点的编译限制。类型引用不会无限展开;再次生成和契约变更检查会遍历引用图并保留手写代码。
范围是 `.api` 的响应类型。递归请求 DTO(包括间接包含递归类型的请求)仍不支持,`contract.New/Describe` 与手写 `openapi.SchemaOf` 的限制不变;它们不会自动获得递归请求绑定能力。直接值递归如 `Child Node`、仅由值字段形成的相互递归,以及字段组合循环仍会报错。业务返回值必须是可序列化的有限数据,不能构造对象指针环。
升级 CLI 后,在项目自己的 `.api` 中将临时 `[]any` 恢复成 `[]项目类型`,执行 `go-omni api generate`、`go-omni api generate --check` 和 `go test ./...`。修改 `.api` 后应先生成再检查;不要手改生成的 DTO。不改变项目的菜单、分类、权限或数据构造规则。完整可运行步骤见 [递归响应示例](examples/recursiveResponse/README.md)。
## 契约驱动的请求策略 ## 契约驱动的请求策略
入口文件可以声明默认策略,单个接口按需覆盖: 入口文件可以声明默认策略,单个接口按需覆盖:
@@ -199,6 +223,31 @@ go-omni workspace check --file omni.workspace.json
各服务保持独立 module;可显式填写各服务的 `api`(相对服务目录)和 `module`。所有服务预检通过后再写;每个服务独立提交文件事务,不承诺跨项目的崩溃原子性。check 汇总格式与生成漂移;业务编译/测试仍在各 module 执行。 各服务保持独立 module;可显式填写各服务的 `api`(相对服务目录)和 `module`。所有服务预检通过后再写;每个服务独立提交文件事务,不承诺跨项目的崩溃原子性。check 汇总格式与生成漂移;业务编译/测试仍在各 module 执行。
### 混合 API 与通用工程
v0.29.0-preview 起,workspace 支持在同一份清单中管理 API、通用工程,也支持仅包含通用工程的工作区:
```json
{
"services": [
{ "dir": "apps/interface", "kind": "api" },
{ "dir": "apps/program", "kind": "project" }
]
}
```
`kind` 可省略:根据 `.goOmni/apiManifest.json` 或 `.goOmni/projectManifest.json` 识别。没有清单时,沿用旧 API 解释,需显式提供 api 入口才能首次生成。两种清单同时存在、显式 kind 与已有清单冲突、未知 kind 均报错。project 类型不能填写 api;可选 module 用来核对工程身份,不会重写 go.mod。
| 命令 | API 工程 | 通用工程 |
| --- | --- | --- |
| workspace generate | API 生成器 | project 生成器 |
| workspace check | 契约格式检查 + API 生成漂移检查 | project 生成漂移检查 |
| workspace format | 格式化 API 契约 | 显示 SKIP,不修改 Go 文件 |
检查会继续处理独立工程并汇总错误,任一失败返回非零。生成/格式化先预检全部目标;预检失败时输出 FAIL 与其他工程的 SKIP,不写任何生成结果。清单语法、目录越界/重叠或共享目录提供方失败属于工作区级错误,会直接中止。已有文件保护与路径规则保持,事务仍以单个工程为单位。
workspace 不创建或修改 go.work、go.mod、go.sum,不替代 Go 编译和业务测试。它只调用生成器;显式 errorCatalogs 导出仍按下节约定执行 Go 提供方,配置类型不会额外启动服务。完整 [混合工程与 go.work 示例](examples/mixedWorkspace/README.md)。
### 同一份 Go 错误目录供多个服务生成 ### 同一份 Go 错误目录供多个服务生成
在 workspace 清单显式声明提供目录的 Go 包和函数: 在 workspace 清单显式声明提供目录的 Go 包和函数:

View File

@@ -5,7 +5,7 @@
## 创建与运行 ## 创建与运行
```sh ```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.0-preview go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.31.0-preview
go-omni project new demo --module example.com/demo go-omni project new demo --module example.com/demo
cd demo cd demo
go mod tidy go mod tidy
@@ -78,4 +78,4 @@ go test ./...
check 检查模板漂移和必要文件,不检查业务正确性,也不能替代 Go 编译与测试。新的模板版本会重新生成受管文件;不自动合并项目所有文件或更新 go.mod。未知的新清单版本会要求使用兼容 CLI。 check 检查模板漂移和必要文件,不检查业务正确性,也不能替代 Go 编译与测试。新的模板版本会重新生成受管文件;不自动合并项目所有文件或更新 go.mod。未知的新清单版本会要求使用兼容 CLI。
原有 `new` / `api new` 及 API 工程行为保留。通用工程使用自己的清单和命令,不能交给 `api generate` 或 `add` 转换;新增业务包直接写普通 Go。现有 `workspace` 命令仍面向 API 契约工程;多个通用工程可分别执行 `project check --dir <目录>`,本版不引入混合工作区配置。 原有 `new` / `api new` 及 API 工程行为保留。通用工程使用自己的清单和命令,不能交给 `api generate` 或 `add` 转换;新增业务包直接写普通 Go。从 v0.29.0-preview 起,`workspace generate/check` 可以同时处理 API 与通用工程。清单可填写 `kind: api | project`,省略时按生成清单识别;format 对通用工程显示 SKIP。见 [完整混合示例](examples/mixedWorkspace/README.md)。各 module 仍独立,workspace 命令不维护 go.work 或模块依赖。

View File

@@ -1,5 +1,7 @@
# API 生成项目与文件维护边界 # API 生成项目与文件维护边界
v0.31 扩展:项目可以在 `svc.ServiceContext` 手写 `RegisterCustomRoutes(*omni.App) error`,新版生成的 `handler.NewApp` 在生成路由和内建端点注册后调用它,用于 WebSocket 等特殊协议。没有此方法时旧行为保持;重复路径继续由路由器拒绝。运行 `api generate` 更新入口即可,不手改受管文件。WS 组件在 svc.Init 登记 Stop/Drain,与其共享依赖使用同一生命周期,见 [通用 WS 接入](ws/README.md#goomni-接入与停机)。
通用 Go 工程使用独立的 `project new/generate/check`,见 [PROGRAM.md](PROGRAM.md)。本文说明 API 契约工程。 通用 Go 工程使用独立的 `project new/generate/check`,见 [PROGRAM.md](PROGRAM.md)。本文说明 API 契约工程。
v0.28.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。 v0.28.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。

View File

@@ -2,13 +2,23 @@
GoOmni 是面向多个项目复用的 Go 基础框架。通用工程使用 `project new` 生成配置、日志、依赖装配、生命周期和普通 Go 运行入口;需要 HTTP 接口时可选择 `.api` 工程生成,也可以在已有项目中按需引入独立模块。运行方式、业务类型和外部依赖由项目决定。 GoOmni 是面向多个项目复用的 Go 基础框架。通用工程使用 `project new` 生成配置、日志、依赖装配、生命周期和普通 Go 运行入口;需要 HTTP 接口时可选择 `.api` 工程生成,也可以在已有项目中按需引入独立模块。运行方式、业务类型和外部依赖由项目决定。
**v0.31 改动:WebSocket**:新增通用 `ws.Client / Server / Session`,支持有界收发、心跳、重连、TLS、鉴权扩展和排空关闭;可选 `ws/hub` 分组广播、`ws/rpc` 双向调用与订阅恢复、`ws/observe` 日志指标。支持标准 nethttp HTTP/1.1 升级;native/实验 http2 暂不支持。见 [完整 API 与安全边界](ws/README.md)、[Go/浏览器示例](examples/websocket/README.md)、[RPC 示例](examples/websocketRPC/README.md)。
**v0.29 改动**:通用签名读取新增 inline/attachment、中文显示文件名、可选 Content-Type 与实际到期时间;Local、S3/R2、OSS 使用同一接口,原下载接口兼容。本地支持 HEAD、Range、206/416,所有响应选项参与签名。见 [存储说明](storage/README.md#签名预览与下载)及[可运行 PDF 示例](examples/signedRead/README.md)。
**v0.30 改动**:telemetry 新增配置化 `HeaderPropagator`,兼容网关传入的 Trace/Span/Request Header,同时保留 W3C `traceparent`;入站父链路、日志关联和出站传播统一处理。框架不绑定任何网关名称。见[可运行示例](examples/telemetryHeaders/README.md)和[观测说明](ADVANCED.md#熔断与观测)。
**v0.28.2 改动**:API 响应 DTO 支持 `Children []Node`、`Next *Node`、递归 map 和相互引用,生成强类型 Go 字段及有限的 OpenAPI `$ref` 图。字段约束、示例校验、生成与漂移检查继续有效;请求递归和非法值递归仍会报错。见 [可运行示例](examples/recursiveResponse/README.md)与[接入说明](DEVELOPMENT.md#递归响应-dto)。
**v0.28.1 改动**:`workspace generate/check` 支持 API 与通用工程混合处理,可选 `kind: api | project`,省略时按生成清单识别。`format` 对通用工程显示跳过;检查只读,生成前统一预检。见 [混合工作区示例](examples/mixedWorkspace/README.md)。
**v0.28 改动**:新增独立的 `project new/generate/check`,默认无 API、监听端口、业务任务或外部服务配置;保护手写代码,并等待主运行入口和登记的后台工作结束后释放依赖。见 [通用工程指南](PROGRAM.md)和[可运行示例](examples/program/README.md)。 **v0.28 改动**:新增独立的 `project new/generate/check`,默认无 API、监听端口、业务任务或外部服务配置;保护手写代码,并等待主运行入口和登记的后台工作结束后释放依赖。见 [通用工程指南](PROGRAM.md)和[可运行示例](examples/program/README.md)。
**v0.27 改动**:统一三种内置服务实现的默认/路由/硬请求体限额,配置冲突在启动前报错;停机以处理函数实际退出为资源清理前提,超时仍有请求时保留共享依赖。见 [HTTP 策略](transport/README.md#统一请求体限额)、[停机规则](lifecycle/README.md#http-请求与依赖的关闭屏障)和[可运行示例](examples/httpSafety/README.md)。 **v0.27 改动**:统一三种内置服务实现的默认/路由/硬请求体限额,配置冲突在启动前报错;停机以处理函数实际退出为资源清理前提,超时仍有请求时保留共享依赖。见 [HTTP 策略](transport/README.md#统一请求体限额)、[停机规则](lifecycle/README.md#http-请求与依赖的关闭屏障)和[可运行示例](examples/httpSafety/README.md)。
**v0.26 改动**:通用 [i18n](i18n/README.md),提供独立词典、语言匹配、回退、命名参数/条件/复数、可选 HTTP 与错误适配、检查/导出 CLI 及前端运行时。文案由项目维护;[完整示例](examples/i18n/README.md)可直接运行。CLI 与框架使用同版 v0.28.0-preview。同时修复 Go 1.22 下请求体限额错误的识别。 **v0.26 改动**:通用 [i18n](i18n/README.md),提供独立词典、语言匹配、回退、命名参数/条件/复数、可选 HTTP 与错误适配、检查/导出 CLI 及前端运行时。文案由项目维护;[完整示例](examples/i18n/README.md)可直接运行。CLI 与框架使用同版 v0.31.0-preview。同时修复 Go 1.22 下请求体限额错误的识别。
**从这份 README 开始接入。** 当前版本 **`v0.28.0-preview`**,通用工程采用根目录入口和 `config / svc / run`;API 工程使用 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。 **从这份 README 开始接入。** 当前版本 **`v0.31.0-preview`**,通用工程采用根目录入口和 `config / svc / run`;API 工程使用 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
**v0.25 改动**:`database.InTx` 返回可识别的事务阶段与结果,保留业务/驱动及回滚错误链;提交报错保守标记为结果不确定,不等同于已回滚。[使用规则](database/README.md#事务阶段与结果)、[可运行故障示例](examples/transactions/README.md)。 **v0.25 改动**:`database.InTx` 返回可识别的事务阶段与结果,保留业务/驱动及回滚错误链;提交报错保守标记为结果不确定,不等同于已回滚。[使用规则](database/README.md#事务阶段与结果)、[可运行故障示例](examples/transactions/README.md)。
@@ -40,6 +50,7 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
| --- | --- | --- | | --- | --- | --- |
| 项目、接口与文档生成 | `go-omni new`;改 `.api` 后 `api generate`;`--check` 检查漂移 | [下方快速开始](#新项目生成并运行)、[CLI](cmd/go-omni/README.md)、[完整生成项目](examples/managedService/README.md) | | 项目、接口与文档生成 | `go-omni new`;改 `.api` 后 `api generate`;`--check` 检查漂移 | [下方快速开始](#新项目生成并运行)、[CLI](cmd/go-omni/README.md)、[完整生成项目](examples/managedService/README.md) |
| 公共字段与跨服务共享契约 | `.api` 组合对象字段;`omni.imports.json` 声明共享根;workspace 批量生成 | [公共字段示例](GENERATION.md#公共字段组合)、[共享契约与 workspace](DEVELOPMENT.md) | | 公共字段与跨服务共享契约 | `.api` 组合对象字段;`omni.imports.json` 声明共享根;workspace 批量生成 | [公共字段示例](GENERATION.md#公共字段组合)、[共享契约与 workspace](DEVELOPMENT.md) |
| 混合工程工作区 | `workspace generate/check` 按清单分发 API/project;format 仅处理 API | [可运行示例](examples/mixedWorkspace/README.md)、[工作区说明](DEVELOPMENT.md#混合-api-与通用工程) |
| 通用 Go 工程 | `go-omni project new/generate/check`;配置、日志、依赖、运行与退出 | [通用工程指南](PROGRAM.md)、[完整可运行示例](examples/program/README.md) | | 通用 Go 工程 | `go-omni project new/generate/check`;配置、日志、依赖、运行与退出 | [通用工程指南](PROGRAM.md)、[完整可运行示例](examples/program/README.md) |
| 中间件依赖工厂、可选 Repository | `omni.gen.json` 选择 factories / none;在 `New<Name>(deps)` 写一次实现 | [配置与完整工厂示例](GENERATION.md) | | 中间件依赖工厂、可选 Repository | `omni.gen.json` 选择 factories / none;在 `New<Name>(deps)` 写一次实现 | [配置与完整工厂示例](GENERATION.md) |
| 路由、分组、请求上下文 | `omni.New()`、`app.GET` / `Group` / `Use`;业务使用 `context.Context` | [基本服务](examples/basic/main.go)、[已有项目接入](#已有项目按模块接入) | | 路由、分组、请求上下文 | `omni.New()`、`app.GET` / `Group` / `Use`;业务使用 `context.Context` | [基本服务](examples/basic/main.go)、[已有项目接入](#已有项目按模块接入) |
@@ -60,11 +71,12 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
| 认证与权限接口 | 注入 Authenticator / Policy;JWT 或 Session 只是可选实现 | [认证说明](auth/README.md)、[鉴权完整例子](examples/platform/README.md) | | 认证与权限接口 | 注入 Authenticator / Policy;JWT 或 Session 只是可选实现 | [认证说明](auth/README.md)、[鉴权完整例子](examples/platform/README.md) |
| 缓存 | `cache.NewMemory` 或 Redis 实现;业务面向 Store 的 Get/Set/Delete | [缓存配置/代码](MODULES.md)、[可运行模块服务](examples/modules/README.md) | | 缓存 | `cache.NewMemory` 或 Redis 实现;业务面向 Store 的 Get/Set/Delete | [缓存配置/代码](MODULES.md)、[可运行模块服务](examples/modules/README.md) |
| 出站 HTTP、熔断 | `httpc.New` 配置代理、连接池和阶段超时;JSON 支持单次成功状态限制/请求头/限额;`resilience.New` 隔离故障 | [HTTP 完整用法](httpc/README.md)、[可运行示例](examples/httpClient/README.md)、[熔断/观测](ADVANCED.md#熔断与观测) | | 出站 HTTP、熔断 | `httpc.New` 配置代理、连接池和阶段超时;JSON 支持单次成功状态限制/请求头/限额;`resilience.New` 隔离故障 | [HTTP 完整用法](httpc/README.md)、[可运行示例](examples/httpClient/README.md)、[熔断/观测](ADVANCED.md#熔断与观测) |
| WebSocket 双向通信 | `ws.NewClient/NewServer`;`ws/omni.Mount`;可选 Hub、RPC、订阅恢复和观测 | [功能、配置与停机](ws/README.md)、[浏览器与 Go 示例](examples/websocket/README.md)、[RPC 示例](examples/websocketRPC/README.md) |
| 请求追踪、访问日志、恢复、限流、幂等、超时 | `app.Use(...)` 按需组合 middleware;跨副本选择 Redis 实现 | [模块说明与限制](MODULES.md#请求追踪与基础指标)、[分布式实现](ADVANCED.md#分布式限流与幂等) | | 请求追踪、访问日志、恢复、限流、幂等、超时 | `app.Use(...)` 按需组合 middleware;跨副本选择 Redis 实现 | [模块说明与限制](MODULES.md#请求追踪与基础指标)、[分布式实现](ADVANCED.md#分布式限流与幂等) |
| 本地后台任务与调度 | `task.New`、Submit、Every;有界队列、排空关闭 | [task 示例与生命周期](MODULES.md#后台执行task) | | 本地后台任务与调度 | `task.New`、Submit、Every;有界队列、排空关闭 | [task 示例与生命周期](MODULES.md#后台执行task) |
| 持久队列 | 业务使用 Publisher / Consumer;Redis Streams 提供至少一次交付 | [队列初始化/消费示例](ADVANCED.md#可靠队列) | | 持久队列 | 业务使用 Publisher / Consumer;Redis Streams 提供至少一次交付 | [队列初始化/消费示例](ADVANCED.md#可靠队列) |
| 文件存储、直传、云端分片 | Store 的 Put/Get/Delete;Local、S3/R2、OSS 按配置装配;扩展接口提供分片续传 | [存储接口与完整例子](storage/README.md)、[本地存储服务](examples/platform/README.md) | | 文件存储、签名预览/下载、直传与云端分片 | Store 的 Put/Get/Delete;ReadPresigner 签名响应选项;Local、S3/R2、OSS 按配置装配 | [存储接口与完整例子](storage/README.md)、[本地存储服务](examples/platform/README.md) |
| 日志关联、指标与链路 | `metrics.New().Middleware()`;telemetry 注入 OTel exporter/reader | [基础指标](MODULES.md#请求追踪与基础指标)、[OTel 用法](ADVANCED.md#熔断与观测)、[完整接入](examples/platform/main.go) | | 日志关联、指标与链路 | `metrics.New().Middleware()`;telemetry 注入 OTel exporter/reader;`HeaderPropagator` 兼容自定义网关 Header | [基础指标](MODULES.md#请求追踪与基础指标)、[OTel 用法](ADVANCED.md#熔断与观测)、[Header 示例](examples/telemetryHeaders/README.md) |
| 审计与分页 | `audit.New` 注入 Sink/白名单;`pagination.Parse` / `NewResult` | [解释与调用例子](ADVANCED.md#迁移审计与分页) | | 审计与分页 | `audit.New` 注入 Sink/白名单;`pagination.Parse` / `NewResult` | [解释与调用例子](ADVANCED.md#迁移审计与分页) |
| 可选结构转换与深拷贝 | `copyx.Copy` / `Clone` / `New`;`Check[S,T]` 预检类型,Go converter 处理差异 | [下方示例](#可选结构转换与深拷贝)、[完整 copyx 指南](COPY.md)、[可运行代码](examples/copying) | | 可选结构转换与深拷贝 | `copyx.Copy` / `Clone` / `New`;`Check[S,T]` 预检类型,Go converter 处理差异 | [下方示例](#可选结构转换与深拷贝)、[完整 copyx 指南](COPY.md)、[可运行代码](examples/copying) |
| 自定义适配器验收 | `transporttest.Run`、`storagetest.Run` / `RunUploads` 验证扩展契约 | [网络契约测试入口](transport/transporttest/router.go)、[存储契约测试入口](storage/storagetest/contract.go) | | 自定义适配器验收 | `transporttest.Run`、`storagetest.Run` / `RunUploads` 验证扩展契约 | [网络契约测试入口](transport/transporttest/router.go)、[存储契约测试入口](storage/storagetest/contract.go) |
@@ -72,7 +84,7 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
## 通用工程快速开始 ## 通用工程快速开始
```sh ```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.0-preview go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.31.0-preview
go-omni project new demo --module example.com/demo go-omni project new demo --module example.com/demo
cd demo cd demo
go mod tidy go mod tidy
@@ -177,7 +189,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.28.0-preview go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.31.0-preview
export PATH="$(go env GOPATH)/bin:$PATH" export PATH="$(go env GOPATH)/bin:$PATH"
go-omni --help go-omni --help
``` ```
@@ -334,7 +346,7 @@ go-omni new --api catalog.api \
在业务项目的 **go.mod 所在目录**执行: 在业务项目的 **go.mod 所在目录**执行:
```sh ```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.28.0-preview go get git.yuntuo.hk/k2safe/go-omni@v0.31.0-preview
``` ```
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。 已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
@@ -665,7 +677,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.28.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.31.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
## 按需深入 ## 按需深入

View File

@@ -1,3 +1,101 @@
# v0.31.0-preview — WebSocket
新增可选通用 [WebSocket 模块](ws/README.md)。Client/Server 共用 Session,标准 WS/WSS 可与第三方互通;RPC 是另选的版本化协议。Hub 只管理进程内连接,订阅恢复仅恢复明确保存的订阅意图,不重放历史业务消息。
现有 HTTP 功能无需改配置。独立 App 使用 `ws/omni.Mount`,生成项目在 svc 登记 WS Stop/Drain,并可手写 `RegisterCustomRoutes(*omni.App) error`;重新生成入口后自动接入。资源只在 HTTP 和 WS 后台任务均退出后释放;未能确认处理器或后台任务退出时返回 `lifecycle.ErrResourcesRetained` 并保留依赖。
默认消息 1 MiB、每方向队列 64 条/4 MiB、压缩关闭、Origin 同源;重连显式启用。用户身份、组权限、业务错误和持久化由项目决定。服务端暂支持 nethttp HTTP/1.1 Upgrade,其他 Runner 明确拒绝。依赖固定 Gorilla WebSocket v1.5.3,工具链安全状态与实际测试结果见 [验收报告](reports/websocket-validation-2026-10-29.md)。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.31.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.31.0-preview
go test -race ./ws/... ./app ./httpx
go run ./examples/websocket
go run ./examples/websocketRPC
```
**已知限制**:修补工具链下,核心 WS 包扫描未发现已知漏洞;包含 `ws/omni` 的完整接入仍受原有 x/net HTTP/2 的 4 项可达漏洞影响,框架整体安全发布门槛尚未通过。浏览器实测未完成。本次发布保留 Go 1.22 源码兼容,依赖安全升级需单独完成并回归,详情和证据见上述验收报告。
# v0.30.0-preview
新增通用 `telemetry.HeaderPropagator`,让服务在不修改业务 Handler 的情况下接入使用自定义 Trace/Span/Request Header 的上游网关。配置 Header 名称后,入站建立远程父 Span,`logx.TraceID` / `RequestID`、访问日志和审计沿用上下文;出站 `telemetry.Transport` 同时注入 W3C 和兼容 Header。
W3C `traceparent` 始终优先于兼容 Header。兼容 Header 只接受合法的 32 位 Trace ID 和 16 位 Span ID,当前服务生成自己的 Span ID;Request ID 单独传播,不参与父 Span。旧项目不配置新传播器时,默认行为不变。兼容 Header 不携带采样标志,有效成对值按已采样处理;需要传递采样决定时使用 W3C。
```go
propagator, err := telemetry.NewHeaderPropagator(telemetry.HeaderPropagationConfig{
TraceIDHeader: "X-Trace-Id", SpanIDHeader: "X-Span-Id", RequestIDHeader: "X-Request-Id",
})
if err != nil { return err }
runtime, err := telemetry.New(telemetry.Config{
Service: "billing", SampleRatio: 1, Propagator: propagator,
})
```
只在受信网关入口启用兼容传播;Trace/Span ID 不是认证、租户或幂等凭据。完整 [接入说明](ADVANCED.md#熔断与观测)、[离线示例](examples/telemetryHeaders/README.md)。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.30.0-preview
go test -race ./telemetry ./examples/telemetryHeaders
```
---
# v0.29.0-preview
新增通用签名读取选项:预览或下载、显示文件名和 MIME 类型,由调用方选择。`PresignGetWithOptions` 返回 URL 与签名内的到期时间;原 `PresignGet` 默认下载、第三方原有接口和旧本地链接继续兼容。有效期仍是 1 秒到 1 小时,不增加撤销或业务分享机制。
Local、S3/R2、OSS 使用同一接口,所有选项参与签名。中文文件名采用标准编码,拒绝 CR/LF 等非法头内容。本地支持 HEAD、单段/多段 Range、206/416 与条件请求,按真实内容范围流式输出;过期与篡改在读取文件之前拒绝。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.29.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.29.0-preview
go test ./...
```
[接口与接入边界](storage/README.md#签名预览与下载)、[完整示例](examples/signedRead/README.md)、[验收记录](reports/signedReadAcceptance.md)。生成模板最低运行时版本不变;使用新读取接口需要升级框架依赖。OSS 默认域名可能强制下载,应在 SDK 签名前配置所需域名;OSS/R2 云账号尚未实测,部署方须按实际桶、域名和浏览器验收。
57 个测试包的全量 race 与 Go 1.22 集成回归、vet/build、隔离 MinIO 验证通过。远程空缓存综合 107 项检查、11 组生成消费者、通用工程 30 项与混合工作区 16 项通过,均无本地 replace。标签固定在 `f0c62152d72b1b50672c29f5191ad88f8be3eee4`,见 [远程报告](reports/signedReadRemote.json)。
---
# v0.28.2-preview
修复 `.api` 响应 DTO 自引用与相互引用的生成限制。`Children []Node`、`Next *Node`、`map[string]Node` 保留强类型,响应文档使用有限的命名组件与 `$ref`;可空引用、字段描述、示例、约束和 scalar 输出类型保持准确。框架不引入业务模型。
请求绑定继续拒绝递归类型,Go 非法直接值循环与字段组合循环继续报错。已有非递归类型保持原有生成行为;无需修改运行时配置或响应 JSON。升级 CLI,在项目 `.api` 中恢复强类型后重新生成,再执行检查和项目回归。生成模板最低运行时版本不变。
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.2-preview
go-omni api generate
go-omni api generate --check
go test ./...
```
[递归响应说明](DEVELOPMENT.md#递归响应-dto)、[可运行示例](examples/recursiveResponse/README.md)。56 个测试包全量 race 与 Go 1.22 集成回归、vet/build、独立 OpenAPI 校验均通过,见[验收记录](reports/recursiveResponseAcceptance.md)。
远程空缓存验收完成:响应与兼容 101 项检查、11 组生成消费者,通用工程 30 项、混合工作区 16 项检查全部通过,均无本地 replace。标签固定在 `0c5b571b31b68d25cdb3e8640970e2a53ddcbea6`,详见[远程报告](reports/recursiveResponseRemote.json)。
---
# v0.28.1-preview
workspace generate/check 支持 API 与通用工程混合分发,也可处理纯通用工程工作区。services 新增可选 kind(api/project);省略时按生成清单识别,保留无清单时显式 api 入口的原有用法。类型与清单冲突、歧义和不合法选项会报错,项目身份、路径与文件归属继续由各生成器校验。
check 只读并汇总独立工程的失败;format 只处理 API 契约,对通用工程明确显示 SKIP。generate 预检所有工程后再写入,预检失败时不修改其他工程;不改变各模块依赖或 go.work,不增加业务逻辑和第三方依赖。模板与运行时接口未变,新 workspace 功能需要更新 CLI,旧生成工程可直接纳入。
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.1-preview
go-omni workspace generate
go-omni workspace check
```
[混合工作区示例](examples/mixedWorkspace/README.md)、[清单规则](DEVELOPMENT.md#混合-api-与通用工程)、[验收记录](reports/workspaceAcceptance.md)。56 个测试包全量 race、5 个 CLI 包的 Go 1.22 回归、实际生成的多模块构建、vet/build 均通过。
远程空缓存验收完成:混合工作区 16 项、通用工程 30 项、原有生成器 98 项检查全部通过,均无本地 replace;见 [混合报告](reports/workspaceRemote.json)、[通用报告](reports/workspaceProgramRemote.json)、[兼容报告](reports/workspaceManagedRemote.json)。标签固定在 `0d2c6040e2694795f203304148dbaec00318c608`。
---
# v0.28.0-preview # v0.28.0-preview
新增通用 Go 工程生成命令 `project new/generate/check`。默认只有配置、日志、svc 依赖装配、生命周期与项目维护的 Run 入口,不生成 API、HTTP Handler、Swagger、业务任务或网络监听,也不自动连接数据库/队列或重试任务。常驻和一次性程序由普通 Go 代码决定。 新增通用 Go 工程生成命令 `project new/generate/check`。默认只有配置、日志、svc 依赖装配、生命周期与项目维护的 Run 入口,不生成 API、HTTP Handler、Swagger、业务任务或网络监听,也不自动连接数据库/队列或重试任务。常驻和一次性程序由普通 Go 代码决定。
@@ -18,6 +116,8 @@ go-omni project check
完整 [通用工程指南](PROGRAM.md)、[可运行示例](examples/program/README.md)、[回归记录](reports/programAcceptance.md)。56 个测试包全量 race 与 Go 1.22 回归、生成消费者、vet/build 和真实进程退出验证通过。 完整 [通用工程指南](PROGRAM.md)、[可运行示例](examples/program/README.md)、[回归记录](reports/programAcceptance.md)。56 个测试包全量 race 与 Go 1.22 回归、生成消费者、vet/build 和真实进程退出验证通过。
空缓存远程安装已完成:通用工程 30 项检查通过(26.45 秒),原有生成项目 98 项检查、10 组消费者通过(111.07 秒),均无本地 replace。见 [通用远程报告](reports/programRemote.json)、[原有能力远程报告](reports/programManagedRemote.json)。标签固定在 `0b6c0043b6c3b58648645aeee03bf80801e6aa3f`。
--- ---
# v0.27.0-preview # v0.27.0-preview

View File

@@ -31,6 +31,7 @@ type App struct {
closers []func() error closers []func() error
closeOnce sync.Once closeOnce sync.Once
closeErr error closeErr error
components *lifecycle.Runtime
clientIPResolver *httpx.ClientIPResolver clientIPResolver *httpx.ClientIPResolver
responseEncoder response.Encoder responseEncoder response.Encoder
requests requestDrain requests requestDrain
@@ -170,7 +171,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 after all handlers have returned. // functions run in reverse registration order after all handlers and registered
// background stop/drain hooks have finished.
// Incomplete draining skips these callbacks and returns ErrResourcesRetained. // 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.
@@ -181,6 +183,33 @@ func (a *App) OnShutdown(cleanup func() error) *App {
return a return a
} }
// OnStop registers a background component's stop-admission hook. Each stop
// requires an OnDrain with the same name. Hooks run after HTTP handlers finish
// and before OnShutdown resources are released. Register before serving.
func (a *App) OnStop(name string, stop func(context.Context) error) *App {
a.componentRuntime().OnStop(name, stop)
return a
}
// OnDrain registers proof that a background component, including its callbacks,
// has finished using shared resources. Incomplete draining retains resources.
// Generated services may instead register both hooks on their svc Runtime.
func (a *App) OnDrain(name string, drain func(context.Context) error) *App {
a.componentRuntime().OnDrain(name, drain)
return a
}
func (a *App) componentRuntime() *lifecycle.Runtime {
if a.components == nil {
r, err := lifecycle.NewRuntimeWithConfig(context.Background(), a.config.Lifecycle)
if err != nil {
panic(err)
}
a.components = r
}
return a.components
}
// Close stops admission, cancels active requests and waits for their handlers // Close stops admission, cancels active requests and waits for their handlers
// to return before releasing resources. It is idempotent. If cancellation grace // to return before releasing resources. It is idempotent. If cancellation grace
// expires, ErrResourcesRetained is permanent and no cleanup callbacks run. // expires, ErrResourcesRetained is permanent and no cleanup callbacks run.
@@ -192,7 +221,11 @@ func (a *App) Close() error {
if timeout <= 0 { if timeout <= 0 {
timeout = 5 * time.Second timeout = 5 * time.Second
} }
if err := a.requests.cancelAndWait(timeout); err != nil { err := a.requests.cancelAndWait(timeout)
if a.components != nil {
err = a.components.Finish(err)
}
if err != nil {
a.closeErr = err a.closeErr = err
return return
} }

View File

@@ -1,5 +1,7 @@
# go-omni CLI # go-omni CLI
v0.28.1 的 `workspace generate/check` 支持 API 与通用工程混合处理。每个 services 项可指定 `kind: api | project`,省略时按工程清单识别,旧 API 配置继续有效。format 只处理 API 契约,通用工程显示 SKIP;check 只读并汇总各工程失败。见 [可运行示例](../../examples/mixedWorkspace/README.md)。
v0.28 新增 `project new/generate/check`:生成无 API、无监听端口和无外部服务依赖的通用 Go 程序,配置/日志/生命周期自动装配,项目维护 svc 与 Run。`project generate --dry-run` 预览,`project check` 只读检查;详见 [通用工程指南](../../PROGRAM.md)。 v0.28 新增 `project new/generate/check`:生成无 API、无监听端口和无外部服务依赖的通用 Go 程序,配置/日志/生命周期自动装配,项目维护 svc 与 Run。`project generate --dry-run` 预览,`project check` 只读检查;详见 [通用工程指南](../../PROGRAM.md)。
v0.26 新增 `i18n check|export|types|extract`:检查独立语言资源、提取 Go 静态消息引用或已导出的错误目录,导出指定命名空间的前端包和 TypeScript 类型。输入只读,结果写 stdout;动态引用明确报告。完整命令见 [国际化工具链](../../i18n/README.md#cli-提取检查与导出)。`.api errorCatalog` 可声明 translation 引用,旧 runtime 在生成前拒绝,接入时使用当前同版 CLI 与框架。 v0.26 新增 `i18n check|export|types|extract`:检查独立语言资源、提取 Go 静态消息引用或已导出的错误目录,导出指定命名空间的前端包和 TypeScript 类型。输入只读,结果写 stdout;动态引用明确报告。完整命令见 [国际化工具链](../../i18n/README.md#cli-提取检查与导出)。`.api errorCatalog` 可声明 translation 引用,旧 runtime 在生成前拒绝,接入时使用当前同版 CLI 与框架。
@@ -18,7 +20,7 @@ API 新项目采用根目录 `main.go` 与 `internal/config、svc、logic、hand
```sh ```sh
# 仓库读取权限与 PATH 配置见根目录 README # 仓库读取权限与 PATH 配置见根目录 README
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.0-preview go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.31.0-preview
go-omni new --module example.com/hello ./hello go-omni new --module example.com/hello ./hello
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在 # 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
@@ -31,6 +33,8 @@ go-omni new --api service.api --module example.com/example ./example
存储配置和客户端位于 config/svc,业务使用统一接口。local 存储签名端点由生成器接入;上传、下载等业务路由由 `.api` 定义。认证与存储路径由项目实现。 存储配置和客户端位于 config/svc,业务使用统一接口。local 存储签名端点由生成器接入;上传、下载等业务路由由 `.api` 定义。认证与存储路径由项目实现。
响应 DTO 支持自引用及相互引用,生成强类型 Go 字段与 OpenAPI `$ref`;见[递归响应示例](../../examples/recursiveResponse/README.md)。请求递归仍会报错。
## 日常生成 ## 日常生成
```sh ```sh
@@ -53,14 +57,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
## 版本与迁移 ## 版本与迁移
CLI 与框架依赖固定使用 v0.28.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换: CLI 与框架依赖固定使用 v0.31.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.28.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。 当前模板与依赖以 v0.31.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
## 数据库迁移命令 ## 数据库迁移命令

View File

@@ -5,8 +5,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"go/ast"
goparser "go/parser"
gotoken "go/token" gotoken "go/token"
"reflect" "reflect"
"regexp" "regexp"
@@ -69,56 +67,17 @@ func Compile(spec *Spec) (*Compiled, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
graph, err := newResponseGraph(spec, definitions, scalarTypes, scalarDefs)
if err != nil {
return nil, err
}
for _, r := range spec.Routes {
if graph.recursive[r.Request] {
return nil, fmt.Errorf("%s: recursive request DTO %s is unsupported; recursive types are supported for responses only", r.Position, r.Request)
}
}
resolved := map[string]reflect.Type{} resolved := map[string]reflect.Type{}
resolving := map[string]bool{}
var resolve func(string, int) (reflect.Type, error) var resolve func(string, int) (reflect.Type, error)
var expression func(ast.Expr, int) (reflect.Type, error)
expression = func(e ast.Expr, depth int) (reflect.Type, error) {
if depth > 32 {
return nil, errors.New("type nesting exceeds 32")
}
switch e := e.(type) {
case *ast.Ident:
if t, ok := scalarTypes[e.Name]; ok {
return t, nil
}
if t, ok := primitiveTypes[e.Name]; ok {
return t, nil
}
return resolve(e.Name, depth+1)
case *ast.StarExpr:
if _, double := e.X.(*ast.StarExpr); double {
return nil, errors.New("multiple pointer levels unsupported")
}
t, err := expression(e.X, depth+1)
if err != nil {
return nil, err
}
return reflect.PointerTo(t), nil
case *ast.ArrayType:
if e.Len != nil {
return nil, errors.New("use slices, not fixed arrays")
}
t, err := expression(e.Elt, depth+1)
if err != nil {
return nil, err
}
return reflect.SliceOf(t), nil
case *ast.MapType:
key, ok := e.Key.(*ast.Ident)
if !ok || key.Name != "string" {
return nil, errors.New("map keys must be string")
}
t, err := expression(e.Value, depth+1)
if err != nil {
return nil, err
}
return reflect.MapOf(reflect.TypeOf(""), t), nil
default:
return nil, errors.New("unsupported type expression")
}
}
resolve = func(name string, depth int) (reflect.Type, error) { resolve = func(name string, depth int) (reflect.Type, error) {
if depth > 32 { if depth > 32 {
return nil, errors.New("type nesting exceeds 32") return nil, errors.New("type nesting exceeds 32")
@@ -126,27 +85,13 @@ func Compile(spec *Spec) (*Compiled, error) {
if t, ok := resolved[name]; ok { if t, ok := resolved[name]; ok {
return t, nil return t, nil
} }
if resolving[name] {
return nil, fmt.Errorf("recursive type %s is unsupported", name)
}
typ, ok := definitions[name] typ, ok := definitions[name]
if !ok { if !ok {
return nil, fmt.Errorf("unknown type %s", name) return nil, fmt.Errorf("unknown type %s", name)
} }
resolving[name] = true
defer delete(resolving, name)
var fields []reflect.StructField var fields []reflect.StructField
seen := map[string]bool{} for i, f := range typ.Fields {
for _, f := range typ.Fields { t, err := graph.expression(graph.exprs[name][i], depth+1, resolve)
if !exportedName.MatchString(f.Name) || seen[f.Name] {
return nil, fmt.Errorf("%s: invalid or duplicate field %s", name, f.Name)
}
seen[f.Name] = true
expr, err := goparser.ParseExpr(f.Type)
if err != nil {
return nil, err
}
t, err := expression(expr, depth+1)
if err != nil { if err != nil {
return nil, fmt.Errorf("%s.%s: %w", name, f.Name, err) return nil, fmt.Errorf("%s.%s: %w", name, f.Name, err)
} }
@@ -158,6 +103,12 @@ func Compile(spec *Spec) (*Compiled, error) {
} }
c := &Compiled{Spec: spec, Bindings: map[string]*contract.Binder{}} c := &Compiled{Spec: spec, Bindings: map[string]*contract.Binder{}}
for _, t := range spec.Types { for _, t := range spec.Types {
if graph.recursive[t.Name] {
if _, err := graph.component(t.Name); err != nil {
return nil, err
}
continue
}
rt, err := resolve(t.Name, 0) rt, err := resolve(t.Name, 0)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -274,7 +225,7 @@ func Compile(spec *Spec) (*Compiled, error) {
} }
var response *openapi.Schema var response *openapi.Schema
if r.Response != "" { if r.Response != "" {
s, err := responseDataSchema(r.Response, c.Bindings) s, err := responseDataSchema(r.Response, c.Bindings, graph)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -316,6 +267,13 @@ func Compile(spec *Spec) (*Compiled, error) {
return nil, err return nil, err
} }
decorateDocument(doc, spec, c.Bindings) decorateDocument(doc, spec, c.Bindings)
if len(graph.components) > 0 {
components := map[string]any{}
for name, schema := range graph.components {
components[name] = schema
}
doc["components"].(map[string]any)["schemas"] = components
}
compactDocument(doc) compactDocument(doc)
c.Document, err = json.MarshalIndent(doc, "", " ") c.Document, err = json.MarshalIndent(doc, "", " ")
return c, err return c, err

View File

@@ -0,0 +1,185 @@
package apigen
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
)
// Exercise the generated consumer, rather than matching a template substring:
// the optional hook must compile without changes to existing ServiceContext,
// survive regeneration, serve requests, and roll back App-owned resources.
func TestGeneratedCustomRoutesConsumer(t *testing.T) {
releaseCLI := os.Getenv("GO_OMNI_RELEASE_CLI")
releaseVersion := os.Getenv("GO_OMNI_RELEASE_VERSION")
if (releaseCLI == "") != (releaseVersion == "") {
t.Fatal("GO_OMNI_RELEASE_CLI and GO_OMNI_RELEASE_VERSION must be set together")
}
dir := filepath.Join(t.TempDir(), "customroutes")
api := filepath.Join(t.TempDir(), "service.api")
writeFile(t, api, "service customroutes {\n @handler ping\n get /ping\n}\n")
opts := Options{Dir: dir, API: api, Module: "example.com/customroutes", FrameworkDir: frameworkRoot(t)}
if releaseCLI != "" {
// The installed CLI must select its own published framework version.
// Never supply a source replacement in remote acceptance mode.
opts.FrameworkDir = ""
}
if err := generateManagedTestProject(t, opts); err != nil {
t.Fatal(err)
}
consumer := filepath.Join(dir, "custom_routes_test.go")
writeFile(t, consumer, customRoutesConsumerImports+`
func TestExistingServiceWithoutCustomRoutes(t *testing.T) {
deps:=newDependencies(t)
if _,ok:=any(deps).(interface{RegisterCustomRoutes(*omni.App) error});ok{t.Fatal("baseline unexpectedly implements extension")}
app,err:=handler.NewApp(deps);if err!=nil{t.Fatal(err)};defer app.Close()
response:=httptest.NewRecorder();app.ServeHTTP(response,httptest.NewRequest("GET","/ping",nil))
if response.Code!=http.StatusNotImplemented{t.Fatalf("generated route: %d %s",response.Code,response.Body.String())}
}
`)
// First compile and run the old project shape before adding any method.
runConsumerGo(t, dir, "test", "-race", "-mod=mod", "-count=1", "./...")
hookPath := filepath.Join(dir, "internal/svc/customRoutes.go")
writeFile(t, hookPath, customRoutesHook)
writeFile(t, consumer, customRoutesConsumerImports+customRoutesConsumerTests)
serviceContext := filepath.Join(dir, "internal/svc/serviceContext.go")
projectConfig := filepath.Join(dir, "internal/config/config.go")
handwritten := map[string]string{}
for _, path := range []string{hookPath, consumer, serviceContext, projectConfig} {
handwritten[path] = readFile(t, path)
}
opts.API = filepath.Join(dir, "api/service.api")
for _, mode := range []string{"dry-run", "generate", "check"} {
opts.DryRun = mode == "dry-run"
opts.Check = mode == "check"
if err := generateManagedTestProject(t, opts); err != nil {
t.Fatalf("%s: %v", mode, err)
}
for path, before := range handwritten {
if readFile(t, path) != before {
t.Fatalf("%s changed project-owned file %s", mode, path)
}
}
}
runConsumerGo(t, dir, "test", "-race", "-mod=mod", "-count=1", "./...")
runConsumerGo(t, dir, "build", "-mod=mod", ".")
if releaseCLI != "" {
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
data, err := cmd.CombinedOutput()
if err != nil {
t.Fatal(err, string(data))
}
var dependency struct {
Version, Sum string
Replace any
}
if err := json.Unmarshal(data, &dependency); err != nil {
t.Fatal(err)
}
if dependency.Version != releaseVersion || dependency.Replace != nil || dependency.Sum == "" {
t.Fatal("custom routes consumer is not using the fixed remote framework without replace")
}
t.Logf("remote framework %s checksum %s; no local replace", dependency.Version, dependency.Sum)
}
}
const customRoutesConsumerImports = `package main
import (
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"example.com/customroutes/internal/config"
"example.com/customroutes/internal/handler"
"example.com/customroutes/internal/svc"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func newDependencies(t *testing.T)*svc.ServiceContext{
t.Helper();cfg:=config.Default();cfg.Server.Log.Writer=io.Discard
logger,err:=logx.New(cfg.Server.Log);if err!=nil{t.Fatal(err)};t.Cleanup(func(){_ = logger.Close()})
deps,err:=svc.New(context.Background(),logger,cfg);if err!=nil{t.Fatal(err)};t.Cleanup(func(){if err:=deps.Close();err!=nil{t.Error(err)}})
return deps
}
`
const customRoutesHook = `package svc
import (
"context"
"errors"
"net/http"
"sync/atomic"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
var ErrCustomRegistration = errors.New("custom registration failed")
var ErrCustomCleanup = errors.New("custom cleanup failed")
var CustomCalls, CustomStopped, CustomDrained, CustomCleaned atomic.Int32
var CustomFailure bool
var CustomApp *omni.App
// This file belongs to the consuming project and must never be generated.
func(c *ServiceContext)RegisterCustomRoutes(app *omni.App)error{
CustomCalls.Add(1);CustomApp=app
app.OnStop("project-custom",func(context.Context)error{CustomStopped.Add(1);return nil})
app.OnDrain("project-custom",func(context.Context)error{CustomDrained.Add(1);return nil})
fail:=CustomFailure
app.OnShutdown(func()error{CustomCleaned.Add(1);if fail{return ErrCustomCleanup};return nil})
app.GET("/custom",httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter,r *http.Request){
w.Header().Set("Content-Type","text/plain");_,_ = w.Write([]byte("project-owned route"))
})))
if fail{return ErrCustomRegistration}
return nil
}
func ResetCustomRoutes(){CustomCalls.Store(0);CustomStopped.Store(0);CustomDrained.Store(0);CustomCleaned.Store(0);CustomFailure=false;CustomApp=nil}
func IsCustomFailure(err error)bool{return errors.Is(err,ErrCustomRegistration)&&errors.Is(err,ErrCustomCleanup)}
`
const customRoutesConsumerTests = `
func TestCustomRoutesServeAndClose(t *testing.T){
svc.ResetCustomRoutes();deps:=newDependencies(t)
var extension interface{RegisterCustomRoutes(*omni.App) error}=deps
if extension==nil{t.Fatal("missing extension")}
app,err:=handler.NewApp(deps);if err!=nil{t.Fatal(err)};defer app.Close()
if svc.CustomCalls.Load()!=1{t.Fatal("hook called wrong number of times")}
server:=httptest.NewServer(app);defer server.Close()
for _,test:=range []struct{path string;status int;body string}{{"/custom",200,"project-owned route"},{"/ping",501,""},{"/health/ready",200,""}}{
response,err:=http.Get(server.URL+test.path);if err!=nil{t.Fatal(err)}
body,err:=io.ReadAll(response.Body);response.Body.Close();if err!=nil{t.Fatal(err)}
if response.StatusCode!=test.status||(test.body!=""&&string(body)!=test.body){t.Fatalf("%s: %d %s",test.path,response.StatusCode,body)}
}
if svc.CustomCleaned.Load()!=0{t.Fatal("resource closed during successful construction")}
if err:=app.Close();err!=nil{t.Fatal(err)}
if svc.CustomStopped.Load()!=1||svc.CustomDrained.Load()!=1||svc.CustomCleaned.Load()!=1{t.Fatal("custom lifecycle did not finish")}
}
func TestCustomRoutesFailureRollsBackOnlyAppResources(t *testing.T){
svc.ResetCustomRoutes();svc.CustomFailure=true;defer func(){svc.CustomFailure=false}()
deps:=newDependencies(t);dependencyClosed:=false;deps.OnClose(func()error{dependencyClosed=true;return nil})
app,err:=handler.NewApp(deps)
if app!=nil||!svc.IsCustomFailure(err){t.Fatalf("expected both hook and cleanup errors, got app=%v err=%v",app,err)}
if svc.CustomCalls.Load()!=1||svc.CustomStopped.Load()!=1||svc.CustomDrained.Load()!=1||svc.CustomCleaned.Load()!=1{t.Fatal("failed construction leaked App resources")}
if dependencyClosed{t.Fatal("NewApp closed caller-owned dependencies")}
response:=httptest.NewRecorder();svc.CustomApp.ServeHTTP(response,httptest.NewRequest("GET","/custom",nil))
if response.Code!=http.StatusServiceUnavailable{t.Fatalf("failed App still accepts requests: %d",response.Code)}
_=svc.CustomApp.Close();if svc.CustomCleaned.Load()!=1{t.Fatal("rollback was not idempotent")}
if err:=deps.Close();err!=nil{t.Fatal(err)};if !dependencyClosed{t.Fatal("caller could not close dependencies")}
}
`

View File

@@ -23,7 +23,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.28.0-preview" const currentVersion = "v0.31.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 {

View File

@@ -0,0 +1,299 @@
package apigen
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func compileRecursive(t *testing.T, source string) *Compiled {
t.Helper()
spec, err := parseText(t, source)
if err != nil {
t.Fatal(err)
}
c, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
again, err := Compile(spec)
if err != nil || !bytes.Equal(c.Document, again.Document) {
t.Fatal("non-deterministic compilation", err)
}
return c
}
func recursiveDocument(t *testing.T, c *Compiled) map[string]any {
t.Helper()
var doc map[string]any
if err := json.Unmarshal(c.Document, &doc); err != nil {
t.Fatal(err)
}
// Inspect reference targets without recursively inlining the graph.
var visit func(any)
visit = func(value any) {
switch v := value.(type) {
case map[string]any:
if ref, ok := v["$ref"].(string); ok {
if len(v) != 1 {
t.Fatalf("OpenAPI 3.0 ref has ignored siblings: %v", v)
}
var target any = doc
for _, part := range strings.Split(strings.TrimPrefix(ref, "#/"), "/") {
m, ok := target.(map[string]any)
if !ok {
t.Fatal("bad reference", ref)
}
target = m[part]
}
if target == nil {
t.Fatal("dangling reference", ref)
}
return
}
for key, child := range v {
if key != "example" && key != "examples" {
visit(child)
}
}
case []any:
for _, child := range v {
visit(child)
}
}
}
visit(doc)
return doc
}
func TestRecursiveResponseGraph(t *testing.T) {
source := readFile(t, filepath.Join(frameworkRoot(t), "examples/recursiveResponse/service.api"))
c := compileRecursive(t, source)
doc := recursiveDocument(t, c)
schemas := doc["components"].(map[string]any)["schemas"].(map[string]any)
node := schemas["DTO_Node"].(map[string]any)
props := node["properties"].(map[string]any)
children := props["children"].(map[string]any)
if children["type"] != "array" || children["maxItems"] != float64(100) || children["items"].(map[string]any)["$ref"] != "#/components/schemas/DTO_Node" {
t.Fatal(children)
}
if props["lookup"].(map[string]any)["additionalProperties"].(map[string]any)["$ref"] != "#/components/schemas/DTO_Node" {
t.Fatal(props)
}
next := props["next"].(map[string]any)["anyOf"].([]any)
if len(next) != 2 || next[0].(map[string]any)["$ref"] != "#/components/schemas/DTO_Node" || next[1].(map[string]any)["nullable"] != true {
t.Fatal(next)
}
for _, pair := range [][2]string{{"Branch", "Leaf"}, {"Leaf", "Branch"}} {
p := schemas["DTO_"+pair[0]].(map[string]any)["properties"].(map[string]any)[strings.ToLower(pair[1])].(map[string]any)
if p["anyOf"].([]any)[0].(map[string]any)["$ref"] != "#/components/schemas/DTO_"+pair[1] {
t.Fatal(p)
}
}
if len(c.Document) > 18000 {
t.Fatal("recursive graph expanded", len(c.Document))
}
first, _ := json.Marshal(doc)
compactDocument(doc)
second, _ := json.Marshal(doc)
if !bytes.Equal(first, second) {
t.Fatal("document compaction changed references")
}
// Change tracking traverses every named dependency once, including cycles.
snapshot := snapshotContract(c.Spec)
if len(snapshot.Routes) == 0 {
t.Fatal("empty contract snapshot")
}
}
func TestRecursiveResponseRejections(t *testing.T) {
tests := []struct{ name, types, route, want string }{
{"valueSelf", "type Node { Child Node `json:\"child\"` }", "get /tree returns (Node)", "recursive value"},
{"valueMutual", "type Node { Child Other `json:\"child\"` }\ntype Other { Child Node `json:\"child\"` }", "get /tree returns (Node)", "recursive value"},
{"request", "type Node { Children []Node `json:\"children\"` }", "post /tree (Node) returns (Node)", "recursive request"},
{"wrappedRequest", "type Node { Children []Node `json:\"children\"` }\ntype Req { Value Node `json:\"value\"` }", "post /tree (Req)", "recursive request"},
{"unknown", "type Node { Children []Node `json:\"children\"`\n Bad Missing `json:\"bad\"` }", "get /tree returns (Node)", "unknown type"},
{"duplicate", "type Node { Children []Node `json:\"children\"`\n Again string `json:\"children\"` }", "get /tree returns (Node)", "conflicting composed field"},
{"nonJSON", "type Node { Children []Node `json:\"children\"`\n Name string `form:\"name\"` }", "get /tree returns (Node)", "response fields must use json"},
{"nestedNonJSON", "type Node { Children []Node `json:\"children\"`\n Detail Detail `json:\"detail\"` }\ntype Detail { Name string `form:\"name\"` }", "get /tree returns (Node)", "response fields must use json"},
{"doublePointer", "type Node { Next **Node `json:\"next\"` }", "get /tree returns (Node)", "multiple pointer"},
{"fixedArray", "type Node { Children [1]Node `json:\"children\"` }", "get /tree returns (Node)", "fixed arrays"},
{"mapKey", "type Node { Children map[int]Node `json:\"children\"` }", "get /tree returns (Node)", "map keys"},
{"badRule", "type Node { Next *Node `json:\"next\" binding:\"min=1\"` }", "get /tree returns (Node)", "requires a number"},
{"badDefault", "type Node { Next *Node `json:\"next,default=x\"` }", "get /tree returns (Node)", "default requires"},
{"upload", "type Node { Children []Node `json:\"children\"`\n Upload File `file:\"upload\"` }", "get /tree returns (Node)", "response fields must use json"},
{"composition", "type Node { Node }", "get /tree returns (Node)", "cycle"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
spec, err := parseText(t, tc.types+"\nservice demo {\n@handler tree\n"+tc.route+"\n}")
if err == nil {
_, err = Compile(spec)
}
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("want %q, got %v", tc.want, err)
}
})
}
}
func TestRecursiveResponseExamples(t *testing.T) {
for _, tc := range []struct {
value string
valid bool
}{
{`[]`, true}, {`[{"name":"first","children":[{"name":"second","children":[]}]}]`, true},
{`[{"name":"","children":[]}]`, false}, {`[{"name":"first"}]`, false},
{`[{"name":"first","children":[],"unknown":true}]`, false},
{`[{"name":"first","children":[]},{"name":123,"children":[]}]`, false},
{`[{"name":"first","children":[{"name":"second","children":[{"name":"","children":[]}]}]}]`, false},
{`null`, false}, {`{} {}`, false},
} {
t.Run(tc.value, func(t *testing.T) {
source := "type Node {\n Name string `json:\"name\" binding:\"min=1\"`\n Children []Node `json:\"children\" description:\"Nested nodes\" example:" + fmt.Sprintf("%q", tc.value) + "`\n}\nservice demo {\n @handler tree\nget /tree returns (Node)\n}"
spec, err := parseText(t, source)
if err == nil {
_, err = Compile(spec)
}
if (err == nil) != tc.valid {
t.Fatal(err)
}
})
}
for _, typ := range []string{"*Node", "map[string]*Node", "[]*Node", "*[]Node"} {
source := "type Node {\n Name string `json:\"name\"`\n Next " + typ + " `json:\"next,omitempty\" description:\"Next node\"`\n}\nservice demo {\n@handler tree\nget /tree returns (*Node)\n}"
recursiveDocument(t, compileRecursive(t, source))
}
}
func TestRecursiveResponseImportsAndMetadata(t *testing.T) {
root := t.TempDir()
writeFile(t, filepath.Join(root, "service.api"), "import \"node.api\"\nimport \"detail.api\"\nservice demo {\n@handler tree\nget /tree returns (Node)\n}")
writeFile(t, filepath.Join(root, "node.api"), "type Node {\n Children []Node `json:\"children\"`\n Detail Detail `json:\"detail\" description:\"Node detail\" example:\"{\\\"count\\\":9007199254740993,\\\"parent\\\":null}\"`\n}")
writeFile(t, filepath.Join(root, "detail.api"), "type Detail {\n Count int64 `json:\"count\"`\n Parent *Node `json:\"parent\"`\n}")
spec, err := Parse(filepath.Join(root, "service.api"))
if err != nil {
t.Fatal(err)
}
c, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
doc := recursiveDocument(t, c)
schemas := doc["components"].(map[string]any)["schemas"].(map[string]any)
detail := schemas["DTO_Node"].(map[string]any)["properties"].(map[string]any)["detail"].(map[string]any)
if detail["description"] != "Node detail" || detail["allOf"].([]any)[0].(map[string]any)["$ref"] != "#/components/schemas/DTO_Detail" {
t.Fatal(detail)
}
if !bytes.Contains(c.Document, []byte("9007199254740993")) {
t.Fatal("example integer lost precision")
}
// Definition order cannot change cycle classification or component schemas.
spec.Types[0], spec.Types[1] = spec.Types[1], spec.Types[0]
other, err := Compile(spec)
if err != nil || !bytes.Equal(c.Document, other.Document) {
t.Fatal("definition order changed schemas", err)
}
scalar := `scalar ID (
goImport: "example.com/scalars"
goType: ID
jsonInput: string | int64
jsonOutput: string
)
type Node {
Value ID ` + "`json:\"value\" example:\"123\"`" + `
Children []Node ` + "`json:\"children\"`" + `
}
service demo {
@handler tree
get /tree returns (Node)
}`
doc = recursiveDocument(t, compileRecursive(t, scalar))
value := doc["components"].(map[string]any)["schemas"].(map[string]any)["DTO_Node"].(map[string]any)["properties"].(map[string]any)["value"].(map[string]any)
if value["type"] != "string" || value["example"] != nil {
t.Fatal("scalar input leaked into response", value)
}
}
func TestRecursiveResponseConsumer(t *testing.T) {
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
}
root := frameworkRoot(t)
dir := filepath.Join(t.TempDir(), "recursive")
o := Options{Dir: dir, API: filepath.Join(root, "examples/recursiveResponse/service.api"), Module: "example.com/recursive", FrameworkDir: root}
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
doc := recursiveDocument(t, &Compiled{Document: []byte(readFile(t, filepath.Join(dir, "docs/openapi.json")))})
schemas := doc["components"].(map[string]any)["schemas"].(map[string]any)
children := schemas["DTO_Node"].(map[string]any)["properties"].(map[string]any)["children"].(map[string]any)
if children["items"].(map[string]any)["$ref"] != "#/components/schemas/DTO_Node" {
t.Fatal("generated response lost its recursive schema", children)
}
for _, name := range []string{"tree", "forest", "optional", "raw", "pair"} {
writeFile(t, filepath.Join(dir, "internal/logic/trees/"+name+".go"), "package trees\n")
}
writeFile(t, filepath.Join(dir, "internal/logic/trees/operations.go"), readFile(t, filepath.Join(root, "examples/recursiveResponse/operations.go.txt")))
writeFile(t, filepath.Join(dir, "consumer_test.go"), readFile(t, "testdata/recursive/consumer_test.go"))
o.API = filepath.Join(dir, "api/service.api")
before := snapshot(t, dir)
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
o.Check = true
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("regeneration/check modified business code")
}
runConsumerGo(t, dir, "test", "-mod=mod", "-race", "-count=1", "./...")
// A recursive descendant change must still be visible to read-only checking.
writeFile(t, o.API, strings.Replace(readFile(t, o.API), "max=100", "max=90", 1))
before = snapshot(t, dir)
if err := generateManagedTestProject(t, o); err == nil {
t.Fatal("check missed recursive DTO drift")
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("drift check wrote files")
}
o.Check = false
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
o.Check = true
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
if os.Getenv("GO_OMNI_RELEASE_CLI") != "" {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
data, err := cmd.CombinedOutput()
if err != nil {
t.Fatal(err, string(data))
}
var dep struct {
Version, Sum string
Replace any
}
if err := json.Unmarshal(data, &dep); err != nil {
t.Fatal(err)
}
if dep.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dep.Sum == "" || dep.Replace != nil {
t.Fatal("not a fixed remote consumer")
}
t.Logf("remote framework %s checksum %s; no local replace", dep.Version, dep.Sum)
}
}

View File

@@ -0,0 +1,370 @@
package apigen
import (
"encoding/json"
"fmt"
"go/ast"
goparser "go/parser"
"reflect"
"sort"
"strconv"
"strings"
"git.yuntuo.hk/k2safe/go-omni/contract"
"git.yuntuo.hk/k2safe/go-omni/openapi"
)
// Named response types form a graph, not an infinitely expanded reflect.StructOf
// tree. Compile each object's local fields with the existing contract compiler,
// then link named fields through OpenAPI references. Request binders still use
// the complete, nonrecursive reflection tree.
type responseGraph struct {
types map[string]Type
exprs map[string][]ast.Expr
recursive map[string]bool // includes types that can reach a cycle
scalars map[string]reflect.Type
scalarDefs []contract.Scalar
components map[string]*openapi.Schema
}
func newResponseGraph(spec *Spec, types map[string]Type, scalars map[string]reflect.Type, defs []contract.Scalar) (*responseGraph, error) {
g := &responseGraph{types: types, exprs: map[string][]ast.Expr{}, recursive: map[string]bool{}, scalars: scalars, scalarDefs: defs, components: map[string]*openapi.Schema{}}
dependencies, values := map[string][]string{}, map[string][]string{}
for _, typ := range spec.Types {
seen := map[string]bool{}
for _, f := range typ.Fields {
if !exportedName.MatchString(f.Name) || seen[f.Name] {
return nil, fmt.Errorf("%s: invalid or duplicate field %s", typ.Name, f.Name)
}
seen[f.Name] = true
e, err := goparser.ParseExpr(f.Type)
if err != nil {
return nil, err
}
// Validate every expression, including otherwise unused declarations.
if _, err = g.expression(e, 0, func(name string, _ int) (reflect.Type, error) {
dependencies[typ.Name] = append(dependencies[typ.Name], name)
return reflect.TypeOf(struct{}{}), nil
}); err != nil {
return nil, fmt.Errorf("%s.%s: %w", typ.Name, f.Name, err)
}
g.exprs[typ.Name] = append(g.exprs[typ.Name], e)
if id, ok := e.(*ast.Ident); ok && types[id.Name].Name != "" {
values[typ.Name] = append(values[typ.Name], id.Name)
}
}
}
// Only pointer/slice/map indirection can break a Go struct layout cycle.
state := map[string]int{}
var valueCycle func(string) error
valueCycle = func(name string) error {
if state[name] == 1 {
return fmt.Errorf("%s: invalid recursive value type; use a pointer, slice or map", name)
}
if state[name] == 2 {
return nil
}
state[name] = 1
for _, next := range values[name] {
if err := valueCycle(next); err != nil {
return err
}
}
state[name] = 2
return nil
}
for _, typ := range spec.Types {
if err := valueCycle(typ.Name); err != nil {
return nil, err
}
}
state = map[string]int{}
var cyclic func(string) bool
cyclic = func(name string) bool {
if state[name] == 1 {
return true
}
if state[name] == 2 {
return g.recursive[name]
}
state[name] = 1
for _, next := range dependencies[name] {
if cyclic(next) {
g.recursive[name] = true
}
}
state[name] = 2
return g.recursive[name]
}
for _, typ := range spec.Types {
cyclic(typ.Name)
}
return g, nil
}
func (g *responseGraph) expression(e ast.Expr, depth int, named func(string, int) (reflect.Type, error)) (reflect.Type, error) {
if depth > 32 {
return nil, fmt.Errorf("type nesting exceeds 32")
}
switch e := e.(type) {
case *ast.Ident:
if t := g.scalars[e.Name]; t != nil {
return t, nil
}
if t := primitiveTypes[e.Name]; t != nil {
return t, nil
}
if g.types[e.Name].Name == "" {
return nil, fmt.Errorf("unknown type %s", e.Name)
}
return named(e.Name, depth+1)
case *ast.StarExpr:
if _, double := e.X.(*ast.StarExpr); double {
return nil, fmt.Errorf("multiple pointer levels unsupported")
}
t, err := g.expression(e.X, depth+1, named)
if err != nil {
return nil, err
}
return reflect.PointerTo(t), nil
case *ast.ArrayType:
if e.Len != nil {
return nil, fmt.Errorf("use slices, not fixed arrays")
}
t, err := g.expression(e.Elt, depth+1, named)
if err != nil {
return nil, err
}
return reflect.SliceOf(t), nil
case *ast.MapType:
key, ok := e.Key.(*ast.Ident)
if !ok || key.Name != "string" {
return nil, fmt.Errorf("map keys must be string")
}
t, err := g.expression(e.Value, depth+1, named)
if err != nil {
return nil, err
}
return reflect.MapOf(reflect.TypeOf(""), t), nil
default:
return nil, fmt.Errorf("unsupported type expression")
}
}
func (g *responseGraph) component(name string) (*openapi.Schema, error) {
key := "DTO_" + name
ref := &openapi.Schema{Ref: "#/components/schemas/" + key}
if _, ok := g.components[key]; ok {
return ref, nil
}
// Register before following edges so both self and mutual references terminate.
g.components[key] = nil
typ := g.types[name]
var fields []reflect.StructField
for i, f := range typ.Fields {
t, err := g.expression(g.exprs[name][i], 0, func(string, int) (reflect.Type, error) { return reflect.TypeOf(struct{}{}), nil })
if err != nil {
return nil, err
}
tag, err := withoutExample(f)
if err != nil {
return nil, err
}
fields = append(fields, reflect.StructField{Name: f.Name, Type: t, Tag: tag})
}
b, err := contract.Describe(reflect.StructOf(fields), g.scalarDefs...)
if err != nil {
return nil, fmt.Errorf("%s: %w", typ.Position, err)
}
s, err := b.ResponseSchema()
if err != nil {
return nil, fmt.Errorf("%s: %w", typ.Position, err)
}
for i, f := range typ.Fields {
wire := strings.Split(reflect.StructTag(fieldTag(f)).Get("json"), ",")[0]
property := s.Properties[wire]
// Examples are checked against a finite projection of their actual values,
// with the same contract rules and without running application decoders.
if _, ok := reflect.StructTag(fieldTag(f)).Lookup("example"); ok {
example, err := g.example(f, g.exprs[name][i])
if err != nil {
return nil, fmt.Errorf("%s.%s: %w", name, f.Name, err)
}
property.Example = example
}
property, err = g.link(g.exprs[name][i], property)
if err != nil {
return nil, err
}
s.Properties[wire] = property
}
g.components[key] = s
return ref, nil
}
func (g *responseGraph) link(e ast.Expr, s *openapi.Schema) (*openapi.Schema, error) {
switch e := e.(type) {
case *ast.Ident:
if g.types[e.Name].Name == "" {
return s, nil
}
ref, err := g.component(e.Name)
if err != nil {
return nil, err
}
if s.Nullable {
ref = nullableReference(ref)
}
if s.Description != "" || s.Example != nil {
// OpenAPI 3.0 ignores siblings on Reference Objects.
ref = &openapi.Schema{AllOf: []*openapi.Schema{ref}, Description: s.Description, Example: s.Example}
}
return ref, nil
case *ast.StarExpr:
return g.link(e.X, s)
case *ast.ArrayType:
item, err := g.link(e.Elt, s.Items)
if err != nil {
return nil, err
}
s.Items = item
case *ast.MapType:
item, err := g.link(e.Value, s.AdditionalProperties)
if err != nil {
return nil, err
}
s.AdditionalProperties = item
}
return s, nil
}
func nullableReference(s *openapi.Schema) *openapi.Schema {
return &openapi.Schema{AnyOf: []*openapi.Schema{s, {Type: "object", Nullable: true, Enum: []any{nil}}}}
}
func withoutExample(f Field) (reflect.StructTag, error) {
tags, err := contract.ParseTags(fieldTag(f))
if err != nil {
return "", err
}
delete(tags, "example")
keys := make([]string, 0, len(tags))
for key := range tags {
keys = append(keys, key)
}
sort.Strings(keys)
var parts []string
for _, key := range keys {
parts = append(parts, key+":"+strconv.Quote(tags[key]))
}
return reflect.StructTag(strings.Join(parts, " ")), nil
}
func (g *responseGraph) example(f Field, e ast.Expr) (any, error) {
raw := reflect.StructTag(fieldTag(f)).Get("example")
var value any
// Plain string examples intentionally are not JSON literals.
if f.Type == "string" || f.Type == "*string" {
value = raw
} else {
decoder := json.NewDecoder(strings.NewReader(raw))
decoder.UseNumber()
if err := decoder.Decode(&value); err != nil {
return nil, fmt.Errorf("invalid example JSON: %w", err)
}
}
budget := 10000
t, err := g.projectExample(e, []any{value}, 0, &budget)
if err != nil {
return nil, err
}
b, err := contract.Describe(reflect.StructOf([]reflect.StructField{{Name: f.Name, Type: t, Tag: reflect.StructTag(fieldTag(f))}}), g.scalarDefs...)
if err != nil {
return nil, err
}
s, err := b.ResponseSchema()
if err != nil {
return nil, err
}
wire := strings.Split(reflect.StructTag(fieldTag(f)).Get("json"), ",")[0]
return s.Properties[wire].Example, nil
}
// Only materialize named objects where the finite example has a non-null value.
// Empty slices/maps and null pointers stop expansion. Unioning collection values
// checks every item using one reflection type, including missing/unknown fields.
func (g *responseGraph) projectExample(e ast.Expr, values []any, depth int, budget *int) (reflect.Type, error) {
*budget--
if depth > 32 || *budget < 0 {
return nil, fmt.Errorf("example type expansion exceeds 32 levels or 10000 nodes")
}
var present []any
for _, v := range values {
if v != nil {
present = append(present, v)
}
}
switch e := e.(type) {
case *ast.Ident:
return g.expression(e, 0, func(name string, _ int) (reflect.Type, error) {
if len(present) == 0 {
return reflect.TypeOf(struct{}{}), nil
}
var fields []reflect.StructField
for i, f := range g.types[name].Fields {
wire := strings.Split(reflect.StructTag(fieldTag(f)).Get("json"), ",")[0]
var nested []any
for _, v := range present {
if obj, ok := v.(map[string]any); ok {
if child, found := obj[wire]; found {
nested = append(nested, child)
}
}
}
t, err := g.projectExample(g.exprs[name][i], nested, depth+1, budget)
if err != nil {
return nil, err
}
tag, err := withoutExample(f)
if err != nil {
return nil, err
}
fields = append(fields, reflect.StructField{Name: f.Name, Type: t, Tag: tag})
}
return reflect.StructOf(fields), nil
})
case *ast.StarExpr:
t, err := g.projectExample(e.X, present, depth+1, budget)
if err != nil {
return nil, err
}
return reflect.PointerTo(t), nil
case *ast.ArrayType:
var nested []any
for _, v := range present {
if list, ok := v.([]any); ok {
nested = append(nested, list...)
}
}
t, err := g.projectExample(e.Elt, nested, depth+1, budget)
if err != nil {
return nil, err
}
return reflect.SliceOf(t), nil
case *ast.MapType:
var nested []any
for _, v := range present {
if obj, ok := v.(map[string]any); ok {
for _, v := range obj {
nested = append(nested, v)
}
}
}
t, err := g.projectExample(e.Value, nested, depth+1, budget)
if err != nil {
return nil, err
}
return reflect.MapOf(reflect.TypeOf(""), t), nil
}
return nil, fmt.Errorf("unsupported example type")
}

View File

@@ -35,7 +35,7 @@ func responseGoType(value string) string {
} }
return "*types." + name return "*types." + name
} }
func responseDataSchema(value string, bindings map[string]*contract.Binder) (*openapi.Schema, error) { func responseDataSchema(value string, bindings map[string]*contract.Binder, graph *responseGraph) (*openapi.Schema, error) {
kind, name := responseShape(value) kind, name := responseShape(value)
if kind == "null" { if kind == "null" {
return &openapi.Schema{Type: "object", Nullable: true, Enum: []any{nil}}, nil return &openapi.Schema{Type: "object", Nullable: true, Enum: []any{nil}}, nil
@@ -56,11 +56,15 @@ func responseDataSchema(value string, bindings map[string]*contract.Binder) (*op
} }
return s.Properties["value"], nil return s.Properties["value"], nil
} }
b, ok := bindings[name] var s *openapi.Schema
if !ok { var err error
if graph.recursive[name] {
s, err = graph.component(name)
} else if b := bindings[name]; b != nil {
s, err = b.ResponseSchema()
} else {
return nil, fmt.Errorf("unknown response DTO %q; use Name, []Name, []string/[]int64/[]bool/[]any, *Name or null", name) return nil, fmt.Errorf("unknown response DTO %q; use Name, []Name, []string/[]int64/[]bool/[]any, *Name or null", name)
} }
s, err := b.ResponseSchema()
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -68,6 +72,9 @@ func responseDataSchema(value string, bindings map[string]*contract.Binder) (*op
case "array": case "array":
return &openapi.Schema{Type: "array", Items: s}, nil return &openapi.Schema{Type: "array", Items: s}, nil
case "nullable": case "nullable":
if s.Ref != "" {
return nullableReference(s), nil
}
s.Nullable = true s.Nullable = true
} }
return s, nil return s, nil

View File

@@ -63,7 +63,14 @@ func NewApp(deps *svc.ServiceContext)(*omni.App,error){
for _,method:=range []string{"GET","HEAD","PUT"}{app.Handle(method,"/storage/object",httpx.WrapHandler(deps.FileHTTP))} for _,method:=range []string{"GET","HEAD","PUT"}{app.Handle(method,"/storage/object",httpx.WrapHandler(deps.FileHTTP))}
` `
} }
source += "return app,nil\n}\n" source += `// Optional project-owned extension; no generated file needs hand edits.
if extension,ok:=any(deps).(interface{RegisterCustomRoutes(*omni.App) error});ok{
if err:=extension.RegisterCustomRoutes(app);err!=nil{return nil,errors.Join(err,app.Close())}
}
return app,nil
}
`
source = strings.Replace(source, "\"fmt\"", "\"fmt\";\"errors\"", 1)
if o.Storage != "local" { if o.Storage != "local" {
source = strings.Replace(source, fmt.Sprintf("%q", frameworkModule+"/httpx"), "", 1) source = strings.Replace(source, fmt.Sprintf("%q", frameworkModule+"/httpx"), "", 1)
} }

View File

@@ -0,0 +1,77 @@
package main_test
import (
"context"
"encoding/json"
"io"
"net/http/httptest"
"testing"
"example.com/recursive/internal/handler"
"example.com/recursive/internal/svc"
"example.com/recursive/internal/types"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func TestRecursiveHTTP(t *testing.T) {
app := omni.New()
defer app.Close()
cfg := logx.Default()
cfg.Writer = io.Discard
logger, err := logx.New(cfg)
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()
if err := handler.RegisterRoutes(app, deps); err != nil {
t.Fatal(err)
}
for _, path := range []string{"/tree", "/forest", "/optional", "/raw", "/pair"} {
w := httptest.NewRecorder()
app.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
if w.Code != 200 {
t.Fatal(path, w.Code, w.Body.String())
}
data := w.Body.Bytes()
if path != "/raw" {
var envelope struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(data, &envelope); err != nil {
t.Fatal(err)
}
data = envelope.Data
}
switch path {
case "/optional":
if string(data) != "null" {
t.Fatal(string(data))
}
case "/pair":
var pair types.Branch
if err := json.Unmarshal(data, &pair); err != nil || pair.Leaf == nil || pair.Leaf.Branch != nil {
t.Fatal(err, string(data))
}
default:
var node types.Node
if path == "/forest" {
var forest []types.Node
if err := json.Unmarshal(data, &forest); err != nil || len(forest) != 1 {
t.Fatal(err, string(data))
}
node = forest[0]
} else if err := json.Unmarshal(data, &node); err != nil {
t.Fatal(err)
}
if len(node.Children) != 1 || len(node.Children[0].Children) != 1 || node.Children[0].Children[0].Name != "leaf" || node.Lookup["entry"].Name != "lookup" {
t.Fatal(node)
}
}
}
}

View File

@@ -3,15 +3,18 @@ package apigen
import ( import (
"errors" "errors"
"fmt" "fmt"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/projectgen"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
) )
type WorkspaceService struct { type WorkspaceService struct {
Dir string `json:"dir"` Dir string `json:"dir"`
Kind string `json:"kind,omitempty"`
API string `json:"api,omitempty"` API string `json:"api,omitempty"`
Module string `json:"module,omitempty"` Module string `json:"module,omitempty"`
} }
@@ -19,10 +22,14 @@ type WorkspaceConfig struct {
Services []WorkspaceService `json:"services"` Services []WorkspaceService `json:"services"`
ErrorCatalogs []WorkspaceCatalog `json:"errorCatalogs,omitempty"` ErrorCatalogs []WorkspaceCatalog `json:"errorCatalogs,omitempty"`
} }
type workspaceTarget struct {
dir, kind, module, entry string
err error
}
// Workspace operates independent Go modules listed in an explicit local file. // Workspace dispatches to each independent project's generator. Directory/schema
// All projects are preflighted before generate/format writes. Each generator // validation precedes any providers; all targets are preflighted before writes.
// transaction is atomic within its project; no cross-module transaction is promised. // Transactions remain per-project, not crash-atomic across the workspace.
func Workspace(path, command string, w io.Writer) error { func Workspace(path, command string, w io.Writer) error {
if command != "generate" && command != "check" && command != "format" { if command != "generate" && command != "check" && command != "format" {
return errors.New("workspace command must be generate, check or format") return errors.New("workspace command must be generate, check or format")
@@ -53,8 +60,8 @@ func Workspace(path, command string, w io.Writer) error {
return errors.New("workspace must list 1..64 services") return errors.New("workspace must list 1..64 services")
} }
root := canonical(filepath.Dir(real)) root := canonical(filepath.Dir(real))
var options []Options
seen := map[string]bool{} seen := map[string]bool{}
// Validate the entire workspace boundary before resolving or running any target.
for _, service := range cfg.Services { for _, service := range cfg.Services {
if service.Dir == "" || service.Dir == "." { if service.Dir == "" || service.Dir == "." {
return errors.New("workspace requires distinct relative service directories") return errors.New("workspace requires distinct relative service directories")
@@ -62,27 +69,26 @@ func Workspace(path, command string, w io.Writer) error {
if err = safePath(root, service.Dir+"/.goOmniWorkspaceCheck"); err != nil { if err = safePath(root, service.Dir+"/.goOmniWorkspaceCheck"); err != nil {
return err return err
} }
dir := filepath.Join(root, filepath.FromSlash(service.Dir)) key := strings.ToLower(filepath.Join(root, filepath.FromSlash(service.Dir)))
key := strings.ToLower(dir)
for prior := range seen { for prior := range seen {
if prior == key || strings.HasPrefix(key, prior+string(filepath.Separator)) || strings.HasPrefix(prior, key+string(filepath.Separator)) { if prior == key || strings.HasPrefix(key, prior+string(filepath.Separator)) || strings.HasPrefix(prior, key+string(filepath.Separator)) {
return errors.New("workspace service directories overlap") return errors.New("workspace service directories overlap")
} }
} }
seen[key] = true seen[key] = true
entry := service.API
if entry == "" {
entry, err = ProjectEntry(dir)
} else {
if err = safePath(dir, entry); err != nil {
return err
} }
entry = filepath.Join(dir, entry) targets := make([]workspaceTarget, 0, len(cfg.Services))
for _, service := range cfg.Services {
t := workspaceTarget{dir: filepath.Join(root, filepath.FromSlash(service.Dir)), module: service.Module}
t.kind, t.err = workspaceKind(t.dir, service.Kind, service.API)
if t.err == nil && t.kind == "api" {
if service.API == "" {
t.entry, t.err = ProjectEntry(t.dir)
} else if t.err = safePath(t.dir, service.API); t.err == nil {
t.entry = filepath.Join(t.dir, filepath.FromSlash(service.API))
} }
if err != nil {
return err
} }
options = append(options, Options{API: entry, Dir: dir, Module: service.Module, DryRun: true}) targets = append(targets, t)
} }
var catalogs map[string][]byte var catalogs map[string][]byte
if command != "format" { if command != "format" {
@@ -90,26 +96,41 @@ func Workspace(path, command string, w io.Writer) error {
if err != nil { if err != nil {
return err return err
} }
for i := range options {
options[i].documentCatalogs = catalogs
}
} }
var failures error var failures error
for _, o := range options { report := func(t workspaceTarget, e error) {
if command == "format" { if e != nil {
spec, e := Parse(o.API) failures = errors.Join(failures, fmt.Errorf("%s: %w", t.dir, e))
if e == nil { fmt.Fprintf(w, "FAIL %s: %v\n", t.dir, e)
_, e = Compile(spec) } else if command == "format" && t.kind == "project" {
} fmt.Fprintf(w, "SKIP %s (project has no API contract to format)\n", t.dir)
err = e
} else { } else {
_, err = Generate(o) fmt.Fprintf(w, "OK %s\n", t.dir)
} }
if err != nil { }
failures = errors.Join(failures, fmt.Errorf("%s: %w", o.Dir, err)) if command == "check" {
// Each generator checks its own manifest and managed outputs. An invalid
// project must not suppress diagnostics for another independent project.
for _, t := range targets {
report(t, runWorkspaceTarget(t, command, false, catalogs))
}
return failures
}
for i := range targets {
t := &targets[i]
t.err = runWorkspaceTarget(*t, command, true, catalogs)
}
for _, t := range targets {
if t.err != nil {
report(t, t.err)
} }
} }
if failures != nil { if failures != nil {
for _, t := range targets {
if t.err == nil {
fmt.Fprintf(w, "SKIP %s (workspace preflight failed; no changes applied)\n", t.dir)
}
}
return failures return failures
} }
if command == "generate" { if command == "generate" {
@@ -119,26 +140,79 @@ func Workspace(path, command string, w io.Writer) error {
} }
} }
} }
for _, o := range options { for _, t := range targets {
switch command { report(t, runWorkspaceTarget(t, command, false, catalogs))
case "format":
_, err = FormatProject(o.API, false)
case "generate":
o.DryRun = false
_, err = Generate(o)
case "check":
_, fmtErr := FormatProject(o.API, true)
o.DryRun = false
o.Check = true
_, genErr := Generate(o)
err = errors.Join(fmtErr, genErr)
}
if err != nil {
failures = errors.Join(failures, fmt.Errorf("%s: %w", o.Dir, err))
fmt.Fprintf(w, "FAIL %s\n", o.Dir)
} else {
fmt.Fprintf(w, "OK %s\n", o.Dir)
}
} }
return failures return failures
} }
// An explicit API entry remains supported before the first API generation.
// Without manifests, omitted kind preserves the previous API interpretation.
// Manifests of both kinds or a conflicting declaration never silently choose one.
func workspaceKind(dir, kind, entry string) (string, error) {
if kind != "" && kind != "api" && kind != "project" {
return "", fmt.Errorf("unknown workspace kind %q; expected api or project", kind)
}
detected := ""
for _, candidate := range []struct{ kind, path string }{{"api", manifestPath}, {"project", ".goOmni/projectManifest.json"}} {
if err := safePath(dir, candidate.path); err != nil {
return "", err
}
if _, err := os.Lstat(filepath.Join(dir, candidate.path)); err == nil {
if detected != "" {
return "", errors.New("ambiguous project: both API and project manifests exist")
}
detected = candidate.kind
} else if !errors.Is(err, os.ErrNotExist) {
return "", err
}
}
if kind != "" && detected != "" && kind != detected {
return "", fmt.Errorf("workspace kind %q conflicts with %s manifest", kind, detected)
}
if kind == "" {
kind = detected
}
if kind == "" {
kind = "api"
}
if kind == "project" && entry != "" {
return "", errors.New("workspace project kind does not accept an api entry")
}
return kind, nil
}
func runWorkspaceTarget(t workspaceTarget, command string, preview bool, catalogs map[string][]byte) error {
if t.err != nil {
return t.err
}
if t.kind == "project" {
if command == "format" {
return nil
}
_, err := projectgen.Generate(projectgen.Options{Dir: t.dir, Module: t.module, DryRun: preview, Check: command == "check"})
return err
}
o := Options{API: t.entry, Dir: t.dir, Module: t.module, DryRun: preview, documentCatalogs: catalogs}
switch command {
case "format":
if preview {
spec, err := Parse(t.entry)
if err == nil {
_, err = Compile(spec)
}
return err
}
_, err := FormatProject(t.entry, false)
return err
case "generate":
_, err := Generate(o)
return err
case "check":
_, formatErr := FormatProject(t.entry, true)
o.Check = true
_, generateErr := Generate(o)
return errors.Join(formatErr, generateErr)
}
return errors.New("unsupported workspace command")
}

View File

@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/projectgen"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
@@ -30,8 +31,11 @@ func Catalog() errors.Catalog {return errors.Catalog{DefaultStatus:400,Definitio
} }
writeFile(t, filepath.Join(dir, "api/omni.imports.json"), `{"roots":{"common":{"path":"../../../contracts"}},"errors":{"source":"@common/errors.json","include":["INVALID"],"routes":{"GET /api/greet/:name":["MISSING"]}}}`) writeFile(t, filepath.Join(dir, "api/omni.imports.json"), `{"roots":{"common":{"path":"../../../contracts"}},"errors":{"source":"@common/errors.json","include":["INVALID"],"routes":{"GET /api/greet/:name":["MISSING"]}}}`)
} }
if _, err := projectgen.New(projectgen.Options{Dir: filepath.Join(root, "services/program"), Module: "example.com/program", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
workspace := filepath.Join(root, "omni.workspace.json") workspace := filepath.Join(root, "omni.workspace.json")
writeFile(t, workspace, `{"errorCatalogs":[{"dir":".","package":"example.com/catalogs/internal/apierrors","function":"Catalog","output":"contracts/errors.json"}],"services":[{"dir":"services/api"},{"dir":"services/id"}]}`) writeFile(t, workspace, `{"errorCatalogs":[{"dir":".","package":"example.com/catalogs/internal/apierrors","function":"Catalog","output":"contracts/errors.json"}],"services":[{"dir":"services/api"},{"dir":"services/id"},{"dir":"services/program"}]}`)
if err := Workspace(workspace, "generate", io.Discard); err != nil { if err := Workspace(workspace, "generate", io.Discard); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -0,0 +1,226 @@
package apigen
import (
"bytes"
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
"testing"
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/projectgen"
)
func mixedWorkspace(t *testing.T) (string, string) {
t.Helper()
root := t.TempDir()
if _, err := New(Options{Dir: filepath.Join(root, "apps/interface"), Module: "example.com/interface", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
if _, err := projectgen.New(projectgen.Options{Dir: filepath.Join(root, "apps/program"), Module: "example.com/program", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
config := filepath.Join(root, "omni.workspace.json")
writeFile(t, config, `{"services":[{"dir":"apps/interface"},{"dir":"apps/program"}]}`)
if err := Workspace(config, "format", io.Discard); err != nil {
t.Fatal(err)
}
if err := Workspace(config, "generate", io.Discard); err != nil {
t.Fatal(err)
}
return root, config
}
func TestWorkspaceMixedKindsAndReadOnlyCheck(t *testing.T) {
for _, explicit := range []bool{false, true} {
t.Run(map[bool]string{false: "inferred", true: "explicit"}[explicit], func(t *testing.T) {
root, config := mixedWorkspace(t)
if explicit {
writeFile(t, config, `{"services":[{"dir":"apps/interface","kind":"api","module":"example.com/interface"},{"dir":"apps/program","kind":"project","module":"example.com/program"}]}`)
}
// Workspaces do not manage the Go toolchain's workspace or dependency files.
writeFile(t, filepath.Join(root, "go.work"), "go 1.22.0\nuse (\n ./apps/interface\n ./apps/program\n)\n")
writeFile(t, filepath.Join(root, "go.work.sum"), "workspace sums marker\n")
for _, dir := range []string{"apps/interface", "apps/program"} {
writeFile(t, filepath.Join(root, dir, "go.sum"), "module sums marker\n")
}
runFile := filepath.Join(root, "apps/program/internal/run/run.go")
writeFile(t, runFile, readFile(t, runFile)+"\n// project-owned work\n")
before := snapshot(t, root)
for _, op := range []string{"check", "generate", "check"} {
var output bytes.Buffer
if err := Workspace(config, op, &output); err != nil {
t.Fatal(op, err)
}
if strings.Count(output.String(), "OK ") != 2 {
t.Fatal(output.String())
}
if !sameFiles(before, snapshot(t, root)) {
t.Fatal(op, "changed clean workspace or project-owned files")
}
}
// Formatting only touches API contracts, even when project Go has drift.
main := filepath.Join(root, "apps/program/main.go")
writeFile(t, main, readFile(t, main)+"\n// drift intentionally not formatted\n")
before = snapshot(t, root)
var output bytes.Buffer
if err := Workspace(config, "format", &output); err != nil {
t.Fatal(err)
}
if !strings.Contains(output.String(), "SKIP ") || !strings.Contains(output.String(), "apps/program") {
t.Fatal(output.String())
}
if !sameFiles(before, snapshot(t, root)) {
t.Fatal("format changed a plain Go project")
}
})
}
}
func TestWorkspaceMixedFailureAggregation(t *testing.T) {
root, config := mixedWorkspace(t)
for _, dir := range []string{"apps/interface", "apps/program"} {
path := filepath.Join(root, dir, "main.go")
writeFile(t, path, readFile(t, path)+"\n// manual drift\n")
}
before := snapshot(t, root)
var output bytes.Buffer
err := Workspace(config, "check", &output)
if err == nil || !strings.Contains(err.Error(), "apps/interface") || !strings.Contains(err.Error(), "apps/program") || strings.Count(output.String(), "FAIL ") != 2 {
t.Fatal(err, output.String())
}
if !sameFiles(before, snapshot(t, root)) {
t.Fatal("failed check wrote files")
}
// Identity failures also keep unrelated project diagnostics.
writeFile(t, config, `{"services":[{"dir":"apps/interface","kind":"project"},{"dir":"apps/program"}]}`)
output.Reset()
before = snapshot(t, root)
err = Workspace(config, "check", &output)
if err == nil || !strings.Contains(err.Error(), "conflicts") || strings.Count(output.String(), "FAIL ") != 2 {
t.Fatal(err, output.String())
}
if !sameFiles(before, snapshot(t, root)) {
t.Fatal("identity failure changed workspace")
}
}
func TestWorkspaceMixedPreflightBeforeAnyWrites(t *testing.T) {
for _, broken := range []string{"apps/interface", "apps/program"} {
t.Run(broken, func(t *testing.T) {
root, config := mixedWorkspace(t)
stale := "apps/interface"
if stale == broken {
stale = "apps/program"
}
// The first valid generator could repair its missing output, but must not
// write if another project's ownership check will fail.
if err := os.Remove(filepath.Join(root, stale, "main.go")); err != nil {
t.Fatal(err)
}
path := filepath.Join(root, broken, "main.go")
writeFile(t, path, readFile(t, path)+"\n// manual change\n")
before := snapshot(t, root)
var output bytes.Buffer
if err := Workspace(config, "generate", &output); err == nil {
t.Fatal("bad preflight accepted")
}
if !sameFiles(before, snapshot(t, root)) {
t.Fatal("partial regeneration before failed preflight")
}
if !strings.Contains(output.String(), "SKIP ") || !strings.Contains(output.String(), "FAIL ") {
t.Fatal(output.String())
}
})
}
root, config := mixedWorkspace(t)
for _, dir := range []string{"apps/interface", "apps/program"} {
if err := os.Remove(filepath.Join(root, dir, "main.go")); err != nil {
t.Fatal(err)
}
}
if err := Workspace(config, "generate", io.Discard); err != nil {
t.Fatal(err)
}
if err := Workspace(config, "check", io.Discard); err != nil {
t.Fatal(err)
}
}
func TestWorkspaceKindGuardsAndLegacyEntry(t *testing.T) {
root, config := mixedWorkspace(t)
for _, tc := range []struct{ services, needle string }{
{`[{"dir":"apps/program","kind":"worker"}]`, "unknown workspace kind"},
{`[{"dir":"apps/program","kind":"api"}]`, "conflicts"},
{`[{"dir":"apps/interface","kind":"project"}]`, "conflicts"},
{`[{"dir":"apps/program","api":"api/service.api"}]`, "does not accept"},
{`[{"dir":"apps/program","kind":"project","api":"api/service.api"}]`, "does not accept"},
{`[{"dir":"apps/program","module":"example.com/other"}]`, "module differs"},
{`[{"dir":"../outside","kind":"project"}]`, "unsafe"},
{`[{"dir":"apps/program"},{"dir":"apps/program/nested","kind":"project"}]`, "overlap"},
{`[{"dir":"apps/program","kind":"project","kind":"api"}]`, "duplicate"},
} {
writeFile(t, config, `{"services":`+tc.services+`}`)
before := snapshot(t, root)
err := Workspace(config, "check", io.Discard)
if err == nil || !strings.Contains(err.Error(), tc.needle) {
t.Fatal(tc.services, err)
}
if !sameFiles(before, snapshot(t, root)) {
t.Fatal("invalid kind wrote files")
}
}
writeFile(t, config, `{"services":[{"dir":"apps/program"}]}`)
other := filepath.Join(root, "apps/program/.goOmni/apiManifest.json")
writeFile(t, other, "{}")
if err := Workspace(config, "check", io.Discard); err == nil || !strings.Contains(err.Error(), "both API and project") {
t.Fatal(err)
}
if err := os.Remove(other); err != nil {
t.Fatal(err)
}
// A malformed manifest must be routed to its owner, never fall back to API.
writeFile(t, filepath.Join(root, "apps/program/.goOmni/projectManifest.json"), "invalid")
if err := Workspace(config, "check", io.Discard); err == nil || !strings.Contains(err.Error(), "invalid project manifest") {
t.Fatal(err)
}
// Explicit API entry outside a managed project preserves the original path.
legacy := filepath.Join(root, "legacy")
writeFile(t, filepath.Join(legacy, "service.api"), "service legacy {\n@handler ping\nget /ping\n}\n")
writeFile(t, config, `{"services":[{"dir":"legacy","api":"service.api","module":"example.com/legacy"}]}`)
if err := Workspace(config, "generate", io.Discard); err != nil {
t.Fatal("legacy API entry", err)
}
}
func TestWorkspaceManifestSymlinkRejected(t *testing.T) {
root, config := mixedWorkspace(t)
path := filepath.Join(root, "apps/program/.goOmni/projectManifest.json")
target := filepath.Join(t.TempDir(), "manifest.json")
writeFile(t, target, readFile(t, path))
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, path); err != nil {
t.Fatal(err)
}
before := snapshot(t, root)
for _, op := range []string{"check", "format", "generate"} {
if err := Workspace(config, op, io.Discard); err == nil || !strings.Contains(err.Error(), "symlink") {
t.Fatal(op, err)
}
}
if !sameFiles(before, snapshot(t, root)) {
t.Fatal("followed symlink")
}
}
func TestWorkspaceProjectOnly(t *testing.T) {
root, config := mixedWorkspace(t)
raw, _ := json.Marshal(WorkspaceConfig{Services: []WorkspaceService{{Dir: "apps/program"}}})
writeFile(t, config, string(raw))
before := snapshot(t, root)
for _, op := range []string{"format", "generate", "check"} {
if err := Workspace(config, op, io.Discard); err != nil {
t.Fatal(op, err)
}
}
if !sameFiles(before, snapshot(t, root)) {
t.Fatal("plain workspace changed")
}
}

View File

@@ -25,7 +25,7 @@ import (
) )
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni" const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
const currentVersion = "v0.28.0-preview" const currentVersion = "v0.31.0-preview"
const minimumVersion = "v0.28.0-preview" const minimumVersion = "v0.28.0-preview"
const manifestPath = ".goOmni/projectManifest.json" const manifestPath = ".goOmni/projectManifest.json"
const lockPath = ".goOmni/project.lock" const lockPath = ".goOmni/project.lock"

View File

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

View File

@@ -0,0 +1,162 @@
package main
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
// The same consumer is exercised with a source-built CLI before release and a
// downloaded CLI after release. Project modules are always independently built.
func TestMixedWorkspaceConsumer(t *testing.T) {
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
}
root := t.TempDir()
cli := os.Getenv("GO_OMNI_RELEASE_CLI")
local := cli == ""
framework, err := filepath.Abs("../..")
if err != nil {
t.Fatal(err)
}
if local {
cli = filepath.Join(t.TempDir(), "go-omni")
workspaceCommand(t, "", nil, false, "go", "build", "-o", cli, ".")
}
modulePath := "git.yuntuo.hk/k2safe/go-omni"
for _, kind := range []string{"api", "project"} {
dir := filepath.Join(root, kind)
args := []string{kind, "new", dir, "--module", "example.com/" + kind}
if local {
args = append(args, "--framework-dir", framework)
}
workspaceCommand(t, root, nil, false, cli, args...)
workspaceCommand(t, dir, nil, false, "go", "mod", "tidy")
data := workspaceCommand(t, dir, nil, false, "go", "list", "-m", "-json", modulePath)
if !local {
var dep struct {
Version, Sum string
Replace any
}
if err := json.Unmarshal([]byte(data), &dep); err != nil {
t.Fatal(err)
}
if dep.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dep.Replace != nil || dep.Sum == "" {
t.Fatal("consumer is not a fixed remote dependency", dep)
}
t.Logf("%s remote framework %s checksum %s; no local replace", kind, dep.Version, dep.Sum)
}
}
write := func(name, data string) {
t.Helper()
if err := os.WriteFile(filepath.Join(root, name), []byte(data), 0644); err != nil {
t.Fatal(err)
}
}
write("omni.workspace.json", `{"services":[{"dir":"api","kind":"api"},{"dir":"project","kind":"project"}]}`)
for _, op := range []string{"format", "generate", "check"} {
output := workspaceCommand(t, root, nil, false, cli, "workspace", op)
if op == "format" && !strings.Contains(output, "SKIP ") {
t.Fatal("missing project format skip", output)
}
}
// Run the actual Go multi-module toolchain independently of generation.
workspaceCommand(t, root, nil, false, "go", "work", "init", "./api", "./project")
work, err := filepath.EvalSymlinks(filepath.Join(root, "go.work"))
if err != nil {
t.Fatal(err)
}
workspaceCommand(t, root, []string{"GOWORK=" + work}, false, "go", "test", "-race", "./api/...", "./project/...")
workspaceCommand(t, root, []string{"GOWORK=" + work}, false, "go", "build", "./api/...", "./project/...")
before := workspaceSnapshot(t, root)
workspaceCommand(t, root, nil, false, cli, "workspace", "check")
if !reflect.DeepEqual(before, workspaceSnapshot(t, root)) {
t.Fatal("check modified Go workspace/module files")
}
// Omitted kinds discover both released manifests as well.
write("omni.workspace.json", `{"services":[{"dir":"api"},{"dir":"project"}]}`)
workspaceCommand(t, root, nil, false, cli, "workspace", "check")
originals := map[string]string{}
for _, kind := range []string{"api", "project"} {
name := kind + "/main.go"
data, err := os.ReadFile(filepath.Join(root, name))
if err != nil {
t.Fatal(err)
}
originals[name] = string(data)
write(name, string(data)+"\n// intentional drift\n")
}
before = workspaceSnapshot(t, root)
output := workspaceCommand(t, root, nil, true, cli, "workspace", "check")
if strings.Count(output, "FAIL ") != 2 {
t.Fatal("did not aggregate both projects", output)
}
if !reflect.DeepEqual(before, workspaceSnapshot(t, root)) {
t.Fatal("failed check wrote files")
}
// The healthy project's missing output must stay missing when another fails.
write("api/main.go", originals["api/main.go"])
if err := os.Remove(filepath.Join(root, "api/main.go")); err != nil {
t.Fatal(err)
}
before = workspaceSnapshot(t, root)
workspaceCommand(t, root, nil, true, cli, "workspace", "generate")
if !reflect.DeepEqual(before, workspaceSnapshot(t, root)) {
t.Fatal("partial workspace generation")
}
write("project/main.go", originals["project/main.go"])
workspaceCommand(t, root, nil, false, cli, "workspace", "generate")
workspaceCommand(t, root, nil, false, cli, "workspace", "check")
}
func workspaceCommand(t *testing.T, dir string, extra []string, wantFailure bool, name string, args ...string) string {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, name, args...)
if dir != "" {
cmd.Dir = dir
}
cmd.Env = append(append(os.Environ(), "GOWORK=off"), extra...)
data, err := cmd.CombinedOutput()
if wantFailure {
exit, ok := err.(*exec.ExitError)
if !ok || exit.ExitCode() != 1 {
t.Fatalf("expected exit 1 from %s %v: %v\n%s", name, args, err, data)
}
} else if err != nil {
t.Fatalf("%s %v: %v\n%s", name, args, err, data)
}
t.Logf("%s %v\n%s", name, args, data)
return string(data)
}
func workspaceSnapshot(t *testing.T, root string) map[string]string {
t.Helper()
files := map[string]string{}
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
data, e := os.ReadFile(path)
if e != nil {
return e
}
rel, e := filepath.Rel(root, path)
if e != nil {
return e
}
files[rel] = string(data)
}
return nil
})
if err != nil {
t.Fatal(err)
}
return files
}

View File

@@ -28,3 +28,5 @@ overrides it. Application transport read timeouts still control slow clients.
See [API.md](../API.md) for source tags, validation rules, null/presence behavior, See [API.md](../API.md) for source tags, validation rules, null/presence behavior,
strict decoding and supported type limits. This API leaves the existing strict decoding and supported type limits. This API leaves the existing
`Context.BindJSON/BindQuery/BindURI` behavior unchanged. `Context.BindJSON/BindQuery/BindURI` behavior unchanged.
Recursive response DTOs in `.api` are compiled by the CLI as named OpenAPI references; see [the response guide](../DEVELOPMENT.md#递归响应-dto). This does not change the nonrecursive request type requirement of `New`, `ForType`, or `Describe`.

View File

@@ -13,7 +13,7 @@
"generated": { "generated": {
"docs/openapi.json": "0370fd6bd6a387e2ef21f6b42b009ce66749846b8bea3d481009a34f3af039a5", "docs/openapi.json": "0370fd6bd6a387e2ef21f6b42b009ce66749846b8bea3d481009a34f3af039a5",
"internal/config/loadGen.go": "d85e3123e4c96c95cb0886e60e4760230426efca4d2bbbfc981822da08b90d13", "internal/config/loadGen.go": "d85e3123e4c96c95cb0886e60e4760230426efca4d2bbbfc981822da08b90d13",
"internal/handler/appGen.go": "f5251b4dc33e2312b0bdb71ead70d2171977d851265e124f640458d705a2cc9d", "internal/handler/appGen.go": "66e54c8f60fc273b4dc856c9578390b15604d99ad2e581a1899cc4b7ebb70f3b",
"internal/handler/demo/handlersGen.go": "3c76d85dbe89eb5127d9d134dc625203ecf9719dd11d174c1f335691b99e1375", "internal/handler/demo/handlersGen.go": "3c76d85dbe89eb5127d9d134dc625203ecf9719dd11d174c1f335691b99e1375",
"internal/handler/openapiGen.go": "f24cab3abbb8557a3fc4dd93fba23cc0b093f3e0c944d3ba1bd5b0eeff2d96f3", "internal/handler/openapiGen.go": "f24cab3abbb8557a3fc4dd93fba23cc0b093f3e0c944d3ba1bd5b0eeff2d96f3",
"internal/handler/responseGen.go": "84397a502bba4777ebfa6fae3d3bc418297acab4f26b04b42e3c31b36c735399", "internal/handler/responseGen.go": "84397a502bba4777ebfa6fae3d3bc418297acab4f26b04b42e3c31b36c735399",

View File

@@ -2,6 +2,7 @@
package handler package handler
import ( import (
"errors"
"fmt" "fmt"
omni "git.yuntuo.hk/k2safe/go-omni" omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/contract" "git.yuntuo.hk/k2safe/go-omni/contract"
@@ -66,5 +67,11 @@ func NewApp(deps *svc.ServiceContext) (*omni.App, error) {
} }
app.GET(cfg.Endpoints.Readiness.Path, deps.Health.ReadinessHandler(options...)) app.GET(cfg.Endpoints.Readiness.Path, deps.Health.ReadinessHandler(options...))
} }
// Optional project-owned extension; no generated file needs hand edits.
if extension, ok := any(deps).(interface{ RegisterCustomRoutes(*omni.App) error }); ok {
if err := extension.RegisterCustomRoutes(app); err != nil {
return nil, errors.Join(err, app.Close())
}
}
return app, nil return app, nil
} }

View File

@@ -0,0 +1,39 @@
# API 与通用工程混合工作区
本例只定义生成工程的类型与目录,不包含业务模型、外部服务或特定调度方式。使用 v0.31.0-preview 或更新版本 CLI。
下面从一个空目录创建两个独立 Go module;API 工程来自 API 模板,通用工程默认不监听端口。
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.31.0-preview
mkdir mixed-demo
cd mixed-demo
go-omni api new apps/interface --module example.com/interface
go-omni project new apps/program --module example.com/program
```
将本目录的 `omni.workspace.json` 复制到 `mixed-demo` 根目录,然后执行:
```sh
go-omni workspace format # 格式化 API 契约;通用工程显示 SKIP
go-omni workspace generate
go-omni workspace check
```
也可以省略两个 `kind`:CLI 根据各工程的生成清单识别。显式声明与清单冲突会报错;不会根据目录名称猜测类型。
Go 多模块编译由 Go 工具链独立管理:
```sh
(cd apps/interface && go mod tidy)
(cd apps/program && go mod tidy)
go work init ./apps/interface ./apps/program
go test ./apps/interface/... ./apps/program/...
go build ./apps/interface/... ./apps/program/...
```
workspace check 不修改 `go.work`、`go.mod`、`go.sum` 或项目源文件;不启动应用。若显式配置了共享错误目录提供方,仍会按既有约定构建并调用该 Go 导出函数,见 [目录导出规则](../../DEVELOPMENT.md#同一份-go-错误目录供多个服务生成)。
手工修改任一生成的 `main.go` 后,workspace check 会报告对应工程失败并返回非零退出码;两个工程都存在问题时,会汇总报告。workspace generate 先预检所有工程,再进行各自的文件事务;一个预检失败时,其他工程也不写入。不承诺多个模块之间的崩溃事务性。
本例命令与 `go.work` 编译流程由 `cmd/go-omni/TestMixedWorkspaceConsumer` 在本地和远程发布验收中执行。

View File

@@ -0,0 +1,6 @@
{
"services": [
{ "dir": "apps/interface", "kind": "api" },
{ "dir": "apps/program", "kind": "project" }
]
}

View File

@@ -0,0 +1,32 @@
# 递归响应类型
通用节点、树集合和相互引用对象的响应示例。没有数据库、权限或业务服务依赖。
在 go-omni 仓库根目录执行(新建独立临时目录):
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.31.0-preview
example_dir="$(mktemp -d)/recursive"
go-omni api generate --api examples/recursiveResponse/service.api --dir "$example_dir" --module example.com/recursive
# 以下文件仅是本次新生成示例的占位实现,替换为示例自身维护的逻辑。
for name in tree forest optional raw pair; do
printf 'package trees\n' > "$example_dir/internal/logic/trees/$name.go"
done
cp examples/recursiveResponse/operations.go.txt "$example_dir/internal/logic/trees/operations.go"
cd "$example_dir"
go mod tidy
go-omni api generate --check
go test ./...
go run .
```
按启动日志显示的监听地址访问 `/tree`、`/forest`、`/optional`、`/raw`、`/pair`。前三层节点通过 `[]types.Node` 编译和返回;`/optional` 返回 null,`/raw` 直接返回节点,`/pair` 返回相互引用类型的有限实例。`docs/openapi.json` 中的 `DTO_Node` 通过 `$ref` 引用自身,`DTO_Branch`、`DTO_Leaf` 互相引用。
嵌套集合遵循普通 Go JSON 规则,示例将叶节点的 Children 初始化为空切片,使其输出 `[]`;框架不会递归改写项目返回数据。业务数据不能包含实际指针环。
框架开发时可在首次生成命令后加 `--framework-dir .` 使用本地代码。回归同时覆盖本地与已发布远程 CLI、真实 HTTP 响应、生成幂等与只读漂移检查:
```sh
# 回到 go-omni 仓库根目录执行
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -count=1 ./cmd/go-omni/apigen -run Recursive
```

View File

@@ -0,0 +1,22 @@
package trees
import (
"context"
"example.com/recursive/internal/types"
)
func sample() types.Node {
return types.Node{Name: "root", Children: []types.Node{
{Name: "child", Children: []types.Node{
{Name: "leaf", Children: []types.Node{}},
}},
}, Lookup: map[string]types.Node{"entry": {Name: "lookup", Children: []types.Node{}}}}
}
func (s *Logic) Tree(context.Context) (*types.Node, error) { node := sample(); return &node, nil }
func (s *Logic) Forest(context.Context) ([]types.Node, error) { return []types.Node{sample()}, nil }
func (s *Logic) Optional(context.Context) (*types.Node, error) { return nil, nil }
func (s *Logic) Raw(context.Context) (*types.Node, error) { node := sample(); return &node, nil }
func (s *Logic) Pair(context.Context) (*types.Branch, error) {
return &types.Branch{Leaf: &types.Leaf{}}, nil
}

View File

@@ -0,0 +1,33 @@
type Node {
Name string `json:"name" binding:"min=1"`
Children []Node `json:"children" binding:"max=100"`
Next *Node `json:"next,omitempty"`
Lookup map[string]Node `json:"lookup,omitempty"`
}
type Branch {
Leaf *Leaf `json:"leaf"`
}
type Leaf {
Branch *Branch `json:"branch"`
}
@server (group: trees)
service recursive-example {
@handler tree
get /tree returns (Node)
@handler forest
get /forest returns ([]Node)
@handler optional
get /optional returns (*Node)
@handler raw
@response raw
get /raw returns (Node)
@handler pair
get /pair returns (Branch)
}

View File

@@ -0,0 +1,15 @@
# 通用签名读取
```sh
go run ./examples/signedRead
# 或执行真实 HTTP 检查并退出
go run ./examples/signedRead -check
```
示例自动创建临时私有存储、一页 PDF 和随机签名密钥,监听随机 loopback 端口。打开终端显示的首页,可预览或下载带中文文件名的 PDF;两条链接从签发起 30 分钟有效。首页显示签名内的实际到期时间,不会通过刷新延长链接;重启会生成新的文件和密钥。Ctrl+C 清理临时目录。
示例使用 `storage.ReadPresigner` 对应的 `PresignGetWithOptions` 能力,本地 `Signer` 与 S3/R2、OSS 适配器采用同一组 `ReadOptions`。省略 Disposition 默认 attachment,inline 请求浏览器内联展示;实际展示仍由浏览器设置和文件格式支持决定。原 `PresignGet` 接口与既有下载链接保持兼容。
本地支持 GET、HEAD、Range、206/416,响应范围只包含文件内容。云端由原生存储地址响应,GET 签名不通用于 HEAD;OSS 默认域名可能强制下载,应在 SDK 签名前配置自己的域名。详见 [存储接入说明](../../storage/README.md#签名预览与下载)。
这只是回环地址上的固定测试文件演示。实际项目先检查权限,再签发指定对象的链接;配置公开签名入口,不能从请求 Host 或转发头猜测。文件路径、权限、审计和页面状态由项目维护。不要记录 URL 查询参数,不公开存储目录。链接到期仅阻止后续读取,不收回已获得的内容;重新签发不会撤销旧链接。

183
examples/signedRead/main.go Normal file
View File

@@ -0,0 +1,183 @@
// signedRead is an offline, loopback-only preview/download demonstration.
package main
import (
"bytes"
"context"
"crypto/rand"
"errors"
"flag"
"fmt"
"html/template"
"io"
"mime"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"time"
"git.yuntuo.hk/k2safe/go-omni/storage"
"git.yuntuo.hk/k2safe/go-omni/storage/local"
)
func main() {
check := flag.Bool("check", false, "run HTTP checks and exit")
flag.Parse()
if err := run(*check); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(check bool) error {
root, err := os.MkdirTemp("", "go-omni-signed-read-")
if err != nil {
return err
}
defer os.RemoveAll(root)
files, err := local.New(root, 1<<20)
if err != nil {
return err
}
content := samplePDF()
if _, err = files.Put(context.Background(), "sample.pdf", bytes.NewReader(content), int64(len(content)), "application/pdf"); err != nil {
return err
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return err
}
defer listener.Close()
baseURL := "http://" + listener.Addr().String()
secret := make([]byte, 32)
if _, err = rand.Read(secret); err != nil {
return err
}
signer, err := storage.NewSigner(files, baseURL+"/file", secret)
if err != nil {
return err
}
// Production applications authorize the caller before this operation. The
// sample intentionally exposes only a generated fixture on loopback.
preview, err := signer.PresignGetWithOptions(context.Background(), "sample.pdf", 30*time.Minute, storage.ReadOptions{
Disposition: storage.DispositionInline, FileName: "示例报告.pdf", ContentType: "application/pdf",
})
if err != nil {
return err
}
download, err := signer.PresignGetWithOptions(context.Background(), "sample.pdf", 30*time.Minute, storage.ReadOptions{FileName: "示例报告.pdf"})
if err != nil {
return err
}
page := template.Must(template.New("page").Parse(`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>Signed read example</title>
<body><h1>文件预览与下载</h1><p><a href="{{.Preview.URL}}">打开示例 PDF</a> · <a href="{{.Download.URL}}">下载示例 PDF</a></p>
<p>链接到期时间:{{.Preview.ExpiresAt}}</p><p>只读演示:无数据库、业务身份或公开磁盘目录。</p></body></html>`))
mux := http.NewServeMux()
mux.Handle("/file", signer)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" || r.Method != http.MethodGet {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "private, no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
_ = page.Execute(w, struct{ Preview, Download storage.ReadLink }{preview, download})
})
server := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 30 * time.Second}
done := make(chan error, 1)
go func() { done <- server.Serve(listener) }()
defer server.Close()
if check {
if err := checkHTTP(preview, download, content); err != nil {
return err
}
fmt.Println("signed read: PDF preview, Chinese filename, download, HEAD and Range passed")
return nil
}
// Never print signed URLs or enable request logging containing query strings.
fmt.Println("Open " + baseURL + " (Ctrl+C to stop)")
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
select {
case err := <-done:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return server.Shutdown(shutdown)
}
}
func checkHTTP(preview, download storage.ReadLink, content []byte) error {
client := &http.Client{Timeout: 5 * time.Second}
for _, tc := range []struct {
link storage.ReadLink
method, span, disposition string
status int
body []byte
}{
{preview, "GET", "", "inline", 200, content},
{preview, "HEAD", "", "inline", 200, nil},
{preview, "GET", "bytes=0-7", "inline", 206, content[:8]},
{download, "GET", "", "attachment", 200, content},
} {
req, err := http.NewRequest(tc.method, tc.link.URL, nil)
if err != nil {
return errors.New("invalid example link")
}
if tc.span != "" {
req.Header.Set("Range", tc.span)
}
response, err := client.Do(req)
if err != nil {
return errors.New("example request failed")
}
body, err := io.ReadAll(response.Body)
response.Body.Close()
kind, params, headerErr := mime.ParseMediaType(response.Header.Get("Content-Disposition"))
if err != nil || headerErr != nil || response.StatusCode != tc.status || !bytes.Equal(body, tc.body) || kind != tc.disposition || params["filename"] != "示例报告.pdf" || response.Header.Get("Content-Type") != "application/pdf" {
return errors.New("example response did not match signed options")
}
size := len(tc.body)
if tc.method == "HEAD" {
size = len(content)
}
if response.Header.Get("Content-Length") != strconv.Itoa(size) || response.Header.Get("Cache-Control") != "private, no-store" {
return errors.New("example size or cache policy mismatch")
}
}
return nil
}
// A complete, single-page PDF with a real cross-reference table. No generator
// dependency or business document is needed for this example.
func samplePDF() []byte {
stream := "BT /F1 24 Tf 72 720 Td (go-omni signed read) Tj ET\n"
objects := []string{
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>",
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
fmt.Sprintf("<< /Length %d >>\nstream\n%sendstream", len(stream), stream),
}
var pdf bytes.Buffer
pdf.WriteString("%PDF-1.4\n")
offsets := []int{0}
for i, object := range objects {
offsets = append(offsets, pdf.Len())
fmt.Fprintf(&pdf, "%d 0 obj\n%s\nendobj\n", i+1, object)
}
xref := pdf.Len()
fmt.Fprintf(&pdf, "xref\n0 %d\n0000000000 65535 f \n", len(offsets))
for _, offset := range offsets[1:] {
fmt.Fprintf(&pdf, "%010d 00000 n \n", offset)
}
fmt.Fprintf(&pdf, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets), xref)
return pdf.Bytes()
}

View File

@@ -0,0 +1,9 @@
package main
import "testing"
func TestSignedReadHTTP(t *testing.T) {
if err := run(true); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,11 @@
# 配置化网关 Trace Header
```sh
go run ./examples/telemetryHeaders
```
这个离线示例使用标准库 `httptest`,不连接网关、数据库或外部 Collector。它把 `X-Trace-Id`、`X-Span-Id` 和 `X-Request-Id` 配置给通用 `telemetry.HeaderPropagator`,再通过 `app.Use(runtime.Middleware())` 进入服务。业务 Handler 只读取 `logx.TraceID` / `logx.RequestID`,不解析 Header。
`HeaderPropagator` 同时支持标准 W3C `traceparent`;两种格式同时存在时优先 W3C。出站请求使用 `telemetry.Transport` 时会注入 W3C 和配置的兼容 Header。旧项目不配置它时,go-omni 仍只使用默认 W3C 传播。
示例中的 ID 是固定测试值。生产环境只有在网关或受信代理已经校验这些 Header 时才启用兼容传播;Trace ID/Span ID 不是认证凭据,也不能用于授权、幂等或租户隔离。自定义 Header 不携带采样标志,适配器会把有效的成对 ID 视为已采样;需要保留上游采样决定时应使用 `traceparent`。

View File

@@ -0,0 +1,62 @@
// telemetryHeaders demonstrates a provider-neutral compatibility boundary for
// gateways that carry trace IDs in configurable HTTP headers.
package main
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/telemetry"
)
func main() {
if err := run(); err != nil {
panic(err)
}
}
func run() error {
propagator, err := telemetry.NewHeaderPropagator(telemetry.HeaderPropagationConfig{
TraceIDHeader: "X-Trace-Id", SpanIDHeader: "X-Span-Id", RequestIDHeader: "X-Request-Id",
})
if err != nil {
return err
}
runtime, err := telemetry.New(telemetry.Config{Service: "header-example", SampleRatio: 1, Propagator: propagator})
if err != nil {
return err
}
defer runtime.Close()
app := omni.New(omni.WithLogger(false), omni.WithRequestIDHeader("X-Request-Id"))
defer app.Close()
app.Use(runtime.Middleware())
app.GET("/", func(c *omni.Context) error {
fmt.Printf("traceId=%s requestId=%s\n", logx.TraceID(c.Context()), logx.RequestID(c.Context()))
return c.OK(map[string]string{"traceId": logx.TraceID(c.Context()), "requestId": logx.RequestID(c.Context())})
})
server := httptest.NewServer(app)
defer server.Close()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL, nil)
if err != nil {
return err
}
req.Header.Set("X-Trace-Id", "11111111111111111111111111111111")
req.Header.Set("X-Span-Id", "2222222222222222")
req.Header.Set("X-Request-Id", "example-request")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
fmt.Printf("status=%d response=%s", resp.StatusCode, body)
return nil
}

View File

@@ -0,0 +1,9 @@
package main
import "testing"
func TestTelemetryHeadersExample(t *testing.T) {
if err := run(); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,17 @@
# WebSocket 通用示例
不依赖外部基础设施,默认在随机本机端口启动 GoOmni Server、Go Client 和 Hub,完成文本回显、分组广播后排空退出:
```sh
go run ./examples/websocket
```
预期输出 `hello websocket`、`hello group`。浏览器标准 WebSocket 示例:
```sh
go run ./examples/websocket -serve
```
打开 http://127.0.0.1:8088 的两个标签页,点击连接、发送、广播。Ctrl+C 触发 HTTP 排空、WS Stop/Drain、Hub 关闭。示例只监听 loopback,无鉴权;接入项目在升级前验证自己的身份和分组权限。浏览器使用同源策略;跨域部署通过明确的 `AllowedOrigins` 配置。
完整 API、限制与安全说明见 [ws](../../ws/README.md);双向调用与重连订阅见 [websocketRPC](../websocketRPC/README.md)。

View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>GoOmni WebSocket 示例</title>
<style>body{font:16px system-ui;max-width:720px;margin:60px auto;padding:0 24px;color:#182b3d}button,input{font:inherit;padding:10px;margin:4px}pre{white-space:pre-wrap;background:#f2f5f8;padding:20px;min-height:180px}#state{color:#52697b}</style>
<h1>WebSocket 回显与分组广播</h1><p>打开两个标签页,连接后发送文本或广播。</p>
<p id="state">未连接</p><button id="connect">连接</button><button id="disconnect">断开</button>
<p><input id="message" value="你好 WebSocket" aria-label="消息"><button id="send">发送</button><button id="broadcast">广播</button></p><pre id="log"></pre>
<script>
let socket; const log=document.querySelector('#log'),state=document.querySelector('#state');
function append(text){log.textContent=(log.textContent+text+'\n').slice(-12000)}
document.querySelector('#connect').onclick=()=>{if(socket&&socket.readyState<2)return;socket=new WebSocket((location.protocol==='https:'?'wss://':'ws://')+location.host+'/ws');socket.onopen=()=>state.textContent='已连接';socket.onmessage=e=>append('收到:'+e.data);socket.onclose=e=>state.textContent='已断开,关闭码 '+e.code;socket.onerror=()=>append('连接发生错误')};
function send(value){if(socket?.readyState===WebSocket.OPEN){socket.send(value);append('发送:'+value)}else append('请先连接')}
document.querySelector('#send').onclick=()=>send(document.querySelector('#message').value);
document.querySelector('#broadcast').onclick=()=>send('broadcast');
document.querySelector('#disconnect').onclick=()=>socket?.close(1000,'');
</script></html>

107
examples/websocket/main.go Normal file
View File

@@ -0,0 +1,107 @@
package main
import (
"context"
_ "embed"
"errors"
"flag"
"fmt"
"net/http/httptest"
"os"
"strings"
"time"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/ws"
"git.yuntuo.hk/k2safe/go-omni/ws/hub"
wsomni "git.yuntuo.hk/k2safe/go-omni/ws/omni"
)
//go:embed index.html
var page []byte
func main() {
serve := flag.Bool("serve", false, "serve the browser demo on 127.0.0.1:8088")
flag.Parse()
if err := run(*serve); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(serve bool) (runErr error) {
application := omni.New(omni.WithLogger(false))
defer func() { runErr = errors.Join(runErr, application.Close()) }()
connections, err := hub.New(hub.Config{})
if err != nil {
return err
}
application.OnShutdown(connections.Close)
server, err := ws.NewServer(ws.ServerConfig{})
if err != nil {
return err
}
if err = wsomni.Mount(application, "/ws", server, ws.Handlers{
OnOpen: func(_ context.Context, s *ws.Session) error {
if err := connections.Add(s); err != nil {
return err
}
return connections.Join(s.ID(), "demo")
},
OnMessage: func(ctx context.Context, s *ws.Session, m ws.Message) error {
if m.Type == ws.Text && string(m.Data) == "broadcast" {
result, err := connections.SendGroup("demo", ws.Message{Type: ws.Text, Data: []byte("hello group")})
if err != nil {
return err
}
if len(result.Errors) > 0 {
return s.SendJSON(ctx, map[string]int{"queued": result.Queued, "failed": len(result.Errors)})
}
return nil
}
return s.Send(ctx, m)
},
}); err != nil {
return err
}
application.GET("/", func(c *httpx.Context) error {
c.Raw().Header().Set("Content-Type", "text/html; charset=utf-8")
_, err := c.Raw().Write(page)
return err
})
if serve {
fmt.Println("Open http://127.0.0.1:8088 in two browser tabs; Ctrl+C stops cleanly.")
return application.Run("127.0.0.1:8088")
}
httpServer := httptest.NewServer(application)
defer httpServer.Close()
client, err := ws.NewClient(ws.ClientConfig{ProxyMode: "direct"})
if err != nil {
return err
}
defer func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
runErr = errors.Join(runErr, client.Shutdown(ctx))
}()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
replies := make(chan string, 2)
session, err := client.Dial(ctx, "ws"+strings.TrimPrefix(httpServer.URL, "http")+"/ws", ws.Handlers{OnMessage: func(_ context.Context, _ *ws.Session, m ws.Message) error { replies <- string(m.Data); return nil }})
if err != nil {
return err
}
for _, message := range []string{"hello websocket", "broadcast"} {
if err = session.Send(ctx, ws.Message{Type: ws.Text, Data: []byte(message)}); err != nil {
return err
}
select {
case reply := <-replies:
fmt.Println(reply)
case <-ctx.Done():
return ctx.Err()
}
}
return nil
}

View File

@@ -0,0 +1,16 @@
# 双向 RPC 与订阅恢复
从仓库根目录运行,无外部服务、数据库或凭证:
```sh
go run ./examples/websocketRPC
go test -race ./examples/websocketRPC
```
示例在本地随机端口启动 HTTP/1.1 WebSocket 服务,客户端与服务端协商 `go-omni.rpc.v1`。每条连接创建独立 Peer:客户端调用 `double`,服务端处理 `identify` 时反向调用客户端的 `client.name`。随后客户端恢复 `updates` 订阅,服务端通过进程内 Hub 广播 RPC 事件;完成取消订阅和关闭后,用新连接再次执行恢复。
每代输出包含 `double=42`、反向调用返回值、`restored=1` 和对应事件序号。两次连接之间保留的是本地期望订阅,不是历史事件。示例显式创建第二条连接,方便验证每代 Peer 和订阅恢复的责任边界;自动网络重连使用 `ws.Client.Run` 与 `Prepare` 创建新 Peer,再从 `OnOpen` 外等待 `WaitReady` 并恢复订阅。
示例的订阅授权只允许公开的 `updates` 主题。生产项目在握手 `Prepare` 验证身份,每次订阅/恢复时检查具体主题权限。服务器注册订阅路由时通过连接 ID 加入 Hub;JSON 中的 key 不自动构成授权。没有离线补发、自动重试业务调用或持久化投递。
关闭 Client/Server 都使用独立、有界 context 并检查错误。RPC 的 OnClose 回调纳入实际业务任务完成屏障,示例的包装保留了该回调。完整协议、配置和退出语义见 [ws/rpc](../../ws/rpc/README.md)。

View File

@@ -0,0 +1,200 @@
// websocketRPC demonstrates bidirectional calls and explicit subscription
// restoration over two successive real TCP connections, without external services.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"time"
"git.yuntuo.hk/k2safe/go-omni/ws"
"git.yuntuo.hk/k2safe/go-omni/ws/hub"
"git.yuntuo.hk/k2safe/go-omni/ws/rpc"
)
func main() {
if err := run(); err != nil {
panic(err)
}
}
func run() (runErr error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
groups, err := hub.New(hub.Config{})
if err != nil {
return err
}
defer groups.Close()
server, err := ws.NewServer(ws.ServerConfig{
Context: ctx, Subprotocols: []string{rpc.Subprotocol}, RequireSubprotocol: true,
Prepare: func(context.Context, *http.Request) (ws.Handlers, error) {
peer, err := rpc.New(rpc.Config{})
if err != nil {
return ws.Handlers{}, err
}
var session *ws.Session
if err = peer.Handle("double", func(_ context.Context, raw json.RawMessage) (any, error) {
var n int
if json.Unmarshal(raw, &n) != nil {
return nil, &rpc.Error{Code: "INVALID_INPUT", Message: "integer required"}
}
return n * 2, nil
}); err != nil {
return ws.Handlers{}, err
}
if err = peer.Handle("identify", func(ctx context.Context, _ json.RawMessage) (any, error) {
var name string
err := peer.Call(ctx, "client.name", nil, &name)
return name, err
}); err != nil {
return ws.Handlers{}, err
}
if err = peer.Handle("rpc.subscribe", func(_ context.Context, raw json.RawMessage) (any, error) {
var sub rpc.Subscription
if json.Unmarshal(raw, &sub) != nil {
return nil, &rpc.Error{Code: "INVALID_INPUT", Message: "invalid subscription"}
}
// Demo policy. A real project authenticates in Prepare, then authorizes the
// requested topic against that verified identity on every subscription.
if sub.Key != "updates" {
return nil, &rpc.Error{Code: "DENIED", Message: "topic not allowed"}
}
return nil, groups.Join(session.ID(), sub.Key)
}); err != nil {
return ws.Handlers{}, err
}
if err = peer.Handle("rpc.unsubscribe", func(_ context.Context, raw json.RawMessage) (any, error) {
var sub struct {
Key string `json:"key"`
}
if json.Unmarshal(raw, &sub) != nil {
return nil, &rpc.Error{Code: "INVALID_INPUT", Message: "invalid subscription"}
}
groups.Leave(session.ID(), sub.Key)
return nil, nil
}); err != nil {
return ws.Handlers{}, err
}
handlers := peer.Handlers()
open := handlers.OnOpen
handlers.OnOpen = func(ctx context.Context, s *ws.Session) error {
session = s
if err := open(ctx, s); err != nil {
return err
}
return groups.Add(s)
}
closeHandler := handlers.OnClose
handlers.OnClose = func(s *ws.Session, err error) { closeHandler(s, err); groups.Remove(s.ID()) }
return handlers, nil
},
})
if err != nil {
return err
}
httpServer := httptest.NewServer(server.Handler(ws.Handlers{}))
defer httpServer.Close()
defer func() {
stop, done := context.WithTimeout(context.Background(), 2*time.Second)
defer done()
runErr = errors.Join(runErr, server.Shutdown(stop))
}()
client, err := ws.NewClient(ws.ClientConfig{ProxyMode: "direct", Subprotocols: []string{rpc.Subprotocol}, RequireSubprotocol: true})
if err != nil {
return err
}
defer func() {
stop, done := context.WithTimeout(context.Background(), 2*time.Second)
defer done()
runErr = errors.Join(runErr, client.Shutdown(stop))
}()
subscriptions, err := rpc.NewSubscriptions(rpc.SubscriptionConfig{})
if err != nil {
return err
}
if err = subscriptions.Subscribe(ctx, nil, "updates", nil); err != nil {
return err
}
endpoint := "ws" + strings.TrimPrefix(httpServer.URL, "http")
for generation := 1; generation <= 2; generation++ {
peer, err := rpc.New(rpc.Config{})
if err != nil {
return err
}
events := make(chan int, 1)
if err = peer.Handle("client.name", func(context.Context, json.RawMessage) (any, error) { return "generic Go client", nil }); err != nil {
return err
}
if err = peer.Handle("updates.changed", func(ctx context.Context, raw json.RawMessage) (any, error) {
var v int
if err := json.Unmarshal(raw, &v); err != nil {
return nil, err
}
select {
case events <- v:
return nil, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}); err != nil {
return err
}
session, err := client.Dial(ctx, endpoint, peer.Handlers())
if err != nil {
return err
}
if err = peer.WaitReady(ctx); err != nil {
return err
}
var doubled int
if err = peer.Call(ctx, "double", 21, &doubled); err != nil {
return err
}
var name string
if err = peer.Call(ctx, "identify", nil, &name); err != nil {
return err
}
restored, err := subscriptions.Restore(ctx, peer)
if err != nil {
return err
}
// The documented envelope also permits native browser clients and Hub fanout.
payload, err := json.Marshal(map[string]any{"v": 1, "type": "event", "method": "updates.changed", "params": generation})
if err != nil {
return err
}
sent, err := groups.SendGroup("updates", ws.Message{Type: ws.Text, Data: payload})
if err != nil {
return err
}
if sent.Queued != 1 || len(sent.Errors) > 0 {
return fmt.Errorf("broadcast failed: queued %d of %d", sent.Queued, sent.Attempted)
}
select {
case received := <-events:
fmt.Printf("connection %d: double=%d, remote callback=%q, restored=%d, event=%d\n", generation, doubled, name, restored, received)
case <-ctx.Done():
return ctx.Err()
}
if err = subscriptions.Unsubscribe(ctx, peer, "updates"); err != nil {
return err
}
// Keep desired state separately for the next physical connection.
if err = subscriptions.Subscribe(ctx, nil, "updates", nil); err != nil {
return err
}
if err = session.Close(1000, ""); err != nil {
return err
}
if err = session.Wait(ctx); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,9 @@
package main
import "testing"
func TestBidirectionalRPCAndRestore(t *testing.T) {
if err := run(); err != nil {
t.Fatal(err)
}
}

1
go.mod
View File

@@ -8,6 +8,7 @@ require (
github.com/go-sql-driver/mysql v1.9.3 github.com/go-sql-driver/mysql v1.9.3
github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golang-migrate/migrate/v4 v4.18.1 github.com/golang-migrate/migrate/v4 v4.18.1
github.com/gorilla/websocket v1.5.3
github.com/minio/minio-go/v7 v7.0.83 github.com/minio/minio-go/v7 v7.0.83
github.com/redis/go-redis/v9 v9.17.3 github.com/redis/go-redis/v9 v9.17.3
go.opentelemetry.io/otel v1.31.0 go.opentelemetry.io/otel v1.31.0

2
go.sum
View File

@@ -62,6 +62,8 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=

View File

@@ -275,6 +275,14 @@ func (c *Context) Status() int {
// Responded reports whether this handler has already written a response. // Responded reports whether this handler has already written a response.
func (c *Context) Responded() bool { return c.responded } func (c *Context) Responded() bool { return c.responded }
// MarkUpgraded records a successful WebSocket HTTP/1.1 upgrade whose 101 bytes
// were written directly to a hijacked connection. Call only after a successful
// upgrade; it does not write headers or upgrade a connection itself.
func (c *Context) MarkUpgraded() {
c.responded = true
c.status = http.StatusSwitchingProtocols
}
// WriteError writes a stable public error response. Internal causes are not // WriteError writes a stable public error response. Internal causes are not
// serialized. Responses committed through Context methods or Raw are preserved. // serialized. Responses committed through Context methods or Raw are preserved.
func (c *Context) WriteError(err error) { func (c *Context) WriteError(err error) {

View File

@@ -1,5 +1,11 @@
# lifecycle # lifecycle
## 独立 App 的后台组件
手写 `omni.New()` 应用也可以通过 `app.OnStop(name,hook)` / `app.OnDrain(name,hook)` 登记后台组件。HTTP Handler 结束后先运行 Stop、再 Drain,最后才执行逆序 OnShutdown。复用 `config.Lifecycle` 的阶段预算与 `ErrResourcesRetained` 规则,组件未退出时保留共享资源。没有登记后台钩子的旧 App 行为不变。
例如 `ws/omni.Mount` 自动登记 WS Server 的 Stop/Drain;生成项目则通常在 svc Runtime 上登记同一对钩子,并通过 `ws/omni.Handler` 注册路由。一个组件只选择一个所有者,不能把依赖释放登记到较早结束的另一个生命周期中。详见 [WS 接入](../ws/README.md#goomni-接入与停机)。
## 后台组件分阶段关闭 ## 后台组件分阶段关闭
从 `v0.15.0-preview` 起,生成项目使用以下顺序:HTTP 停止接收并排空 → 全部 OnStop → 全部 OnDrain → 逆序 OnClose → 关闭日志。初始化、创建 App 或监听失败也经过后台收尾。Runtime 不导入 Orbit、数据库、ORM 或 Redis。 从 `v0.15.0-preview` 起,生成项目使用以下顺序:HTTP 停止接收并排空 → 全部 OnStop → 全部 OnDrain → 逆序 OnClose → 关闭日志。初始化、创建 App 或监听失败也经过后台收尾。Runtime 不导入 Orbit、数据库、ORM 或 Redis。

View File

@@ -17,6 +17,8 @@ import (
) )
type Schema struct { type Schema struct {
Ref string `json:"$ref,omitempty"`
AllOf []*Schema `json:"allOf,omitempty"`
MaxBytes *int64 `json:"x-omni-max-bytes,omitempty"` MaxBytes *int64 `json:"x-omni-max-bytes,omitempty"`
Example any `json:"example,omitempty"` Example any `json:"example,omitempty"`
EnumDescriptions []string `json:"x-enum-descriptions,omitempty"` EnumDescriptions []string `json:"x-enum-descriptions,omitempty"`

View File

@@ -2,6 +2,7 @@ package openapi
import ( import (
"encoding/json" "encoding/json"
"reflect"
"strings" "strings"
"testing" "testing"
@@ -14,6 +15,8 @@ func TestSchemaAdditionalPropertiesAndExactNumbers(t *testing.T) {
`{"type":"object","additionalProperties":true}`, `{"type":"object","additionalProperties":true}`,
`{"type":"object","additionalProperties":{"type":"string"}}`, `{"type":"object","additionalProperties":{"type":"string"}}`,
`{"type":"integer","default":18446744073709551615,"enum":[18446744073709551615]}`, `{"type":"integer","default":18446744073709551615,"enum":[18446744073709551615]}`,
`{"$ref":"#/components/schemas/Node"}`,
`{"description":"Node","allOf":[{"anyOf":[{"$ref":"#/components/schemas/Node"},{"type":"object","nullable":true,"enum":[null]}]}]}`,
} { } {
var s Schema var s Schema
if err := json.Unmarshal([]byte(text), &s); err != nil { if err := json.Unmarshal([]byte(text), &s); err != nil {
@@ -30,6 +33,9 @@ func TestSchemaAdditionalPropertiesAndExactNumbers(t *testing.T) {
if err = json.Unmarshal(b, &roundTrip); err != nil { if err = json.Unmarshal(b, &roundTrip); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if !reflect.DeepEqual(s, roundTrip) {
t.Fatalf("schema round trip changed: %s", b)
}
} }
api, _ := New("Exact numbers", "1") api, _ := New("Exact numbers", "1")
s := &Schema{Type: "integer", Enum: []any{json.Number("18446744073709551615")}} s := &Schema{Type: "integer", Enum: []any{json.Number("18446744073709551615")}}

View File

@@ -0,0 +1,25 @@
# Header 传播适配验收
## 范围
本次为 go-omni 增加通用 `telemetry.HeaderPropagator`。调用方配置 Trace ID、Span ID 和可选 Request ID Header 后,框架负责入站父链路、日志关联、访问日志和出站传播;框架不识别任何网关名称,也不解析业务 Header。
标准 W3C `traceparent` 与兼容 Header 同时出现时优先 W3C。兼容 Header 必须是合法的 32 位 Trace ID 和 16 位 Span ID;旧 Header 没有采样位,合法成对值按已采样处理。Request ID 只用于关联,不参与 Span 父子关系。兼容传播只应放在已校验 Header 的受信入口。
## 验收结果
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -count=1 ./...`:通过,包含生成器、生命周期、存储、i18n、telemetry 和离线示例。
- `go test -race ./telemetry ./examples/telemetryHeaders`:通过。
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 GOTOOLCHAIN=go1.22.12 go test -count=1 ./...`:通过。
- `go vet ./...`、`go build ./...`、`git diff --check`:通过。
- `go run ./examples/telemetryHeaders`:通过,示例打印固定 Trace ID、Request ID 和 JSON 响应。
- 发布后空模块缓存验收:`verifyManagedRelease.py` 通过 110 项,`verifyProgramRelease.py` 通过 30 项,`verifyWorkspaceRelease.py` 通过 16 项;三份结果分别见 `headerPropagationRemote.json`、`headerPropagationProgramRemote.json` 和 `headerPropagationWorkspaceRemote.json`。
完整竞态套件在高并发生成器生命周期用例中出现了已有的时序超时(`TestGeneratedMainLifecycle/drainBeforeClose`、`TestGeneratedBackgroundPhases/nethttp`);单独重跑受影响的 `TestManagedGeneratedProject` 通过,功能定向竞态测试通过,未发现本次 Header 传播相关竞态。
## 代码边界
- 默认 `telemetry.Config` 仍使用 W3C 传播,未配置新适配器的项目行为不变。
- `telemetry.Transport` 在兼容模式下同时注入 W3C 与配置 Header,调用方请求对象不会被修改。
- 无效 ID、重复 Header 名称以及试图覆盖 `traceparent` / `tracestate` 的配置会被拒绝或忽略。
- 不增加认证、租户、幂等、业务重试或网关依赖。

View File

@@ -0,0 +1,74 @@
{
"passed": true,
"frameworkVersion": "v0.30.0-preview",
"frameworkSum": "h1:sUtS24GAmm1QQYYqdV2XWZ3STR2V/KPqtxRcXGokH48=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 21.72,
"tests": [
"TestConflictRollsBackEarlierWrites",
"TestUnsafePaths",
"TestGeneratedProgramConsumer",
"TestOwnershipAndDrift",
"TestRecordedTemplateUpgrade",
"TestProjectGuards/module",
"TestProjectGuards/oldFramework",
"TestProjectGuards/manifest",
"TestProjectGuards/lock",
"TestProjectGuards/otherGenerator",
"TestProjectGuards/symlink",
"TestProjectGuards"
],
"checks": [
"remoteCLIInstall",
"installedVersionDefault",
"generatedConsumerBuildRaceVet",
"defaultNoAPIFiles",
"defaultNoNetworkListener",
"finiteProgram",
"SIGINT",
"SIGTERM",
"initializationFailureAndPanic",
"runErrorPanicGoexit",
"fatalComponentFailure",
"nonzeroProcessFailureAndTimeoutExits",
"partialStartupDraining",
"acknowledgementBeforeResourceClose",
"workContextSurvivesGracefulStop",
"cancellationGrace",
"unresponsiveRunAndBackgroundWork",
"retainedResourcesAndLogger",
"explicitConfigurationOnly",
"checkConfigWithoutDependencies",
"handwrittenFilesPreserved",
"generatedFileConflict",
"missingGeneratedFileRestored",
"missingHandwrittenFileNotRecreated",
"readOnlyCheckAndDryRun",
"templateDriftUpgrade",
"manifestIdentityAndVersionGuards",
"symlinkAndLockProtection",
"fileTransactionRollback",
"publishedExampleFiniteRun"
],
"sourceHashes": {
"cmd/go-omni/internal/genfs/files.go": "97e338c74ddb3145eb0ec942b69d5c21c83dfb2f9f2165376fc83366ae1e93af",
"cmd/go-omni/internal/genfs/files_test.go": "a659b31ddf195c80d48cfc00302a3f4c6d5a1ed68b58d241da03a44ad54fe34a",
"cmd/go-omni/project.go": "07e362664053e59dbb8f8f46d4c7be9d66afb93a7142a810109b66cd296ae8a6",
"cmd/go-omni/projectgen/consumer_test.go": "312442d56599c8264f14aef03722a221a72e70c038875f11c3dbfc19e9872883",
"cmd/go-omni/projectgen/generate.go": "19afc56ed90fbdb7e6c77b5a7509a63d5176c0b576946e42b4241942b7dd1da6",
"cmd/go-omni/projectgen/generate_test.go": "f113258f0150a9f569dbe422b9f40e8ba0db3d115caf09a2c81432a8ae126e03",
"cmd/go-omni/projectgen/templates/.gitignore.tmpl": "82abf6aa84ec1c3ec218b09b5ce465af616c4fe8ff6de6db09abef962a137401",
"cmd/go-omni/projectgen/templates/README.md.tmpl": "3d01821fb4af9e97ca92f47e80a520d6f64ef136e49a8bcd4fed53dec32bde23",
"cmd/go-omni/projectgen/templates/etc_config.yaml.tmpl": "c224c68f50ea00a10e9dfd317347ac843c9107738861dab7824f7a7b1ae3a1ad",
"cmd/go-omni/projectgen/templates/internal_config_config.go.tmpl": "55111dbe02f0b91ed58a36449dcee8c9d7338251730764cda0de3e77e4a82f28",
"cmd/go-omni/projectgen/templates/internal_config_loadGen.go.tmpl": "95fb848065e3e516a8026f4439ede6d8be100e1726a56a124a6e49cb1d9d817d",
"cmd/go-omni/projectgen/templates/internal_config_loader.go.tmpl": "c9ad9f26a7d84622d7f2114a020b57ba6871a86ccf6cbc015c63674026ce81c3",
"cmd/go-omni/projectgen/templates/internal_run_run.go.tmpl": "51c2b4b407aa8b350e666be49b9d888ee42070627e28303ae2e1b0c9b4d73c95",
"cmd/go-omni/projectgen/templates/internal_svc_runtimeGen.go.tmpl": "8e259634ac7fbac0d2a13629864b2a5c89e43b6e9c477db9272b00cf49dc47d1",
"cmd/go-omni/projectgen/templates/internal_svc_serviceContext.go.tmpl": "1ed62fd86fd050468cac1c158a438deed01d4c25b4b634eec4ccd84b24df476f",
"cmd/go-omni/projectgen/templates/main.go.tmpl": "c59a7773f00c31c1a67e6a0093de26bc6f2dc8dbe34c61c1fa7e1b341e4afcc2",
"cmd/go-omni/projectgen/testdata/consumer_test.go": "38df45a69e843dfe6897d0296337e10b507076ea3c7a32901c1cad340915b0ee",
"scripts/verifyProgramRelease.py": "9127743ee929cb32701e4a063372a817ead3fd2795c2722b75f2a0c9123fa85a"
}
}

View File

@@ -0,0 +1,341 @@
{
"passed": true,
"frameworkVersion": "v0.30.0-preview",
"frameworkSum": "h1:sUtS24GAmm1QQYYqdV2XWZ3STR2V/KPqtxRcXGokH48=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 145.32,
"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",
"generatedIncompleteHTTPDrainRetainsDependenciesAndLogger",
"recursiveResponseTypedHTTP",
"recursiveResponseReferenceGraph",
"recursiveResponseRegenerationAndDrift",
"remoteHTTPClientRaceTests",
"remoteHTTPProxyAndTimeouts",
"remoteHTTPHeaderIsolation",
"remoteHTTPPoolAndDrainBudgets",
"remoteHTTPExample",
"remoteExplicitConfigurationRaceTests",
"literalAndRequiredDefaultReferences",
"remoteCheckConfigBeforeDependencies",
"removedImplicitEnvironmentRules",
"remoteSignedReadOptionsAndLegacyLinks",
"remoteReadSignatureTamperingAndExpiry",
"remoteLocalPayloadRangesAndHEAD",
"remoteOSSNativeV1V4Signatures",
"remoteS3R2ReadOptions",
"remoteSignedReadHTTPExample",
"remoteConfiguredHeaderPropagation",
"remoteHeaderW3CPrecedenceAndValidation",
"remoteHeaderTelemetryExample",
"remotePreciseModuleDiagnostics",
"remoteJSONSuccessStatuses",
"remoteRejectedStatusBodyCleanup",
"remoteCopyTypePreflight",
"remoteEmptyCollectionTypeRejection",
"remoteLoggerTimeZoneIsolation",
"remoteCORSPolicyRace",
"remoteCORSErrorResponses",
"remoteCORSTransports",
"generatedCORSConfigOnly",
"remoteUnifiedBodyPolicyTransports",
"remoteBodyLimitsBeforeMiddleware",
"remoteHardLimitConflictBeforeServing",
"remoteCanceledHandlerCleanupBarrier",
"remoteExternalServingResourceRetention",
"remoteAdmissionCloseRace",
"remoteRuntimeFinishRetainsHTTPDependencies",
"remoteHTTP2DisconnectedHandlerDrain",
"remoteNativeEarly413AndBusinessHTTP200",
"remoteHTTPSafetyExampleBuild",
"remoteTransactionStages",
"remoteTransactionCauseChains",
"remoteCommitOutcomeUnknown",
"remoteCancellationOutcomeUnknown",
"remoteTransactionPanicAndConcurrency",
"remoteTransactionPublicErrors",
"remoteTransactionExample",
"remoteI18nRuntimeRace",
"remoteI18nGeneratedConsumer",
"remoteI18nHTTPBusinessInvariants",
"remoteI18nSourceOnlyFallback",
"remoteCLDRSamplesAndBrowserConformance",
"remoteI18nCLIExtractionCheckExportTypes",
"remoteI18nNamespaceIsolation",
"remoteBrowserRaceAndFailureRetention",
"remoteJSONReaderCausePreservation",
"remoteTypeScriptConsumerSkippedWithoutTSC",
"flatFieldComposition",
"copyxThreeEndpointConsumer",
"explicitNullAndMoneyPolicies",
"fieldAdditionWithoutCopyRuleChanges",
"middlewareFactoryInjectionAndCleanup",
"optionalRepositoryPreservesBusiness",
"generatedUnifiedConfig",
"jsonYamlXmlAndCustomDecoder",
"configValidationBeforeDependencies",
"configEnvProvenance",
"configExtensionPreserved",
"configLoaderDriftCheck"
],
"sourceHashes": {
"lifecycle/runtime.go": "d34fb26879932153671b9cbd164daae484b23dca08b8a3be5b1b89f8f6168e47",
"lifecycle/phases.go": "6e72499d2c5fcecb57cf712e76e3a03fed61a4caf528f873f2058291a8ab0b4d",
"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": "866a7ab9cc28f987645785203213e10722490970d5a1fd3c060d88db8b1278dd",
"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": "4eb7f9bed7f1a9823870275eadedeb87e7400a6cb07c7ac6c2e8c310fdafc9bf",
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
"httpx/response.go": "99e29724e4fa51d2c7ec1a52892c24ac5daf210a2ee547c5e69a175015298efd",
"app/app.go": "2e672b3f6376049dd9695f799698439909f1d649d4338d13478e0dbf1de68800",
"app/transport.go": "8c665f47447d9839bba7ba3efa290316be9635179d2276b0da0d0f8eda3842ec",
"scripts/verifyManagedRelease.py": "7c7be0eff8eecdc0de54f6c619e00e4150e02f6511bd87fb78c12d6addd4a531",
"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": "cb4a07539adbd163c13fc6cbb914518506b0c60a88473589f212f3406e763e4b",
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "94a5787218c0d92159b473d390197858241ede2cab591df0976e611ed279dcfe",
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "b078bd9052e513ab6173a1e65cf54b28d3cfad445cb50518d9e2e76736071047",
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "589e2c54d9ad7f64e88ac556329a52062a27b4e898deb9a97feff200787c32c1",
"cmd/go-omni/apigen/testdata/managed/operations.go": "a1d91744abe020cbfb476512cffb7dad422a95da1c3be6957fdcfe27c361030b",
"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": "b0d573b5dbdfe397b0ca2bfc2e79c85c589bd11b2269f7206b3828cf0ff5afd7",
"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": "cbf09c158a9add8289befd9960f911a8cf42de0b840a57278bc71bf800121ec8",
"config/cors_test.go": "b40e76dd943f28c80a06eeb2dc74a536b217c4918c4a21d7de870404483a377a",
"config/errorCatalog_test.go": "a9cca5233da1ebf9280a022e10ff07e50f99cf01eac8ae70b905fe12665e3d9d",
"config/suggestions.go": "5220f78bc8e4fda44f4cedc6cc6227895417d83d49eaef3f54711f3a0f9e1245",
"config/http_test.go": "975dc288486ba537cf7fd7ee87df792ba7eceed6ab0a58e6a5c0660ce724ab9d",
"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": "d02858d522ad63e86185f5d87d3fec9718141c75b7199fe75d0894dfa016d24d",
"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",
"app/body.go": "4064b96fc38c5b75ec17f0d82e0dec79242640c4d0903bf544e4fda057e5397a",
"app/router.go": "b0418e18fd546b855a4cce9ff19efc25b274d9b9c52144e12338bb5d97a5be9f",
"app/requests.go": "c4cb453e8215ed594e6755b256b4b1bbcd3b870584f4be19480d4dd1b9e0aa3d",
"app/bodyPolicy_test.go": "505afa7bcc3f70009ef2af2c455e377277db01c2b883c8e5204de0cbebf25cbb",
"app/requests_test.go": "b008b5599e1f5eea01a08f15220588bd581a2d5e98658d53f0acb273dd946137",
"app/transport_test.go": "10f086eeb77e3b11e83768bbbb0372aa68056a8c8839a18edfaf597b5acff689",
"httpx/context.go": "e3a7d3cd31bc6bddaf21c2be35887cf5c85e69ec5e6ff82178884cb0e4696ffb",
"internal/httplimits/limits.go": "98ba40ceda78fcf4b08d51b0851de851329f568fd35b0a4aedefd4b48a43e780",
"lifecycle/runtime_test.go": "ccd302f78535215bea69d7c8ad8c812c79c52cca087353358206cb6b5926d34b",
"transport/http2/server.go": "e0b7fdb3069583d042df5117b36a3fffc423ee452b0a9da162e3604fcce80b49",
"transport/http2/lifecycle_test.go": "2a298b8e466ff4145105338904ec5d9e19087c91d829d6ae5cad8c8b1ee05b15",
"transport/native/server.go": "8c2fc503bc157265838b453bfe289ec68eeb2bdaf63f6dd1e585536a8278ba0c",
"transport/native/earlyReject_test.go": "0fd598e24199beb55bead10d741d739f7361345584c4430b4a97c35d59d62774",
"examples/httpSafety/main.go": "4651b3806e7a8030101ba6a70794fcc18a50d2939fe0a59d7f80bb4a7de30f15",
"storage/upload.go": "9ff2930ba389163acc8063b51107c09a60290609ba384de8004c498ce09f20a1",
"storage/read_test.go": "9b11ab9640e98c1c9fa7ce1e2587df217e874e3a6a6e27a0dd0ade6fbf3d5e1a",
"storage/signedUpload.go": "a8d31bcbbad64796f04611e69271e94d917c9bcd5aeabd7a97ea155b0093c814",
"storage/multipart_test.go": "4053f680f7795db2a6b4aa23590b3669eec7a609dd92c4a1e20891a5299274ee",
"storage/multipart.go": "3b718ebe6da681c89a3f797892c88e7acb15c295df2376d64ac35da0976ed69d",
"storage/signed_test.go": "eaffd40894018814e976477f96cf0fbaecfa1987caad78830d9fc8e96995882f",
"storage/upload_test.go": "12b60419807ed64b89e30861674e19abee7b0b7bb0e8ec9ac5b3aa2e0710a1da",
"storage/storage.go": "cb670872f01cf1d7b4ebf21cec9fc6f52ad93bfdf523b2f28bbb8d9c7fe39a6c",
"storage/signed.go": "ae4b144137627be35718c40743ebc2f063ec667c24b6d8984ccfcca034a73787",
"storage/read.go": "92787c74ae024822f82b5b84bffc9f8a355f1f2565b99ca7fbefe49157ef4851",
"storage/s3/upload.go": "3ce0b3626249ffe7a041606d6a9481743af8a0d4868fadea20ed3ed44ee0c765",
"storage/s3/read_test.go": "9b64fda5c83ba41508ec23cf41aeeb9826b677caaa851dcbeb04abe1f43eed65",
"storage/s3/config.go": "626ec6a2c0b06c95ee7eb7aa0832b190cfcdeb8a127b6b89e99204040bc3b041",
"storage/s3/upload_test.go": "a41fdbd100d9c79a3606c5942f98017afe26d841b251d96e979df87d0af93867",
"storage/s3/read.go": "319b57f2ecf435b71085c7cdcafd50a1e30caa5f357f83d4db3cdcef085792b6",
"storage/s3/s3_test.go": "9ef284926ec9936ddfec8c52a9bf04fc7b4be55c43a535f6c5a428d1535f6fec",
"storage/s3/s3.go": "016bef5948e97f3d043d2e858716f1c02d9f27c4e02802bdc1be3860116f4b6c",
"storage/local/read_test.go": "ef1f7feb0887ea4b1f30304dfaf542fe9788b6e17856c942f1cd98448656194f",
"storage/local/local_test.go": "3a2241840968df8e37fd67169c3ef708228449e81f65aff908c8b60d49434dc0",
"storage/local/local.go": "5d1ca75d0d2ca87981eef84a60bf98d0feefa2a9594940d6bd231877f4f8ec02",
"storage/oss/upload.go": "556129611f9e5549cac8a36b4026bfb19c112a295b55192579f9ac116e20626f",
"storage/oss/read_test.go": "a1c273cfedd2f3ee31ce407a7ddd6fd5ad02c65ad632a91bc7f3ead3712b5bbd",
"storage/oss/oss_test.go": "555a954010b1218804dc7951fc75e42eae24eba9a99c0871f5ba787f3fc87d45",
"storage/oss/upload_test.go": "08eb0ed157f3743288d781f61d4d2b7bfe77852a8259c5e598c719d35889409e",
"storage/oss/oss.go": "4216ce9f3fe3ac5418a5b809db3cef7e99dcca72a25a2a12301f5897cda85dd5",
"storage/oss/read.go": "429a9a9f6c586a885d7cccb76c9d572d53a79df637ff128caacb2eac35b8d862",
"storage/storagetest/contract.go": "e7ad7242102437882269d4066462059d7974586b36a82d18639eca10fd932be9",
"storage/storagetest/uploads.go": "9b66f86dd94ccdf30c7d603d432bc2a55956608898e46c1274fa27d16fa1a969",
"examples/signedRead/main.go": "f921a3c5fbcbfa84291c0c95a9c2c211319a8f0537acc5ec640238f251e652e1",
"examples/signedRead/main_test.go": "6ab559c442b2aa355231efe0342b9ec3617bf97bb1f6dfd7ad304b9a452e4fc4",
"telemetry/errorCatalog_test.go": "eb4f487e226dfb45f179dbe13b5eba5a23a478b3417eea7a9710230ce71f9ad5",
"telemetry/telemetry_test.go": "a44531e5c9efc69d99bf3fd73bafc904bc5dae0f886676cdcf0bda30552e4b3b",
"telemetry/telemetry.go": "043f295a62bef4d4a92b8918f6d201ac54a15afd6823a01d64ac0656c2bede5d",
"telemetry/adapters.go": "a892ef0c2b268caf266a3ea0f833ecf7e9228ff59aeaeecae6fe4a2170ba51e2",
"telemetry/headers.go": "a65f275a78e69bfe0b9c91bb7d4947609891ea34b6b7377e436b26b53f6ef74a",
"examples/telemetryHeaders/main.go": "f331d9cf2beb45d95fe1c3ba3e5d720e99b16e76511fe7d2c54bbcae97202592",
"examples/telemetryHeaders/main_test.go": "abe3a6d1d7f3348d0802dacc25f8fb267e592819949e567991be6dbd19783d88"
}
}

View File

@@ -0,0 +1,34 @@
{
"passed": true,
"frameworkVersion": "v0.30.0-preview",
"frameworkSum": "h1:sUtS24GAmm1QQYYqdV2XWZ3STR2V/KPqtxRcXGokH48=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 17.58,
"checks": [
"remoteCLIInstall",
"remoteAPIModule",
"remoteGenericModule",
"explicitKinds",
"manifestInferredKinds",
"mixedFormatWithProjectSkip",
"mixedGeneration",
"mixedCheck",
"nativeGoWorkBuild",
"nativeGoWorkRaceTests",
"moduleAndWorkspaceMetadataPreserved",
"bothProjectFailuresAggregated",
"nonzeroFailureExit",
"failedCheckIsReadOnly",
"crossProjectPreflight",
"missingGeneratedOutputRestored"
],
"sourceHashes": {
"cmd/go-omni/apigen/workspace.go": "cc39732ed9a41bb2dfa14933a830a7f10e8e3b2edc2ada07c376a738c0ec77e0",
"cmd/go-omni/apigen/workspaceMixed_test.go": "119f2a2aadc2b3ee2471bed1309c6ad8e36488b258f2ab1ba5ba5bab4219643f",
"cmd/go-omni/apigen/workspaceCatalog_test.go": "372874322aece46714351f088b5741642fe4e63d95062743a575958fa58b8b39",
"cmd/go-omni/workspace.go": "b7bbd3d9d58b3987090edf7e3714d7d8396e7c332174022d918a52e16396397d",
"cmd/go-omni/workspace_test.go": "0170d18158325262765d8b3067f55ca3ce9fe994f2d030cafee3e82b13bfcd4b",
"scripts/verifyWorkspaceRelease.py": "0495dc97c68ac17a7969088cf94b1cbbfa6e53f18fb36147c64bc34edb260bca"
}
}

View File

@@ -32,4 +32,9 @@
## 远程发布验收 ## 远程发布验收
发布后运行 `scripts/verifyProgramRelease.py` 与原有 `scripts/verifyManagedRelease.py`,使用空模块缓存安装固定远程 CLI,禁止本地 replace。结果在完成后补入本文件及 JSON 报告。 2026-09-30,两个独立空模块缓存安装远程 v0.28.0-preview,均未使用本地 replace:
- [通用工程远程报告](programRemote.json):30 项检查通过,耗时 26.45 秒。测试直接从下载的框架模块运行,并通过远程 CLI 生成实际消费者;包含示例的一次性运行。
- [既有生成项目远程报告](programManagedRemote.json):98 项检查、10 组消费者通过,耗时 111.07 秒。覆盖原有配置、HTTP 关闭、上传策略、国际化及 TypeScript 消费者;TypeScript 5.6.3 通过 npm exec 提供,未跳过该检查。
- 两组报告的远程模块 checksum 一致:`h1:nD9Xtn7RhHy81n+qp96rlcJlxpDc276Per+dt1low1g=`。
- 发布标签固定在代码提交 `0b6c0043b6c3b58648645aeee03bf80801e6aa3f`;标签对象 `50caea1fc81a52080c0f0832d8c4a63cac786859`。代码与标签已推送,验收文档随后独立提交,不移动发布标签。

View File

@@ -0,0 +1,292 @@
{
"passed": true,
"frameworkVersion": "v0.28.0-preview",
"frameworkSum": "h1:nD9Xtn7RhHy81n+qp96rlcJlxpDc276Per+dt1low1g=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 111.07,
"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",
"generatedIncompleteHTTPDrainRetainsDependenciesAndLogger",
"remoteHTTPClientRaceTests",
"remoteHTTPProxyAndTimeouts",
"remoteHTTPHeaderIsolation",
"remoteHTTPPoolAndDrainBudgets",
"remoteHTTPExample",
"remoteExplicitConfigurationRaceTests",
"literalAndRequiredDefaultReferences",
"remoteCheckConfigBeforeDependencies",
"removedImplicitEnvironmentRules",
"remotePreciseModuleDiagnostics",
"remoteJSONSuccessStatuses",
"remoteRejectedStatusBodyCleanup",
"remoteCopyTypePreflight",
"remoteEmptyCollectionTypeRejection",
"remoteLoggerTimeZoneIsolation",
"remoteCORSPolicyRace",
"remoteCORSErrorResponses",
"remoteCORSTransports",
"generatedCORSConfigOnly",
"remoteUnifiedBodyPolicyTransports",
"remoteBodyLimitsBeforeMiddleware",
"remoteHardLimitConflictBeforeServing",
"remoteCanceledHandlerCleanupBarrier",
"remoteExternalServingResourceRetention",
"remoteAdmissionCloseRace",
"remoteRuntimeFinishRetainsHTTPDependencies",
"remoteHTTP2DisconnectedHandlerDrain",
"remoteNativeEarly413AndBusinessHTTP200",
"remoteHTTPSafetyExampleBuild",
"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": "d34fb26879932153671b9cbd164daae484b23dca08b8a3be5b1b89f8f6168e47",
"lifecycle/phases.go": "6e72499d2c5fcecb57cf712e76e3a03fed61a4caf528f873f2058291a8ab0b4d",
"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": "866a7ab9cc28f987645785203213e10722490970d5a1fd3c060d88db8b1278dd",
"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": "e89e8b9cabc18903d231ef22396ff94b9f2f6d233df889669ed3644cf283317f",
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
"httpx/response.go": "99e29724e4fa51d2c7ec1a52892c24ac5daf210a2ee547c5e69a175015298efd",
"app/app.go": "2e672b3f6376049dd9695f799698439909f1d649d4338d13478e0dbf1de68800",
"app/transport.go": "8c665f47447d9839bba7ba3efa290316be9635179d2276b0da0d0f8eda3842ec",
"scripts/verifyManagedRelease.py": "6860e3f440c4d4f51a55085e50066b1a43cd31d5068c2f7a01a708c16823aef7",
"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": "cb4a07539adbd163c13fc6cbb914518506b0c60a88473589f212f3406e763e4b",
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "94a5787218c0d92159b473d390197858241ede2cab591df0976e611ed279dcfe",
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "b078bd9052e513ab6173a1e65cf54b28d3cfad445cb50518d9e2e76736071047",
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "589e2c54d9ad7f64e88ac556329a52062a27b4e898deb9a97feff200787c32c1",
"cmd/go-omni/apigen/testdata/managed/operations.go": "a1d91744abe020cbfb476512cffb7dad422a95da1c3be6957fdcfe27c361030b",
"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": "cbf09c158a9add8289befd9960f911a8cf42de0b840a57278bc71bf800121ec8",
"config/cors_test.go": "b40e76dd943f28c80a06eeb2dc74a536b217c4918c4a21d7de870404483a377a",
"config/errorCatalog_test.go": "a9cca5233da1ebf9280a022e10ff07e50f99cf01eac8ae70b905fe12665e3d9d",
"config/suggestions.go": "5220f78bc8e4fda44f4cedc6cc6227895417d83d49eaef3f54711f3a0f9e1245",
"config/http_test.go": "975dc288486ba537cf7fd7ee87df792ba7eceed6ab0a58e6a5c0660ce724ab9d",
"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": "d02858d522ad63e86185f5d87d3fec9718141c75b7199fe75d0894dfa016d24d",
"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",
"app/body.go": "4064b96fc38c5b75ec17f0d82e0dec79242640c4d0903bf544e4fda057e5397a",
"app/router.go": "b0418e18fd546b855a4cce9ff19efc25b274d9b9c52144e12338bb5d97a5be9f",
"app/requests.go": "c4cb453e8215ed594e6755b256b4b1bbcd3b870584f4be19480d4dd1b9e0aa3d",
"app/bodyPolicy_test.go": "505afa7bcc3f70009ef2af2c455e377277db01c2b883c8e5204de0cbebf25cbb",
"app/requests_test.go": "b008b5599e1f5eea01a08f15220588bd581a2d5e98658d53f0acb273dd946137",
"app/transport_test.go": "10f086eeb77e3b11e83768bbbb0372aa68056a8c8839a18edfaf597b5acff689",
"httpx/context.go": "e3a7d3cd31bc6bddaf21c2be35887cf5c85e69ec5e6ff82178884cb0e4696ffb",
"internal/httplimits/limits.go": "98ba40ceda78fcf4b08d51b0851de851329f568fd35b0a4aedefd4b48a43e780",
"lifecycle/runtime_test.go": "ccd302f78535215bea69d7c8ad8c812c79c52cca087353358206cb6b5926d34b",
"transport/http2/server.go": "e0b7fdb3069583d042df5117b36a3fffc423ee452b0a9da162e3604fcce80b49",
"transport/http2/lifecycle_test.go": "2a298b8e466ff4145105338904ec5d9e19087c91d829d6ae5cad8c8b1ee05b15",
"transport/native/server.go": "8c2fc503bc157265838b453bfe289ec68eeb2bdaf63f6dd1e585536a8278ba0c",
"transport/native/earlyReject_test.go": "0fd598e24199beb55bead10d741d739f7361345584c4430b4a97c35d59d62774",
"examples/httpSafety/main.go": "4651b3806e7a8030101ba6a70794fcc18a50d2939fe0a59d7f80bb4a7de30f15"
}
}

View File

@@ -0,0 +1,74 @@
{
"passed": true,
"frameworkVersion": "v0.28.0-preview",
"frameworkSum": "h1:nD9Xtn7RhHy81n+qp96rlcJlxpDc276Per+dt1low1g=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 26.45,
"tests": [
"TestConflictRollsBackEarlierWrites",
"TestUnsafePaths",
"TestGeneratedProgramConsumer",
"TestOwnershipAndDrift",
"TestRecordedTemplateUpgrade",
"TestProjectGuards/module",
"TestProjectGuards/oldFramework",
"TestProjectGuards/manifest",
"TestProjectGuards/lock",
"TestProjectGuards/otherGenerator",
"TestProjectGuards/symlink",
"TestProjectGuards"
],
"checks": [
"remoteCLIInstall",
"installedVersionDefault",
"generatedConsumerBuildRaceVet",
"defaultNoAPIFiles",
"defaultNoNetworkListener",
"finiteProgram",
"SIGINT",
"SIGTERM",
"initializationFailureAndPanic",
"runErrorPanicGoexit",
"fatalComponentFailure",
"nonzeroProcessFailureAndTimeoutExits",
"partialStartupDraining",
"acknowledgementBeforeResourceClose",
"workContextSurvivesGracefulStop",
"cancellationGrace",
"unresponsiveRunAndBackgroundWork",
"retainedResourcesAndLogger",
"explicitConfigurationOnly",
"checkConfigWithoutDependencies",
"handwrittenFilesPreserved",
"generatedFileConflict",
"missingGeneratedFileRestored",
"missingHandwrittenFileNotRecreated",
"readOnlyCheckAndDryRun",
"templateDriftUpgrade",
"manifestIdentityAndVersionGuards",
"symlinkAndLockProtection",
"fileTransactionRollback",
"publishedExampleFiniteRun"
],
"sourceHashes": {
"cmd/go-omni/internal/genfs/files.go": "97e338c74ddb3145eb0ec942b69d5c21c83dfb2f9f2165376fc83366ae1e93af",
"cmd/go-omni/internal/genfs/files_test.go": "a659b31ddf195c80d48cfc00302a3f4c6d5a1ed68b58d241da03a44ad54fe34a",
"cmd/go-omni/project.go": "07e362664053e59dbb8f8f46d4c7be9d66afb93a7142a810109b66cd296ae8a6",
"cmd/go-omni/projectgen/consumer_test.go": "312442d56599c8264f14aef03722a221a72e70c038875f11c3dbfc19e9872883",
"cmd/go-omni/projectgen/generate.go": "c4b1ba2d1f09eca02a031330ed9a1e486bab711b37c77b72f0f9dd0906e1f8e4",
"cmd/go-omni/projectgen/generate_test.go": "f113258f0150a9f569dbe422b9f40e8ba0db3d115caf09a2c81432a8ae126e03",
"cmd/go-omni/projectgen/templates/.gitignore.tmpl": "82abf6aa84ec1c3ec218b09b5ce465af616c4fe8ff6de6db09abef962a137401",
"cmd/go-omni/projectgen/templates/README.md.tmpl": "3d01821fb4af9e97ca92f47e80a520d6f64ef136e49a8bcd4fed53dec32bde23",
"cmd/go-omni/projectgen/templates/etc_config.yaml.tmpl": "c224c68f50ea00a10e9dfd317347ac843c9107738861dab7824f7a7b1ae3a1ad",
"cmd/go-omni/projectgen/templates/internal_config_config.go.tmpl": "55111dbe02f0b91ed58a36449dcee8c9d7338251730764cda0de3e77e4a82f28",
"cmd/go-omni/projectgen/templates/internal_config_loadGen.go.tmpl": "95fb848065e3e516a8026f4439ede6d8be100e1726a56a124a6e49cb1d9d817d",
"cmd/go-omni/projectgen/templates/internal_config_loader.go.tmpl": "c9ad9f26a7d84622d7f2114a020b57ba6871a86ccf6cbc015c63674026ce81c3",
"cmd/go-omni/projectgen/templates/internal_run_run.go.tmpl": "51c2b4b407aa8b350e666be49b9d888ee42070627e28303ae2e1b0c9b4d73c95",
"cmd/go-omni/projectgen/templates/internal_svc_runtimeGen.go.tmpl": "8e259634ac7fbac0d2a13629864b2a5c89e43b6e9c477db9272b00cf49dc47d1",
"cmd/go-omni/projectgen/templates/internal_svc_serviceContext.go.tmpl": "1ed62fd86fd050468cac1c158a438deed01d4c25b4b634eec4ccd84b24df476f",
"cmd/go-omni/projectgen/templates/main.go.tmpl": "c59a7773f00c31c1a67e6a0093de26bc6f2dc8dbe34c61c1fa7e1b341e4afcc2",
"cmd/go-omni/projectgen/testdata/consumer_test.go": "38df45a69e843dfe6897d0296337e10b507076ea3c7a32901c1cad340915b0ee",
"scripts/verifyProgramRelease.py": "9127743ee929cb32701e4a063372a817ead3fd2795c2722b75f2a0c9123fa85a"
}
}

View File

@@ -0,0 +1,32 @@
# v0.28.2 递归响应验收
范围:`.api` 响应 DTO 的自引用与相互引用,使用通用 Node/Branch/Leaf;不加入接入项目的业务字段或业务行为。非递归响应保持原生成路径,递归请求与非法值循环仍拒绝。
## 本地回归
2026-09-30,macOS arm64:
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -count=1 ./...`:56 个测试包通过,apigen 集成包 112.675 秒。
- `GOTOOLCHAIN=go1.22.12 GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -count=1 ./...`:56 个测试包通过,apigen 集成包 91.536 秒。
- 新增生成文档断言后,重新运行专项 `go test -race -count=1 ./cmd/go-omni/apigen -run Recursive`(开启集成):通过,4.318 秒。
- `go vet ./...`、`go build ./...`、`git diff --check`:通过。
- 示例通过实际 CLI 导出后,`@apidevtools/swagger-cli@4.0.4 validate` 确认完整 OpenAPI 3.0.3 文档有效。
## 覆盖内容
- 强类型三层树、递归 slice/map/pointer、可空对象、raw、数组根响应以及相互引用的有限值,生成消费者真实 HTTP 返回和 Go JSON 解码。
- `$ref` 目标完整、没有被忽略的引用同级属性、可空引用的 null 分支、循环引用不展开、文档压缩幂等。
- 跨源文件引用、声明顺序无关、直接值与引用混合的合法类型、scalar 输出 schema、字段约束、描述、精确 int64 示例。
- 有限递归示例、空集合、缺失/未知字段、深层非法值、异构数组错误、非法 null 与额外 JSON 值。
- 非法直接值循环、相互值循环、字段组合循环、递归请求及间接递归请求、未知类型、非法标签、重复字段、上传字段、双指针、固定数组和错误 map 键。
- 反复生成、手写代码保留、只读检查、递归字段变更诊断;请求绑定与原有生成消费者继续全量回归。
## 发布验收
三个独立空模块缓存安装固定远程 `v0.28.2-preview`,均无本地 replace,全部通过:
- [响应与兼容报告](recursiveResponseRemote.json):101 项检查、11 组生成消费者,160.57 秒。包含强类型递归 HTTP、完整引用图、再生成和漂移检查,以及原有生命周期、上传、配置、错误和 i18n 回归。
- [通用工程报告](recursiveResponseProgramRemote.json):30 项检查,73.66 秒。
- [混合工作区报告](recursiveResponseWorkspaceRemote.json):16 项检查,66.19 秒。
三次下载的框架 checksum 一致:`h1:QjHXXV184SO45GCXLyejeVDdCzLa5EtRu2ngyYpLzYI=`。代码与固定标签指向 `0c5b571b31b68d25cdb3e8640970e2a53ddcbea6`;远程验收记录另行提交,不移动标签。

View File

@@ -0,0 +1,74 @@
{
"passed": true,
"frameworkVersion": "v0.28.2-preview",
"frameworkSum": "h1:QjHXXV184SO45GCXLyejeVDdCzLa5EtRu2ngyYpLzYI=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 73.66,
"tests": [
"TestConflictRollsBackEarlierWrites",
"TestUnsafePaths",
"TestGeneratedProgramConsumer",
"TestOwnershipAndDrift",
"TestRecordedTemplateUpgrade",
"TestProjectGuards/module",
"TestProjectGuards/oldFramework",
"TestProjectGuards/manifest",
"TestProjectGuards/lock",
"TestProjectGuards/otherGenerator",
"TestProjectGuards/symlink",
"TestProjectGuards"
],
"checks": [
"remoteCLIInstall",
"installedVersionDefault",
"generatedConsumerBuildRaceVet",
"defaultNoAPIFiles",
"defaultNoNetworkListener",
"finiteProgram",
"SIGINT",
"SIGTERM",
"initializationFailureAndPanic",
"runErrorPanicGoexit",
"fatalComponentFailure",
"nonzeroProcessFailureAndTimeoutExits",
"partialStartupDraining",
"acknowledgementBeforeResourceClose",
"workContextSurvivesGracefulStop",
"cancellationGrace",
"unresponsiveRunAndBackgroundWork",
"retainedResourcesAndLogger",
"explicitConfigurationOnly",
"checkConfigWithoutDependencies",
"handwrittenFilesPreserved",
"generatedFileConflict",
"missingGeneratedFileRestored",
"missingHandwrittenFileNotRecreated",
"readOnlyCheckAndDryRun",
"templateDriftUpgrade",
"manifestIdentityAndVersionGuards",
"symlinkAndLockProtection",
"fileTransactionRollback",
"publishedExampleFiniteRun"
],
"sourceHashes": {
"cmd/go-omni/internal/genfs/files.go": "97e338c74ddb3145eb0ec942b69d5c21c83dfb2f9f2165376fc83366ae1e93af",
"cmd/go-omni/internal/genfs/files_test.go": "a659b31ddf195c80d48cfc00302a3f4c6d5a1ed68b58d241da03a44ad54fe34a",
"cmd/go-omni/project.go": "07e362664053e59dbb8f8f46d4c7be9d66afb93a7142a810109b66cd296ae8a6",
"cmd/go-omni/projectgen/consumer_test.go": "312442d56599c8264f14aef03722a221a72e70c038875f11c3dbfc19e9872883",
"cmd/go-omni/projectgen/generate.go": "f4388d73901a0c5d5f3aac58d8ee1d1aa69ea395019875ec3c4cffc49fb1179d",
"cmd/go-omni/projectgen/generate_test.go": "f113258f0150a9f569dbe422b9f40e8ba0db3d115caf09a2c81432a8ae126e03",
"cmd/go-omni/projectgen/templates/.gitignore.tmpl": "82abf6aa84ec1c3ec218b09b5ce465af616c4fe8ff6de6db09abef962a137401",
"cmd/go-omni/projectgen/templates/README.md.tmpl": "3d01821fb4af9e97ca92f47e80a520d6f64ef136e49a8bcd4fed53dec32bde23",
"cmd/go-omni/projectgen/templates/etc_config.yaml.tmpl": "c224c68f50ea00a10e9dfd317347ac843c9107738861dab7824f7a7b1ae3a1ad",
"cmd/go-omni/projectgen/templates/internal_config_config.go.tmpl": "55111dbe02f0b91ed58a36449dcee8c9d7338251730764cda0de3e77e4a82f28",
"cmd/go-omni/projectgen/templates/internal_config_loadGen.go.tmpl": "95fb848065e3e516a8026f4439ede6d8be100e1726a56a124a6e49cb1d9d817d",
"cmd/go-omni/projectgen/templates/internal_config_loader.go.tmpl": "c9ad9f26a7d84622d7f2114a020b57ba6871a86ccf6cbc015c63674026ce81c3",
"cmd/go-omni/projectgen/templates/internal_run_run.go.tmpl": "51c2b4b407aa8b350e666be49b9d888ee42070627e28303ae2e1b0c9b4d73c95",
"cmd/go-omni/projectgen/templates/internal_svc_runtimeGen.go.tmpl": "8e259634ac7fbac0d2a13629864b2a5c89e43b6e9c477db9272b00cf49dc47d1",
"cmd/go-omni/projectgen/templates/internal_svc_serviceContext.go.tmpl": "1ed62fd86fd050468cac1c158a438deed01d4c25b4b634eec4ccd84b24df476f",
"cmd/go-omni/projectgen/templates/main.go.tmpl": "c59a7773f00c31c1a67e6a0093de26bc6f2dc8dbe34c61c1fa7e1b341e4afcc2",
"cmd/go-omni/projectgen/testdata/consumer_test.go": "38df45a69e843dfe6897d0296337e10b507076ea3c7a32901c1cad340915b0ee",
"scripts/verifyProgramRelease.py": "9127743ee929cb32701e4a063372a817ead3fd2795c2722b75f2a0c9123fa85a"
}
}

View File

@@ -0,0 +1,295 @@
{
"passed": true,
"frameworkVersion": "v0.28.2-preview",
"frameworkSum": "h1:QjHXXV184SO45GCXLyejeVDdCzLa5EtRu2ngyYpLzYI=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 160.57,
"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",
"generatedIncompleteHTTPDrainRetainsDependenciesAndLogger",
"recursiveResponseTypedHTTP",
"recursiveResponseReferenceGraph",
"recursiveResponseRegenerationAndDrift",
"remoteHTTPClientRaceTests",
"remoteHTTPProxyAndTimeouts",
"remoteHTTPHeaderIsolation",
"remoteHTTPPoolAndDrainBudgets",
"remoteHTTPExample",
"remoteExplicitConfigurationRaceTests",
"literalAndRequiredDefaultReferences",
"remoteCheckConfigBeforeDependencies",
"removedImplicitEnvironmentRules",
"remotePreciseModuleDiagnostics",
"remoteJSONSuccessStatuses",
"remoteRejectedStatusBodyCleanup",
"remoteCopyTypePreflight",
"remoteEmptyCollectionTypeRejection",
"remoteLoggerTimeZoneIsolation",
"remoteCORSPolicyRace",
"remoteCORSErrorResponses",
"remoteCORSTransports",
"generatedCORSConfigOnly",
"remoteUnifiedBodyPolicyTransports",
"remoteBodyLimitsBeforeMiddleware",
"remoteHardLimitConflictBeforeServing",
"remoteCanceledHandlerCleanupBarrier",
"remoteExternalServingResourceRetention",
"remoteAdmissionCloseRace",
"remoteRuntimeFinishRetainsHTTPDependencies",
"remoteHTTP2DisconnectedHandlerDrain",
"remoteNativeEarly413AndBusinessHTTP200",
"remoteHTTPSafetyExampleBuild",
"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": "d34fb26879932153671b9cbd164daae484b23dca08b8a3be5b1b89f8f6168e47",
"lifecycle/phases.go": "6e72499d2c5fcecb57cf712e76e3a03fed61a4caf528f873f2058291a8ab0b4d",
"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": "866a7ab9cc28f987645785203213e10722490970d5a1fd3c060d88db8b1278dd",
"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": "20579cb9509382ec75cae5d21e84da4eaed470b854978ce55398bdfdc6d68ebf",
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
"httpx/response.go": "99e29724e4fa51d2c7ec1a52892c24ac5daf210a2ee547c5e69a175015298efd",
"app/app.go": "2e672b3f6376049dd9695f799698439909f1d649d4338d13478e0dbf1de68800",
"app/transport.go": "8c665f47447d9839bba7ba3efa290316be9635179d2276b0da0d0f8eda3842ec",
"scripts/verifyManagedRelease.py": "a726d2a7480926edd0538a737bf381866e83ac32defd4e34b1901c9d445012c5",
"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": "cb4a07539adbd163c13fc6cbb914518506b0c60a88473589f212f3406e763e4b",
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "94a5787218c0d92159b473d390197858241ede2cab591df0976e611ed279dcfe",
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "b078bd9052e513ab6173a1e65cf54b28d3cfad445cb50518d9e2e76736071047",
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "589e2c54d9ad7f64e88ac556329a52062a27b4e898deb9a97feff200787c32c1",
"cmd/go-omni/apigen/testdata/managed/operations.go": "a1d91744abe020cbfb476512cffb7dad422a95da1c3be6957fdcfe27c361030b",
"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": "b0d573b5dbdfe397b0ca2bfc2e79c85c589bd11b2269f7206b3828cf0ff5afd7",
"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": "cbf09c158a9add8289befd9960f911a8cf42de0b840a57278bc71bf800121ec8",
"config/cors_test.go": "b40e76dd943f28c80a06eeb2dc74a536b217c4918c4a21d7de870404483a377a",
"config/errorCatalog_test.go": "a9cca5233da1ebf9280a022e10ff07e50f99cf01eac8ae70b905fe12665e3d9d",
"config/suggestions.go": "5220f78bc8e4fda44f4cedc6cc6227895417d83d49eaef3f54711f3a0f9e1245",
"config/http_test.go": "975dc288486ba537cf7fd7ee87df792ba7eceed6ab0a58e6a5c0660ce724ab9d",
"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": "d02858d522ad63e86185f5d87d3fec9718141c75b7199fe75d0894dfa016d24d",
"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",
"app/body.go": "4064b96fc38c5b75ec17f0d82e0dec79242640c4d0903bf544e4fda057e5397a",
"app/router.go": "b0418e18fd546b855a4cce9ff19efc25b274d9b9c52144e12338bb5d97a5be9f",
"app/requests.go": "c4cb453e8215ed594e6755b256b4b1bbcd3b870584f4be19480d4dd1b9e0aa3d",
"app/bodyPolicy_test.go": "505afa7bcc3f70009ef2af2c455e377277db01c2b883c8e5204de0cbebf25cbb",
"app/requests_test.go": "b008b5599e1f5eea01a08f15220588bd581a2d5e98658d53f0acb273dd946137",
"app/transport_test.go": "10f086eeb77e3b11e83768bbbb0372aa68056a8c8839a18edfaf597b5acff689",
"httpx/context.go": "e3a7d3cd31bc6bddaf21c2be35887cf5c85e69ec5e6ff82178884cb0e4696ffb",
"internal/httplimits/limits.go": "98ba40ceda78fcf4b08d51b0851de851329f568fd35b0a4aedefd4b48a43e780",
"lifecycle/runtime_test.go": "ccd302f78535215bea69d7c8ad8c812c79c52cca087353358206cb6b5926d34b",
"transport/http2/server.go": "e0b7fdb3069583d042df5117b36a3fffc423ee452b0a9da162e3604fcce80b49",
"transport/http2/lifecycle_test.go": "2a298b8e466ff4145105338904ec5d9e19087c91d829d6ae5cad8c8b1ee05b15",
"transport/native/server.go": "8c2fc503bc157265838b453bfe289ec68eeb2bdaf63f6dd1e585536a8278ba0c",
"transport/native/earlyReject_test.go": "0fd598e24199beb55bead10d741d739f7361345584c4430b4a97c35d59d62774",
"examples/httpSafety/main.go": "4651b3806e7a8030101ba6a70794fcc18a50d2939fe0a59d7f80bb4a7de30f15"
}
}

View File

@@ -0,0 +1,34 @@
{
"passed": true,
"frameworkVersion": "v0.28.2-preview",
"frameworkSum": "h1:QjHXXV184SO45GCXLyejeVDdCzLa5EtRu2ngyYpLzYI=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 66.19,
"checks": [
"remoteCLIInstall",
"remoteAPIModule",
"remoteGenericModule",
"explicitKinds",
"manifestInferredKinds",
"mixedFormatWithProjectSkip",
"mixedGeneration",
"mixedCheck",
"nativeGoWorkBuild",
"nativeGoWorkRaceTests",
"moduleAndWorkspaceMetadataPreserved",
"bothProjectFailuresAggregated",
"nonzeroFailureExit",
"failedCheckIsReadOnly",
"crossProjectPreflight",
"missingGeneratedOutputRestored"
],
"sourceHashes": {
"cmd/go-omni/apigen/workspace.go": "cc39732ed9a41bb2dfa14933a830a7f10e8e3b2edc2ada07c376a738c0ec77e0",
"cmd/go-omni/apigen/workspaceMixed_test.go": "119f2a2aadc2b3ee2471bed1309c6ad8e36488b258f2ab1ba5ba5bab4219643f",
"cmd/go-omni/apigen/workspaceCatalog_test.go": "372874322aece46714351f088b5741642fe4e63d95062743a575958fa58b8b39",
"cmd/go-omni/workspace.go": "b7bbd3d9d58b3987090edf7e3714d7d8396e7c332174022d918a52e16396397d",
"cmd/go-omni/workspace_test.go": "0170d18158325262765d8b3067f55ca3ce9fe994f2d030cafee3e82b13bfcd4b",
"scripts/verifyWorkspaceRelease.py": "0495dc97c68ac17a7969088cf94b1cbbfa6e53f18fb36147c64bc34edb260bca"
}
}

View File

@@ -0,0 +1,43 @@
# v0.29.0 通用签名读取验收
范围:Local、S3/R2、OSS 的签名读取选项和本地范围读取。接口不包含业务身份、租户、分享记录、数据库、撤销或固定有效期。旧 Presigner、旧本地链接和默认下载行为兼容。
## 本地回归
2026-09-30,macOS arm64,Go 1.27.1:
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -count=1 ./...`:57 个测试包通过,apigen 集成包 123.143 秒。
- `GOTOOLCHAIN=go1.22.12 GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -count=1 ./...`:57 个测试包通过,apigen 集成包 113.212 秒。
- `go vet ./...`、`go build ./...`、`git diff --check`:通过。
- `go run ./examples/signedRead -check`:真实回环 HTTP 预览、中文文件名、下载、HEAD、Range 通过。
- `python3 scripts/verifyUploads.py --output reports/signedReadMinio.json`:真实隔离 MinIO 的新读取选项、原上传/分片与存储契约通过,见 [报告](signedReadMinio.json)和[日志](signedReadMinio.txt)。独立容器无外部网络、不映射端口,已清理,原有容器保持运行。
首次 MinIO 回归中,原有契约故意发送错误长度触发 SDK 重试退避,耗尽契约的 10 秒预算。测试专用 client 改为 MaxRetries=1,与现有协议测试一致;生产 client 配置未变,修正后通过。
## 覆盖内容
- 默认 attachment、inline、可选 MIME、ASCII/中文/特殊字符文件名,拒绝 CR/LF、控制字符、路径、过长参数、非法 MIME 和 disposition。
- 本地签名绑定版本、key、有效期、全部响应选项,重复参数、未知参数、签名篡改、版本剥离和旧下载 URL 注入 inline 均拒绝,拒绝前不读存储。
- 30 分钟实际签名到期时间、TTL 上下限、精确到期边界、取消 context;过期 Range/HEAD/条件请求不得读取文件或返回缓存命中。
- 本地真实文件 GET/HEAD 内容长度、前缀/后缀/开放区间/截断/多段范围、206/416,Seek 不暴露元数据,按范围读取而非全文件缓冲;顺序读取 Store 回退完整 200。
- 错误及条件响应保持 private/no-store,HEAD 成功与失败均无响应体。
- OSS SDK V1/V4 独立重新计算签名,验证响应头在签名前加入,中文文件名、配置域名、到期时间及选项篡改;拒绝未签名配置。S3/R2 验证原生 SDK 查询参数与实际到期时间。
- MinIO 实际 GET/Range 的响应头、字节和状态正确,篡改 disposition/type/cache/expiry 被服务端拒绝。
## 验证边界
OSS/R2 未使用云账号实测;SDK/MinIO 验证不能代替实际桶和域名验收。OSS 默认域名可能强制下载,其响应头覆盖受平台规则限制,需部署方检查实际 200/206。
浏览器控制工具不可用,隔离 Chrome/Chromium 截图尝试未获得成功渲染证据,因此不计为浏览器预览通过。真实 PDF 的 HTTP 响应已检查,浏览器视觉预览与实际云域名接入仍需项目侧回归。
## 发布验收
三个独立空模块缓存安装远程固定 `v0.29.0-preview`,均无本地 replace,全部通过:
- [综合远程报告](signedReadRemote.json):107 项检查、11 组生成消费者,138.76 秒。包括下载模块中的签名读取、旧链接兼容、篡改/过期、范围读取、OSS V1/V4、S3/R2 和真实 HTTP 示例,以及原有生成器、生命周期、配置、i18n 等回归。
- [通用工程报告](signedReadProgramRemote.json):30 项,31.82 秒。
- [混合工作区报告](signedReadWorkspaceRemote.json):16 项,25.55 秒。
首次综合检查因生成的基础消费者未引入可选云存储 SDK,缺少相应 go.sum 条目而中止。验收脚本增加固定版本的可选适配器依赖准备后,从空缓存重跑全部通过;框架发布代码未修改。
三次下载 checksum 一致:`h1:FV3AzbYWBFQ7qnF3T2FWypGv2PSecCJfcOuNa2yKzRk=`。固定标签对应代码 `f0c62152d72b1b50672c29f5191ad88f8be3eee4`。验收脚本、说明和远程报告另行提交,不移动标签。

View File

@@ -0,0 +1,111 @@
{
"status": "passed",
"startedAt": "2026-09-30T14:35:32.113432+00:00",
"architecture": "arm64",
"goVersion": "go version go1.27.1 darwin/arm64",
"image": "quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z",
"imageId": "sha256:8f08aee614800a237906bd48114d733e5ac5bfac4ccdf731f141b0e880d7a253",
"sourceSha256": {
"go.mod": "e3877788b18b605a007530f80b684f06a7ae308844c2959a8750ed55ae8037a3",
"go.sum": "53c136b153ec7c91731f7c927dfda540fbd683037f3fbd1f3d067b1ec7aabc15",
"internal/storagefixture/multipart.go": "da9ec938b3bdb872e10e4f874b6a0224121c14a631dcf4e86d7ebbc1cd34a770",
"internal/storagefixture/server.go": "defacc905246a643faf5371e437f9ce458f05461e0a922766034d1f9ffdaf003",
"storage/local/local.go": "5d1ca75d0d2ca87981eef84a60bf98d0feefa2a9594940d6bd231877f4f8ec02",
"storage/local/local_test.go": "3a2241840968df8e37fd67169c3ef708228449e81f65aff908c8b60d49434dc0",
"storage/local/read_test.go": "ef1f7feb0887ea4b1f30304dfaf542fe9788b6e17856c942f1cd98448656194f",
"storage/multipart.go": "3b718ebe6da681c89a3f797892c88e7acb15c295df2376d64ac35da0976ed69d",
"storage/multipart_test.go": "4053f680f7795db2a6b4aa23590b3669eec7a609dd92c4a1e20891a5299274ee",
"storage/oss/oss.go": "4216ce9f3fe3ac5418a5b809db3cef7e99dcca72a25a2a12301f5897cda85dd5",
"storage/oss/oss_test.go": "555a954010b1218804dc7951fc75e42eae24eba9a99c0871f5ba787f3fc87d45",
"storage/oss/read.go": "429a9a9f6c586a885d7cccb76c9d572d53a79df637ff128caacb2eac35b8d862",
"storage/oss/read_test.go": "a1c273cfedd2f3ee31ce407a7ddd6fd5ad02c65ad632a91bc7f3ead3712b5bbd",
"storage/oss/upload.go": "556129611f9e5549cac8a36b4026bfb19c112a295b55192579f9ac116e20626f",
"storage/oss/upload_test.go": "08eb0ed157f3743288d781f61d4d2b7bfe77852a8259c5e598c719d35889409e",
"storage/read.go": "92787c74ae024822f82b5b84bffc9f8a355f1f2565b99ca7fbefe49157ef4851",
"storage/read_test.go": "9b11ab9640e98c1c9fa7ce1e2587df217e874e3a6a6e27a0dd0ade6fbf3d5e1a",
"storage/s3/config.go": "626ec6a2c0b06c95ee7eb7aa0832b190cfcdeb8a127b6b89e99204040bc3b041",
"storage/s3/read.go": "319b57f2ecf435b71085c7cdcafd50a1e30caa5f357f83d4db3cdcef085792b6",
"storage/s3/read_test.go": "9b64fda5c83ba41508ec23cf41aeeb9826b677caaa851dcbeb04abe1f43eed65",
"storage/s3/s3.go": "016bef5948e97f3d043d2e858716f1c02d9f27c4e02802bdc1be3860116f4b6c",
"storage/s3/s3_test.go": "9ef284926ec9936ddfec8c52a9bf04fc7b4be55c43a535f6c5a428d1535f6fec",
"storage/s3/upload.go": "3ce0b3626249ffe7a041606d6a9481743af8a0d4868fadea20ed3ed44ee0c765",
"storage/s3/upload_test.go": "a41fdbd100d9c79a3606c5942f98017afe26d841b251d96e979df87d0af93867",
"storage/signed.go": "ae4b144137627be35718c40743ebc2f063ec667c24b6d8984ccfcca034a73787",
"storage/signedUpload.go": "a8d31bcbbad64796f04611e69271e94d917c9bcd5aeabd7a97ea155b0093c814",
"storage/signed_test.go": "eaffd40894018814e976477f96cf0fbaecfa1987caad78830d9fc8e96995882f",
"storage/storage.go": "cb670872f01cf1d7b4ebf21cec9fc6f52ad93bfdf523b2f28bbb8d9c7fe39a6c",
"storage/storagetest/contract.go": "e7ad7242102437882269d4066462059d7974586b36a82d18639eca10fd932be9",
"storage/storagetest/uploads.go": "9b66f86dd94ccdf30c7d603d432bc2a55956608898e46c1274fa27d16fa1a969",
"storage/upload.go": "9ff2930ba389163acc8063b51107c09a60290609ba384de8004c498ce09f20a1",
"storage/upload_test.go": "12b60419807ed64b89e30861674e19abee7b0b7bb0e8ec9ac5b3aa2e0710a1da"
},
"commands": [
[
"go",
"test",
"-p=2",
"-c",
"-o",
"/var/folders/wm/1sg73x8n7879sp3t6m4jpnrr0000gn/T/goOmniUpload3cba6ibp/s3.test",
"./storage/s3"
],
[
"docker",
"run",
"--rm",
"--name",
"go-omni-upload-c388755df4ec",
"--platform",
"linux/arm64",
"--cpus",
"2",
"--memory",
"2048m",
"--memory-swap",
"2048m",
"--pids-limit",
"256",
"--network",
"none",
"--read-only",
"--cap-drop",
"ALL",
"--security-opt",
"no-new-privileges",
"--user",
"1000:1000",
"--tmpfs",
"/tmp:rw,nosuid,noexec,size=64m,mode=1777",
"--tmpfs",
"/data:rw,nosuid,noexec,size=128m,mode=1777",
"-v",
"/var/folders/wm/1sg73x8n7879sp3t6m4jpnrr0000gn/T/goOmniUpload3cba6ibp:/test:ro",
"-e",
"GOMAXPROCS=2",
"-e",
"MINIO_ROOT_USER=goOmniTest",
"-e",
"MINIO_ROOT_PASSWORD=goOmniTestSecret",
"-e",
"GO_OMNI_S3_TEST_ADDR=127.0.0.1:9000",
"--entrypoint",
"sh",
"quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z",
"-c",
"set -eu\nminio --version\nminio server /data --address 127.0.0.1:9000 --console-address 127.0.0.1:9001 >/tmp/minio.log 2>&1 &\nserver=$!\ntrap 'kill \"$server\" 2>/dev/null || true; wait \"$server\" 2>/dev/null || true' EXIT\n/test/s3.test -test.v -test.run '^TestS3Integration$' -test.count=1 -test.timeout=60s || { cat /tmp/minio.log; exit 1; }\n"
]
],
"containerLimits": {
"cpus": 2,
"memoryBytes": 2147483648,
"pids": 256,
"network": "none"
},
"notes": [
"MinIO verifies S3 signatures; OSS/R2 real cloud accounts are not exercised."
],
"preExistingContainersStillRunning": true,
"cleanedContainer": "go-omni-upload-c388755df4ec",
"finishedAt": "2026-09-30T14:35:34.723540+00:00",
"logSha256": "c25db6c44baee149c1e3d6d3b7f81faac9483eb79709bed25f61b03d7b9b3133"
}

View File

@@ -0,0 +1,7 @@
minio version RELEASE.2025-09-07T16-13-09Z (commit-id=07c3a429bfed433e49018cb0f78a52145d4bedeb)
Runtime: go1.24.6 linux/arm64
License: GNU AGPLv3 - https://www.gnu.org/licenses/agpl-3.0.html
Copyright: 2015-2025 MinIO, Inc.
=== RUN TestS3Integration
--- PASS: TestS3Integration (0.63s)
PASS

View File

@@ -0,0 +1,74 @@
{
"passed": true,
"frameworkVersion": "v0.29.0-preview",
"frameworkSum": "h1:FV3AzbYWBFQ7qnF3T2FWypGv2PSecCJfcOuNa2yKzRk=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 31.82,
"tests": [
"TestConflictRollsBackEarlierWrites",
"TestUnsafePaths",
"TestGeneratedProgramConsumer",
"TestOwnershipAndDrift",
"TestRecordedTemplateUpgrade",
"TestProjectGuards/module",
"TestProjectGuards/oldFramework",
"TestProjectGuards/manifest",
"TestProjectGuards/lock",
"TestProjectGuards/otherGenerator",
"TestProjectGuards/symlink",
"TestProjectGuards"
],
"checks": [
"remoteCLIInstall",
"installedVersionDefault",
"generatedConsumerBuildRaceVet",
"defaultNoAPIFiles",
"defaultNoNetworkListener",
"finiteProgram",
"SIGINT",
"SIGTERM",
"initializationFailureAndPanic",
"runErrorPanicGoexit",
"fatalComponentFailure",
"nonzeroProcessFailureAndTimeoutExits",
"partialStartupDraining",
"acknowledgementBeforeResourceClose",
"workContextSurvivesGracefulStop",
"cancellationGrace",
"unresponsiveRunAndBackgroundWork",
"retainedResourcesAndLogger",
"explicitConfigurationOnly",
"checkConfigWithoutDependencies",
"handwrittenFilesPreserved",
"generatedFileConflict",
"missingGeneratedFileRestored",
"missingHandwrittenFileNotRecreated",
"readOnlyCheckAndDryRun",
"templateDriftUpgrade",
"manifestIdentityAndVersionGuards",
"symlinkAndLockProtection",
"fileTransactionRollback",
"publishedExampleFiniteRun"
],
"sourceHashes": {
"cmd/go-omni/internal/genfs/files.go": "97e338c74ddb3145eb0ec942b69d5c21c83dfb2f9f2165376fc83366ae1e93af",
"cmd/go-omni/internal/genfs/files_test.go": "a659b31ddf195c80d48cfc00302a3f4c6d5a1ed68b58d241da03a44ad54fe34a",
"cmd/go-omni/project.go": "07e362664053e59dbb8f8f46d4c7be9d66afb93a7142a810109b66cd296ae8a6",
"cmd/go-omni/projectgen/consumer_test.go": "312442d56599c8264f14aef03722a221a72e70c038875f11c3dbfc19e9872883",
"cmd/go-omni/projectgen/generate.go": "da0cfb726c81ebe1b3dcf306130650f65d67c70df7bdc6b0899251d75d7f9b94",
"cmd/go-omni/projectgen/generate_test.go": "f113258f0150a9f569dbe422b9f40e8ba0db3d115caf09a2c81432a8ae126e03",
"cmd/go-omni/projectgen/templates/.gitignore.tmpl": "82abf6aa84ec1c3ec218b09b5ce465af616c4fe8ff6de6db09abef962a137401",
"cmd/go-omni/projectgen/templates/README.md.tmpl": "3d01821fb4af9e97ca92f47e80a520d6f64ef136e49a8bcd4fed53dec32bde23",
"cmd/go-omni/projectgen/templates/etc_config.yaml.tmpl": "c224c68f50ea00a10e9dfd317347ac843c9107738861dab7824f7a7b1ae3a1ad",
"cmd/go-omni/projectgen/templates/internal_config_config.go.tmpl": "55111dbe02f0b91ed58a36449dcee8c9d7338251730764cda0de3e77e4a82f28",
"cmd/go-omni/projectgen/templates/internal_config_loadGen.go.tmpl": "95fb848065e3e516a8026f4439ede6d8be100e1726a56a124a6e49cb1d9d817d",
"cmd/go-omni/projectgen/templates/internal_config_loader.go.tmpl": "c9ad9f26a7d84622d7f2114a020b57ba6871a86ccf6cbc015c63674026ce81c3",
"cmd/go-omni/projectgen/templates/internal_run_run.go.tmpl": "51c2b4b407aa8b350e666be49b9d888ee42070627e28303ae2e1b0c9b4d73c95",
"cmd/go-omni/projectgen/templates/internal_svc_runtimeGen.go.tmpl": "8e259634ac7fbac0d2a13629864b2a5c89e43b6e9c477db9272b00cf49dc47d1",
"cmd/go-omni/projectgen/templates/internal_svc_serviceContext.go.tmpl": "1ed62fd86fd050468cac1c158a438deed01d4c25b4b634eec4ccd84b24df476f",
"cmd/go-omni/projectgen/templates/main.go.tmpl": "c59a7773f00c31c1a67e6a0093de26bc6f2dc8dbe34c61c1fa7e1b341e4afcc2",
"cmd/go-omni/projectgen/testdata/consumer_test.go": "38df45a69e843dfe6897d0296337e10b507076ea3c7a32901c1cad340915b0ee",
"scripts/verifyProgramRelease.py": "9127743ee929cb32701e4a063372a817ead3fd2795c2722b75f2a0c9123fa85a"
}
}

View File

@@ -0,0 +1,331 @@
{
"passed": true,
"frameworkVersion": "v0.29.0-preview",
"frameworkSum": "h1:FV3AzbYWBFQ7qnF3T2FWypGv2PSecCJfcOuNa2yKzRk=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 138.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",
"generatedIncompleteHTTPDrainRetainsDependenciesAndLogger",
"recursiveResponseTypedHTTP",
"recursiveResponseReferenceGraph",
"recursiveResponseRegenerationAndDrift",
"remoteHTTPClientRaceTests",
"remoteHTTPProxyAndTimeouts",
"remoteHTTPHeaderIsolation",
"remoteHTTPPoolAndDrainBudgets",
"remoteHTTPExample",
"remoteExplicitConfigurationRaceTests",
"literalAndRequiredDefaultReferences",
"remoteCheckConfigBeforeDependencies",
"removedImplicitEnvironmentRules",
"remoteSignedReadOptionsAndLegacyLinks",
"remoteReadSignatureTamperingAndExpiry",
"remoteLocalPayloadRangesAndHEAD",
"remoteOSSNativeV1V4Signatures",
"remoteS3R2ReadOptions",
"remoteSignedReadHTTPExample",
"remotePreciseModuleDiagnostics",
"remoteJSONSuccessStatuses",
"remoteRejectedStatusBodyCleanup",
"remoteCopyTypePreflight",
"remoteEmptyCollectionTypeRejection",
"remoteLoggerTimeZoneIsolation",
"remoteCORSPolicyRace",
"remoteCORSErrorResponses",
"remoteCORSTransports",
"generatedCORSConfigOnly",
"remoteUnifiedBodyPolicyTransports",
"remoteBodyLimitsBeforeMiddleware",
"remoteHardLimitConflictBeforeServing",
"remoteCanceledHandlerCleanupBarrier",
"remoteExternalServingResourceRetention",
"remoteAdmissionCloseRace",
"remoteRuntimeFinishRetainsHTTPDependencies",
"remoteHTTP2DisconnectedHandlerDrain",
"remoteNativeEarly413AndBusinessHTTP200",
"remoteHTTPSafetyExampleBuild",
"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": "d34fb26879932153671b9cbd164daae484b23dca08b8a3be5b1b89f8f6168e47",
"lifecycle/phases.go": "6e72499d2c5fcecb57cf712e76e3a03fed61a4caf528f873f2058291a8ab0b4d",
"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": "866a7ab9cc28f987645785203213e10722490970d5a1fd3c060d88db8b1278dd",
"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": "c97c8c797105516e8e6870efffd662c7894e99d882a36207dee0162016b728fd",
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
"httpx/response.go": "99e29724e4fa51d2c7ec1a52892c24ac5daf210a2ee547c5e69a175015298efd",
"app/app.go": "2e672b3f6376049dd9695f799698439909f1d649d4338d13478e0dbf1de68800",
"app/transport.go": "8c665f47447d9839bba7ba3efa290316be9635179d2276b0da0d0f8eda3842ec",
"scripts/verifyManagedRelease.py": "4ef18cd08909ce0792af150539219c542facecc98204f1dd938c0646acc32e4e",
"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": "cb4a07539adbd163c13fc6cbb914518506b0c60a88473589f212f3406e763e4b",
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "94a5787218c0d92159b473d390197858241ede2cab591df0976e611ed279dcfe",
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "b078bd9052e513ab6173a1e65cf54b28d3cfad445cb50518d9e2e76736071047",
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "589e2c54d9ad7f64e88ac556329a52062a27b4e898deb9a97feff200787c32c1",
"cmd/go-omni/apigen/testdata/managed/operations.go": "a1d91744abe020cbfb476512cffb7dad422a95da1c3be6957fdcfe27c361030b",
"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": "b0d573b5dbdfe397b0ca2bfc2e79c85c589bd11b2269f7206b3828cf0ff5afd7",
"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": "cbf09c158a9add8289befd9960f911a8cf42de0b840a57278bc71bf800121ec8",
"config/cors_test.go": "b40e76dd943f28c80a06eeb2dc74a536b217c4918c4a21d7de870404483a377a",
"config/errorCatalog_test.go": "a9cca5233da1ebf9280a022e10ff07e50f99cf01eac8ae70b905fe12665e3d9d",
"config/suggestions.go": "5220f78bc8e4fda44f4cedc6cc6227895417d83d49eaef3f54711f3a0f9e1245",
"config/http_test.go": "975dc288486ba537cf7fd7ee87df792ba7eceed6ab0a58e6a5c0660ce724ab9d",
"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": "d02858d522ad63e86185f5d87d3fec9718141c75b7199fe75d0894dfa016d24d",
"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",
"app/body.go": "4064b96fc38c5b75ec17f0d82e0dec79242640c4d0903bf544e4fda057e5397a",
"app/router.go": "b0418e18fd546b855a4cce9ff19efc25b274d9b9c52144e12338bb5d97a5be9f",
"app/requests.go": "c4cb453e8215ed594e6755b256b4b1bbcd3b870584f4be19480d4dd1b9e0aa3d",
"app/bodyPolicy_test.go": "505afa7bcc3f70009ef2af2c455e377277db01c2b883c8e5204de0cbebf25cbb",
"app/requests_test.go": "b008b5599e1f5eea01a08f15220588bd581a2d5e98658d53f0acb273dd946137",
"app/transport_test.go": "10f086eeb77e3b11e83768bbbb0372aa68056a8c8839a18edfaf597b5acff689",
"httpx/context.go": "e3a7d3cd31bc6bddaf21c2be35887cf5c85e69ec5e6ff82178884cb0e4696ffb",
"internal/httplimits/limits.go": "98ba40ceda78fcf4b08d51b0851de851329f568fd35b0a4aedefd4b48a43e780",
"lifecycle/runtime_test.go": "ccd302f78535215bea69d7c8ad8c812c79c52cca087353358206cb6b5926d34b",
"transport/http2/server.go": "e0b7fdb3069583d042df5117b36a3fffc423ee452b0a9da162e3604fcce80b49",
"transport/http2/lifecycle_test.go": "2a298b8e466ff4145105338904ec5d9e19087c91d829d6ae5cad8c8b1ee05b15",
"transport/native/server.go": "8c2fc503bc157265838b453bfe289ec68eeb2bdaf63f6dd1e585536a8278ba0c",
"transport/native/earlyReject_test.go": "0fd598e24199beb55bead10d741d739f7361345584c4430b4a97c35d59d62774",
"examples/httpSafety/main.go": "4651b3806e7a8030101ba6a70794fcc18a50d2939fe0a59d7f80bb4a7de30f15",
"storage/upload.go": "9ff2930ba389163acc8063b51107c09a60290609ba384de8004c498ce09f20a1",
"storage/read_test.go": "9b11ab9640e98c1c9fa7ce1e2587df217e874e3a6a6e27a0dd0ade6fbf3d5e1a",
"storage/signedUpload.go": "a8d31bcbbad64796f04611e69271e94d917c9bcd5aeabd7a97ea155b0093c814",
"storage/multipart_test.go": "4053f680f7795db2a6b4aa23590b3669eec7a609dd92c4a1e20891a5299274ee",
"storage/multipart.go": "3b718ebe6da681c89a3f797892c88e7acb15c295df2376d64ac35da0976ed69d",
"storage/signed_test.go": "eaffd40894018814e976477f96cf0fbaecfa1987caad78830d9fc8e96995882f",
"storage/upload_test.go": "12b60419807ed64b89e30861674e19abee7b0b7bb0e8ec9ac5b3aa2e0710a1da",
"storage/storage.go": "cb670872f01cf1d7b4ebf21cec9fc6f52ad93bfdf523b2f28bbb8d9c7fe39a6c",
"storage/signed.go": "ae4b144137627be35718c40743ebc2f063ec667c24b6d8984ccfcca034a73787",
"storage/read.go": "92787c74ae024822f82b5b84bffc9f8a355f1f2565b99ca7fbefe49157ef4851",
"storage/s3/upload.go": "3ce0b3626249ffe7a041606d6a9481743af8a0d4868fadea20ed3ed44ee0c765",
"storage/s3/read_test.go": "9b64fda5c83ba41508ec23cf41aeeb9826b677caaa851dcbeb04abe1f43eed65",
"storage/s3/config.go": "626ec6a2c0b06c95ee7eb7aa0832b190cfcdeb8a127b6b89e99204040bc3b041",
"storage/s3/upload_test.go": "a41fdbd100d9c79a3606c5942f98017afe26d841b251d96e979df87d0af93867",
"storage/s3/read.go": "319b57f2ecf435b71085c7cdcafd50a1e30caa5f357f83d4db3cdcef085792b6",
"storage/s3/s3_test.go": "9ef284926ec9936ddfec8c52a9bf04fc7b4be55c43a535f6c5a428d1535f6fec",
"storage/s3/s3.go": "016bef5948e97f3d043d2e858716f1c02d9f27c4e02802bdc1be3860116f4b6c",
"storage/local/read_test.go": "ef1f7feb0887ea4b1f30304dfaf542fe9788b6e17856c942f1cd98448656194f",
"storage/local/local_test.go": "3a2241840968df8e37fd67169c3ef708228449e81f65aff908c8b60d49434dc0",
"storage/local/local.go": "5d1ca75d0d2ca87981eef84a60bf98d0feefa2a9594940d6bd231877f4f8ec02",
"storage/oss/upload.go": "556129611f9e5549cac8a36b4026bfb19c112a295b55192579f9ac116e20626f",
"storage/oss/read_test.go": "a1c273cfedd2f3ee31ce407a7ddd6fd5ad02c65ad632a91bc7f3ead3712b5bbd",
"storage/oss/oss_test.go": "555a954010b1218804dc7951fc75e42eae24eba9a99c0871f5ba787f3fc87d45",
"storage/oss/upload_test.go": "08eb0ed157f3743288d781f61d4d2b7bfe77852a8259c5e598c719d35889409e",
"storage/oss/oss.go": "4216ce9f3fe3ac5418a5b809db3cef7e99dcca72a25a2a12301f5897cda85dd5",
"storage/oss/read.go": "429a9a9f6c586a885d7cccb76c9d572d53a79df637ff128caacb2eac35b8d862",
"storage/storagetest/contract.go": "e7ad7242102437882269d4066462059d7974586b36a82d18639eca10fd932be9",
"storage/storagetest/uploads.go": "9b66f86dd94ccdf30c7d603d432bc2a55956608898e46c1274fa27d16fa1a969",
"examples/signedRead/main.go": "f921a3c5fbcbfa84291c0c95a9c2c211319a8f0537acc5ec640238f251e652e1",
"examples/signedRead/main_test.go": "6ab559c442b2aa355231efe0342b9ec3617bf97bb1f6dfd7ad304b9a452e4fc4"
}
}

View File

@@ -0,0 +1,34 @@
{
"passed": true,
"frameworkVersion": "v0.29.0-preview",
"frameworkSum": "h1:FV3AzbYWBFQ7qnF3T2FWypGv2PSecCJfcOuNa2yKzRk=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 25.55,
"checks": [
"remoteCLIInstall",
"remoteAPIModule",
"remoteGenericModule",
"explicitKinds",
"manifestInferredKinds",
"mixedFormatWithProjectSkip",
"mixedGeneration",
"mixedCheck",
"nativeGoWorkBuild",
"nativeGoWorkRaceTests",
"moduleAndWorkspaceMetadataPreserved",
"bothProjectFailuresAggregated",
"nonzeroFailureExit",
"failedCheckIsReadOnly",
"crossProjectPreflight",
"missingGeneratedOutputRestored"
],
"sourceHashes": {
"cmd/go-omni/apigen/workspace.go": "cc39732ed9a41bb2dfa14933a830a7f10e8e3b2edc2ada07c376a738c0ec77e0",
"cmd/go-omni/apigen/workspaceMixed_test.go": "119f2a2aadc2b3ee2471bed1309c6ad8e36488b258f2ab1ba5ba5bab4219643f",
"cmd/go-omni/apigen/workspaceCatalog_test.go": "372874322aece46714351f088b5741642fe4e63d95062743a575958fa58b8b39",
"cmd/go-omni/workspace.go": "b7bbd3d9d58b3987090edf7e3714d7d8396e7c332174022d918a52e16396397d",
"cmd/go-omni/workspace_test.go": "0170d18158325262765d8b3067f55ca3ce9fe994f2d030cafee3e82b13bfcd4b",
"scripts/verifyWorkspaceRelease.py": "0495dc97c68ac17a7969088cf94b1cbbfa6e53f18fb36147c64bc34edb260bca"
}
}

View File

@@ -0,0 +1,52 @@
goos: darwin
goarch: arm64
pkg: git.yuntuo.hk/k2safe/go-omni/ws
cpu: Apple M2 Max
BenchmarkClientRoundTrip-12 15706 47299 ns/op 0.19 MB/s 1520 B/op 13 allocs/op
BenchmarkClientRoundTrip-12 24013 26259 ns/op 0.34 MB/s 1520 B/op 13 allocs/op
BenchmarkClientRoundTrip-12 23534 25959 ns/op 0.35 MB/s 1520 B/op 13 allocs/op
BenchmarkServerRoundTrip/64-12 24288 24335 ns/op 5.26 MB/s 1568 B/op 13 allocs/op
BenchmarkServerRoundTrip/64-12 23325 23724 ns/op 5.40 MB/s 1568 B/op 13 allocs/op
BenchmarkServerRoundTrip/64-12 25240 23571 ns/op 5.43 MB/s 1568 B/op 13 allocs/op
BenchmarkServerRoundTrip/1024-12 23334 26435 ns/op 77.47 MB/s 7136 B/op 17 allocs/op
BenchmarkServerRoundTrip/1024-12 22939 28210 ns/op 72.60 MB/s 7136 B/op 17 allocs/op
BenchmarkServerRoundTrip/1024-12 23402 30573 ns/op 66.99 MB/s 7136 B/op 17 allocs/op
BenchmarkServerRoundTrip/65536-12 4402 140895 ns/op 930.28 MB/s 635956 B/op 45 allocs/op
BenchmarkServerRoundTrip/65536-12 4201 144080 ns/op 909.72 MB/s 635955 B/op 45 allocs/op
BenchmarkServerRoundTrip/65536-12 4046 145193 ns/op 902.74 MB/s 635953 B/op 45 allocs/op
BenchmarkSessionSend-12 110679 5370 ns/op 47.67 MB/s 1168 B/op 9 allocs/op
BenchmarkSessionSend-12 110362 5340 ns/op 47.94 MB/s 1168 B/op 9 allocs/op
BenchmarkSessionSend-12 113160 5339 ns/op 47.95 MB/s 1168 B/op 9 allocs/op
PASS
ok git.yuntuo.hk/k2safe/go-omni/ws 12.286s
goos: darwin
goarch: arm64
pkg: git.yuntuo.hk/k2safe/go-omni/ws/hub
cpu: Apple M2 Max
BenchmarkHubBroadcast/1-12 3537676 152.5 ns/op 256 B/op 2 allocs/op
BenchmarkHubBroadcast/1-12 3975487 151.5 ns/op 256 B/op 2 allocs/op
BenchmarkHubBroadcast/1-12 3925134 154.0 ns/op 256 B/op 2 allocs/op
BenchmarkHubBroadcast/100-12 176622 3444 ns/op 3544 B/op 4 allocs/op
BenchmarkHubBroadcast/100-12 170431 3533 ns/op 3544 B/op 4 allocs/op
BenchmarkHubBroadcast/100-12 166556 3982 ns/op 3544 B/op 4 allocs/op
BenchmarkHubBroadcast/1000-12 8853 64870 ns/op 54656 B/op 6 allocs/op
BenchmarkHubBroadcast/1000-12 8582 65407 ns/op 54657 B/op 6 allocs/op
BenchmarkHubBroadcast/1000-12 8001 64712 ns/op 54657 B/op 6 allocs/op
PASS
ok git.yuntuo.hk/k2safe/go-omni/ws/hub 6.195s
PASS
ok git.yuntuo.hk/k2safe/go-omni/ws/observe 0.280s
PASS
ok git.yuntuo.hk/k2safe/go-omni/ws/omni 0.300s
goos: darwin
goarch: arm64
pkg: git.yuntuo.hk/k2safe/go-omni/ws/rpc
cpu: Apple M2 Max
BenchmarkPeerCall-12 31053 17732 ns/op 8386 B/op 193 allocs/op
BenchmarkPeerCall-12 33787 17619 ns/op 8386 B/op 193 allocs/op
BenchmarkPeerCall-12 33901 17817 ns/op 8386 B/op 193 allocs/op
BenchmarkProtocolDecode-12 123168 4893 ns/op 19.62 MB/s 3656 B/op 106 allocs/op
BenchmarkProtocolDecode-12 122533 4824 ns/op 19.90 MB/s 3656 B/op 106 allocs/op
BenchmarkProtocolDecode-12 125842 4675 ns/op 20.54 MB/s 3656 B/op 106 allocs/op
PASS
ok git.yuntuo.hk/k2safe/go-omni/ws/rpc 4.531s

View File

@@ -0,0 +1,86 @@
ok git.yuntuo.hk/k2safe/go-omni 0.400s
ok git.yuntuo.hk/k2safe/go-omni/app 5.723s
ok git.yuntuo.hk/k2safe/go-omni/audit (cached)
ok git.yuntuo.hk/k2safe/go-omni/auth 1.045s
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 3.720s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen 31.148s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/internal/genfs (cached)
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/projectgen (cached)
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold 2.786s
ok git.yuntuo.hk/k2safe/go-omni/config (cached)
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.063s
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)
? git.yuntuo.hk/k2safe/go-omni/examples/httpSafety [no test files]
ok git.yuntuo.hk/k2safe/go-omni/examples/i18n 1.968s
ok git.yuntuo.hk/k2safe/go-omni/examples/modules 2.777s
ok git.yuntuo.hk/k2safe/go-omni/examples/platform 2.707s
ok git.yuntuo.hk/k2safe/go-omni/examples/reference 2.993s
? 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]
ok git.yuntuo.hk/k2safe/go-omni/examples/signedRead (cached)
ok git.yuntuo.hk/k2safe/go-omni/examples/telemetryHeaders 2.822s
? git.yuntuo.hk/k2safe/go-omni/examples/transactions [no test files]
? git.yuntuo.hk/k2safe/go-omni/examples/websocket [no test files]
ok git.yuntuo.hk/k2safe/go-omni/examples/websocketRPC 2.839s
ok git.yuntuo.hk/k2safe/go-omni/health 3.382s
ok git.yuntuo.hk/k2safe/go-omni/httpc (cached)
ok git.yuntuo.hk/k2safe/go-omni/httpx 3.869s
ok git.yuntuo.hk/k2safe/go-omni/i18n (cached)
? 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]
? git.yuntuo.hk/k2safe/go-omni/internal/httplimits [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 2.257s
ok git.yuntuo.hk/k2safe/go-omni/openapi 2.425s
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 2.595s
ok git.yuntuo.hk/k2safe/go-omni/transport 2.868s
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 5.567s
ok git.yuntuo.hk/k2safe/go-omni/transport/nethttp 3.181s
? 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/ws 3.875s
ok git.yuntuo.hk/k2safe/go-omni/ws/hub 3.462s
ok git.yuntuo.hk/k2safe/go-omni/ws/observe 3.337s
ok git.yuntuo.hk/k2safe/go-omni/ws/omni 3.019s
ok git.yuntuo.hk/k2safe/go-omni/ws/rpc 3.064s

View File

@@ -0,0 +1,85 @@
# WebSocket 验收记录(2026-10-29)
本次增加通用 WS Client/Server/Session、Hub、可选 RPC/订阅恢复、观测、App 生命周期与生成项目扩展。对应版本 `v0.31.0-preview`,本文记录源码验证;远程发布验收单独记录。
## 结论
- 功能、真实 TCP/TLS、并发竞态、静态检查、Go 1.22 兼容性和可运行 Go 示例通过。
- 最终全库回归输出见 [websocket-regression-go1.25.txt](websocket-regression-go1.25.txt);Go 1.27.3 下 `go test ./ws/...` 也通过。生成示例只同步 appGen 与清单,生成漂移检查及消费项目编译通过;新增生成消费回归验证了自定义路由和失败清理。
- Origin 模糊测试发现并修复无括号 IPv6 归一化歧义,失败样本保留在 `ws/testdata/fuzz/FuzzOrigin`;修复后 10 秒约 552,325 次执行通过。
- 新增核心包在隔离 Go 1.27.3 工具链、govulncheck v1.9.0 下扫描未发现已知漏洞。
- **包含 ws/omni 的完整接入安全扫描未通过**:仓库原有 `golang.org/x/net v0.33.0` 报告 4 项 HTTP/2 可达漏洞。不能将本次功能验收解释为整个框架已通过安全发布门槛。
- 浏览器自动化访问 `http://127.0.0.1:8088` 被浏览器权限策略拒绝,因此浏览器 UI/浏览器协议实测未完成;不以 Go 客户端测试代替这一项。示例源文件可供手动运行,临时服务已停止。
## 功能与并发验证
使用 Apple M2 Max / darwin arm64,主测试工具链 Go 1.25.0。独立 Go 1.22.12 用于最低版本兼容检查,没有替换系统 Go。
执行过:
```sh
go test ./...
go test -race -count=5 -timeout=120s ./ws/... ./examples/websocketRPC ./app ./httpx
go test -race -coverprofile=/tmp/go-omni-websocket-coverage.out ./ws/...
go vet ./ws/... ./app ./httpx ./examples/websocket ./examples/websocketRPC
GOTOOLCHAIN=go1.22.12 go test ./ws/... ./app ./httpx
go test ./ws -run '^$' -fuzz FuzzOrigin -fuzztime=10s -parallel=4
go test ./ws/rpc -run '^$' -fuzz FuzzProtocolDecode -fuzztime=10s
go run ./examples/websocket
go run ./examples/websocketRPC
```
真实网络覆盖:回显、文本/二进制、128 个并行连接各 50 次往返、TLS/mTLS、HTTP CONNECT 代理认证、握手取消/超时、重连时刷新认证头、重试预算、快速重连与连接上限、双向嵌套 RPC、断线后两代 Session 的订阅恢复。
最终审查另修复了远端 `1001 going away` 被当成本地主动关闭、导致 Run 不重连的问题,并验证本地主动关闭仍停止重试;补充累计拨号/失败/重连指标和配置上界。修复后 `go test -race -count=3 ./ws/...` 通过。
安全与资源覆盖:同源/白名单/无 Origin/重复 Origin/null、拒绝错误子协议、认证拒绝与 panic、连接/握手速率限额、非法 UTF-8、压缩膨胀限额、发送数据拷贝、接收队列满、慢消费者、阻塞业务时仍处理 Ping/Pong、取消写入、断网、反复关闭、握手与停止并发、回调未退出时保留共享资源。
RPC 覆盖:双向请求、取消、迟到回复、重复在途 ID、未知字段、重复 JSON 键、嵌套深度、消息长度、pending/执行并发上限、公共与内部错误区分、panic 脱敏、订阅期望状态与恢复。RPC fuzz 10 秒约 71,268 次执行通过。
覆盖率快照:
| 包 | 语句覆盖率 |
| --- | ---: |
| ws | 84.7% |
| ws/hub | 86.5% |
| ws/observe | 88.4% |
| ws/omni | 86.7% |
| ws/rpc | 80.7% |
覆盖率只描述这组测试执行到的代码,不等同于正确性或安全证明。测试不包含生产长时间浸泡、跨地区网络容量、HTTP/2 extended CONNECT 或 Autobahn 全套认证。
## 性能基准
```sh
go test -run '^$' -bench . -benchmem -benchtime=500ms -count=3 ./ws/...
```
原始输出:[websocket-bench-go1.25.txt](websocket-bench-go1.25.txt)。环境为 Apple M2 Max、Go 1.25.0、GOMAXPROCS=12、loopback、无 TLS、压缩关闭。三次样本的范围如下;首轮客户端样本与其它验证有时间重叠,保留原始波动,不选取单个最好值作为承诺。
| 基准 | 时间/操作 | 分配 |
| --- | ---: | ---: |
| Client 回声往返(9 B) | 26.0–47.3 μs | 1520 B / 13 allocs |
| Server 回声往返(64 B) | 23.6–24.3 μs | 1568 B / 13 allocs |
| Server 回声往返(1 KiB) | 26.4–30.6 μs | 7136 B / 17 allocs |
| Server 回声往返(64 KiB) | 140.9–145.2 μs | 约 636 KB / 45 allocs |
| Session 发送(256 B) | 5.34–5.37 μs | 1168 B / 9 allocs |
| Hub 广播到 1000 个模拟连接 | 64.7–65.4 μs | 约 54.7 KB / 6 allocs |
| 内存传输 RPC Call | 17.6–17.8 μs | 8386 B / 193 allocs |
| 严格 RPC envelope 解析 | 4.68–4.89 μs | 3656 B / 106 allocs |
网络往返基准包括双方处理及本机 socket 成本;64 KiB 场景的分配包含双方完整读入及安全发送拷贝,后续大消息优化需在保持消息所有权的前提下进行。Hub 基准仅测注册表和分发,RPC 内存基准不包含网络。这里没有从单连接 loopback 数字外推生产 QPS 或最大连接数。
内存预算需按连接数乘以消息/队列上限估算,并额外计入解码中消息、协议缓冲和业务持有的 payload。默认 4096 连接上限并不意味着任何机器都能承受所有连接同时用满双向 4 MiB 队列。部署时需根据机器容量设置 MaxConnections、消息与队列字节数、RPC 执行并发;压缩及 TLS 应另做容量测量。
## 漏洞扫描与剩余风险
扫描包含源码可达性分析,不是人工证明每条告警均可利用。没有隐藏、忽略或豁免扫描结果。
1. Go 1.25.0 + govulncheck v1.1.4 扫描 `./ws/...`:29 项标准库 symbol findings,以及 4 项 x/net symbol findings;Gorilla WebSocket 没有报告项。工具链版本是现有本机版本。扫描结果摘要保留在 [JSON](websocket-vuln-go1.25.json)。
2. 隔离 Go 1.27.3 + govulncheck v1.9.0 扫描 `./ws/...`:标准库告警消除,剩余 4 项 x/net HTTP/2 findings,详见 [完整输出](websocket-vuln-patched-go.txt)。
3. 同一修补工具链扫描 `./ws ./ws/hub ./ws/rpc ./ws/observe`:`No vulnerabilities found.`,见 [输出](websocket-vuln-core-patched-go.txt)。这不覆盖 ws/omni 引入的原有 App/HTTP2 依赖图。
剩余报告:GO-2026-6617(HPACK 并发)、GO-2026-6612(流控退款)、GO-2026-6611(重复窗口变更 CPU)、GO-2026-6603(Trailer 内存)。扫描器给出的修复版本为 `golang.org/x/net v0.60.0`;它的 [官方 go.mod](https://raw.githubusercontent.com/golang/net/v0.60.0/go.mod) 要求 Go 1.26,并升级 x/crypto、x/sys、x/text。此变更会改变框架当前公开的 Go 1.22 基线与多个既有适配器,未在 WS 功能提交中隐式实施。需要作为明确的框架安全升级进行全库与消费项目回归,生产发布前解决。
生产应使用受支持且已修补的 Go 工具链。Go 1.22 测试只验证源码兼容,不代表该旧工具链具备当前安全修复。对应标准库证书问题的官方说明见 [GO-2026-6809](https://pkg.go.dev/vuln/GO-2026-6809);HTTP/2 问题见 [GO-2026-6617](https://pkg.go.dev/vuln/GO-2026-6617)。

View File

@@ -0,0 +1 @@
No vulnerabilities found.

View File

@@ -0,0 +1,839 @@
{
"command": "go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 -format=json ./ws/...",
"config": {
"protocol_version": "v1.0.0",
"scanner_name": "govulncheck",
"scanner_version": "v1.1.4",
"db": "https://vuln.go.dev",
"db_last_modified": "2026-10-27T21:16:18Z",
"go_version": "go1.25.0",
"scan_level": "symbol",
"scan_mode": "source"
},
"findings": [
{
"id": "GO-2025-3487",
"module": "golang.org/x/crypto",
"found": "v0.31.0",
"fixed": "v0.35.0",
"symbolReachable": false,
"summary": "Potential denial of service in golang.org/x/crypto"
},
{
"id": "GO-2025-4116",
"module": "golang.org/x/crypto",
"found": "v0.31.0",
"fixed": "v0.43.0",
"symbolReachable": false,
"summary": "Potential denial of service in golang.org/x/crypto/ssh/agent"
},
{
"id": "GO-2025-4134",
"module": "golang.org/x/crypto",
"found": "v0.31.0",
"fixed": "v0.45.0",
"symbolReachable": false,
"summary": "Unbounded memory consumption in golang.org/x/crypto/ssh"
},
{
"id": "GO-2025-4135",
"module": "golang.org/x/crypto",
"found": "v0.31.0",
"fixed": "v0.45.0",
"symbolReachable": false,
"summary": "Malformed constraint may cause denial of service in golang.org/x/crypto/ssh/agent"
},
{
"id": "GO-2026-5005",
"module": "golang.org/x/crypto",
"found": "v0.31.0",
"fixed": "v0.52.0",
"symbolReachable": false,
"summary": "Invoking key constraints not enforced in golang.org/x/crypto/ssh/agent"
},
{
"id": "GO-2026-5006",
"module": "golang.org/x/crypto",
"found": "v0.31.0",
"fixed": "v0.52.0",
"symbolReachable": false,
"summary": "Invoking agent constraints dropped when forwarding keys in golang.org/x/crypto/ssh/agent"
},
{
"id": "GO-2026-5013",
"module": "golang.org/x/crypto",
"found": "v0.31.0",
"fixed": "v0.52.0",
"symbolReachable": false,
"summary": "Invoking byte arithmetic causes underflow and panic in golang.org/x/crypto/ssh"
},
{
"id": "GO-2026-5014",
"module": "golang.org/x/crypto",
"found": "v0.31.0",
"fixed": "v0.52.0",
"symbolReachable": false,
"summary": "Invoking bypass of certificate restrictions in golang.org/x/crypto/ssh"
},
{
"id": "GO-2026-5015",
"module": "golang.org/x/crypto",
"found": "v0.31.0",
"fixed": "v0.52.0",
"symbolReachable": false,
"summary": "Invoking server panic during CheckHostKey/Authenticate in golang.org/x/crypto/ssh"
},
{
"id": "GO-2026-5016",
"module": "golang.org/x/crypto",
"found": "v0.31.0",
"fixed": "v0.52.0",
"symbolReachable": false,
"summary": "Invoking memory leak when rejecting channels can lead to DoS in golang.org/x/crypto/ssh"
},
{
"id": "GO-2026-5017",
"module": "golang.org/x/crypto",
"found": "v0.31.0",
"fixed": "v0.52.0",
"symbolReachable": false,
"summary": "Invoking client can cause server deadlock on unexpected responses in golang.org/x/crypto/ssh"
},
{
"id": "GO-2026-5018",
"module": "golang.org/x/crypto",
"found": "v0.31.0",
"fixed": "v0.52.0",
"symbolReachable": false,
"summary": "Invoking pathological RSA/DSA parameters may cause DoS in golang.org/x/crypto/ssh"
},
{
"id": "GO-2026-5019",
"module": "golang.org/x/crypto",
"found": "v0.31.0",
"fixed": "v0.52.0",
"symbolReachable": false,
"summary": "Invoking bypass of FIDO/U2F security keys physical interaction in golang.org/x/crypto/ssh"
},
{
"id": "GO-2026-5020",
"module": "golang.org/x/crypto",
"found": "v0.31.0",
"fixed": "v0.52.0",
"symbolReachable": false,
"summary": "Invoking infinite loop on large channel writes in golang.org/x/crypto/ssh"
},
{
"id": "GO-2026-5021",
"module": "golang.org/x/crypto",
"found": "v0.31.0",
"fixed": "v0.52.0",
"symbolReachable": false,
"summary": "Invoking auth bypass via unenforced @revoked status in golang.org/x/crypto/ssh/knownhosts"
},
{
"id": "GO-2026-5023",
"module": "golang.org/x/crypto",
"found": "v0.31.0",
"fixed": "v0.52.0",
"symbolReachable": false,
"summary": "Invoking VerifiedPublicKeyCallback permissions skip enforcement in golang.org/x/crypto/ssh"
},
{
"id": "GO-2026-5033",
"module": "golang.org/x/crypto",
"found": "v0.31.0",
"fixed": "v0.52.0",
"symbolReachable": false,
"summary": "Invoking pathological inputs can lead to client panic in golang.org/x/crypto/ssh/agent"
},
{
"id": "GO-2026-5932",
"module": "golang.org/x/crypto",
"found": "v0.31.0",
"fixed": null,
"symbolReachable": false,
"summary": "The golang.org/x/crypto/openpgp package is unmaintained, unsafe by design, and has known security issues"
},
{
"id": "GO-2026-6303",
"module": "golang.org/x/crypto",
"found": "v0.31.0",
"fixed": "v0.55.0",
"symbolReachable": false,
"summary": "Source-address critical option not enforced for non-public-key auth callbacks in golang.org/x/crypto/ssh"
},
{
"id": "GO-2026-6354",
"module": "golang.org/x/crypto",
"found": "v0.31.0",
"fixed": "v0.56.0",
"symbolReachable": false,
"summary": "Prevent DoS on deadlocked undecided channel in golang.org/x/crypto/ssh"
},
{
"id": "GO-2026-6355",
"module": "golang.org/x/crypto",
"found": "v0.31.0",
"fixed": "v0.56.0",
"symbolReachable": false,
"summary": "Prevent DoS on deadlocked established channel in golang.org/x/crypto/ssh"
},
{
"id": "GO-2025-3955",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.1",
"symbolReachable": false,
"summary": "CrossOriginProtection insecure bypass patterns not limited to exact matches in net/http"
},
{
"id": "GO-2025-4006",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.2",
"symbolReachable": false,
"summary": "Excessive CPU consumption in ParseAddress in net/mail"
},
{
"id": "GO-2025-4007",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.3",
"symbolReachable": true,
"summary": "Quadratic complexity when checking name constraints in crypto/x509"
},
{
"id": "GO-2025-4008",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.2",
"symbolReachable": true,
"summary": "ALPN negotiation error contains attacker controlled information in crypto/tls"
},
{
"id": "GO-2025-4009",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.2",
"symbolReachable": false,
"summary": "Quadratic complexity when parsing some invalid inputs in encoding/pem"
},
{
"id": "GO-2025-4010",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.2",
"symbolReachable": true,
"summary": "Insufficient validation of bracketed IPv6 hostnames in net/url"
},
{
"id": "GO-2025-4011",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.2",
"symbolReachable": true,
"summary": "Parsing DER payload can cause memory exhaustion in encoding/asn1"
},
{
"id": "GO-2025-4012",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.2",
"symbolReachable": true,
"summary": "Lack of limit when parsing cookies can cause memory exhaustion in net/http"
},
{
"id": "GO-2025-4013",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.2",
"symbolReachable": true,
"summary": "Panic when validating certificates with DSA public keys in crypto/x509"
},
{
"id": "GO-2025-4014",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.2",
"symbolReachable": false,
"summary": "Unbounded allocation when parsing GNU sparse map in archive/tar"
},
{
"id": "GO-2025-4015",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.2",
"symbolReachable": false,
"summary": "Excessive CPU consumption in Reader.ReadResponse in net/textproto"
},
{
"id": "GO-2025-4155",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.5",
"symbolReachable": true,
"summary": "Excessive resource consumption when printing error string for host certificate validation in crypto/x509"
},
{
"id": "GO-2025-4175",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.5",
"symbolReachable": true,
"summary": "Improper application of excluded DNS name constraints when verifying wildcard names in crypto/x509"
},
{
"id": "GO-2026-4337",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.7",
"symbolReachable": true,
"summary": "Unexpected session resumption in crypto/tls"
},
{
"id": "GO-2026-4340",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.6",
"symbolReachable": true,
"summary": "Handshake messages may be processed at the incorrect encryption level in crypto/tls"
},
{
"id": "GO-2026-4341",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.6",
"symbolReachable": false,
"summary": "Memory exhaustion in query parameter parsing in net/url"
},
{
"id": "GO-2026-4342",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.6",
"symbolReachable": false,
"summary": "Excessive CPU consumption when building archive index in archive/zip"
},
{
"id": "GO-2026-4601",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.8",
"symbolReachable": true,
"summary": "Incorrect parsing of IPv6 host literals in net/url"
},
{
"id": "GO-2026-4602",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.8",
"symbolReachable": true,
"summary": "FileInfo can escape from a Root in os"
},
{
"id": "GO-2026-4603",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.8",
"symbolReachable": false,
"summary": "URLs in meta content attribute actions are not escaped in html/template"
},
{
"id": "GO-2026-4864",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.9",
"symbolReachable": false,
"summary": "TOCTOU permits root escape on Linux via Root.Chmod in os in internal/syscall/unix"
},
{
"id": "GO-2026-4865",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.9",
"symbolReachable": false,
"summary": "JsBraceDepth Context Tracking Bugs (XSS) in html/template"
},
{
"id": "GO-2026-4869",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.9",
"symbolReachable": false,
"summary": "Unbounded allocation for old GNU sparse in archive/tar"
},
{
"id": "GO-2026-4870",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.9",
"symbolReachable": true,
"summary": "Unauthenticated TLS 1.3 KeyUpdate record can cause persistent connection retention and DoS in crypto/tls"
},
{
"id": "GO-2026-4918",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.10",
"symbolReachable": false,
"summary": "Infinite loop in HTTP/2 transport when given bad SETTINGS_MAX_FRAME_SIZE in net/http/internal/http2 in golang.org/x/net"
},
{
"id": "GO-2026-4946",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.9",
"symbolReachable": true,
"summary": "Inefficient policy validation in crypto/x509"
},
{
"id": "GO-2026-4947",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.9",
"symbolReachable": true,
"summary": "Unexpected work during chain building in crypto/x509"
},
{
"id": "GO-2026-4970",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.12",
"symbolReachable": false,
"summary": "Root escape via symlink plus trailing slash in os"
},
{
"id": "GO-2026-4971",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.10",
"symbolReachable": true,
"summary": "Panic in Dial and LookupPort when handling NUL byte on Windows in net"
},
{
"id": "GO-2026-4976",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.10",
"symbolReachable": false,
"summary": "ReverseProxy forwards queries with more than urlmaxqueryparams parameters in net/http/httputil"
},
{
"id": "GO-2026-4977",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.10",
"symbolReachable": false,
"summary": "Quadratic string concatenation in consumePhrase in net/mail"
},
{
"id": "GO-2026-4980",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.10",
"symbolReachable": false,
"summary": "Escaper bypass leads to XSS in html/template"
},
{
"id": "GO-2026-4981",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.10",
"symbolReachable": false,
"summary": "Crash when handling long CNAME response in net"
},
{
"id": "GO-2026-4982",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.10",
"symbolReachable": false,
"summary": "Bypass of meta content URL escaping causes XSS in html/template"
},
{
"id": "GO-2026-4986",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.10",
"symbolReachable": false,
"summary": "Quadratic string concatentation in consumeComment in net/mail"
},
{
"id": "GO-2026-5026",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.13",
"symbolReachable": false,
"summary": "Invoking failure to reject ASCII-only Punycode-encoded labels in golang.org/x/net/idna"
},
{
"id": "GO-2026-5037",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.11",
"symbolReachable": true,
"summary": "Inefficient candidate hostname parsing in crypto/x509"
},
{
"id": "GO-2026-5038",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.11",
"symbolReachable": false,
"summary": "Quadratic complexity in WordDecoder.DecodeHeader in mime"
},
{
"id": "GO-2026-5039",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.11",
"symbolReachable": true,
"summary": "Arbitrary inputs are included in errors without any escaping in net/textproto"
},
{
"id": "GO-2026-5856",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.12",
"symbolReachable": true,
"summary": "Invoking Encrypted Client Hello privacy leak in crypto/tls"
},
{
"id": "GO-2026-5972",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.13",
"symbolReachable": true,
"summary": "Enforce maximum recursion depth in encoding/asn1"
},
{
"id": "GO-2026-6088",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.13",
"symbolReachable": false,
"summary": "Add recursion depth guard during decode in encoding/xml"
},
{
"id": "GO-2026-6089",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.13",
"symbolReachable": false,
"summary": "Apply ReadHeaderTimeout when doing unencrypted HTTP/2 check in net/http"
},
{
"id": "GO-2026-6090",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.13",
"symbolReachable": true,
"summary": "Limit handshake messages we are willing to accept post-handshake in crypto/tls"
},
{
"id": "GO-2026-6091",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.13",
"symbolReachable": false,
"summary": "Fix Javascript regexp context tracking in html/template"
},
{
"id": "GO-2026-6218",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.25.13",
"symbolReachable": false,
"summary": "Avoid quadratic complexity in resolvePath in net/url"
},
{
"id": "GO-2026-6599",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.26.9",
"symbolReachable": false,
"summary": "Reset context tracking on consecutive template expressions in html/template"
},
{
"id": "GO-2026-6600",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.26.9",
"symbolReachable": false,
"summary": "Recognize yield as regexp preceder keyword in html/template"
},
{
"id": "GO-2026-6603",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.26.9",
"symbolReachable": true,
"summary": "HTTP/2 server memory exhaustion due to Trailer headers in net/http"
},
{
"id": "GO-2026-6604",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.26.9",
"symbolReachable": false,
"summary": "Root.Mkdir(All) can follow junctions out of the root on Windows in os"
},
{
"id": "GO-2026-6605",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.26.9",
"symbolReachable": false,
"summary": "HTTP/1 client connection desynchronization after CONNECT rejection in net/http"
},
{
"id": "GO-2026-6607",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.26.9",
"symbolReachable": true,
"summary": "Reject malformed ECH outer extension references in crypto/tls"
},
{
"id": "GO-2026-6608",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.26.9",
"symbolReachable": true,
"summary": "Memory limit bypass when parsing MIME headers in net/textproto, mime/multipart"
},
{
"id": "GO-2026-6609",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.26.9",
"symbolReachable": false,
"summary": "Lack of limit on size of parsed Range headers in net/http"
},
{
"id": "GO-2026-6610",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.26.9",
"symbolReachable": false,
"summary": "HTTP/2 transport accepts malformed framing-related headers in net/http"
},
{
"id": "GO-2026-6611",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.26.9",
"symbolReachable": true,
"summary": "Excessive CPU consumption from repeated initial window changes in net/http"
},
{
"id": "GO-2026-6612",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.26.9",
"symbolReachable": true,
"summary": "Double flow control refund on HTTP/2 server streams in net/http"
},
{
"id": "GO-2026-6613",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.26.9",
"symbolReachable": true,
"summary": "HTTP/1 server connection desynchronization after 2xx CONNECT response in net/http"
},
{
"id": "GO-2026-6617",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.26.9",
"symbolReachable": true,
"summary": "HTTP/2 server crash due to HPACK encoder race in net/http"
},
{
"id": "GO-2026-6809",
"module": "stdlib",
"found": "v1.25.0",
"fixed": "v1.26.10",
"symbolReachable": true,
"summary": "Distrust compromised intermediate certificates in crypto/x509"
},
{
"id": "GO-2025-3503",
"module": "golang.org/x/net",
"found": "v0.33.0",
"fixed": "v0.36.0",
"symbolReachable": false,
"summary": "HTTP Proxy bypass using IPv6 Zone IDs in golang.org/x/net"
},
{
"id": "GO-2025-3595",
"module": "golang.org/x/net",
"found": "v0.33.0",
"fixed": "v0.38.0",
"symbolReachable": false,
"summary": "Incorrect Neutralization of Input During Web Page Generation in x/net in golang.org/x/net"
},
{
"id": "GO-2026-4440",
"module": "golang.org/x/net",
"found": "v0.33.0",
"fixed": "v0.45.0",
"symbolReachable": false,
"summary": "Quadratic parsing complexity in golang.org/x/net/html"
},
{
"id": "GO-2026-4441",
"module": "golang.org/x/net",
"found": "v0.33.0",
"fixed": "v0.45.0",
"symbolReachable": false,
"summary": "Infinite parsing loop in golang.org/x/net"
},
{
"id": "GO-2026-4918",
"module": "golang.org/x/net",
"found": "v0.33.0",
"fixed": "v0.53.0",
"symbolReachable": false,
"summary": "Infinite loop in HTTP/2 transport when given bad SETTINGS_MAX_FRAME_SIZE in net/http/internal/http2 in golang.org/x/net"
},
{
"id": "GO-2026-5025",
"module": "golang.org/x/net",
"found": "v0.33.0",
"fixed": "v0.55.0",
"symbolReachable": false,
"summary": "Invoking incorrect handling of namespaced elements in foreign content in golang.org/x/net/html"
},
{
"id": "GO-2026-5026",
"module": "golang.org/x/net",
"found": "v0.33.0",
"fixed": "v0.55.0",
"symbolReachable": false,
"summary": "Invoking failure to reject ASCII-only Punycode-encoded labels in golang.org/x/net/idna"
},
{
"id": "GO-2026-5027",
"module": "golang.org/x/net",
"found": "v0.33.0",
"fixed": "v0.55.0",
"symbolReachable": false,
"summary": "Invoking incorrect handling of HTML elements in foreign content in golang.org/x/net/html"
},
{
"id": "GO-2026-5028",
"module": "golang.org/x/net",
"found": "v0.33.0",
"fixed": "v0.55.0",
"symbolReachable": false,
"summary": "Invoking denial of service when parsing arbitrary HTML in golang.org/x/net/html"
},
{
"id": "GO-2026-5029",
"module": "golang.org/x/net",
"found": "v0.33.0",
"fixed": "v0.55.0",
"symbolReachable": false,
"summary": "Invoking incorrect handling of character references in DOCTYPE nodes in golang.org/x/net/html"
},
{
"id": "GO-2026-5030",
"module": "golang.org/x/net",
"found": "v0.33.0",
"fixed": "v0.55.0",
"symbolReachable": false,
"summary": "Invoking duplicate attributes can cause XSS in golang.org/x/net/html"
},
{
"id": "GO-2026-5942",
"module": "golang.org/x/net",
"found": "v0.33.0",
"fixed": "v0.56.0",
"symbolReachable": false,
"summary": "Parsing an invalid SVCB or HTTPS RR can panic in golang.org/x/net/dns/dnsmessage"
},
{
"id": "GO-2026-6603",
"module": "golang.org/x/net",
"found": "v0.33.0",
"fixed": "v0.60.0",
"symbolReachable": true,
"summary": "HTTP/2 server memory exhaustion due to Trailer headers in net/http"
},
{
"id": "GO-2026-6610",
"module": "golang.org/x/net",
"found": "v0.33.0",
"fixed": "v0.60.0",
"symbolReachable": false,
"summary": "HTTP/2 transport accepts malformed framing-related headers in net/http"
},
{
"id": "GO-2026-6611",
"module": "golang.org/x/net",
"found": "v0.33.0",
"fixed": "v0.60.0",
"symbolReachable": true,
"summary": "Excessive CPU consumption from repeated initial window changes in net/http"
},
{
"id": "GO-2026-6612",
"module": "golang.org/x/net",
"found": "v0.33.0",
"fixed": "v0.60.0",
"symbolReachable": true,
"summary": "Double flow control refund on HTTP/2 server streams in net/http"
},
{
"id": "GO-2026-6617",
"module": "golang.org/x/net",
"found": "v0.33.0",
"fixed": "v0.60.0",
"symbolReachable": true,
"summary": "HTTP/2 server crash due to HPACK encoder race in net/http"
},
{
"id": "GO-2026-6860",
"module": "golang.org/x/net",
"found": "v0.33.0",
"fixed": "v0.62.0",
"symbolReachable": false,
"summary": "Avoid quadratic complexity when coalescing text nodes in html in golang.org/x/net"
},
{
"id": "GO-2026-6866",
"module": "golang.org/x/net",
"found": "v0.33.0",
"fixed": "v0.62.0",
"symbolReachable": false,
"summary": "Check all ancestor namespace transitions when rendering raw text in html in golang.org/x/net"
},
{
"id": "GO-2026-5024",
"module": "golang.org/x/sys",
"found": "v0.28.0",
"fixed": "v0.44.0",
"symbolReachable": false,
"summary": "Invoking integer overflow in NewNTUnicodeString in golang.org/x/sys/windows"
},
{
"id": "GO-2026-5970",
"module": "golang.org/x/text",
"found": "v0.21.0",
"fixed": "v0.39.0",
"symbolReachable": false,
"summary": "Infinite loop on invalid input in golang.org/x/text"
},
{
"id": "GO-2026-6629",
"module": "golang.org/x/text",
"found": "v0.21.0",
"fixed": "v0.41.0",
"symbolReachable": false,
"summary": "Panic parsing crafted input in x/text/secure/precis in golang.org/x/text"
}
]
}

View File

@@ -0,0 +1,83 @@
=== Symbol Results ===
Vulnerability #1: GO-2026-6617
HTTP/2 server crash due to HPACK encoder race in net/http
More info: https://pkg.go.dev/vuln/GO-2026-6617
Module: golang.org/x/net
Found in: golang.org/x/net@v0.33.0
Fixed in: golang.org/x/net@v0.60.0
Example traces found:
#1: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.ErrCode.String
#2: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.FrameType.String
#3: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.Setting.String
#4: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.SettingID.String
#5: ws/server.go:320:31: ws.Server.Accept calls websocket.Upgrader.Upgrade, which eventually calls http2.bufferedWriterTimeoutWriter.Write
#6: ws/server.go:320:31: ws.Server.Accept calls websocket.Upgrader.Upgrade, which eventually calls http2.chunkWriter.Write
#7: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.duplicatePseudoHeaderError.Error
#8: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.headerFieldNameError.Error
#9: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.headerFieldValueError.Error
#10: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.pseudoHeaderError.Error
#11: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.writeData.String
Vulnerability #2: GO-2026-6612
Double flow control refund on HTTP/2 server streams in net/http
More info: https://pkg.go.dev/vuln/GO-2026-6612
Module: golang.org/x/net
Found in: golang.org/x/net@v0.33.0
Fixed in: golang.org/x/net@v0.60.0
Example traces found:
#1: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.ErrCode.String
#2: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.FrameType.String
#3: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.Setting.String
#4: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.SettingID.String
#5: ws/server.go:320:31: ws.Server.Accept calls websocket.Upgrader.Upgrade, which eventually calls http2.bufferedWriterTimeoutWriter.Write
#6: ws/server.go:320:31: ws.Server.Accept calls websocket.Upgrader.Upgrade, which eventually calls http2.chunkWriter.Write
#7: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.duplicatePseudoHeaderError.Error
#8: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.headerFieldNameError.Error
#9: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.headerFieldValueError.Error
#10: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.pseudoHeaderError.Error
#11: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.writeData.String
Vulnerability #3: GO-2026-6611
Excessive CPU consumption from repeated initial window changes in net/http
More info: https://pkg.go.dev/vuln/GO-2026-6611
Module: golang.org/x/net
Found in: golang.org/x/net@v0.33.0
Fixed in: golang.org/x/net@v0.60.0
Example traces found:
#1: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.ErrCode.String
#2: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.FrameType.String
#3: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.Setting.String
#4: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.SettingID.String
#5: ws/server.go:320:31: ws.Server.Accept calls websocket.Upgrader.Upgrade, which eventually calls http2.bufferedWriterTimeoutWriter.Write
#6: ws/server.go:320:31: ws.Server.Accept calls websocket.Upgrader.Upgrade, which eventually calls http2.chunkWriter.Write
#7: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.duplicatePseudoHeaderError.Error
#8: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.headerFieldNameError.Error
#9: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.headerFieldValueError.Error
#10: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.pseudoHeaderError.Error
#11: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.writeData.String
Vulnerability #4: GO-2026-6603
HTTP/2 server memory exhaustion due to Trailer headers in net/http
More info: https://pkg.go.dev/vuln/GO-2026-6603
Module: golang.org/x/net
Found in: golang.org/x/net@v0.33.0
Fixed in: golang.org/x/net@v0.60.0
Example traces found:
#1: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.ErrCode.String
#2: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.FrameType.String
#3: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.Setting.String
#4: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.SettingID.String
#5: ws/server.go:320:31: ws.Server.Accept calls websocket.Upgrader.Upgrade, which eventually calls http2.bufferedWriterTimeoutWriter.Write
#6: ws/server.go:320:31: ws.Server.Accept calls websocket.Upgrader.Upgrade, which eventually calls http2.chunkWriter.Write
#7: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.duplicatePseudoHeaderError.Error
#8: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.headerFieldNameError.Error
#9: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.headerFieldValueError.Error
#10: ws/rpc/peer.go:234:29: rpc.Peer.Notify calls json.Marshal, which eventually calls http2.pseudoHeaderError.Error
#11: ws/errors.go:36:20: ws.HandshakeError.Error calls fmt.Sprintf, which eventually calls http2.writeData.String
Your code is affected by 4 vulnerabilities from 1 module.
This scan also found 14 vulnerabilities in packages you import and 25
vulnerabilities in modules you require, but your code doesn't appear to call
these vulnerabilities.
Use '-show verbose' for more details.

View File

@@ -0,0 +1,34 @@
# v0.28.1 混合工作区验收
2026-09-30,macOS arm64。范围为 CLI 工程识别与命令分发,不改变运行时接口、生成模板归属或业务规则。
## 发布前回归
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -count=1 ./...`:56 个测试包通过,包含实际生成消费者。
- `GOTOOLCHAIN=go1.22.12 GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -count=1 ./cmd/go-omni/...`:5 个 CLI 包通过,含 API 与通用工程消费者。
- `go vet ./...`、`go build ./...`、`git diff --check`:通过。
## 行为覆盖
- 显式 kind 与按清单自动识别,两类工程混合及纯通用工程工作区。
- 原有无 kind 的 API 清单、首次生成时显式 api 入口保持兼容。
- 未知类型、类型与已有清单冲突、两种清单并存、project 携带 api、错误 module、损坏清单、重复 JSON 键、越界/重叠路径与符号链接均拒绝。
- check 汇总两个工程的生成漂移,也在一个工程身份错误时继续检查其他工程;失败与成功均不写文件。
- format 对通用工程显示 SKIP,即使它的 Go 文件存在漂移也不修改;API 格式化规则保持。
- 任意一类工程预检失败时,另一类工程的待补生成文件不被提前写入;所有预检通过后可补回缺失产物。
- 手写文件、go.work、go.work.sum、各 module 的 go.mod/go.sum 保持。
- 原有显式共享 Go 错误目录提供方在加入通用工程后仍可生成、检查,失败不改项目文件。
- 真实 CLI 创建两个独立 module,执行混合 format/generate/check,然后用 Go 原生 go.work 完成 race 测试与多模块构建。
- 真实 CLI 遇到两个工程漂移时汇总输出,退出码 1;失败生成保持原工作区内容,修复后可恢复生成与检查。
检查只涉及生成产物,不替代业务测试。显式 errorCatalogs 仍执行受信任的 Go 导出函数;生成事务按单个工程提交,不保证跨模块的崩溃原子性。
## 远程发布验收
三个独立空模块缓存安装固定远程 v0.28.1-preview,均未使用本地 replace:
- [混合工作区报告](workspaceRemote.json):16 项检查通过,耗时 21.74 秒。远程 CLI 创建两类 module,完成显式/隐式类型识别、混合生成检查、真实 go.work race/构建、失败汇总与写入前预检。
- [通用工程报告](workspaceProgramRemote.json):30 项检查通过,耗时 25.69 秒,含真实进程退出、资源保留与手写代码保护。
- [既有生成器报告](workspaceManagedRemote.json):98 项检查、10 组消费者通过,耗时 108.92 秒。包含配置、HTTP 安全、国际化与 TypeScript;TypeScript 5.6.3 通过 npm exec 提供,未跳过。
- 三组远程模块 checksum 一致:`h1:tah8kFIFZMuPK/veNBu/AjB6bdmlp5DgN+fTuNNkQmA=`。
- 发布标签固定在代码提交 `0d2c6040e2694795f203304148dbaec00318c608`,标签对象 `e0e483b2741f92c8e44a0e45e56d78a920cb3ebb`。验收文档随后独立提交,发布标签不移动。

View File

@@ -0,0 +1,292 @@
{
"passed": true,
"frameworkVersion": "v0.28.1-preview",
"frameworkSum": "h1:tah8kFIFZMuPK/veNBu/AjB6bdmlp5DgN+fTuNNkQmA=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 108.92,
"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",
"generatedIncompleteHTTPDrainRetainsDependenciesAndLogger",
"remoteHTTPClientRaceTests",
"remoteHTTPProxyAndTimeouts",
"remoteHTTPHeaderIsolation",
"remoteHTTPPoolAndDrainBudgets",
"remoteHTTPExample",
"remoteExplicitConfigurationRaceTests",
"literalAndRequiredDefaultReferences",
"remoteCheckConfigBeforeDependencies",
"removedImplicitEnvironmentRules",
"remotePreciseModuleDiagnostics",
"remoteJSONSuccessStatuses",
"remoteRejectedStatusBodyCleanup",
"remoteCopyTypePreflight",
"remoteEmptyCollectionTypeRejection",
"remoteLoggerTimeZoneIsolation",
"remoteCORSPolicyRace",
"remoteCORSErrorResponses",
"remoteCORSTransports",
"generatedCORSConfigOnly",
"remoteUnifiedBodyPolicyTransports",
"remoteBodyLimitsBeforeMiddleware",
"remoteHardLimitConflictBeforeServing",
"remoteCanceledHandlerCleanupBarrier",
"remoteExternalServingResourceRetention",
"remoteAdmissionCloseRace",
"remoteRuntimeFinishRetainsHTTPDependencies",
"remoteHTTP2DisconnectedHandlerDrain",
"remoteNativeEarly413AndBusinessHTTP200",
"remoteHTTPSafetyExampleBuild",
"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": "d34fb26879932153671b9cbd164daae484b23dca08b8a3be5b1b89f8f6168e47",
"lifecycle/phases.go": "6e72499d2c5fcecb57cf712e76e3a03fed61a4caf528f873f2058291a8ab0b4d",
"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": "866a7ab9cc28f987645785203213e10722490970d5a1fd3c060d88db8b1278dd",
"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": "92abd77a4f4172d5388a6b4efc43910eea281fa30a7ceab7aee75cab6fbb03c1",
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
"httpx/response.go": "99e29724e4fa51d2c7ec1a52892c24ac5daf210a2ee547c5e69a175015298efd",
"app/app.go": "2e672b3f6376049dd9695f799698439909f1d649d4338d13478e0dbf1de68800",
"app/transport.go": "8c665f47447d9839bba7ba3efa290316be9635179d2276b0da0d0f8eda3842ec",
"scripts/verifyManagedRelease.py": "6860e3f440c4d4f51a55085e50066b1a43cd31d5068c2f7a01a708c16823aef7",
"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": "cb4a07539adbd163c13fc6cbb914518506b0c60a88473589f212f3406e763e4b",
"cmd/go-omni/apigen/testdata/managed/phases_test.go": "94a5787218c0d92159b473d390197858241ede2cab591df0976e611ed279dcfe",
"cmd/go-omni/apigen/testdata/managed/shutdown_test.go": "b078bd9052e513ab6173a1e65cf54b28d3cfad445cb50518d9e2e76736071047",
"cmd/go-omni/apigen/testdata/managed/consumer_test.go": "589e2c54d9ad7f64e88ac556329a52062a27b4e898deb9a97feff200787c32c1",
"cmd/go-omni/apigen/testdata/managed/operations.go": "a1d91744abe020cbfb476512cffb7dad422a95da1c3be6957fdcfe27c361030b",
"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": "cbf09c158a9add8289befd9960f911a8cf42de0b840a57278bc71bf800121ec8",
"config/cors_test.go": "b40e76dd943f28c80a06eeb2dc74a536b217c4918c4a21d7de870404483a377a",
"config/errorCatalog_test.go": "a9cca5233da1ebf9280a022e10ff07e50f99cf01eac8ae70b905fe12665e3d9d",
"config/suggestions.go": "5220f78bc8e4fda44f4cedc6cc6227895417d83d49eaef3f54711f3a0f9e1245",
"config/http_test.go": "975dc288486ba537cf7fd7ee87df792ba7eceed6ab0a58e6a5c0660ce724ab9d",
"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": "d02858d522ad63e86185f5d87d3fec9718141c75b7199fe75d0894dfa016d24d",
"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",
"app/body.go": "4064b96fc38c5b75ec17f0d82e0dec79242640c4d0903bf544e4fda057e5397a",
"app/router.go": "b0418e18fd546b855a4cce9ff19efc25b274d9b9c52144e12338bb5d97a5be9f",
"app/requests.go": "c4cb453e8215ed594e6755b256b4b1bbcd3b870584f4be19480d4dd1b9e0aa3d",
"app/bodyPolicy_test.go": "505afa7bcc3f70009ef2af2c455e377277db01c2b883c8e5204de0cbebf25cbb",
"app/requests_test.go": "b008b5599e1f5eea01a08f15220588bd581a2d5e98658d53f0acb273dd946137",
"app/transport_test.go": "10f086eeb77e3b11e83768bbbb0372aa68056a8c8839a18edfaf597b5acff689",
"httpx/context.go": "e3a7d3cd31bc6bddaf21c2be35887cf5c85e69ec5e6ff82178884cb0e4696ffb",
"internal/httplimits/limits.go": "98ba40ceda78fcf4b08d51b0851de851329f568fd35b0a4aedefd4b48a43e780",
"lifecycle/runtime_test.go": "ccd302f78535215bea69d7c8ad8c812c79c52cca087353358206cb6b5926d34b",
"transport/http2/server.go": "e0b7fdb3069583d042df5117b36a3fffc423ee452b0a9da162e3604fcce80b49",
"transport/http2/lifecycle_test.go": "2a298b8e466ff4145105338904ec5d9e19087c91d829d6ae5cad8c8b1ee05b15",
"transport/native/server.go": "8c2fc503bc157265838b453bfe289ec68eeb2bdaf63f6dd1e585536a8278ba0c",
"transport/native/earlyReject_test.go": "0fd598e24199beb55bead10d741d739f7361345584c4430b4a97c35d59d62774",
"examples/httpSafety/main.go": "4651b3806e7a8030101ba6a70794fcc18a50d2939fe0a59d7f80bb4a7de30f15"
}
}

View File

@@ -0,0 +1,74 @@
{
"passed": true,
"frameworkVersion": "v0.28.1-preview",
"frameworkSum": "h1:tah8kFIFZMuPK/veNBu/AjB6bdmlp5DgN+fTuNNkQmA=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 25.69,
"tests": [
"TestConflictRollsBackEarlierWrites",
"TestUnsafePaths",
"TestGeneratedProgramConsumer",
"TestOwnershipAndDrift",
"TestRecordedTemplateUpgrade",
"TestProjectGuards/module",
"TestProjectGuards/oldFramework",
"TestProjectGuards/manifest",
"TestProjectGuards/lock",
"TestProjectGuards/otherGenerator",
"TestProjectGuards/symlink",
"TestProjectGuards"
],
"checks": [
"remoteCLIInstall",
"installedVersionDefault",
"generatedConsumerBuildRaceVet",
"defaultNoAPIFiles",
"defaultNoNetworkListener",
"finiteProgram",
"SIGINT",
"SIGTERM",
"initializationFailureAndPanic",
"runErrorPanicGoexit",
"fatalComponentFailure",
"nonzeroProcessFailureAndTimeoutExits",
"partialStartupDraining",
"acknowledgementBeforeResourceClose",
"workContextSurvivesGracefulStop",
"cancellationGrace",
"unresponsiveRunAndBackgroundWork",
"retainedResourcesAndLogger",
"explicitConfigurationOnly",
"checkConfigWithoutDependencies",
"handwrittenFilesPreserved",
"generatedFileConflict",
"missingGeneratedFileRestored",
"missingHandwrittenFileNotRecreated",
"readOnlyCheckAndDryRun",
"templateDriftUpgrade",
"manifestIdentityAndVersionGuards",
"symlinkAndLockProtection",
"fileTransactionRollback",
"publishedExampleFiniteRun"
],
"sourceHashes": {
"cmd/go-omni/internal/genfs/files.go": "97e338c74ddb3145eb0ec942b69d5c21c83dfb2f9f2165376fc83366ae1e93af",
"cmd/go-omni/internal/genfs/files_test.go": "a659b31ddf195c80d48cfc00302a3f4c6d5a1ed68b58d241da03a44ad54fe34a",
"cmd/go-omni/project.go": "07e362664053e59dbb8f8f46d4c7be9d66afb93a7142a810109b66cd296ae8a6",
"cmd/go-omni/projectgen/consumer_test.go": "312442d56599c8264f14aef03722a221a72e70c038875f11c3dbfc19e9872883",
"cmd/go-omni/projectgen/generate.go": "c200bb490623622aef4586baef2d3becf158ca6a6833abb9abe8d1cb9b138d6a",
"cmd/go-omni/projectgen/generate_test.go": "f113258f0150a9f569dbe422b9f40e8ba0db3d115caf09a2c81432a8ae126e03",
"cmd/go-omni/projectgen/templates/.gitignore.tmpl": "82abf6aa84ec1c3ec218b09b5ce465af616c4fe8ff6de6db09abef962a137401",
"cmd/go-omni/projectgen/templates/README.md.tmpl": "3d01821fb4af9e97ca92f47e80a520d6f64ef136e49a8bcd4fed53dec32bde23",
"cmd/go-omni/projectgen/templates/etc_config.yaml.tmpl": "c224c68f50ea00a10e9dfd317347ac843c9107738861dab7824f7a7b1ae3a1ad",
"cmd/go-omni/projectgen/templates/internal_config_config.go.tmpl": "55111dbe02f0b91ed58a36449dcee8c9d7338251730764cda0de3e77e4a82f28",
"cmd/go-omni/projectgen/templates/internal_config_loadGen.go.tmpl": "95fb848065e3e516a8026f4439ede6d8be100e1726a56a124a6e49cb1d9d817d",
"cmd/go-omni/projectgen/templates/internal_config_loader.go.tmpl": "c9ad9f26a7d84622d7f2114a020b57ba6871a86ccf6cbc015c63674026ce81c3",
"cmd/go-omni/projectgen/templates/internal_run_run.go.tmpl": "51c2b4b407aa8b350e666be49b9d888ee42070627e28303ae2e1b0c9b4d73c95",
"cmd/go-omni/projectgen/templates/internal_svc_runtimeGen.go.tmpl": "8e259634ac7fbac0d2a13629864b2a5c89e43b6e9c477db9272b00cf49dc47d1",
"cmd/go-omni/projectgen/templates/internal_svc_serviceContext.go.tmpl": "1ed62fd86fd050468cac1c158a438deed01d4c25b4b634eec4ccd84b24df476f",
"cmd/go-omni/projectgen/templates/main.go.tmpl": "c59a7773f00c31c1a67e6a0093de26bc6f2dc8dbe34c61c1fa7e1b341e4afcc2",
"cmd/go-omni/projectgen/testdata/consumer_test.go": "38df45a69e843dfe6897d0296337e10b507076ea3c7a32901c1cad340915b0ee",
"scripts/verifyProgramRelease.py": "9127743ee929cb32701e4a063372a817ead3fd2795c2722b75f2a0c9123fa85a"
}
}

View File

@@ -0,0 +1,34 @@
{
"passed": true,
"frameworkVersion": "v0.28.1-preview",
"frameworkSum": "h1:tah8kFIFZMuPK/veNBu/AjB6bdmlp5DgN+fTuNNkQmA=",
"freshModuleCache": true,
"noLocalReplace": true,
"durationSeconds": 21.74,
"checks": [
"remoteCLIInstall",
"remoteAPIModule",
"remoteGenericModule",
"explicitKinds",
"manifestInferredKinds",
"mixedFormatWithProjectSkip",
"mixedGeneration",
"mixedCheck",
"nativeGoWorkBuild",
"nativeGoWorkRaceTests",
"moduleAndWorkspaceMetadataPreserved",
"bothProjectFailuresAggregated",
"nonzeroFailureExit",
"failedCheckIsReadOnly",
"crossProjectPreflight",
"missingGeneratedOutputRestored"
],
"sourceHashes": {
"cmd/go-omni/apigen/workspace.go": "cc39732ed9a41bb2dfa14933a830a7f10e8e3b2edc2ada07c376a738c0ec77e0",
"cmd/go-omni/apigen/workspaceMixed_test.go": "119f2a2aadc2b3ee2471bed1309c6ad8e36488b258f2ab1ba5ba5bab4219643f",
"cmd/go-omni/apigen/workspaceCatalog_test.go": "372874322aece46714351f088b5741642fe4e63d95062743a575958fa58b8b39",
"cmd/go-omni/workspace.go": "b7bbd3d9d58b3987090edf7e3714d7d8396e7c332174022d918a52e16396397d",
"cmd/go-omni/workspace_test.go": "0170d18158325262765d8b3067f55ca3ce9fe994f2d030cafee3e82b13bfcd4b",
"scripts/verifyWorkspaceRelease.py": "0495dc97c68ac17a7969088cf94b1cbbfa6e53f18fb36147c64bc34edb260bca"
}
}

View File

@@ -124,7 +124,7 @@ def main():
checks += ["sharedGoCatalogExport", "documentOnlyErrorSelection", "workspaceCheckNoWrites", "staleCatalogRejected", "twoIndependentServiceBuilds"] checks += ["sharedGoCatalogExport", "documentOnlyErrorSelection", "workspaceCheckNoWrites", "staleCatalogRejected", "twoIndependentServiceBuilds"]
env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(cli), GO_OMNI_RELEASE_VERSION=args.version) env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(cli), GO_OMNI_RELEASE_VERSION=args.version)
tests = ["TestManagedGeneratedProject", "TestErrorInheritanceConsumer", "TestErrorParamsConsumer", "TestCompatibilityConsumer", "TestBusinessGeneratedProject", "TestPrimitiveArrayConsumer", "TestCopyxThreeEndpointConsumer", "TestGeneratedConfigurationConsumer", "TestGeneratedCORSConsumer", "TestI18nGeneratedConsumer"] tests = ["TestManagedGeneratedProject", "TestErrorInheritanceConsumer", "TestErrorParamsConsumer", "TestCompatibilityConsumer", "TestBusinessGeneratedProject", "TestPrimitiveArrayConsumer", "TestCopyxThreeEndpointConsumer", "TestGeneratedConfigurationConsumer", "TestGeneratedCORSConsumer", "TestI18nGeneratedConsumer", "TestRecursiveResponseConsumer"]
print("Running generated consumers against the remote framework, including lifecycle failure cleanup", flush=True) print("Running generated consumers against the remote framework, including lifecycle failure cleanup", flush=True)
output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=240s", "./cmd/go-omni/apigen", "-run", "^(" + "|".join(tests) + ")$"], ROOT, env) output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=240s", "./cmd/go-omni/apigen", "-run", "^(" + "|".join(tests) + ")$"], ROOT, env)
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")] events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
@@ -135,6 +135,7 @@ def main():
assert len(sums) == len(tests) and all(s == dependency["Sum"] for s in sums), "remote consumer checksum mismatch" assert len(sums) == len(tests) and all(s == dependency["Sum"] for s in sums), "remote consumer checksum mismatch"
checks += ["initializationAndListenFailureCleanup", "drainBeforeDependencyClose", "generatedConsumerRaceTests", "errorParamsAndInheritance", "manualHooksAndScalars", "dependencyFatalShutdown", "fatalProcessExitCode", "initializationShutdownNotification", "configuredBuiltinEndpoints", "projectHealthEnvelope", "endpointConflictCleanup", "responseArrayNullableNull", "primitiveAndDynamicArrays", "strictRequestPolicies", "declarativeMultipartBinding", "backgroundStopDrainResourceOrder", "backgroundInitAndListenFailureCleanup", "backgroundTimeoutRetainsResourcesAndExits"] checks += ["initializationAndListenFailureCleanup", "drainBeforeDependencyClose", "generatedConsumerRaceTests", "errorParamsAndInheritance", "manualHooksAndScalars", "dependencyFatalShutdown", "fatalProcessExitCode", "initializationShutdownNotification", "configuredBuiltinEndpoints", "projectHealthEnvelope", "endpointConflictCleanup", "responseArrayNullableNull", "primitiveAndDynamicArrays", "strictRequestPolicies", "declarativeMultipartBinding", "backgroundStopDrainResourceOrder", "backgroundInitAndListenFailureCleanup", "backgroundTimeoutRetainsResourcesAndExits"]
checks += ["generatedIncompleteHTTPDrainRetainsDependenciesAndLogger"] checks += ["generatedIncompleteHTTPDrainRetainsDependenciesAndLogger"]
checks += ["recursiveResponseTypedHTTP", "recursiveResponseReferenceGraph", "recursiveResponseRegenerationAndDrift"]
# Run tests from the downloaded module, not the framework checkout. # Run tests from the downloaded module, not the framework checkout.
http_output = run(["go", "test", "-race", "-count=1", "-json", MODULE + "/httpc"], starter, env) http_output = run(["go", "test", "-race", "-count=1", "-json", MODULE + "/httpc"], starter, env)
@@ -160,7 +161,24 @@ def main():
checks += ["remoteExplicitConfigurationRaceTests", "literalAndRequiredDefaultReferences", checks += ["remoteExplicitConfigurationRaceTests", "literalAndRequiredDefaultReferences",
"remoteCheckConfigBeforeDependencies", "removedImplicitEnvironmentRules"] "remoteCheckConfigBeforeDependencies", "removedImplicitEnvironmentRules"]
# The generated starter imports no cloud storage SDK. Resolve these
# optional adapter dependencies before testing their downloaded packages;
# lazy module loading otherwise leaves their sums out of starter/go.sum.
run(["go", "get", MODULE + "/storage/oss@" + args.version,
MODULE + "/storage/s3@" + args.version,
MODULE + "/telemetry@" + args.version], starter, env)
typescript_available = shutil.which("tsc", path=env.get("PATH")) is not None
i18n_tests = {"TestIndependentImmutableBundlesAndFallback", "TestResourceAndParameterDiagnostics", "TestStrictLoadingAndExplicitOverlay", "TestPinnedCLDRSamples", "TestBrowserConformance", "TestSourceLocaleCanBeFallbackOnly"}
if typescript_available:
i18n_tests.add("TestTypeScriptConsumer")
for package, expected in { for package, expected in {
"storage": {"TestReadOptions", "TestReadSignatureAndExpiry", "TestReadRangeStreamingAndCache"},
"storage/local": {"TestSignedPayloadRanges"},
"storage/oss": {"TestReadOptionsNativeSignatures"},
"storage/s3": {"TestReadOptionsS3AndR2"},
"examples/signedRead": {"TestSignedReadHTTP"},
"telemetry": {"TestConfiguredHeaderPropagation", "TestConfiguredHeaderPropagationPrefersW3CAndRejectsInvalidSetup"},
"examples/telemetryHeaders": {"TestTelemetryHeadersExample"},
"copyx": {"TestCheckTypesAndEmptyCollections", "TestCheckRulesAndConverterBoundaries", "TestCopierCheckConcurrentAndOptionsIsolation"}, "copyx": {"TestCheckTypesAndEmptyCollections", "TestCheckRulesAndConverterBoundaries", "TestCopierCheckConcurrentAndOptionsIsolation"},
"logx": {"TestLoggerTimeZoneIsolation", "TestLoggerTimeZoneValidationAndDST", "TestLoggerTimeZoneConcurrentChildren"}, "logx": {"TestLoggerTimeZoneIsolation", "TestLoggerTimeZoneValidationAndDST", "TestLoggerTimeZoneConcurrentChildren"},
"cors": {"TestPolicyPreflightAndActual", "TestPolicyValidation", "TestPolicyCredentialsWildcardAndDuplicates", "TestPolicyPreservesHTTPAndSnapshot"}, "cors": {"TestPolicyPreflightAndActual", "TestPolicyValidation", "TestPolicyCredentialsWildcardAndDuplicates", "TestPolicyPreservesHTTPAndSnapshot"},
@@ -171,7 +189,7 @@ def main():
"transport/http2": {"TestShutdownWaitsForHandlerAfterClientDisconnect"}, "transport/http2": {"TestShutdownWaitsForHandlerAfterClientDisconnect"},
"transport/native": {"TestApplicationRejectsUnreadBodyPromptly", "TestBodyRejectionWithBusinessHTTP200DoesNotDrain"}, "transport/native": {"TestApplicationRejectsUnreadBodyPromptly", "TestBodyRejectionWithBusinessHTTP200DoesNotDrain"},
"contract": {"TestBodyPolicyPropagatesOuterLimit", "TestJSONClosingDelimiterPreservesReaderCause"}, "contract": {"TestBodyPolicyPropagatesOuterLimit", "TestJSONClosingDelimiterPreservesReaderCause"},
"i18n": {"TestIndependentImmutableBundlesAndFallback", "TestResourceAndParameterDiagnostics", "TestStrictLoadingAndExplicitOverlay", "TestPinnedCLDRSamples", "TestBrowserConformance", "TestSourceLocaleCanBeFallbackOnly", "TestTypeScriptConsumer"}, "i18n": i18n_tests,
"i18n/httpi18n": {"TestNegotiationAndExplicitResolvers"}, "i18n/httpi18n": {"TestNegotiationAndExplicitResolvers"},
"i18n/errori18n": {"TestCatalogAdapterIsExplicitAndDetached"}, "i18n/errori18n": {"TestCatalogAdapterIsExplicitAndDetached"},
"database": {"TestInTxStagesAndCauses", "TestInTxCommitOutcomeUnknown", "TestInTxCancellationAndOwnership", "TestInTxCancellationRace", "TestInTxSuccessPanicAndConcurrency", "TestInTxPublicErrorCompatibility"}, "database": {"TestInTxStagesAndCauses", "TestInTxCommitOutcomeUnknown", "TestInTxCancellationAndOwnership", "TestInTxCancellationRace", "TestInTxSuccessPanicAndConcurrency", "TestInTxPublicErrorCompatibility"},
@@ -179,7 +197,16 @@ def main():
output = run(["go", "test", "-race", "-count=1", "-json", MODULE + "/" + package], starter, env) output = run(["go", "test", "-race", "-count=1", "-json", MODULE + "/" + package], starter, env)
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")] events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
passed = {event.get("Test") for event in events if event.get("Action") == "pass"} passed = {event.get("Test") for event in events if event.get("Action") == "pass"}
assert expected <= passed assert expected <= passed, f"{package} missing tests: {sorted(expected - passed)}"
signed_read_output = run(["go", "run", MODULE + "/examples/signedRead", "-check"], starter, env)
assert "PDF preview, Chinese filename, download, HEAD and Range passed" in signed_read_output
checks += ["remoteSignedReadOptionsAndLegacyLinks", "remoteReadSignatureTamperingAndExpiry",
"remoteLocalPayloadRangesAndHEAD", "remoteOSSNativeV1V4Signatures",
"remoteS3R2ReadOptions", "remoteSignedReadHTTPExample"]
header_output = run(["go", "run", MODULE + "/examples/telemetryHeaders"], starter, env)
assert "traceId=11111111111111111111111111111111" in header_output
checks += ["remoteConfiguredHeaderPropagation", "remoteHeaderW3CPrecedenceAndValidation",
"remoteHeaderTelemetryExample"]
checks += ["remotePreciseModuleDiagnostics", "remoteJSONSuccessStatuses", "remoteRejectedStatusBodyCleanup", checks += ["remotePreciseModuleDiagnostics", "remoteJSONSuccessStatuses", "remoteRejectedStatusBodyCleanup",
"remoteCopyTypePreflight", "remoteEmptyCollectionTypeRejection", "remoteLoggerTimeZoneIsolation", "remoteCopyTypePreflight", "remoteEmptyCollectionTypeRejection", "remoteLoggerTimeZoneIsolation",
"remoteCORSPolicyRace", "remoteCORSErrorResponses", "remoteCORSTransports", "generatedCORSConfigOnly"] "remoteCORSPolicyRace", "remoteCORSErrorResponses", "remoteCORSTransports", "generatedCORSConfigOnly"]
@@ -215,9 +242,10 @@ def main():
browser_output = run(["node", "--test", downloaded / "i18n/browser/runtime.test.mjs"], starter, env) browser_output = run(["node", "--test", downloaded / "i18n/browser/runtime.test.mjs"], starter, env)
assert "# fail 0" in browser_output assert "# fail 0" in browser_output
checks += ["remoteI18nRuntimeRace", "remoteI18nGeneratedConsumer", "remoteI18nHTTPBusinessInvariants", checks += ["remoteI18nRuntimeRace", "remoteI18nGeneratedConsumer", "remoteI18nHTTPBusinessInvariants",
"remoteI18nSourceOnlyFallback", "remoteCLDRSamplesAndBrowserConformance", "remoteTypeScriptConsumer", "remoteI18nSourceOnlyFallback", "remoteCLDRSamplesAndBrowserConformance",
"remoteI18nCLIExtractionCheckExportTypes", "remoteI18nNamespaceIsolation", "remoteI18nCLIExtractionCheckExportTypes", "remoteI18nNamespaceIsolation",
"remoteBrowserRaceAndFailureRetention", "remoteJSONReaderCausePreservation"] "remoteBrowserRaceAndFailureRetention", "remoteJSONReaderCausePreservation"]
checks.append("remoteTypeScriptConsumer" if typescript_available else "remoteTypeScriptConsumerSkippedWithoutTSC")
checks += ["flatFieldComposition", "copyxThreeEndpointConsumer", "explicitNullAndMoneyPolicies", "fieldAdditionWithoutCopyRuleChanges", "middlewareFactoryInjectionAndCleanup", "optionalRepositoryPreservesBusiness"] checks += ["flatFieldComposition", "copyxThreeEndpointConsumer", "explicitNullAndMoneyPolicies", "fieldAdditionWithoutCopyRuleChanges", "middlewareFactoryInjectionAndCleanup", "optionalRepositoryPreservesBusiness"]
@@ -246,6 +274,10 @@ def main():
sources += ["app/body.go", "app/router.go", "app/requests.go", "app/bodyPolicy_test.go", "app/requests_test.go", "app/transport_test.go", sources += ["app/body.go", "app/router.go", "app/requests.go", "app/bodyPolicy_test.go", "app/requests_test.go", "app/transport_test.go",
"httpx/context.go", "internal/httplimits/limits.go", "lifecycle/runtime_test.go", "transport/http2/server.go", "transport/http2/lifecycle_test.go", "httpx/context.go", "internal/httplimits/limits.go", "lifecycle/runtime_test.go", "transport/http2/server.go", "transport/http2/lifecycle_test.go",
"transport/native/server.go", "transport/native/earlyReject_test.go", "examples/httpSafety/main.go"] "transport/native/server.go", "transport/native/earlyReject_test.go", "examples/httpSafety/main.go"]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "storage").rglob("*.go")]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "examples/signedRead").glob("*.go")]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "telemetry").glob("*.go")]
sources += [str(p.relative_to(ROOT)) for p in (ROOT / "examples/telemetryHeaders").glob("*.go")]
report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"], report = {"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"],
"freshModuleCache": True, "noLocalReplace": True, "freshModuleCache": True, "noLocalReplace": True,
"durationSeconds": round(time.monotonic() - started, 2), "checks": checks, "durationSeconds": round(time.monotonic() - started, 2), "checks": checks,

View File

@@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""Verify a fixed WebSocket release and generated routes from an empty module cache."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import tempfile
import time
from verifyErrorInheritanceRelease import run
ROOT = Path(__file__).resolve().parents[1]
MODULE = "git.yuntuo.hk/k2safe/go-omni"
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True)
parser.add_argument("--report", type=Path, default=ROOT / "reports/websocket-remote.json")
args = parser.parse_args()
if not re.fullmatch(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", args.version):
parser.error("a fixed release version is required")
env = {k: v for k, v in os.environ.items() if not k.startswith("GO_OMNI_")}
env.update(GOWORK="off", GOFLAGS="", GOPRIVATE="git.yuntuo.hk", GONOSUMDB="git.yuntuo.hk")
started = time.monotonic()
with tempfile.TemporaryDirectory(prefix="goOmniWebsocketRelease-") as directory:
temp = Path(directory)
(temp / "bin").mkdir()
env.update(GOBIN=str(temp / "bin"), GOMODCACHE=str(temp / "modcache"))
print(f"Installing remote CLI {args.version} in an empty cache", flush=True)
run(["go", "install", MODULE + "/cmd/go-omni@" + args.version], temp, env)
cli = temp / "bin/go-omni"
starter = temp / "starter"
# Verify the installed default, without --framework-version or a local replace.
run([cli, "new", "--module", "example.com/websocketRelease", starter], temp, env)
run(["go", "mod", "tidy"], starter, env)
dependency = json.loads(run(["go", "list", "-m", "-json", MODULE], starter, env))
assert dependency["Version"] == args.version and dependency.get("Sum") and not dependency.get("Replace")
downloaded = Path(dependency["Dir"])
# Add the optional WS dependency only after checking the CLI's default.
run(["go", "get", MODULE + "/ws@" + args.version], starter, env)
print("Testing downloaded WS, Hub, RPC, observation and App integration with race detection", flush=True)
output = run(["go", "test", "-mod=mod", "-race", "-count=1", "-json", "-timeout=180s",
MODULE + "/ws/...", MODULE + "/examples/websocketRPC"], starter, env)
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
packages = sorted({e["Package"] for e in events if e.get("Action") == "pass" and "Test" not in e})
expected = [MODULE + "/ws" + suffix for suffix in ("", "/hub", "/observe", "/omni", "/rpc")]
assert set(expected + [MODULE + "/examples/websocketRPC"]) == set(packages), output
tests = sorted(e["Package"] + ":" + e["Test"] for e in events
if e.get("Action") == "pass" and "Test" in e and "/" not in e["Test"])
echo = run(["go", "run", "-mod=mod", MODULE + "/examples/websocket"], starter, env)
assert "hello websocket" in echo and "hello group" in echo, echo
rpc = run(["go", "run", "-mod=mod", MODULE + "/examples/websocketRPC"], starter, env)
for generation in (1, 2):
assert f'connection {generation}: double=42, remote callback="generic Go client", restored=1, event={generation}' in rpc, rpc
env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(cli), GO_OMNI_RELEASE_VERSION=args.version)
print("Testing generated custom routes and failure cleanup against the fixed remote release", flush=True)
output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=180s", "./cmd/go-omni/apigen",
"-run", "^TestGeneratedCustomRoutesConsumer$"], downloaded, env)
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
assert any(e.get("Action") == "pass" and e.get("Test") == "TestGeneratedCustomRoutesConsumer" for e in events), output
evidence = "".join(e.get("Output", "") for e in events)
assert f'checksum {dependency["Sum"]}; no local replace' in evidence, evidence
final_dependency = json.loads(run(["go", "list", "-m", "-json", MODULE], starter, env))
assert final_dependency["Version"] == args.version and final_dependency["Sum"] == dependency["Sum"]
assert not final_dependency.get("Replace")
report = {
"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"],
"toolchain": run(["go", "version"], temp, env).strip(),
"freshModuleCache": True, "noLocalReplace": True,
"durationSeconds": round(time.monotonic() - started, 2),
"packages": packages, "tests": tests,
"checks": ["remoteCLIInstall", "installedVersionDefault", "fixedRemoteModuleChecksum",
"downloadedWebsocketRaceTests", "remoteEchoAndGroupExample", "remoteBidirectionalRPCExample",
"remoteTwoConnectionSubscriptionRestore", "generatedCustomRoutesConsumer",
"oldServiceWithoutHook", "handwrittenHookPreserved", "customRouteFailureCleanup"],
"examples": {"websocket": echo.strip(), "websocketRPC": rpc.strip()},
"sourceHashes": {name: hashlib.sha256((downloaded / name).read_bytes()).hexdigest() for name in
("scripts/verifyWebsocketRelease.py", "cmd/go-omni/apigen/customRoutes_test.go")},
"limitations": ["Functional release acceptance; not a vulnerability scan or browser UI test.",
"Known framework HTTP/2 dependency vulnerabilities remain documented in the validation report."],
}
args.report.parent.mkdir(parents=True, exist_ok=True)
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
print(f"PASS remote WebSocket release {args.version}; temporary files removed", flush=True)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Exercise mixed workspace generation/check and native Go multi-module builds remotely."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import tempfile
import time
from verifyErrorInheritanceRelease import run
ROOT = Path(__file__).resolve().parents[1]
MODULE = "git.yuntuo.hk/k2safe/go-omni"
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True)
parser.add_argument("--report", type=Path, default=ROOT / "reports/workspaceRemote.json")
args = parser.parse_args()
if not re.fullmatch(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", args.version):
parser.error("a fixed release version is required")
env = {k: v for k, v in os.environ.items() if not k.startswith("GO_OMNI_")}
env.update(GOWORK="off", GOFLAGS="", GOPRIVATE="git.yuntuo.hk", GONOSUMDB="git.yuntuo.hk")
started = time.monotonic()
with tempfile.TemporaryDirectory(prefix="goOmniWorkspaceRelease-") as directory:
temp = Path(directory)
(temp / "bin").mkdir()
env.update(GOBIN=str(temp / "bin"), GOMODCACHE=str(temp / "modcache"))
print(f"Installing remote CLI {args.version} in an empty cache", flush=True)
run(["go", "install", MODULE + "/cmd/go-omni@" + args.version], temp, env)
dependency = json.loads(run(["go", "mod", "download", "-json", MODULE + "@" + args.version], temp, env))
assert dependency["Version"] == args.version and dependency.get("Sum")
downloaded = Path(dependency["Dir"])
env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(temp / "bin/go-omni"), GO_OMNI_RELEASE_VERSION=args.version)
print("Testing mixed API/project consumers and go.work builds from the downloaded release", flush=True)
output = run(["go", "test", "-race", "-json", "-count=1", "-timeout=240s", "./cmd/go-omni",
"-run", "^TestMixedWorkspaceConsumer$"], downloaded, env)
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
passed = [e["Test"] for e in events if e.get("Action") == "pass" and "Test" in e]
assert passed == ["TestMixedWorkspaceConsumer"], output
evidence = "".join(e.get("Output", "") for e in events)
assert evidence.count("; no local replace") == 2, evidence
assert dependency["Sum"] in evidence, evidence
sources = ["cmd/go-omni/apigen/workspace.go", "cmd/go-omni/apigen/workspaceMixed_test.go",
"cmd/go-omni/apigen/workspaceCatalog_test.go", "cmd/go-omni/workspace.go",
"cmd/go-omni/workspace_test.go", "scripts/verifyWorkspaceRelease.py"]
report = {
"passed": True, "frameworkVersion": args.version, "frameworkSum": dependency["Sum"],
"freshModuleCache": True, "noLocalReplace": True,
"durationSeconds": round(time.monotonic() - started, 2),
"checks": ["remoteCLIInstall", "remoteAPIModule", "remoteGenericModule", "explicitKinds",
"manifestInferredKinds", "mixedFormatWithProjectSkip", "mixedGeneration", "mixedCheck",
"nativeGoWorkBuild", "nativeGoWorkRaceTests", "moduleAndWorkspaceMetadataPreserved",
"bothProjectFailuresAggregated", "nonzeroFailureExit", "failedCheckIsReadOnly",
"crossProjectPreflight", "missingGeneratedOutputRestored"],
"sourceHashes": {p: hashlib.sha256((ROOT / p).read_bytes()).hexdigest() for p in sources},
}
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
print(f"PASS remote mixed workspace {args.version}: {len(report['checks'])} checks", flush=True)
if __name__ == "__main__":
main()

View File

@@ -6,12 +6,42 @@
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| 读写删除 | Store | 支持 | 支持 | 支持 | | 读写删除 | Store | 支持 | 支持 | 支持 |
| 临时下载 | Presigner | NewSigner | 支持 | 支持 | | 临时下载 | Presigner | NewSigner | 支持 | 支持 |
| 带选项的预览/下载 | ReadPresigner | NewSigner | 支持 | 支持 |
| 限定大小/类型的直传 | UploadPresigner | NewUploadSigner | 支持 | 支持,需要配置签名 Header | | 限定大小/类型的直传 | UploadPresigner | NewUploadSigner | 支持 | 支持,需要配置签名 Header |
| 创建、上传、查询、完成、取消分片 | MultipartStore | 当前不提供 | 支持 | 支持 | | 创建、上传、查询、完成、取消分片 | MultipartStore | 当前不提供 | 支持 | 支持 |
| 分片直传签名 | PartPresigner | 当前不提供 | 支持 | 支持,需要配置签名 Header | | 分片直传签名 | PartPresigner | 当前不提供 | 支持 | 支持,需要配置签名 Header |
没有固定业务字段、目录规则或身份模型。鉴权、对象归属、业务配额由应用定义。SDK 客户端和连接池由应用持有;适配器不会在一次调用后关闭客户端。 没有固定业务字段、目录规则或身份模型。鉴权、对象归属、业务配额由应用定义。SDK 客户端和连接池由应用持有;适配器不会在一次调用后关闭客户端。
## 签名预览与下载
`v0.29.0-preview` 新增独立的可选接口 `ReadPresigner`,不扩充原 Store/Presigner,不要求第三方适配器修改代码。原 `PresignGet(ctx, key, ttl)` 和既有本地下载链接继续有效、默认 attachment。
```go
// signer 可以是 storage.NewSigner 返回的本地 Handler,或 S3/R2、OSS Store。
link, err := signer.PresignGetWithOptions(ctx, key, 30*time.Minute, storage.ReadOptions{
Disposition: storage.DispositionInline,
FileName: "示例报告.pdf",
ContentType: "application/pdf",
})
if err != nil { return err }
// 把 link.URL 和 link.ExpiresAt 返回给已授权的调用方。
```
TTL 仍为 1 秒到 1 小时,30 分钟无需改配置。`ReadLink` 包含 `url` 和 `expiresAt`,到期时间取自实际签名,精确到秒(不足一秒的部分舍弃);临时云凭据可能更早失效。无需额外请求 Header,客户端直接打开 URL。可通过 `storage.ReadPresigner` 做能力判断;不支持时由调用方明确处理,不应静默降级成公开地址。
`Disposition` 只接受 inline/attachment,空值为 attachment。`FileName` 是显示名,与存储 key 无关;中文使用标准 `filename*` 编码,拒绝控制字符(包括 CR/LF)、路径分隔符及超长值。`ContentType` 为空时沿用对象元数据,可显式指定合法 MIME 类型;它只控制响应,不转换或验证文件内容。PDF 应在上传时就保存 `application/pdf` 元数据。inline 请求内联显示,能否展示还取决于浏览器的格式支持与用户设置。
本地签名绑定 key、到期时间、版本及全部选项,拒绝重复/未知参数和篡改;先验签、验有效期,再读取对象或处理条件请求。Local 使用受文件内容边界约束的流,GET/HEAD 的长度不包含内部元数据;支持单段/多段 Range、206/416,以及 Last-Modified 条件请求。HEAD 返回完整对象头、无响应体,忽略 Range。自定义 Store 的 Body 实现 `io.ReadSeeker` 时复用同一行为;仅支持顺序读取时返回完整 200,不宣称支持范围读取,也不把整个对象加载到内存。
云端适配器在 OSS/S3 原生 SDK 签名前传入响应参数,禁止先签名后拼参数或替换域名。Range 由云存储处理;云端 GET 签名不能直接改成 HEAD 使用。新接口请求 `Cache-Control: private, no-store`,本地成功、错误及条件响应均保持该策略;云端响应头覆盖受平台规则约束,需检查实际 200/206 响应与代理缓存配置。
OSS 默认域名或加速域名可能强制下载。需要自定义域名时,在应用装配 SDK client 时设置已绑定桶的域名和 `WithUseCName(true)`,再传给适配器;SDK 的区域、签名版本、凭据由应用配置。例如在现有 SDK config 上调用 `WithEndpoint("https://files.example").WithUseCName(true)`。参见 [OSS 预览规则](https://help.aliyun.com/zh/oss/how-to-ensure-an-object-is-previewed-when-you-access-the-object)与 [GetObject 响应参数规则](https://www.alibabacloud.com/help/en/oss/developer-reference/getobject)。
签发权限、对象是否不可变、审计和前端链接复用由项目维护。框架不添加分享表、撤销、租户或数据库依赖;重新签发不会撤销旧链接,到期不会收回已下载内容,也不会中断已开始的传输。本地入口使用显式配置的公开 baseURL,不猜测 Host/转发头,不公开磁盘目录。应用与代理日志应隐藏查询参数。
完整离线 [预览/下载示例](../examples/signedRead/README.md),包含真实 HTTP 检查与一页 PDF。OSS V1/V4、S3/R2 SDK 签名和隔离 MinIO 有回归;OSS/R2 云账号尚未实测,部署方仍需对实际桶、域名及浏览器进行验收。
## 服务端上传限制 ## 服务端上传限制
```go ```go
@@ -85,7 +115,7 @@ if err != nil { return err }
request, err := signer.PresignPut(ctx, key, size, "image/png", time.Minute) request, err := signer.PresignPut(ctx, key, size, "image/png", time.Minute)
``` ```
Handler 检查签名、有效期、单值参数、Content-Length/Content-Type,以及实际内容大小和嗅探类型,成功返回 201。本地存储只开放一个受控入口,不把磁盘目录映射成公共目录。原 `NewSigner` 继续只服务下载。请求超时、CORS、并发限制和速率限制由承载该 Handler 的服务配置。 Handler 检查签名、有效期、单值参数、Content-Length/Content-Type,以及实际内容大小和嗅探类型,成功返回 201。本地存储只开放一个受控入口,不把磁盘目录映射成公共目录。`NewSigner` 提供签名读取,不接受上传;需要上传时使用 `NewUploadSigner`。请求超时、CORS、并发限制和速率限制由承载该 Handler 的服务配置。
## 分片与续传 ## 分片与续传

View File

@@ -151,14 +151,31 @@ func (s *Store) Get(ctx context.Context, key string) (storage.Object, error) {
} }
info.ModifiedAt = stat.ModTime() info.ModifiedAt = stat.ModTime()
ok = true ok = true
return storage.Object{Info: info, Body: &body{Reader: storage.ContextReader{Context: ctx, Reader: io.LimitReader(f, info.Size)}, Closer: f}}, nil // Expose only the payload as a seekable section. Seeks are relative to the
// object, never to our private metadata header or other disk bytes.
return storage.Object{Info: info, Body: &body{section: io.NewSectionReader(f, int64(length)+4, info.Size), file: f, ctx: ctx}}, nil
} }
type body struct { type body struct {
io.Reader section *io.SectionReader
io.Closer file *os.File
ctx context.Context
} }
func (b *body) Read(p []byte) (int, error) {
if err := b.ctx.Err(); err != nil {
return 0, err
}
return b.section.Read(p)
}
func (b *body) Seek(offset int64, whence int) (int64, error) {
if err := b.ctx.Err(); err != nil {
return 0, err
}
return b.section.Seek(offset, whence)
}
func (b *body) Close() error { return b.file.Close() }
func (s *Store) Delete(ctx context.Context, key string) error { func (s *Store) Delete(ctx context.Context, key string) error {
if err := storage.Check(ctx, key); err != nil { if err := storage.Check(ctx, key); err != nil {
return err return err

125
storage/local/read_test.go Normal file
View File

@@ -0,0 +1,125 @@
package local
import (
"context"
"io"
"mime"
"mime/multipart"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/storage"
)
func TestSignedPayloadRanges(t *testing.T) {
store, err := New(t.TempDir(), 1<<20)
if err != nil {
t.Fatal(err)
}
data := "%PDF-1.4\n" + strings.Repeat("0123456789", 100) + "\n%%EOF\n"
ctx := context.Background()
if _, err = store.Put(ctx, "private/document", strings.NewReader(data), int64(len(data)), "application/pdf"); err != nil {
t.Fatal(err)
}
signer, _ := storage.NewSigner(store, "https://files.example/read", []byte(strings.Repeat("s", 32)))
link, err := signer.PresignGetWithOptions(ctx, "private/document", 30*time.Minute, storage.ReadOptions{Disposition: storage.DispositionInline, FileName: "示例.pdf"})
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
method, span string
status int
want string
}{
{"GET", "", 200, data}, {"HEAD", "", 200, ""}, {"HEAD", "bytes=0-3", 200, ""},
{"GET", "bytes=0-7", 206, data[:8]}, {"GET", "bytes=20-", 206, data[20:]}, {"GET", "bytes=-6", 206, data[len(data)-6:]},
{"GET", "bytes=1000-999999", 206, data[1000:]}, {"GET", "bytes=999999-", 416, ""}, {"GET", "bytes=bad", 416, ""},
} {
t.Run(tc.method+tc.span, func(t *testing.T) {
r := httptest.NewRequest(tc.method, link.URL, nil)
if tc.span != "" {
r.Header.Set("Range", tc.span)
}
w := httptest.NewRecorder()
signer.ServeHTTP(w, r)
if w.Code != tc.status {
t.Fatal(w.Code)
}
if tc.status != 416 && w.Body.String() != tc.want {
t.Fatal("wrong payload slice")
}
if tc.status == 206 && (w.Header().Get("Content-Length") != strconv.Itoa(len(tc.want)) || !strings.HasSuffix(w.Header().Get("Content-Range"), "/"+strconv.Itoa(len(data)))) {
t.Fatal(w.Header())
}
if tc.status == 200 && w.Header().Get("Content-Length") != strconv.Itoa(len(data)) {
t.Fatal("metadata included in length")
}
if tc.span == "bytes=999999-" && w.Header().Get("Content-Range") != "bytes */"+strconv.Itoa(len(data)) {
t.Fatal(w.Header())
}
if w.Header().Get("Cache-Control") != "private, no-store" {
t.Fatal("cache policy")
}
if tc.status != 416 {
kind, params, err := mime.ParseMediaType(w.Header().Get("Content-Disposition"))
if err != nil || kind != "inline" || params["filename"] != "示例.pdf" || w.Header().Get("Content-Type") != "application/pdf" {
t.Fatal(w.Header(), err)
}
}
})
}
r := httptest.NewRequest("GET", link.URL, nil)
r.Header.Set("Range", "bytes=0-3,10-13")
w := httptest.NewRecorder()
signer.ServeHTTP(w, r)
kind, params, err := mime.ParseMediaType(w.Header().Get("Content-Type"))
if err != nil || w.Code != 206 || kind != "multipart/byteranges" {
t.Fatal(w.Header(), err)
}
reader := multipart.NewReader(w.Body, params["boundary"])
for _, want := range []string{data[:4], data[10:14]} {
p, err := reader.NextPart()
if err != nil {
t.Fatal(err)
}
got, err := io.ReadAll(p)
if err != nil || string(got) != want {
t.Fatal("multipart bytes", err)
}
}
if _, err := reader.NextPart(); err != io.EOF {
t.Fatal(err)
}
// Random access cannot expose the managed header and still observes context.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
object, err := store.Get(ctx, "private/document")
if err != nil {
t.Fatal(err)
}
defer object.Body.Close()
seeker := object.Body.(io.ReadSeeker)
if size, err := seeker.Seek(0, io.SeekEnd); err != nil || size != int64(len(data)) {
t.Fatal(size, err)
}
if _, err := seeker.Seek(-1, io.SeekStart); err == nil {
t.Fatal("seek into header")
}
if _, err := seeker.Seek(0, io.SeekStart); err != nil {
t.Fatal(err)
}
prefix := make([]byte, 8)
if _, err := io.ReadFull(seeker, prefix); err != nil || string(prefix) != data[:8] {
t.Fatal("metadata exposed", err)
}
cancel()
if _, err := seeker.Seek(0, io.SeekStart); err != context.Canceled {
t.Fatal(err)
}
if _, err := seeker.Read(prefix); err != context.Canceled {
t.Fatal(err)
}
}

59
storage/oss/read.go Normal file
View File

@@ -0,0 +1,59 @@
package oss
import (
"context"
"errors"
"net/url"
"strconv"
"time"
"git.yuntuo.hk/k2safe/go-omni/storage"
sdk "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
)
func (s *Store) PresignGetWithOptions(ctx context.Context, key string, ttl time.Duration, options storage.ReadOptions) (storage.ReadLink, error) {
if err := storage.Check(ctx, key); err != nil {
return storage.ReadLink{}, err
}
if ttl < time.Second || ttl > time.Hour {
return storage.ReadLink{}, errors.New("storage/oss: TTL must be 1s..1h")
}
headers, err := options.ResponseHeaders()
if err != nil {
return storage.ReadLink{}, err
}
request := &sdk.GetObjectRequest{Bucket: sdk.Ptr(s.bucket), Key: sdk.Ptr(key), ResponseContentDisposition: sdk.Ptr(headers.Get("Content-Disposition")), ResponseCacheControl: sdk.Ptr(headers.Get("Cache-Control"))}
if contentType := headers.Get("Content-Type"); contentType != "" {
request.ResponseContentType = sdk.Ptr(contentType)
}
r, err := s.client.Presign(ctx, request, sdk.PresignExpires(ttl))
if err != nil {
return storage.ReadLink{}, err
}
if len(r.SignedHeaders) != 0 {
return storage.ReadLink{}, errors.New("storage/oss: direct GET requires a signature without additional request headers")
}
u, err := url.Parse(r.URL)
if err != nil {
return storage.ReadLink{}, errors.New("storage/oss: invalid signed URL")
}
q := u.Query()
var expires time.Time
if q.Get("x-oss-signature") != "" {
start, e := time.Parse("20060102T150405Z", q.Get("x-oss-date"))
seconds, e2 := strconv.ParseInt(q.Get("x-oss-expires"), 10, 64)
if e != nil || e2 != nil || seconds < 1 || seconds > 3600 {
return storage.ReadLink{}, errors.New("storage/oss: invalid signed expiry")
}
expires = start.Add(time.Duration(seconds) * time.Second)
} else {
seconds, e := strconv.ParseInt(q.Get("Expires"), 10, 64)
if e != nil || q.Get("Signature") == "" {
return storage.ReadLink{}, errors.New("storage/oss: missing signed expiry")
}
expires = time.Unix(seconds, 0).UTC()
}
return storage.ReadLink{URL: r.URL, ExpiresAt: expires}, nil
}
var _ storage.ReadPresigner = (*Store)(nil)

140
storage/oss/read_test.go Normal file
View File

@@ -0,0 +1,140 @@
package oss
import (
"context"
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"mime"
"net/url"
"sort"
"strconv"
"strings"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/storage"
sdk "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/signer"
)
// Recompute signatures independently from the SDK using fixed test credentials.
// This catches response parameters appended after signing, not just their presence.
func validReadSignature(u *url.URL, version sdk.SignatureVersionType) bool {
q := u.Query()
if version == sdk.SignatureVersionV1 {
var keys []string
for key := range q {
if strings.HasPrefix(key, "response-") {
keys = append(keys, key)
}
}
sort.Strings(keys)
var params []string
for _, key := range keys {
params = append(params, key+"="+q.Get(key))
}
message := "GET\n\n\n" + q.Get("Expires") + "\n/testbucket/folder/file.pdf?" + strings.Join(params, "&")
mac := hmac.New(sha1.New, []byte("fixtureSecret"))
mac.Write([]byte(message))
return hmac.Equal([]byte(q.Get("Signature")), []byte(base64.StdEncoding.EncodeToString(mac.Sum(nil))))
}
signature := q.Get("x-oss-signature")
q.Del("x-oss-signature")
canonical := "GET\n/testbucket/folder/file.pdf\n" + strings.ReplaceAll(q.Encode(), "+", "%20") + "\n\n\nUNSIGNED-PAYLOAD"
hash := sha256.Sum256([]byte(canonical))
_, scope, _ := strings.Cut(q.Get("x-oss-credential"), "/")
message := "OSS4-HMAC-SHA256\n" + q.Get("x-oss-date") + "\n" + scope + "\n" + hex.EncodeToString(hash[:])
key := []byte("aliyun_v4fixtureSecret")
for _, part := range strings.Split(scope, "/") {
mac := hmac.New(sha256.New, key)
mac.Write([]byte(part))
key = mac.Sum(nil)
}
mac := hmac.New(sha256.New, key)
mac.Write([]byte(message))
return hmac.Equal([]byte(signature), []byte(hex.EncodeToString(mac.Sum(nil))))
}
func TestReadOptionsNativeSignatures(t *testing.T) {
for name, version := range map[string]sdk.SignatureVersionType{"v1": sdk.SignatureVersionV1, "v4": sdk.SignatureVersionV4} {
t.Run(name, func(t *testing.T) {
cfg := sdk.LoadDefaultConfig().WithRegion("cn-hangzhou").WithEndpoint("https://files.example").WithUseCName(true).WithSignatureVersion(version).WithCredentialsProvider(credentials.NewStaticCredentialsProvider("fixtureAccess", "fixtureSecret"))
store, err := New(sdk.NewClient(cfg), "testbucket", 64)
if err != nil {
t.Fatal(err)
}
for _, options := range []storage.ReadOptions{{}, {Disposition: storage.DispositionInline, FileName: "报告 2026.pdf", ContentType: "application/pdf"}, {Disposition: storage.DispositionAttachment, FileName: "download.pdf"}} {
before := time.Now().UTC().Truncate(time.Second)
link, err := store.PresignGetWithOptions(context.Background(), "folder/file.pdf", 30*time.Minute, options)
if err != nil {
t.Fatal(err)
}
u, err := url.Parse(link.URL)
if err != nil {
t.Fatal(err)
}
q := u.Query()
if u.Host != "files.example" || u.Path != "/folder/file.pdf" || !validReadSignature(u, version) {
t.Fatal("SDK response overrides were not signed")
}
disposition, params, err := mime.ParseMediaType(q.Get("response-content-disposition"))
want := options.Disposition
if want == "" {
want = "attachment"
}
if err != nil || disposition != want || params["filename"] != options.FileName || q.Get("response-content-type") != options.ContentType || q.Get("response-cache-control") != "private, no-store" {
t.Fatal("wrong signed headers", err)
}
if link.ExpiresAt.Before(before.Add(30*time.Minute)) || link.ExpiresAt.After(time.Now().Add(30*time.Minute)) {
t.Fatal("expiry drift")
}
var expires time.Time
if version == sdk.SignatureVersionV4 {
start, _ := time.Parse("20060102T150405Z", q.Get("x-oss-date"))
seconds, _ := strconv.Atoi(q.Get("x-oss-expires"))
expires = start.Add(time.Duration(seconds) * time.Second)
} else {
seconds, _ := strconv.ParseInt(q.Get("Expires"), 10, 64)
expires = time.Unix(seconds, 0)
}
if !expires.Equal(link.ExpiresAt) {
t.Fatal("result does not match signature expiry")
}
for _, name := range []string{"response-content-disposition", "response-content-type", "response-cache-control"} {
changed := *u
query := changed.Query()
query.Set(name, "changed")
changed.RawQuery = query.Encode()
if validReadSignature(&changed, version) {
t.Fatal("unsigned override", name)
}
}
}
for _, ttl := range []time.Duration{0, time.Second - time.Nanosecond, time.Hour + time.Nanosecond} {
if _, err := store.PresignGetWithOptions(context.Background(), "file", ttl, storage.ReadOptions{}); err == nil {
t.Fatal("TTL accepted")
}
}
for _, options := range []storage.ReadOptions{{FileName: "x\r\nY: z"}, {ContentType: "text/plain\r\nY: z"}, {Disposition: "bad"}} {
if _, err := store.PresignGetWithOptions(context.Background(), "file", time.Minute, options); err == nil {
t.Fatal("invalid header accepted")
}
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := store.PresignGetWithOptions(ctx, "file", time.Minute, storage.ReadOptions{}); err != context.Canceled {
t.Fatal(err)
}
})
}
// A caller-supplied unsigned SDK configuration cannot masquerade as a link.
client := sdk.NewClient(sdk.LoadDefaultConfig().WithRegion("cn-hangzhou").WithEndpoint("https://files.example").WithCredentialsProvider(credentials.NewStaticCredentialsProvider("fixtureAccess", "fixtureSecret")), func(o *sdk.Options) { o.Signer = &signer.NopSigner{} })
store, _ := New(client, "testbucket", 64)
if _, err := store.PresignGetWithOptions(context.Background(), "file", time.Minute, storage.ReadOptions{}); err == nil {
t.Fatal("unsigned URL accepted")
}
}

72
storage/read.go Normal file
View File

@@ -0,0 +1,72 @@
package storage
import (
"context"
"errors"
"mime"
"net/http"
"strings"
"time"
"unicode"
"unicode/utf8"
)
const (
DispositionAttachment = "attachment"
DispositionInline = "inline"
)
// ReadOptions controls the response to one signed GET. Empty Disposition means
// attachment; empty ContentType preserves object metadata. FileName is a display
// name, not an object key or filesystem path. All options are signed.
type ReadOptions struct {
Disposition string
FileName string
ContentType string
}
// ReadLink grants GET access without additional request headers. ExpiresAt is
// the expiry encoded in the signature, at second precision. Provider credentials
// may expire earlier. Do not log the URL. Issuing another link does not revoke it.
type ReadLink struct {
URL string `json:"url"`
ExpiresAt time.Time `json:"expiresAt"`
}
// ReadPresigner is optional; existing Store and Presigner implementations do not
// need to change. Cloud GET signatures do not also authorize HEAD requests.
type ReadPresigner interface {
PresignGetWithOptions(context.Context, string, time.Duration, ReadOptions) (ReadLink, error)
}
// ResponseHeaders validates options and returns a fresh set of response headers
// for provider adapters to include BEFORE signing, requesting private, no-store.
func (o ReadOptions) ResponseHeaders() (http.Header, error) {
disposition := o.Disposition
if disposition == "" {
disposition = DispositionAttachment
}
if disposition != DispositionAttachment && disposition != DispositionInline {
return nil, errors.New("storage: disposition must be inline or attachment")
}
if len(o.FileName) > 1024 || !utf8.ValidString(o.FileName) || strings.IndexFunc(o.FileName, unicode.IsControl) >= 0 || strings.ContainsAny(o.FileName, `/\`) || o.FileName == "." || o.FileName == ".." {
return nil, errors.New("storage: invalid display filename")
}
params := map[string]string{}
if o.FileName != "" {
params["filename"] = o.FileName
}
h := http.Header{"Content-Disposition": {mime.FormatMediaType(disposition, params)}, "Cache-Control": {"private, no-store"}}
if o.ContentType != "" {
if !utf8.ValidString(o.ContentType) || strings.IndexFunc(o.ContentType, unicode.IsControl) >= 0 {
return nil, ErrContentType
}
kind, err := MediaType(o.ContentType)
if err != nil {
return nil, err
}
_, params, _ := mime.ParseMediaType(o.ContentType)
h.Set("Content-Type", mime.FormatMediaType(kind, params))
}
return h, nil
}

219
storage/read_test.go Normal file
View File

@@ -0,0 +1,219 @@
package storage
import (
"context"
"io"
"mime"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func TestReadOptions(t *testing.T) {
for _, name := range []string{"", "report.pdf", "报告 2026.pdf", `a";b.pdf`, "100%.pdf"} {
h, err := (ReadOptions{Disposition: DispositionInline, FileName: name, ContentType: "Application/PDF"}).ResponseHeaders()
if err != nil {
t.Fatal(err)
}
disposition, params, err := mime.ParseMediaType(h.Get("Content-Disposition"))
if err != nil || disposition != "inline" || params["filename"] != name || h.Get("Content-Type") != "application/pdf" || h.Get("Cache-Control") != "private, no-store" {
t.Fatal("invalid read headers", h, err)
}
}
h, err := (ReadOptions{}).ResponseHeaders()
if err != nil || h.Get("Content-Disposition") != "attachment" || h.Get("Content-Type") != "" {
t.Fatal(h, err)
}
for _, options := range []ReadOptions{
{Disposition: "form-data"}, {Disposition: "Inline"}, {Disposition: "inline\r\nX: y"},
{FileName: "x\r\nX: y"}, {FileName: "a\x00b"}, {FileName: "a\tb"}, {FileName: "bad\xff"}, {FileName: "a/b"}, {FileName: `a\b`}, {FileName: ".."}, {FileName: strings.Repeat("a", 1025)},
{ContentType: "bad"}, {ContentType: "image/*"}, {ContentType: "application/pdf\r\nX: y"}, {ContentType: "application/pdf\x00"}, {ContentType: strings.Repeat("x", 257)},
} {
if _, err := options.ResponseHeaders(); err == nil {
t.Fatal("invalid options accepted", options)
}
}
}
type readTestStore struct {
calls, bytes int
streaming bool
}
func (*readTestStore) Put(context.Context, string, io.Reader, int64, string) (Info, error) {
panic("unused")
}
func (*readTestStore) Delete(context.Context, string) error { panic("unused") }
func (s *readTestStore) Get(context.Context, string) (Object, error) {
s.calls++
body := &readTestBody{Reader: strings.NewReader("0123456789"), store: s}
var stream io.ReadCloser = body
if s.streaming {
stream = struct {
io.Reader
io.Closer
}{body, body}
}
return Object{Info: Info{Size: 10, ContentType: "application/pdf", ModifiedAt: time.Unix(1000, 0)}, Body: stream}, nil
}
type readTestBody struct {
*strings.Reader
store *readTestStore
}
func (b *readTestBody) Read(p []byte) (int, error) {
n, err := b.Reader.Read(p)
b.store.bytes += n
return n, err
}
func (*readTestBody) Close() error { return nil }
func TestReadSignatureAndExpiry(t *testing.T) {
store := &readTestStore{}
s, _ := NewSigner(store, "https://files.example/read", []byte(strings.Repeat("s", 32)))
now := time.Unix(1800000000, 0).UTC()
s.now = func() time.Time { return now }
link, err := s.PresignGetWithOptions(context.Background(), "folder/file", 30*time.Minute, ReadOptions{Disposition: DispositionInline, FileName: "报告.pdf", ContentType: "application/pdf"})
if err != nil || !link.ExpiresAt.Equal(now.Add(30*time.Minute)) {
t.Fatal("incorrect expiry", err)
}
serve := func(method, address string, headers http.Header) *httptest.ResponseRecorder {
r := httptest.NewRequest(method, address, nil)
if headers != nil {
r.Header = headers
}
w := httptest.NewRecorder()
s.ServeHTTP(w, r)
return w
}
for _, method := range []string{"GET", "HEAD"} {
w := serve(method, link.URL, nil)
if w.Code != 200 || w.Header().Get("Content-Type") != "application/pdf" || w.Header().Get("Cache-Control") != "private, no-store" {
t.Fatal(w)
}
if method == "HEAD" && w.Body.Len() != 0 {
t.Fatal("HEAD body")
}
}
old, err := s.PresignGet(context.Background(), "folder/file", 30*time.Minute)
if err != nil {
t.Fatal(err)
}
if w := serve("GET", old.String(), nil); w.Code != 200 || w.Header().Get("Content-Disposition") != "attachment" {
t.Fatal("legacy download changed")
}
mutations := []func(url.Values){
func(q url.Values) { q.Set("key", "other") }, func(q url.Values) { q.Set("expires", "1800001900") }, func(q url.Values) { q.Set("signature", "bad") },
func(q url.Values) { q.Set("filename", "other.pdf") }, func(q url.Values) { q.Set("disposition", "attachment") }, func(q url.Values) { q.Set("contentType", "text/html") },
func(q url.Values) { q.Set("v", "3") }, func(q url.Values) { q.Del("v") }, func(q url.Values) {
for _, k := range []string{"v", "filename", "disposition", "contentType"} {
q.Del(k)
}
},
func(q url.Values) { q.Set("extra", "x") }, func(q url.Values) { q.Set("filename", "evil\r\nX: yes") },
}
for _, key := range []string{"key", "expires", "signature", "v", "disposition", "filename", "contentType"} {
key := key
mutations = append(mutations, func(q url.Values) { q.Add(key, q.Get(key)) })
}
for _, mutate := range mutations {
u, _ := url.Parse(link.URL)
q := u.Query()
mutate(q)
u.RawQuery = q.Encode()
before := store.calls
if w := serve("GET", u.String(), http.Header{"Range": {"bytes=0-1"}}); w.Code != 403 || store.calls != before {
t.Fatal("tampered link reached storage")
}
}
u := *old
q := u.Query()
q.Set("disposition", "inline")
u.RawQuery = q.Encode()
if serve("GET", u.String(), nil).Code != 403 {
t.Fatal("legacy download upgraded by query")
}
if serve("GET", link.URL+"&bad=%ZZ", nil).Code != 403 {
t.Fatal("malformed query accepted")
}
before := store.calls
now = link.ExpiresAt.Add(-time.Nanosecond)
if serve("GET", link.URL, nil).Code != 200 {
t.Fatal("early expiration")
}
before = store.calls
now = link.ExpiresAt
for _, method := range []string{"GET", "HEAD"} {
w := serve(method, link.URL, http.Header{"Range": {"bytes=0-1"}, "If-Modified-Since": {"Fri, 01 Jan 2100 00:00:00 GMT"}})
if w.Code != 403 || store.calls != before || w.Header().Get("Cache-Control") != "private, no-store" {
t.Fatal("expired link reached storage or cache")
}
if method == "HEAD" && w.Body.Len() != 0 {
t.Fatal("HEAD error has body")
}
}
for _, ttl := range []time.Duration{0, time.Second - time.Nanosecond, time.Hour + time.Nanosecond} {
if _, err := s.PresignGetWithOptions(context.Background(), "key", ttl, ReadOptions{}); err == nil {
t.Fatal("invalid TTL")
}
}
for _, ttl := range []time.Duration{time.Second, time.Hour} {
if _, err := s.PresignGetWithOptions(context.Background(), "key", ttl, ReadOptions{}); err != nil {
t.Fatal(err)
}
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := s.PresignGetWithOptions(ctx, "key", time.Minute, ReadOptions{}); err != context.Canceled {
t.Fatal(err)
}
}
func TestReadRangeStreamingAndCache(t *testing.T) {
for _, streaming := range []bool{false, true} {
store := &readTestStore{streaming: streaming}
s, _ := NewSigner(store, "https://files.example/read", []byte(strings.Repeat("s", 32)))
link, _ := s.PresignGetWithOptions(context.Background(), "key", 30*time.Minute, ReadOptions{})
for _, method := range []string{"GET", "HEAD"} {
before := store.bytes
r := httptest.NewRequest(method, link.URL, nil)
r.Header.Set("Range", "bytes=2-4")
w := httptest.NewRecorder()
s.ServeHTTP(w, r)
if method == "HEAD" {
if w.Code != 200 || w.Body.Len() != 0 || store.bytes != before {
t.Fatal("HEAD read body", w)
}
} else if streaming {
if w.Code != 200 || w.Body.String() != "0123456789" || w.Header().Get("Accept-Ranges") != "" {
t.Fatal("stream fallback", w)
}
} else {
if w.Code != 206 || w.Body.String() != "234" || store.bytes-before != 3 {
t.Fatal("range buffered full body", w)
}
}
if w.Header().Get("Cache-Control") != "private, no-store" {
t.Fatal("missing cache policy")
}
}
if !streaming {
for _, h := range []http.Header{{"Range": {"bytes=99-100"}}, {"If-Modified-Since": {"Fri, 01 Jan 2100 00:00:00 GMT"}}} {
r := httptest.NewRequest("GET", link.URL, nil)
r.Header = h
w := httptest.NewRecorder()
s.ServeHTTP(w, r)
if w.Code != 416 && w.Code != 304 {
t.Fatal(w.Code)
}
if w.Header().Get("Cache-Control") != "private, no-store" {
t.Fatal("cache policy lost on error/conditional")
}
}
}
}
}

53
storage/s3/read.go Normal file
View File

@@ -0,0 +1,53 @@
package s3
import (
"context"
"errors"
"net/url"
"strconv"
"time"
"git.yuntuo.hk/k2safe/go-omni/storage"
)
func (s *Store) PresignGetWithOptions(ctx context.Context, key string, ttl time.Duration, options storage.ReadOptions) (storage.ReadLink, error) {
if err := storage.Check(ctx, key); err != nil {
return storage.ReadLink{}, err
}
if ttl < time.Second || ttl > time.Hour {
return storage.ReadLink{}, errors.New("storage/s3: TTL must be 1s..1h")
}
headers, err := options.ResponseHeaders()
if err != nil {
return storage.ReadLink{}, err
}
query := url.Values{"response-content-disposition": {headers.Get("Content-Disposition")}, "response-cache-control": {headers.Get("Cache-Control")}}
if contentType := headers.Get("Content-Type"); contentType != "" {
query.Set("response-content-type", contentType)
}
u, err := s.client.PresignedGetObject(ctx, s.bucket, key, ttl, query)
if err != nil {
return storage.ReadLink{}, err
}
// Derive the exact expiry from the SDK's signed URL, not a second clock read
// around credential discovery or bucket-region lookup.
q := u.Query()
var expires time.Time
if q.Get("X-Amz-Signature") != "" {
start, e := time.Parse("20060102T150405Z", q.Get("X-Amz-Date"))
seconds, e2 := strconv.ParseInt(q.Get("X-Amz-Expires"), 10, 64)
if e != nil || e2 != nil || seconds < 1 || seconds > 3600 {
return storage.ReadLink{}, errors.New("storage/s3: invalid signed expiry")
}
expires = start.Add(time.Duration(seconds) * time.Second)
} else {
seconds, e := strconv.ParseInt(q.Get("Expires"), 10, 64)
if e != nil || q.Get("Signature") == "" {
return storage.ReadLink{}, errors.New("storage/s3: missing signed expiry")
}
expires = time.Unix(seconds, 0).UTC()
}
return storage.ReadLink{URL: u.String(), ExpiresAt: expires}, nil
}
var _ storage.ReadPresigner = (*Store)(nil)

145
storage/s3/read_test.go Normal file
View File

@@ -0,0 +1,145 @@
package s3
import (
"context"
"io"
"mime"
"net/http"
"net/url"
"strconv"
"strings"
"testing"
"time"
"git.yuntuo.hk/k2safe/go-omni/storage"
"github.com/minio/minio-go/v7/pkg/credentials"
)
func TestReadOptionsS3AndR2(t *testing.T) {
for _, r2 := range []bool{false, true} {
cfg := Config{Endpoint: "files.example", Bucket: "testbucket", Region: "us-east-1", Secure: true, Credentials: credentials.NewStaticV4("fixtureAccess", "fixtureSecret", "")}
if r2 {
var err error
cfg, err = R2Config(strings.Repeat("a", 32), "testbucket", cfg.Credentials)
if err != nil {
t.Fatal(err)
}
}
store, err := Open(cfg)
if err != nil {
t.Fatal(err)
}
for _, options := range []storage.ReadOptions{{}, {Disposition: storage.DispositionInline, FileName: "报告.pdf", ContentType: "application/pdf"}} {
link, err := store.PresignGetWithOptions(context.Background(), "folder/file.pdf", 30*time.Minute, options)
if err != nil {
t.Fatal(err)
}
u, _ := url.Parse(link.URL)
q := u.Query()
kind, params, err := mime.ParseMediaType(q.Get("response-content-disposition"))
want := options.Disposition
if want == "" {
want = "attachment"
}
if err != nil || kind != want || params["filename"] != options.FileName || q.Get("response-content-type") != options.ContentType || q.Get("response-cache-control") != "private, no-store" || q.Get("X-Amz-Signature") == "" {
t.Fatal("missing response parameters", err)
}
if u.Host != cfg.Endpoint || q.Get("X-Amz-Expires") != "1800" {
t.Fatal("wrong endpoint/TTL")
}
start, err := time.Parse("20060102T150405Z", q.Get("X-Amz-Date"))
if err != nil || !link.ExpiresAt.Equal(start.Add(30*time.Minute)) {
t.Fatal("expiry drift", err)
}
}
for _, ttl := range []time.Duration{0, time.Hour + time.Nanosecond} {
if _, err := store.PresignGetWithOptions(context.Background(), "file", ttl, storage.ReadOptions{}); err == nil {
t.Fatal("invalid TTL")
}
}
if _, err := store.PresignGetWithOptions(context.Background(), "file", time.Minute, storage.ReadOptions{FileName: "x\r\nY: z"}); err == nil {
t.Fatal("invalid filename")
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := store.PresignGetWithOptions(ctx, "file", time.Minute, storage.ReadOptions{}); err != context.Canceled {
t.Fatal(err)
}
}
}
// Called inside the existing isolated MinIO integration. The actual provider
// verifies signatures and rejects tampered response parameters.
func checkReadOptionsIntegration(t *testing.T, store *Store) {
t.Helper()
ctx := context.Background()
data := "%PDF-1.4\nread-test\n%%EOF"
_, err := store.Put(ctx, "preview.pdf", strings.NewReader(data), int64(len(data)), "application/pdf")
if err != nil {
t.Fatal(err)
}
defer store.Delete(ctx, "preview.pdf")
for _, kind := range []string{storage.DispositionInline, storage.DispositionAttachment} {
link, err := store.PresignGetWithOptions(ctx, "preview.pdf", 30*time.Minute, storage.ReadOptions{Disposition: kind, FileName: "示例.pdf", ContentType: "application/pdf"})
if err != nil {
t.Fatal(err)
}
client := &http.Client{Timeout: 5 * time.Second}
for _, span := range []string{"", "bytes=0-7", "bytes=999-"} {
req, _ := http.NewRequest("GET", link.URL, nil)
if span != "" {
req.Header.Set("Range", span)
}
r, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(r.Body)
r.Body.Close()
if err != nil {
t.Fatal(err)
}
want := 200
if span == "bytes=0-7" {
want = 206
}
if span == "bytes=999-" {
want = 416
}
if r.StatusCode != want {
t.Fatal("status", r.StatusCode)
}
if want == 416 {
continue
}
disposition, params, err := mime.ParseMediaType(r.Header.Get("Content-Disposition"))
if err != nil || disposition != kind || params["filename"] != "示例.pdf" || r.Header.Get("Content-Type") != "application/pdf" || r.Header.Get("Cache-Control") != "private, no-store" {
t.Fatal("provider headers", r.Header, err)
}
value := data
if want == 206 {
value = data[:8]
}
if string(body) != value || r.Header.Get("Content-Length") != strconv.Itoa(len(value)) {
t.Fatal("provider body")
}
}
for _, name := range []string{"response-content-disposition", "response-content-type", "response-cache-control", "X-Amz-Expires"} {
u, _ := url.Parse(link.URL)
q := u.Query()
q.Set(name, "changed")
if name == "X-Amz-Expires" {
q.Set(name, "1801")
}
u.RawQuery = q.Encode()
r, err := client.Get(u.String())
if err != nil {
t.Fatal(err)
}
r.Body.Close()
if r.StatusCode != 403 {
t.Fatal("tampered parameter", name, r.StatusCode)
}
}
}
}

View File

@@ -84,7 +84,9 @@ func TestS3Integration(t *testing.T) {
if !ready { if !ready {
t.Fatal("isolated S3 server did not start") t.Fatal("isolated S3 server did not start")
} }
client, err := minio.New(address, &minio.Options{Creds: credentials.NewStaticV4("goOmniTest", "goOmniTestSecret", ""), Region: "us-east-1"}) // The contract deliberately sends malformed bodies. Avoid retry backoff
// exhausting its deadline; these are expected failures, not network retries.
client, err := minio.New(address, &minio.Options{Creds: credentials.NewStaticV4("goOmniTest", "goOmniTestSecret", ""), Region: "us-east-1", MaxRetries: 1})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -114,6 +116,7 @@ func TestS3Integration(t *testing.T) {
} }
} }
s, _ := New(client, bucket, 64) s, _ := New(client, bucket, 64)
checkReadOptionsIntegration(t, s)
storagetest.Run(t, s) storagetest.Run(t, s)
s.Put(ctx, "signed", strings.NewReader("hello"), 5, "text/plain") s.Put(ctx, "signed", strings.NewReader("hello"), 5, "text/plain")
defer s.Delete(context.Background(), "signed") defer s.Delete(context.Background(), "signed")

View File

@@ -49,30 +49,88 @@ func (s *Signer) PresignGet(ctx context.Context, key string, ttl time.Duration)
return &u, nil return &u, nil
} }
// ServeHTTP serves signed downloads as attachments. Issue links only after func (s *Signer) readSignature(q url.Values) string {
values := make(url.Values, len(q))
for key, value := range q {
if key != "signature" {
values[key] = value
}
}
mac := hmac.New(sha256.New, s.secret)
io.WriteString(mac, "GET-OPTIONS-v2\n"+values.Encode())
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func (s *Signer) PresignGetWithOptions(ctx context.Context, key string, ttl time.Duration, options ReadOptions) (ReadLink, error) {
if err := Check(ctx, key); err != nil {
return ReadLink{}, err
}
if ttl < time.Second || ttl > time.Hour {
return ReadLink{}, errors.New("storage: TTL must be 1s..1h")
}
headers, err := options.ResponseHeaders()
if err != nil {
return ReadLink{}, err
}
if options.Disposition == "" {
options.Disposition = DispositionAttachment
}
expires := s.now().Add(ttl).UTC().Truncate(time.Second)
q := url.Values{"v": {"2"}, "key": {key}, "expires": {strconv.FormatInt(expires.Unix(), 10)}, "disposition": {options.Disposition}, "filename": {options.FileName}, "contentType": {headers.Get("Content-Type")}}
q.Set("signature", s.readSignature(q))
u := *s.base
u.RawQuery = q.Encode()
return ReadLink{URL: u.String(), ExpiresAt: expires}, nil
}
// ServeHTTP serves signed GET/HEAD requests, with optional preview headers.
// Seekable object bodies support Range and conditional reads; other stores keep
// streaming full responses. No full-object buffer is used. Issue links only after
// authorization; possession of an unexpired URL grants access to its object. // authorization; possession of an unexpired URL grants access to its object.
func (s *Signer) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Signer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut && s.uploadPolicy != nil { if r.Method == http.MethodPut && s.uploadPolicy != nil {
s.serveUpload(w, r) s.serveUpload(w, r)
return return
} }
w = &readResponseWriter{ResponseWriter: w, head: r.Method == http.MethodHead}
if r.Method != "GET" && r.Method != "HEAD" { if r.Method != "GET" && r.Method != "HEAD" {
w.Header().Set("Allow", "GET, HEAD") w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", 405) http.Error(w, "method not allowed", 405)
return return
} }
q, err := url.ParseQuery(r.URL.RawQuery) q, err := url.ParseQuery(r.URL.RawQuery)
if err != nil || len(q) != 3 || len(q["key"]) != 1 || len(q["expires"]) != 1 || len(q["signature"]) != 1 { keys := []string{"key", "expires", "signature"}
if q.Get("v") == "2" {
keys = append(keys, "v", "disposition", "filename", "contentType")
}
valid := err == nil && len(q) == len(keys)
for _, key := range keys {
valid = valid && len(q[key]) == 1
}
if !valid {
http.Error(w, "invalid link", 403) http.Error(w, "invalid link", 403)
return return
} }
key, expiry := q.Get("key"), q.Get("expires") key, expiry := q.Get("key"), q.Get("expires")
expires, err := strconv.ParseInt(expiry, 10, 64) expires, err := strconv.ParseInt(expiry, 10, 64)
now := s.now().Unix() now := s.now().Unix()
if err != nil || expires <= now || expires > now+3600 || Check(r.Context(), key) != nil || !hmac.Equal([]byte(q.Get("signature")), []byte(s.signature(key, expiry))) { expected := s.signature(key, expiry)
if q.Get("v") == "2" {
expected = s.readSignature(q)
}
if err != nil || expires <= now || expires > now+3600 || Check(r.Context(), key) != nil || !hmac.Equal([]byte(q.Get("signature")), []byte(expected)) {
http.Error(w, "invalid or expired link", 403) http.Error(w, "invalid or expired link", 403)
return return
} }
options := ReadOptions{}
if q.Get("v") == "2" {
options = ReadOptions{Disposition: q.Get("disposition"), FileName: q.Get("filename"), ContentType: q.Get("contentType")}
}
headers, err := options.ResponseHeaders()
if err != nil {
http.Error(w, "invalid link", 403)
return
}
object, err := s.store.Get(r.Context(), key) object, err := s.store.Get(r.Context(), key)
if err != nil { if err != nil {
status := 500 status := 500
@@ -82,17 +140,66 @@ func (s *Signer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Error(w, "object unavailable", status) http.Error(w, "object unavailable", status)
return return
} }
if object.Body == nil {
http.Error(w, "object unavailable", 500)
return
}
defer object.Body.Close() defer object.Body.Close()
if object.Size < 0 {
http.Error(w, "object unavailable", 500)
return
}
contentType := object.ContentType contentType := object.ContentType
if options.ContentType != "" {
contentType = headers.Get("Content-Type")
}
if contentType == "" { if contentType == "" {
contentType = "application/octet-stream" contentType = "application/octet-stream"
} }
w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Disposition", "attachment") w.Header().Set("Content-Disposition", headers.Get("Content-Disposition"))
w.Header().Set("X-Content-Type-Options", "nosniff") if body, ok := object.Body.(io.ReadSeeker); ok {
w.Header().Set("Cache-Control", "private, no-store") // Range describes a GET representation. HEAD reports full-object headers.
if r.Method == http.MethodHead {
r = r.Clone(r.Context())
r.Header.Del("Range")
}
http.ServeContent(w, r, "", object.ModifiedAt, body)
return
}
w.Header().Set("Content-Length", strconv.FormatInt(object.Size, 10)) w.Header().Set("Content-Length", strconv.FormatInt(object.Size, 10))
w.WriteHeader(http.StatusOK)
if r.Method == "GET" { if r.Method == "GET" {
_, _ = io.Copy(w, object.Body) _, _ = io.Copy(w, object.Body)
} }
} }
// ServeContent may strip cache headers on errors in newer Go versions. Restore
// the signed-link policy at the actual write, including 304/416 and HEAD errors.
type readResponseWriter struct {
http.ResponseWriter
head, wrote bool
}
func (w *readResponseWriter) WriteHeader(status int) {
if w.wrote {
return
}
w.wrote = true
w.Header().Set("Cache-Control", "private, no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.ResponseWriter.WriteHeader(status)
}
func (w *readResponseWriter) Write(p []byte) (int, error) {
if !w.wrote {
w.WriteHeader(http.StatusOK)
}
if w.head {
return len(p), nil
}
return w.ResponseWriter.Write(p)
}
func (w *readResponseWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
var _ Presigner = (*Signer)(nil)
var _ ReadPresigner = (*Signer)(nil)

173
telemetry/headers.go Normal file
View File

@@ -0,0 +1,173 @@
package telemetry
import (
"context"
"errors"
"net/http"
"sort"
"strings"
"unicode"
"git.yuntuo.hk/k2safe/go-omni/logx"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/trace"
)
// HeaderPropagationConfig maps a legacy or gateway header pair to the
// standard OpenTelemetry trace context. TraceIDHeader and SpanIDHeader must
// carry a 32- and 16-hexadecimal ID respectively. RequestIDHeader is optional
// and is copied to/from the request correlation context.
//
// Legacy headers do not carry a sampling flag. The adapter treats a valid pair
// as sampled so the trace remains visible to the configured exporter. Services
// that need upstream sampling decisions should use the standard traceparent
// header, which always takes precedence.
type HeaderPropagationConfig struct {
TraceIDHeader string `json:"traceIdHeader"`
SpanIDHeader string `json:"spanIdHeader"`
RequestIDHeader string `json:"requestIdHeader,omitempty"`
}
// HeaderPropagator accepts standard W3C trace context and one configured
// legacy header pair. W3C context wins when both formats are present. It also
// propagates the configured request ID without making it part of the span
// parent. Use it only at a boundary that is authorized to supply these IDs.
type HeaderPropagator struct {
config HeaderPropagationConfig
trace propagation.TraceContext
}
// NewHeaderPropagator creates a generic compatibility propagator. Header names
// are canonicalized and compared case-insensitively. It does not trust or
// inspect a Host header; the caller decides which ingress is trusted.
func NewHeaderPropagator(config HeaderPropagationConfig) (*HeaderPropagator, error) {
traceHeader, err := canonicalPropagationHeader(config.TraceIDHeader)
if err != nil {
return nil, errors.Join(errors.New("telemetry: invalid trace ID header"), err)
}
spanHeader, err := canonicalPropagationHeader(config.SpanIDHeader)
if err != nil {
return nil, errors.Join(errors.New("telemetry: invalid span ID header"), err)
}
requestHeader := ""
if strings.TrimSpace(config.RequestIDHeader) != "" {
requestHeader, err = canonicalPropagationHeader(config.RequestIDHeader)
if err != nil {
return nil, errors.Join(errors.New("telemetry: invalid request ID header"), err)
}
}
if strings.EqualFold(traceHeader, spanHeader) ||
(requestHeader != "" && (strings.EqualFold(traceHeader, requestHeader) || strings.EqualFold(spanHeader, requestHeader))) {
return nil, errors.New("telemetry: propagation headers must be distinct")
}
for _, name := range []string{traceHeader, spanHeader, requestHeader} {
if strings.EqualFold(name, "Traceparent") || strings.EqualFold(name, "Tracestate") {
return nil, errors.New("telemetry: custom headers cannot replace W3C trace context")
}
}
config.TraceIDHeader = traceHeader
config.SpanIDHeader = spanHeader
config.RequestIDHeader = requestHeader
return &HeaderPropagator{config: config}, nil
}
func canonicalPropagationHeader(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", errors.New("header name is required")
}
canonical := http.CanonicalHeaderKey(name)
if canonical == "" || strings.IndexFunc(name, unicode.IsSpace) >= 0 || strings.ContainsAny(name, "\r\n:") {
return "", errors.New("header name is malformed")
}
return canonical, nil
}
// Inject writes W3C trace context first and then the configured compatibility
// headers. Existing caller values are replaced so all propagated IDs describe
// the same current span.
func (p *HeaderPropagator) Inject(ctx context.Context, carrier propagation.TextMapCarrier) {
if p == nil || carrier == nil {
return
}
p.trace.Inject(ctx, carrier)
sc := trace.SpanContextFromContext(ctx)
if sc.IsValid() {
carrier.Set(p.config.TraceIDHeader, sc.TraceID().String())
carrier.Set(p.config.SpanIDHeader, sc.SpanID().String())
}
if p.config.RequestIDHeader != "" {
if requestID := validCorrelationID(logx.RequestID(ctx)); requestID != "" {
carrier.Set(p.config.RequestIDHeader, requestID)
}
}
}
// Extract prefers a valid W3C parent. If it is absent or malformed, a valid
// configured legacy pair becomes a remote parent. Invalid legacy IDs are
// ignored, never copied into logs or a new outbound request.
func (p *HeaderPropagator) Extract(ctx context.Context, carrier propagation.TextMapCarrier) context.Context {
if p == nil || carrier == nil {
return ctx
}
ctx = p.trace.Extract(ctx, carrier)
if p.config.RequestIDHeader != "" {
if requestID := validCorrelationID(carrierValue(carrier, p.config.RequestIDHeader)); requestID != "" {
ctx = logx.WithRequestID(ctx, requestID)
}
}
if trace.SpanContextFromContext(ctx).IsValid() {
return ctx
}
traceID, err := trace.TraceIDFromHex(carrierValue(carrier, p.config.TraceIDHeader))
if err != nil || !traceID.IsValid() {
return ctx
}
spanID, err := trace.SpanIDFromHex(carrierValue(carrier, p.config.SpanIDHeader))
if err != nil || !spanID.IsValid() {
return ctx
}
return trace.ContextWithRemoteSpanContext(ctx, trace.NewSpanContext(trace.SpanContextConfig{
TraceID: traceID, SpanID: spanID, TraceFlags: trace.FlagsSampled, Remote: true,
}))
}
// Fields reports both W3C and compatibility fields in stable order.
func (p *HeaderPropagator) Fields() []string {
if p == nil {
return nil
}
fields := []string{"traceparent", "tracestate", p.config.TraceIDHeader, p.config.SpanIDHeader}
if p.config.RequestIDHeader != "" {
fields = append(fields, p.config.RequestIDHeader)
}
sort.Strings(fields)
return fields
}
func carrierValue(carrier propagation.TextMapCarrier, name string) string {
if value := carrier.Get(name); value != "" {
return value
}
for _, key := range carrier.Keys() {
if strings.EqualFold(key, name) {
return carrier.Get(key)
}
}
return ""
}
func validCorrelationID(value string) string {
if value == "" || value != strings.TrimSpace(value) || len(value) > 128 {
return ""
}
for _, r := range value {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.' || r == ':' {
continue
}
return ""
}
return value
}
var _ propagation.TextMapPropagator = (*HeaderPropagator)(nil)

View File

@@ -25,6 +25,7 @@ type Config struct {
SampleRatio float64 `json:"sampleRatio"` SampleRatio float64 `json:"sampleRatio"`
Exporter sdktrace.SpanExporter `json:"-"` Exporter sdktrace.SpanExporter `json:"-"`
Reader sdkmetric.Reader `json:"-"` Reader sdkmetric.Reader `json:"-"`
Propagator propagation.TextMapPropagator `json:"-"`
} }
type Runtime struct { type Runtime struct {
traces *sdktrace.TracerProvider traces *sdktrace.TracerProvider
@@ -48,7 +49,11 @@ func New(cfg Config) (*Runtime, error) {
if cfg.Reader != nil { if cfg.Reader != nil {
metricOptions = append(metricOptions, sdkmetric.WithReader(cfg.Reader)) metricOptions = append(metricOptions, sdkmetric.WithReader(cfg.Reader))
} }
r := &Runtime{traces: sdktrace.NewTracerProvider(traceOptions...), meters: sdkmetric.NewMeterProvider(metricOptions...), propagator: propagation.TraceContext{}} propagator := cfg.Propagator
if propagator == nil {
propagator = propagation.TraceContext{}
}
r := &Runtime{traces: sdktrace.NewTracerProvider(traceOptions...), meters: sdkmetric.NewMeterProvider(metricOptions...), propagator: propagator}
r.tracer = r.traces.Tracer("git.yuntuo.hk/k2safe/go-omni") r.tracer = r.traces.Tracer("git.yuntuo.hk/k2safe/go-omni")
meter := r.meters.Meter("git.yuntuo.hk/k2safe/go-omni") meter := r.meters.Meter("git.yuntuo.hk/k2safe/go-omni")
r.duration, _ = meter.Float64Histogram("http.server.request.duration", metric.WithUnit("s"), metric.WithExplicitBucketBoundaries(.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10)) r.duration, _ = meter.Float64Histogram("http.server.request.duration", metric.WithUnit("s"), metric.WithExplicitBucketBoundaries(.005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10))
@@ -121,8 +126,14 @@ func (r *Runtime) Middleware() httpx.Middleware {
r.duration.Record(ctx, time.Since(started).Seconds(), metric.WithAttributes(attrs...)) r.duration.Record(ctx, time.Since(started).Seconds(), metric.WithAttributes(attrs...))
span.End() span.End()
// Preserve correlation for outer access-log middleware without // Preserve correlation for outer access-log middleware without
// exposing an ended span as its active span. // exposing an ended span as its active span. A custom propagator may
c.WithContext(logx.WithTraceID(parent, span.SpanContext().TraceID().String())) // have supplied a request ID that the built-in RequestID middleware
// did not know about, so carry that value back as well.
outer := logx.WithTraceID(parent, span.SpanContext().TraceID().String())
if requestID := logx.RequestID(ctx); requestID != "" {
outer = logx.WithRequestID(outer, requestID)
}
c.WithContext(outer)
if p != nil { if p != nil {
panic(p) panic(p)
} }

View File

@@ -7,6 +7,7 @@ import (
"git.yuntuo.hk/k2safe/go-omni/logx" "git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/telemetry" "git.yuntuo.hk/k2safe/go-omni/telemetry"
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"go.opentelemetry.io/otel/propagation"
sdkmetric "go.opentelemetry.io/otel/sdk/metric" sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata" "go.opentelemetry.io/otel/sdk/metric/metricdata"
"go.opentelemetry.io/otel/sdk/trace/tracetest" "go.opentelemetry.io/otel/sdk/trace/tracetest"
@@ -115,3 +116,115 @@ func TestHTTPTracePropagationMetricsAndRedisNil(t *testing.T) {
t.Fatal("request metric", count) t.Fatal("request metric", count)
} }
} }
func TestConfiguredHeaderPropagation(t *testing.T) {
exporter := tracetest.NewInMemoryExporter()
propagator, err := telemetry.NewHeaderPropagator(telemetry.HeaderPropagationConfig{
TraceIDHeader: "X-Trace-Id", SpanIDHeader: "X-Span-Id", RequestIDHeader: "X-Request-Id",
})
if err != nil {
t.Fatal(err)
}
runtime, err := telemetry.New(telemetry.Config{Service: "header-test", SampleRatio: 1, Exporter: exporter, Propagator: propagator})
if err != nil {
t.Fatal(err)
}
defer runtime.Close()
const traceID = "11111111111111111111111111111111"
const parentSpanID = "2222222222222222"
const requestID = "gateway-request-1"
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Trace-Id") != traceID || r.Header.Get("X-Request-Id") != requestID {
t.Errorf("custom propagation = trace %q request %q", r.Header.Get("X-Trace-Id"), r.Header.Get("X-Request-Id"))
}
if r.Header.Get("X-Span-Id") == parentSpanID || len(r.Header.Get("X-Span-Id")) != 16 || !strings.HasPrefix(r.Header.Get("Traceparent"), "00-"+traceID+"-") {
t.Errorf("outbound parent propagation = %q", r.Header)
}
io.WriteString(w, "ok")
}))
defer upstream.Close()
app := omni.New(omni.WithLogger(false), omni.WithRequestIDHeader("X-Request-Id"))
defer app.Close()
app.Use(runtime.Middleware())
app.GET("/", func(c *httpx.Context) error {
if got := logx.TraceID(c.Context()); got != traceID {
t.Errorf("inbound trace correlation = %q", got)
}
if got := logx.RequestID(c.Context()); got != requestID {
t.Errorf("inbound request correlation = %q", got)
}
req, _ := http.NewRequestWithContext(c.Context(), http.MethodGet, upstream.URL, nil)
resp, err := (telemetry.Transport{Runtime: runtime}).RoundTrip(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.ReadAll(resp.Body)
return c.OK("ok")
})
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-Trace-Id", traceID)
req.Header.Set("X-Span-Id", parentSpanID)
req.Header.Set("X-Request-Id", requestID)
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || rec.Header().Get("X-Request-Id") != requestID {
t.Fatalf("response status/request ID = %d/%q", rec.Code, rec.Header().Get("X-Request-Id"))
}
if err := runtime.Flush(context.Background()); err != nil {
t.Fatal(err)
}
spans := exporter.GetSpans()
var server, client *tracetest.SpanStub
for _, span := range spans {
switch span.SpanKind {
case trace.SpanKindServer:
span := span
server = &span
case trace.SpanKindClient:
span := span
client = &span
}
}
if server == nil || client == nil {
t.Fatalf("missing spans: %d", len(spans))
}
if server.SpanContext.TraceID().String() != traceID || server.Parent.TraceID().String() != traceID || server.Parent.SpanID().String() != parentSpanID || !server.Parent.IsRemote() {
t.Fatalf("server parent = %s/%s remote=%v", server.SpanContext.TraceID(), server.Parent.SpanID(), server.Parent.IsRemote())
}
if client.SpanContext.TraceID().String() != traceID || client.Parent.SpanID() != server.SpanContext.SpanID() {
t.Fatalf("client linkage = trace %s parent %s", client.SpanContext.TraceID(), client.Parent.SpanID())
}
}
func TestConfiguredHeaderPropagationPrefersW3CAndRejectsInvalidSetup(t *testing.T) {
propagator, err := telemetry.NewHeaderPropagator(telemetry.HeaderPropagationConfig{TraceIDHeader: "x-trace-id", SpanIDHeader: "x-span-id"})
if err != nil {
t.Fatal(err)
}
carrier := propagation.MapCarrier{
"traceparent": "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01",
"X-Trace-Id": "11111111111111111111111111111111",
"X-Span-Id": "2222222222222222",
}
ctx := propagator.Extract(context.Background(), carrier)
parent := trace.SpanContextFromContext(ctx)
if parent.TraceID().String() != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" || parent.SpanID().String() != "bbbbbbbbbbbbbbbb" {
t.Fatalf("W3C parent was not preferred: %s/%s", parent.TraceID(), parent.SpanID())
}
for _, config := range []telemetry.HeaderPropagationConfig{
{TraceIDHeader: "", SpanIDHeader: "X-Span-Id"},
{TraceIDHeader: "X-Trace-Id", SpanIDHeader: ""},
{TraceIDHeader: "X-Trace-Id", SpanIDHeader: "X-Trace-Id"},
{TraceIDHeader: "traceparent", SpanIDHeader: "X-Span-Id"},
{TraceIDHeader: "X Trace", SpanIDHeader: "X-Span-Id"},
} {
if _, err := telemetry.NewHeaderPropagator(config); err == nil {
t.Fatalf("invalid propagation config accepted: %+v", config)
}
}
invalid := propagation.MapCarrier{"X-Trace-Id": "bad", "X-Span-Id": "bad"}
if got := trace.SpanContextFromContext(propagator.Extract(context.Background(), invalid)); got.IsValid() {
t.Fatal("invalid legacy IDs became a parent")
}
}

149
ws/README.md Normal file
View File

@@ -0,0 +1,149 @@
# WebSocket
`ws` 提供通用 Go Client、Server 和单次物理连接 Session。核心使用标准 RFC 6455 WebSocket,可连接浏览器或第三方 WS 服务。`hub`、`rpc`、`observe` 按需引入;不预设用户、租户、设备、错误码或数据存储。
- [回显、分组广播与浏览器示例](../examples/websocket/README.md)
- [双向 RPC 与订阅恢复示例](../examples/websocketRPC/README.md)
- [RPC 协议与订阅规则](rpc/README.md)
- [日志与指标适配](observe/README.md)
- [测试与基准报告](../reports/websocket-validation-2026-10-29.md)
## 独立 Server 与 Client
```go
server, err := ws.NewServer(ws.ServerConfig{
MaxConnections: 4096,
// 省略时允许同源和无 Origin 的非浏览器请求。
AllowedOrigins: []string{"https://console.example.com"},
})
if err != nil { return err }
mux.Handle("/ws", server.Handler(ws.Handlers{
OnMessage: func(ctx context.Context, session *ws.Session, msg ws.Message) error {
return session.Send(ctx, msg)
},
}))
// HTTP 宿主退出后、依赖释放前调用 server.Shutdown(ctx)。
client, err := ws.NewClient(ws.ClientConfig{ProxyMode: "direct"})
if err != nil { return err }
session, err := client.Dial(workCtx, "wss://service.example.com/ws", ws.Handlers{
OnMessage: func(ctx context.Context, session *ws.Session, msg ws.Message) error {
// msg.Type 是 ws.Text 或 ws.Binary;此回调中处理业务消息。
return nil
},
})
if err != nil { return err }
err = session.SendJSON(ctx, map[string]string{"message": "hello"})
// 退出前 client.Shutdown(ctx),确保回调结束后再关闭其依赖。
```
`NewServer` / `NewClient` 只验证并构造,无监听或拨号。Server 是 `http.Handler`,复用 HTTP 监听器及 TLS;客户端 `TLSConfig` 支持系统信任链、自定义 CA 与客户端证书。注入的证书/CA/回调对象由调用方保持并发安全且不再修改。TLS 默认正常验证,不自动跳过验证。
`Server.Accept(w,r,handlers)` 会写出握手成功或错误响应,成功后启动托管会话并返回。原 HTTP Handler 可以立即返回;不要保留整个 `*httpx.Context`、Request 或可变鉴权对象。Server 的会话生命周期由 `ServerConfig.Context`(默认 Background)管理,握手 context 仅用于握手。Client.Dial 的 ctx 持有会话寿命,其配置的 HandshakeTimeout 只限制握手。
## 握手与安全配置
`ServerConfig` 支持 `Session`、`MaxConnections`、`HandshakeTimeout`、`AllowedOrigins`、`RequireOrigin`、`Subprotocols`、`RequireSubprotocol`、`EnableCompression`、`HandshakeRate`、`HandshakeBurst`。连接上限包含进行中的握手。速率为每秒令牌数、burst 为突发数,零 rate 默认关闭;超过速率返回 429,连接上限/停机返回 503。HTTP 读头超时和头大小限制仍由宿主配置。
Origin 校验与 CORS 独立:
- 默认只接受与请求的真实 scheme/host/port 一致的 Origin;缺少 Origin 默认允许,便于 Go 等非浏览器客户端,设置 RequireOrigin 可拒绝。
- 显式 AllowedOrigins 是完整来源白名单,支持明确的 `http://` / `https://` 和端口;不支持通配 `*`、路径、userinfo、null 或重复 Origin。默认端口归一化。
- 不信任 Forwarded/X-Forwarded-* 来推断来源。TLS 在代理终止时,应填写实际外部 Origin 白名单。
- Origin 不是身份认证。`Prepare(ctx,*http.Request)(Handlers,error)` 在升级前验证业务身份,同时可为每连接创建 handlers。失败默认 401;返回 `*ws.RejectError{StatusCode:403}` 可选择 4xx。响应不包含底层错误。
- 浏览器原生 WebSocket 不能自定义 Authorization Header,业务可采用已受保护的 Cookie 或短期握手票据。若使用 Cookie,继续严格检查 Origin;避免在 URL 中放长期凭证,并配置代理访问日志脱敏。
RequireSubprotocol 要求协商成功,否则握手失败;纯 WS 默认无必选子协议。RPC 使用 `go-omni.rpc.v1`,两端显式声明并要求它。
## Session 的收发与关闭
Session 提供 `ID / Context / Done / Err / Subprotocol / Stats / Send / TrySend / SendJSON / Close / Abort / Wait`。
| 操作 | 语义 |
| --- | --- |
| `Send(ctx,Message)` | 有界队列入场后等待写入结果;队列满立即返回 ErrQueueFull,不无限等待空位。 |
| `TrySend(Message)` | 只确认入队,随后仍可能写失败;用于广播、单向通知。 |
| `SendJSON(ctx,value)` | JSON 编码后按 Text 发送,适用于应用自己的协议。 |
| `Close(code,reason)` | 幂等启动关闭,取消回调 context,尽力发送已接纳且未取消的出站消息,再发关闭帧;不等待自身回调。 |
| `Abort()` | 立即关闭 socket,仍需 Wait 才能证明回调退出。 |
| `Wait(ctx)` / `Done()` | 等待读、写、分发、监控及 OnClose 全部结束;Wait 返回运行错误或等待超时。 |
文本严格验证 UTF-8;二进制保持字节内容。入队复制发送 payload,Send/TrySend 返回后调用方可以复用原切片。不得在调用进行中并发修改它。接收 payload 不复用,回调可以保留,但应用自行承担保留后的内存。业务不得直接抢占底层 reader/writer。
单个会话 OnOpen、OnMessage 顺序执行,OnClose 在 I/O 和消息回调结束后执行;不同会话可并发。回调必须响应 ctx 取消,不要在回调内调用自身 Wait、Client/Server.Shutdown 或 App.Close,可调用 Session.Close 或 Server.Stop。OnOpen 中不要等待必须依赖 OnMessage 才完成的操作;RPC Call/订阅恢复应在 WaitReady 后从独立受管理任务调用。
排队时 Send ctx 取消会跳过该消息;写入期间取消可能已部分送出,此时终止物理连接。成功写出也不代表对端业务处理成功。框架不缓存离线消息、不自动重放;业务需要 ACK、去重、持久化时显式设计。Close 不是业务事务提交屏障,会取消当前业务并丢弃尚未分发的入站消息。
## 资源上限与心跳
`SessionConfig` 零字段使用默认值,负值或超过支持边界的值在构造前报错。明确关闭心跳使用 `DisableHeartbeat`,零时间不表示禁用。
| 配置 | 默认 |
| --- | --- |
| MaxMessageBytes | 1 MiB,包括解压后的消息 |
| ReadQueueSize / WriteQueueSize | 每方向 64 条 |
| ReadQueueBytes / WriteQueueBytes | 每方向 4 MiB |
| WriteTimeout | 10 秒 |
| PingInterval / PongTimeout | 30 / 60 秒 |
| CloseTimeout | 5 秒 |
队列计数包含正在执行的回调/正在写的消息;此外还需计入一条正在解码的消息、协议缓冲和应用持有的对象。实例总内存还受连接数影响。接收队列溢出关闭该连接;发送满明确报错,广播逐连接返回错误。有限队列允许持续处理控制帧,不让慢业务无限阻塞 reader。压缩默认关闭;启用后仍执行解压大小限制,但会消耗额外 CPU,应结合并发连接限额验证。
协议 Ping/Pong 由底层持续处理;浏览器自动回复 Pong。心跳监测连接活性,不证明业务健康。
## Client 重连与网络设置
`ClientConfig` 支持 Session、HandshakeTimeout(默认 10s)、MaxConnections(默认 64)、Subprotocols/RequireSubprotocol、EnableCompression、ProxyMode/ProxyURL、TLSConfig、Headers、HeaderProvider、Prepare、Reconnect、ShouldReconnect。
- `Dial` 只拨一次。`Run(ctx,url,handlers)` 管理一条重连链;同 Client 可以并发运行多条连接,统一限制和关闭。
- `Reconnect.Enabled` 显式开启;默认 minBackoff=100ms、maxBackoff=30s、stableAfter=30s。指数退避带抖动;短暂反复断线不重置预算。MaxAttempts/MaxElapsed 可限制重试次数和时间;开启重连后两者为零表示不限。
- 默认不重试 401/403、协议不兼容、证书验证错误、正常 1000 关闭;远端先发 1001(going away)、1012 等重启关闭码和临时网络故障可重试。本地主动 Close/Client.Stop 不触发重连。ShouldReconnect 可改变分类,不能绕过显式关闭和预算。
- 每次握手调用 HeaderProvider(ctx),用于刷新凭证;返回头会复制。Prepare(ctx) 可为每代创建新的 RPC Peer/handlers,禁止复用已经绑定的 Peer。
- ProxyMode 默认为 environment,可显式 direct 或 url;固定代理支持 HTTP CONNECT、socks5、socks5h。HTTPS 代理当前明确拒绝,不会降级直连。
- 自有 HTTP 包装链 `httpc.Do`、`telemetry.Transport`、`resilience.Transport` 不直接用于 WS 升级;WS 独立处理握手、消息与连接寿命。
`Client.Close` / `Stop` 停止入场、取消握手/退避并发起会话关闭;`Drain` / `Shutdown` 等待所有 Run、握手、会话和回调结束。Server 同样提供 Stop/Drain/Shutdown。终止后对象不可重新启动。
## Hub 与扩展
`hub.New(Config)` 提供 `Add / Remove / Join / Leave / Send / SendGroup / Broadcast / Kick / Stats / Close`。Session 实现其 `Connection` 接口,也可实现其他有界非阻塞连接适配器。组键是业务自定义字符串,Join 前由业务鉴权。
Send/Broadcast/SendGroup 返回 `Result{Attempted,Queued,Errors}`,逐连接尝试入队,慢连接不会等待网络写出。Queued 不等于远端收到。Hub 借用连接,Close 只清注册和 watcher;Kick 才主动关闭,Session.Done 后自动清理。上限可配置连接数、组数、总成员关系、每连接组数量。
Hub 是单进程注册表。跨节点广播由业务消息总线投递到各节点的本地 Hub;此版不含分布式路由、离线存储或 exactly-once 保证。通过接口扩展时仍需保持 TrySend 有界、非阻塞、并发安全。
## GoOmni 接入与停机
独立 App:
```go
application := omni.New()
server, err := ws.NewServer(ws.ServerConfig{})
if err != nil { return err }
if err = wsomni.Mount(application, "/ws", server, handlers); err != nil { return err }
// Mount 同时登记 Server.Stop/Drain,App.OnShutdown 的资源晚于会话释放。
return application.RunContext(ctx, ":8080")
```
`wsomni` 的 import 是 `git.yuntuo.hk/k2safe/go-omni/ws/omni`。App 新增通用 `OnStop(name,hook)` / `OnDrain(name,hook)`:HTTP 结束 → 全部 Stop → 全部 Drain → OnShutdown。复用 lifecycle 配置及 ErrResourcesRetained 规则;只在用到后台组件时创建额外 Runtime。
生成项目在 `svc.Init` 创建 server,Context 设为 svc.WorkContext,并登记 `svc.OnStop("ws",server.Stop)` / `svc.OnDrain("ws",server.Drain)`。项目可手写以下方法,新版生成 NewApp 自动调用:
```go
func (c *ServiceContext) RegisterCustomRoutes(app *omni.App) error {
app.GET("/ws", wsomni.Handler(c.WebSocket, c.WebSocketHandlers))
return nil
}
```
不要同时把同一组件登记到 App 和 svc;依赖归属与排空屏障必须一致。自定义路由在生成路由与内建端点之后注册,重复路径由路由器拒绝,不会覆盖现有接口。重新运行 `api generate` 更新入口,无需修改受管文件;普通项目没有此方法时行为不变。握手成功会记录 HTTP 101,HTTP 日志只计算握手时间。
ServerConfig/ClientConfig 直接组合进项目的 Config/Default/Validate;文件参数仍采用显式 `${NAME}` 引用。框架不新增 WS DSL 或自动业务 Token。
兼容范围:默认 nethttp 的 HTTP/1.1 Upgrade、标准 HTTP/1.1 反向代理、WS/WSS。自研 native 和独立实验 http2 Runner 目前不支持 WS;Mount 在配置时拒绝,普通 Handler 遇到不支持接管的 ResponseWriter 返回 501。不宣称支持 HTTP/2 extended CONNECT。切换 Runner 时仍需遵守此边界。
## 错误与观测
`errors.Is` 判断 ErrClosed、ErrNotConnected、ErrQueueFull、ErrMessageTooLarge、ErrInvalidMessage、ErrUnsupportedTransport、ErrConnectionLimit;`errors.As` 获取 HandshakeError.StatusCode、CloseError.Code、OpError.Op。Cause 保留诊断链,但可能包含敏感数据;默认错误文本不包含 URL、认证头、payload 或对端 reason,不要无条件递归记录 Cause。
Server.Stats 提供握手数、活跃连接、累计接受/拒绝;Client.Stats 提供活跃连接、握手、Run数、关闭状态及累计 DialAttempts、HandshakeFailures、Reconnects;Session.Stats 提供消息/字节和队列占用。`observe` 提供安全 logx 日志和跨会话统计快照,可由项目导出到 metrics/OTel。连接 ID 不适合作为指标标签;不要把整条长连接当成持续数小时的 HTTP Span。业务消息 tracing 由业务协议显式传播。

633
ws/client.go Normal file
View File

@@ -0,0 +1,633 @@
package ws
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"io"
"math/rand"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
var ErrConnectionLimit = errors.New("ws: connection limit reached")
// ReconnectConfig controls Run only. Dial never retries. Zero MaxAttempts and
// MaxElapsed mean unlimited retries/time when Enabled is explicitly true.
// A stable connection resets both retry budgets; short flaps do not.
type ReconnectConfig struct {
Enabled bool `json:"enabled"`
MinBackoff time.Duration `json:"minBackoff"`
MaxBackoff time.Duration `json:"maxBackoff"`
StableAfter time.Duration `json:"stableAfter"`
MaxAttempts int `json:"maxAttempts"`
MaxElapsed time.Duration `json:"maxElapsed"`
}
type ClientConfig struct {
Session SessionConfig `json:"session"`
HandshakeTimeout time.Duration `json:"handshakeTimeout"`
MaxConnections int `json:"maxConnections"`
Subprotocols []string `json:"subprotocols"`
RequireSubprotocol bool `json:"requireSubprotocol"`
EnableCompression bool `json:"enableCompression"`
// ProxyMode is environment (default), direct, or url. ProxyURL may contain
// credentials and must not be logged. Explicit proxy URLs ignore NO_PROXY.
// Supported proxy schemes are http, socks5, and socks5h (remote DNS).
ProxyMode string `json:"proxyMode"`
ProxyURL string `json:"proxyUrl"`
Reconnect ReconnectConfig `json:"reconnect"`
Headers http.Header `json:"-"`
// HeaderProvider is called before each handshake. Return a fresh header map;
// the client snapshots it. Provider values replace static Headers values.
// Implementations must honor context cancellation and support concurrent calls.
HeaderProvider func(context.Context) (http.Header, error) `json:"-"`
// Prepare creates per-session handlers after a successful handshake. When
// set, its result replaces Dial/Run handlers. It must honor cancellation.
Prepare func(context.Context) (Handlers, error) `json:"-"`
TLSConfig *tls.Config `json:"-"`
// ShouldReconnect overrides default error classification, not budgets or
// explicit client/context shutdown. It must be safe for concurrent Run calls.
ShouldReconnect func(error) bool `json:"-"`
}
func (cfg ClientConfig) Validate() error { _, _, err := normalizeClientConfig(cfg); return err }
func normalizeClientConfig(cfg ClientConfig) (ClientConfig, func(*http.Request) (*url.URL, error), error) {
var err error
cfg.Session, err = normalizeSessionConfig(cfg.Session)
if err != nil {
return cfg, nil, err
}
if cfg.HandshakeTimeout < 0 || cfg.MaxConnections < 0 || cfg.MaxConnections > 65536 || cfg.Reconnect.MinBackoff < 0 || cfg.Reconnect.MaxBackoff < 0 || cfg.Reconnect.StableAfter < 0 || cfg.Reconnect.MaxElapsed < 0 || cfg.Reconnect.MaxAttempts < 0 {
return cfg, nil, errors.New("ws: invalid client limits or durations")
}
if cfg.HandshakeTimeout == 0 {
cfg.HandshakeTimeout = 10 * time.Second
}
if cfg.MaxConnections == 0 {
cfg.MaxConnections = 64
}
if cfg.Reconnect.MinBackoff == 0 {
cfg.Reconnect.MinBackoff = 100 * time.Millisecond
}
if cfg.Reconnect.MaxBackoff == 0 {
cfg.Reconnect.MaxBackoff = 30 * time.Second
}
if cfg.Reconnect.StableAfter == 0 {
cfg.Reconnect.StableAfter = 30 * time.Second
}
if cfg.Reconnect.MinBackoff > cfg.Reconnect.MaxBackoff {
return cfg, nil, errors.New("ws: minBackoff exceeds maxBackoff")
}
cfg.Headers, err = clientHeaders(cfg.Headers)
if err != nil {
return cfg, nil, err
}
if len(cfg.Subprotocols) > 32 {
return cfg, nil, errors.New("ws: too many subprotocols")
}
seen := map[string]bool{}
for _, protocol := range cfg.Subprotocols {
if !headerToken(protocol) || len(protocol) > 256 || seen[protocol] {
return cfg, nil, errors.New("ws: invalid or duplicate subprotocol")
}
seen[protocol] = true
}
if cfg.RequireSubprotocol && len(cfg.Subprotocols) == 0 {
return cfg, nil, errors.New("ws: required subprotocol list is empty")
}
cfg.Subprotocols = append([]string(nil), cfg.Subprotocols...)
if cfg.TLSConfig != nil {
cfg.TLSConfig = cfg.TLSConfig.Clone()
}
if cfg.ProxyMode != "url" && cfg.ProxyURL != "" {
return cfg, nil, errors.New("ws: proxyUrl requires url proxyMode")
}
var proxy func(*http.Request) (*url.URL, error)
switch cfg.ProxyMode {
case "", "environment":
proxy = func(r *http.Request) (*url.URL, error) {
u, err := http.ProxyFromEnvironment(r)
if err != nil || u == nil {
return u, err
}
return supportedProxy(u)
}
case "direct":
case "url":
u, e := url.Parse(cfg.ProxyURL)
if e != nil || u == nil || u.Hostname() == "" || u.Opaque != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || (u.Path != "" && u.Path != "/") || !validPort(u) {
return cfg, nil, errors.New("ws: invalid proxy URL")
}
u, e = supportedProxy(u)
if e != nil {
return cfg, nil, e
}
proxy = http.ProxyURL(u)
default:
return cfg, nil, errors.New("ws: invalid proxyMode")
}
return cfg, proxy, nil
}
func supportedProxy(u *url.URL) (*url.URL, error) {
copyURL := *u
switch copyURL.Scheme {
case "http", "socks5":
case "socks5h":
copyURL.Scheme = "socks5"
default:
return nil, errors.New("ws: proxy scheme must be http, socks5 or socks5h")
}
return &copyURL, nil
}
func validPort(u *url.URL) bool {
if strings.HasSuffix(u.Host, ":") {
return false
}
if p := u.Port(); p != "" {
n, err := strconv.Atoi(p)
return err == nil && n > 0 && n <= 65535
}
return true
}
func headerToken(s string) bool {
if s == "" {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("!#$%&'*+-.^_`|~", rune(c)) {
continue
}
return false
}
return true
}
func clientHeaders(input http.Header) (http.Header, error) {
if len(input) > 128 {
return nil, errors.New("ws: too many handshake headers")
}
out := make(http.Header, len(input))
size := 0
for key, values := range input {
name := http.CanonicalHeaderKey(key)
if !headerToken(key) || len(key) > 256 {
return nil, errors.New("ws: invalid handshake header name")
}
switch name {
case "Host", "Connection", "Upgrade", "Sec-Websocket-Key", "Sec-Websocket-Version", "Sec-Websocket-Extensions", "Sec-Websocket-Protocol", "Content-Length", "Transfer-Encoding", "Trailer":
return nil, errors.New("ws: reserved handshake header")
}
if _, ok := out[name]; ok {
return nil, errors.New("ws: duplicate handshake header")
}
for _, value := range values {
size += len(key) + len(value)
for i := 0; i < len(value); i++ {
if value[i] < 32 && value[i] != '\t' || value[i] == 127 {
return nil, errors.New("ws: invalid handshake header value")
}
}
}
if size > 64<<10 {
return nil, errors.New("ws: handshake headers exceed limit")
}
out[name] = append([]string(nil), values...)
}
return out, nil
}
// Client owns its sessions. Concurrent Dial and Run calls are independent and
// share only limits and lifecycle. One Run owns one reconnecting connection;
// every successful handshake produces a new Session with a new ID.
type Client struct {
config ClientConfig
dialer websocket.Dialer
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex
closed bool
active int
connections int
dialAttempts, handshakeFailures, reconnects uint64
sessions map[string]*Session
releases map[string]func()
done chan struct{}
}
func NewClient(cfg ClientConfig) (*Client, error) {
cfg, proxy, err := normalizeClientConfig(cfg)
if err != nil {
return nil, err
}
ctx, cancel := context.WithCancel(context.Background())
return &Client{config: cfg, dialer: websocket.Dialer{Proxy: proxy, HandshakeTimeout: cfg.HandshakeTimeout, TLSClientConfig: cfg.TLSConfig, Subprotocols: cfg.Subprotocols, EnableCompression: cfg.EnableCompression}, ctx: ctx, cancel: cancel, sessions: make(map[string]*Session), releases: make(map[string]func()), done: make(chan struct{})}, nil
}
func (c *Client) begin(connection bool) error {
for {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return ErrClosed
}
if connection && c.connections >= c.config.MaxConnections {
// Done may wake a caller before its registry watcher gets CPU time.
// Retire finished sessions synchronously before reporting overload.
var release func()
for id, s := range c.sessions {
select {
case <-s.Done():
release = c.releases[id]
default:
}
if release != nil {
break
}
}
c.mu.Unlock()
if release != nil {
release()
continue
}
return ErrConnectionLimit
}
c.active++
if connection {
c.connections++
c.dialAttempts++
}
c.mu.Unlock()
return nil
}
}
func (c *Client) end(connection bool, id string) {
c.mu.Lock()
defer c.mu.Unlock()
if id != "" {
delete(c.sessions, id)
delete(c.releases, id)
}
c.active--
if connection {
c.connections--
}
if c.closed && c.active == 0 {
close(c.done)
}
}
// Dial performs exactly one handshake. Its timeout ends at the handshake;
// ctx continues to own the resulting session's lifetime. No message is replayed.
func (c *Client) Dial(ctx context.Context, endpoint string, handlers Handlers) (*Session, error) {
return c.dial(ctx, endpoint, handlers, 0)
}
func (c *Client) dial(ctx context.Context, endpoint string, handlers Handlers, budget time.Duration) (*Session, error) {
if ctx == nil {
return nil, errors.New("ws: nil context")
}
if err := ctx.Err(); err != nil {
return nil, err
}
u, err := url.Parse(endpoint)
if err != nil || u == nil || u.Hostname() == "" || u.User != nil || u.Fragment != "" || u.Opaque != "" || (u.Scheme != "ws" && u.Scheme != "wss") || !validPort(u) {
return nil, errors.New("ws: invalid endpoint")
}
if err = c.begin(true); err != nil {
return nil, err
}
transferred := false
sessionCtx, cancelSession := context.WithCancel(ctx)
stopSessionShutdown := context.AfterFunc(c.ctx, cancelSession)
defer func() {
if !transferred {
c.mu.Lock()
c.handshakeFailures++
c.mu.Unlock()
stopSessionShutdown()
cancelSession()
c.end(true, "")
}
}()
handshakeTimeout := c.config.HandshakeTimeout
if budget > 0 && budget < handshakeTimeout {
handshakeTimeout = budget
}
hctx, cancel := context.WithTimeout(ctx, handshakeTimeout)
stop := context.AfterFunc(c.ctx, cancel)
defer func() { stop(); cancel() }()
headers := c.config.Headers.Clone()
if c.config.HeaderProvider != nil {
provided, e := provideHeaders(c.config.HeaderProvider, hctx)
if e != nil {
return nil, &OpError{Op: "headers", Cause: e}
}
provided, e = clientHeaders(provided)
if e != nil {
return nil, &OpError{Op: "headers", Cause: e}
}
for k, v := range provided {
headers[k] = v
}
}
dialer := c.dialer
// Gorilla applies the context deadline to handshake I/O, but canceling a
// deadline-less context after TCP connect does not interrupt its HTTP read.
// Retain the raw socket only through the handshake so Close/Stop cancels
// stalled HTTP and proxy handshakes immediately, without timing out sessions.
var stopHandshake func() bool
dialer.NetDialContext = func(dialctx context.Context, network, address string) (net.Conn, error) {
conn, err := (&net.Dialer{Timeout: c.config.HandshakeTimeout, KeepAlive: 30 * time.Second}).DialContext(dialctx, network, address)
if err == nil {
stopHandshake = context.AfterFunc(hctx, func() { _ = conn.Close() })
}
return conn, err
}
if dialer.TLSClientConfig != nil {
dialer.TLSClientConfig = dialer.TLSClientConfig.Clone()
}
conn, response, err := dialer.DialContext(hctx, endpoint, headers)
if stopHandshake != nil {
stopHandshake()
}
if err == nil && hctx.Err() != nil {
_ = conn.Close()
err = hctx.Err()
}
if err != nil {
status := 0
if response != nil {
status = response.StatusCode
if response.Body != nil {
_ = response.Body.Close()
}
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
if c.ctx.Err() != nil {
return nil, ErrClosed
}
if hctx.Err() != nil {
err = hctx.Err()
} else if deadline, ok := hctx.Deadline(); ok && !time.Now().Before(deadline) {
var timed net.Error
if errors.As(err, &timed) && timed.Timeout() {
err = errors.Join(context.DeadlineExceeded, err)
}
}
return nil, &HandshakeError{StatusCode: status, Cause: err}
}
if c.config.RequireSubprotocol && conn.Subprotocol() == "" {
_ = conn.Close()
return nil, &HandshakeError{StatusCode: http.StatusSwitchingProtocols, Cause: websocket.ErrBadHandshake}
}
if c.config.Prepare != nil {
handlers, err = prepareClient(c.config.Prepare, sessionCtx)
if err != nil {
_ = conn.Close()
return nil, &OpError{Op: "prepare", Cause: err}
}
}
c.mu.Lock()
if c.closed {
c.mu.Unlock()
_ = conn.Close()
return nil, ErrClosed
}
s := newSession(conn, c.config.Session, handlers, sessionCtx)
c.sessions[s.ID()] = s
var releaseOnce sync.Once
release := func() { releaseOnce.Do(func() { cancelSession(); c.end(true, s.ID()) }) }
c.releases[s.ID()] = release
transferred = true
stopSessionShutdown()
c.mu.Unlock()
s.start()
go func() { <-s.Done(); release() }()
return s, nil
}
func provideHeaders(fn func(context.Context) (http.Header, error), ctx context.Context) (headers http.Header, err error) {
defer func() {
if recover() != nil {
err = errors.New("ws: header provider panicked")
}
}()
return fn(ctx)
}
func prepareClient(fn func(context.Context) (Handlers, error), ctx context.Context) (handlers Handlers, err error) {
defer func() {
if recover() != nil {
err = errors.New("ws: prepare callback panicked")
}
}()
return fn(ctx)
}
// Run blocks until canceled, closed, or retry policy/budget stops it. Reconnect
// must be enabled explicitly. Handlers are reused; OnOpen must restore any
// application subscriptions. Credentials are refreshed by HeaderProvider.
func (c *Client) Run(ctx context.Context, endpoint string, handlers Handlers) error {
if ctx == nil {
return errors.New("ws: nil context")
}
if err := c.begin(false); err != nil {
return err
}
defer c.end(false, "")
runctx, cancel := context.WithCancel(ctx)
stop := context.AfterFunc(c.ctx, cancel)
defer func() { stop(); cancel() }()
attempts := 0
episode := time.Now()
var previousError error = context.DeadlineExceeded
repeat := false
for {
var remaining time.Duration
if c.config.Reconnect.Enabled && c.config.Reconnect.MaxElapsed > 0 {
remaining = c.config.Reconnect.MaxElapsed - time.Since(episode)
if remaining <= 0 {
return previousError
}
}
started := time.Now()
if repeat {
c.mu.Lock()
c.reconnects++
c.mu.Unlock()
}
repeat = true
s, err := c.dial(runctx, endpoint, handlers, remaining)
if err == nil {
started = time.Now()
err = s.Wait(runctx)
if time.Since(started) >= c.config.Reconnect.StableAfter {
attempts = 0
episode = time.Now()
}
}
if ctx.Err() != nil {
return ctx.Err()
}
if c.ctx.Err() != nil {
return nil
}
if !c.config.Reconnect.Enabled || !c.retryable(err) {
return err
}
previousError = err
rc := c.config.Reconnect
if rc.MaxAttempts > 0 && attempts >= rc.MaxAttempts {
return err
}
delay := retryBackoff(rc, attempts)
if rc.MaxElapsed > 0 && time.Since(episode)+delay >= rc.MaxElapsed {
return err
}
attempts++
timer := time.NewTimer(delay)
select {
case <-runctx.Done():
timer.Stop()
if ctx.Err() != nil {
return ctx.Err()
}
return nil
case <-timer.C:
}
}
}
func retryBackoff(cfg ReconnectConfig, attempt int) time.Duration {
d := cfg.MinBackoff
for i := 0; i < attempt && d < cfg.MaxBackoff; i++ {
if d > cfg.MaxBackoff/2 {
d = cfg.MaxBackoff
} else {
d *= 2
}
}
if d > 1 {
d = d/2 + time.Duration(rand.Int63n(int64(d-d/2))+1)
}
return d
}
func (c *Client) retryable(err error) bool {
if err == nil || errors.Is(err, ErrClosed) || errors.Is(err, context.Canceled) {
return false
}
if c.config.ShouldReconnect != nil {
return c.config.ShouldReconnect(err)
}
var handshake *HandshakeError
if errors.As(err, &handshake) {
switch handshake.StatusCode {
case 429, 500, 502, 503, 504:
return true
case 0:
default:
return false
}
if errors.Is(err, websocket.ErrBadHandshake) {
return false
}
}
var unknown x509.UnknownAuthorityError
var invalid x509.CertificateInvalidError
var hostname x509.HostnameError
if errors.As(err, &unknown) || errors.As(err, &invalid) || errors.As(err, &hostname) {
return false
}
var closeError *CloseError
if errors.As(err, &closeError) {
switch closeError.Code {
case 1001, 1006, 1011, 1012, 1013:
return true
default:
return false
}
}
var network net.Error
if errors.As(err, &network) || errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return true
}
return handshake != nil || errors.Is(err, ErrNotConnected)
}
// Close is idempotent and nonblocking: it rejects new work, cancels handshakes
// and reconnect waits, and requests session close. Drain waits for all work,
// including user callbacks. A callback that ignores cancellation cannot be killed.
func (c *Client) Close() error {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return nil
}
c.closed = true
sessions := make([]*Session, 0, len(c.sessions))
for _, s := range c.sessions {
sessions = append(sessions, s)
}
if c.active == 0 {
close(c.done)
}
c.mu.Unlock()
c.cancel()
for _, s := range sessions {
_ = s.Close(1001, "client shutdown")
}
return nil
}
func (c *Client) Stop(context.Context) error { return c.Close() }
func (c *Client) Drain(ctx context.Context) error {
if ctx == nil {
return errors.New("ws: nil context")
}
select {
case <-c.done:
return nil
case <-ctx.Done():
c.mu.Lock()
sessions := make([]*Session, 0, len(c.sessions))
for _, s := range c.sessions {
sessions = append(sessions, s)
}
c.mu.Unlock()
for _, s := range sessions {
s.Abort()
}
return ctx.Err()
}
}
func (c *Client) Shutdown(ctx context.Context) error { _ = c.Close(); return c.Drain(ctx) }
type ClientStats struct {
ActiveConnections int
Handshakes int
Running int
Closed bool
// DialAttempts includes admitted attempts canceled before the HTTP handshake.
DialAttempts uint64
HandshakeFailures uint64
// Reconnects counts subsequent Dial attempts made by Run, successful or not.
Reconnects uint64
}
func (c *Client) Stats() ClientStats {
c.mu.Lock()
defer c.mu.Unlock()
return ClientStats{ActiveConnections: len(c.sessions), Handshakes: c.connections - len(c.sessions), Running: c.active - c.connections, Closed: c.closed, DialAttempts: c.dialAttempts, HandshakeFailures: c.handshakeFailures, Reconnects: c.reconnects}
}

494
ws/client_test.go Normal file
View File

@@ -0,0 +1,494 @@
package ws
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gorilla/websocket"
)
func clientTestConfig() ClientConfig {
return ClientConfig{ProxyMode: "direct", Session: SessionConfig{CloseTimeout: 100 * time.Millisecond}}
}
func clientForTest(t *testing.T, cfg ClientConfig) *Client {
t.Helper()
c, err := NewClient(cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := c.Shutdown(ctx); err != nil {
t.Errorf("client shutdown: %v", err)
}
})
return c
}
func echoWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for {
kind, data, err := conn.ReadMessage()
if err != nil {
return
}
if err = conn.WriteMessage(kind, data); err != nil {
return
}
}
}
func wsURL(s *httptest.Server) string { return "ws" + strings.TrimPrefix(s.URL, "http") }
func receiveClientMessage(t *testing.T, ch <-chan Message) Message {
t.Helper()
select {
case m := <-ch:
return m
case <-time.After(2 * time.Second):
t.Fatal("message timeout")
return Message{}
}
}
func TestClientHandshakeDeadlineDoesNotExpireSession(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(echoWebSocket))
defer server.Close()
cfg := clientTestConfig()
cfg.HandshakeTimeout = 40 * time.Millisecond
c := clientForTest(t, cfg)
messages := make(chan Message, 2)
s, err := c.Dial(context.Background(), wsURL(server), Handlers{OnMessage: func(_ context.Context, _ *Session, m Message) error { messages <- m; return nil }})
if err != nil {
t.Fatal(err)
}
timer := time.NewTimer(100 * time.Millisecond)
<-timer.C
for _, kind := range []MessageType{Text, Binary} {
if err = s.Send(context.Background(), Message{Type: kind, Data: []byte("alive")}); err != nil {
t.Fatal(err)
}
got := receiveClientMessage(t, messages)
if got.Type != kind || string(got.Data) != "alive" {
t.Fatalf("unexpected echo: %#v", got)
}
}
_ = s.Close(1000, "")
}
func TestClientTLSAndMutualTLS(t *testing.T) {
for _, mutual := range []bool{false, true} {
t.Run(fmt.Sprint(mutual), func(t *testing.T) {
server := httptest.NewUnstartedServer(http.HandlerFunc(echoWebSocket))
if mutual {
server.TLS = &tls.Config{ClientAuth: tls.RequireAnyClientCert}
}
server.StartTLS()
defer server.Close()
roots := x509.NewCertPool()
roots.AddCert(server.Certificate())
cfg := clientTestConfig()
cfg.TLSConfig = &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12}
if mutual {
cfg.TLSConfig.Certificates = append([]tls.Certificate(nil), server.TLS.Certificates...)
}
c := clientForTest(t, cfg)
cfg.TLSConfig.ServerName = "wrong-after-construction.invalid"
messages := make(chan Message, 1)
s, err := c.Dial(context.Background(), wsURL(server), Handlers{OnMessage: func(_ context.Context, _ *Session, m Message) error { messages <- m; return nil }})
if err != nil {
t.Fatal(err)
}
if err = s.Send(context.Background(), Message{Type: Text, Data: []byte("tls")}); err != nil {
t.Fatal(err)
}
if string(receiveClientMessage(t, messages).Data) != "tls" {
t.Fatal("bad echo")
}
_ = s.Close(1000, "")
})
}
}
func TestClientHeadersRefreshReconnectAndFreshSessions(t *testing.T) {
var accepted atomic.Int32
firstOpen := make(chan struct{})
var firstOpenOnce sync.Once
headerValues := make(chan string, 4)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
headerValues <- r.Header.Get("Authorization")
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
if accepted.Add(1) == 1 {
select {
case <-firstOpen:
case <-r.Context().Done():
return
}
_ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(1012, "restart"), time.Now().Add(time.Second))
return
}
for {
if _, _, err = conn.ReadMessage(); err != nil {
return
}
}
}))
defer server.Close()
var providerCalls atomic.Int32
var prepareCalls atomic.Int32
ids := make(chan string, 4)
cfg := clientTestConfig()
cfg.Reconnect = ReconnectConfig{Enabled: true, MinBackoff: time.Millisecond, MaxBackoff: 5 * time.Millisecond, StableAfter: time.Second, MaxAttempts: 3}
cfg.HeaderProvider = func(context.Context) (http.Header, error) {
n := providerCalls.Add(1)
return http.Header{"Authorization": {fmt.Sprint(n)}}, nil
}
cfg.Prepare = func(ctx context.Context) (Handlers, error) {
prepareCalls.Add(1)
return Handlers{OnOpen: func(sessionCtx context.Context, s *Session) error {
if ctx.Err() != nil || sessionCtx.Err() != nil {
return errors.New("prepare context canceled early")
}
ids <- s.ID()
firstOpenOnce.Do(func() { close(firstOpen) })
return nil
}}, nil
}
c := clientForTest(t, cfg)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
finished := make(chan error, 1)
go func() { finished <- c.Run(ctx, wsURL(server), Handlers{}) }()
var first, second string
select {
case first = <-ids:
case <-time.After(2 * time.Second):
t.Fatal("first open timeout")
}
select {
case second = <-ids:
case <-time.After(2 * time.Second):
t.Fatal("reopen timeout")
}
if first == second {
t.Fatal("reused physical session ID")
}
if <-headerValues != "1" || <-headerValues != "2" {
t.Fatal("headers not refreshed")
}
if prepareCalls.Load() != 2 {
t.Fatal("prepare not recreated")
}
cancel()
select {
case err := <-finished:
if !errors.Is(err, context.Canceled) {
t.Fatalf("run: %v", err)
}
case <-time.After(time.Second):
t.Fatal("run cancel timeout")
}
}
func TestClientDoesNotReconnectAuthenticationOrProtocolFailure(t *testing.T) {
for _, status := range []int{400, 401, 403, 404} {
t.Run(fmt.Sprint(status), func(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(status)
_, _ = w.Write([]byte("secret response"))
}))
defer server.Close()
cfg := clientTestConfig()
cfg.Reconnect.Enabled = true
c := clientForTest(t, cfg)
err := c.Run(context.Background(), wsURL(server)+"/?token=TOPSECRET", Handlers{})
var handshake *HandshakeError
if !errors.As(err, &handshake) || handshake.StatusCode != status {
t.Fatalf("error: %v", err)
}
if calls.Load() != 1 || strings.Contains(err.Error(), "SECRET") || strings.Contains(err.Error(), "secret") {
t.Fatalf("retry/leak: calls=%d error=%v", calls.Load(), err)
}
})
}
}
func TestClientCloseCancelsHandshakeAndBackoff(t *testing.T) {
t.Run("handshake", func(t *testing.T) {
entered := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(entered); <-r.Context().Done() }))
defer server.Close()
c := clientForTest(t, clientTestConfig())
finished := make(chan error, 1)
go func() { _, err := c.Dial(context.Background(), wsURL(server), Handlers{}); finished <- err }()
<-entered
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := c.Shutdown(ctx); err != nil {
t.Fatal(err)
}
if err := <-finished; !errors.Is(err, ErrClosed) {
t.Fatalf("dial: %v", err)
}
})
t.Run("backoff", func(t *testing.T) {
entered := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { close(entered); w.WriteHeader(503) }))
defer server.Close()
cfg := clientTestConfig()
cfg.Reconnect = ReconnectConfig{Enabled: true, MinBackoff: time.Hour, MaxBackoff: time.Hour}
c := clientForTest(t, cfg)
finished := make(chan error, 1)
go func() { finished <- c.Run(context.Background(), wsURL(server), Handlers{}) }()
<-entered
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := c.Shutdown(ctx); err != nil {
t.Fatal(err)
}
if err := <-finished; err != nil {
t.Fatal(err)
}
})
}
func TestClientConcurrentDialLimitAndClose(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(echoWebSocket))
defer server.Close()
cfg := clientTestConfig()
cfg.MaxConnections = 2
c := clientForTest(t, cfg)
var wg sync.WaitGroup
var successes atomic.Int32
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, err := c.Dial(context.Background(), wsURL(server), Handlers{})
if err == nil {
successes.Add(1)
} else if !errors.Is(err, ErrConnectionLimit) {
t.Errorf("dial: %v", err)
}
}()
}
wg.Wait()
if successes.Load() != 2 {
t.Fatalf("connections=%d", successes.Load())
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := c.Shutdown(ctx); err != nil {
t.Fatal(err)
}
if _, err := c.Dial(ctx, wsURL(server), Handlers{}); !errors.Is(err, ErrClosed) {
t.Fatalf("closed dial: %v", err)
}
}
func TestClientRetryBudgetAndValidation(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1); w.WriteHeader(503) }))
defer server.Close()
cfg := clientTestConfig()
cfg.Reconnect = ReconnectConfig{Enabled: true, MinBackoff: time.Millisecond, MaxBackoff: 2 * time.Millisecond, MaxAttempts: 2}
c := clientForTest(t, cfg)
if err := c.Run(context.Background(), wsURL(server), Handlers{}); err == nil {
t.Fatal("wanted failure")
}
if calls.Load() != 3 {
t.Fatalf("retry budget: %d", calls.Load())
}
for _, endpoint := range []string{"https://example.test", "ws://user:password@example.test/", "ws://", "ws://example.test/#fragment"} {
if _, err := c.Dial(context.Background(), endpoint, Handlers{}); err == nil || strings.Contains(err.Error(), "password") {
t.Fatalf("invalid endpoint: %v", err)
}
}
for _, cfg := range []ClientConfig{{HandshakeTimeout: -1}, {ProxyMode: "bad"}, {ProxyMode: "direct", ProxyURL: "http://proxy"}, {Headers: http.Header{"Authorization": {"bad\r\nheader"}}}, {Headers: http.Header{"Sec-WebSocket-Key": {"fake"}}}, {RequireSubprotocol: true}, {Subprotocols: []string{"bad protocol"}}} {
if _, err := NewClient(cfg); err == nil {
t.Fatal("accepted invalid configuration")
}
}
for i := 0; i < 100; i++ {
d := retryBackoff(ReconnectConfig{MinBackoff: time.Second, MaxBackoff: 30 * time.Second}, i)
if d <= 0 || d > 30*time.Second {
t.Fatalf("unbounded delay %v", d)
}
}
}
func TestClientElapsedBudgetInterruptsStalledHandshake(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() }))
defer server.Close()
cfg := clientTestConfig()
cfg.Reconnect = ReconnectConfig{Enabled: true, MaxElapsed: 50 * time.Millisecond}
c := clientForTest(t, cfg)
started := time.Now()
err := c.Run(context.Background(), wsURL(server), Handlers{})
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("budget error: %v", err)
}
if time.Since(started) > time.Second {
t.Fatal("handshake exceeded reconnect budget")
}
}
func TestClientSingleSlotRapidReconnect(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
code := 1012
if calls.Add(1) >= 20 {
code = 1000
}
_ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(code, ""), time.Now().Add(time.Second))
_, _, _ = conn.ReadMessage()
}))
defer server.Close()
cfg := clientTestConfig()
cfg.MaxConnections = 1
cfg.Reconnect = ReconnectConfig{Enabled: true, MinBackoff: time.Nanosecond, MaxBackoff: time.Nanosecond, MaxAttempts: 25}
c := clientForTest(t, cfg)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := c.Run(ctx, wsURL(server), Handlers{}); err != nil {
t.Fatalf("rapid reconnect: %v", err)
}
if calls.Load() != 20 {
t.Fatalf("connections: %d", calls.Load())
}
}
func TestClientSubprotocol(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := (&websocket.Upgrader{Subprotocols: []string{"omni.v1"}}).Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for {
if _, _, err = conn.ReadMessage(); err != nil {
return
}
}
}))
defer server.Close()
cfg := clientTestConfig()
cfg.Subprotocols = []string{"omni.v1"}
cfg.RequireSubprotocol = true
c := clientForTest(t, cfg)
s, err := c.Dial(context.Background(), wsURL(server), Handlers{})
if err != nil {
t.Fatal(err)
}
if s.Subprotocol() != "omni.v1" {
t.Fatal("wrong protocol")
}
_ = s.Close(1000, "")
cfg.Subprotocols = []string{"missing"}
other := clientForTest(t, cfg)
if _, err = other.Dial(context.Background(), wsURL(server), Handlers{}); err == nil {
t.Fatal("required protocol not enforced")
}
}
func TestClientHTTPProxyAuthenticationAndTunnel(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Proxy-Authorization") != "" {
t.Error("proxy credential leaked to target")
}
echoWebSocket(w, r)
}))
defer server.Close()
var connected atomic.Int32
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodConnect || r.Header.Get("Proxy-Authorization") != "Basic dXNlcjpwYXNz" {
w.WriteHeader(http.StatusProxyAuthRequired)
return
}
upstream, err := net.Dial("tcp", r.Host)
if err != nil {
t.Error(err)
w.WriteHeader(502)
return
}
conn, rw, err := w.(http.Hijacker).Hijack()
if err != nil {
upstream.Close()
t.Error(err)
return
}
connected.Add(1)
_, _ = conn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
go func() { defer conn.Close(); defer upstream.Close(); _, _ = io.Copy(upstream, rw) }()
go func() { defer conn.Close(); defer upstream.Close(); _, _ = io.Copy(conn, upstream) }()
}))
defer proxy.Close()
cfg := clientTestConfig()
cfg.ProxyMode = "url"
cfg.ProxyURL = strings.Replace(proxy.URL, "http://", "http://user:pass@", 1)
c := clientForTest(t, cfg)
messages := make(chan Message, 1)
s, err := c.Dial(context.Background(), wsURL(server), Handlers{OnMessage: func(_ context.Context, _ *Session, m Message) error { messages <- m; return nil }})
if err != nil {
t.Fatal(err)
}
if err = s.Send(context.Background(), Message{Type: Text, Data: []byte("proxied")}); err != nil {
t.Fatal(err)
}
if string(receiveClientMessage(t, messages).Data) != "proxied" || connected.Load() != 1 {
t.Fatal("proxy not used")
}
_ = s.Close(1000, "")
}
func BenchmarkClientRoundTrip(b *testing.B) {
server := httptest.NewServer(http.HandlerFunc(echoWebSocket))
defer server.Close()
c, err := NewClient(clientTestConfig())
if err != nil {
b.Fatal(err)
}
defer c.Shutdown(context.Background())
received := make(chan struct{}, 1)
s, err := c.Dial(context.Background(), wsURL(server), Handlers{OnMessage: func(context.Context, *Session, Message) error { received <- struct{}{}; return nil }})
if err != nil {
b.Fatal(err)
}
message := Message{Type: Text, Data: []byte("benchmark")}
b.ReportAllocs()
b.SetBytes(int64(len(message.Data)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := s.Send(context.Background(), message); err != nil {
b.Fatal(err)
}
<-received
}
b.StopTimer()
_ = s.Close(1000, "")
}

83
ws/config.go Normal file
View File

@@ -0,0 +1,83 @@
package ws
import (
"fmt"
"time"
)
// SessionConfig bounds connection memory and I/O. Zero fields use defaults;
// DisableHeartbeat explicitly disables automatic ping/pong deadlines.
type SessionConfig struct {
MaxMessageBytes int64 `json:"maxMessageBytes"`
ReadQueueSize int `json:"readQueueSize"`
ReadQueueBytes int64 `json:"readQueueBytes"`
WriteQueueSize int `json:"writeQueueSize"`
WriteQueueBytes int64 `json:"writeQueueBytes"`
WriteTimeout time.Duration `json:"writeTimeout"`
PingInterval time.Duration `json:"pingInterval"`
PongTimeout time.Duration `json:"pongTimeout"`
CloseTimeout time.Duration `json:"closeTimeout"`
DisableHeartbeat bool `json:"disableHeartbeat"`
}
func DefaultSessionConfig() SessionConfig {
return SessionConfig{MaxMessageBytes: 1 << 20, ReadQueueSize: 64, ReadQueueBytes: 4 << 20,
WriteQueueSize: 64, WriteQueueBytes: 4 << 20, WriteTimeout: 10 * time.Second,
PingInterval: 30 * time.Second, PongTimeout: 60 * time.Second, CloseTimeout: 5 * time.Second}
}
func (c SessionConfig) defaults() SessionConfig {
d := DefaultSessionConfig()
if c.MaxMessageBytes == 0 {
c.MaxMessageBytes = d.MaxMessageBytes
}
if c.ReadQueueSize == 0 {
c.ReadQueueSize = d.ReadQueueSize
}
if c.ReadQueueBytes == 0 {
c.ReadQueueBytes = d.ReadQueueBytes
}
if c.WriteQueueSize == 0 {
c.WriteQueueSize = d.WriteQueueSize
}
if c.WriteQueueBytes == 0 {
c.WriteQueueBytes = d.WriteQueueBytes
}
if c.WriteTimeout == 0 {
c.WriteTimeout = d.WriteTimeout
}
if c.PingInterval == 0 {
c.PingInterval = d.PingInterval
}
if c.PongTimeout == 0 {
c.PongTimeout = d.PongTimeout
}
if c.CloseTimeout == 0 {
c.CloseTimeout = d.CloseTimeout
}
return c
}
func (c SessionConfig) Validate() error {
if c.MaxMessageBytes < 0 || c.MaxMessageBytes > 1<<30 || c.ReadQueueSize < 0 || c.ReadQueueSize > 65536 || c.ReadQueueBytes < 0 || c.ReadQueueBytes > 1<<30 || c.WriteQueueSize < 0 || c.WriteQueueSize > 65536 || c.WriteQueueBytes < 0 || c.WriteQueueBytes > 1<<30 {
return fmt.Errorf("ws: message and queue limits must be nonnegative and bounded")
}
if c.WriteTimeout < 0 || c.PingInterval < 0 || c.PongTimeout < 0 || c.CloseTimeout < 0 {
return fmt.Errorf("ws: timeouts must be nonnegative")
}
if c.WriteTimeout > 24*time.Hour || c.PingInterval > 24*time.Hour || c.PongTimeout > 24*time.Hour || c.CloseTimeout > 24*time.Hour {
return fmt.Errorf("ws: timeout exceeds 24 hours")
}
c = c.defaults()
if !c.DisableHeartbeat && c.PongTimeout <= c.PingInterval {
return fmt.Errorf("ws: pongTimeout must exceed pingInterval")
}
return nil
}
func normalizeSessionConfig(c SessionConfig) (SessionConfig, error) {
if err := c.Validate(); err != nil {
return SessionConfig{}, err
}
return c.defaults(), nil
}

48
ws/errors.go Normal file
View File

@@ -0,0 +1,48 @@
package ws
import (
"errors"
"fmt"
)
var (
ErrClosed = errors.New("ws: session closed")
ErrNotConnected = errors.New("ws: not connected")
ErrQueueFull = errors.New("ws: queue full")
ErrMessageTooLarge = errors.New("ws: message too large")
ErrUnsupportedTransport = errors.New("ws: transport does not support websocket upgrade")
ErrServerClosed = errors.New("ws: server closed")
ErrInvalidMessage = errors.New("ws: invalid message")
)
// CloseError deliberately excludes the peer-provided reason, which is untrusted
// and may contain credentials. Cause remains available through errors.Unwrap.
type CloseError struct {
Code int
Cause error
}
func (e *CloseError) Error() string { return fmt.Sprintf("ws: connection closed (code %d)", e.Code) }
func (e *CloseError) Unwrap() error { return e.Cause }
// HandshakeError exposes the HTTP status without putting a URL or response body
// in the error text. Inspect Cause explicitly only when it is safe to do so.
type HandshakeError struct {
StatusCode int
Cause error
}
func (e *HandshakeError) Error() string {
return fmt.Sprintf("ws: handshake failed (HTTP %d)", e.StatusCode)
}
func (e *HandshakeError) Unwrap() error { return e.Cause }
// OpError preserves the underlying cause without including its potentially
// sensitive text in logs. Op is a fixed operation name, never a URL or payload.
type OpError struct {
Op string
Cause error
}
func (e *OpError) Error() string { return "ws: " + e.Op + " failed" }
func (e *OpError) Unwrap() error { return e.Cause }

319
ws/hub/hub.go Normal file
View File

@@ -0,0 +1,319 @@
// Package hub provides a bounded, process-local WebSocket registry. Connection
// and group IDs are opaque application keys; callers authorize membership.
// It does not persist messages or provide cross-process delivery.
package hub
import (
"errors"
"reflect"
"strings"
"sync"
"git.yuntuo.hk/k2safe/go-omni/ws"
)
var (
ErrClosed = errors.New("ws/hub: closed")
ErrNotFound = errors.New("ws/hub: connection not found")
ErrDuplicate = errors.New("ws/hub: duplicate connection ID")
ErrLimit = errors.New("ws/hub: resource limit reached")
ErrInvalid = errors.New("ws/hub: invalid connection or group")
)
// Connection is implemented by *ws.Session. Implementations must be safe for
// concurrent use, have a stable ID, and make TrySend nonblocking with bounded
// memory. Done closes only after connection work finishes.
type Connection interface {
ID() string
Done() <-chan struct{}
TrySend(ws.Message) error
Close(int, string) error
}
type Config struct {
MaxConnections int `json:"maxConnections"`
MaxGroups int `json:"maxGroups"`
MaxMemberships int `json:"maxMemberships"`
MaxGroupsPerConnection int `json:"maxGroupsPerConnection"`
}
func (cfg Config) Validate() error {
if cfg.MaxConnections < 0 || cfg.MaxConnections > 1000000 || cfg.MaxGroups < 0 || cfg.MaxGroups > 1000000 || cfg.MaxMemberships < 0 || cfg.MaxMemberships > 10000000 || cfg.MaxGroupsPerConnection < 0 || cfg.MaxGroupsPerConnection > 10000 {
return errors.New("ws/hub: invalid limits")
}
return nil
}
type entry struct {
connection Connection
groups map[string]struct{}
removed chan struct{}
}
type Hub struct {
mu sync.RWMutex
config Config
connections map[string]*entry
groups map[string]map[string]*entry
memberships int
closed bool
done chan struct{}
watchers sync.WaitGroup
}
func New(cfg Config) (*Hub, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
if cfg.MaxConnections == 0 {
cfg.MaxConnections = 10000
}
if cfg.MaxGroups == 0 {
cfg.MaxGroups = 1024
}
if cfg.MaxMemberships == 0 {
cfg.MaxMemberships = 100000
}
if cfg.MaxGroupsPerConnection == 0 {
cfg.MaxGroupsPerConnection = 32
}
return &Hub{config: cfg, connections: make(map[string]*entry), groups: make(map[string]map[string]*entry), done: make(chan struct{})}, nil
}
func validKey(key string) bool {
return key != "" && len(key) <= 256 && !strings.ContainsAny(key, "\r\n\x00")
}
// Add borrows a connection and starts one bounded watcher to remove it when
// Done closes. Remove and Close stop those watchers without closing connections.
func (h *Hub) Add(connection Connection) error {
if connection == nil {
return ErrInvalid
}
v := reflect.ValueOf(connection)
if (v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface || v.Kind() == reflect.Map || v.Kind() == reflect.Func || v.Kind() == reflect.Slice || v.Kind() == reflect.Chan) && v.IsNil() {
return ErrInvalid
}
id := connection.ID()
done := connection.Done()
if !validKey(id) || done == nil {
return ErrInvalid
}
select {
case <-done:
return ws.ErrClosed
default:
}
h.mu.Lock()
if h.closed {
h.mu.Unlock()
return ErrClosed
}
if _, ok := h.connections[id]; ok {
h.mu.Unlock()
return ErrDuplicate
}
if len(h.connections) >= h.config.MaxConnections {
h.mu.Unlock()
return ErrLimit
}
e := &entry{connection: connection, groups: make(map[string]struct{}), removed: make(chan struct{})}
h.connections[id] = e
h.watchers.Add(1)
h.mu.Unlock()
go func() {
defer h.watchers.Done()
select {
case <-done:
h.mu.Lock()
if h.connections[id] == e {
h.removeLocked(id, e)
}
h.mu.Unlock()
case <-e.removed:
case <-h.done:
}
}()
return nil
}
func (h *Hub) removeLocked(id string, e *entry) {
delete(h.connections, id)
close(e.removed)
for name := range e.groups {
delete(h.groups[name], id)
h.memberships--
if len(h.groups[name]) == 0 {
delete(h.groups, name)
}
}
}
func (h *Hub) Remove(id string) bool {
h.mu.Lock()
defer h.mu.Unlock()
e := h.connections[id]
if e == nil {
return false
}
h.removeLocked(id, e)
return true
}
func (h *Hub) Join(id, group string) error {
if !validKey(group) {
return ErrInvalid
}
h.mu.Lock()
defer h.mu.Unlock()
if h.closed {
return ErrClosed
}
e := h.connections[id]
if e == nil {
return ErrNotFound
}
if _, ok := e.groups[group]; ok {
return nil
}
if len(e.groups) >= h.config.MaxGroupsPerConnection || h.memberships >= h.config.MaxMemberships {
return ErrLimit
}
if h.groups[group] == nil {
if len(h.groups) >= h.config.MaxGroups {
return ErrLimit
}
h.groups[group] = make(map[string]*entry)
}
h.groups[group][id] = e
e.groups[group] = struct{}{}
h.memberships++
return nil
}
func (h *Hub) Leave(id, group string) bool {
h.mu.Lock()
defer h.mu.Unlock()
e := h.connections[id]
if e == nil {
return false
}
if _, ok := e.groups[group]; !ok {
return false
}
delete(e.groups, group)
delete(h.groups[group], id)
h.memberships--
if len(h.groups[group]) == 0 {
delete(h.groups, group)
}
return true
}
// Result reports queue acceptance, never remote receipt or business success.
// Errors contains one entry per rejected destination; no messages are silently
// dropped. Concurrent removal may race a snapshot and yield a closed error.
type Result struct {
Attempted int
Queued int
Errors map[string]error
}
func send(entries map[string]*entry, message ws.Message) Result {
result := Result{Attempted: len(entries)}
for id, e := range entries {
if err := e.connection.TrySend(message); err != nil {
if result.Errors == nil {
result.Errors = make(map[string]error)
}
result.Errors[id] = err
} else {
result.Queued++
}
}
return result
}
func (h *Hub) Send(id string, message ws.Message) (Result, error) {
h.mu.RLock()
if h.closed {
h.mu.RUnlock()
return Result{}, ErrClosed
}
e := h.connections[id]
h.mu.RUnlock()
if e == nil {
return Result{}, ErrNotFound
}
return send(map[string]*entry{id: e}, message), nil
}
func (h *Hub) snapshot(group string) (map[string]*entry, error) {
h.mu.RLock()
defer h.mu.RUnlock()
if h.closed {
return nil, ErrClosed
}
source := h.connections
if group != "" {
source = h.groups[group]
}
out := make(map[string]*entry, len(source))
for id, e := range source {
out[id] = e
}
return out, nil
}
func (h *Hub) Broadcast(message ws.Message) (Result, error) {
entries, err := h.snapshot("")
if err != nil {
return Result{}, err
}
return send(entries, message), nil
}
func (h *Hub) SendGroup(group string, message ws.Message) (Result, error) {
if !validKey(group) {
return Result{}, ErrInvalid
}
entries, err := h.snapshot(group)
if err != nil {
return Result{}, err
}
return send(entries, message), nil
}
func (h *Hub) Kick(id string, code int, reason string) error {
h.mu.RLock()
if h.closed {
h.mu.RUnlock()
return ErrClosed
}
e := h.connections[id]
h.mu.RUnlock()
if e == nil {
return ErrNotFound
}
return e.connection.Close(code, reason)
}
type Stats struct {
Connections int
Groups int
Memberships int
}
func (h *Hub) Stats() Stats {
h.mu.RLock()
defer h.mu.RUnlock()
return Stats{len(h.connections), len(h.groups), h.memberships}
}
// Close unregisters borrowed connections, releases membership memory and waits
// for internal watchers only. Use Session/Server shutdown to close connections.
func (h *Hub) Close() error {
h.mu.Lock()
if !h.closed {
h.closed = true
close(h.done)
for id, e := range h.connections {
h.removeLocked(id, e)
}
h.groups = make(map[string]map[string]*entry)
}
h.mu.Unlock()
h.watchers.Wait()
return nil
}

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