Compare commits
12 Commits
v0.28.1-pr
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b03b23c54d | |||
| 3272974bc1 | |||
| 19ee9746e9 | |||
| 2c45dc1e89 | |||
| 021d53304a | |||
| f9ca6631ca | |||
| 9665fb5974 | |||
| d65af03162 | |||
| f0c62152d7 | |||
| 01a03149f9 | |||
| 0c5b571b31 | |||
| f2c5957c15 |
16
ADVANCED.md
16
ADVANCED.md
@@ -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 自定义域名和版本管理仍由平台/部署管理。
|
||||
|
||||
需要 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 等价。
|
||||
|
||||
验证区分: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)` 包裹。
|
||||
|
||||
上游网关如果使用自定义 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。
|
||||
|
||||
## 分布式限流与幂等
|
||||
|
||||
4
API.md
4
API.md
@@ -109,7 +109,7 @@ service user-api {
|
||||
- 来源标签中的 `optional` / `omitempty`、标量 `default`、标量 `options=a|b`、数值 `range=[1:100]`(支持开区间和单边界)。
|
||||
- `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 与业务项目中的框架依赖需一起升级。
|
||||
|
||||
@@ -331,3 +331,5 @@ python3 scripts/verifyManagedProject.py
|
||||
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#配置内置接口)。
|
||||
|
||||
响应 DTO 自引用与相互引用从 v0.28.2 起支持,保留强类型并生成 OpenAPI `$ref`;见[递归响应说明](DEVELOPMENT.md#递归响应-dto)。直接值循环与字段组合循环仍然非法。
|
||||
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -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
|
||||
|
||||
## v0.27.0-preview(2026-09-29)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 通用业务开发指南(v0.27.0-preview)
|
||||
# 通用业务开发指南(v0.31.0-preview)
|
||||
|
||||
从 [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 数组。
|
||||
|
||||
## 递归响应 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)。
|
||||
|
||||
## 契约驱动的请求策略
|
||||
|
||||
入口文件可以声明默认策略,单个接口按需覆盖:
|
||||
@@ -201,7 +225,7 @@ go-omni workspace check --file omni.workspace.json
|
||||
|
||||
### 混合 API 与通用工程
|
||||
|
||||
v0.28.1-preview 起,workspace 支持在同一份清单中管理 API、通用工程,也支持仅包含通用工程的工作区:
|
||||
v0.29.0-preview 起,workspace 支持在同一份清单中管理 API、通用工程,也支持仅包含通用工程的工作区:
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
## 创建与运行
|
||||
|
||||
```sh
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.1-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
|
||||
cd demo
|
||||
go mod tidy
|
||||
@@ -78,4 +78,4 @@ go test ./...
|
||||
|
||||
check 检查模板漂移和必要文件,不检查业务正确性,也不能替代 Go 编译与测试。新的模板版本会重新生成受管文件;不自动合并项目所有文件或更新 go.mod。未知的新清单版本会要求使用兼容 CLI。
|
||||
|
||||
原有 `new` / `api new` 及 API 工程行为保留。通用工程使用自己的清单和命令,不能交给 `api generate` 或 `add` 转换;新增业务包直接写普通 Go。从 v0.28.1-preview 起,`workspace generate/check` 可以同时处理 API 与通用工程。清单可填写 `kind: api | project`,省略时按生成清单识别;format 对通用工程显示 SKIP。见 [完整混合示例](examples/mixedWorkspace/README.md)。各 module 仍独立,workspace 命令不维护 go.work 或模块依赖。
|
||||
原有 `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 或模块依赖。
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# 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 契约工程。
|
||||
|
||||
v0.28.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
|
||||
|
||||
25
README.md
25
README.md
@@ -2,15 +2,23 @@
|
||||
|
||||
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.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.1-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.1-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)。
|
||||
|
||||
@@ -63,11 +71,12 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
|
||||
| 认证与权限接口 | 注入 Authenticator / Policy;JWT 或 Session 只是可选实现 | [认证说明](auth/README.md)、[鉴权完整例子](examples/platform/README.md) |
|
||||
| 缓存 | `cache.NewMemory` 或 Redis 实现;业务面向 Store 的 Get/Set/Delete | [缓存配置/代码](MODULES.md)、[可运行模块服务](examples/modules/README.md) |
|
||||
| 出站 HTTP、熔断 | `httpc.New` 配置代理、连接池和阶段超时;JSON 支持单次成功状态限制/请求头/限额;`resilience.New` 隔离故障 | [HTTP 完整用法](httpc/README.md)、[可运行示例](examples/httpClient/README.md)、[熔断/观测](ADVANCED.md#熔断与观测) |
|
||||
| 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#分布式限流与幂等) |
|
||||
| 本地后台任务与调度 | `task.New`、Submit、Every;有界队列、排空关闭 | [task 示例与生命周期](MODULES.md#后台执行task) |
|
||||
| 持久队列 | 业务使用 Publisher / Consumer;Redis Streams 提供至少一次交付 | [队列初始化/消费示例](ADVANCED.md#可靠队列) |
|
||||
| 文件存储、直传、云端分片 | Store 的 Put/Get/Delete;Local、S3/R2、OSS 按配置装配;扩展接口提供分片续传 | [存储接口与完整例子](storage/README.md)、[本地存储服务](examples/platform/README.md) |
|
||||
| 日志关联、指标与链路 | `metrics.New().Middleware()`;telemetry 注入 OTel exporter/reader | [基础指标](MODULES.md#请求追踪与基础指标)、[OTel 用法](ADVANCED.md#熔断与观测)、[完整接入](examples/platform/main.go) |
|
||||
| 文件存储、签名预览/下载、直传与云端分片 | Store 的 Put/Get/Delete;ReadPresigner 签名响应选项;Local、S3/R2、OSS 按配置装配 | [存储接口与完整例子](storage/README.md)、[本地存储服务](examples/platform/README.md) |
|
||||
| 日志关联、指标与链路 | `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#迁移审计与分页) |
|
||||
| 可选结构转换与深拷贝 | `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) |
|
||||
@@ -75,7 +84,7 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
|
||||
## 通用工程快速开始
|
||||
|
||||
```sh
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.1-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
|
||||
cd demo
|
||||
go mod tidy
|
||||
@@ -180,7 +189,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可
|
||||
|
||||
```sh
|
||||
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.1-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.31.0-preview
|
||||
export PATH="$(go env GOPATH)/bin:$PATH"
|
||||
go-omni --help
|
||||
```
|
||||
@@ -337,7 +346,7 @@ go-omni new --api catalog.api \
|
||||
在业务项目的 **go.mod 所在目录**执行:
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.28.1-preview
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.31.0-preview
|
||||
```
|
||||
|
||||
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
|
||||
@@ -668,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#契约变更提示)。
|
||||
|
||||
CLI 与业务 module 固定使用 `v0.28.1-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)。
|
||||
|
||||
## 按需深入
|
||||
|
||||
|
||||
84
RELEASE.md
84
RELEASE.md
@@ -1,3 +1,85 @@
|
||||
# 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 源码兼容,依赖安全升级需单独完成并回归,详情和证据见上述验收报告。
|
||||
|
||||
远程空缓存验收通过:WS 的 6 个包、60 个顶层 race 测试和 11 项发布检查;原有 API 工程 110 项、通用工程 30 项、混合工作区 16 项检查。四组均使用固定远程版本,无本地 replace,模块校验和一致。标签固定在 `3272974bc1cd19c79efe7354b00b6ed67f27f51e`;见 [WS](reports/websocket-remote.json)、[API](reports/websocket-managed-remote.json)、[通用工程](reports/websocket-program-remote.json)、[工作区](reports/websocket-workspace-remote.json) 报告。
|
||||
|
||||
# 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 入口的原有用法。类型与清单冲突、歧义和不合法选项会报错,项目身份、路径与文件归属继续由各生成器校验。
|
||||
@@ -12,6 +94,8 @@ 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
|
||||
|
||||
37
app/app.go
37
app/app.go
@@ -31,6 +31,7 @@ type App struct {
|
||||
closers []func() error
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
components *lifecycle.Runtime
|
||||
clientIPResolver *httpx.ClientIPResolver
|
||||
responseEncoder response.Encoder
|
||||
requests requestDrain
|
||||
@@ -170,7 +171,8 @@ func (a *App) Use(middleware ...httpx.Middleware) *App {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// This keeps omni.New as the only public startup entry point while allowing
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
// to return before releasing resources. It is idempotent. If cancellation grace
|
||||
// expires, ErrResourcesRetained is permanent and no cleanup callbacks run.
|
||||
@@ -192,7 +221,11 @@ func (a *App) Close() error {
|
||||
if timeout <= 0 {
|
||||
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
|
||||
return
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ API 新项目采用根目录 `main.go` 与 `internal/config、svc、logic、hand
|
||||
|
||||
```sh
|
||||
# 仓库读取权限与 PATH 配置见根目录 README
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.1-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.31.0-preview
|
||||
go-omni new --module example.com/hello ./hello
|
||||
|
||||
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
||||
@@ -33,6 +33,8 @@ go-omni new --api service.api --module example.com/example ./example
|
||||
|
||||
存储配置和客户端位于 config/svc,业务使用统一接口。local 存储签名端点由生成器接入;上传、下载等业务路由由 `.api` 定义。认证与存储路径由项目实现。
|
||||
|
||||
响应 DTO 支持自引用及相互引用,生成强类型 Go 字段与 OpenAPI `$ref`;见[递归响应示例](../../examples/recursiveResponse/README.md)。请求递归仍会报错。
|
||||
|
||||
## 日常生成
|
||||
|
||||
```sh
|
||||
@@ -55,14 +57,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
|
||||
|
||||
## 版本与迁移
|
||||
|
||||
CLI 与框架依赖固定使用 v0.28.1-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||
CLI 与框架依赖固定使用 v0.31.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||
|
||||
```sh
|
||||
# 在 go-omni 源码仓库中执行
|
||||
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
|
||||
```
|
||||
|
||||
当前模板与依赖以 v0.28.1-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` 命令的手动路由装配。
|
||||
|
||||
## 数据库迁移命令
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
goparser "go/parser"
|
||||
gotoken "go/token"
|
||||
"reflect"
|
||||
"regexp"
|
||||
@@ -69,56 +67,17 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved := map[string]reflect.Type{}
|
||||
resolving := map[string]bool{}
|
||||
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")
|
||||
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{}
|
||||
var resolve func(string, int) (reflect.Type, error)
|
||||
resolve = func(name string, depth int) (reflect.Type, error) {
|
||||
if depth > 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 {
|
||||
return t, nil
|
||||
}
|
||||
if resolving[name] {
|
||||
return nil, fmt.Errorf("recursive type %s is unsupported", name)
|
||||
}
|
||||
typ, ok := definitions[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown type %s", name)
|
||||
}
|
||||
resolving[name] = true
|
||||
defer delete(resolving, name)
|
||||
var fields []reflect.StructField
|
||||
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", 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)
|
||||
for i, f := range typ.Fields {
|
||||
t, err := graph.expression(graph.exprs[name][i], depth+1, resolve)
|
||||
if err != nil {
|
||||
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{}}
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -274,7 +225,7 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
}
|
||||
var response *openapi.Schema
|
||||
if r.Response != "" {
|
||||
s, err := responseDataSchema(r.Response, c.Bindings)
|
||||
s, err := responseDataSchema(r.Response, c.Bindings, graph)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -316,6 +267,13 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
return nil, err
|
||||
}
|
||||
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)
|
||||
c.Document, err = json.MarshalIndent(doc, "", " ")
|
||||
return c, err
|
||||
|
||||
185
cmd/go-omni/apigen/customRoutes_test.go
Normal file
185
cmd/go-omni/apigen/customRoutes_test.go
Normal 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")}
|
||||
}
|
||||
`
|
||||
@@ -23,7 +23,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
|
||||
// Runtime-only releases need not raise the managed template compatibility floor.
|
||||
const minimumVersion = "v0.22.0-preview"
|
||||
const currentVersion = "v0.28.1-preview"
|
||||
const currentVersion = "v0.31.0-preview"
|
||||
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
||||
|
||||
type Options struct {
|
||||
|
||||
299
cmd/go-omni/apigen/recursiveResponse_test.go
Normal file
299
cmd/go-omni/apigen/recursiveResponse_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
370
cmd/go-omni/apigen/responseGraph.go
Normal file
370
cmd/go-omni/apigen/responseGraph.go
Normal 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")
|
||||
}
|
||||
@@ -35,7 +35,7 @@ func responseGoType(value string) string {
|
||||
}
|
||||
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)
|
||||
if kind == "null" {
|
||||
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
|
||||
}
|
||||
b, ok := bindings[name]
|
||||
if !ok {
|
||||
var s *openapi.Schema
|
||||
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)
|
||||
}
|
||||
s, err := b.ResponseSchema()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -68,6 +72,9 @@ func responseDataSchema(value string, bindings map[string]*contract.Binder) (*op
|
||||
case "array":
|
||||
return &openapi.Schema{Type: "array", Items: s}, nil
|
||||
case "nullable":
|
||||
if s.Ref != "" {
|
||||
return nullableReference(s), nil
|
||||
}
|
||||
s.Nullable = true
|
||||
}
|
||||
return s, nil
|
||||
|
||||
@@ -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))}
|
||||
`
|
||||
}
|
||||
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" {
|
||||
source = strings.Replace(source, fmt.Sprintf("%q", frameworkModule+"/httpx"), "", 1)
|
||||
}
|
||||
|
||||
77
cmd/go-omni/apigen/testdata/recursive/consumer_test.go
vendored
Normal file
77
cmd/go-omni/apigen/testdata/recursive/consumer_test.go
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ import (
|
||||
)
|
||||
|
||||
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
const currentVersion = "v0.28.1-preview"
|
||||
const currentVersion = "v0.31.0-preview"
|
||||
const minimumVersion = "v0.28.0-preview"
|
||||
const manifestPath = ".goOmni/projectManifest.json"
|
||||
const lockPath = ".goOmni/project.lock"
|
||||
|
||||
@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
// Source builds can carry an unpublished VCS version (including +dirty).
|
||||
// Keep this fallback in sync with the next release; installed CLIs use their
|
||||
// downloaded module version, which also has a module checksum.
|
||||
const defaultFrameworkVersion = "v0.28.1-preview"
|
||||
const defaultFrameworkVersion = "v0.31.0-preview"
|
||||
|
||||
// This target is not a claim that the release already exists. Source users
|
||||
// select a local framework checkout until the module templates are published.
|
||||
|
||||
@@ -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,
|
||||
strict decoding and supported type limits. This API leaves the existing
|
||||
`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`.
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"generated": {
|
||||
"docs/openapi.json": "0370fd6bd6a387e2ef21f6b42b009ce66749846b8bea3d481009a34f3af039a5",
|
||||
"internal/config/loadGen.go": "d85e3123e4c96c95cb0886e60e4760230426efca4d2bbbfc981822da08b90d13",
|
||||
"internal/handler/appGen.go": "f5251b4dc33e2312b0bdb71ead70d2171977d851265e124f640458d705a2cc9d",
|
||||
"internal/handler/appGen.go": "66e54c8f60fc273b4dc856c9578390b15604d99ad2e581a1899cc4b7ebb70f3b",
|
||||
"internal/handler/demo/handlersGen.go": "3c76d85dbe89eb5127d9d134dc625203ecf9719dd11d174c1f335691b99e1375",
|
||||
"internal/handler/openapiGen.go": "f24cab3abbb8557a3fc4dd93fba23cc0b093f3e0c944d3ba1bd5b0eeff2d96f3",
|
||||
"internal/handler/responseGen.go": "84397a502bba4777ebfa6fae3d3bc418297acab4f26b04b42e3c31b36c735399",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"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...))
|
||||
}
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# API 与通用工程混合工作区
|
||||
|
||||
本例只定义生成工程的类型与目录,不包含业务模型、外部服务或特定调度方式。使用 v0.28.1-preview 或更新版本 CLI。
|
||||
本例只定义生成工程的类型与目录,不包含业务模型、外部服务或特定调度方式。使用 v0.31.0-preview 或更新版本 CLI。
|
||||
|
||||
下面从一个空目录创建两个独立 Go module;API 工程来自 API 模板,通用工程默认不监听端口。
|
||||
|
||||
```sh
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.1-preview
|
||||
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
|
||||
|
||||
32
examples/recursiveResponse/README.md
Normal file
32
examples/recursiveResponse/README.md
Normal 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
|
||||
```
|
||||
22
examples/recursiveResponse/operations.go.txt
Normal file
22
examples/recursiveResponse/operations.go.txt
Normal 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
|
||||
}
|
||||
33
examples/recursiveResponse/service.api
Normal file
33
examples/recursiveResponse/service.api
Normal 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)
|
||||
}
|
||||
15
examples/signedRead/README.md
Normal file
15
examples/signedRead/README.md
Normal 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
183
examples/signedRead/main.go
Normal 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()
|
||||
}
|
||||
9
examples/signedRead/main_test.go
Normal file
9
examples/signedRead/main_test.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSignedReadHTTP(t *testing.T) {
|
||||
if err := run(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
11
examples/telemetryHeaders/README.md
Normal file
11
examples/telemetryHeaders/README.md
Normal 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`。
|
||||
62
examples/telemetryHeaders/main.go
Normal file
62
examples/telemetryHeaders/main.go
Normal 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
|
||||
}
|
||||
9
examples/telemetryHeaders/main_test.go
Normal file
9
examples/telemetryHeaders/main_test.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestTelemetryHeadersExample(t *testing.T) {
|
||||
if err := run(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
17
examples/websocket/README.md
Normal file
17
examples/websocket/README.md
Normal 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)。
|
||||
16
examples/websocket/index.html
Normal file
16
examples/websocket/index.html
Normal 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
107
examples/websocket/main.go
Normal 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
|
||||
}
|
||||
16
examples/websocketRPC/README.md
Normal file
16
examples/websocketRPC/README.md
Normal 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)。
|
||||
200
examples/websocketRPC/main.go
Normal file
200
examples/websocketRPC/main.go
Normal 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
|
||||
}
|
||||
9
examples/websocketRPC/main_test.go
Normal file
9
examples/websocketRPC/main_test.go
Normal 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
1
go.mod
@@ -8,6 +8,7 @@ require (
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/golang-jwt/jwt/v5 v5.3.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/redis/go-redis/v9 v9.17.3
|
||||
go.opentelemetry.io/otel v1.31.0
|
||||
|
||||
2
go.sum
2
go.sum
@@ -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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
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.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
|
||||
@@ -275,6 +275,14 @@ func (c *Context) Status() int {
|
||||
// Responded reports whether this handler has already written a response.
|
||||
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
|
||||
// serialized. Responses committed through Context methods or Raw are preserved.
|
||||
func (c *Context) WriteError(err error) {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 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。
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
)
|
||||
|
||||
type Schema struct {
|
||||
Ref string `json:"$ref,omitempty"`
|
||||
AllOf []*Schema `json:"allOf,omitempty"`
|
||||
MaxBytes *int64 `json:"x-omni-max-bytes,omitempty"`
|
||||
Example any `json:"example,omitempty"`
|
||||
EnumDescriptions []string `json:"x-enum-descriptions,omitempty"`
|
||||
|
||||
@@ -2,6 +2,7 @@ package openapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -14,6 +15,8 @@ func TestSchemaAdditionalPropertiesAndExactNumbers(t *testing.T) {
|
||||
`{"type":"object","additionalProperties":true}`,
|
||||
`{"type":"object","additionalProperties":{"type":"string"}}`,
|
||||
`{"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
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(s, roundTrip) {
|
||||
t.Fatalf("schema round trip changed: %s", b)
|
||||
}
|
||||
}
|
||||
api, _ := New("Exact numbers", "1")
|
||||
s := &Schema{Type: "integer", Enum: []any{json.Number("18446744073709551615")}}
|
||||
|
||||
25
reports/headerPropagationAcceptance.md
Normal file
25
reports/headerPropagationAcceptance.md
Normal 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` 的配置会被拒绝或忽略。
|
||||
- 不增加认证、租户、幂等、业务重试或网关依赖。
|
||||
74
reports/headerPropagationProgramRemote.json
Normal file
74
reports/headerPropagationProgramRemote.json
Normal 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"
|
||||
}
|
||||
}
|
||||
341
reports/headerPropagationRemote.json
Normal file
341
reports/headerPropagationRemote.json
Normal 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"
|
||||
}
|
||||
}
|
||||
34
reports/headerPropagationWorkspaceRemote.json
Normal file
34
reports/headerPropagationWorkspaceRemote.json
Normal 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"
|
||||
}
|
||||
}
|
||||
32
reports/recursiveResponseAcceptance.md
Normal file
32
reports/recursiveResponseAcceptance.md
Normal 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`;远程验收记录另行提交,不移动标签。
|
||||
74
reports/recursiveResponseProgramRemote.json
Normal file
74
reports/recursiveResponseProgramRemote.json
Normal 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"
|
||||
}
|
||||
}
|
||||
295
reports/recursiveResponseRemote.json
Normal file
295
reports/recursiveResponseRemote.json
Normal 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"
|
||||
}
|
||||
}
|
||||
34
reports/recursiveResponseWorkspaceRemote.json
Normal file
34
reports/recursiveResponseWorkspaceRemote.json
Normal 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"
|
||||
}
|
||||
}
|
||||
43
reports/signedReadAcceptance.md
Normal file
43
reports/signedReadAcceptance.md
Normal 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`。验收脚本、说明和远程报告另行提交,不移动标签。
|
||||
111
reports/signedReadMinio.json
Normal file
111
reports/signedReadMinio.json
Normal 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"
|
||||
}
|
||||
7
reports/signedReadMinio.txt
Normal file
7
reports/signedReadMinio.txt
Normal 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
|
||||
74
reports/signedReadProgramRemote.json
Normal file
74
reports/signedReadProgramRemote.json
Normal 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"
|
||||
}
|
||||
}
|
||||
331
reports/signedReadRemote.json
Normal file
331
reports/signedReadRemote.json
Normal 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"
|
||||
}
|
||||
}
|
||||
34
reports/signedReadWorkspaceRemote.json
Normal file
34
reports/signedReadWorkspaceRemote.json
Normal 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"
|
||||
}
|
||||
}
|
||||
52
reports/websocket-bench-go1.25.txt
Normal file
52
reports/websocket-bench-go1.25.txt
Normal 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
|
||||
341
reports/websocket-managed-remote.json
Normal file
341
reports/websocket-managed-remote.json
Normal file
@@ -0,0 +1,341 @@
|
||||
{
|
||||
"passed": true,
|
||||
"frameworkVersion": "v0.31.0-preview",
|
||||
"frameworkSum": "h1:d0sf951iJwMUtHj29D6BjUwOgg+C79GwQ+2vjAHLjDo=",
|
||||
"freshModuleCache": true,
|
||||
"noLocalReplace": true,
|
||||
"durationSeconds": 145.85,
|
||||
"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": "ff56e82b6df4898177dbb45e9c183f21409aa5190a4a0e096bee06d4de7ad274",
|
||||
"lifecycle/controller.go": "1fb9393df1908afed93d87a1bf3ecea8d79d78e437a656b56bb7f1e5f6e51d31",
|
||||
"config/endpoints.go": "9f5123f962d52b71e5b73d5942316170875e895129a94e8b669ce782969ef27d",
|
||||
"health/health.go": "41f07f27a7fdcfa7973c2c554a22b73129ebc5d44e945e70e054e1cff24ab866",
|
||||
"httpx/response.go": "99e29724e4fa51d2c7ec1a52892c24ac5daf210a2ee547c5e69a175015298efd",
|
||||
"app/app.go": "f7df6355c02050a265862e2e1ec3238cb05788017e871a2b15eed33fbab78e04",
|
||||
"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": "ba5b38ac7f0308d1c8e922426a5f2526e03a3c2f8164da80117ea5f8729c2ef9",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
74
reports/websocket-program-remote.json
Normal file
74
reports/websocket-program-remote.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"passed": true,
|
||||
"frameworkVersion": "v0.31.0-preview",
|
||||
"frameworkSum": "h1:d0sf951iJwMUtHj29D6BjUwOgg+C79GwQ+2vjAHLjDo=",
|
||||
"freshModuleCache": true,
|
||||
"noLocalReplace": true,
|
||||
"durationSeconds": 22.77,
|
||||
"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": "d2ad08bc857a692aa79f05b9a14e2c0a21e2dfed84f22e4b89f353ca0168a260",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
86
reports/websocket-regression-go1.25.txt
Normal file
86
reports/websocket-regression-go1.25.txt
Normal 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
|
||||
104
reports/websocket-remote.json
Normal file
104
reports/websocket-remote.json
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"passed": true,
|
||||
"frameworkVersion": "v0.31.0-preview",
|
||||
"frameworkSum": "h1:d0sf951iJwMUtHj29D6BjUwOgg+C79GwQ+2vjAHLjDo=",
|
||||
"toolchain": "go version go1.25.0 darwin/arm64",
|
||||
"freshModuleCache": true,
|
||||
"noLocalReplace": true,
|
||||
"durationSeconds": 25.68,
|
||||
"packages": [
|
||||
"git.yuntuo.hk/k2safe/go-omni/examples/websocketRPC",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/hub",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/observe",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/omni",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/rpc"
|
||||
],
|
||||
"tests": [
|
||||
"git.yuntuo.hk/k2safe/go-omni/examples/websocketRPC:TestBidirectionalRPCAndRestore",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/hub:TestHubConcurrentLifecycle",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/hub:TestHubLimitsAndReusedID",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/hub:TestHubRealSessionDeliveryAndDisconnect",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/hub:TestHubRoutingBackpressureAndCleanup",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/observe:TestObserverCloseCallbackBoundaryAndPanic",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/observe:TestObserverCloseFieldsNeverExpandsErrors",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/observe:TestObserverConcurrentSessionsAndTotals",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/observe:TestObserverPayloadFreeLogsAndIndependentDurations",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/observe:TestObserverQueueSnapshotsAndSampledPeaks",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/omni:TestAppRetainsResourcesForBlockedComponent",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/omni:TestMountLifetimeAndStatus",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/omni:TestMountRejectsNative",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/rpc:FuzzProtocolDecode",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/rpc:TestBidirectionalNestedCalls",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/rpc:TestCancellationBusyAndCompletionBarrier",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/rpc:TestDuplicateIncomingIDRejected",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/rpc:TestErrorsDoNotExposeImplementation",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/rpc:TestEventErrorObservation",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/rpc:TestPendingLimitAndDisconnect",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/rpc:TestRealTCPRPCAndShutdown",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/rpc:TestReplyLateDuplicateAndProtocolLimits",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/rpc:TestResultEncodingPanicAndOversize",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws/rpc:TestSubscriptionsRestoreAndRemoval",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:FuzzOrigin",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientCloseCancelsHandshakeAndBackoff",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientConcurrentDialLimitAndClose",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientDoesNotReconnectAuthenticationOrProtocolFailure",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientElapsedBudgetInterruptsStalledHandshake",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientHTTPProxyAuthenticationAndTunnel",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientHandshakeDeadlineDoesNotExpireSession",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientHandshakeStats",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientHeadersRefreshReconnectAndFreshSessions",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientReconnectsRemoteGoingAway",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientRetryBudgetAndValidation",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientSingleSlotRapidReconnect",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientSubprotocol",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestClientTLSAndMutualTLS",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestServerAllowedOriginsAndAuthentication",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestServerConcurrentShutdown",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestServerConfigValidation",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestServerHandshakeRateAndPanic",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestServerLimitsAndShutdownCallbacks",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestServerOriginAndProtocolPolicy",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestServerParallelConnections",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestServerStopDuringHandshake",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestServerTypedNilRejection",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestServerUnsupportedWriter",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestSessionBoundedDispatcherDoesNotBlockPing",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestSessionCallbackPanicAndCloseBarrier",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestSessionCanceledQueueDoesNotCloseConnection",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestSessionCompressedReadLimitAndInvalidUTF8",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestSessionConcurrentSendAndJSON",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestSessionConfigUpperBounds",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestSessionEchoAndCompletion",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestSessionHeartbeatDetectsUnresponsivePeer",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestSessionInboundQueueFullCancelsCallback",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestSessionQueueBoundsCopiesAndGracefulDrain",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestSessionValidationAndRedactedErrors",
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws:TestSessionWriteCancellationStopsConnection"
|
||||
],
|
||||
"checks": [
|
||||
"remoteCLIInstall",
|
||||
"installedVersionDefault",
|
||||
"fixedRemoteModuleChecksum",
|
||||
"downloadedWebsocketRaceTests",
|
||||
"remoteEchoAndGroupExample",
|
||||
"remoteBidirectionalRPCExample",
|
||||
"remoteTwoConnectionSubscriptionRestore",
|
||||
"generatedCustomRoutesConsumer",
|
||||
"oldServiceWithoutHook",
|
||||
"handwrittenHookPreserved",
|
||||
"customRouteFailureCleanup"
|
||||
],
|
||||
"examples": {
|
||||
"websocket": "hello websocket\nhello group",
|
||||
"websocketRPC": "connection 1: double=42, remote callback=\"generic Go client\", restored=1, event=1\nconnection 2: double=42, remote callback=\"generic Go client\", restored=1, event=2"
|
||||
},
|
||||
"sourceHashes": {
|
||||
"scripts/verifyWebsocketRelease.py": "f87e2981fb352b8e5d494afc2d0b496bed5949af05e7b03a5340c8fc1901d56a",
|
||||
"cmd/go-omni/apigen/customRoutes_test.go": "3250e489326faa5a2cf60be4fb97f75d2e2666baef726b30f72464898c057f0d"
|
||||
},
|
||||
"limitations": [
|
||||
"Functional release acceptance; not a vulnerability scan or browser UI test.",
|
||||
"Known framework HTTP/2 dependency vulnerabilities remain documented in the validation report."
|
||||
]
|
||||
}
|
||||
109
reports/websocket-validation-2026-10-29.md
Normal file
109
reports/websocket-validation-2026-10-29.md
Normal file
@@ -0,0 +1,109 @@
|
||||
# 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 客户端测试代替这一项。示例源文件可供手动运行,临时服务已停止。
|
||||
|
||||
## 远程发布验收
|
||||
|
||||
发布标签 `v0.31.0-preview` 固定在提交 `3272974bc1cd19c79efe7354b00b6ed67f27f51e`,已核对远程标签解引用结果。CLI 默认版本与框架版本一致,发布前再次通过全库 `go test ./...` 和 managedService 生成漂移检查。报告随后独立提交,不移动发布标签。
|
||||
|
||||
四组验收各自使用空 `GOMODCACHE`、临时 `GOBIN`、`GOWORK=off` 和 Go 1.25.0,从远程安装固定版本 CLI;消费项目均无本地 replace。四组校验和一致:`h1:d0sf951iJwMUtHj29D6BjUwOgg+C79GwQ+2vjAHLjDo=`。
|
||||
|
||||
| 验收 | 结果 | 报告 |
|
||||
| --- | --- | --- |
|
||||
| WS 发布包 | 6 个包、60 个顶层测试通过 race;11 项检查,含两个可运行示例、两代连接订阅恢复、生成路由与失败清理 | [WS 远程验收](websocket-remote.json) |
|
||||
| API 受管工程 | 110 项检查,含 11 组生成消费者与旧能力回归 | [API 远程验收](websocket-managed-remote.json) |
|
||||
| 通用工程 | 30 项检查 | [通用工程远程验收](websocket-program-remote.json) |
|
||||
| 混合工作区 | 16 项检查 | [工作区远程验收](websocket-workspace-remote.json) |
|
||||
|
||||
复验命令(在源码仓库根目录,PATH 选择所需 Go 工具链):
|
||||
|
||||
```sh
|
||||
python3 scripts/verifyWebsocketRelease.py --version v0.31.0-preview --report reports/websocket-remote.json
|
||||
python3 scripts/verifyManagedRelease.py --version v0.31.0-preview --report reports/websocket-managed-remote.json
|
||||
python3 scripts/verifyProgramRelease.py --version v0.31.0-preview --report reports/websocket-program-remote.json
|
||||
python3 scripts/verifyWorkspaceRelease.py --version v0.31.0-preview --report reports/websocket-workspace-remote.json
|
||||
```
|
||||
|
||||
这些结果确认发布内容的功能与消费可用性,不改变下文的工具链、HTTP/2 依赖安全结论和浏览器实测缺口。
|
||||
|
||||
## 功能与并发验证
|
||||
|
||||
使用 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)。
|
||||
1
reports/websocket-vuln-core-patched-go.txt
Normal file
1
reports/websocket-vuln-core-patched-go.txt
Normal file
@@ -0,0 +1 @@
|
||||
No vulnerabilities found.
|
||||
839
reports/websocket-vuln-go1.25.json
Normal file
839
reports/websocket-vuln-go1.25.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
83
reports/websocket-vuln-patched-go.txt
Normal file
83
reports/websocket-vuln-patched-go.txt
Normal 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.
|
||||
34
reports/websocket-workspace-remote.json
Normal file
34
reports/websocket-workspace-remote.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"passed": true,
|
||||
"frameworkVersion": "v0.31.0-preview",
|
||||
"frameworkSum": "h1:d0sf951iJwMUtHj29D6BjUwOgg+C79GwQ+2vjAHLjDo=",
|
||||
"freshModuleCache": true,
|
||||
"noLocalReplace": true,
|
||||
"durationSeconds": 17.97,
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -25,4 +25,10 @@
|
||||
|
||||
## 远程发布验收
|
||||
|
||||
发布后从独立空模块缓存安装固定 CLI,执行混合工作区与既有生成器验收;结果完成后补入 JSON 报告。
|
||||
三个独立空模块缓存安装固定远程 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`。验收文档随后独立提交,发布标签不移动。
|
||||
|
||||
292
reports/workspaceManagedRemote.json
Normal file
292
reports/workspaceManagedRemote.json
Normal 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"
|
||||
}
|
||||
}
|
||||
74
reports/workspaceProgramRemote.json
Normal file
74
reports/workspaceProgramRemote.json
Normal 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"
|
||||
}
|
||||
}
|
||||
34
reports/workspaceRemote.json
Normal file
34
reports/workspaceRemote.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -124,7 +124,7 @@ def main():
|
||||
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)
|
||||
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)
|
||||
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("{")]
|
||||
@@ -135,6 +135,7 @@ def main():
|
||||
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 += ["generatedIncompleteHTTPDrainRetainsDependenciesAndLogger"]
|
||||
checks += ["recursiveResponseTypedHTTP", "recursiveResponseReferenceGraph", "recursiveResponseRegenerationAndDrift"]
|
||||
|
||||
# Run tests from the downloaded module, not the framework checkout.
|
||||
http_output = run(["go", "test", "-race", "-count=1", "-json", MODULE + "/httpc"], starter, env)
|
||||
@@ -160,7 +161,24 @@ def main():
|
||||
checks += ["remoteExplicitConfigurationRaceTests", "literalAndRequiredDefaultReferences",
|
||||
"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 {
|
||||
"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"},
|
||||
"logx": {"TestLoggerTimeZoneIsolation", "TestLoggerTimeZoneValidationAndDST", "TestLoggerTimeZoneConcurrentChildren"},
|
||||
"cors": {"TestPolicyPreflightAndActual", "TestPolicyValidation", "TestPolicyCredentialsWildcardAndDuplicates", "TestPolicyPreservesHTTPAndSnapshot"},
|
||||
@@ -171,7 +189,7 @@ def main():
|
||||
"transport/http2": {"TestShutdownWaitsForHandlerAfterClientDisconnect"},
|
||||
"transport/native": {"TestApplicationRejectsUnreadBodyPromptly", "TestBodyRejectionWithBusinessHTTP200DoesNotDrain"},
|
||||
"contract": {"TestBodyPolicyPropagatesOuterLimit", "TestJSONClosingDelimiterPreservesReaderCause"},
|
||||
"i18n": {"TestIndependentImmutableBundlesAndFallback", "TestResourceAndParameterDiagnostics", "TestStrictLoadingAndExplicitOverlay", "TestPinnedCLDRSamples", "TestBrowserConformance", "TestSourceLocaleCanBeFallbackOnly", "TestTypeScriptConsumer"},
|
||||
"i18n": i18n_tests,
|
||||
"i18n/httpi18n": {"TestNegotiationAndExplicitResolvers"},
|
||||
"i18n/errori18n": {"TestCatalogAdapterIsExplicitAndDetached"},
|
||||
"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)
|
||||
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"}
|
||||
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",
|
||||
"remoteCopyTypePreflight", "remoteEmptyCollectionTypeRejection", "remoteLoggerTimeZoneIsolation",
|
||||
"remoteCORSPolicyRace", "remoteCORSErrorResponses", "remoteCORSTransports", "generatedCORSConfigOnly"]
|
||||
@@ -215,9 +242,10 @@ def main():
|
||||
browser_output = run(["node", "--test", downloaded / "i18n/browser/runtime.test.mjs"], starter, env)
|
||||
assert "# fail 0" in browser_output
|
||||
checks += ["remoteI18nRuntimeRace", "remoteI18nGeneratedConsumer", "remoteI18nHTTPBusinessInvariants",
|
||||
"remoteI18nSourceOnlyFallback", "remoteCLDRSamplesAndBrowserConformance", "remoteTypeScriptConsumer",
|
||||
"remoteI18nSourceOnlyFallback", "remoteCLDRSamplesAndBrowserConformance",
|
||||
"remoteI18nCLIExtractionCheckExportTypes", "remoteI18nNamespaceIsolation",
|
||||
"remoteBrowserRaceAndFailureRetention", "remoteJSONReaderCausePreservation"]
|
||||
checks.append("remoteTypeScriptConsumer" if typescript_available else "remoteTypeScriptConsumerSkippedWithoutTSC")
|
||||
|
||||
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",
|
||||
"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"]
|
||||
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"],
|
||||
"freshModuleCache": True, "noLocalReplace": True,
|
||||
"durationSeconds": round(time.monotonic() - started, 2), "checks": checks,
|
||||
|
||||
91
scripts/verifyWebsocketRelease.py
Normal file
91
scripts/verifyWebsocketRelease.py
Normal 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()
|
||||
@@ -6,12 +6,42 @@
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 读写删除 | Store | 支持 | 支持 | 支持 |
|
||||
| 临时下载 | Presigner | NewSigner | 支持 | 支持 |
|
||||
| 带选项的预览/下载 | ReadPresigner | NewSigner | 支持 | 支持 |
|
||||
| 限定大小/类型的直传 | UploadPresigner | NewUploadSigner | 支持 | 支持,需要配置签名 Header |
|
||||
| 创建、上传、查询、完成、取消分片 | MultipartStore | 当前不提供 | 支持 | 支持 |
|
||||
| 分片直传签名 | PartPresigner | 当前不提供 | 支持 | 支持,需要配置签名 Header |
|
||||
|
||||
没有固定业务字段、目录规则或身份模型。鉴权、对象归属、业务配额由应用定义。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
|
||||
@@ -85,7 +115,7 @@ if err != nil { return err }
|
||||
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 的服务配置。
|
||||
|
||||
## 分片与续传
|
||||
|
||||
|
||||
@@ -151,14 +151,31 @@ func (s *Store) Get(ctx context.Context, key string) (storage.Object, error) {
|
||||
}
|
||||
info.ModifiedAt = stat.ModTime()
|
||||
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 {
|
||||
io.Reader
|
||||
io.Closer
|
||||
section *io.SectionReader
|
||||
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 {
|
||||
if err := storage.Check(ctx, key); err != nil {
|
||||
return err
|
||||
|
||||
125
storage/local/read_test.go
Normal file
125
storage/local/read_test.go
Normal 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
59
storage/oss/read.go
Normal 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
140
storage/oss/read_test.go
Normal 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
72
storage/read.go
Normal 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
219
storage/read_test.go
Normal 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
53
storage/s3/read.go
Normal 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
145
storage/s3/read_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,9 @@ func TestS3Integration(t *testing.T) {
|
||||
if !ready {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -114,6 +116,7 @@ func TestS3Integration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
s, _ := New(client, bucket, 64)
|
||||
checkReadOptionsIntegration(t, s)
|
||||
storagetest.Run(t, s)
|
||||
s.Put(ctx, "signed", strings.NewReader("hello"), 5, "text/plain")
|
||||
defer s.Delete(context.Background(), "signed")
|
||||
|
||||
@@ -49,30 +49,88 @@ func (s *Signer) PresignGet(ctx context.Context, key string, ttl time.Duration)
|
||||
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.
|
||||
func (s *Signer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPut && s.uploadPolicy != nil {
|
||||
s.serveUpload(w, r)
|
||||
return
|
||||
}
|
||||
w = &readResponseWriter{ResponseWriter: w, head: r.Method == http.MethodHead}
|
||||
if r.Method != "GET" && r.Method != "HEAD" {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
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)
|
||||
return
|
||||
}
|
||||
key, expiry := q.Get("key"), q.Get("expires")
|
||||
expires, err := strconv.ParseInt(expiry, 10, 64)
|
||||
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)
|
||||
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)
|
||||
if err != nil {
|
||||
status := 500
|
||||
@@ -82,17 +140,66 @@ func (s *Signer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "object unavailable", status)
|
||||
return
|
||||
}
|
||||
if object.Body == nil {
|
||||
http.Error(w, "object unavailable", 500)
|
||||
return
|
||||
}
|
||||
defer object.Body.Close()
|
||||
if object.Size < 0 {
|
||||
http.Error(w, "object unavailable", 500)
|
||||
return
|
||||
}
|
||||
contentType := object.ContentType
|
||||
if options.ContentType != "" {
|
||||
contentType = headers.Get("Content-Type")
|
||||
}
|
||||
if contentType == "" {
|
||||
contentType = "application/octet-stream"
|
||||
}
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Disposition", "attachment")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Cache-Control", "private, no-store")
|
||||
w.Header().Set("Content-Disposition", headers.Get("Content-Disposition"))
|
||||
if body, ok := object.Body.(io.ReadSeeker); ok {
|
||||
// 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.WriteHeader(http.StatusOK)
|
||||
if r.Method == "GET" {
|
||||
_, _ = 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
173
telemetry/headers.go
Normal 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)
|
||||
@@ -21,10 +21,11 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Service string `json:"service"`
|
||||
SampleRatio float64 `json:"sampleRatio"`
|
||||
Exporter sdktrace.SpanExporter `json:"-"`
|
||||
Reader sdkmetric.Reader `json:"-"`
|
||||
Service string `json:"service"`
|
||||
SampleRatio float64 `json:"sampleRatio"`
|
||||
Exporter sdktrace.SpanExporter `json:"-"`
|
||||
Reader sdkmetric.Reader `json:"-"`
|
||||
Propagator propagation.TextMapPropagator `json:"-"`
|
||||
}
|
||||
type Runtime struct {
|
||||
traces *sdktrace.TracerProvider
|
||||
@@ -48,7 +49,11 @@ func New(cfg Config) (*Runtime, error) {
|
||||
if cfg.Reader != nil {
|
||||
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")
|
||||
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))
|
||||
@@ -121,8 +126,14 @@ func (r *Runtime) Middleware() httpx.Middleware {
|
||||
r.duration.Record(ctx, time.Since(started).Seconds(), metric.WithAttributes(attrs...))
|
||||
span.End()
|
||||
// Preserve correlation for outer access-log middleware without
|
||||
// exposing an ended span as its active span.
|
||||
c.WithContext(logx.WithTraceID(parent, span.SpanContext().TraceID().String()))
|
||||
// exposing an ended span as its active span. A custom propagator may
|
||||
// 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 {
|
||||
panic(p)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/telemetry"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/metric/metricdata"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
@@ -115,3 +116,115 @@ func TestHTTPTracePropagationMetricsAndRedisNil(t *testing.T) {
|
||||
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
149
ws/README.md
Normal 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
633
ws/client.go
Normal 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 ©URL, 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
494
ws/client_test.go
Normal 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
83
ws/config.go
Normal 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
48
ws/errors.go
Normal 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
319
ws/hub/hub.go
Normal 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
|
||||
}
|
||||
271
ws/hub/hub_test.go
Normal file
271
ws/hub/hub_test.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type testConnection struct {
|
||||
id string
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
full bool
|
||||
sent atomic.Int64
|
||||
}
|
||||
|
||||
func (c *testConnection) ID() string { return c.id }
|
||||
func (c *testConnection) Done() <-chan struct{} { return c.done }
|
||||
func (c *testConnection) TrySend(ws.Message) error {
|
||||
select {
|
||||
case <-c.done:
|
||||
return ws.ErrClosed
|
||||
default:
|
||||
}
|
||||
if c.full {
|
||||
return ws.ErrQueueFull
|
||||
}
|
||||
c.sent.Add(1)
|
||||
return nil
|
||||
}
|
||||
func (c *testConnection) Close(int, string) error { c.once.Do(func() { close(c.done) }); return nil }
|
||||
func newConnection(id string) *testConnection {
|
||||
return &testConnection{id: id, done: make(chan struct{})}
|
||||
}
|
||||
func testHub(t *testing.T, cfg Config) *Hub {
|
||||
t.Helper()
|
||||
h, err := New(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = h.Close() })
|
||||
return h
|
||||
}
|
||||
|
||||
func TestHubRoutingBackpressureAndCleanup(t *testing.T) {
|
||||
h := testHub(t, Config{})
|
||||
a, b, c := newConnection("a"), newConnection("b"), newConnection("c")
|
||||
b.full = true
|
||||
for _, connection := range []*testConnection{a, b, c} {
|
||||
if err := h.Add(connection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, id := range []string{"a", "b"} {
|
||||
if err := h.Join(id, "group"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
message := ws.Message{Type: ws.Text, Data: []byte("hello")}
|
||||
result, err := h.SendGroup("group", message)
|
||||
if err != nil || result.Attempted != 2 || result.Queued != 1 || !errors.Is(result.Errors["b"], ws.ErrQueueFull) {
|
||||
t.Fatalf("group: %#v %v", result, err)
|
||||
}
|
||||
if c.sent.Load() != 0 {
|
||||
t.Fatal("group leaked")
|
||||
}
|
||||
result, err = h.Broadcast(message)
|
||||
if err != nil || result.Attempted != 3 || result.Queued != 2 {
|
||||
t.Fatalf("broadcast: %#v %v", result, err)
|
||||
}
|
||||
if err = h.Kick("a", 1000, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.After(time.Second)
|
||||
for h.Stats().Connections != 2 {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("auto cleanup timeout")
|
||||
default:
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
if got := h.Stats(); got.Memberships != 1 || got.Groups != 1 {
|
||||
t.Fatalf("stats: %#v", got)
|
||||
}
|
||||
if !h.Remove("b") {
|
||||
t.Fatal("remove")
|
||||
}
|
||||
if got := h.Stats(); got.Memberships != 0 || got.Groups != 0 {
|
||||
t.Fatalf("group leak: %#v", got)
|
||||
}
|
||||
if _, err = h.Send("missing", message); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = h.Close()
|
||||
if _, err = h.Broadcast(message); !errors.Is(err, ErrClosed) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-c.done:
|
||||
t.Fatal("hub closed borrowed connection")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubLimitsAndReusedID(t *testing.T) {
|
||||
h := testHub(t, Config{MaxConnections: 2, MaxGroups: 1, MaxMemberships: 1, MaxGroupsPerConnection: 1})
|
||||
first := newConnection("a")
|
||||
if err := h.Add(first); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.Add(first); !errors.Is(err, ErrDuplicate) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.Add(newConnection("b")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.Add(newConnection("c")); !errors.Is(err, ErrLimit) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.Join("a", "one"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := h.Join("a", "one"); err != nil {
|
||||
t.Fatal("idempotent join", err)
|
||||
}
|
||||
for _, pair := range [][2]string{{"a", "two"}, {"b", "one"}} {
|
||||
if err := h.Join(pair[0], pair[1]); !errors.Is(err, ErrLimit) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
h.Remove("a")
|
||||
second := newConnection("a")
|
||||
if err := h.Add(second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = first.Close(1000, "")
|
||||
if err := h.Join("a", "replacement"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := h.Send("a", ws.Message{Type: ws.Text}); err != nil {
|
||||
t.Fatal("old watcher removed replacement", err)
|
||||
}
|
||||
var nilConnection *testConnection
|
||||
if err := h.Add(nilConnection); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubConcurrentLifecycle(t *testing.T) {
|
||||
h := testHub(t, Config{MaxConnections: 100})
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 50; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
c := newConnection(fmt.Sprint(i))
|
||||
if err := h.Add(c); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
for j := 0; j < 10; j++ {
|
||||
_ = h.Join(c.ID(), "shared")
|
||||
_, _ = h.Broadcast(ws.Message{Type: ws.Text})
|
||||
h.Leave(c.ID(), "shared")
|
||||
}
|
||||
_ = c.Close(1000, "")
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
_ = h.Close()
|
||||
if h.Stats() != (Stats{}) {
|
||||
t.Fatal("registry not released")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHubRealSessionDeliveryAndDisconnect(t *testing.T) {
|
||||
received := make(chan string, 1)
|
||||
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()
|
||||
for {
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
received <- string(data)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := ws.NewClient(ws.ClientConfig{ProxyMode: "direct", Session: ws.SessionConfig{CloseTimeout: 100 * time.Millisecond}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := client.Shutdown(ctx); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
s, err := client.Dial(context.Background(), "ws"+strings.TrimPrefix(server.URL, "http"), ws.Handlers{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := testHub(t, Config{})
|
||||
if err = h.Add(s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = h.Join(s.ID(), "room"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := h.SendGroup("room", ws.Message{Type: ws.Text, Data: []byte("queued")})
|
||||
if err != nil || result.Queued != 1 {
|
||||
t.Fatalf("send: %#v %v", result, err)
|
||||
}
|
||||
select {
|
||||
case got := <-received:
|
||||
if got != "queued" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("delivery timed out")
|
||||
}
|
||||
_ = s.Close(1000, "")
|
||||
deadline := time.After(time.Second)
|
||||
for h.Stats().Connections != 0 {
|
||||
select {
|
||||
case <-deadline:
|
||||
t.Fatal("disconnected Session still registered")
|
||||
default:
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkHubBroadcast(b *testing.B) {
|
||||
for _, count := range []int{1, 100, 1000} {
|
||||
b.Run(fmt.Sprint(count), func(b *testing.B) {
|
||||
h, _ := New(Config{})
|
||||
defer h.Close()
|
||||
for i := 0; i < count; i++ {
|
||||
if err := h.Add(newConnection(fmt.Sprint(i))); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
message := ws.Message{Type: ws.Text, Data: []byte("benchmark")}
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
result, err := h.Broadcast(message)
|
||||
if err != nil || result.Queued != count {
|
||||
b.Fatal("broadcast failed")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
100
ws/limits_test.go
Normal file
100
ws/limits_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func TestSessionConfigUpperBounds(t *testing.T) {
|
||||
for _, c := range []SessionConfig{{MaxMessageBytes: 1<<30 + 1}, {ReadQueueSize: 65537}, {WriteQueueSize: 65537}, {ReadQueueBytes: 1<<30 + 1}, {WriteQueueBytes: 1<<30 + 1}, {CloseTimeout: 25 * time.Hour}} {
|
||||
if c.Validate() == nil {
|
||||
t.Fatal("oversized config accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientReconnectsRemoteGoingAway(t *testing.T) {
|
||||
var handshakes 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()
|
||||
if handshakes.Add(1) == 1 {
|
||||
_ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(1001, ""), time.Now().Add(time.Second))
|
||||
}
|
||||
for {
|
||||
if _, _, err = conn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := NewClient(ClientConfig{ProxyMode: "direct", Reconnect: ReconnectConfig{Enabled: true, MinBackoff: time.Millisecond, MaxBackoff: time.Millisecond}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := client.Shutdown(ctx); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
connected := make(chan struct{}, 1)
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- client.Run(ctx, "ws"+strings.TrimPrefix(server.URL, "http"), Handlers{OnOpen: func(context.Context, *Session) error {
|
||||
if handshakes.Load() >= 2 {
|
||||
connected <- struct{}{}
|
||||
}
|
||||
return nil
|
||||
}})
|
||||
}()
|
||||
select {
|
||||
case <-connected:
|
||||
case err := <-done:
|
||||
t.Fatalf("Run ended before reconnect: %v", err)
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
_ = client.Close()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
if stats := client.Stats(); stats.DialAttempts != 2 || stats.Reconnects != 1 || stats.HandshakeFailures != 0 {
|
||||
t.Fatal(stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientHandshakeStats(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "denied", http.StatusUnauthorized) }))
|
||||
defer server.Close()
|
||||
client, err := NewClient(ClientConfig{ProxyMode: "direct"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer client.Close()
|
||||
_, err = client.Dial(context.Background(), "ws"+strings.TrimPrefix(server.URL, "http"), Handlers{})
|
||||
if err == nil {
|
||||
t.Fatal("expected rejection")
|
||||
}
|
||||
if stats := client.Stats(); stats.DialAttempts != 1 || stats.HandshakeFailures != 1 || stats.Reconnects != 0 {
|
||||
t.Fatal(stats)
|
||||
}
|
||||
}
|
||||
44
ws/observe/README.md
Normal file
44
ws/observe/README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# WebSocket 可选观测
|
||||
|
||||
`ws/observe` 为 `ws.Client`、`ws.Server` 的会话提供连接日志和聚合快照。核心 `ws` 不依赖全局 Logger;观测器不启动后台 goroutine,不给每条消息增加日志、锁或 span。
|
||||
|
||||
```go
|
||||
logger, err := logx.New(logx.Default())
|
||||
if err != nil { return err }
|
||||
defer logger.Close() // 必须晚于 Client/Server 的 Shutdown 完成
|
||||
|
||||
observer := observe.New(logger) // nil 表示只统计,不记日志
|
||||
handlers := observer.Wrap(ws.Handlers{
|
||||
OnMessage: func(ctx context.Context, session *ws.Session, message ws.Message) error {
|
||||
return session.Send(ctx, message)
|
||||
},
|
||||
})
|
||||
server, err := ws.NewServer(ws.ServerConfig{})
|
||||
if err != nil { return err }
|
||||
mux.Handle("/ws", server.Handler(handlers))
|
||||
|
||||
// 由项目按自己的采样周期读取,接入 metrics、Prometheus 或 OTel。
|
||||
snapshot := observer.Snapshot()
|
||||
```
|
||||
|
||||
导入路径为 `git.yuntuo.hk/k2safe/go-omni/ws/observe`。同一个 Observer 和同一组包装后的 Handlers 可供多个连接并发复用;每个会话有独立的开始时间。每个会话仅包装一次。使用客户端重连和 `ClientConfig.Prepare` 时,在每次 Prepare 返回前调用 `observer.Wrap(handlers)`。
|
||||
|
||||
快照包含:
|
||||
|
||||
- `Opened / Closed / Active`:已观察到的连接总数、关闭回调结束数、当前处于受观察生命周期中的连接数。连接可能在 OnOpen 执行前断开,此时仍在 OnClose 计入 Opened 和 Closed,日志时长为 0。
|
||||
- `MessagesSent / MessagesReceived / BytesSent / BytesReceived`:已关闭会话的最终统计,加上当前会话的即时统计。发送成功表示写入完成;不是对端收到消息或业务处理成功的确认。统计口径与 `Session.Stats()` 一致。
|
||||
- `WriteQueuedMessages / WriteQueuedBytes / ReadQueuedMessages / ReadQueuedBytes`:调用快照时,各活跃连接队列水位之和。接收侧包括仍在处理、尚未释放的消息。
|
||||
- `SampledPeak*`:历次 Snapshot 观察到的聚合峰值;不是精确的历史峰值,采样间隔内的突增可能未被记录。
|
||||
- `CloseCallbackPanics`:下游 OnClose 回调发生 panic 的次数。
|
||||
|
||||
Snapshot 为 O(活跃会话数),只在采样时读取 `Session.Stats()`,不会替换消息回调。避免在每条消息上采样;采样周期由项目根据连接规模决定。快照不会保存已关闭连接的 ID,也不引入高基数指标标签。自行导出 OTel 时,建议记录握手、连接时长、消息数与字节数;业务消息追踪应显式传递上下文,不自动延长握手 span 到整条连接的寿命。
|
||||
|
||||
日志只由适配器写入固定事件名、Session ID、时长、关闭码和固定阶段。不会展开错误文本、Cause、URL、请求头、消息体或对端关闭原因;业务自定义 `OpError.Op` 也通过固定白名单筛选。用户传入 Logger 自身已经附加的属性仍由用户负责。默认日志级别为 Info。
|
||||
|
||||
OnClose 的收尾使用 defer,即使业务回调 panic,也会归档消息统计、移除活跃记录、记录固定的 `on_close_panic` 阶段,再把 panic 交还核心处理。此处“关闭”表示 I/O 已停止且下游 OnClose 已返回或 panic;核心随后才关闭 `Session.Done()`。因此 `Snapshot.Active == 0` 不是生命周期完成屏障,停机必须等待 `Client/Server.Shutdown` 或 `Session.Done`,再关闭 Logger。观测器不会在 OnClose 中等待自身 Done,也不会在 Done 后继续写日志。
|
||||
|
||||
运行真实 TCP 与并发测试:
|
||||
|
||||
```sh
|
||||
go test -race ./ws/observe
|
||||
```
|
||||
194
ws/observe/observe.go
Normal file
194
ws/observe/observe.go
Normal file
@@ -0,0 +1,194 @@
|
||||
// Package observe adds opt-in, payload-free WebSocket lifecycle logs and
|
||||
// aggregate snapshots. The ws core has no logger dependency. Observers do not
|
||||
// own loggers, start goroutines, or create per-message tracing spans.
|
||||
package observe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws"
|
||||
)
|
||||
|
||||
// Snapshot contains process-local totals for sessions observed by this Observer.
|
||||
// Active covers observed callback lifetimes: removal happens after the user's
|
||||
// OnClose returns, immediately before Session.Done. It is not a drain barrier.
|
||||
// Queue peaks are sampled only when Snapshot is called, not lifetime maxima.
|
||||
type Snapshot struct {
|
||||
Opened uint64 `json:"opened"`
|
||||
Closed uint64 `json:"closed"`
|
||||
Active int `json:"active"`
|
||||
CloseCallbackPanics uint64 `json:"closeCallbackPanics"`
|
||||
MessagesSent uint64 `json:"messagesSent"`
|
||||
MessagesReceived uint64 `json:"messagesReceived"`
|
||||
BytesSent uint64 `json:"bytesSent"`
|
||||
BytesReceived uint64 `json:"bytesReceived"`
|
||||
WriteQueuedMessages int `json:"writeQueuedMessages"`
|
||||
WriteQueuedBytes int64 `json:"writeQueuedBytes"`
|
||||
ReadQueuedMessages int `json:"readQueuedMessages"`
|
||||
ReadQueuedBytes int64 `json:"readQueuedBytes"`
|
||||
SampledPeakWriteQueuedMessages int `json:"sampledPeakWriteQueuedMessages"`
|
||||
SampledPeakWriteQueuedBytes int64 `json:"sampledPeakWriteQueuedBytes"`
|
||||
SampledPeakReadQueuedMessages int `json:"sampledPeakReadQueuedMessages"`
|
||||
SampledPeakReadQueuedBytes int64 `json:"sampledPeakReadQueuedBytes"`
|
||||
}
|
||||
|
||||
type observation struct{ started time.Time }
|
||||
|
||||
type Observer struct {
|
||||
log *logx.Logger
|
||||
mu sync.Mutex
|
||||
active map[*ws.Session]observation
|
||||
totals Snapshot
|
||||
}
|
||||
|
||||
// New borrows log without changing or closing it. nil disables logs and retains
|
||||
// metrics. Keep the logger open until Client/Server shutdown completes.
|
||||
func New(log *logx.Logger) *Observer {
|
||||
return &Observer{log: log, active: make(map[*ws.Session]observation)}
|
||||
}
|
||||
|
||||
// Wrap can be reused across concurrent sessions. It leaves OnMessage unchanged;
|
||||
// message accounting comes from Session.Stats during snapshots and closure.
|
||||
// Apply it once per session. With ClientConfig.Prepare, wrap each returned set
|
||||
// of handlers so each reconnect uses the same observer and its own start time.
|
||||
func (o *Observer) Wrap(next ws.Handlers) ws.Handlers {
|
||||
if o == nil {
|
||||
return next
|
||||
}
|
||||
return ws.Handlers{
|
||||
OnOpen: func(ctx context.Context, s *ws.Session) error {
|
||||
o.opened(s)
|
||||
if next.OnOpen != nil {
|
||||
return next.OnOpen(ctx, s)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
OnMessage: next.OnMessage,
|
||||
OnClose: func(s *ws.Session, err error) {
|
||||
defer func() {
|
||||
p := recover()
|
||||
o.closed(s, err, p != nil)
|
||||
if p != nil {
|
||||
panic(p)
|
||||
}
|
||||
}()
|
||||
if next.OnClose != nil {
|
||||
next.OnClose(s, err)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Observer) opened(s *ws.Session) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
o.mu.Lock()
|
||||
_, exists := o.active[s]
|
||||
if !exists {
|
||||
o.active[s] = observation{started: time.Now()}
|
||||
o.totals.Opened++
|
||||
}
|
||||
o.mu.Unlock()
|
||||
if !exists && o.log != nil {
|
||||
o.log.Info("ws.open", "sessionId", s.ID(), "phase", "open")
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Observer) closed(s *ws.Session, err error, panicked bool) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
stats := s.Stats()
|
||||
o.mu.Lock()
|
||||
observed, exists := o.active[s]
|
||||
delete(o.active, s)
|
||||
// A connection can close before its dispatch loop reaches OnOpen. Count
|
||||
// that observed physical connection too, with zero measured duration.
|
||||
if !exists {
|
||||
o.totals.Opened++
|
||||
}
|
||||
o.totals.Closed++
|
||||
if panicked {
|
||||
o.totals.CloseCallbackPanics++
|
||||
}
|
||||
addMessages(&o.totals, stats)
|
||||
o.mu.Unlock()
|
||||
duration := time.Duration(0)
|
||||
if !observed.started.IsZero() {
|
||||
duration = time.Since(observed.started)
|
||||
}
|
||||
if o.log != nil {
|
||||
code, phase := closeFields(err, panicked)
|
||||
o.log.Info("ws.close", "sessionId", s.ID(), "durationMs", duration.Seconds()*1000, "closeCode", code, "phase", phase)
|
||||
}
|
||||
}
|
||||
|
||||
func closeFields(err error, panicked bool) (int, string) {
|
||||
if panicked {
|
||||
return 0, "on_close_panic"
|
||||
}
|
||||
if err == nil {
|
||||
return 1000, "close"
|
||||
}
|
||||
var closed *ws.CloseError
|
||||
if errors.As(err, &closed) {
|
||||
return closed.Code, "close"
|
||||
}
|
||||
var op *ws.OpError
|
||||
if errors.As(err, &op) {
|
||||
// OpError is exported. Never write an arbitrary user-created Op to logs.
|
||||
switch op.Op {
|
||||
case "read", "write", "on_open", "on_message", "on_close", "ping", "pong", "close", "encode":
|
||||
return 0, op.Op
|
||||
}
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return 0, "canceled"
|
||||
}
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return 0, "timeout"
|
||||
}
|
||||
return 0, "error"
|
||||
}
|
||||
|
||||
func addMessages(out *Snapshot, stats ws.SessionStats) {
|
||||
out.MessagesSent += stats.MessagesSent
|
||||
out.MessagesReceived += stats.MessagesReceived
|
||||
out.BytesSent += stats.BytesSent
|
||||
out.BytesReceived += stats.BytesReceived
|
||||
}
|
||||
|
||||
// Snapshot samples live Session counters and adds immutable closed totals. It
|
||||
// costs O(active sessions), acquires no locks on the message path, and keeps no
|
||||
// per-connection records after OnClose. Message totals count I/O, not peer ACKs.
|
||||
func (o *Observer) Snapshot() Snapshot {
|
||||
if o == nil {
|
||||
return Snapshot{}
|
||||
}
|
||||
o.mu.Lock()
|
||||
defer o.mu.Unlock()
|
||||
out := o.totals
|
||||
out.Active = len(o.active)
|
||||
for session := range o.active {
|
||||
stats := session.Stats()
|
||||
addMessages(&out, stats)
|
||||
out.WriteQueuedMessages += stats.QueuedMessages
|
||||
out.WriteQueuedBytes += stats.QueuedBytes
|
||||
out.ReadQueuedMessages += stats.ReadQueuedMessages
|
||||
out.ReadQueuedBytes += stats.ReadQueuedBytes
|
||||
}
|
||||
o.totals.SampledPeakWriteQueuedMessages = max(o.totals.SampledPeakWriteQueuedMessages, out.WriteQueuedMessages)
|
||||
o.totals.SampledPeakWriteQueuedBytes = max(o.totals.SampledPeakWriteQueuedBytes, out.WriteQueuedBytes)
|
||||
o.totals.SampledPeakReadQueuedMessages = max(o.totals.SampledPeakReadQueuedMessages, out.ReadQueuedMessages)
|
||||
o.totals.SampledPeakReadQueuedBytes = max(o.totals.SampledPeakReadQueuedBytes, out.ReadQueuedBytes)
|
||||
out.SampledPeakWriteQueuedMessages = o.totals.SampledPeakWriteQueuedMessages
|
||||
out.SampledPeakWriteQueuedBytes = o.totals.SampledPeakWriteQueuedBytes
|
||||
out.SampledPeakReadQueuedMessages = o.totals.SampledPeakReadQueuedMessages
|
||||
out.SampledPeakReadQueuedBytes = o.totals.SampledPeakReadQueuedBytes
|
||||
return out
|
||||
}
|
||||
300
ws/observe/observe_test.go
Normal file
300
ws/observe/observe_test.go
Normal file
@@ -0,0 +1,300 @@
|
||||
package observe
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/ws"
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
func echo(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 testClient(t *testing.T) *ws.Client {
|
||||
t.Helper()
|
||||
c, err := ws.NewClient(ws.ClientConfig{ProxyMode: "direct", Session: ws.SessionConfig{CloseTimeout: 50 * time.Millisecond}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
if err := c.Shutdown(ctx); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
return c
|
||||
}
|
||||
func endpoint(s *httptest.Server) string { return "ws" + strings.TrimPrefix(s.URL, "http") }
|
||||
func wait(t *testing.T, ch <-chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timeout")
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserverConcurrentSessionsAndTotals(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(echo))
|
||||
defer server.Close()
|
||||
client := testClient(t)
|
||||
o := New(nil)
|
||||
const connections = 8
|
||||
const count = 10
|
||||
var workers sync.WaitGroup
|
||||
stopSamples := make(chan struct{})
|
||||
samplesDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(samplesDone)
|
||||
for {
|
||||
select {
|
||||
case <-stopSamples:
|
||||
return
|
||||
default:
|
||||
_ = o.Snapshot()
|
||||
}
|
||||
}
|
||||
}()
|
||||
for i := 0; i < connections; i++ {
|
||||
workers.Add(1)
|
||||
go func() {
|
||||
defer workers.Done()
|
||||
opened := make(chan struct{})
|
||||
received := make(chan struct{}, count)
|
||||
s, err := client.Dial(context.Background(), endpoint(server), o.Wrap(ws.Handlers{OnOpen: func(context.Context, *ws.Session) error { close(opened); return nil }, OnMessage: func(context.Context, *ws.Session, ws.Message) error { received <- struct{}{}; return nil }}))
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
wait(t, opened)
|
||||
for j := 0; j < count; j++ {
|
||||
if err = s.Send(context.Background(), ws.Message{Type: ws.Text, Data: []byte("test")}); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
wait(t, received)
|
||||
}
|
||||
_ = s.Close(1000, "")
|
||||
wait(t, s.Done())
|
||||
}()
|
||||
}
|
||||
workers.Wait()
|
||||
close(stopSamples)
|
||||
wait(t, samplesDone)
|
||||
got := o.Snapshot()
|
||||
if got.Opened != connections || got.Closed != connections || got.Active != 0 || got.MessagesSent != connections*count || got.MessagesReceived != connections*count || got.BytesSent != 4*connections*count || got.BytesReceived != 4*connections*count {
|
||||
t.Fatalf("snapshot: %+v", got)
|
||||
}
|
||||
if again := o.Snapshot(); again != got {
|
||||
t.Fatalf("closed totals changed: %+v -> %+v", got, again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserverCloseCallbackBoundaryAndPanic(t *testing.T) {
|
||||
for _, panics := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint(panics), func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(echo))
|
||||
defer server.Close()
|
||||
client := testClient(t)
|
||||
o := New(nil)
|
||||
opened := make(chan struct{})
|
||||
closing := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
s, err := client.Dial(context.Background(), endpoint(server), o.Wrap(ws.Handlers{OnOpen: func(context.Context, *ws.Session) error { close(opened); return nil }, OnClose: func(*ws.Session, error) {
|
||||
close(closing)
|
||||
<-release
|
||||
if panics {
|
||||
panic("SECRET CALLBACK PANIC")
|
||||
}
|
||||
}}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wait(t, opened)
|
||||
_ = s.Close(1000, "")
|
||||
wait(t, closing)
|
||||
if got := o.Snapshot(); got.Active != 1 || got.Closed != 0 {
|
||||
t.Fatalf("closed before callback finished: %+v", got)
|
||||
}
|
||||
select {
|
||||
case <-s.Done():
|
||||
t.Fatal("Done closed before user callback")
|
||||
default:
|
||||
}
|
||||
close(release)
|
||||
wait(t, s.Done())
|
||||
got := o.Snapshot()
|
||||
if got.Active != 0 || got.Closed != 1 {
|
||||
t.Fatalf("callback did not release observation: %+v", got)
|
||||
}
|
||||
if panics && got.CloseCallbackPanics != 1 {
|
||||
t.Fatalf("panic not counted: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserverPayloadFreeLogsAndIndependentDurations(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
logger, err := logx.New(logx.Config{Writer: &output, Format: "json", Level: "info"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
o := New(logger)
|
||||
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()
|
||||
if _, _, err = conn.ReadMessage(); err == nil {
|
||||
_ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(1008, "SECRET CLOSE REASON"), time.Now().Add(time.Second))
|
||||
_, _, _ = conn.ReadMessage()
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
client := testClient(t)
|
||||
var sessions []*ws.Session
|
||||
for i := 0; i < 2; i++ {
|
||||
opened := make(chan struct{})
|
||||
s, err := client.Dial(context.Background(), endpoint(server)+"/?token=SECRET-URL", o.Wrap(ws.Handlers{OnOpen: func(context.Context, *ws.Session) error { close(opened); return nil }}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wait(t, opened)
|
||||
sessions = append(sessions, s)
|
||||
if i == 0 {
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
for _, s := range sessions {
|
||||
if err = s.Send(context.Background(), ws.Message{Type: ws.Text, Data: []byte("SECRET MESSAGE")}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wait(t, s.Done())
|
||||
}
|
||||
if strings.Contains(output.String(), "SECRET") {
|
||||
t.Fatalf("log leaked source data: %s", output.String())
|
||||
}
|
||||
var durations []float64
|
||||
for _, line := range strings.Split(strings.TrimSpace(output.String()), "\n") {
|
||||
var row map[string]any
|
||||
if err = json.Unmarshal([]byte(line), &row); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key := range row {
|
||||
switch key {
|
||||
case "time", "level", "msg", "sessionId", "phase", "durationMs", "closeCode":
|
||||
default:
|
||||
t.Fatalf("unexpected log field %q", key)
|
||||
}
|
||||
}
|
||||
if row["msg"] == "ws.close" {
|
||||
if row["closeCode"] != float64(1008) {
|
||||
t.Fatalf("close code: %#v", row)
|
||||
}
|
||||
durations = append(durations, row["durationMs"].(float64))
|
||||
}
|
||||
}
|
||||
if len(durations) != 2 || durations[0] < 30 {
|
||||
t.Fatalf("timers not per session: %v", durations)
|
||||
}
|
||||
}
|
||||
|
||||
func TestObserverQueueSnapshotsAndSampledPeaks(t *testing.T) {
|
||||
startSending := make(chan struct{})
|
||||
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()
|
||||
<-startSending
|
||||
for i := 0; i < 5; i++ {
|
||||
if conn.WriteMessage(websocket.TextMessage, []byte("queue")) != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
for {
|
||||
if _, _, err = conn.ReadMessage(); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
client := testClient(t)
|
||||
o := New(nil)
|
||||
opened := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
s, err := client.Dial(context.Background(), endpoint(server), o.Wrap(ws.Handlers{OnOpen: func(context.Context, *ws.Session) error { close(opened); return nil }, OnMessage: func(context.Context, *ws.Session, ws.Message) error { <-release; return nil }}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wait(t, opened)
|
||||
close(startSending)
|
||||
deadline := time.After(2 * time.Second)
|
||||
var sampled Snapshot
|
||||
for {
|
||||
sampled = o.Snapshot()
|
||||
if sampled.MessagesReceived == 5 {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-deadline:
|
||||
close(release)
|
||||
t.Fatal("receive timeout")
|
||||
default:
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
if sampled.ReadQueuedMessages != 5 || sampled.ReadQueuedBytes != 25 || sampled.SampledPeakReadQueuedMessages != 5 {
|
||||
t.Fatalf("queue snapshot: %+v", sampled)
|
||||
}
|
||||
close(release)
|
||||
_ = s.Close(1000, "")
|
||||
wait(t, s.Done())
|
||||
final := o.Snapshot()
|
||||
if final.ReadQueuedMessages != 0 || final.SampledPeakReadQueuedMessages != 5 {
|
||||
t.Fatalf("final: %+v", final)
|
||||
}
|
||||
}
|
||||
|
||||
type opaqueError struct{}
|
||||
|
||||
func (opaqueError) Error() string { panic("must not expand error") }
|
||||
func TestObserverCloseFieldsNeverExpandsErrors(t *testing.T) {
|
||||
for _, err := range []error{opaqueError{}, &ws.OpError{Op: "SECRET STAGE", Cause: opaqueError{}}, &ws.CloseError{Code: 1008, Cause: opaqueError{}}} {
|
||||
_, phase := closeFields(err, false)
|
||||
if strings.Contains(phase, "SECRET") {
|
||||
t.Fatal("untrusted phase")
|
||||
}
|
||||
}
|
||||
if code, phase := closeFields(opaqueError{}, true); code != 0 || phase != "on_close_panic" {
|
||||
t.Fatalf("panic fields: %d %s", code, phase)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user