Compare commits
26 Commits
v0.21.0-pr
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 19ee9746e9 | |||
| 2c45dc1e89 | |||
| 021d53304a | |||
| f9ca6631ca | |||
| 9665fb5974 | |||
| d65af03162 | |||
| f0c62152d7 | |||
| 01a03149f9 | |||
| 0c5b571b31 | |||
| f2c5957c15 | |||
| 0d2c6040e2 | |||
| 1052f156bd | |||
| 0b6c0043b6 | |||
| f8e0dcc5a6 | |||
| 3b00bb55d1 | |||
| 859f1851b1 | |||
| d1a747c36f | |||
| 6a95cad184 | |||
| 7a177744ab | |||
| 8eddef9cc8 | |||
| 800974cfff | |||
| 9fade3bedb | |||
| 3bbc6f60d7 | |||
| 69585dab61 | |||
| d598b2ebee | |||
| 4bf6e88cfe |
22
ADVANCED.md
22
ADVANCED.md
@@ -11,7 +11,7 @@
|
|||||||
| `openapi` | 路由与 OpenAPI 3.0.3 同时注册、DTO schema、响应信封 | 显式声明请求/响应;不自动推断任意 Handler 行为,不附带 Swagger UI |
|
| `openapi` | 路由与 OpenAPI 3.0.3 同时注册、DTO schema、响应信封 | 显式声明请求/响应;不自动推断任意 Handler 行为,不附带 Swagger UI |
|
||||||
| `auth` | RS256/EdDSA JWT 验证与公钥轮换、可撤销 Session、Policy 接口 | 登录验证和业务授权规则由接入方提供;不自动获取 token 指定的密钥 |
|
| `auth` | RS256/EdDSA JWT 验证与公钥轮换、可撤销 Session、Policy 接口 | 登录验证和业务授权规则由接入方提供;不自动获取 token 指定的密钥 |
|
||||||
| `telemetry` | HTTP 入站/出站、Redis、SQL spans,HTTP 指标和 traceId 日志关联 | 注入 OTel exporter/reader 后才外发;SQL Query span 不覆盖后续遍历;不接管全局 provider |
|
| `telemetry` | HTTP 入站/出站、Redis、SQL spans,HTTP 指标和 traceId 日志关联 | 注入 OTel exporter/reader 后才外发;SQL Query span 不覆盖后续遍历;不接管全局 provider |
|
||||||
| `config` / `logx` | JSON/YAML 文件、环境变量覆盖、动态日志级别、轮转/脱敏/堆栈/慢请求 | 热更新只开放日志级别;连接池、监听、认证等静态变更要求重启 |
|
| `config` / `logx` | JSON/YAML/XML 文件、显式环境引用、动态日志级别、轮转/脱敏/堆栈/慢请求 | 热更新只开放日志级别;连接池、监听、认证等静态变更要求重启 |
|
||||||
| `resilience` | 失败阈值、熔断冷却、单探针恢复、上游并发隔离 | 一个实例对应一个依赖;默认内存状态,不做跨进程全局熔断 |
|
| `resilience` | 失败阈值、熔断冷却、单探针恢复、上游并发隔离 | 一个实例对应一个依赖;默认内存状态,不做跨进程全局熔断 |
|
||||||
| `middleware/redis` | 跨副本限流、幂等 claim/回放、容量限制 | 专用 Redis namespace;TTL 窗口内保护不等于永久业务去重 |
|
| `middleware/redis` | 跨副本限流、幂等 claim/回放、容量限制 | 专用 Redis namespace;TTL 窗口内保护不等于永久业务去重 |
|
||||||
| `queue` / `queue/redis` | 持久消息接口、Streams 接管、有限重试、失败消息管理 | 至少一次交付,处理器必须幂等;持久性取决于 Redis 部署 |
|
| `queue` / `queue/redis` | 持久消息接口、Streams 接管、有限重试、失败消息管理 | 至少一次交付,处理器必须幂等;持久性取决于 Redis 部署 |
|
||||||
@@ -57,6 +57,8 @@ files, err := oss.New(client, bucket, 32<<20)
|
|||||||
|
|
||||||
业务需要临时链接时显式依赖额外接口 `storage.Presigner`。S3/OSS 实现 `PresignGet`;Local 用 `storage.NewSigner(files, downloadBaseURL, secret)` 提供 HTTP 下载 Handler。链接 TTL 为 1 秒到 1 小时,私钥/临时凭据可能让云链接提前失效。签发链接前必须完成业务授权;对象 key 及 bucket 本身不是租户权限。签名链接不要写日志。受限直传和云端分片现已提供可选接口,见 [上传与续传说明](storage/README.md)。生命周期、ACL、CDN 自定义域名和版本管理仍由平台/部署管理。
|
业务需要临时链接时显式依赖额外接口 `storage.Presigner`。S3/OSS 实现 `PresignGet`;Local 用 `storage.NewSigner(files, downloadBaseURL, secret)` 提供 HTTP 下载 Handler。链接 TTL 为 1 秒到 1 小时,私钥/临时凭据可能让云链接提前失效。签发链接前必须完成业务授权;对象 key 及 bucket 本身不是租户权限。签名链接不要写日志。受限直传和云端分片现已提供可选接口,见 [上传与续传说明](storage/README.md)。生命周期、ACL、CDN 自定义域名和版本管理仍由平台/部署管理。
|
||||||
|
|
||||||
|
需要 inline 预览、显示文件名或指定响应 MIME 时,使用可选 `storage.ReadPresigner.PresignGetWithOptions`,返回 URL 和签名内的实际到期时间。Local、S3/R2、OSS 使用相同选项;Local 支持 HEAD 与范围读取,云端由原生存储服务处理。旧下载接口不变,见 [签名读取说明](storage/README.md#签名预览与下载)和[完整示例](examples/signedRead/README.md)。
|
||||||
|
|
||||||
R2 实现 S3 的子集,配置入口使用标准账户 endpoint;法域专属 endpoint 可显式配置。兼容性依据:[R2 S3 API](https://developers.cloudflare.com/r2/api/s3/api/)、[R2 签名链接](https://developers.cloudflare.com/r2/api/s3/presigned-urls/)。OSS 使用其[官方 Go SDK v2](https://www.alibabacloud.com/help/en/oss/developer-reference/manual-for-go-sdk-v2/),没有假定全部 S3 行为与 OSS 等价。
|
R2 实现 S3 的子集,配置入口使用标准账户 endpoint;法域专属 endpoint 可显式配置。兼容性依据:[R2 S3 API](https://developers.cloudflare.com/r2/api/s3/api/)、[R2 签名链接](https://developers.cloudflare.com/r2/api/s3/presigned-urls/)。OSS 使用其[官方 Go SDK v2](https://www.alibabacloud.com/help/en/oss/developer-reference/manual-for-go-sdk-v2/),没有假定全部 S3 行为与 OSS 等价。
|
||||||
|
|
||||||
验证区分:Local 契约、OSS SDK HTTP fixture、S3 SDK fixture 和 R2 签名参数属于本地测试;Linux runner 再跑真实 MinIO 上传/读取/删除与签名拒绝。**MinIO 通过不能代替 OSS/R2 实网验证。**迁移存储平台还需要复制已有对象和调整部署权限,不只是切配置。
|
验证区分:Local 契约、OSS SDK HTTP fixture、S3 SDK fixture 和 R2 签名参数属于本地测试;Linux runner 再跑真实 MinIO 上传/读取/删除与签名拒绝。**MinIO 通过不能代替 OSS/R2 实网验证。**迁移存储平台还需要复制已有对象和调整部署权限,不只是切配置。
|
||||||
@@ -102,7 +104,7 @@ err = consumer.Run(serviceContext, func(ctx context.Context, item queue.Delivery
|
|||||||
|
|
||||||
## 配置与日志热更新
|
## 配置与日志热更新
|
||||||
|
|
||||||
`config.LoadFile(path, prefix)` 按默认值→文件→非空环境变量加载并验证。两种文件格式都使用 JSON tag 的 lowerCamelCase,duration 可以写 `"5s"`;文件上限 1 MiB,拒绝未知字段、多文档和非法值。自定义 prefix 要包含期望的尾部下划线,例如 `BILLING_`。空环境变量沿用文件值,不清空已配置字段。
|
`config.LoadFile(path)` 使用默认值→文件字段显式引用→类型绑定→校验;没有自动环境覆盖。用 `${NAME}` 或 `${NAME:-fallback}` 指定来源,完整规则与迁移见 [CONFIG.md](CONFIG.md)。
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
address: '127.0.0.1:8080'
|
address: '127.0.0.1:8080'
|
||||||
@@ -113,7 +115,7 @@ log:
|
|||||||
format: json
|
format: json
|
||||||
```
|
```
|
||||||
|
|
||||||
先按配置创建 `logx.New(cfg.Log)`,再 `config.NewReloader(path, prefix, logger)`。显式调用 `Reload()` 或运行 `Watch(ctx, time.Second, onError)`;后者阻塞,生命周期由应用负责。只有 `log.level` 允许变化,校验失败或静态字段变化保留原快照与日志级别。子 logger 共享动态级别。替换文件时建议使用临时文件 + rename。
|
先按配置创建 `logx.New(cfg.Log)`,再 `config.NewReloader(path, logger)`。显式调用 `Reload()` 或运行 `Watch(ctx, time.Second, onError)`;后者阻塞,生命周期由应用负责。只有 `log.level` 允许变化,校验失败或静态字段变化保留原快照与日志级别。子 logger 共享动态级别。替换文件时建议使用临时文件 + rename。
|
||||||
|
|
||||||
## 熔断与观测
|
## 熔断与观测
|
||||||
|
|
||||||
@@ -121,6 +123,20 @@ log:
|
|||||||
|
|
||||||
`telemetry.New` 接受 OTel `sdktrace.SpanExporter` 和 `sdkmetric.Reader`;可注入官方 OTLP exporter、PeriodicReader 或测试用 exporter,基础包不强制选择 collector 协议。`app.Use(runtime.Middleware())` 采集入站,`telemetry.Transport` 采集出站并传播 W3C trace context;Redis 使用 `client.AddHook(telemetry.RedisHook{Runtime: runtime})`,SQL 使用 `telemetry.SQL{DB: db, Runtime: runtime}`。业务操作/事务可用 `runtime.Do(ctx, "fixedOperationName", fn)` 包裹。
|
`telemetry.New` 接受 OTel `sdktrace.SpanExporter` 和 `sdkmetric.Reader`;可注入官方 OTLP exporter、PeriodicReader 或测试用 exporter,基础包不强制选择 collector 协议。`app.Use(runtime.Middleware())` 采集入站,`telemetry.Transport` 采集出站并传播 W3C trace context;Redis 使用 `client.AddHook(telemetry.RedisHook{Runtime: runtime})`,SQL 使用 `telemetry.SQL{DB: db, Runtime: runtime}`。业务操作/事务可用 `runtime.Do(ctx, "fixedOperationName", fn)` 包裹。
|
||||||
|
|
||||||
|
上游网关如果使用自定义 Header,可配置通用兼容传播器,不在业务 Handler 里解析 Header:
|
||||||
|
|
||||||
|
```go
|
||||||
|
propagator, err := telemetry.NewHeaderPropagator(telemetry.HeaderPropagationConfig{
|
||||||
|
TraceIDHeader: "X-Trace-Id", SpanIDHeader: "X-Span-Id", RequestIDHeader: "X-Request-Id",
|
||||||
|
})
|
||||||
|
if err != nil { return err }
|
||||||
|
runtime, err := telemetry.New(telemetry.Config{
|
||||||
|
Service: "billing", SampleRatio: 1, Propagator: propagator,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`HeaderPropagator` 同时支持 W3C `traceparent`;两者同时存在时优先 W3C。有效的兼容 Header 会成为远程父 Span,当前服务仍生成自己的 Span ID,`logx.TraceID` / `RequestID`、访问日志和审计会沿用上下文,出站 `telemetry.Transport` 会同时传播 W3C 和配置的兼容 Header。自定义 Header 不携带采样标志,适配器默认把有效成对 ID 当作已采样;需要保留上游采样决定时使用 W3C。只有受信网关入口才应启用该适配器,Trace/Span ID 不是认证、租户或幂等凭据。完整离线示例见 [telemetryHeaders](examples/telemetryHeaders/README.md)。
|
||||||
|
|
||||||
创建者负责在停止请求/Worker 后调用 `runtime.Shutdown(ctx)` 刷新并关闭 exporter/reader。指标按注册路由模板和固定方法分类;自动采集不记录 URL query、SQL 正文、Redis key、token 或业务载荷。业务自定义 span 名称同样应固定,避免用用户 ID 或对象 key。
|
创建者负责在停止请求/Worker 后调用 `runtime.Shutdown(ctx)` 刷新并关闭 exporter/reader。指标按注册路由模板和固定方法分类;自动采集不记录 URL query、SQL 正文、Redis key、token 或业务载荷。业务自定义 span 名称同样应固定,避免用用户 ID 或对象 key。
|
||||||
|
|
||||||
## 分布式限流与幂等
|
## 分布式限流与幂等
|
||||||
|
|||||||
@@ -6,3 +6,5 @@
|
|||||||
- examples 使用通用场景,避免依赖业务项目、私有数据库或线上服务。必要的外部基础设施配置应明确说明。
|
- examples 使用通用场景,避免依赖业务项目、私有数据库或线上服务。必要的外部基础设施配置应明确说明。
|
||||||
- 2026-09-25 用户决定:移除 mappings/mappingFiles 及结构映射代码生成机制,改用可选 copyx 工具包。不得恢复另一套 JSON 或 Go 映射声明语言。
|
- 2026-09-25 用户决定:移除 mappings/mappingFiles 及结构映射代码生成机制,改用可选 copyx 工具包。不得恢复另一套 JSON 或 Go 映射声明语言。
|
||||||
- 深拷贝的自定义转换结果不得与输入共享可变引用;发现共享应报错,不能降级成浅拷贝或静默接受。
|
- 深拷贝的自定义转换结果不得与输入共享可变引用;发现共享应报错,不能降级成浅拷贝或静默接受。
|
||||||
|
|
||||||
|
- 2026-09-26 用户决定:配置文件显式声明固定值或 `${NAME}` / `${NAME:-fallback}`,旧自动环境覆盖、前缀/别名及 env/envDefault 标签模式移除,不得重新引入隐式二次覆盖。项目维护 Config/Default/Validate/文件,生成器维护 Load/main。
|
||||||
|
|||||||
8
API.md
8
API.md
@@ -48,7 +48,7 @@ go run ./cmd/go-omni new --api examples/contracts/user.api \
|
|||||||
--log-output file --log-format json ../userService
|
--log-output file --log-format json ../userService
|
||||||
```
|
```
|
||||||
|
|
||||||
`api generate` 在新目录中也会生成完整项目。默认只监听 localhost;`GO_OMNI_HTTP_ADDR` 修改地址,`GO_OMNI_TRANSPORT=native` 切换网络层。配置顺序为默认值 → `etc/config.yaml` → 环境变量。文件日志支持现有轮转配置。远程试用使用与 CLI 一致的固定版本,见 [发布说明](RELEASE.md)。
|
`api generate` 在新目录中也会生成完整项目。默认只监听 localhost;`GO_OMNI_HTTP_ADDR` 修改地址,`GO_OMNI_TRANSPORT=native` 切换网络层。配置顺序为 Default() → `etc/config.yaml` 中的显式环境引用 → 类型绑定 → Validate();固定值不再被隐式覆盖。文件日志支持现有轮转配置。远程试用使用与 CLI 一致的固定版本,见 [发布说明](RELEASE.md)。
|
||||||
|
|
||||||
API 项目现在支持组合数据库、存储和日志:
|
API 项目现在支持组合数据库、存储和日志:
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ go-omni new --api user.api --module example.com/userService \
|
|||||||
|
|
||||||
初次选项记录在 `.goOmni/apiManifest.json`,以后直接 `api generate` 会继承。已有项目通过参数切换选项会报错,因为初始化代码属于业务方;可在新目录生成对照后手动迁移。`--force` 不允许覆盖 API 项目。new 与 api new 统一生成契约项目,不再使用旧式 CRUD 目录。
|
初次选项记录在 `.goOmni/apiManifest.json`,以后直接 `api generate` 会继承。已有项目通过参数切换选项会报错,因为初始化代码属于业务方;可在新目录生成对照后手动迁移。`--force` 不允许覆盖 API 项目。new 与 api new 统一生成契约项目,不再使用旧式 CRUD 目录。
|
||||||
|
|
||||||
MySQL 使用官方 `database/mysql` 结构化适配器,密码从 `GO_OMNI_DB_PASSWORD` 覆盖,非秘密池配置位于 YAML,环境变量优先。存储使用原有 `GO_OMNI_STORAGE_*`;API 项目不会额外生成公开的签名发放接口,鉴权和对象权限由业务实现。初始化失败会清理已打开资源,退出清理可重复调用。
|
MySQL 使用官方 `database/mysql` 结构化适配器,生成文件以 `password: "${GO_OMNI_DB_PASSWORD}"` 显式读取密码;其他字段可写固定值或引用,缺失项保留 Default()。存储配置也只读取文件明确引用的变量;API 项目不会额外生成公开的签名发放接口,鉴权和对象权限由业务实现。初始化失败会清理已打开资源,退出清理可重复调用。
|
||||||
|
|
||||||
HTTP 请求类型不能推导数据库表结构;迁移和 Repository 由业务自己维护。当前目录的完整示例见 [managedService](examples/managedService/README.md)。MySQL、事务审计和鉴权实现可参考 [旧目录用户管理示例](examples/userService/README.md),接入时按 [项目指南](PROJECT.md) 装配到 config/svc/logic。
|
HTTP 请求类型不能推导数据库表结构;迁移和 Repository 由业务自己维护。当前目录的完整示例见 [managedService](examples/managedService/README.md)。MySQL、事务审计和鉴权实现可参考 [旧目录用户管理示例](examples/userService/README.md),接入时按 [项目指南](PROJECT.md) 装配到 config/svc/logic。
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ service user-api {
|
|||||||
- 来源标签中的 `optional` / `omitempty`、标量 `default`、标量 `options=a|b`、数值 `range=[1:100]`(支持开区间和单边界)。
|
- 来源标签中的 `optional` / `omitempty`、标量 `default`、标量 `options=a|b`、数值 `range=[1:100]`(支持开区间和单边界)。
|
||||||
- `binding` 的 `required`、`min`、`max`、`len`、`email`。长度按 Unicode 字符或切片元素数;范围边界必须有限且位于 ±2^53 内。
|
- `binding` 的 `required`、`min`、`max`、`len`、`email`。长度按 Unicode 字符或切片元素数;范围边界必须有限且位于 ±2^53 内。
|
||||||
|
|
||||||
暂不支持匿名嵌入、别名、递归类型、未声明的外部 Go 类型、未通过 scalar 显式接入的自定义编解码、`[]byte`、泛型、`json:",string"`、任意 validator 标签、go-zero 的 JWT/签名/SSE 声明,以及客户端 SDK 生成。认证验证通过声明关联的命名中间件实现。未列出的语法会明确报错。
|
暂不支持匿名嵌入、别名、递归请求类型、未声明的外部 Go 类型、未通过 scalar 显式接入的自定义编解码、`[]byte`、泛型、`json:",string"`、任意 validator 标签、go-zero 的 JWT/签名/SSE 声明,以及客户端 SDK 生成。认证验证通过声明关联的命名中间件实现。未列出的语法会明确报错。
|
||||||
|
|
||||||
业务错误、具名鉴权、多行文档及下述开发命令从 CLI `v0.7.0-preview.1` 提供。公共响应模板从 `v0.8.0-preview` 提供,CLI 与业务项目中的框架依赖需一起升级。
|
业务错误、具名鉴权、多行文档及下述开发命令从 CLI `v0.7.0-preview.1` 提供。公共响应模板从 `v0.8.0-preview` 提供,CLI 与业务项目中的框架依赖需一起升级。
|
||||||
|
|
||||||
@@ -331,3 +331,5 @@ python3 scripts/verifyManagedProject.py
|
|||||||
Go 测试覆盖契约拒绝规则、多源绑定、零值/缺失、默认/范围/嵌套集合、文档约束、幂等更新、只读检查、业务保留、删除路由、路径边界及生成项目编译。Python 验收会在临时目录生成项目,分别启动 nethttp/native,验证真实 HTTP 请求、中间件、日志 requestId 和优雅退出;结束后清理自己创建的进程和临时文件。结果记录在 `performance/managedProjectLocal.json`;完整记录见 [第一版验收](performance/managedProjectAcceptance.md)。
|
Go 测试覆盖契约拒绝规则、多源绑定、零值/缺失、默认/范围/嵌套集合、文档约束、幂等更新、只读检查、业务保留、删除路由、路径边界及生成项目编译。Python 验收会在临时目录生成项目,分别启动 nethttp/native,验证真实 HTTP 请求、中间件、日志 requestId 和优雅退出;结束后清理自己创建的进程和临时文件。结果记录在 `performance/managedProjectLocal.json`;完整记录见 [第一版验收](performance/managedProjectAcceptance.md)。
|
||||||
|
|
||||||
内置健康与 OpenAPI 路由可通过配置禁用或修改路径,`.api` 不再固定保留 `/health/live`、`/health/ready`、`/openapi.json`。启用的内置路径与业务 GET 路由重叠时,生成的 NewApp 在监听前返回配置错误;禁用后可以由 `.api` 自己维护这些路径。见 [内置接口配置](PROJECT.md#配置内置接口)。
|
内置健康与 OpenAPI 路由可通过配置禁用或修改路径,`.api` 不再固定保留 `/health/live`、`/health/ready`、`/openapi.json`。启用的内置路径与业务 GET 路由重叠时,生成的 NewApp 在监听前返回配置错误;禁用后可以由 `.api` 自己维护这些路径。见 [内置接口配置](PROJECT.md#配置内置接口)。
|
||||||
|
|
||||||
|
响应 DTO 自引用与相互引用从 v0.28.2 起支持,保留强类型并生成 OpenAPI `$ref`;见[递归响应说明](DEVELOPMENT.md#递归响应-dto)。直接值循环与字段组合循环仍然非法。
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
## 实施与验收清单
|
## 实施与验收清单
|
||||||
|
|
||||||
- [x] 运行期:资源登记、逆序释放、幂等关闭、失败回滚、故障原因保留归框架;生成入口与项目依赖初始化边界明确。多 App 日志隔离、标准 log.Logger 适配、可信请求元数据与依赖中间件示例。
|
- [x] 运行期:资源登记、逆序释放、幂等关闭、失败回滚、故障原因保留归框架;生成入口与项目依赖初始化边界明确。多 App 日志隔离、标准 log.Logger 适配、可信请求元数据与依赖中间件示例。
|
||||||
- [x] 配置:项目完整结构体环境覆盖;默认值→文件→环境;嵌套、duration、slice、显式零值、空值策略、必填与脱敏;日志无副作用完整校验。
|
- [x] 配置:Default()→文件显式环境引用→类型绑定→Validate;嵌套、duration、slice、显式零值、必填/备用值、转义与安全诊断;日志无副作用完整校验。
|
||||||
- [x] 数据库:官方 MySQL 结构化配置/TLS/驱动日志适配,继续复用 OpenConnector 和唯一连接池;独立启动检查、失败清理、生成器接入;不内置版本/表结构/业务错误策略。
|
- [x] 数据库:官方 MySQL 结构化配置/TLS/驱动日志适配,继续复用 OpenConnector 和唯一连接池;独立启动检查、失败清理、生成器接入;不内置版本/表结构/业务错误策略。
|
||||||
- [x] 响应契约:数组、可空对象、成功 null、独立 HTTP 204;Logic/Handler/OpenAPI/漂移检查一致。
|
- [x] 响应契约:数组、可空对象、成功 null、独立 HTTP 204;Logic/Handler/OpenAPI/漂移检查一致。
|
||||||
- [x] 请求契约:未知 query、JSON UTF-8/charset、无 Body/必填空对象/可选 Body,App 默认与路由覆盖;scalar/any/数组/大小限制一致,普通 JSON 只绑定一次。
|
- [x] 请求契约:未知 query、JSON UTF-8/charset、无 Body/必填空对象/可选 Body,App 默认与路由覆盖;scalar/any/数组/大小限制一致,普通 JSON 只绑定一次。
|
||||||
|
|||||||
69
CHANGELOG.md
69
CHANGELOG.md
@@ -1,5 +1,74 @@
|
|||||||
|
# v0.30.0-preview
|
||||||
|
|
||||||
|
- 新增通用 `telemetry.HeaderPropagator` 与 `HeaderPropagationConfig`,配置化接入 Trace ID、Span ID、Request ID Header;不绑定任何网关名称。
|
||||||
|
- 入站优先使用标准 W3C `traceparent`;缺少有效 W3C 父链路时,合法的兼容 Header 对成为远程父 Span。日志、审计、访问日志沿用 request/trace 上下文。
|
||||||
|
- 出站 `telemetry.Transport` 同时传播 W3C 和配置的兼容 Header;旧配置不变,默认仍只使用 W3C。
|
||||||
|
- 补充自定义 Header、W3C 优先级、非法 ID、父子 Span 和通用离线示例回归;不引入业务类型、网关名称或第三方依赖。
|
||||||
|
|
||||||
|
# v0.29.0-preview
|
||||||
|
|
||||||
|
- 新增可选 `storage.ReadPresigner` / `ReadOptions` / `ReadLink`,签名读取可选择 inline/attachment、显示文件名、MIME 类型,返回签名内的实际到期时间。原 Store/Presigner 和旧下载链接兼容,TTL 上限仍为一小时。
|
||||||
|
- Local、S3/R2、OSS 均实现;全部选项参与本地 HMAC 或云端原生 SDK 签名,标准编码中文文件名并拒绝非法响应头。
|
||||||
|
- 本地读取支持 GET/HEAD、单段与多段 Range、206/416、条件请求,按文件内容真实长度处理,流式读取不暴露本地元数据。顺序读取的第三方 Store 保持完整 200 回退。
|
||||||
|
- 补充通用 PDF 运行示例、篡改与到期回归、OSS V1/V4 签名校验、真实隔离 MinIO 验证和远程安装检查;不引入业务模型或第三方依赖。
|
||||||
|
|
||||||
|
# v0.28.2-preview
|
||||||
|
|
||||||
|
- 修复响应 DTO 的自引用/相互引用生成,支持 slice、pointer、map;OpenAPI 使用命名组件与循环引用,保留约束、描述、示例及 scalar 输出 schema。
|
||||||
|
- 拒绝 Go 非法值循环与递归请求;保留格式化、生成、契约变更和只读检查能力。新增通用树示例与真实生成消费者回归。
|
||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## v0.27.0-preview(2026-09-29)
|
||||||
|
|
||||||
|
- HTTP 配置新增 defaultMaxBodyBytes、maxBodyBytes、shutdownCancelTimeout;三种内置服务统一默认/路由/硬请求体限额,配置冲突在监听前报告完整路由与限额。native/HTTP2 不再以隐含 8 MiB 阻挡已放宽的 App 上传路由。
|
||||||
|
- App 在所有服务入口跟踪完整请求处理链;关闭连接后有界等待处理器实际退出。超时仍有请求则返回 ErrResourcesRetained,跳过 App 清理;Runtime.Finish 继续停止后台工作但保留依赖,生成入口保留日志。
|
||||||
|
- native 提前拒绝上传后有界半关闭连接,减少 TCP reset 吞掉错误响应;业务 HTTP 200 策略不再触发对被拒绝请求体的继续读取。
|
||||||
|
- HTTP/2 Shutdown 同时等待连接和独立处理器,修复客户端断开后误判完成。增加真实 TLS/流式上传、取消收尾、拒绝晚到请求、资源保留和生成消费者回归,以及 httpSafety 示例。
|
||||||
|
- 迁移:nethttp 手写普通路由新增默认 8 MiB 请求体上限,大请求显式覆盖;生成契约默认 1 MiB 保持。HTTP 停机多一个默认 5s 取消收尾窗口;外部宿主须将运行错误传给 Runtime.Finish,并遵守资源保留信号。
|
||||||
|
|
||||||
|
## v0.26.0-preview(2026-09-29)
|
||||||
|
|
||||||
|
- 新增独立 i18n:不可变命名空间词典、源/默认/启用语言、BCP 47 匹配、显式回退、参数/条件/基数复数和有界资源加载;不依赖 HTTP、业务或数据库。
|
||||||
|
- Go/浏览器共用固定 CLDR 46.1 复数规则;JSON 资源版本、类型导出和按命名空间选择的语言包独立于业务错误格式。
|
||||||
|
- 新增可选 httpi18n/errori18n 适配;错误 translation 元数据、PublicMessage 展示入口,保留原业务码/状态/失败语义/原始原因和首次幂等响应。
|
||||||
|
- 新增 i18n check/export/types/extract;静态与动态键诊断、未知字段/重复定义/参数校验、显式覆盖;输入只读,不执行任意项目 Go。
|
||||||
|
- 新增无前端框架依赖的浏览器模块、按需加载/切换竞态/失败保留、CLI/HTTP/表单示例及通用验收。旧项目默认行为不变。
|
||||||
|
- 新 translation 生成代码要求 v0.26.0-preview 或具备能力的本地源码,未使用该元数据的模板最低版本保持 v0.22。
|
||||||
|
|
||||||
|
- 修复 JSON 对象/数组结束定界符读取失败时丢失原始错误的问题,保留请求体限额的 HTTP 413;共享 Go 目录测试工程改用声明支持的 Go 1.22。
|
||||||
|
|
||||||
|
## v0.25.0-preview(2026-09-26)
|
||||||
|
|
||||||
|
- database.InTx 新增 TxError、TxStage、TxOutcome,区分开启/回调/提交失败以及未执行回调、已确认回滚、结果不确定;原签名保留。
|
||||||
|
- 回调失败后只有显式回滚返回成功才标记 rolledBack;提交报错一律保守返回 unknown,不根据断网/超时或 ErrTxDone 推断回滚。不自动重试事务,不处理文件清理等业务决策。
|
||||||
|
- Err/RollbackErr 分开保留,errors.As/Is 可遍历两条原因链;业务错误目录和公开响应行为保持。nil context 接受,nil 回调在开启前报错,panic/Goexit 保持传播并尝试回滚。
|
||||||
|
- 新增阶段/取消竞争/确认丢失/并发/公开响应回归、真实 MySQL 驱动边界故障验收、性能比较和 examples/transactions;同步 README 与 database 指南。
|
||||||
|
- 错误现在有类型包装;直接错误相等、直接类型断言和错误文本依赖需改为 errors.Is/As。标准库异步回滚不公开的底层原因无法还原,结果保持 unknown;不改变 GORM 自己的 Transaction。
|
||||||
|
|
||||||
|
## v0.24.0-preview(2026-09-26)
|
||||||
|
|
||||||
|
- 新增可选 cors 包和 server.cors 配置;生成项目直接加载,手写项目使用 omni.WithCORS,独立 HTTP 服务可使用 Policy.Wrap。默认关闭,无新增第三方依赖。
|
||||||
|
- 精确来源白名单、方法/请求头、暴露头、凭据和预检缓存可配置;拒绝凭据与通配来源混用、非法来源/头及歧义的重复请求头,校验错误定位具体字段。
|
||||||
|
- 在路由和鉴权之前处理预检;实际请求仍执行原有中间件,统一响应、404/405、上传下载和流式能力保持。nethttp/native/HTTP2 共用策略。
|
||||||
|
- 补充真实生成项目、TLS 三种 transport、20 项真实浏览器检查、race/fuzz/性能基准;同步 README、配置指南、cors 指南及 examples/cors。
|
||||||
|
- 没有移除现有功能或修改默认跨域行为。代理已负责 CORS 的项目保持关闭,避免重复头;高级调用 Router().ServeHTTP 不经过应用外层 CORS。
|
||||||
|
|
||||||
|
## v0.23.0-preview(2026-09-26)
|
||||||
|
|
||||||
|
- MySQL/连接池、日志、HTTP 客户端、生命周期和请求策略返回字段级安全诊断,配置加载器保留文件/变量/位置与原始错误链。
|
||||||
|
- httpc.JSON 新增 WithSuccessStatuses,单次调用可限制允许的 2xx,默认兼容,拒绝响应继续关闭和有界回收。
|
||||||
|
- copyx.Check[S,T] / Copier.Check 预检嵌套类型与现有规则;修正空集合隐藏类型不匹配,动态接口和 converter 的具体行为继续运行时验证。
|
||||||
|
- logx.Config.TimeZone 绑定 Logger 实例,JSON/text/子 Logger/标准日志适配一致;不修改 time.Local 或业务时间属性。
|
||||||
|
- 无新映射文件、强制模块或第三方依赖;更新 README、各包指南和 developerTools 可运行示例。错误文本更精确,空集合中的确定类型错误现在会被拒绝。
|
||||||
|
|
||||||
|
## v0.22.0-preview(2026-09-26)
|
||||||
|
|
||||||
|
- 文件显式环境引用与备用值,字符串组合/转义,安全有界展开和类型绑定;变量值原样插入、不递归处理。
|
||||||
|
- 移除隐式环境覆盖、EnvOptions/前缀/别名及 env/envDefault;数据库统一使用文件加载,不再单独扫描环境。
|
||||||
|
- 生成 main/Load 新增 -check-config,精简配置文件,保留开发者扩展;错误字段建议和迁移检查。
|
||||||
|
- README、CONFIG、数据库/HTTP 客户端指南及示例同步新规则;配置中的响应模板需要 $$ 转义,.api 不变。
|
||||||
|
|
||||||
## v0.21.0-preview(2026-09-26)
|
## v0.21.0-preview(2026-09-26)
|
||||||
|
|
||||||
- httpc 可配置环境/直连/固定代理、常用连接阶段超时和独立空闲池参数。
|
- httpc 可配置环境/直连/固定代理、常用连接阶段超时和独立空闲池参数。
|
||||||
|
|||||||
277
CONFIG.md
277
CONFIG.md
@@ -1,178 +1,199 @@
|
|||||||
# 统一配置加载
|
# 配置:文件明确声明值的来源
|
||||||
|
|
||||||
v0.20 起,新生成服务的 `main.go` 调用生成器维护的 `config.Load`。项目只需要定义业务字段、默认值和校验;日志、HTTP、数据库等配置使用同一流程,配置加载时不打开数据库、监听端口或创建日志文件。
|
v0.22 起只使用这一条流程:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
代码默认值 → envDefault(仅填零值)→ 可选配置文件
|
项目 Default() → 解析选中的一个文件 → 解析字段值中的环境引用
|
||||||
→ 完整结构体环境覆盖(含别名)→ 框架规范化 → 项目 Validate
|
→ 按 Go 类型绑定已提供的字段 → 项目 Validate() → 初始化日志和依赖
|
||||||
→ 初始化日志及项目依赖 → 启动服务
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 生成项目要维护什么
|
没有环境前缀、别名表或隐式二次覆盖。业务只维护 `Config`、`Default()`、`Validate()` 和配置文件。项目 Validate 中继续调用嵌套框架配置的 Validate;生成模板已包含这些调用。验证函数只检查值,不应打开资源。
|
||||||
|
|
||||||
| 文件 | 维护方 | 用途 |
|
## 直接使用
|
||||||
| --- | --- | --- |
|
|
||||||
| `main.go` | 生成器 | 启动、信号、请求排空、依赖关闭 |
|
|
||||||
| `internal/config/loadGen.go` | 生成器 | 调用统一加载入口;纳入 `generate --check` |
|
|
||||||
| `internal/config/config.go` | 项目 | `Config`、`Default()`、`Validate()` |
|
|
||||||
| `internal/config/loader.go` | 项目 | `NewLoader()`,只有增加文件格式时才需修改 |
|
|
||||||
| `etc/config.yaml` | 项目 | 部署配置;可改用 JSON/XML 并传 `-config` |
|
|
||||||
| `internal/svc` | 项目与生成器分文件 | 初始化依赖、登记清理、AppOptions |
|
|
||||||
|
|
||||||
比如增加业务的批次数量:在 Config 增加一个字段,在 Validate 加范围校验;需要部署值时改配置文件或环境变量。不修改 main、Load 或 Handler。
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// 添加到项目 Config。
|
|
||||||
BatchSize int `json:"batchSize" envDefault:"100"`
|
|
||||||
|
|
||||||
// 添加到项目 Validate。
|
|
||||||
if c.BatchSize < 1 || c.BatchSize > 1000 {
|
|
||||||
return omniConfig.Invalid("batchSize", "must be between 1 and 1000")
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
自动支持 `GO_OMNI_BATCH_SIZE`。生成服务的空环境值策略为 `ignore`,0 和 false 是有效覆盖;范围由 Validate 决定。
|
|
||||||
重新生成保留 config.go、loader.go 和 svc 的手写扩展。
|
|
||||||
|
|
||||||
## 手写项目用法
|
|
||||||
|
|
||||||
```go
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"time"
|
|
||||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Timeout time.Duration `json:"timeout" envDefault:"3s"`
|
Server omniConfig.Config `json:"server"`
|
||||||
Limit int `json:"limit" envDefault:"100"`
|
BatchSize int `json:"batchSize"`
|
||||||
Token string `json:"-" env:"APP_TOKEN,secret"`
|
Enabled bool `json:"enabled"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Timeout time.Duration `json:"timeout"`
|
||||||
|
}
|
||||||
|
func Default() Config {
|
||||||
|
return Config{Server: omniConfig.Default(), BatchSize: 100, Enabled: true,
|
||||||
|
Timeout: 3*time.Second}
|
||||||
}
|
}
|
||||||
func (c Config) Validate() error {
|
func (c Config) Validate() error {
|
||||||
if c.Timeout <= 0 {
|
if err := c.Server.Validate(); err != nil {
|
||||||
return config.Invalid("timeout", "must be positive")
|
return omniConfig.AtField("server", err)
|
||||||
}
|
}
|
||||||
|
if c.BatchSize < 0 { return omniConfig.Invalid("batchSize", "must not be negative") }
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// 手写项目;生成项目的 Load 已自动调用此入口。
|
||||||
// cfg, err := config.LoadProject("etc/config.yaml", Config{},
|
cfg, err := omniConfig.LoadProject("etc/config.yaml", Default())
|
||||||
// config.EnvOptions{Prefix: "APP_", Empty: "reject"})
|
|
||||||
```
|
|
||||||
|
|
||||||
`LoadProject` 接收结构体默认值,可实现值接收器或指针接收器的 `Validate() error`。空路径表示不读文件。
|
|
||||||
`DecodeFile` 只做文件覆盖,不做 envDefault、环境覆盖或业务 Validate;旧的两参数调用仍有效,可用结构体或结构体指针。低层 `ApplyDefaults`、`OverlayEnv`、`LoadFile` 继续存在。
|
|
||||||
|
|
||||||
嵌套校验用 `config.AtField("client", err)` 加路径前缀。公共原因通过 `config.Invalid` 提供,原因中不要包含密码、DSN、Token 或输入原值。
|
|
||||||
|
|
||||||
## JSON / YAML / XML
|
|
||||||
|
|
||||||
按文件扩展名选择解析器:`.json`、`.yaml`、`.yml`、`.xml`,大小写不敏感。所有格式使用 Go 结构体的 **json 字段名**,无需为 XML 复制一份结构或标签。
|
|
||||||
以下配置等价:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{"client":{"timeout":"2s"},"enabled":false,"ports":[8080,8081],"optional":null}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
client:
|
server:
|
||||||
timeout: 2s
|
address: "${HTTP_ADDR:-127.0.0.1:8080}"
|
||||||
|
password: "${DB_PASSWORD}"
|
||||||
|
timeout: "${REQUEST_TIMEOUT:-3s}"
|
||||||
enabled: false
|
enabled: false
|
||||||
ports: [8080, 8081]
|
```
|
||||||
optional: null
|
|
||||||
|
文件未写 batchSize,保留 100;enabled 明确写 false,就使用 false。变量名字完全由文件决定,不需要对应 Go 字段名。配置文件可只列出部署需要变动的字段;不会自动导出/打印包含凭据的完整配置。
|
||||||
|
|
||||||
|
## 引用语法
|
||||||
|
|
||||||
|
| 文件里的值 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `host: 127.0.0.1` | 固定值,不读取任何环境变量 |
|
||||||
|
| `password: "${DB_PASSWORD}"` | 未设置或空字符串报错,不回退到 Default() |
|
||||||
|
| `port: "${DB_PORT:-3306}"` | 未设置或为空时使用 3306 |
|
||||||
|
| `sessionToken: "${SESSION_TOKEN:-}"` | 未设置或为空时使用空字符串,是否合法由目标类型和 Validate 决定 |
|
||||||
|
| `url: "https://${HOST}:${PORT:-443}/v1"` | 普通字符串中可出现多个引用 |
|
||||||
|
| `literal: "$${DB_PASSWORD}"` | 得到字面量 `${DB_PASSWORD}`,不读取变量 |
|
||||||
|
| `literal: "$$cost"` | `$$` 转义一个 `$`,得到 `$cost` |
|
||||||
|
|
||||||
|
变量名遵循 `[A-Za-z_][A-Za-z0-9_]*`。只支持 `${NAME}`、`${NAME:-fallback}` 和 `$$` 转义,不支持 `${NAME-default}`、嵌套引用或 shell 表达式。普通单独的 `$` 保留。fallback 到第一个 `}` 结束,可包含冒号、`:-`、引号等文件格式允许的字符;需要包含 `}` 的值可使用固定值或环境变量。
|
||||||
|
|
||||||
|
解析只扫描文件的**已解码字段值**,不替换 YAML/JSON/XML 源文本,不扩展字段名,也不扫描 Default() 的字符串。插入的环境值和 fallback 不再展开。密码 `DDSFLKJlsf:saf:-123`、引号、换行、`${OTHER}` 均原样保留,不破坏文档结构。字符串不统一 TrimSpace;`"0"`、`"false"` 和全空格字符串均属于已提供。
|
||||||
|
|
||||||
|
同一变量每次加载最多读取一次;下一次加载会重新读取。没有全局变量值缓存。并发加载各自维护结果,不能把调用者正在修改的默认配置当作共享可变输入。
|
||||||
|
|
||||||
|
## 类型与覆盖
|
||||||
|
|
||||||
|
目标 Go 类型决定引用结果的转换:整数、浮点数、布尔值、`time.Duration`、字符串、指针及已有自定义 JSON/Text 反序列化。数值范围按目标位宽检查,整数不经过 float64。自定义反序列化仍由自定义类型解释文本,不猜测其业务含义。
|
||||||
|
|
||||||
|
- 文件缺失字段保留初始值;显式 0、false、空字符串正常覆盖,不用零值判断字段是否存在。
|
||||||
|
- `${MISSING}` 失败就报错;需要备用值必须写 `${MISSING:-value}`。
|
||||||
|
- 字符串编号 `"001"` 保持 `001`;绑定到 int 才转整数。环境里的 `null` 不自动变成空指针,`[1,2]` 不自动变成数组。
|
||||||
|
- 集合直接在文件里声明,可在每个标量元素中使用引用,例如 `ports: ["${PORT_A:-8080}", "${PORT_B:-8081}"]`。
|
||||||
|
- 数组整体替换;空 slice 保持非 nil;固定长度 Go 数组要求长度一致。map 合并键,已提供键的值整体替换。
|
||||||
|
- 固定文件 null 沿用 JSON 语义:清空指针/map/slice/interface,普通值字段保持初始值。需要区分 null 时使用指针。
|
||||||
|
- `any` 的引用结果保持字符串,不猜测动态类型。固定 JSON/YAML 数字使用 json.Number 保留精度。
|
||||||
|
- 引用成功后的值不会再覆盖一次。旧 `GO_OMNI_*` 名称只有出现在文件引用中才有作用;不隐式重置日志 file/rotation。
|
||||||
|
|
||||||
|
普通固定字符串仍按原有严格绑定规则处理;不会为了支持变量,把所有 `"123"` 都自动转换成数字。
|
||||||
|
|
||||||
|
## YAML、JSON、XML 一致
|
||||||
|
|
||||||
|
使用 Go 的 `json` 字段名,无需维护三份 tag。以下三份文件等价:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
port: "${PORT:-3306}"
|
||||||
|
password: "${PASSWORD}"
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"port":"${PORT:-3306}","password":"${PASSWORD}"}
|
||||||
```
|
```
|
||||||
|
|
||||||
```xml
|
```xml
|
||||||
<config>
|
<config><port>${PORT:-3306}</port><password>${PASSWORD}</password></config>
|
||||||
<client><timeout>2s</timeout></client>
|
|
||||||
<enabled>false</enabled>
|
|
||||||
<ports type="array"><item>8080</item><item>8081</item></ports>
|
|
||||||
<optional type="null"/>
|
|
||||||
</config>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
XML 必须使用一个 `<config>` 根;普通叶节点按目标 Go 类型转换。数组显式 `type="array"` 加 `<item>`,不根据重复元素猜测类型。`type="array"` 空元素表示空数组,`type="object"` 表示空对象,`type="null"` 表示 null。`any` 中的 XML 叶节点默认是字符串,需要动态数字/布尔时标注 `type="number"` / `type="bool"`;也可用 `type="string"`。
|
XML 使用 `<config>` 根,标量占位符写在普通叶元素或 `type="string"` 元素中,由 Go 字段决定类型;显式 `type="number"` / `type="bool"` 表示文件里已经有合法数字/布尔常量。XML 数组用 `type="array"` 加 `<item>`;空对象 `type="object"`、null 用 `type="null"`。不从重复元素猜测数组。
|
||||||
|
|
||||||
类型和覆盖规则:
|
`.api` 不经过配置加载器,响应模板 `${code}` 不变。**如果把响应模板写在 YAML/JSON/XML 配置文件里,须转义:**
|
||||||
|
|
||||||
- 缺失字段保留默认值;显式 0、false、空字符串正常覆盖。
|
```yaml
|
||||||
- null 清空指针、map、slice、interface;普通值字段保持默认值,沿用 Go JSON 解码语义。需要区分 null 时用指针。
|
server:
|
||||||
- 数组整体替换;空 slice 保持非 nil;固定长度 Go 数组要求长度一致,避免丢弃多余配置。
|
response:
|
||||||
- map 合并键,已提供的键整体覆盖对应值;map 中未提供的键保留。
|
body:
|
||||||
- duration 支持 `250ms`、`3s`;整数保持精度,动态 JSON 数字使用 `json.Number`,不先转 float64。
|
code: "$${code}"
|
||||||
- 自定义 `json.Unmarshaler` / `encoding.TextUnmarshaler` 仍可接入。这些回调是可信项目代码,必须自己保证并发和副作用安全;不要修改共享外部状态。
|
msg: "$${message}"
|
||||||
- JSON `,string` 字段、嵌入字段遵循 JSON 字段选择;未知字段、重复绑定、冲突均拒绝。
|
data: "$${data}"
|
||||||
|
|
||||||
内置解析器限制:单文件最多 1 MiB,解析最多 64 层、100000 个节点;绑定和默认值复制也有深度限制。只接受普通文件,不读取设备或命名管道。不支持多文档、重复字段、无效 UTF-8;YAML 拒绝 alias/merge/custom tag,XML 拒绝 DTD/外部实体、命名空间、业务属性和混合内容。XML 仅支持配置所需的子集,不是通用 XML 文档映射器。
|
|
||||||
|
|
||||||
这些限制针对启动配置,避免意外超大文件、别名扩展和模糊覆盖。自定义值解码器的内部字段校验由该解码器负责;例如现有错误目录使用其专有解码规则。
|
|
||||||
|
|
||||||
## 环境覆盖
|
|
||||||
|
|
||||||
统一入口的 EnvOptions 控制前缀、空值策略、别名和 Lookup。完整 JSON 路径自动转换为 UPPER_SNAKE_CASE:`client.timeout` → `APP_CLIENT_TIMEOUT`。
|
|
||||||
|
|
||||||
```go
|
|
||||||
config.EnvOptions{
|
|
||||||
Prefix: "GO_OMNI_", Empty: "ignore",
|
|
||||||
Aliases: config.ServerEnvAliases("GO_OMNI_", "server"),
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
生成项目同时接受完整的 `GO_OMNI_SERVER_HTTP_READ_TIMEOUT` 与短别名 `GO_OMNI_HTTP_READ_TIMEOUT`。完整名存在时优先,即便其空值被 ignore,也不回退别名。不会再二次调用 ApplyEnv 覆盖业务字段。
|
加载后恢复为 `${code}` 等响应占位符,仍由响应编码器处理。
|
||||||
|
|
||||||
显式 `env:"CUSTOM_NAME"` 是绝对名称;`env:"-"` 跳过子树。`json:"-"` 只在声明显式 env 名时参与,可让密钥只能从环境加载。`secret` 标签表达用途,解析错误对所有字段都不打印值。slice/map 的环境值采用 JSON。`required` 检查字符串非空、指针/集合非 nil;复杂范围由 Validate 负责。详见 [完整业务环境覆盖](DEVELOPMENT.md#完整业务配置的环境覆盖)。
|
## 生成项目与检查
|
||||||
|
|
||||||
显式环境选择日志 stdout/stderr 会清除文件 profile 的 File/Rotation,除非同时提供非空 File 覆盖。该兼容规则由框架处理。
|
| 文件 | 维护方 |
|
||||||
|
| --- | --- |
|
||||||
|
| `main.go`、`internal/config/loadGen.go` | 生成器维护,纳入 `generate --check` |
|
||||||
|
| `internal/config/config.go` | 项目维护 Config、Default、Validate |
|
||||||
|
| `etc/config.yaml` 等选定文件 | 项目维护,重新生成不覆盖 |
|
||||||
|
| `internal/config/loader.go` | 初次生成即可使用,仅添加自定义格式时才修改 |
|
||||||
|
|
||||||
## 扩展解析器
|
```sh
|
||||||
|
go run . -config etc/config.test.yaml -check-config
|
||||||
|
go run . -config etc/config.local.yaml
|
||||||
|
go build .
|
||||||
|
go-omni api generate --check
|
||||||
|
```
|
||||||
|
|
||||||
内置格式直接可用。新增格式只在 `internal/config/loader.go` 的 `NewLoader` 注册:
|
`-check-config` 使用与启动完全相同的 Load,通过时退出 0、失败时非零;不打印配置,不创建日志文件、数据库、后台组件或 HTTP 监听。它只校验配置,不检测真实数据库连接或证书握手。
|
||||||
|
|
||||||
|
`-config` 只选一个文件,不自动叠加 local/test/default。指定文件不存在时报错;`-config ''` 只使用 Default() 后校验,不读取任何环境变量,也不寻找其他文件。示例:[examples/configuration](examples/configuration/README.md)。
|
||||||
|
|
||||||
|
## 安全诊断与限制
|
||||||
|
|
||||||
|
```text
|
||||||
|
config "etc/config.yaml":7:9 env "DB_PORT" field "database.port": expected a valid int
|
||||||
|
```
|
||||||
|
|
||||||
|
`*config.Error` 包含 File/Field/Env/Line/Column/Reason。引用错误同时保留文件与环境变量名;组合引用失败可列出多个名字。行列定位原文件字段,不把环境值伪装成源文件文本。无法取得位置时仍提供可用的文件/字段信息。
|
||||||
|
|
||||||
|
用 `config.Invalid(field, publicReason)` 或 `config.AtField(prefix, err)` 报业务校验错误。未知字段给出唯一且接近的已声明字段建议,不自动修改。错误默认不打印原值;自定义 Reason 不应拼入秘密值。原始 cause 仍支持 errors.Is/As,不要把 Unwrap 后的错误或整个配置写到日志。
|
||||||
|
|
||||||
|
文件最多 1 MiB;单个环境值/展开字段最多 1 MiB,展开字符串合计/变量缓存分别最多 4 MiB;解析/展开限制 64 层、100000 个节点与引用计数。拒绝无效 UTF-8、多文档、重复字段、未知字段、非普通文件;YAML 不接受 alias/merge/custom tag,XML 不接受 DTD/外部实体。不执行 shell,不自动加载 `.env`。
|
||||||
|
|
||||||
|
## 框架模块的字段诊断
|
||||||
|
|
||||||
|
v0.23 起 MySQL(含连接池/TLS)、通用数据库池、日志(含轮转/时区)、HTTP 客户端、退出阶段和请求策略返回字段级安全原因。沿用项目已有的 `config.AtField("database", err)` / `config.AtField("server", err)`,自动组合嵌套字段,不需业务重复校验基础参数。
|
||||||
|
|
||||||
|
例如文件中 `database.port: "${DB_PORT}"` 的引用值为 70000:
|
||||||
|
|
||||||
|
```text
|
||||||
|
config "etc/config.yaml":3:9 env "DB_PORT" field "database.port": must be between 1 and 65535
|
||||||
|
```
|
||||||
|
|
||||||
|
日志级别非法指向 `server.log.level`;池参数指向 `database.pool.maxIdleConns` / `pingTimeout`。框架只公开已定义的安全原因,第三方/项目普通 error 仍显示 validation failed 并保留 errors.Is/As 原因链;业务使用 Invalid/AtField 提供公开原因。不要解析错误字符串;使用 `*config.Error` 字段。未提供字段的位置可能不可用,字段与原因仍保留。
|
||||||
|
|
||||||
|
生成项目现有 Load 和 `-check-config` 自动获得这些诊断。日志可在 `server.log.timeZone` 配置 IANA 名称,见 [实例时区](logx/README.md#实例时区)。
|
||||||
|
|
||||||
|
## 可选解析器与测试来源
|
||||||
|
|
||||||
|
内置格式无需项目手写加载器。需要额外格式时在已有 NewLoader 注册 Decoder,后续仍自动经过同一引用/绑定/校验流程:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func NewLoader() (*omniConfig.Loader, error) {
|
func NewLoader() (*omniConfig.Loader, error) {
|
||||||
return omniConfig.NewLoader(
|
return omniConfig.NewLoader(
|
||||||
omniConfig.WithDecoder("custom", omniConfig.DecoderFunc(
|
omniConfig.WithDecoder("custom", omniConfig.JSONDecoder{}),
|
||||||
func(data []byte) (omniConfig.Document, error) {
|
|
||||||
// 演示注册扩展名;实际可在这里调用项目选定的 TOML 等解析库。
|
|
||||||
return (omniConfig.JSONDecoder{}).Decode(data)
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Decoder 返回 Document:Value 是 map[string]any 根,子值为 string/bool/json.Number/nil/[]any/map[string]any;无自身类型的叶节点可返回 config.Text。Positions 可选,用 `client.timeout` 或 `servers[0].timeout` 标记 1-based 行列。不提供位置时仍有文件、字段、原因。
|
Decoder 返回 Document(map/string/bool/json.Number/nil/[]any,XML 类标量可用 Text),可提供 Positions。实例注册无全局 init;插件必须可并发调用、不修改共享输入,并且是可信 Go 代码。
|
||||||
|
|
||||||
自己的语法错误可返回 `&config.Error{Field: "client.timeout", Line: 3, Column: 5, Reason: "expected a duration"}`。Reason 必须安全;普通第三方错误仅公开 `invalid configuration syntax`,内部 cause 通过 errors.Is/As 保留。
|
测试或嵌入宿主可以用 `WithLookupEnv(func(string)(string,bool))` 替换引用查找函数。它仍然只读取文件明确引用的名称,不启用任何自动覆盖。普通业务不需要这个选项。
|
||||||
|
|
||||||
注册表属于 Loader 实例,无全局 init 注册;不能覆盖已有扩展名。Loader 可共享,但 Decoder 必须支持并发且不保留/修改输入。解析插件是可信 Go 代码,框架不是插件沙箱。新格式依赖由选择它的项目显式引入,核心不强制引入 TOML 等库。
|
## v0.22 迁移:旧模式已移除
|
||||||
|
|
||||||
## 定位配置错误
|
这是不兼容调整,不保留两套加载逻辑:
|
||||||
|
|
||||||
```text
|
- 删除 `EnvOptions`、`LoadFromEnv`、`ApplyEnv`、`OverlayEnv`、`ApplyDefaults`、`ServerEnvAliases` 和 `env`/`envDefault` 标签处理;检出遗留标签会提示迁移。
|
||||||
config "etc/config.yaml":4:12 field "client.timeout": expected a duration such as 5s
|
- 删除 `database.ConfigFromEnv` / `database.ApplyEnv`;数据库配置作为项目 Config 的字段绑定。DSN 字段可在文件里使用 `dsn: "${DB_DSN}"`,序列化 database.Config 不输出 DSN 原值。
|
||||||
config env "APP_CLIENT_TIMEOUT" field "client.timeout": expected a valid time.Duration
|
- `LoadProject(path, defaults[, loader])` 不再接收 EnvOptions;`LoadFile(path)`、`NewReloader(path, logger)` 不再接收前缀。
|
||||||
```
|
- `omni.LoadConfig(path)` 的参数现在是文件路径;空路径只用框架默认值。
|
||||||
|
|
||||||
`errors.As(err, &diagnostic)` 可获取 `*config.Error` 的 File/Field/Env/Line/Column/Reason;原 `DecodeError` 名称保留为别名。`errors.Is` 可判断文件不存在等 I/O 原因。
|
迁移顺序:
|
||||||
|
|
||||||
解析失败显示文件和可获取的行列;Validate 使用 Invalid/AtField 才能定位业务字段。环境覆盖导致的错误优先标环境变量,不错误地归因到文件;数组元素验证也会追溯到覆盖它的集合环境变量。默认值未出现在文件里时没有行列。YAML 语法错误通常只有行号,列显示 1。
|
1. 将 envDefault 默认值搬入 Default(),移除 env/envDefault 标签。需要文件绑定的旧 `json:"-"` 密钥字段改成明确 json 名称;不打印配置对象。
|
||||||
|
2. 按实际部署需要,在选定文件写环境引用。固定值保持固定,不按原前缀猜测。原先依赖环境切换日志输出的项目,应在文件明确配置 file/output/rotation。
|
||||||
|
3. 配置文件中的其他 `${...}` 模板改成 `$${...}` 字面量;`.api` 无需修改。
|
||||||
|
4. 手写项目改为 LoadProject。生成项目保留 Config/Default/Validate/NewLoader,删除旧手写 Load 后重新生成;main/loadGen 由生成器更新,业务配置文件不会被静默改写。
|
||||||
|
5. 升级匹配的 CLI 与框架,执行 generate、generate --check、go test、go build 和 `-check-config`,再使用真实部署配置启动。
|
||||||
|
|
||||||
不打印底层错误原文或配置值;任意普通 `errors.New(...)` 校验失败的公开原因是 `validation failed`,内部原因仍在链中。项目应改用 Invalid 给出安全的原因。第三方子配置校验若没有字段元数据,只定位到对应子树,框架不会从任意错误文案猜字段。
|
低层 `DecodeFile(path, defaults[, loader])` 也解析显式引用,但不调用 Validate;默认值和原始 Document 都不会因失败而被部分修改。日志 Reloader 仍只允许 log.level 变化,其他静态字段变更要求重启。
|
||||||
|
|
||||||
不要把 Unwrap 后的原始错误、整个配置对象写入公开日志/HTTP;自定义解析器与校验器传入的 Reason 由项目负责脱敏。
|
## 国际化配置
|
||||||
|
|
||||||
## v0.19 及更早项目迁移
|
通用国际化的 `i18n.Config` 可作为项目配置的字段显式加载并调用 Validate。正文词典使用 `i18n.LoadFS` / `DecodeJSON`,不经过环境引用展开。构建 Bundle 后通过 `WithLanguagePolicy` / `WithErrorLocalizer` 装配借用的只读实例;没有隐式环境变量或全局当前语言。见 [国际化指南](i18n/README.md) 和 [完整示例](examples/i18n/README.md)。
|
||||||
|
|
||||||
先提交现有代码,再升级匹配的 CLI 与框架。旧项目的 `internal/config/config.go` 含手写 Load,新生成器会明确阻止 generate/check/dry-run,不会直接覆盖它。
|
## 跨域配置
|
||||||
|
|
||||||
1. 保留 Config、Default、Validate、业务配置结构及辅助函数。
|
生成项目的 `server.cors` 由本加载流程直接绑定、校验并交给 App,不需要修改生成入口。支持固定来源数组或元素内显式环境引用;非法来源定位具体数组元素。默认关闭,完整配置与行为见 [CORS 指南](cors/README.md)。
|
||||||
2. 删除旧 Load。普通的 defaults/file/env/Validate 流程已由 LoadProject 接管;移除由此不再使用的 import。将自定义默认值和校验分别迁入 Default / Validate,自定义文件格式迁入 NewLoader。
|
|
||||||
3. 框架只在 `loader.go` 不存在时创建 NewLoader;已有项目文件同名时需要保证它提供同签名函数。无需手写 loadGen.go。
|
|
||||||
4. 重新生成,执行 `generate --check`、`go test ./...`、`go build .`,再用真实配置验证启动。
|
|
||||||
|
|
||||||
保留业务需要的显式 env 标签/规则;如果原 Load 有解密或其他项目特殊步骤,不要直接删除,应先明确其归属,必要时在自定义 Decoder 中处理。生成器不自动搬动或猜测手写行为。`api upgrade` 仍可预览较早版本的基础设施差异,但已修改的配置必须按上述步骤迁移。
|
|
||||||
|
|
||||||
不会移除低层配置 API;本次移除的是**新项目里重复手写的加载流程**,不删除旧项目的业务文件。
|
|
||||||
|
|
||||||
完整可运行例子:[examples/configuration](examples/configuration/README.md)。
|
|
||||||
|
|||||||
25
COPY.md
25
COPY.md
@@ -28,6 +28,31 @@ if err := copyx.Copy(&input, req); err != nil { return err }
|
|||||||
|
|
||||||
新字段:普通接口只改 `.api` 和使用该字段的 Logic。独立 DTO/业务类型都需要此字段时,两边各声明一次;同名同类型不增加拷贝配置。检查在运行时发生,实际类型对必须有测试,不能把运行时工具当作旧生成器的编译期字段覆盖检查。
|
新字段:普通接口只改 `.api` 和使用该字段的 Logic。独立 DTO/业务类型都需要此字段时,两边各声明一次;同名同类型不增加拷贝配置。检查在运行时发生,实际类型对必须有测试,不能把运行时工具当作旧生成器的编译期字段覆盖检查。
|
||||||
|
|
||||||
|
## 类型预检
|
||||||
|
|
||||||
|
v0.23 新增可选预检,无需填写映射文件或构造测试值:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 参数方向是源 → 目标;与 Copy 使用同一组选项。
|
||||||
|
if err := copyx.Check[Request, BusinessInput](options...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 复用已有 Copier:传结果类型,不添加 Copy 所需的额外输出指针。
|
||||||
|
err := copier.Check(reflect.TypeFor[Request](), reflect.TypeFor[BusinessInput]())
|
||||||
|
```
|
||||||
|
|
||||||
|
适合 CI 测试或初始化检查;这是程序执行的类型检查,不是 Go 编译器自动证明。递归检查切片/数组元素、map 键值、指针、嵌套字段、改名、忽略和严格覆盖;用已访问类型对处理递归结构。不执行 converter,也不调用构造器,不读取实例值。错误路径如 `$.Rows[].Value: copyx: incompatible types: string -> int`。
|
||||||
|
|
||||||
|
Check 复用 Copy 的规则,包括浅拷贝对同类型容器的直接赋值:这种情况下内部 converter 不会自动逐项执行。需要处理内部字段时选择深拷贝或容器 converter。
|
||||||
|
|
||||||
|
- 未知具体值的源 interface 需要运行时验证;无法静态判定时返回 `ErrDynamicType`。项目可给该接口类型对提供精确 converter 并承担内容验证。
|
||||||
|
- 已注册 converter 的签名被接受,但函数不在 Check 中执行;其返回错误、panic、金额规则不能靠预检证明。
|
||||||
|
- nil 到非可空目标、map 键转换碰撞、深拷贝结果是否残留源引用继续在 Copy 时检查。Check 成功不会关闭这些保护。
|
||||||
|
- 实际 Copy 也修正了空集合的检查缺口:确定的嵌套类型不匹配不再因为切片/map 为空或数组长度为 0 而通过。需要这些转换时提供显式 converter;目标仍只在整体成功后更新。
|
||||||
|
- 不引入全局配置、映射语言或业务类型;Copier.Check 可并发调用,不共享每次检查的遍历状态。
|
||||||
|
|
||||||
|
示例:[developerTools](examples/developerTools/README.md)。普通同名同类型字段不需要额外配置。
|
||||||
|
|
||||||
## 深拷贝与构造器
|
## 深拷贝与构造器
|
||||||
|
|
||||||
```go
|
```go
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# 通用业务开发指南(v0.21.0-preview)
|
# 通用业务开发指南(v0.30.0-preview)
|
||||||
|
|
||||||
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
|
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
|
||||||
|
|
||||||
@@ -35,6 +35,30 @@ delete /item
|
|||||||
|
|
||||||
基础类型数组还支持 int/int8/int16/int32、uint/uint16/uint32/uint64、float32/float64。nil 根数组都输出 `[]`;`[]any` 在 OpenAPI 中是 `type: array, items: {}`,不限制元素形状,但元素必须可被标准 JSON 编码器序列化。`[]uint8`(Go 的 byte slice)会编码为 base64,故明确拒绝;数字数组可用 `[]int32`,二进制用下载接口。当前不支持根嵌套数组或根自定义 scalar 数组。
|
基础类型数组还支持 int/int8/int16/int32、uint/uint16/uint32/uint64、float32/float64。nil 根数组都输出 `[]`;`[]any` 在 OpenAPI 中是 `type: array, items: {}`,不限制元素形状,但元素必须可被标准 JSON 编码器序列化。`[]uint8`(Go 的 byte slice)会编码为 base64,故明确拒绝;数字数组可用 `[]int32`,二进制用下载接口。当前不支持根嵌套数组或根自定义 scalar 数组。
|
||||||
|
|
||||||
|
## 递归响应 DTO
|
||||||
|
|
||||||
|
v0.28.2 起,响应 DTO 可引用自身或相互引用;名字与字段由项目定义:
|
||||||
|
|
||||||
|
```text
|
||||||
|
type Node {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Children []Node `json:"children"`
|
||||||
|
Next *Node `json:"next,omitempty"`
|
||||||
|
Lookup map[string]Node `json:"lookup,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
@handler tree
|
||||||
|
get /tree returns (Node)
|
||||||
|
```
|
||||||
|
|
||||||
|
Go 类型保留 `[]Node`、`*Node`、`map[string]Node`,不降级为 `any`。生成器将递归响应图中的命名 DTO 写入 `components.schemas.DTO_<类型名>`,字段使用 `$ref`;对象、数组、可空根对象、raw 及自定义响应模板都支持。可空引用使用 `anyOf` 的引用与 null 分支,描述和示例通过 `allOf` 保留,遵循 [OpenAPI 3.0.3 引用规则](https://spec.openapis.org/oas/v3.0.3.html#reference-object)。
|
||||||
|
|
||||||
|
字段命名、标签、约束、重复字段与示例继续校验,自定义 scalar 使用声明的输出类型。有限 JSON 示例按实际内容检查,仍受 32 层/10000 节点的编译限制。类型引用不会无限展开;再次生成和契约变更检查会遍历引用图并保留手写代码。
|
||||||
|
|
||||||
|
范围是 `.api` 的响应类型。递归请求 DTO(包括间接包含递归类型的请求)仍不支持,`contract.New/Describe` 与手写 `openapi.SchemaOf` 的限制不变;它们不会自动获得递归请求绑定能力。直接值递归如 `Child Node`、仅由值字段形成的相互递归,以及字段组合循环仍会报错。业务返回值必须是可序列化的有限数据,不能构造对象指针环。
|
||||||
|
|
||||||
|
升级 CLI 后,在项目自己的 `.api` 中将临时 `[]any` 恢复成 `[]项目类型`,执行 `go-omni api generate`、`go-omni api generate --check` 和 `go test ./...`。修改 `.api` 后应先生成再检查;不要手改生成的 DTO。不改变项目的菜单、分类、权限或数据构造规则。完整可运行步骤见 [递归响应示例](examples/recursiveResponse/README.md)。
|
||||||
|
|
||||||
## 契约驱动的请求策略
|
## 契约驱动的请求策略
|
||||||
|
|
||||||
入口文件可以声明默认策略,单个接口按需覆盖:
|
入口文件可以声明默认策略,单个接口按需覆盖:
|
||||||
@@ -100,44 +124,13 @@ defer reader.Close()
|
|||||||
|
|
||||||
不声明 multipart 字段时,保留原来的 `body *httpx.Body` 流式入口,不创建临时文件。该方式适合特殊协议;项目应在不可撤销副作用之前完成校验并调用 `body.Complete()`。下载和原始回调继续使用 `@produces` / `@body(mode: raw)`。
|
不声明 multipart 字段时,保留原来的 `body *httpx.Body` 流式入口,不创建临时文件。该方式适合特殊协议;项目应在不可撤销副作用之前完成校验并调用 `body.Complete()`。下载和原始回调继续使用 `@produces` / `@body(mode: raw)`。
|
||||||
|
|
||||||
## 完整业务配置的环境覆盖
|
## 文件声明配置来源
|
||||||
|
|
||||||
生成器维护 `internal/config/loadGen.go`;项目维护 Config/Default/Validate 和可选 NewLoader。三种格式、可扩展解析器、字段错误定位及旧 Load 迁移见 [统一配置指南](CONFIG.md)。
|
项目维护 Config、Default()、Validate() 和配置文件;生成器维护 Load/main。流程为默认值→选定文件→显式 `${NAME}` / `${NAME:-fallback}` 引用→类型绑定→校验。没有前缀、别名或 env/envDefault 隐式覆盖。
|
||||||
|
|
||||||
生成项目默认执行:代码与 `envDefault` 默认值 → JSON/YAML/XML 文件 → 全结构体环境覆盖(含别名)→ 规范化 → `Validate`。增加业务字段不需要另外写 `os.Getenv`。
|
固定值保持固定,缺失字段保留 Default(),0/false/空字符串正常覆盖;引用失败不回退初始化默认值。`$$` 转义 `$`,字符串凭据保留空格与特殊字符。
|
||||||
|
|
||||||
```go
|
`go run . -config etc/config.test.yaml -check-config` 只校验,不打开日志、数据库或端口。生成/check/dry-run 保留业务文件;新的模板要求 v0.22。完整格式、错误定位、解析器与移除清单见 [CONFIG.md](CONFIG.md)。
|
||||||
type ClientConfig struct {
|
|
||||||
Endpoint string `json:"endpoint" env:"CLIENT_ENDPOINT,required"`
|
|
||||||
Token string `json:"-" env:"CLIENT_TOKEN,required,secret,empty=reject"`
|
|
||||||
Timeout time.Duration `json:"timeout" envDefault:"3s"`
|
|
||||||
Retries []int `json:"retries"`
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
把字段加入项目 `Config` 后,默认环境名取 `GO_OMNI_` 加 JSON 路径的 UPPER_SNAKE_CASE,如 `GO_OMNI_CLIENT_TIMEOUT`。显式 env 名是完整名称,可以使用自己的业务前缀。`env:"-"` 排除整个子树;`json:"-"` 字段只有显式 env 标签才参与。
|
|
||||||
|
|
||||||
duration 使用 `250ms`、`3s`;slice/map 使用 JSON,如 `[1,3,5]`、`{"retry":"2s"}`。支持嵌套、零/false、空数组、自定义 TextUnmarshaler。空环境值默认允许;可选择 `empty=ignore` 或 `empty=reject`。required 要求字符串非空、指针/集合非 nil;0 和 false 可以是合法业务值,范围由项目 Validate 决定。
|
|
||||||
|
|
||||||
手写项目可使用 `config.LoadProject(path, defaults, config.EnvOptions{Prefix:"MY_APP_"})`,或分别调用 `ApplyDefaults/DecodeFile/OverlayEnv`。读取失败不修改输入默认值;诊断不会打印输入值。不要主动打印含密钥的整个配置,或把底层解析错误展开到公开响应。Validate 不打开日志文件、数据库或存储连接;证书的可读性和握手信任在依赖初始化阶段验证。
|
|
||||||
|
|
||||||
### 一次覆盖完整配置与 HTTP/日志短名称
|
|
||||||
|
|
||||||
```go
|
|
||||||
cfg, err = config.OverlayEnv(cfg, config.EnvOptions{
|
|
||||||
Prefix: "MY_APP_",
|
|
||||||
Empty: "reject", // allow / ignore / reject,项目自行选择
|
|
||||||
Aliases: config.ServerEnvAliases("MY_APP_", "server"),
|
|
||||||
// Lookup 可注入测试或项目配置源;默认 os.LookupEnv。
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
不要再紧接着调用 `ApplyEnv(cfg.Server, ...)`。上面的调用同时处理完整配置和 HTTP_ADDR、SERVICE、LOG_LEVEL 等框架短名称。例如 `MY_APP_SERVER_ADDRESS` 与 `MY_APP_HTTP_ADDR` 指向同一字段,**完整名称优先**;未设置时才读取短名称。完整名称显式设空仍优先:allow 交给该字段解析(string 可清空;数字等仍要求合法值),ignore 保留文件/默认值,reject 报错。不会因 ignore 再退回别名。字段 `env` 标签的 empty 规则优先于全局规则。
|
|
||||||
|
|
||||||
自定义别名用 `EnvOptions.Aliases`,键是完整环境名、值是按优先级排列的别名数组。两种名称都使用同一 Lookup、空值策略和类型转换;布尔 false、数值 0 与未设置不同。集合始终使用 JSON,包括 `MY_APP_TRUSTED_PROXIES='["127.0.0.1"]'`。
|
|
||||||
|
|
||||||
新项目模板一次调用 OverlayEnv,默认 `Empty: "ignore"`。旧 `ApplyEnv` 保留原有忽略空值、修剪空白及 TRUSTED_PROXIES 逗号列表行为。已有项目的 `internal/config` 属于开发者文件,升级不覆盖它;按上例合并原来的两次覆盖调用后,保留项目自己的 Validate。日志输出的显式完整名称也优先于短名称。
|
|
||||||
|
|
||||||
## 依赖、日志与中间件
|
## 依赖、日志与中间件
|
||||||
|
|
||||||
@@ -230,6 +223,31 @@ go-omni workspace check --file omni.workspace.json
|
|||||||
|
|
||||||
各服务保持独立 module;可显式填写各服务的 `api`(相对服务目录)和 `module`。所有服务预检通过后再写;每个服务独立提交文件事务,不承诺跨项目的崩溃原子性。check 汇总格式与生成漂移;业务编译/测试仍在各 module 执行。
|
各服务保持独立 module;可显式填写各服务的 `api`(相对服务目录)和 `module`。所有服务预检通过后再写;每个服务独立提交文件事务,不承诺跨项目的崩溃原子性。check 汇总格式与生成漂移;业务编译/测试仍在各 module 执行。
|
||||||
|
|
||||||
|
### 混合 API 与通用工程
|
||||||
|
|
||||||
|
v0.29.0-preview 起,workspace 支持在同一份清单中管理 API、通用工程,也支持仅包含通用工程的工作区:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"services": [
|
||||||
|
{ "dir": "apps/interface", "kind": "api" },
|
||||||
|
{ "dir": "apps/program", "kind": "project" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`kind` 可省略:根据 `.goOmni/apiManifest.json` 或 `.goOmni/projectManifest.json` 识别。没有清单时,沿用旧 API 解释,需显式提供 api 入口才能首次生成。两种清单同时存在、显式 kind 与已有清单冲突、未知 kind 均报错。project 类型不能填写 api;可选 module 用来核对工程身份,不会重写 go.mod。
|
||||||
|
|
||||||
|
| 命令 | API 工程 | 通用工程 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| workspace generate | API 生成器 | project 生成器 |
|
||||||
|
| workspace check | 契约格式检查 + API 生成漂移检查 | project 生成漂移检查 |
|
||||||
|
| workspace format | 格式化 API 契约 | 显示 SKIP,不修改 Go 文件 |
|
||||||
|
|
||||||
|
检查会继续处理独立工程并汇总错误,任一失败返回非零。生成/格式化先预检全部目标;预检失败时输出 FAIL 与其他工程的 SKIP,不写任何生成结果。清单语法、目录越界/重叠或共享目录提供方失败属于工作区级错误,会直接中止。已有文件保护与路径规则保持,事务仍以单个工程为单位。
|
||||||
|
|
||||||
|
workspace 不创建或修改 go.work、go.mod、go.sum,不替代 Go 编译和业务测试。它只调用生成器;显式 errorCatalogs 导出仍按下节约定执行 Go 提供方,配置类型不会额外启动服务。完整 [混合工程与 go.work 示例](examples/mixedWorkspace/README.md)。
|
||||||
|
|
||||||
### 同一份 Go 错误目录供多个服务生成
|
### 同一份 Go 错误目录供多个服务生成
|
||||||
|
|
||||||
在 workspace 清单显式声明提供目录的 Go 包和函数:
|
在 workspace 清单显式声明提供目录的 Go 包和函数:
|
||||||
@@ -280,7 +298,7 @@ go-omni workspace check --file omni.workspace.json
|
|||||||
|
|
||||||
## v0.13 项目升级
|
## v0.13 项目升级
|
||||||
|
|
||||||
先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.20.0-preview(或显式引用当前源码)。
|
先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.22.0-preview(或显式引用当前源码)。
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go-omni api upgrade --dir . --dry-run
|
go-omni api upgrade --dir . --dry-run
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ app.GET("/metrics", measurements.Handler())
|
|||||||
// 也可以直接读取 measurements.Snapshot()。
|
// 也可以直接读取 measurements.Snapshot()。
|
||||||
```
|
```
|
||||||
|
|
||||||
记录请求数、失败数、处理中数量、累计耗时与 HTTP 状态分布;返回的是 JSON 快照,并非 Prometheus 文本格式。指标接口是否暴露以及访问权限由项目配置。需要 HTTP/SQL/Redis spans 和标准 OTel exporter 时,使用 [telemetry 示例](examples/platform/main.go)。CORS 由项目中间件或代理配置,当前没有内置 CORS 模块。
|
记录请求数、失败数、处理中数量、累计耗时与 HTTP 状态分布;返回的是 JSON 快照,并非 Prometheus 文本格式。指标接口是否暴露以及访问权限由项目配置。需要 HTTP/SQL/Redis spans 和标准 OTel exporter 时,使用 [telemetry 示例](examples/platform/main.go)。需要跨域时配置 `server.cors`,由框架在路由与鉴权前处理预检;默认关闭。已有代理 CORS 可继续使用,避免两处重复设置。见 [CORS 配置与示例](cors/README.md)。
|
||||||
|
|
||||||
## 缓存:cache 与 cache/redis
|
## 缓存:cache 与 cache/redis
|
||||||
|
|
||||||
|
|||||||
81
PROGRAM.md
Normal file
81
PROGRAM.md
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# 通用 Go 项目模板
|
||||||
|
|
||||||
|
从 v0.28.0-preview 起,`project new/generate/check` 提供不绑定协议与业务的工程骨架。默认只有配置、日志、依赖装配、生命周期和普通 Go 运行入口;不生成 `.api`、HTTP Handler、Swagger、业务任务,也不监听端口、连接数据库或队列。不增加第三方依赖。
|
||||||
|
|
||||||
|
## 创建与运行
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.30.0-preview
|
||||||
|
go-omni project new demo --module example.com/demo
|
||||||
|
cd demo
|
||||||
|
go mod tidy
|
||||||
|
go run . -check-config
|
||||||
|
go run . # 默认等待 SIGINT / SIGTERM
|
||||||
|
go build -o program .
|
||||||
|
```
|
||||||
|
|
||||||
|
目标目录必须为空;未指定 module 时使用目录名。远程安装的 CLI 将自己的固定版本写入 go.mod,无本地替换。当前通用模板要求框架 v0.28.0-preview 或更新版本。源码开发可显式 `--framework-dir /path/to/go-omni`,不会从当前工作目录隐式选择本地框架。`--framework-version` 可指定兼容版本。框架版本、依赖更新由项目修改 go.mod,重新生成不会代为升级依赖。
|
||||||
|
|
||||||
|
常驻程序、一次性程序用同一份骨架:项目自己的 `Run` 决定何时完成。完整生成项目见 [examples/program](examples/program/README.md),不需要外部服务。
|
||||||
|
|
||||||
|
## 文件职责
|
||||||
|
|
||||||
|
| 路径 | 维护方与职责 |
|
||||||
|
| --- | --- |
|
||||||
|
| `main.go` | 生成器:信号、启动、运行入口监督、退出状态与日志收尾 |
|
||||||
|
| `internal/config/loadGen.go` | 生成器:统一配置加载 |
|
||||||
|
| `internal/svc/runtimeGen.go` | 生成器:依赖初始化及失败收尾 |
|
||||||
|
| `internal/config/config.go` | 项目:Config、Default、Validate;初始只含 log/lifecycle |
|
||||||
|
| `internal/config/loader.go` | 项目:按需注册额外文件格式 |
|
||||||
|
| `etc/config.yaml` | 项目:配置与显式环境引用 |
|
||||||
|
| `internal/svc/serviceContext.go` | 项目:依赖字段、Init、资源与后台工作登记 |
|
||||||
|
| `internal/run/run.go` | 项目:Run 的实际实现 |
|
||||||
|
| `go.mod`、README、.gitignore、其他 Go 文件 | 项目:生成器不覆盖 |
|
||||||
|
| `.goOmni/projectManifest.json` | 生成器:工程身份、模板版本、生成文件哈希;应提交 Git |
|
||||||
|
|
||||||
|
配置遵守 [CONFIG.md](CONFIG.md):默认值 → 文件显式 `${NAME}` / `${NAME:-fallback}` → 类型绑定 → 校验,没有隐式环境覆盖。JSON/YAML/XML 内置;`-config ""` 只用默认值。`-check-config` 不打开日志文件、不初始化依赖、不调用 Run。日志通过独立实例传给 svc,并注入 Context/WorkContext;不替换进程级默认日志实例。
|
||||||
|
|
||||||
|
## 运行与退出
|
||||||
|
|
||||||
|
```go
|
||||||
|
func Run(ctx context.Context, deps *svc.ServiceContext) error {
|
||||||
|
// 一次性程序可完成处理后直接 return nil。
|
||||||
|
// 常驻程序收到 ctx.Done() 后停止自己的主流程。
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`nil` 表示正常完成,返回 error 表示运行失败。后台组件用 `RequestShutdown(err)` 报告致命故障,`RequestShutdown(nil)` 请求正常停止。框架不替项目重试、重新调度或重启任务。
|
||||||
|
|
||||||
|
依赖在 `svc.Init` 中装配:创建后立即用 `OnClose` 登记清理,后台组件同时登记同名 `OnStop` 和 `OnDrain`。所有登记应在 Init 返回前完成;Run 不得并发追加生命周期登记。组件何时接收工作由项目决定,停止后不得重新开始领取或启动未登记的工作。
|
||||||
|
|
||||||
|
- `Context()` 是提前停止通知,用来停止主流程或接收新工作。
|
||||||
|
- `WorkContext()` 在正常排空期间仍有效,用于已接受的工作。
|
||||||
|
- `OnStop` 停止接收新工作,不关闭处理中的工作仍依赖的资源。
|
||||||
|
- `OnDrain` 返回 nil,必须意味着组件处理、确认和 defer 收尾均已结束。仅返回 `ctx.Err()` 不能证明结束。
|
||||||
|
- 全部停止钩子先于全部排空钩子;同阶段并行。有先后依赖的组件在一个钩子内显式排序。
|
||||||
|
- 全部工作确认结束后,资源按 `OnClose` 登记的反序释放,最后关闭入口拥有的日志。
|
||||||
|
|
||||||
|
生成入口自动监督 Run 本身,以 `go-omni/project/run` 保留名称登记完成屏障。收到信号、Run 返回或报告故障后开始收尾,同时等待 Run 及其他已登记工作。Run 不应等待资源关闭:资源关闭发生在 Run 完成之后。
|
||||||
|
|
||||||
|
初始化失败同样收尾已创建的资源和已启动的工作。Init/Run 内 panic 被转为失败,安全收尾后进程以非零状态退出;任意自建 goroutine 的 panic 仍由项目负责处理。Run 不响应取消或其他工作无法确认结束时,`lifecycle.stopTimeout/drainTimeout/cancelTimeout` 限定等待窗口,返回 `ErrResourcesRetained`,跳过共享资源和日志关闭,以失败状态退出。取消后的工作不能被 Go 强制终止,保留资源不代表工作已经完成。SIGKILL 和进程崩溃不会执行退出钩子。
|
||||||
|
|
||||||
|
详见 [生命周期契约](lifecycle/README.md)。现成组件必须按其真实停止/完成语义适配,不能把某个 `Run/Close` 返回直接视为业务结束。
|
||||||
|
|
||||||
|
## 生成检查与兼容
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go-omni project generate --dry-run
|
||||||
|
go-omni project generate
|
||||||
|
go-omni project check # 等价于 project generate --check
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
生成器不执行项目 Go 代码、不下载依赖。check/预览只读,不创建锁文件;生成时使用独立项目锁、路径和哈希预检。生成文件被手工修改会报冲突,不提供强制覆盖。删除的生成文件可补回;缺失的项目所有文件必须显式恢复,避免把删除的业务文件当成空白骨架重建。
|
||||||
|
|
||||||
|
每个文件通过原子替换写入,普通写入失败回滚此前变更;整个目录不具备崩溃事务性。生成期间不要同时手工编辑。发现遗留 project.lock 时,先确认没有生成进程再手工移除。
|
||||||
|
|
||||||
|
check 检查模板漂移和必要文件,不检查业务正确性,也不能替代 Go 编译与测试。新的模板版本会重新生成受管文件;不自动合并项目所有文件或更新 go.mod。未知的新清单版本会要求使用兼容 CLI。
|
||||||
|
|
||||||
|
原有 `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 或模块依赖。
|
||||||
14
PROJECT.md
14
PROJECT.md
@@ -1,6 +1,8 @@
|
|||||||
# 生成项目与文件维护边界
|
# API 生成项目与文件维护边界
|
||||||
|
|
||||||
v0.21.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
|
通用 Go 工程使用独立的 `project new/generate/check`,见 [PROGRAM.md](PROGRAM.md)。本文说明 API 契约工程。
|
||||||
|
|
||||||
|
v0.28.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
|
||||||
|
|
||||||
## 创建和日常开发
|
## 创建和日常开发
|
||||||
|
|
||||||
@@ -98,7 +100,7 @@ func (c *ServiceContext) AppOptions() []omni.Option {
|
|||||||
|
|
||||||
最终配置校验包含自定义响应编码器与错误目录的组合。码类型/映射冲突、编码器已绑定另一份目录等问题在创建 App 前返回错误,进入入口的清理流程,不等到构造 App 时才 panic。
|
最终配置校验包含自定义响应编码器与错误目录的组合。码类型/映射冲突、编码器已绑定另一份目录等问题在创建 App 前返回错误,进入入口的清理流程,不等到构造 App 时才 panic。
|
||||||
|
|
||||||
日志在 AppOptions 前初始化,其配置应放在 Config 中。退出信号先触发 HTTP 排空;达到 shutdownTimeout 后按框架规则强制关闭连接。请求仍依赖的后台任务/客户端不能直接绑定信号 context 提前关闭,应持有独立生命周期,使用 WorkContext 并登记成对的 OnStop/OnDrain,详见 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭)。业务必须响应请求 context 取消;忽略取消的 goroutine 无法保证在超时前结束。
|
日志在 AppOptions 前初始化,其配置应放在 Config 中。退出信号先触发 HTTP 排空;达到 shutdownTimeout 后取消请求并关闭连接,再按 `http.shutdownCancelTimeout` 等待处理函数收尾。仍未结束时保留依赖并返回 `ErrResourcesRetained`,不会提前关闭数据库。请求仍依赖的后台任务/客户端不能直接绑定信号 context 提前关闭,应持有独立生命周期,使用 WorkContext 并登记成对的 OnStop/OnDrain,详见 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭)。业务必须响应请求 context 取消;忽略取消的 goroutine 无法保证在超时前结束。
|
||||||
|
|
||||||
## 依赖主动申请退出
|
## 依赖主动申请退出
|
||||||
|
|
||||||
@@ -109,7 +111,7 @@ func (c *ServiceContext) AppOptions() []omni.Option {
|
|||||||
c.RequestShutdown(err)
|
c.RequestShutdown(err)
|
||||||
```
|
```
|
||||||
|
|
||||||
这只是通知,立即返回,不等待 HTTP 排空,也不直接关闭数据库。生成入口停止接收新请求,等待在途请求完成;超过 `shutdownTimeout` 后取消请求并关闭连接,再执行 svc.Close,最后关闭日志。非空故障原因保留在返回错误链中,进程退出码为 1;`RequestShutdown(nil)` 或正常 SIGINT/SIGTERM 在没有其他故障时退出码为 0。
|
这只是通知,立即返回,不等待 HTTP 排空,也不直接关闭数据库。生成入口停止接收新请求,等待在途请求完成;超过 `shutdownTimeout` 后取消请求并关闭连接,再等待 HTTP 取消收尾窗口。通过 `svc.Finish(runErr)` 执行后台停机;确认工作结束才关闭依赖与日志,无法确认时保留资源并非零退出。非空故障原因保留在返回错误链中,进程退出码为 1;`RequestShutdown(nil)` 或正常 SIGINT/SIGTERM 在没有其他故障时退出码为 0。
|
||||||
|
|
||||||
多个依赖可并发通知,关闭流程仅执行一次,首个非空故障原因保留。正常信号已开始排空时收到的故障也不会被清理动作覆盖。初始化期间的通知同样有效,不依赖 HTTP 监听已经启动。后台监控任务应通过 OnStop/OnDrain 停止并等待,再进入资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。
|
多个依赖可并发通知,关闭流程仅执行一次,首个非空故障原因保留。正常信号已开始排空时收到的故障也不会被清理动作覆盖。初始化期间的通知同样有效,不依赖 HTTP 监听已经启动。后台监控任务应通过 OnStop/OnDrain 停止并等待,再进入资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。
|
||||||
|
|
||||||
@@ -143,7 +145,7 @@ server:
|
|||||||
|
|
||||||
`responseMode: raw` 返回 `{"status":"ready"}` 等健康数据,有依赖时包含检查名称、结果与耗时;不包装公共体。存活检查不执行依赖探针;服务开始退出后,就绪检查返回 503,存活仍可返回 200,直到监听关闭。OpenAPI 始终输出规范文档本身,不使用业务响应包装;关闭 HTTP 文档端点不影响生成 `docs/openapi.json`。
|
`responseMode: raw` 返回 `{"status":"ready"}` 等健康数据,有依赖时包含检查名称、结果与耗时;不包装公共体。存活检查不执行依赖探针;服务开始退出后,就绪检查返回 503,存活仍可返回 200,直到监听关闭。OpenAPI 始终输出规范文档本身,不使用业务响应包装;关闭 HTTP 文档端点不影响生成 `docs/openapi.json`。
|
||||||
|
|
||||||
环境变量覆盖配置文件,例如 `GO_OMNI_ENDPOINTS_READINESS_PATH=/readyz`、`GO_OMNI_ENDPOINTS_READINESS_RESPONSE_MODE=raw`、`GO_OMNI_ENDPOINTS_OPENAPI_ENABLED=false`;liveness/readiness/openapi 都支持对应的 ENABLED 和 PATH。Config/AppOptions 的最终有效配置决定是否注册。
|
内置路由配置由项目文件明确设置,也可以使用 `${NAME:-fallback}` 引用;不再按字段名自动读取环境变量。Config/AppOptions 的最终有效配置决定是否注册。
|
||||||
|
|
||||||
关闭或改名后,旧路径不再由框架保留,项目可以在 `.api` 自己声明。启用的内置 GET 路由与业务路由相同或被业务参数/通配路径覆盖时,在创建 App、开始监听之前报错;不会覆盖业务路由。内置接口仍经过全局中间件;项目在鉴权中间件中明确决定这些路径的访问规则,框架不自动绕过鉴权。
|
关闭或改名后,旧路径不再由框架保留,项目可以在 `.api` 自己声明。启用的内置 GET 路由与业务路由相同或被业务参数/通配路径覆盖时,在创建 App、开始监听之前报错;不会覆盖业务路由。内置接口仍经过全局中间件;项目在鉴权中间件中明确决定这些路径的访问规则,框架不自动绕过鉴权。
|
||||||
|
|
||||||
@@ -215,7 +217,7 @@ get /download (DownloadReq)
|
|||||||
|
|
||||||
## 已有服务接入步骤
|
## 已有服务接入步骤
|
||||||
|
|
||||||
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.21.0-preview:
|
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.28.0-preview:
|
||||||
|
|
||||||
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
|
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
|
||||||
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。
|
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。
|
||||||
|
|||||||
169
README.md
169
README.md
@@ -1,8 +1,28 @@
|
|||||||
# GoOmni
|
# GoOmni
|
||||||
|
|
||||||
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
|
GoOmni 是面向多个项目复用的 Go 基础框架。通用工程使用 `project new` 生成配置、日志、依赖装配、生命周期和普通 Go 运行入口;需要 HTTP 接口时可选择 `.api` 工程生成,也可以在已有项目中按需引入独立模块。运行方式、业务类型和外部依赖由项目决定。
|
||||||
|
|
||||||
**从这份 README 开始接入。** 当前版本 **`v0.21.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.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.30.0-preview。同时修复 Go 1.22 下请求体限额错误的识别。
|
||||||
|
|
||||||
|
**从这份 README 开始接入。** 当前版本 **`v0.30.0-preview`**,通用工程采用根目录入口和 `config / svc / run`;API 工程使用 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
||||||
|
|
||||||
|
**v0.25 改动**:`database.InTx` 返回可识别的事务阶段与结果,保留业务/驱动及回滚错误链;提交报错保守标记为结果不确定,不等同于已回滚。[使用规则](database/README.md#事务阶段与结果)、[可运行故障示例](examples/transactions/README.md)。
|
||||||
|
|
||||||
|
**v0.24 改动**:新增可选 CORS,生成项目通过 `server.cors` 自动接入,支持预检、错误响应和明确的来源白名单;默认关闭。[配置与规则](cors/README.md)、[可运行示例](examples/cors/README.md)。
|
||||||
|
|
||||||
|
**v0.23 改动**:配置校验定位具体字段和安全原因;`httpc.JSON` 可选限制成功状态;`copyx.Check` 无需样本预检类型;`logx` 支持实例独立时区。[四项完整示例](examples/developerTools/README.md)。
|
||||||
|
|
||||||
**v0.19 改动**:新增可选 `copyx` 工具,用普通 Go 调用完成浅拷贝、深拷贝和显式类型转换;深拷贝会拒绝自定义转换中残留的源对象可变引用。**移除了 `mappings` / `mappingFiles` 和映射代码生成**,已有使用者须按 [迁移步骤](COPY.md#从旧-mapping-迁移) 调整,不能直接删除仍在调用的旧文件。
|
**v0.19 改动**:新增可选 `copyx` 工具,用普通 Go 调用完成浅拷贝、深拷贝和显式类型转换;深拷贝会拒绝自定义转换中残留的源对象可变引用。**移除了 `mappings` / `mappingFiles` 和映射代码生成**,已有使用者须按 [迁移步骤](COPY.md#从旧-mapping-迁移) 调整,不能直接删除仍在调用的旧文件。
|
||||||
|
|
||||||
@@ -12,9 +32,9 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
|
|||||||
|
|
||||||
响应字段、业务码、登录方式、权限规则和数据库表结构由接入项目决定。文中的 `code/msg/data`、`200/9999` 都是示例配置,可以替换。
|
响应字段、业务码、登录方式、权限规则和数据库表结构由接入项目决定。文中的 `code/msg/data`、`200/9999` 都是示例配置,可以替换。
|
||||||
|
|
||||||
契约支持 `returns([]string/[]int64/[]bool/[]any)`、OpenAPI 公共引用、workspace 一次导出共享 Go 错误目录,以及统一环境变量别名覆盖。详细配置与升级步骤见 [业务开发指南](DEVELOPMENT.md)。
|
契约支持 `returns([]string/[]int64/[]bool/[]any)`、OpenAPI 公共引用、workspace 一次导出共享 Go 错误目录,以及文件显式环境引用与配置检查。详细配置与升级步骤见 [业务开发指南](DEVELOPMENT.md)。
|
||||||
|
|
||||||
已有通用开发能力见 [业务开发指南](DEVELOPMENT.md):数组/null、严格请求策略、声明式上传、完整配置环境覆盖、资源生命周期、共享契约和升级工具。MySQL 直接使用 [官方适配器](database/mysql/README.md)。
|
已有通用开发能力见 [业务开发指南](DEVELOPMENT.md):数组/null、严格请求策略、声明式上传、文件显式配置引用、资源生命周期、共享契约和升级工具。MySQL 直接使用 [官方适配器](database/mysql/README.md)。
|
||||||
|
|
||||||
后台组件可在 svc 登记 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭),先停止接收工作并等待收尾,再关闭共享资源;无需绑定队列或 ORM。
|
后台组件可在 svc 登记 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭),先停止接收工作并等待收尾,再关闭共享资源;无需绑定队列或 ORM。
|
||||||
|
|
||||||
@@ -28,32 +48,72 @@ Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 项目、接口与文档生成 | `go-omni new`;改 `.api` 后 `api generate`;`--check` 检查漂移 | [下方快速开始](#新项目生成并运行)、[CLI](cmd/go-omni/README.md)、[完整生成项目](examples/managedService/README.md) |
|
| 项目、接口与文档生成 | `go-omni new`;改 `.api` 后 `api generate`;`--check` 检查漂移 | [下方快速开始](#新项目生成并运行)、[CLI](cmd/go-omni/README.md)、[完整生成项目](examples/managedService/README.md) |
|
||||||
| 公共字段与跨服务共享契约 | `.api` 组合对象字段;`omni.imports.json` 声明共享根;workspace 批量生成 | [公共字段示例](GENERATION.md#公共字段组合)、[共享契约与 workspace](DEVELOPMENT.md) |
|
| 公共字段与跨服务共享契约 | `.api` 组合对象字段;`omni.imports.json` 声明共享根;workspace 批量生成 | [公共字段示例](GENERATION.md#公共字段组合)、[共享契约与 workspace](DEVELOPMENT.md) |
|
||||||
|
| 混合工程工作区 | `workspace generate/check` 按清单分发 API/project;format 仅处理 API | [可运行示例](examples/mixedWorkspace/README.md)、[工作区说明](DEVELOPMENT.md#混合-api-与通用工程) |
|
||||||
|
| 通用 Go 工程 | `go-omni project new/generate/check`;配置、日志、依赖、运行与退出 | [通用工程指南](PROGRAM.md)、[完整可运行示例](examples/program/README.md) |
|
||||||
| 中间件依赖工厂、可选 Repository | `omni.gen.json` 选择 factories / none;在 `New<Name>(deps)` 写一次实现 | [配置与完整工厂示例](GENERATION.md) |
|
| 中间件依赖工厂、可选 Repository | `omni.gen.json` 选择 factories / none;在 `New<Name>(deps)` 写一次实现 | [配置与完整工厂示例](GENERATION.md) |
|
||||||
| 路由、分组、请求上下文 | `omni.New()`、`app.GET` / `Group` / `Use`;业务使用 `context.Context` | [基本服务](examples/basic/main.go)、[已有项目接入](#已有项目按模块接入) |
|
| 路由、分组、请求上下文 | `omni.New()`、`app.GET` / `Group` / `Use`;业务使用 `context.Context` | [基本服务](examples/basic/main.go)、[已有项目接入](#已有项目按模块接入) |
|
||||||
| HTTP 网络与扩展适配 | 配置 transport 为 nethttp / native / http2;自定义 Router/Runner | [网络层配置与契约](transport/README.md)、[性能与限制](PERFORMANCE.md) |
|
| HTTP 网络与扩展适配 | 配置 transport 为 nethttp / native / http2;自定义 Router/Runner | [网络层配置与契约](transport/README.md)、[性能与限制](PERFORMANCE.md) |
|
||||||
|
| 浏览器跨域 CORS | `server.cors` / `omni.WithCORS`;独立 HTTP 用 `cors.New(...).Wrap(handler)` | [配置、默认值与协议边界](cors/README.md)、[鉴权与预检示例](examples/cors/README.md) |
|
||||||
| HTTP 老接口桥接 | 通过官方 Handler/Middleware 适配器接入标准 `net/http`,跟踪实际响应状态 | [接入说明](MIGRATION.md)、[桥接/上传/自定义 ID 示例](examples/compatibility/main.go) |
|
| HTTP 老接口桥接 | 通过官方 Handler/Middleware 适配器接入标准 `net/http`,跟踪实际响应状态 | [接入说明](MIGRATION.md)、[桥接/上传/自定义 ID 示例](examples/compatibility/main.go) |
|
||||||
| 请求校验、动态 JSON、自定义标量 | `.api` 声明来源/校验/scalar;手写契约用 `contract.New[T]`;简单绑定可用 `validate.JSON/Query/URI` | [语法与例子](API.md)、[请求策略](DEVELOPMENT.md) |
|
| 请求校验、动态 JSON、自定义标量 | `.api` 声明来源/校验/scalar;手写契约用 `contract.New[T]`;简单绑定可用 `validate.JSON/Query/URI` | [语法与例子](API.md)、[请求策略](DEVELOPMENT.md) |
|
||||||
| 数组、null、原始请求、上传下载 | `.api` 声明数组/可空返回、body/multipart 与大小限制;必要时使用 Bind/Manual 扩展 | [契约示例](examples/contracts)、[含上传/回调/下载的项目](examples/managedService/README.md) |
|
| 数组、null、原始请求、上传下载 | `.api` 声明数组/可空返回、body/multipart 与大小限制;必要时使用 Bind/Manual 扩展 | [契约示例](examples/contracts)、[含上传/回调/下载的项目](examples/managedService/README.md) |
|
||||||
| 公共响应与特殊协议 | `.api` 定义 response 并 `@app(defaultResponse: ...)`;Go 用 `WithResponse` / Encoder | [响应配置](RESPONSE.md)、[响应模板契约](examples/contracts/responses.api) |
|
| 公共响应与特殊协议 | `.api` 定义 response 并 `@app(defaultResponse: ...)`;Go 用 `WithResponse` / Encoder | [响应配置](RESPONSE.md)、[响应模板契约](examples/contracts/responses.api) |
|
||||||
| 错误目录与动态提示 | `WithErrors(catalog)` 注册一次;`ByCode(...).WithParams(...).WithCause(err)` | [错误目录](errors/README.md)、[模板参数示例](examples/contracts/errorParams.api) |
|
| 错误目录与动态提示 | `WithErrors(catalog)` 注册一次;`ByCode(...).WithParams(...).WithCause(err)` | [错误目录](errors/README.md)、[模板参数示例](examples/contracts/errorParams.api) |
|
||||||
|
| 通用国际化 | `i18n.New/Render`;可选 `WithLanguagePolicy/WithErrorLocalizer`;`go-omni i18n check/export/types/extract` | [资源格式与边界](i18n/README.md)、[CLI/HTTP/前端示例](examples/i18n/README.md)、[生成契约](examples/contracts/i18n.api) |
|
||||||
| OpenAPI 与错误公共引用 | `.api` 自动生成;手写接口可使用 `openapi` 注册器;Go 错误目录显式导出 | [契约工具](DEVELOPMENT.md)、[注册示例](examples/platform/main.go) |
|
| OpenAPI 与错误公共引用 | `.api` 自动生成;手写接口可使用 `openapi` 注册器;Go 错误目录显式导出 | [契约工具](DEVELOPMENT.md)、[注册示例](examples/platform/main.go) |
|
||||||
| 结构化业务日志 | `logx.Info/Error`、`logx.FromContext(ctx)`、`logx.New`;支持轮转、脱敏、慢请求和级别更新 | [下方日志示例](#日志)、[完整配置](logx/README.md) |
|
| 结构化业务日志 | `logx.Info/Error`、`logx.FromContext(ctx)`、`logx.New`;支持独立时区、轮转、脱敏、慢请求和级别更新 | [下方日志示例](#日志)、[完整配置](logx/README.md) |
|
||||||
| 文件配置与环境覆盖 | 默认值→JSON/YAML/XML→环境→校验;完整业务结构用 `config.LoadProject[T]` / 环境别名 | [统一配置指南](CONFIG.md)、[完整可运行示例](examples/configuration/README.md)、[动态级别](ADVANCED.md#配置与日志热更新) |
|
| 文件配置与环境引用 | Default()→JSON/YAML/XML 字段引用→类型绑定→校验;无隐式覆盖,支持 `-check-config` | [统一配置指南](CONFIG.md)、[完整可运行示例](examples/configuration/README.md)、[动态级别](ADVANCED.md#配置与日志热更新) |
|
||||||
| 生命周期与健康检查 | svc 登记 OnClose/OnStop/OnDrain,RequestShutdown 通知故障;健康路径/开关可配 | [生命周期用法](lifecycle/README.md)、[健康检查](health/README.md)、[生成项目扩展](PROJECT.md) |
|
| 生命周期与健康检查 | svc 登记 OnClose/OnStop/OnDrain,RequestShutdown 通知故障;健康路径/开关可配 | [生命周期用法](lifecycle/README.md)、[健康检查](health/README.md)、[生成项目扩展](PROJECT.md) |
|
||||||
| 数据库、连接池与事务 | `database.Open` / `OpenConnector`;事务与检查由项目显式调用 | [通用数据库](database/README.md)、[MySQL TLS/Logger](database/mysql/README.md) |
|
| 数据库、连接池与事务 | `database.Open` / `OpenConnector`;`InTx` 的 `TxError` 提供阶段、已知结果与原因链 | [通用数据库](database/README.md)、[事务失败示例](examples/transactions/README.md)、[MySQL TLS/Logger](database/mysql/README.md) |
|
||||||
| 可选 GORM 与迁移 | gormx 复用已有连接池;`go-omni migrate` 预览,显式 apply | [GORM 示例](database/gormx/README.md)、[迁移用法](ADVANCED.md#迁移审计与分页) |
|
| 可选 GORM 与迁移 | gormx 复用已有连接池;`go-omni migrate` 预览,显式 apply | [GORM 示例](database/gormx/README.md)、[迁移用法](ADVANCED.md#迁移审计与分页) |
|
||||||
| 认证与权限接口 | 注入 Authenticator / Policy;JWT 或 Session 只是可选实现 | [认证说明](auth/README.md)、[鉴权完整例子](examples/platform/README.md) |
|
| 认证与权限接口 | 注入 Authenticator / Policy;JWT 或 Session 只是可选实现 | [认证说明](auth/README.md)、[鉴权完整例子](examples/platform/README.md) |
|
||||||
| 缓存 | `cache.NewMemory` 或 Redis 实现;业务面向 Store 的 Get/Set/Delete | [缓存配置/代码](MODULES.md)、[可运行模块服务](examples/modules/README.md) |
|
| 缓存 | `cache.NewMemory` 或 Redis 实现;业务面向 Store 的 Get/Set/Delete | [缓存配置/代码](MODULES.md)、[可运行模块服务](examples/modules/README.md) |
|
||||||
| 出站 HTTP、熔断 | `httpc.New` 配置代理、连接池和阶段超时;JSON 支持单次请求头/限额;`resilience.New` 隔离故障 | [HTTP 完整用法](httpc/README.md)、[可运行示例](examples/httpClient/README.md)、[熔断/观测](ADVANCED.md#熔断与观测) |
|
| 出站 HTTP、熔断 | `httpc.New` 配置代理、连接池和阶段超时;JSON 支持单次成功状态限制/请求头/限额;`resilience.New` 隔离故障 | [HTTP 完整用法](httpc/README.md)、[可运行示例](examples/httpClient/README.md)、[熔断/观测](ADVANCED.md#熔断与观测) |
|
||||||
| 请求追踪、访问日志、恢复、限流、幂等、超时 | `app.Use(...)` 按需组合 middleware;跨副本选择 Redis 实现 | [模块说明与限制](MODULES.md#请求追踪与基础指标)、[分布式实现](ADVANCED.md#分布式限流与幂等) |
|
| 请求追踪、访问日志、恢复、限流、幂等、超时 | `app.Use(...)` 按需组合 middleware;跨副本选择 Redis 实现 | [模块说明与限制](MODULES.md#请求追踪与基础指标)、[分布式实现](ADVANCED.md#分布式限流与幂等) |
|
||||||
| 本地后台任务与调度 | `task.New`、Submit、Every;有界队列、排空关闭 | [task 示例与生命周期](MODULES.md#后台执行task) |
|
| 本地后台任务与调度 | `task.New`、Submit、Every;有界队列、排空关闭 | [task 示例与生命周期](MODULES.md#后台执行task) |
|
||||||
| 持久队列 | 业务使用 Publisher / Consumer;Redis Streams 提供至少一次交付 | [队列初始化/消费示例](ADVANCED.md#可靠队列) |
|
| 持久队列 | 业务使用 Publisher / Consumer;Redis Streams 提供至少一次交付 | [队列初始化/消费示例](ADVANCED.md#可靠队列) |
|
||||||
| 文件存储、直传、云端分片 | Store 的 Put/Get/Delete;Local、S3/R2、OSS 按配置装配;扩展接口提供分片续传 | [存储接口与完整例子](storage/README.md)、[本地存储服务](examples/platform/README.md) |
|
| 文件存储、签名预览/下载、直传与云端分片 | Store 的 Put/Get/Delete;ReadPresigner 签名响应选项;Local、S3/R2、OSS 按配置装配 | [存储接口与完整例子](storage/README.md)、[本地存储服务](examples/platform/README.md) |
|
||||||
| 日志关联、指标与链路 | `metrics.New().Middleware()`;telemetry 注入 OTel exporter/reader | [基础指标](MODULES.md#请求追踪与基础指标)、[OTel 用法](ADVANCED.md#熔断与观测)、[完整接入](examples/platform/main.go) |
|
| 日志关联、指标与链路 | `metrics.New().Middleware()`;telemetry 注入 OTel exporter/reader;`HeaderPropagator` 兼容自定义网关 Header | [基础指标](MODULES.md#请求追踪与基础指标)、[OTel 用法](ADVANCED.md#熔断与观测)、[Header 示例](examples/telemetryHeaders/README.md) |
|
||||||
| 审计与分页 | `audit.New` 注入 Sink/白名单;`pagination.Parse` / `NewResult` | [解释与调用例子](ADVANCED.md#迁移审计与分页) |
|
| 审计与分页 | `audit.New` 注入 Sink/白名单;`pagination.Parse` / `NewResult` | [解释与调用例子](ADVANCED.md#迁移审计与分页) |
|
||||||
| 可选结构转换与深拷贝 | `copyx.Copy` / `Clone` / `New`;Go converter 处理差异 | [下方示例](#可选结构转换与深拷贝)、[完整 copyx 指南](COPY.md)、[可运行代码](examples/copying) |
|
| 可选结构转换与深拷贝 | `copyx.Copy` / `Clone` / `New`;`Check[S,T]` 预检类型,Go converter 处理差异 | [下方示例](#可选结构转换与深拷贝)、[完整 copyx 指南](COPY.md)、[可运行代码](examples/copying) |
|
||||||
| 自定义适配器验收 | `transporttest.Run`、`storagetest.Run` / `RunUploads` 验证扩展契约 | [网络契约测试入口](transport/transporttest/router.go)、[存储契约测试入口](storage/storagetest/contract.go) |
|
| 自定义适配器验收 | `transporttest.Run`、`storagetest.Run` / `RunUploads` 验证扩展契约 | [网络契约测试入口](transport/transporttest/router.go)、[存储契约测试入口](storage/storagetest/contract.go) |
|
||||||
|
|
||||||
|
## 通用工程快速开始
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.30.0-preview
|
||||||
|
go-omni project new demo --module example.com/demo
|
||||||
|
cd demo
|
||||||
|
go mod tidy
|
||||||
|
go run . -check-config
|
||||||
|
go run .
|
||||||
|
# Ctrl+C / SIGTERM 退出;项目自己的 Run 也可以完成后直接返回。
|
||||||
|
go-omni project check
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
项目只修改配置、svc 依赖初始化和 Run;默认没有 HTTP 端口、数据库、队列或自动重试。`main.go`、加载和装配代码由生成器维护;手改受管文件会报冲突,手写文件保持。完整维护边界、异常退出和超时行为见 [PROGRAM.md](PROGRAM.md)。
|
||||||
|
|
||||||
|
## 浏览器跨域 CORS
|
||||||
|
|
||||||
|
同源部署保持默认关闭;需要跨域时,在生成项目的配置文件中加入:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
cors:
|
||||||
|
enabled: true
|
||||||
|
allowedOrigins: [https://admin.example.com, https://merchant.example.com]
|
||||||
|
allowedMethods: [GET, HEAD, POST, PUT, PATCH, DELETE]
|
||||||
|
allowedHeaders: [Authorization, Content-Type]
|
||||||
|
exposedHeaders: [Content-Disposition, X-Request-ID]
|
||||||
|
allowCredentials: false
|
||||||
|
maxAge: 10m
|
||||||
|
```
|
||||||
|
|
||||||
|
不需要修改 main、Logic 或 `.api`。框架在鉴权前处理合法预检;实际请求仍鉴权,401/404/405/500 保持项目响应格式并附带允许来源的跨域头。没有 Origin 的服务端回调正常处理。配置只决定浏览器读取权限,不替代登录、验签或 CSRF 防护。
|
||||||
|
|
||||||
|
手写项目用 `omni.WithCORS(omni.CORSConfig{...})`,独立 HTTP 服务用 `cors.New(...).Wrap(handler)`;代理已经处理 CORS 时保持关闭。完整 [默认值与边界](cors/README.md)、[可运行示例](examples/cors/README.md)。
|
||||||
|
|
||||||
## 带认证的出站 HTTP
|
## 带认证的出站 HTTP
|
||||||
|
|
||||||
HTTP Client 在 svc 初始化一次,业务重复使用。普通内部请求可直接选择直连和连接参数,无需手写 Transport:
|
HTTP Client 在 svc 初始化一次,业务重复使用。普通内部请求可直接选择直连和连接参数,无需手写 Transport:
|
||||||
@@ -81,26 +141,30 @@ go run ./examples/httpClient
|
|||||||
|
|
||||||
## 统一配置加载
|
## 统一配置加载
|
||||||
|
|
||||||
新生成项目的加载流程由框架管理。业务在 `internal/config/config.go` 增加字段、默认值和 `Validate`,无需改 main 或复制环境变量解析。JSON、YAML、XML 共用字段和校验;扩展格式在 `loader.go` 注册。
|
业务只维护 Config、Default()、Validate() 和配置文件。生成器维护 Load/main;文件缺失字段保留默认值,显式 0/false/空字符串正常覆盖,固定值不会被同名环境变量再次覆盖。
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// 项目 Config 中增加字段,自动支持 GO_OMNI_BATCH_SIZE:
|
BatchSize int `json:"batchSize"` // 加入 Config;在 Default() 中设置 BatchSize: 100。
|
||||||
BatchSize int `json:"batchSize" envDefault:"100"`
|
|
||||||
|
|
||||||
// 项目 Validate 中增加业务范围检查:
|
|
||||||
if c.BatchSize < 1 {
|
|
||||||
return omniConfig.Invalid("batchSize", "must be positive")
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
错误包含文件、字段、可用的行列或环境变量名;输入原值不自动写入错误。
|
```yaml
|
||||||
|
server:
|
||||||
|
address: "${HTTP_ADDR:-127.0.0.1:8080}"
|
||||||
|
batchSize: "${BATCH_SIZE:-100}"
|
||||||
|
password: "${DB_PASSWORD}"
|
||||||
|
sessionToken: "${SESSION_TOKEN:-}"
|
||||||
|
```
|
||||||
|
|
||||||
|
`${NAME}` 未设置或为空就报错;`${NAME:-value}` 明确指定备用值。`$$` 转义 `$`;密码中的冒号、换行、引号和 `${...}` 原样插入,不二次解释。JSON/YAML/XML 共用解析后的字段处理和 Go 类型绑定,错误同时定位文件、字段、变量及可用行列,不打印原值。
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go run ./examples/configuration -format xml
|
go run ./examples/configuration -format xml
|
||||||
DEMO_CLIENT_TIMEOUT=500ms go run ./examples/configuration
|
DEMO_CLIENT_TIMEOUT=500ms go run ./examples/configuration
|
||||||
|
# 在生成项目中,仅检查选中的文件,不初始化依赖:
|
||||||
|
go run . -config etc/config.test.yaml -check-config
|
||||||
```
|
```
|
||||||
|
|
||||||
详见 [统一加载、解析器、迁移](CONFIG.md) 和 [完整示例](examples/configuration/README.md)。**旧项目须先迁移手写 Load,再重新生成**;生成器会保留业务配置并拒绝冲突,不静默覆盖。
|
完整 [语义与迁移](CONFIG.md)、[可运行示例](examples/configuration/README.md)。**v0.22 移除旧自动环境覆盖、前缀/别名及 env/envDefault 标签**,生成项目先迁移开发者配置再重新生成;文件中的响应模板 `${code}` 写成 `$${code}`,`.api` 保持原样。
|
||||||
|
|
||||||
## 选择接入方式
|
## 选择接入方式
|
||||||
|
|
||||||
@@ -122,7 +186,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
|
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
|
||||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.21.0-preview
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.30.0-preview
|
||||||
export PATH="$(go env GOPATH)/bin:$PATH"
|
export PATH="$(go env GOPATH)/bin:$PATH"
|
||||||
go-omni --help
|
go-omni --help
|
||||||
```
|
```
|
||||||
@@ -279,7 +343,7 @@ go-omni new --api catalog.api \
|
|||||||
在业务项目的 **go.mod 所在目录**执行:
|
在业务项目的 **go.mod 所在目录**执行:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go get git.yuntuo.hk/k2safe/go-omni@v0.21.0-preview
|
go get git.yuntuo.hk/k2safe/go-omni@v0.30.0-preview
|
||||||
```
|
```
|
||||||
|
|
||||||
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
|
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
|
||||||
@@ -505,6 +569,28 @@ if err := copier.Copy(&view, input); err != nil { return err }
|
|||||||
|
|
||||||
自定义函数的源/目标类型由 Go 泛型确定。深拷贝时返回原指针、原 map、子切片或嵌套旧引用都会报错;字符串/整数原值可以返回。差异规则、nil 策略、字段覆盖、资源限制和原 mapping 移除步骤见 [copyx 指南](COPY.md),完整类型及可运行代码见 [examples/copying](examples/copying/main.go)。工具使用运行时检查,类型对需测试;深拷贝不适用于连接、锁等资源。
|
自定义函数的源/目标类型由 Go 泛型确定。深拷贝时返回原指针、原 map、子切片或嵌套旧引用都会报错;字符串/整数原值可以返回。差异规则、nil 策略、字段覆盖、资源限制和原 mapping 移除步骤见 [copyx 指南](COPY.md),完整类型及可运行代码见 [examples/copying](examples/copying/main.go)。工具使用运行时检查,类型对需测试;深拷贝不适用于连接、锁等资源。
|
||||||
|
|
||||||
|
### 减少接入代码:四个常用入口
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
log:
|
||||||
|
timeZone: Asia/Shanghai
|
||||||
|
```
|
||||||
|
|
||||||
|
时区由 Logger 实例管理,不修改全局 `time.Local`。非法日志级别、数据库端口/连接池等配置会指出 `server.log.level`、`database.port` 等字段和原因,不打印输入值。详见 [配置诊断](CONFIG.md#框架模块的字段诊断)、[日志时区](logx/README.md#实例时区)。
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 本次请求只接受 HTTP 200;默认仍接受所有 2xx。
|
||||||
|
err := client.JSON(ctx, http.MethodGet, url, nil, &result,
|
||||||
|
httpc.WithSuccessStatuses(http.StatusOK),
|
||||||
|
)
|
||||||
|
|
||||||
|
// CI/启动时检查类型,无需构造样本;仍使用 Copy 的同一组 Option。
|
||||||
|
err = copyx.Check[Request, BusinessInput](options...)
|
||||||
|
```
|
||||||
|
|
||||||
|
状态不符合要求返回 `*httpc.StatusError`,不会写入 result,响应体仍由框架关闭。Check 不执行 converter;动态 interface、具体 nil/转换失败和深拷贝引用检查有明确边界,见 [类型预检](COPY.md#类型预检)。完整可运行代码见 [developerTools](examples/developerTools/README.md)。
|
||||||
|
|
||||||
### 数据库、存储及其他模块
|
### 数据库、存储及其他模块
|
||||||
|
|
||||||
生成项目在 `internal/svc` 中创建依赖,提供给 Logic/Repository,并用 `c.OnClose(db.Close)` 登记自有资源。生成的 main 在 HTTP 排空后关闭 svc,再关闭日志。直接使用框架的手写项目则可以用 `app.OnShutdown(db.Close)`;不要对同一资源重复登记。
|
生成项目在 `internal/svc` 中创建依赖,提供给 Logic/Repository,并用 `c.OnClose(db.Close)` 登记自有资源。生成的 main 在 HTTP 排空后关闭 svc,再关闭日志。直接使用框架的手写项目则可以用 `app.OnShutdown(db.Close)`;不要对同一资源重复登记。
|
||||||
@@ -523,20 +609,25 @@ API 项目的数据库迁移需要单独维护并显式执行;启动不会自
|
|||||||
|
|
||||||
## 配置与运行
|
## 配置与运行
|
||||||
|
|
||||||
生成项目从 `etc/config.yaml` 读取配置,优先级为 **默认值 → 配置文件 → 环境变量**。密钥通过环境变量提供。手写入口需显式调用配置加载函数并传给 `omni.New(omni.WithConfig(settings))`;单独调用 `omni.New()` 不会自动读取环境变量。
|
生成项目使用 **Default() → 选定文件的显式引用 → 类型绑定 → Validate()**。固定值不会被环境变量覆盖;配置规则、转义及迁移见 [配置指南](CONFIG.md)。手写入口显式加载配置,再传入 `omni.New(omni.WithConfig(settings))`。
|
||||||
|
|
||||||
| 环境变量 | 用途 |
|
| 文件字段 | 用途 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `GO_OMNI_HTTP_ADDR` | 监听地址;生成项目默认 `127.0.0.1:8080` |
|
| `server.address` | 监听地址,生成项目默认 127.0.0.1:8080 |
|
||||||
| `GO_OMNI_TRANSPORT` | `nethttp`(默认)、`native`、`http2` |
|
| `server.transport` | nethttp(默认)、native、http2 |
|
||||||
| `GO_OMNI_TLS_CERT_FILE` / `GO_OMNI_TLS_KEY_FILE` | 成对设置 TLS 证书和私钥路径 |
|
| `server.tls.certFile/keyFile` | 成对配置 TLS 证书和私钥路径 |
|
||||||
| `GO_OMNI_HTTP_READ_HEADER_TIMEOUT` / `GO_OMNI_HTTP_READ_TIMEOUT` | 请求头 / 请求读取超时,如 `5s` / `30s` |
|
| `server.http.readHeaderTimeout/readTimeout` | 请求头 / 请求读取超时 |
|
||||||
| `GO_OMNI_HTTP_WRITE_TIMEOUT` / `GO_OMNI_HTTP_IDLE_TIMEOUT` | 响应写入 / Keep-Alive 空闲超时 |
|
| `server.http.writeTimeout/idleTimeout` | 响应写入 / Keep-Alive 空闲超时 |
|
||||||
| `GO_OMNI_HTTP_MAX_HEADER_BYTES` | 请求头大小限制,字节数 |
|
| `server.http.maxHeaderBytes` | 请求头大小限制,字节数 |
|
||||||
| `GO_OMNI_SHUTDOWN_TIMEOUT` | 优雅退出等待时间,默认 `10s` |
|
| `server.http.defaultMaxBodyBytes` | 普通 App 路由默认请求体上限,零默认 8 MiB;路由可覆盖 |
|
||||||
| `GO_OMNI_ENABLE_LOGGER` / `GO_OMNI_LOG_LEVEL` | 访问日志开关、日志级别 |
|
| `server.http.maxBodyBytes` | 服务请求体硬上限,路由不得超过;零不添加低于框架 1 TiB 的硬上限 |
|
||||||
| `GO_OMNI_TRUSTED_PROXIES` | 可解析转发地址头的可信代理 IP/CIDR,逗号分隔 |
|
| `server.http.shutdownCancelTimeout` | 请求取消后等待处理函数收尾,零默认 5s;未结束则保留资源 |
|
||||||
| `GO_OMNI_DB_HOST/USER/PASSWORD/DATABASE` | 官方 MySQL 适配器的结构化连接配置 |
|
| `server.shutdownTimeout` | 优雅退出等待时间,默认 10s |
|
||||||
|
| `server.enableLogger` / `server.log.level` | 访问日志开关、日志级别 |
|
||||||
|
| `server.trustedProxies` | 可信代理 IP/CIDR,文件数组 |
|
||||||
|
| `database.host/user/password/database` | 官方 MySQL 结构化连接配置 |
|
||||||
|
|
||||||
|
初始生成文件已为地址、网络层和选中模块的必要配置声明环境引用;其他字段按需要添加固定值或引用。
|
||||||
|
|
||||||
例如在生成项目中切换 native:
|
例如在生成项目中切换 native:
|
||||||
|
|
||||||
@@ -558,13 +649,15 @@ server:
|
|||||||
maxHeaderBytes: 1048576
|
maxHeaderBytes: 1048576
|
||||||
```
|
```
|
||||||
|
|
||||||
代码配置使用 `omni.WithHTTPConfig(omni.HTTPConfig{ReadTimeout: 30*time.Second, WriteTimeout: 30*time.Second})`。`Run`、`RunContext`、`ServeContext` 会应用它;`NewServer` 会配置返回的标准 `http.Server`,包含可选的 Go 字段 `ErrorLog`。**正值覆盖、零值保留所选网络层的原有默认行为、负值报错**;不配置时升级行为不变。超时含义与支持范围见 [HTTP 配置](transport/README.md#http-配置v091-preview),不要把网络 I/O 超时当成业务执行时限。
|
代码配置使用 `omni.WithHTTPConfig(omni.HTTPConfig{ReadTimeout: 30*time.Second, WriteTimeout: 30*time.Second})`。`Run`、`RunContext`、`ServeContext` 会应用它;`NewServer` 会配置返回的标准 `http.Server`,包含可选的 Go 字段 `ErrorLog`。**网络 I/O 参数正值覆盖、零值保留所选网络层的原有默认行为、负值报错**。请求体与取消收尾字段按上表解释;网络超时含义与支持范围见 [HTTP 配置](transport/README.md#http-配置v091-preview),不要把网络 I/O 超时当成业务执行时限。
|
||||||
|
|
||||||
|
外部宿主在注册完成后可用 `app.ValidateHTTP()` 预检配置与路由限额。上传路由使用 `app.HandleWithBodyLimit("POST", "/upload", 16<<20, nil, handler)` 或 `.api @request(maxBytes: 16777216)`;限额涵盖整个请求体。配置硬上限时至少允许该值;multipart 单文件、字段数以及幂等中间件缓存限制仍独立配置。新增普通路由默认 8 MiB;原 nethttp 手写大请求请显式设置默认值或路由上限,生成路由既有 `maxBytes`(默认 1 MiB)保持。
|
||||||
|
|
||||||
自研网络层通过 `RunWith(ctx, runner)` 接入监听与连接生命周期,通过 `NewWithRouter(router)` 替换路由;业务 Handler/Service 不需要导入具体适配器。细节与验收契约见 [网络适配层](transport/README.md)。
|
自研网络层通过 `RunWith(ctx, runner)` 接入监听与连接生命周期,通过 `NewWithRouter(router)` 替换路由;业务 Handler/Service 不需要导入具体适配器。细节与验收契约见 [网络适配层](transport/README.md)。
|
||||||
|
|
||||||
生成项目的依赖可调用 `svcCtx.RequestShutdown(err)` 主动申请退出,框架排空请求、清理依赖后保留故障原因并以非零状态退出。健康检查与 OpenAPI 的开关、路径通过 `server.endpoints` 配置,健康响应默认沿用项目公共体;未就绪保持 HTTP 503。完整配置和 v0.12 项目的升级步骤见 [项目指南](PROJECT.md#依赖主动申请退出)。
|
生成项目的依赖可调用 `svcCtx.RequestShutdown(err)` 主动申请退出,框架排空请求、清理依赖后保留故障原因并以非零状态退出。健康检查与 OpenAPI 的开关、路径通过 `server.endpoints` 配置,健康响应默认沿用项目公共体;未就绪保持 HTTP 503。完整配置和 v0.12 项目的升级步骤见 [项目指南](PROJECT.md#依赖主动申请退出)。
|
||||||
|
|
||||||
`Run` 处理 SIGINT/SIGTERM;`RunContext` 接收调用方控制的退出信号。服务先停止接收新请求,等待活跃请求完成,再清理资源;超时会取消请求并关闭连接,业务代码仍需响应 context 取消。
|
`Run` 处理 SIGINT/SIGTERM;`RunContext` 接收调用方控制的退出信号。先停止接收新请求并等待排空,超时取消请求、关闭连接,再等待 `http.shutdownCancelTimeout` 收尾窗口。只有处理函数和它的 defer 全部退出才清理资源;仍有请求时返回 `lifecycle.ErrResourcesRetained`,跳过 App 清理钩子。生成入口通过 `deps.Finish(runErr)` 同步保留数据库等依赖及日志,非零退出。`App.Close`、`Engine`、`NewServer`、`RunWith` 共用这个完成屏障;业务自行启动的 goroutine、Hijack 后的工作需另行注册后台生命周期。
|
||||||
|
|
||||||
## 日常开发与升级
|
## 日常开发与升级
|
||||||
|
|
||||||
@@ -581,7 +674,7 @@ go test ./...
|
|||||||
|
|
||||||
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、`omni.gen.json`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
|
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、`omni.gen.json`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
|
||||||
|
|
||||||
CLI 与业务 module 固定使用 `v0.21.0-preview`。v0.14–v0.19 项目先按 [配置入口迁移](CONFIG.md#v019-及更早项目迁移) 整理旧 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
CLI 与业务 module 固定使用 `v0.30.0-preview`。v0.21 及更早项目先按 [配置迁移](CONFIG.md#v022-迁移旧模式已移除) 整理旧标签、文件与 Load,再升级依赖与 CLI 并重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。使用过旧 mapping 的项目必须先完成 [移除迁移](COPY.md#从旧-mapping-迁移),旧配置或旧生成文件会阻止生成。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
||||||
|
|
||||||
## 按需深入
|
## 按需深入
|
||||||
|
|
||||||
|
|||||||
253
RELEASE.md
253
RELEASE.md
@@ -1,4 +1,251 @@
|
|||||||
# v0.21.0-preview
|
# v0.30.0-preview
|
||||||
|
|
||||||
|
新增通用 `telemetry.HeaderPropagator`,让服务在不修改业务 Handler 的情况下接入使用自定义 Trace/Span/Request Header 的上游网关。配置 Header 名称后,入站建立远程父 Span,`logx.TraceID` / `RequestID`、访问日志和审计沿用上下文;出站 `telemetry.Transport` 同时注入 W3C 和兼容 Header。
|
||||||
|
|
||||||
|
W3C `traceparent` 始终优先于兼容 Header。兼容 Header 只接受合法的 32 位 Trace ID 和 16 位 Span ID,当前服务生成自己的 Span ID;Request ID 单独传播,不参与父 Span。旧项目不配置新传播器时,默认行为不变。兼容 Header 不携带采样标志,有效成对值按已采样处理;需要传递采样决定时使用 W3C。
|
||||||
|
|
||||||
|
```go
|
||||||
|
propagator, err := telemetry.NewHeaderPropagator(telemetry.HeaderPropagationConfig{
|
||||||
|
TraceIDHeader: "X-Trace-Id", SpanIDHeader: "X-Span-Id", RequestIDHeader: "X-Request-Id",
|
||||||
|
})
|
||||||
|
if err != nil { return err }
|
||||||
|
runtime, err := telemetry.New(telemetry.Config{
|
||||||
|
Service: "billing", SampleRatio: 1, Propagator: propagator,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
只在受信网关入口启用兼容传播;Trace/Span ID 不是认证、租户或幂等凭据。完整 [接入说明](ADVANCED.md#熔断与观测)、[离线示例](examples/telemetryHeaders/README.md)。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get git.yuntuo.hk/k2safe/go-omni@v0.30.0-preview
|
||||||
|
go test -race ./telemetry ./examples/telemetryHeaders
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v0.29.0-preview
|
||||||
|
|
||||||
|
新增通用签名读取选项:预览或下载、显示文件名和 MIME 类型,由调用方选择。`PresignGetWithOptions` 返回 URL 与签名内的到期时间;原 `PresignGet` 默认下载、第三方原有接口和旧本地链接继续兼容。有效期仍是 1 秒到 1 小时,不增加撤销或业务分享机制。
|
||||||
|
|
||||||
|
Local、S3/R2、OSS 使用同一接口,所有选项参与签名。中文文件名采用标准编码,拒绝 CR/LF 等非法头内容。本地支持 HEAD、单段/多段 Range、206/416 与条件请求,按真实内容范围流式输出;过期与篡改在读取文件之前拒绝。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get git.yuntuo.hk/k2safe/go-omni@v0.29.0-preview
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.29.0-preview
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
[接口与接入边界](storage/README.md#签名预览与下载)、[完整示例](examples/signedRead/README.md)、[验收记录](reports/signedReadAcceptance.md)。生成模板最低运行时版本不变;使用新读取接口需要升级框架依赖。OSS 默认域名可能强制下载,应在 SDK 签名前配置所需域名;OSS/R2 云账号尚未实测,部署方须按实际桶、域名和浏览器验收。
|
||||||
|
|
||||||
|
57 个测试包的全量 race 与 Go 1.22 集成回归、vet/build、隔离 MinIO 验证通过。远程空缓存综合 107 项检查、11 组生成消费者、通用工程 30 项与混合工作区 16 项通过,均无本地 replace。标签固定在 `f0c62152d72b1b50672c29f5191ad88f8be3eee4`,见 [远程报告](reports/signedReadRemote.json)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v0.28.2-preview
|
||||||
|
|
||||||
|
修复 `.api` 响应 DTO 自引用与相互引用的生成限制。`Children []Node`、`Next *Node`、`map[string]Node` 保留强类型,响应文档使用有限的命名组件与 `$ref`;可空引用、字段描述、示例、约束和 scalar 输出类型保持准确。框架不引入业务模型。
|
||||||
|
|
||||||
|
请求绑定继续拒绝递归类型,Go 非法直接值循环与字段组合循环继续报错。已有非递归类型保持原有生成行为;无需修改运行时配置或响应 JSON。升级 CLI,在项目 `.api` 中恢复强类型后重新生成,再执行检查和项目回归。生成模板最低运行时版本不变。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.2-preview
|
||||||
|
go-omni api generate
|
||||||
|
go-omni api generate --check
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
[递归响应说明](DEVELOPMENT.md#递归响应-dto)、[可运行示例](examples/recursiveResponse/README.md)。56 个测试包全量 race 与 Go 1.22 集成回归、vet/build、独立 OpenAPI 校验均通过,见[验收记录](reports/recursiveResponseAcceptance.md)。
|
||||||
|
|
||||||
|
远程空缓存验收完成:响应与兼容 101 项检查、11 组生成消费者,通用工程 30 项、混合工作区 16 项检查全部通过,均无本地 replace。标签固定在 `0c5b571b31b68d25cdb3e8640970e2a53ddcbea6`,详见[远程报告](reports/recursiveResponseRemote.json)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v0.28.1-preview
|
||||||
|
|
||||||
|
workspace generate/check 支持 API 与通用工程混合分发,也可处理纯通用工程工作区。services 新增可选 kind(api/project);省略时按生成清单识别,保留无清单时显式 api 入口的原有用法。类型与清单冲突、歧义和不合法选项会报错,项目身份、路径与文件归属继续由各生成器校验。
|
||||||
|
|
||||||
|
check 只读并汇总独立工程的失败;format 只处理 API 契约,对通用工程明确显示 SKIP。generate 预检所有工程后再写入,预检失败时不修改其他工程;不改变各模块依赖或 go.work,不增加业务逻辑和第三方依赖。模板与运行时接口未变,新 workspace 功能需要更新 CLI,旧生成工程可直接纳入。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.1-preview
|
||||||
|
go-omni workspace generate
|
||||||
|
go-omni workspace check
|
||||||
|
```
|
||||||
|
|
||||||
|
[混合工作区示例](examples/mixedWorkspace/README.md)、[清单规则](DEVELOPMENT.md#混合-api-与通用工程)、[验收记录](reports/workspaceAcceptance.md)。56 个测试包全量 race、5 个 CLI 包的 Go 1.22 回归、实际生成的多模块构建、vet/build 均通过。
|
||||||
|
|
||||||
|
远程空缓存验收完成:混合工作区 16 项、通用工程 30 项、原有生成器 98 项检查全部通过,均无本地 replace;见 [混合报告](reports/workspaceRemote.json)、[通用报告](reports/workspaceProgramRemote.json)、[兼容报告](reports/workspaceManagedRemote.json)。标签固定在 `0d2c6040e2694795f203304148dbaec00318c608`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v0.28.0-preview
|
||||||
|
|
||||||
|
新增通用 Go 工程生成命令 `project new/generate/check`。默认只有配置、日志、svc 依赖装配、生命周期与项目维护的 Run 入口,不生成 API、HTTP Handler、Swagger、业务任务或网络监听,也不自动连接数据库/队列或重试任务。常驻和一次性程序由普通 Go 代码决定。
|
||||||
|
|
||||||
|
入口监督 Run 的真实完成并复用现有 Runtime:信号、正常结束、运行错误或初始化失败都会进入收尾;先停止接收工作、等待已登记工作及 Run 结束,再反序释放依赖并关闭日志。Run/Init panic 转为失败;超时仍有工作则传播 ErrResourcesRetained,保留共享资源及日志,以非零状态退出。组件登记在 svc.Init 完成,业务语义和调度方式由项目负责。
|
||||||
|
|
||||||
|
配置遵守现有显式文件引用约定;支持 -check-config,在初始化日志和依赖前校验。生成器只管理 main/loadGen/runtimeGen,手写文件保留,生成文件被修改时拒绝覆盖;check/预览只读,独立 manifest/锁和共享文件事务保障生成。新模板要求框架 v0.28.0-preview;已有 new/api/workspace 行为保留,无强制迁移和新增第三方依赖。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.28.0-preview
|
||||||
|
go-omni project new demo --module example.com/demo
|
||||||
|
cd demo
|
||||||
|
go mod tidy
|
||||||
|
go run . -check-config
|
||||||
|
go run .
|
||||||
|
go-omni project check
|
||||||
|
```
|
||||||
|
|
||||||
|
完整 [通用工程指南](PROGRAM.md)、[可运行示例](examples/program/README.md)、[回归记录](reports/programAcceptance.md)。56 个测试包全量 race 与 Go 1.22 回归、生成消费者、vet/build 和真实进程退出验证通过。
|
||||||
|
|
||||||
|
空缓存远程安装已完成:通用工程 30 项检查通过(26.45 秒),原有生成项目 98 项检查、10 组消费者通过(111.07 秒),均无本地 replace。见 [通用远程报告](reports/programRemote.json)、[原有能力远程报告](reports/programManagedRemote.json)。标签固定在 `0b6c0043b6c3b58648645aeee03bf80801e6aa3f`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v0.27.0-preview
|
||||||
|
|
||||||
|
新增统一请求体策略与请求完成屏障,无业务字段、数据库依赖或新第三方包。`server.http.defaultMaxBodyBytes` 控制普通路由默认值(零为 8 MiB),`maxBodyBytes` 为不可由路由突破的服务硬上限(零仅保留 1 TiB 框架上界)。已有显式路由和生成契约限额保持,冲突会在启动前报错;旧 nethttp 手写大请求需显式设置路由或默认上限。multipart 单文件、中间件缓存及代理限额仍各自配置。
|
||||||
|
|
||||||
|
HTTP 关闭连接后继续等待处理器实际退出,收尾窗口由 `server.http.shutdownCancelTimeout` 设置(零默认 5s)。超时仍有处理器运行时返回 `lifecycle.ErrResourcesRetained`,App/Runtime/生成入口保留共享依赖和日志;不再将连接关闭当成业务完成。`Runtime.Finish(runErr)` 会传播该信号,手写入口必须处理它,避免无条件关闭额外资源。该行为适用于全部 App 服务入口;HTTP/2 低层 Shutdown 同时等待独立处理器。
|
||||||
|
|
||||||
|
详见 [请求体策略](transport/README.md#统一请求体限额)、[停机规则](lifecycle/README.md#http-请求与依赖的关闭屏障)、[运行示例](examples/httpSafety/README.md)。CLI 与框架固定使用同版 v0.27.0-preview;模板最低兼容版本仍为 v0.22,HTTP 安全改进需要升级运行时。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get git.yuntuo.hk/k2safe/go-omni@v0.27.0-preview
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.27.0-preview
|
||||||
|
go-omni api generate --check
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
54 个测试包全量 race、Go 1.22 兼容检查、生成项目集成测试、vet/build 及真实进程关闭检查通过,见 [验收记录](reports/httpSafetyAcceptance.md)。
|
||||||
|
|
||||||
|
空模块缓存安装远程固定版本后,10 组生成消费者和 98 项发布检查通过,耗时 107.57 秒,无本地 replace;覆盖上传限额、取消收尾、HTTP/2 断连、生成项目依赖与日志保留。标签固定在 `3b00bb55d189cbe480c7d15c8d14bcd09d3f2d52`,见 [远程验收记录](reports/httpSafetyRemote.json)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v0.26.0-preview
|
||||||
|
|
||||||
|
新增可独立使用的 i18n 底层模块及可选 HTTP/错误适配。项目维护文案和语言设置,框架提供不可变词典、语言匹配、回退、参数与分支、检查和导出;没有绑定业务身份、页面框架、数据库或响应字段名。
|
||||||
|
|
||||||
|
前后端共享版本化消息资源和固定 Unicode CLDR 46.1 基数复数规则。CLI 只读输入,支持源目录提取、缺译/参数/动态引用检查、按命名空间导出语言包和 TypeScript 类型。可选浏览器模块先加载并验证再切换,失败保留当前语言,旧请求不能覆盖新选择。
|
||||||
|
|
||||||
|
错误目录新增可选 `translation` 引用。内置响应使用展示译文,原目录文案继续参与契约校验;自定义 Encoder 通过 `PublicMessage()` 显式采用译文。业务码、HTTP 状态、成功/失败语义、cause、直接协议输出和幂等首次快照保持。旧配置与生成项目不启用时无需迁移。
|
||||||
|
|
||||||
|
完整 [使用指南](i18n/README.md)、[通用示例](examples/i18n/README.md)、[验收记录](reports/i18nAcceptance.md)。包含 translation 的新契约远程依赖最低版本为 v0.26.0-preview;未使用该元数据的模板最低版本仍为 v0.22。CLI 与框架使用同版。
|
||||||
|
|
||||||
|
同时修复 JSON 结束定界符读取失败时丢失原始错误的问题,使请求体超过外层限制时在 Go 1.22 中也保持 HTTP 413;共享目录测试工程使用 Go 1.22。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get git.yuntuo.hk/k2safe/go-omni@v0.26.0-preview
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.26.0-preview
|
||||||
|
go-omni api generate --check
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
发布前 54 个测试包全量 race 与 Go 1.22(含生成消费者)、vet/build、真实进程检查通过。空缓存安装远程固定版本后,10 组生成消费者和 87 项发布检查通过,耗时 83.76 秒,无本地 replace;包含下载模块的国际化、CLDR/浏览器一致性、TypeScript 类型和 CLI 工具链验证。标签固定在 `d1a747c36f15d964dd445d661623b68a45892adf`,[远程验收记录](reports/i18nRemote.json)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v0.25.0-preview
|
||||||
|
|
||||||
|
事务失败可以按阶段和已知结果处理,避免把“提交错误”误判为“数据库没有保存”。
|
||||||
|
|
||||||
|
- `database.InTx` 签名不变,普通错误返回 `*database.TxError`;Stage 为 begin/work/commit,Outcome 为 notStarted/rolledBack/unknown。成功提交仍返回 nil。
|
||||||
|
- Commit 报错一律 unknown;回调失败且显式 Rollback 返回 nil 才标记 rolledBack。BeginTx 失败不执行回调。ErrTxDone 不作为回滚成功证据,框架不重试事务。
|
||||||
|
- `Err` 与 `RollbackErr` 保留两条原因链,可通过 errors.Is/As 识别;APIError、目录参数提示和公开响应继续工作。内部原因不自动进入 HTTP 提示。
|
||||||
|
- 提供无需外部数据库的 [故障演示](examples/transactions/README.md)。真实 MySQL 与性能结果见 [验收记录](reports/transactionAcceptance.md)。不引入新依赖,不包含文件删除、补偿任务或业务规则。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get git.yuntuo.hk/k2safe/go-omni@v0.25.0-preview
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.25.0-preview
|
||||||
|
go-omni api generate --check
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
**兼容边界:** 既有 errors.Is/As 保持;直接 `err == sentinel`、直接类型断言或字符串匹配需调整。nil context 使用 Background,nil 回调在开启前返回 typed error。panic/Goexit 继续传播并尝试回滚,不转换为 error;标准库自动回滚不公开的原因无法还原,不能推断已回滚。结果仅覆盖按约定使用的当前事务,不覆盖非事务表、隐式提交 SQL、其他连接或外部副作用。GORM 原生 Transaction 没有被替换。
|
||||||
|
|
||||||
|
[database 使用规则](database/README.md#事务阶段与结果) 已同步。本轮 runtime 能力需要 v0.25,模板最低版本仍为 v0.22;没有新增必填配置,当前仍为 preview。
|
||||||
|
|
||||||
|
49 个测试包全量 race、Go 1.22、vet/build、取消竞争复测通过;成功事务没有新增分配。固定远程版本在空缓存下通过 77 项发布检查和 9 组生成消费者,耗时 71.93 秒。独立 MySQL TLS 容器的 53 条测试记录(含子用例)通过,包含真实提交/回滚后的确认丢失、可选 GORM、生成项目和后台关闭,耗时 28.49 秒。均无本地 replace,标签固定在 `7a177744ab2bb85f23efc0ec267fd905d349bd4b`。见 [远程发布记录](reports/transactionRemote.json)、[远程 MySQL 记录](reports/transactionMySQLRemote.json)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v0.24.0-preview(历史版本)
|
||||||
|
|
||||||
|
业务项目通过配置开启跨域,不需要手写 OPTIONS Handler 或逐条修改接口。
|
||||||
|
|
||||||
|
- 默认关闭,在 `server.cors` 显式配置来源、方法和请求头;统一文件加载、环境引用和字段级诊断自动生效。生成项目无需修改 main、svc 或 `.api`。
|
||||||
|
- 预检在鉴权前返回 204/403 空响应;实际请求继续鉴权、业务错误编码和路由处理。允许来源可读取 401/403/404/405/500,上传下载和流式能力保留。
|
||||||
|
- 只支持精确 HTTP(S) 来源,公开无凭据服务可配置单一 `*`;禁止 `*` 与凭据混用。配置初始化快照、请求并发隔离、Vary 缓存分流和有界请求头处理。
|
||||||
|
- 同一个 App 策略覆盖标准库、native 和 HTTP2;独立服务可使用 `cors.New(...).Wrap(handler)`。没有新增第三方依赖,没有移除现有功能。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get git.yuntuo.hk/k2safe/go-omni@v0.24.0-preview
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.24.0-preview
|
||||||
|
go-omni api generate
|
||||||
|
go-omni api generate --check
|
||||||
|
go run . -config etc/config.yaml -check-config
|
||||||
|
```
|
||||||
|
|
||||||
|
现有项目不配置 CORS 时行为不变。代理已处理 CORS 的项目继续关闭;跨域不替代登录、权限或 CSRF 防护。通过 App/Engine/Run 接入,直接对外暴露底层 Router 会绕过应用外层策略。本轮新增 runtime 配置需 v0.24,未使用该配置的模板最低版本仍为 v0.22。
|
||||||
|
|
||||||
|
[配置与规则](cors/README.md)、[可运行示例](examples/cors/README.md)、[验收与性能](reports/corsAcceptance.md) 已同步。49 个测试包全量 race、Go 1.22、vet/build、实际生成项目、三种 transport 的 TLS 场景和 20 项真实浏览器检查通过;30 秒 fuzz 约 288 万次。当前仍为 preview。
|
||||||
|
|
||||||
|
空缓存安装远程版本后,9 组生成消费者和 70 项发布检查通过,耗时 66.78 秒,无本地 replace;包括下载模块的 cors/app race、三种 transport 的 TLS 检查和只改配置的生成项目。标签固定在 `800974cfff6228c2697373d9961d3c7c75611d08`,见 [远程记录](reports/corsRemote.json)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v0.23.0-preview(历史版本)
|
||||||
|
|
||||||
|
减少业务接入时重复维护的配置校验、HTTP 包装和类型检查。
|
||||||
|
|
||||||
|
- 配置诊断定位 database.port、database.pool.maxIdleConns、server.log.level/timeZone 等具体字段,保留文件、环境变量、行列和安全原因;凭据不进入公开提示,原始原因可 errors.Is/As。
|
||||||
|
- JSON 可传 `httpc.WithSuccessStatuses(200)`,默认仍接受全部 2xx;不匹配时不改目标,继续负责有界读取、关闭和连接回收,不添加新的重试规则。
|
||||||
|
- `copyx.Check[S,T](options...)` 或已有 Copier.Check 复用同一组 Go 选项,无需样本和映射文件;实际拷贝不再因集合为空而漏过确定的类型不匹配。动态 interface、nil 值、converter 失败及深拷贝共享引用仍有运行时检查。
|
||||||
|
- `server.log.timeZone: Asia/Shanghai` 配置实例时区;JSON/text/子日志一致,业务 time.Time 属性保持原样。极简镜像可用 `go build -tags timetzdata .` 内置时区数据,库不强制增加体积。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get git.yuntuo.hk/k2safe/go-omni@v0.23.0-preview
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.23.0-preview
|
||||||
|
go-omni api generate
|
||||||
|
go-omni api generate --check
|
||||||
|
go run . -config etc/config.yaml -check-config
|
||||||
|
```
|
||||||
|
|
||||||
|
现有调用接口保留;配置错误文本现在更精确,业务应通过类型/字段识别错误。空集合里确定的类型错误会提前暴露,需要显式 converter 或调整实际类型,不应靠空值跳过校验。预检通过不代表 converter 对所有业务值必定成功。当前模板最低版本仍是 v0.22,本轮新增 runtime 能力需使用 v0.23。
|
||||||
|
|
||||||
|
[完整示例](examples/developerTools/README.md)、[配置](CONFIG.md)、[JSON 选项](httpc/README.md)、[类型预检](COPY.md#类型预检)、[日志时区](logx/README.md#实例时区)。验收与性能记录见 [本轮验收](reports/developerToolsAcceptance.md)。仍为 preview。
|
||||||
|
|
||||||
|
48 个测试包全量 race、Go 1.22、vet/build、实际生成消费者及约 392 万次 copyx fuzz 通过。空缓存安装远程版本后,66 项发布检查通过,包括下载模块的 config/httpc/copyx/logx race;耗时 57.06 秒,无本地 replace。标签固定在 `3bbc6f60d76cb9eae6f01fa6ca122eb1fbf481af`,见 [远程验收](reports/developerToolsRemote.json)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v0.22.0-preview(历史版本)
|
||||||
|
|
||||||
|
配置文件明确决定每个值的来源,项目只维护 Config、Default()、Validate() 和文件。
|
||||||
|
|
||||||
|
- `${NAME}` 必填,`${NAME:-fallback}` 在未设置/空字符串时使用备用值,`$$` 转义 `$`。支持字符串组合;环境值不二次展开,凭据空格和特殊字符原样保留。
|
||||||
|
- 在 JSON/YAML/XML 已解析的字段值上展开,再按目标 Go 类型绑定;缺失字段保留默认值,0/false/空字符串可显式覆盖。字段错误定位文件、环境名和可用行列,不回显值。
|
||||||
|
- 生成 Load/main 自动接入;新增 `-check-config`,选定一个文件检查,成功退出,不启动日志/依赖/监听。精简生成配置,重新生成保留业务 Config、Default、Validate 和文件。
|
||||||
|
- **不兼容变更:旧自动环境覆盖、前缀/别名、env/envDefault、database.ConfigFromEnv/ApplyEnv 已移除。** LoadFile/NewReloader 不再接收 prefix,omni.LoadConfig 参数改为文件路径。发现旧标签提示迁移。
|
||||||
|
- 配置文件中的响应模板用 `$${code}` 等转义;`.api` 中的 `${code}` 不变。日志 file/output/rotation 均由显式文件配置决定,不再隐式清除。
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get git.yuntuo.hk/k2safe/go-omni@v0.22.0-preview
|
||||||
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.22.0-preview
|
||||||
|
# 按 CONFIG.md 迁移开发者配置后:
|
||||||
|
go-omni api generate
|
||||||
|
go-omni api generate --check
|
||||||
|
go run . -config etc/config.yaml -check-config
|
||||||
|
```
|
||||||
|
|
||||||
|
[完整规则与迁移](CONFIG.md)、[可运行示例](examples/configuration/README.md)、[场景与性能验收](reports/explicitConfigurationAcceptance.md) 已同步。48 个测试包全量 race、实际生成消费者、Go 1.22、vet/build 和约 28 万次引用 fuzz 通过;小配置固定值/带引用加载约 25 µs,属于本机启动阶段基准。当前仍为 preview。
|
||||||
|
|
||||||
|
空缓存远程 CLI 安装、下载模块 config/httpc race、8 组实际生成消费者与 `-check-config` 验收通过,无本地 replace。标签固定在 `d598b2ebee8cde36859021e882a6bc1e0bb4fd4c`;见 [远程验收记录](reports/explicitConfigurationRemote.json)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# v0.21.0-preview(历史版本)
|
||||||
|
|
||||||
让带认证的 HTTP 调用和内部直连可以直接使用 httpc,减少手写 Transport 与 JSON 响应处理。
|
让带认证的 HTTP 调用和内部直连可以直接使用 httpc,减少手写 Transport 与 JSON 响应处理。
|
||||||
|
|
||||||
@@ -17,6 +264,8 @@ go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.21.0-preview
|
|||||||
|
|
||||||
[参数与使用说明](httpc/README.md)、[可运行示例](examples/httpClient/README.md)、[场景与性能验收](reports/httpcAcceptance.md) 已同步到 README。48 个测试包全量 race、Go 1.22 兼容、vet/build、约 110 万次请求头 fuzz 通过。性能来自本机同条件基准,不能代替项目实际流量验证;仍为 preview。
|
[参数与使用说明](httpc/README.md)、[可运行示例](examples/httpClient/README.md)、[场景与性能验收](reports/httpcAcceptance.md) 已同步到 README。48 个测试包全量 race、Go 1.22 兼容、vet/build、约 110 万次请求头 fuzz 通过。性能来自本机同条件基准,不能代替项目实际流量验证;仍为 preview。
|
||||||
|
|
||||||
|
空缓存远程安装、下载模块的 httpc race 与示例、8 组生成消费者验收通过,无本地 replace。标签固定在 `96e7d9629b5c8b78e9cfefbcb05da0ab702c242b`,见 [远程记录](reports/httpcRemote.json)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# v0.20.0-preview(历史版本)
|
# v0.20.0-preview(历史版本)
|
||||||
@@ -137,7 +386,7 @@ go-omni api generate --check
|
|||||||
go test ./...
|
go test ./...
|
||||||
```
|
```
|
||||||
|
|
||||||
旧项目 config/Logic/svc 继续由开发者维护,不会被重新生成覆盖。已有环境配置需按 [一次覆盖完整配置](DEVELOPMENT.md#一次覆盖完整配置与-http日志短名称) 合并 OverlayEnv 与 ApplyEnv;共享错误目录接入见 [workspace 配置](DEVELOPMENT.md#同一份-go-错误目录供多个服务生成)。已有手写权限 Handler 切换自动生成前,先迁出实现并显式移除 Manual 文件,生成器不会静默删除。
|
旧项目 config/Logic/svc 继续由开发者维护,不会被重新生成覆盖。已有环境配置需按 本版当时的统一覆盖方式合并 OverlayEnv 与 ApplyEnv(该模式已在 v0.22 移除,当前升级见 [配置迁移](CONFIG.md#v022-迁移旧模式已移除));共享错误目录接入见 [workspace 配置](DEVELOPMENT.md#同一份-go-错误目录供多个服务生成)。已有手写权限 Handler 切换自动生成前,先迁出实现并显式移除 Manual 文件,生成器不会静默删除。
|
||||||
|
|
||||||
全量 race(含实际生成消费者)、vet、build、根入口/退出/再生成验证通过。eTax 只读契约验收:API 文档 20,751,149 → 847,171 字节,ID 文档 1,008,210 → 392,481 字节;70 个操作展开引用后与原文档完全一致。空缓存远程 CLI / 固定版本消费者验收也已通过,没有本地 replace;标签固定在 `64e3bd6cfced225476d58e95037249cd0a4fe695`。详见 [验收记录](reports/contractFollowupAcceptance.md)。这不代表已完成业务项目迁移。
|
全量 race(含实际生成消费者)、vet、build、根入口/退出/再生成验证通过。eTax 只读契约验收:API 文档 20,751,149 → 847,171 字节,ID 文档 1,008,210 → 392,481 字节;70 个操作展开引用后与原文档完全一致。空缓存远程 CLI / 固定版本消费者验收也已通过,没有本地 replace;标签固定在 `64e3bd6cfced225476d58e95037249cd0a4fe695`。详见 [验收记录](reports/contractFollowupAcceptance.md)。这不代表已完成业务项目迁移。
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# 公共响应体与独立接口协议
|
# 公共响应体与独立接口协议
|
||||||
|
|
||||||
|
v0.26 新增的 [国际化适配](i18n/README.md#接入-go-omni-错误与-http)保留现有响应契约。`Result.Message` 是校验使用的目录原文;`MessageOverride` 是可选展示译文,`PublicMessage()` 返回最终文案。内置 Formatter 自动使用译文;自定义 Encoder 显式选择,固定文本/XML 回执和直接输出不自动改写。
|
||||||
|
|
||||||
这些功能从 `v0.8.0-preview` 提供。CLI 与项目框架依赖请固定到同一版本;源码开发可显式使用 `--framework-dir`。`v0.7.0-preview.1` 不包含响应模板声明。
|
这些功能从 `v0.8.0-preview` 提供。CLI 与项目框架依赖请固定到同一版本;源码开发可显式使用 `--framework-dir`。`v0.7.0-preview.1` 不包含响应模板声明。
|
||||||
|
|
||||||
go-omni 的响应编码由应用或接口配置。没有配置时保留旧版默认值;业务可以定义自己的字段、嵌套结构、错误码类型、提示和空值行为。`response.Response` 仅保留为兼容 DTO,运行时与生成器不再通过它固定响应结构。
|
go-omni 的响应编码由应用或接口配置。没有配置时保留旧版默认值;业务可以定义自己的字段、嵌套结构、错误码类型、提示和空值行为。`response.Response` 仅保留为兼容 DTO,运行时与生成器不再通过它固定响应结构。
|
||||||
|
|||||||
79
app/app.go
79
app/app.go
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/cors"
|
||||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
@@ -24,6 +25,7 @@ import (
|
|||||||
// httpx.Router, leaving the network implementation replaceable.
|
// httpx.Router, leaving the network implementation replaceable.
|
||||||
type App struct {
|
type App struct {
|
||||||
router httpx.Router
|
router httpx.Router
|
||||||
|
handler http.Handler
|
||||||
config config.Config
|
config config.Config
|
||||||
middlewares []httpx.Middleware
|
middlewares []httpx.Middleware
|
||||||
closers []func() error
|
closers []func() error
|
||||||
@@ -31,6 +33,10 @@ type App struct {
|
|||||||
closeErr error
|
closeErr error
|
||||||
clientIPResolver *httpx.ClientIPResolver
|
clientIPResolver *httpx.ClientIPResolver
|
||||||
responseEncoder response.Encoder
|
responseEncoder response.Encoder
|
||||||
|
requests requestDrain
|
||||||
|
bodyLimits []routeBodyLimit
|
||||||
|
validateOnce sync.Once
|
||||||
|
validateErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runner is the small lifecycle boundary used by an HTTP server
|
// Runner is the small lifecycle boundary used by an HTTP server
|
||||||
@@ -67,6 +73,11 @@ func configure(options ...config.Option) config.Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newApp(router httpx.Router, settings config.Config) *App {
|
func newApp(router httpx.Router, settings config.Config) *App {
|
||||||
|
settings.CORS = settings.CORS.Clone()
|
||||||
|
corsPolicy, corsErr := cors.New(settings.CORS)
|
||||||
|
if corsErr != nil {
|
||||||
|
panic(config.AtField("cors", corsErr))
|
||||||
|
}
|
||||||
settings.Response = settings.Response.Clone()
|
settings.Response = settings.Response.Clone()
|
||||||
settings.Errors = settings.Errors.Clone()
|
settings.Errors = settings.Errors.Clone()
|
||||||
encoder, responseErr := response.New(settings.Response)
|
encoder, responseErr := response.New(settings.Response)
|
||||||
@@ -93,6 +104,14 @@ func newApp(router httpx.Router, settings config.Config) *App {
|
|||||||
}
|
}
|
||||||
middlewares = append(middlewares, middleware.Recovery())
|
middlewares = append(middlewares, middleware.Recovery())
|
||||||
application := &App{router: router, config: settings, middlewares: middlewares, clientIPResolver: resolver, responseEncoder: responseEncoder}
|
application := &App{router: router, config: settings, middlewares: middlewares, clientIPResolver: resolver, responseEncoder: responseEncoder}
|
||||||
|
application.requests.done = make(chan struct{})
|
||||||
|
application.handler = corsPolicy.Wrap(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.ContentLength > application.hardBodyLimit() {
|
||||||
|
application.adapt(func(*httpx.Context) error { return apierrors.New(413, "BODY_TOO_LARGE", "request body exceeds limit") }).ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.ServeHTTP(w, r)
|
||||||
|
}))
|
||||||
if fallback, ok := router.(httpx.FallbackRouter); ok {
|
if fallback, ok := router.(httpx.FallbackRouter); ok {
|
||||||
adaptError := func(status int, code, message string) http.Handler {
|
adaptError := func(status int, code, message string) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -104,21 +123,39 @@ func newApp(router httpx.Router, settings config.Config) *App {
|
|||||||
return application
|
return application
|
||||||
}
|
}
|
||||||
|
|
||||||
// Router returns the transport contract used by this application.
|
// Router exposes the underlying routing contract for advanced registration.
|
||||||
|
// Serve through App or Engine for CORS, hard body limits and shutdown tracking.
|
||||||
func (a *App) Router() httpx.Router { return a.router }
|
func (a *App) Router() httpx.Router { return a.router }
|
||||||
|
|
||||||
// Engine is kept as a compatibility name for the old API. It returns a
|
// Engine is kept as a compatibility name for the old API. It returns a
|
||||||
// standard http.Handler, so callers do not depend on a router type.
|
// standard http.Handler, so callers do not depend on a router type.
|
||||||
func (a *App) Engine() http.Handler { return a.router }
|
func (a *App) Engine() http.Handler { return a }
|
||||||
|
|
||||||
// ServeHTTP implements http.Handler for standard servers and HTTP tests.
|
// ServeHTTP implements http.Handler for standard servers and HTTP tests.
|
||||||
func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
|
||||||
a.router.ServeHTTP(writer, request)
|
ctx, work := a.requests.begin(request.Context())
|
||||||
|
if work == nil {
|
||||||
|
// Do not invoke encoders, middleware or loggers after their owners close.
|
||||||
|
http.Error(writer, "server shutting down", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer a.requests.end(work)
|
||||||
|
a.validateOnce.Do(func() { a.validateErr = a.ValidateHTTP() })
|
||||||
|
if a.validateErr != nil {
|
||||||
|
http.Error(writer, "invalid HTTP configuration", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request = request.WithContext(ctx)
|
||||||
|
if request.Body != nil {
|
||||||
|
request.Body = http.MaxBytesReader(writer, request.Body, a.hardBodyLimit())
|
||||||
|
}
|
||||||
|
a.handler.ServeHTTP(writer, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config returns the effective application configuration.
|
// Config returns the effective application configuration.
|
||||||
func (a *App) Config() config.Config {
|
func (a *App) Config() config.Config {
|
||||||
settings := a.config
|
settings := a.config
|
||||||
|
settings.CORS = settings.CORS.Clone()
|
||||||
settings.Response = settings.Response.Clone()
|
settings.Response = settings.Response.Clone()
|
||||||
settings.Errors = settings.Errors.Clone()
|
settings.Errors = settings.Errors.Clone()
|
||||||
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
|
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
|
||||||
@@ -133,7 +170,8 @@ func (a *App) Use(middleware ...httpx.Middleware) *App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OnShutdown registers a cleanup function owned by the application. Cleanup
|
// OnShutdown registers a cleanup function owned by the application. Cleanup
|
||||||
// functions run in reverse registration order when Close or RunContext ends.
|
// functions run in reverse registration order after all handlers have returned.
|
||||||
|
// Incomplete draining skips these callbacks and returns ErrResourcesRetained.
|
||||||
// This keeps omni.New as the only public startup entry point while allowing
|
// This keeps omni.New as the only public startup entry point while allowing
|
||||||
// projects to attach databases, loggers and other resources to its lifecycle.
|
// projects to attach databases, loggers and other resources to its lifecycle.
|
||||||
func (a *App) OnShutdown(cleanup func() error) *App {
|
func (a *App) OnShutdown(cleanup func() error) *App {
|
||||||
@@ -143,9 +181,21 @@ func (a *App) OnShutdown(cleanup func() error) *App {
|
|||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close releases application resources. It is safe to call more than once.
|
// Close stops admission, cancels active requests and waits for their handlers
|
||||||
|
// to return before releasing resources. It is idempotent. If cancellation grace
|
||||||
|
// expires, ErrResourcesRetained is permanent and no cleanup callbacks run.
|
||||||
|
// Use RunContext/ServeContext for graceful draining before cancellation. Do not
|
||||||
|
// call Close from a handler or cleanup callback; it would wait for itself.
|
||||||
func (a *App) Close() error {
|
func (a *App) Close() error {
|
||||||
a.closeOnce.Do(func() {
|
a.closeOnce.Do(func() {
|
||||||
|
timeout := a.config.HTTP.ShutdownCancelTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
if err := a.requests.cancelAndWait(timeout); err != nil {
|
||||||
|
a.closeErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
for index := len(a.closers) - 1; index >= 0; index-- {
|
for index := len(a.closers) - 1; index >= 0; index-- {
|
||||||
if err := a.closers[index](); err != nil {
|
if err := a.closers[index](); err != nil {
|
||||||
a.closeErr = errors.Join(a.closeErr, err)
|
a.closeErr = errors.Join(a.closeErr, err)
|
||||||
@@ -167,7 +217,7 @@ func (a *App) Run(address ...string) error {
|
|||||||
// Invalid HTTP options panic; RunContext/ServeContext return validation errors.
|
// Invalid HTTP options panic; RunContext/ServeContext return validation errors.
|
||||||
// Use RunContext or ServeContext to apply the configured transport and TLS.
|
// Use RunContext or ServeContext to apply the configured transport and TLS.
|
||||||
func (a *App) NewServer(address string) *http.Server {
|
func (a *App) NewServer(address string) *http.Server {
|
||||||
if err := a.config.HTTP.Validate(); err != nil {
|
if err := a.ValidateHTTP(); err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
if address == "" {
|
if address == "" {
|
||||||
@@ -177,7 +227,7 @@ func (a *App) NewServer(address string) *http.Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
cfg := a.config.HTTP
|
cfg := a.config.HTTP
|
||||||
return &http.Server{Addr: address, Handler: a.router,
|
return &http.Server{Addr: address, Handler: a,
|
||||||
ReadHeaderTimeout: cfg.ReadHeaderTimeout, ReadTimeout: cfg.ReadTimeout,
|
ReadHeaderTimeout: cfg.ReadHeaderTimeout, ReadTimeout: cfg.ReadTimeout,
|
||||||
WriteTimeout: cfg.WriteTimeout, IdleTimeout: cfg.IdleTimeout,
|
WriteTimeout: cfg.WriteTimeout, IdleTimeout: cfg.IdleTimeout,
|
||||||
MaxHeaderBytes: cfg.MaxHeaderBytes, ErrorLog: cfg.ErrorLog}
|
MaxHeaderBytes: cfg.MaxHeaderBytes, ErrorLog: cfg.ErrorLog}
|
||||||
@@ -192,6 +242,8 @@ func (a *App) RunContext(ctx context.Context, address ...string) (runErr error)
|
|||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return a.Close()
|
return a.Close()
|
||||||
}
|
}
|
||||||
|
stopWatching := a.watchShutdown(ctx)
|
||||||
|
defer stopWatching()
|
||||||
serve, err := a.prepareTransport()
|
serve, err := a.prepareTransport()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Join(err, a.Close())
|
return errors.Join(err, a.Close())
|
||||||
@@ -218,6 +270,11 @@ func (a *App) RunWith(ctx context.Context, runner Runner, address ...string) (ru
|
|||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return a.Close()
|
return a.Close()
|
||||||
}
|
}
|
||||||
|
if err := a.ValidateHTTP(); err != nil {
|
||||||
|
return errors.Join(err, a.Close())
|
||||||
|
}
|
||||||
|
stopWatching := a.watchShutdown(ctx)
|
||||||
|
defer stopWatching()
|
||||||
return errors.Join(runner.Run(ctx, a, a.listenAddress(address...)), a.Close())
|
return errors.Join(runner.Run(ctx, a, a.listenAddress(address...)), a.Close())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,12 +308,16 @@ func (a *App) serveContext(ctx context.Context, server *http.Server, listener ne
|
|||||||
|
|
||||||
select {
|
select {
|
||||||
case err := <-serveErr:
|
case err := <-serveErr:
|
||||||
|
// Serve returning only proves the accept loop ended. Cancel any remaining
|
||||||
|
// connections before the application completion barrier and cleanup.
|
||||||
|
_ = server.Close()
|
||||||
closeErr := a.Close()
|
closeErr := a.Close()
|
||||||
if err == http.ErrServerClosed {
|
if err == http.ErrServerClosed {
|
||||||
return closeErr
|
return closeErr
|
||||||
}
|
}
|
||||||
return errors.Join(err, closeErr)
|
return errors.Join(err, closeErr)
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
a.requests.stop()
|
||||||
shutdownTimeout := a.config.ShutdownTimeout
|
shutdownTimeout := a.config.ShutdownTimeout
|
||||||
if shutdownTimeout <= 0 {
|
if shutdownTimeout <= 0 {
|
||||||
shutdownTimeout = 10 * time.Second
|
shutdownTimeout = 10 * time.Second
|
||||||
@@ -265,8 +326,8 @@ func (a *App) serveContext(ctx context.Context, server *http.Server, listener ne
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
shutdownErr := server.Shutdown(shutdownCtx)
|
shutdownErr := server.Shutdown(shutdownCtx)
|
||||||
if shutdownErr != nil {
|
if shutdownErr != nil {
|
||||||
// Shutdown leaves active connections open on timeout. Close them so
|
// Closing sockets requests cancellation; App.Close separately waits
|
||||||
// request contexts are canceled before releasing shared resources.
|
// for the actual handler return before releasing shared resources.
|
||||||
shutdownErr = errors.Join(shutdownErr, server.Close())
|
shutdownErr = errors.Join(shutdownErr, server.Close())
|
||||||
}
|
}
|
||||||
if err := <-serveErr; err != nil && err != http.ErrServerClosed {
|
if err := <-serveErr; err != nil && err != http.ErrServerClosed {
|
||||||
|
|||||||
63
app/body.go
63
app/body.go
@@ -1,11 +1,68 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
"fmt"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/internal/httplimits"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type routeBodyLimit struct {
|
||||||
|
method, path string
|
||||||
|
maxBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) hardBodyLimit() int64 {
|
||||||
|
if a.config.HTTP.MaxBodyBytes > 0 {
|
||||||
|
return a.config.HTTP.MaxBodyBytes
|
||||||
|
}
|
||||||
|
return httplimits.MaxBodyBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) defaultBodyLimit() int64 {
|
||||||
|
limit := a.config.HTTP.DefaultMaxBodyBytes
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = httplimits.DefaultBodyBytes
|
||||||
|
}
|
||||||
|
if limit > a.hardBodyLimit() {
|
||||||
|
limit = a.hardBodyLimit()
|
||||||
|
}
|
||||||
|
// Invalid configuration is rejected at startup or the first external request.
|
||||||
|
if limit > httplimits.MaxBodyBytes {
|
||||||
|
limit = httplimits.MaxBodyBytes
|
||||||
|
}
|
||||||
|
return limit
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateHTTP checks HTTP settings and registered route body limits without
|
||||||
|
// opening a listener. Call after registration when hosting App/Engine externally.
|
||||||
|
// Built-in serving entry points and RunWith perform this check automatically.
|
||||||
|
func (a *App) ValidateHTTP() error {
|
||||||
|
if err := a.config.HTTP.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, route := range a.bodyLimits {
|
||||||
|
if route.maxBytes > a.hardBodyLimit() {
|
||||||
|
return &config.ConfigError{Field: "http.maxBodyBytes", Message: fmt.Sprintf("route %s %s maxBytes=%d exceeds service hard limit=%d; raise http.maxBodyBytes or lower the route limit", route.method, route.path, route.maxBytes, a.hardBodyLimit())}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinRoutePath(prefix, path string) string {
|
||||||
|
if path == "" {
|
||||||
|
if prefix == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return prefix
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(prefix, "/") + "/" + strings.TrimPrefix(path, "/")
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) bodyHandler(maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) http.Handler {
|
func (a *App) bodyHandler(maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) http.Handler {
|
||||||
checked := httpx.LimitBody(maxBytes)(handler)
|
checked := httpx.LimitBody(maxBytes)(handler)
|
||||||
if encoder == nil {
|
if encoder == nil {
|
||||||
@@ -25,8 +82,10 @@ func (a *App) bodyHandler(maxBytes int64, encoder response.Encoder, handler http
|
|||||||
// the response policy, including auth failures and parameter errors.
|
// the response policy, including auth failures and parameter errors.
|
||||||
func (a *App) HandleWithBodyLimit(method, path string, maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
|
func (a *App) HandleWithBodyLimit(method, path string, maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||||
a.router.Handle(method, path, a.bodyHandler(maxBytes, encoder, handler, middleware...))
|
a.router.Handle(method, path, a.bodyHandler(maxBytes, encoder, handler, middleware...))
|
||||||
|
a.bodyLimits = append(a.bodyLimits, routeBodyLimit{method, path, maxBytes})
|
||||||
}
|
}
|
||||||
func (g *RouterGroup) HandleWithBodyLimit(method, path string, maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
|
func (g *RouterGroup) HandleWithBodyLimit(method, path string, maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||||
combined := append(append([]httpx.Middleware{}, g.middlewares...), middleware...)
|
combined := append(append([]httpx.Middleware{}, g.middlewares...), middleware...)
|
||||||
g.group.Handle(method, path, g.app.bodyHandler(maxBytes, encoder, handler, combined...))
|
g.group.Handle(method, path, g.app.bodyHandler(maxBytes, encoder, handler, combined...))
|
||||||
|
g.app.bodyLimits = append(g.app.bodyLimits, routeBodyLimit{method, joinRoutePath(g.prefix, path), maxBytes})
|
||||||
}
|
}
|
||||||
|
|||||||
224
app/bodyPolicy_test.go
Normal file
224
app/bodyPolicy_test.go
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
func serveBodyPolicy(t *testing.T, a *App, transport string) (string, *http.Client) {
|
||||||
|
t.Helper()
|
||||||
|
cert, key, clientTLS := testutil.CertificateFiles(t)
|
||||||
|
a.config.Transport = transport
|
||||||
|
a.config.TLS = config.TLSConfig{CertFile: cert, KeyFile: key}
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- a.ServeContext(ctx, l) }()
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: clientTLS, ForceAttemptHTTP2: transport == config.TransportHTTP2}}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
client.CloseIdleConnections()
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Error("server did not stop")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return "https://" + l.Addr().String(), client
|
||||||
|
}
|
||||||
|
|
||||||
|
func consumeBody(c *httpx.Context) error {
|
||||||
|
_, err := io.Copy(io.Discard, c.Request().Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.Text(200, "accepted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadPolicyAcrossTransports(t *testing.T) {
|
||||||
|
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
||||||
|
t.Run(transport, func(t *testing.T) {
|
||||||
|
a := New()
|
||||||
|
a.POST("/default", consumeBody)
|
||||||
|
a.HandleWithBodyLimit("POST", "/large", 16<<20, nil, consumeBody)
|
||||||
|
a.Group("/v1").Group("files").HandleWithBodyLimit("POST", "/large", 16<<20, nil, consumeBody)
|
||||||
|
base, client := serveBodyPolicy(t, a, transport)
|
||||||
|
payload := bytes.Repeat([]byte{'x'}, 9<<20)
|
||||||
|
for _, chunked := range []bool{false, true} {
|
||||||
|
for _, path := range []string{"/default", "/large", "/v1/files/large"} {
|
||||||
|
var body io.Reader = bytes.NewReader(payload)
|
||||||
|
if chunked {
|
||||||
|
body = struct{ io.Reader }{body}
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest("POST", base+path, body)
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(res.Body)
|
||||||
|
res.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := 200
|
||||||
|
if path == "/default" {
|
||||||
|
want = 413
|
||||||
|
}
|
||||||
|
if res.StatusCode != want || (want == 413 && !strings.Contains(string(data), "BODY_TOO_LARGE")) {
|
||||||
|
t.Fatalf("%s unknownLength=%v: %d %s", path, chunked, res.StatusCode, data)
|
||||||
|
}
|
||||||
|
if transport == config.TransportHTTP2 && res.ProtoMajor != 2 {
|
||||||
|
t.Fatal("HTTP/2 not negotiated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfiguredBodyPolicyBeforeMiddleware(t *testing.T) {
|
||||||
|
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
||||||
|
t.Run(transport, func(t *testing.T) {
|
||||||
|
a := New(config.WithHTTPConfig(config.HTTPConfig{DefaultMaxBodyBytes: 32, MaxBodyBytes: 128}))
|
||||||
|
var calls atomic.Int32
|
||||||
|
a.Use(func(next httpx.Handler) httpx.Handler {
|
||||||
|
return func(c *httpx.Context) error {
|
||||||
|
data, err := io.ReadAll(c.Request().Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.Request().Body = io.NopCloser(bytes.NewReader(data))
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
h := func(c *httpx.Context) error { calls.Add(1); return consumeBody(c) }
|
||||||
|
a.POST("/default", h)
|
||||||
|
a.HandleWithBodyLimit("POST", "/override", 64, nil, h)
|
||||||
|
a.HandleWithBodyLimit("POST", "/hard", 128, nil, h)
|
||||||
|
base, client := serveBodyPolicy(t, a, transport)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
path string
|
||||||
|
size, status int
|
||||||
|
}{
|
||||||
|
{"/default", 32, 200}, {"/default", 33, 413}, {"/override", 64, 200}, {"/override", 65, 413}, {"/hard", 128, 200}, {"/hard", 129, 413},
|
||||||
|
} {
|
||||||
|
for _, unknown := range []bool{false, true} {
|
||||||
|
before := calls.Load()
|
||||||
|
var body io.Reader = strings.NewReader(strings.Repeat("a", tc.size))
|
||||||
|
if unknown {
|
||||||
|
body = struct{ io.Reader }{body}
|
||||||
|
}
|
||||||
|
res, err := client.Post(base+tc.path, "application/octet-stream", body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := io.ReadAll(res.Body)
|
||||||
|
res.Body.Close()
|
||||||
|
if err != nil || res.StatusCode != tc.status {
|
||||||
|
t.Fatalf("%+v unknown=%v: %d %s %v", tc, unknown, res.StatusCode, data, err)
|
||||||
|
}
|
||||||
|
if tc.status == 413 && (calls.Load() != before || !strings.Contains(string(data), "BODY_TOO_LARGE")) {
|
||||||
|
t.Fatal("oversize reached business or lost error", string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBodyHardLimitConflictBeforeServing(t *testing.T) {
|
||||||
|
for _, entry := range []string{"RunContext", "ServeContext", "RunWith", "NewServer", "Engine"} {
|
||||||
|
t.Run(entry, func(t *testing.T) {
|
||||||
|
a := New(config.WithHTTPConfig(config.HTTPConfig{MaxBodyBytes: 8 << 20}))
|
||||||
|
a.Group("/v1").Group("files").HandleWithBodyLimit("POST", "/upload/", 16<<20, nil, consumeBody)
|
||||||
|
closed := 0
|
||||||
|
a.OnShutdown(func() error { closed++; return nil })
|
||||||
|
if err := a.ValidateHTTP(); err == nil {
|
||||||
|
t.Fatal("external preflight accepted conflicting route limit")
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
switch entry {
|
||||||
|
case "RunContext":
|
||||||
|
err = a.RunContext(context.Background(), "bad address")
|
||||||
|
case "ServeContext":
|
||||||
|
l, e := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
err = a.ServeContext(context.Background(), l)
|
||||||
|
if _, e = l.Accept(); !errors.Is(e, net.ErrClosed) {
|
||||||
|
t.Fatal("listener leaked", e)
|
||||||
|
}
|
||||||
|
case "RunWith":
|
||||||
|
err = a.RunWith(context.Background(), runnerFunc(func(context.Context, http.Handler, string) error { t.Error("runner started"); return nil }))
|
||||||
|
case "NewServer":
|
||||||
|
func() {
|
||||||
|
defer func() {
|
||||||
|
if p := recover(); p != nil {
|
||||||
|
err, _ = p.(error)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
a.NewServer("")
|
||||||
|
}()
|
||||||
|
_ = a.Close()
|
||||||
|
case "Engine":
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.Engine().ServeHTTP(w, httptest.NewRequest("POST", "/v1/files/upload/", nil))
|
||||||
|
if w.Code != 503 {
|
||||||
|
t.Fatal(w.Code)
|
||||||
|
}
|
||||||
|
err = a.validateErr
|
||||||
|
_ = a.Close()
|
||||||
|
}
|
||||||
|
for _, part := range []string{"POST /v1/files/upload/", "16777216", "8388608", "http.maxBodyBytes"} {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), part) {
|
||||||
|
t.Fatalf("missing %q: %v", part, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if closed != 1 {
|
||||||
|
t.Fatal("cleanup count", closed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultBodyLimitRegistrationPaths(t *testing.T) {
|
||||||
|
a := New(config.WithHTTPConfig(config.HTTPConfig{DefaultMaxBodyBytes: 4, MaxBodyBytes: 16}))
|
||||||
|
h := func(*httpx.Context) error { t.Error("oversize reached business"); return nil }
|
||||||
|
encoder, _ := response.New(response.Config{})
|
||||||
|
a.Handle("POST", "/handle", h)
|
||||||
|
a.HandleWithResponse("POST", "/response", encoder, h)
|
||||||
|
a.Any("/any", h)
|
||||||
|
g := a.Group("/group")
|
||||||
|
g.POST("/post", h)
|
||||||
|
g.Any("/any", h)
|
||||||
|
g.HandleWithResponse("POST", "/response", encoder, h)
|
||||||
|
for _, path := range []string{"/handle", "/response", "/any", "/group/post", "/group/any", "/group/response"} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("POST", path, strings.NewReader("12345"))
|
||||||
|
a.ServeHTTP(w, r)
|
||||||
|
if w.Code != 413 {
|
||||||
|
t.Fatal(path, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
187
app/cors_test.go
Normal file
187
app/cors_test.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
package app_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
func corsApp() *omni.App {
|
||||||
|
return omni.New(omni.WithCORS(omni.CORSConfig{Enabled: true, AllowedOrigins: []string{"https://web.example"}, AllowedMethods: []string{"GET", "POST", "PUT"}, AllowedHeaders: []string{"Authorization", "Content-Type"}, ExposedHeaders: []string{"Content-Disposition"}}), omni.WithResponse(response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 999, MessageField: "msg", DataMode: "always"}))
|
||||||
|
}
|
||||||
|
func TestAppCORSRoutingAndErrors(t *testing.T) {
|
||||||
|
a := corsApp()
|
||||||
|
defer a.Close()
|
||||||
|
var middlewareCalls int
|
||||||
|
a.Use(func(next omni.Handler) omni.Handler {
|
||||||
|
return func(c *omni.Context) error { middlewareCalls++; return next(c) }
|
||||||
|
})
|
||||||
|
a.GET("/secure", func(c *omni.Context) error { return c.OK("private") }, func(next omni.Handler) omni.Handler {
|
||||||
|
return func(c *omni.Context) error { return omni.Unauthorized("LOGIN_REQUIRED", "login required") }
|
||||||
|
})
|
||||||
|
a.GET("/failure", func(c *omni.Context) error { return omni.Internal(errors.New("private detail")) })
|
||||||
|
a.GET("/panic", func(c *omni.Context) error { panic("private panic") })
|
||||||
|
a.Handle("OPTIONS", "/options", func(c *omni.Context) error { return c.Text(200, "ordinary options") })
|
||||||
|
for name, entry := range map[string]http.Handler{"App": a, "Engine": a.Engine(), "NewServer": a.NewServer("").Handler} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
method, path string
|
||||||
|
status int
|
||||||
|
}{{"GET", "/secure", 401}, {"GET", "/failure", 500}, {"GET", "/panic", 500}, {"GET", "/absent", 404}, {"PUT", "/secure", 405}} {
|
||||||
|
r := httptest.NewRequest(tc.method, tc.path, nil)
|
||||||
|
r.Header.Set("Origin", "https://web.example")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
entry.ServeHTTP(w, r)
|
||||||
|
var body map[string]any
|
||||||
|
err := json.Unmarshal(w.Body.Bytes(), &body)
|
||||||
|
if err != nil || w.Code != tc.status || w.Header().Get("Access-Control-Allow-Origin") != "https://web.example" || body["code"] != float64(999) || body["data"] != nil || body["msg"] == nil || strings.Contains(w.Body.String(), "private") {
|
||||||
|
t.Fatal(w.Code, w.Header(), w.Body.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, path := range []string{"/secure", "/absent"} {
|
||||||
|
before := middlewareCalls
|
||||||
|
r := httptest.NewRequest("OPTIONS", path, nil)
|
||||||
|
r.Header.Set("Origin", "https://web.example")
|
||||||
|
r.Header.Set("Access-Control-Request-Method", "POST")
|
||||||
|
r.Header.Set("Access-Control-Request-Headers", "authorization,content-type")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
entry.ServeHTTP(w, r)
|
||||||
|
if w.Code != 204 || w.Body.Len() != 0 || middlewareCalls != before {
|
||||||
|
t.Fatal(w.Code, w.Body.String(), middlewareCalls, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r := httptest.NewRequest("OPTIONS", "/options", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
entry.ServeHTTP(w, r)
|
||||||
|
if w.Code != 200 || w.Body.String() != "ordinary options" {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppCORSConfigIsolation(t *testing.T) {
|
||||||
|
cfg := config.Default()
|
||||||
|
cfg.CORS = omni.CORSConfig{Enabled: true, AllowedOrigins: []string{"https://one.example"}, AllowedHeaders: []string{"Authorization"}}
|
||||||
|
option := omni.WithConfig(cfg)
|
||||||
|
cfg.CORS.AllowedOrigins[0] = "https://evil.example"
|
||||||
|
a := omni.New(option)
|
||||||
|
defer a.Close()
|
||||||
|
b := omni.New(option)
|
||||||
|
defer b.Close()
|
||||||
|
a.Config().CORS.AllowedOrigins[0] = "https://evil.example"
|
||||||
|
for _, app := range []*omni.App{a, b} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/absent", nil)
|
||||||
|
r.Header.Set("Origin", "https://one.example")
|
||||||
|
app.ServeHTTP(w, r)
|
||||||
|
if w.Header().Get("Access-Control-Allow-Origin") != "https://one.example" {
|
||||||
|
t.Fatal(w.Header())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if recover() == nil {
|
||||||
|
t.Error("invalid CORS passed direct New")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
omni.New(omni.WithCORS(omni.CORSConfig{Enabled: true}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCORSTransports(t *testing.T) {
|
||||||
|
for _, transport := range []string{omni.TransportNetHTTP, omni.TransportNative, omni.TransportHTTP2} {
|
||||||
|
t.Run(transport, func(t *testing.T) {
|
||||||
|
cert, key, tlsClient := testutil.CertificateFiles(t)
|
||||||
|
cfg := omni.CORSConfig{Enabled: true, AllowedOrigins: []string{"https://web.example"}, AllowedMethods: []string{"GET", "POST"}, AllowedHeaders: []string{"Content-Type"}, ExposedHeaders: []string{"Content-Disposition"}}
|
||||||
|
a := omni.New(omni.WithCORS(cfg), omni.WithTransport(transport), omni.WithTLS(cert, key))
|
||||||
|
var called atomic.Int32
|
||||||
|
a.Use(func(next omni.Handler) omni.Handler {
|
||||||
|
return func(c *omni.Context) error { called.Add(1); return next(c) }
|
||||||
|
})
|
||||||
|
a.POST("/upload", func(c *omni.Context) error {
|
||||||
|
b, err := io.ReadAll(c.Request().Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.Text(200, string(b))
|
||||||
|
})
|
||||||
|
a.GET("/download", func(c *omni.Context) error {
|
||||||
|
return c.WriteStream(200, "application/octet-stream", &httpx.Stream{Content: io.NopCloser(strings.NewReader("file bytes")), Name: "example.txt"})
|
||||||
|
})
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- a.ServeContext(ctx, listener) }()
|
||||||
|
defer func() {
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Error("server did not close")
|
||||||
|
listener.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
|
||||||
|
defer client.CloseIdleConnections()
|
||||||
|
url := "https://" + listener.Addr().String()
|
||||||
|
for _, tc := range []struct {
|
||||||
|
method, path, body string
|
||||||
|
status int
|
||||||
|
}{{"OPTIONS", "/upload", "", 204}, {"POST", "/upload", "raw upload", 200}, {"GET", "/download", "file bytes", 200}, {"GET", "/missing", "", 404}} {
|
||||||
|
var input io.Reader
|
||||||
|
if tc.method == "POST" {
|
||||||
|
input = strings.NewReader(tc.body)
|
||||||
|
}
|
||||||
|
r, _ := http.NewRequest(tc.method, url+tc.path, input)
|
||||||
|
r.Header.Set("Origin", "https://web.example")
|
||||||
|
if tc.method == "OPTIONS" {
|
||||||
|
r.Header.Set("Access-Control-Request-Method", "POST")
|
||||||
|
r.Header.Set("Access-Control-Request-Headers", "content-type")
|
||||||
|
}
|
||||||
|
res, err := client.Do(r)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(res.Body)
|
||||||
|
res.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res.StatusCode != tc.status || res.Header.Get("Access-Control-Allow-Origin") != "https://web.example" {
|
||||||
|
t.Fatal(res.StatusCode, res.Header)
|
||||||
|
}
|
||||||
|
if tc.status == 200 && string(body) != tc.body {
|
||||||
|
t.Fatal(string(body))
|
||||||
|
}
|
||||||
|
if tc.status == 204 && (len(body) != 0 || called.Load() != 0) {
|
||||||
|
t.Fatal("preflight reached middleware")
|
||||||
|
}
|
||||||
|
if tc.path == "/download" && (!strings.Contains(res.Header.Get("Content-Disposition"), "example.txt") || res.Header.Get("Access-Control-Expose-Headers") != "Content-Disposition") {
|
||||||
|
t.Fatal(res.Header)
|
||||||
|
}
|
||||||
|
if transport == omni.TransportHTTP2 && res.ProtoMajor != 2 {
|
||||||
|
t.Fatal(res.Proto)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
241
app/i18n_test.go
Normal file
241
app/i18n_test.go
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
package app_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n/errori18n"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/middleware"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
func localizedCatalog(namespace string, status int) apierrors.Catalog {
|
||||||
|
return errori18n.Bind(apierrors.Catalog{DefaultStatus: status, Definitions: map[string]apierrors.Definition{
|
||||||
|
"SAME": {Code: int64(9223372036854775807), Message: namespace + ":{value}", Params: []apierrors.Parameter{{Name: "value", Type: "string"}}},
|
||||||
|
"INTERNAL_ERROR": {Status: 500, Code: int64(500), Message: "internal"},
|
||||||
|
"FIXED": {Status: 401, Code: int64(401), Message: "fixed {literal}"},
|
||||||
|
}}, namespace)
|
||||||
|
}
|
||||||
|
func localizedOptions(t *testing.T, catalogs ...apierrors.Catalog) (*httpi18n.Policy, *errori18n.Localizer) {
|
||||||
|
t.Helper()
|
||||||
|
docs := []i18n.Document{}
|
||||||
|
for _, catalog := range catalogs {
|
||||||
|
source, err := errori18n.Source(catalog, "en")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
docs = append(docs, source...)
|
||||||
|
for _, d := range source {
|
||||||
|
docs = append(docs, i18n.Document{Version: 1, Namespace: d.Namespace, Locale: "zh-Hans", Messages: map[string]i18n.Message{
|
||||||
|
"SAME": {Value: i18n.Pattern{Text: d.Namespace + "译文:{value}"}}, "FIXED": {Value: i18n.Pattern{Text: "需要认证"}}, "INTERNAL_ERROR": {Value: i18n.Pattern{Text: "系统错误"}},
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, err := i18n.New(i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en", "zh-Hans"}}, docs...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
l := errori18n.New(b, nil)
|
||||||
|
for _, c := range catalogs {
|
||||||
|
if err := l.Validate(c); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p, err := httpi18n.New(b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p, l
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestI18nRouteScopesEarlyFailuresAndReplay(t *testing.T) {
|
||||||
|
global, local := localizedCatalog("app", 200), localizedCatalog("route", 409)
|
||||||
|
policy, localizer := localizedOptions(t, global, local)
|
||||||
|
format := response.Config{CodeType: "int64", SuccessCode: int64(0), ErrorCode: int64(999), Body: map[string]any{"error": map[string]any{"reason": "${message}", "id": "${code}"}, "ok": "${ok}", "data": "${data}"}, DataMode: "always", Messages: map[string]string{"FIXED": "fixed {literal}"}}
|
||||||
|
app := omni.New(omni.WithErrors(global), omni.WithResponse(format), omni.WithLanguagePolicy(policy), omni.WithErrorLocalizer(localizer))
|
||||||
|
defer app.Close()
|
||||||
|
private := errors.New("private-value")
|
||||||
|
app.Use(func(next httpx.Handler) httpx.Handler {
|
||||||
|
return func(c *httpx.Context) error {
|
||||||
|
switch c.Header("X-Early") {
|
||||||
|
case "auth":
|
||||||
|
return apierrors.ByCode("FIXED")
|
||||||
|
case "panic":
|
||||||
|
panic(private)
|
||||||
|
}
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
handler := func(c *httpx.Context) error {
|
||||||
|
if c.Header("X-Params") == "missing" {
|
||||||
|
return apierrors.ByCode("SAME")
|
||||||
|
}
|
||||||
|
return apierrors.ByCode("SAME").WithParams(map[string]any{"value": "{literal}"}).WithCause(private)
|
||||||
|
}
|
||||||
|
app.GET("/global", handler)
|
||||||
|
bridge := httpx.WrapMiddleware(func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { next.ServeHTTP(w, r) })
|
||||||
|
})
|
||||||
|
app.HandleWithResponse("GET", "/route", response.RawJSON(response.MustWithErrors(local, response.MustNew(format))), handler, bridge)
|
||||||
|
for _, path := range []string{"/global", "/route"} {
|
||||||
|
for _, locale := range []string{"en", "zh-CN"} {
|
||||||
|
for _, mode := range []string{"", "auth", "panic", "missing"} {
|
||||||
|
r := httptest.NewRequest("GET", path, nil)
|
||||||
|
r.Header.Set("Accept-Language", locale)
|
||||||
|
r.Header.Set("X-Early", mode)
|
||||||
|
r.Header.Set("X-Params", mode)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
app.ServeHTTP(w, r)
|
||||||
|
var body struct {
|
||||||
|
Error struct {
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
ID json.Number `json:"id"`
|
||||||
|
} `json:"error"`
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Data any `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatal(err, w.Body.String())
|
||||||
|
}
|
||||||
|
ns, status := "app", 200
|
||||||
|
if path == "/route" {
|
||||||
|
ns, status = "route", 409
|
||||||
|
}
|
||||||
|
want := ns + ":{literal}"
|
||||||
|
if locale != "en" {
|
||||||
|
want = ns + "译文:{literal}"
|
||||||
|
}
|
||||||
|
if mode == "auth" {
|
||||||
|
status = 401
|
||||||
|
want = "fixed {literal}"
|
||||||
|
if locale != "en" {
|
||||||
|
want = "需要认证"
|
||||||
|
}
|
||||||
|
} else if mode == "panic" || mode == "missing" {
|
||||||
|
status = 500
|
||||||
|
want = "internal"
|
||||||
|
if locale != "en" {
|
||||||
|
want = "系统错误"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w.Code != status || body.OK || body.Error.Reason != want || strings.Contains(w.Body.String(), "private-value") {
|
||||||
|
t.Fatal(path, locale, mode, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if mode == "" && body.Error.ID.String() != "9223372036854775807" {
|
||||||
|
t.Fatal("public code changed", body.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, shared := range []bool{false, true} {
|
||||||
|
cfg := middleware.IdempotencyConfig{Scope: func(*httpx.Context) (string, error) { return "test", nil }}
|
||||||
|
if shared {
|
||||||
|
cfg.Store = &catalogStore{records: map[string]middleware.IdempotencyRecord{}}
|
||||||
|
}
|
||||||
|
idem, err := middleware.NewIdempotency(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := "/localReplay"
|
||||||
|
if shared {
|
||||||
|
path = "/sharedReplay"
|
||||||
|
}
|
||||||
|
calls := 0
|
||||||
|
app.POST(path, func(c *httpx.Context) error { calls++; return handler(c) }, idem.Middleware())
|
||||||
|
var first string
|
||||||
|
for _, locale := range []string{"en", "zh-CN"} {
|
||||||
|
r := httptest.NewRequest("POST", path, strings.NewReader(`{}`))
|
||||||
|
r.Header.Set("Idempotency-Key", "same")
|
||||||
|
r.Header.Set("Accept-Language", locale)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
app.ServeHTTP(w, r)
|
||||||
|
if locale == "en" {
|
||||||
|
first = w.Body.String()
|
||||||
|
} else if w.Body.String() != first || w.Header().Get("Idempotency-Replayed") != "true" {
|
||||||
|
t.Fatal("replay changed", w.Body.String(), first)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Fatal("language retriggered business", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestI18nExplicitProtocolsAndConcurrentApps(t *testing.T) {
|
||||||
|
apps := []*omni.App{}
|
||||||
|
for _, ns := range []string{"one", "two"} {
|
||||||
|
c := localizedCatalog(ns, 200)
|
||||||
|
p, l := localizedOptions(t, c)
|
||||||
|
a := omni.New(omni.WithErrors(c), omni.WithResponse(response.Config{CodeType: "int64", SuccessCode: 0, ErrorCode: 999}), omni.WithLanguagePolicy(p), omni.WithErrorLocalizer(l))
|
||||||
|
defer a.Close()
|
||||||
|
a.GET("/", func(*httpx.Context) error { return apierrors.ByCode("FIXED") })
|
||||||
|
a.HandleWithResponse("GET", "/ack", response.EncoderFunc(func(r response.Result) (response.Payload, error) {
|
||||||
|
if !r.IsError() || r.Message != "fixed {literal}" || r.PublicMessage() != "需要认证" {
|
||||||
|
t.Error(r)
|
||||||
|
}
|
||||||
|
return response.Text(200, "ACK"), nil
|
||||||
|
}), func(*httpx.Context) error { return apierrors.ByCode("FIXED") })
|
||||||
|
a.GET("/direct", func(c *httpx.Context) error { return c.Text(200, "fixed-protocol") })
|
||||||
|
apps = append(apps, a)
|
||||||
|
}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for n := 0; n < 60; n++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(n int) {
|
||||||
|
defer wg.Done()
|
||||||
|
locale, want := "en", "fixed {literal}"
|
||||||
|
if n%2 == 0 {
|
||||||
|
locale, want = "zh-CN", "需要认证"
|
||||||
|
}
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.Header.Set("Accept-Language", locale)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
apps[n%len(apps)].ServeHTTP(w, r)
|
||||||
|
if w.Code != 401 || !strings.Contains(w.Body.String(), want) {
|
||||||
|
t.Error(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}(n)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
for _, path := range []string{"/ack", "/direct"} {
|
||||||
|
r := httptest.NewRequest("GET", path, nil)
|
||||||
|
r.Header.Set("Accept-Language", "zh-CN")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
apps[0].ServeHTTP(w, r)
|
||||||
|
want := "ACK"
|
||||||
|
if path == "/direct" {
|
||||||
|
want = "fixed-protocol"
|
||||||
|
}
|
||||||
|
if w.Body.String() != want {
|
||||||
|
t.Fatal(w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type panickingLocalizer struct{}
|
||||||
|
|
||||||
|
func (panickingLocalizer) Localize(context.Context, *apierrors.Registry, *apierrors.APIError) (string, bool) {
|
||||||
|
panic("presentation failed")
|
||||||
|
}
|
||||||
|
func TestI18nAdapterFailurePreservesBusiness(t *testing.T) {
|
||||||
|
c := localizedCatalog("test", 200)
|
||||||
|
a := omni.New(omni.WithErrors(c), omni.WithResponse(response.Config{CodeType: "int64", SuccessCode: 0, ErrorCode: 999}), omni.WithErrorLocalizer(panickingLocalizer{}))
|
||||||
|
defer a.Close()
|
||||||
|
a.GET("/", func(*httpx.Context) error { return apierrors.ByCode("FIXED") })
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
|
||||||
|
if w.Code != 401 || !strings.Contains(w.Body.String(), "fixed {literal}") {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
88
app/requests.go
Normal file
88
app/requests.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Admission and completion share a lock: once stopped, no request can enter
|
||||||
|
// between observing zero active handlers and releasing their dependencies.
|
||||||
|
type requestDrain struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
stopped bool
|
||||||
|
active map[*requestWork]struct{}
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type requestWork struct{ cancel context.CancelFunc }
|
||||||
|
|
||||||
|
func (d *requestDrain) begin(ctx context.Context) (context.Context, *requestWork) {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if d.stopped {
|
||||||
|
return ctx, nil
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
w := &requestWork{cancel: cancel}
|
||||||
|
if d.active == nil {
|
||||||
|
d.active = make(map[*requestWork]struct{})
|
||||||
|
}
|
||||||
|
d.active[w] = struct{}{}
|
||||||
|
return ctx, w
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *requestDrain) end(w *requestWork) {
|
||||||
|
w.cancel()
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
delete(d.active, w)
|
||||||
|
if d.stopped && len(d.active) == 0 {
|
||||||
|
close(d.done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *requestDrain) stop() {
|
||||||
|
d.mu.Lock()
|
||||||
|
defer d.mu.Unlock()
|
||||||
|
if d.stopped {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.stopped = true
|
||||||
|
if len(d.active) == 0 {
|
||||||
|
close(d.done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *requestDrain) cancelAndWait(timeout time.Duration) error {
|
||||||
|
d.stop()
|
||||||
|
d.mu.Lock()
|
||||||
|
for w := range d.active {
|
||||||
|
w.cancel()
|
||||||
|
}
|
||||||
|
d.mu.Unlock()
|
||||||
|
timer := time.NewTimer(timeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-d.done:
|
||||||
|
return nil
|
||||||
|
case <-timer.C:
|
||||||
|
select {
|
||||||
|
case <-d.done:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return fmt.Errorf("go-omni: HTTP handlers did not finish within cancellation grace %s: %w", timeout,
|
||||||
|
errors.Join(context.DeadlineExceeded, lifecycle.ErrResourcesRetained))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchShutdown stops admission on the early signal without canceling accepted
|
||||||
|
// work. The transport retains its graceful drain budget before Close cancels.
|
||||||
|
func (a *App) watchShutdown(ctx context.Context) func() bool {
|
||||||
|
return context.AfterFunc(ctx, a.requests.stop)
|
||||||
|
}
|
||||||
167
app/requests_test.go
Normal file
167
app/requests_test.go
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShutdownWaitsForCancellationCleanup(t *testing.T) {
|
||||||
|
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
||||||
|
t.Run(transport, func(t *testing.T) {
|
||||||
|
cert, key, tlsClient := testutil.CertificateFiles(t)
|
||||||
|
a := New(config.WithTransport(transport), config.WithTLS(cert, key), config.WithShutdownTimeout(30*time.Millisecond), config.WithHTTPConfig(config.HTTPConfig{ShutdownCancelTimeout: time.Second}))
|
||||||
|
started, canceled, release := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||||
|
var once sync.Once
|
||||||
|
unblock := func() { once.Do(func() { close(release) }) }
|
||||||
|
defer unblock()
|
||||||
|
var closed atomic.Int32
|
||||||
|
a.OnShutdown(func() error { closed.Add(1); return nil })
|
||||||
|
a.GET("/work", func(c *httpx.Context) error {
|
||||||
|
close(started)
|
||||||
|
<-c.Context().Done()
|
||||||
|
close(canceled)
|
||||||
|
<-release
|
||||||
|
if closed.Load() != 0 {
|
||||||
|
t.Error("dependency closed during cancellation cleanup")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- a.ServeContext(ctx, l) }()
|
||||||
|
client := &http.Client{Timeout: 3 * time.Second, Transport: &http.Transport{TLSClientConfig: tlsClient, ForceAttemptHTTP2: true}}
|
||||||
|
defer client.CloseIdleConnections()
|
||||||
|
requestDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(requestDone)
|
||||||
|
res, err := client.Get("https://" + l.Addr().String() + "/work")
|
||||||
|
if err == nil {
|
||||||
|
io.Copy(io.Discard, res.Body)
|
||||||
|
res.Body.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
await(t, started)
|
||||||
|
cancel()
|
||||||
|
await(t, canceled)
|
||||||
|
if closed.Load() != 0 {
|
||||||
|
t.Fatal("dependency closed on cancellation")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
t.Fatal("returned before handler cleanup", err)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
// Even an in-memory invocation through Engine must now be rejected.
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.Engine().ServeHTTP(w, httptest.NewRequest("GET", "/work", nil))
|
||||||
|
if w.Code != 503 {
|
||||||
|
t.Fatal(w.Code)
|
||||||
|
}
|
||||||
|
unblock()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, lifecycle.ErrResourcesRetained) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("shutdown stuck")
|
||||||
|
}
|
||||||
|
await(t, requestDone)
|
||||||
|
if closed.Load() != 1 {
|
||||||
|
t.Fatal("cleanup count", closed.Load())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExternalServingEntriesRetainActiveResources(t *testing.T) {
|
||||||
|
for _, entry := range []string{"App", "Engine", "NewServer", "RunWith"} {
|
||||||
|
t.Run(entry, func(t *testing.T) {
|
||||||
|
a := New(config.WithHTTPConfig(config.HTTPConfig{ShutdownCancelTimeout: 30 * time.Millisecond}))
|
||||||
|
started, release, returned := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||||
|
var closed atomic.Int32
|
||||||
|
a.OnShutdown(func() error { closed.Add(1); return nil })
|
||||||
|
a.GET("/", func(*httpx.Context) error {
|
||||||
|
close(started)
|
||||||
|
<-release
|
||||||
|
if closed.Load() != 0 {
|
||||||
|
t.Error("closed while active")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
var h http.Handler = a
|
||||||
|
if entry == "Engine" {
|
||||||
|
h = a.Engine()
|
||||||
|
}
|
||||||
|
if entry == "NewServer" {
|
||||||
|
h = a.NewServer("").Handler
|
||||||
|
}
|
||||||
|
invoke := func(h http.Handler) {
|
||||||
|
defer close(returned)
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
|
||||||
|
}
|
||||||
|
defer func() { close(release); await(t, returned) }()
|
||||||
|
var err error
|
||||||
|
if entry == "RunWith" {
|
||||||
|
err = a.RunWith(context.Background(), runnerFunc(func(_ context.Context, h http.Handler, _ string) error { go invoke(h); await(t, started); return nil }))
|
||||||
|
} else {
|
||||||
|
go invoke(h)
|
||||||
|
await(t, started)
|
||||||
|
err = a.Close()
|
||||||
|
}
|
||||||
|
if !errors.Is(err, lifecycle.ErrResourcesRetained) || closed.Load() != 0 {
|
||||||
|
t.Fatal("retention failed", err, closed.Load())
|
||||||
|
}
|
||||||
|
if err = a.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) {
|
||||||
|
t.Fatal("repeat close lost retention", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestAdmissionAndCloseRace(t *testing.T) {
|
||||||
|
for attempt := 0; attempt < 20; attempt++ {
|
||||||
|
a := New()
|
||||||
|
var closed atomic.Bool
|
||||||
|
a.OnShutdown(func() error { closed.Store(true); return nil })
|
||||||
|
a.GET("/", func(c *httpx.Context) error {
|
||||||
|
if closed.Load() {
|
||||||
|
t.Error("handler admitted after resource release")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
start := make(chan struct{})
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
<-start
|
||||||
|
a.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
if err := a.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (a *App) handle(routes httpx.RouterGroup, method, path string, handler httpx.Handler, middleware []httpx.Middleware) {
|
func (a *App) handle(routes httpx.RouterGroup, method, path string, handler httpx.Handler, middleware []httpx.Middleware) {
|
||||||
routes.Handle(method, path, a.adapt(handler, middleware...))
|
routes.Handle(method, path, a.bodyHandler(a.defaultBodyLimit(), nil, handler, middleware...))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) adapt(handler httpx.Handler, middleware ...httpx.Middleware) http.Handler {
|
func (a *App) adapt(handler httpx.Handler, middleware ...httpx.Middleware) http.Handler {
|
||||||
@@ -28,6 +28,9 @@ func (a *App) adaptResponse(encoder response.Encoder, handler httpx.Handler, mid
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||||
|
if a.config.LanguagePolicy != nil {
|
||||||
|
request = a.config.LanguagePolicy.Apply(writer, request)
|
||||||
|
}
|
||||||
request = request.WithContext(logx.WithLogger(contract.WithRequestPolicy(request.Context(), a.config.Request), a.config.LogInstance))
|
request = request.WithContext(logx.WithLogger(contract.WithRequestPolicy(request.Context(), a.config.Request), a.config.LogInstance))
|
||||||
if len(a.config.TrustedProxies) > 0 {
|
if len(a.config.TrustedProxies) > 0 {
|
||||||
request = request.WithContext(httpx.WithClientIPResolver(request.Context(), a.clientIPResolver))
|
request = request.WithContext(httpx.WithClientIPResolver(request.Context(), a.clientIPResolver))
|
||||||
@@ -40,6 +43,7 @@ func (a *App) adaptResponse(encoder response.Encoder, handler httpx.Handler, mid
|
|||||||
requestContext.SetErrorRegistry(provider.ErrorRegistry())
|
requestContext.SetErrorRegistry(provider.ErrorRegistry())
|
||||||
}
|
}
|
||||||
requestContext.SetResponseEncoder(encoder)
|
requestContext.SetResponseEncoder(encoder)
|
||||||
|
requestContext.SetErrorLocalizer(a.config.ErrorLocalizer)
|
||||||
defer func() { httpx.CleanupMultipart(requestContext.Request()) }()
|
defer func() { httpx.CleanupMultipart(requestContext.Request()) }()
|
||||||
if err := wrapped(requestContext); err != nil {
|
if err := wrapped(requestContext); err != nil {
|
||||||
a.writeError(requestContext, err)
|
a.writeError(requestContext, err)
|
||||||
@@ -58,7 +62,7 @@ func (a *App) HandleWithResponse(method, path string, encoder response.Encoder,
|
|||||||
if encoder == nil {
|
if encoder == nil {
|
||||||
encoder = a.responseEncoder
|
encoder = a.responseEncoder
|
||||||
}
|
}
|
||||||
a.router.Handle(method, path, a.adaptResponse(encoder, handler, middleware...))
|
a.router.Handle(method, path, a.bodyHandler(a.defaultBodyLimit(), encoder, handler, middleware...))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
func (a *App) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||||
@@ -82,18 +86,19 @@ func (a *App) DELETE(path string, handler httpx.Handler, middleware ...httpx.Mid
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Any(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
func (a *App) Any(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||||
a.router.Any(path, a.adapt(handler, middleware...))
|
a.router.Any(path, a.bodyHandler(a.defaultBodyLimit(), nil, handler, middleware...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// RouterGroup scopes a route prefix and optional middleware.
|
// RouterGroup scopes a route prefix and optional middleware.
|
||||||
type RouterGroup struct {
|
type RouterGroup struct {
|
||||||
|
prefix string
|
||||||
group httpx.RouterGroup
|
group httpx.RouterGroup
|
||||||
app *App
|
app *App
|
||||||
middlewares []httpx.Middleware
|
middlewares []httpx.Middleware
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) Group(path string, middleware ...httpx.Middleware) *RouterGroup {
|
func (a *App) Group(path string, middleware ...httpx.Middleware) *RouterGroup {
|
||||||
return &RouterGroup{group: a.router.Group(path), app: a, middlewares: middleware}
|
return &RouterGroup{group: a.router.Group(path), app: a, middlewares: middleware, prefix: joinRoutePath("", path)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *RouterGroup) Use(middleware ...httpx.Middleware) *RouterGroup {
|
func (g *RouterGroup) Use(middleware ...httpx.Middleware) *RouterGroup {
|
||||||
@@ -104,7 +109,7 @@ func (g *RouterGroup) Use(middleware ...httpx.Middleware) *RouterGroup {
|
|||||||
func (g *RouterGroup) Group(path string, middleware ...httpx.Middleware) *RouterGroup {
|
func (g *RouterGroup) Group(path string, middleware ...httpx.Middleware) *RouterGroup {
|
||||||
combined := append([]httpx.Middleware{}, g.middlewares...)
|
combined := append([]httpx.Middleware{}, g.middlewares...)
|
||||||
combined = append(combined, middleware...)
|
combined = append(combined, middleware...)
|
||||||
return &RouterGroup{group: g.group.Group(path), app: g.app, middlewares: combined}
|
return &RouterGroup{group: g.group.Group(path), app: g.app, middlewares: combined, prefix: joinRoutePath(g.prefix, path)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *RouterGroup) route(method, path string, handler httpx.Handler, middleware []httpx.Middleware) {
|
func (g *RouterGroup) route(method, path string, handler httpx.Handler, middleware []httpx.Middleware) {
|
||||||
@@ -124,7 +129,7 @@ func (g *RouterGroup) HandleWithResponse(method, path string, encoder response.E
|
|||||||
}
|
}
|
||||||
combined := append([]httpx.Middleware{}, g.middlewares...)
|
combined := append([]httpx.Middleware{}, g.middlewares...)
|
||||||
combined = append(combined, middleware...)
|
combined = append(combined, middleware...)
|
||||||
g.group.Handle(method, path, g.app.adaptResponse(encoder, handler, combined...))
|
g.group.Handle(method, path, g.app.bodyHandler(g.app.defaultBodyLimit(), encoder, handler, combined...))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *RouterGroup) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
func (g *RouterGroup) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||||
@@ -150,5 +155,5 @@ func (g *RouterGroup) DELETE(path string, handler httpx.Handler, middleware ...h
|
|||||||
func (g *RouterGroup) Any(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
func (g *RouterGroup) Any(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||||
combined := append([]httpx.Middleware{}, g.middlewares...)
|
combined := append([]httpx.Middleware{}, g.middlewares...)
|
||||||
combined = append(combined, middleware...)
|
combined = append(combined, middleware...)
|
||||||
g.group.Any(path, g.app.adapt(handler, combined...))
|
g.group.Any(path, g.app.bodyHandler(g.app.defaultBodyLimit(), nil, handler, combined...))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/internal/httplimits"
|
||||||
h2transport "git.yuntuo.hk/k2safe/go-omni/transport/http2"
|
h2transport "git.yuntuo.hk/k2safe/go-omni/transport/http2"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/transport/native"
|
"git.yuntuo.hk/k2safe/go-omni/transport/native"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ServeContext serves a caller-created listener using the configured transport
|
// ServeContext serves a caller-created listener using the configured transport
|
||||||
// and TLS. App takes ownership of the listener, including on startup failure,
|
// and TLS. App takes ownership of the listener, including on startup failure,
|
||||||
// and runs resource cleanup after the server has stopped.
|
// and runs resource cleanup only after admitted handlers have returned.
|
||||||
func (a *App) ServeContext(ctx context.Context, listener net.Listener) (runErr error) {
|
func (a *App) ServeContext(ctx context.Context, listener net.Listener) (runErr error) {
|
||||||
defer func() { runErr = shutdownResult(ctx, runErr) }()
|
defer func() { runErr = shutdownResult(ctx, runErr) }()
|
||||||
if listener == nil {
|
if listener == nil {
|
||||||
@@ -30,6 +31,8 @@ func (a *App) ServeContext(ctx context.Context, listener net.Listener) (runErr e
|
|||||||
_ = listener.Close()
|
_ = listener.Close()
|
||||||
return a.Close()
|
return a.Close()
|
||||||
}
|
}
|
||||||
|
stopWatching := a.watchShutdown(ctx)
|
||||||
|
defer stopWatching()
|
||||||
serve, err := a.prepareTransport()
|
serve, err := a.prepareTransport()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = listener.Close()
|
_ = listener.Close()
|
||||||
@@ -39,6 +42,9 @@ func (a *App) ServeContext(ctx context.Context, listener net.Listener) (runErr e
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) prepareTransport() (func(context.Context, net.Listener) error, error) {
|
func (a *App) prepareTransport() (func(context.Context, net.Listener) error, error) {
|
||||||
|
if err := a.ValidateHTTP(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if err := a.config.ValidateTransport(); err != nil {
|
if err := a.config.ValidateTransport(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -57,6 +63,9 @@ func (a *App) prepareTransport() (func(context.Context, net.Listener) error, err
|
|||||||
switch a.config.Transport {
|
switch a.config.Transport {
|
||||||
case config.TransportNative:
|
case config.TransportNative:
|
||||||
server := native.New(a)
|
server := native.New(a)
|
||||||
|
// App enforces the service/route policy after routing. Keep only the
|
||||||
|
// supported framing ceiling here, never a hidden smaller transport cap.
|
||||||
|
server.MaxBodyBytes = httplimits.MaxBodyBytes
|
||||||
server.ShutdownTimeout = shutdownTimeout
|
server.ShutdownTimeout = shutdownTimeout
|
||||||
cfg := a.config.HTTP
|
cfg := a.config.HTTP
|
||||||
if cfg.ReadHeaderTimeout > 0 {
|
if cfg.ReadHeaderTimeout > 0 {
|
||||||
@@ -82,6 +91,7 @@ func (a *App) prepareTransport() (func(context.Context, net.Listener) error, err
|
|||||||
return server.Serve, nil
|
return server.Serve, nil
|
||||||
case config.TransportHTTP2:
|
case config.TransportHTTP2:
|
||||||
server := h2transport.New(a)
|
server := h2transport.New(a)
|
||||||
|
server.MaxBodyBytes = httplimits.MaxBodyBytes
|
||||||
server.ShutdownTimeout = shutdownTimeout
|
server.ShutdownTimeout = shutdownTimeout
|
||||||
if a.config.HTTP.IdleTimeout > 0 {
|
if a.config.HTTP.IdleTimeout > 0 {
|
||||||
server.IdleTimeout = a.config.HTTP.IdleTimeout
|
server.IdleTimeout = a.config.HTTP.IdleTimeout
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
|
"git.yuntuo.hk/k2safe/go-omni/internal/testutil"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestConfiguredTransportLifecycle(t *testing.T) {
|
func TestConfiguredTransportLifecycle(t *testing.T) {
|
||||||
@@ -269,7 +270,9 @@ func TestConfiguredShutdownDoesNotWaitForUncooperativeHandler(t *testing.T) {
|
|||||||
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative, config.TransportHTTP2} {
|
||||||
t.Run(transport, func(t *testing.T) {
|
t.Run(transport, func(t *testing.T) {
|
||||||
cert, key, tlsClient := testutil.CertificateFiles(t)
|
cert, key, tlsClient := testutil.CertificateFiles(t)
|
||||||
application := New(config.WithTransport(transport), config.WithTLS(cert, key), config.WithShutdownTimeout(80*time.Millisecond))
|
application := New(config.WithTransport(transport), config.WithTLS(cert, key), config.WithShutdownTimeout(80*time.Millisecond), config.WithHTTPConfig(config.HTTPConfig{ShutdownCancelTimeout: 40 * time.Millisecond}))
|
||||||
|
var cleaned atomic.Int32
|
||||||
|
application.OnShutdown(func() error { cleaned.Add(1); return nil })
|
||||||
started := make(chan context.Context, 1)
|
started := make(chan context.Context, 1)
|
||||||
release, finished := make(chan struct{}), make(chan struct{})
|
release, finished := make(chan struct{}), make(chan struct{})
|
||||||
application.GET("/blocked", func(ctx *httpx.Context) error {
|
application.GET("/blocked", func(ctx *httpx.Context) error {
|
||||||
@@ -304,11 +307,17 @@ func TestConfiguredShutdownDoesNotWaitForUncooperativeHandler(t *testing.T) {
|
|||||||
close(release)
|
close(release)
|
||||||
t.Fatal("handler did not start")
|
t.Fatal("handler did not start")
|
||||||
}
|
}
|
||||||
defer func() { close(release); await(t, finished) }()
|
defer func() {
|
||||||
|
close(release)
|
||||||
|
await(t, finished)
|
||||||
|
if err := application.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) || cleaned.Load() != 0 {
|
||||||
|
t.Fatalf("late handler return must not trigger cleanup: %v count=%d", err, cleaned.Load())
|
||||||
|
}
|
||||||
|
}()
|
||||||
cancel()
|
cancel()
|
||||||
select {
|
select {
|
||||||
case err := <-done:
|
case err := <-done:
|
||||||
if !errors.Is(err, context.DeadlineExceeded) {
|
if !errors.Is(err, context.DeadlineExceeded) || !errors.Is(err, lifecycle.ErrResourcesRetained) || cleaned.Load() != 0 {
|
||||||
t.Fatalf("shutdown: %v", err)
|
t.Fatalf("shutdown: %v", err)
|
||||||
}
|
}
|
||||||
case <-time.After(2 * time.Second):
|
case <-time.After(2 * time.Second):
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# go-omni CLI
|
# go-omni CLI
|
||||||
|
|
||||||
|
v0.28.1 的 `workspace generate/check` 支持 API 与通用工程混合处理。每个 services 项可指定 `kind: api | project`,省略时按工程清单识别,旧 API 配置继续有效。format 只处理 API 契约,通用工程显示 SKIP;check 只读并汇总各工程失败。见 [可运行示例](../../examples/mixedWorkspace/README.md)。
|
||||||
|
|
||||||
|
v0.28 新增 `project new/generate/check`:生成无 API、无监听端口和无外部服务依赖的通用 Go 程序,配置/日志/生命周期自动装配,项目维护 svc 与 Run。`project generate --dry-run` 预览,`project check` 只读检查;详见 [通用工程指南](../../PROGRAM.md)。
|
||||||
|
|
||||||
|
v0.26 新增 `i18n check|export|types|extract`:检查独立语言资源、提取 Go 静态消息引用或已导出的错误目录,导出指定命名空间的前端包和 TypeScript 类型。输入只读,结果写 stdout;动态引用明确报告。完整命令见 [国际化工具链](../../i18n/README.md#cli-提取检查与导出)。`.api errorCatalog` 可声明 translation 引用,旧 runtime 在生成前拒绝,接入时使用当前同版 CLI 与框架。
|
||||||
|
|
||||||
v0.19 移除结构映射生成:`omni.gen.json` 只保留 Repository 和中间件选项。普通接口直接使用生成 DTO;需要类型转换时用普通 Go 或可选 [copyx](../../COPY.md)。旧 `mappings` / `mappingFiles` 必须按迁移步骤清理,CLI 不会静默删除仍在调用的旧生成文件。
|
v0.19 移除结构映射生成:`omni.gen.json` 只保留 Repository 和中间件选项。普通接口直接使用生成 DTO;需要类型转换时用普通 Go 或可选 [copyx](../../COPY.md)。旧 `mappings` / `mappingFiles` 必须按迁移步骤清理,CLI 不会静默删除仍在调用的旧生成文件。
|
||||||
|
|
||||||
公共字段组合、中间件工厂和按需 Repository 继续支持,见 [生成能力指南](../../GENERATION.md)。
|
公共字段组合、中间件工厂和按需 Repository 继续支持,见 [生成能力指南](../../GENERATION.md)。
|
||||||
@@ -8,13 +14,13 @@ v0.16 增加基础类型/动态 JSON 数组返回、OpenAPI 公共引用和 work
|
|||||||
|
|
||||||
v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目录>|--apply]`,以及 `workspace format|generate|check --file omni.workspace.json`。详见 [通用开发指南](../../DEVELOPMENT.md)。
|
v0.14 新增 `api format [--check]`、`api upgrade [--dry-run|--preview <空目录>|--apply]`,以及 `workspace format|generate|check --file omni.workspace.json`。详见 [通用开发指南](../../DEVELOPMENT.md)。
|
||||||
|
|
||||||
新项目采用根目录 `main.go` 与 `internal/config、svc、logic、handler、types`。完整职责、启动扩展、流式接口和生成文件边界见 [项目指南](../../PROJECT.md)。
|
API 新项目采用根目录 `main.go` 与 `internal/config、svc、logic、handler、types`。完整职责、启动扩展、流式接口和生成文件边界见 [项目指南](../../PROJECT.md)。
|
||||||
|
|
||||||
## 创建项目
|
## 创建 API 项目
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 仓库读取权限与 PATH 配置见根目录 README
|
# 仓库读取权限与 PATH 配置见根目录 README
|
||||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.21.0-preview
|
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.30.0-preview
|
||||||
go-omni new --module example.com/hello ./hello
|
go-omni new --module example.com/hello ./hello
|
||||||
|
|
||||||
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
||||||
@@ -27,6 +33,8 @@ go-omni new --api service.api --module example.com/example ./example
|
|||||||
|
|
||||||
存储配置和客户端位于 config/svc,业务使用统一接口。local 存储签名端点由生成器接入;上传、下载等业务路由由 `.api` 定义。认证与存储路径由项目实现。
|
存储配置和客户端位于 config/svc,业务使用统一接口。local 存储签名端点由生成器接入;上传、下载等业务路由由 `.api` 定义。认证与存储路径由项目实现。
|
||||||
|
|
||||||
|
响应 DTO 支持自引用及相互引用,生成强类型 Go 字段与 OpenAPI `$ref`;见[递归响应示例](../../examples/recursiveResponse/README.md)。请求递归仍会报错。
|
||||||
|
|
||||||
## 日常生成
|
## 日常生成
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -49,14 +57,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
|
|||||||
|
|
||||||
## 版本与迁移
|
## 版本与迁移
|
||||||
|
|
||||||
CLI 与框架依赖固定使用 v0.20.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
CLI 与框架依赖固定使用 v0.30.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# 在 go-omni 源码仓库中执行
|
# 在 go-omni 源码仓库中执行
|
||||||
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
|
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
|
||||||
```
|
```
|
||||||
|
|
||||||
当前模板与依赖以 v0.20.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
当前模板与依赖以 v0.30.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||||
|
|
||||||
## 数据库迁移命令
|
## 数据库迁移命令
|
||||||
|
|
||||||
@@ -70,6 +78,8 @@ go-omni migrate --dir migrations --apply
|
|||||||
|
|
||||||
SQL 命名示例 `0001_createUsers.up.sql`;预览只显示版本和 SHA-256,不连接数据库。失败保留 dirty 状态,不自动 down/force。细节见 [扩展模块](../../ADVANCED.md)。
|
SQL 命名示例 `0001_createUsers.up.sql`;预览只显示版本和 SHA-256,不连接数据库。失败保留 dirty 状态,不自动 down/force。细节见 [扩展模块](../../ADVANCED.md)。
|
||||||
|
|
||||||
### v0.20 配置入口
|
### v0.22 文件显式配置
|
||||||
|
|
||||||
新生成 `internal/config/loadGen.go` 调用统一加载,纳入生成检查;项目 config.go 只维护字段、默认值和校验,loader.go 用于注册额外格式。JSON/YAML/XML 内置。旧手写 Load 会阻止生成,请先按 [迁移步骤](../../CONFIG.md#v019-及更早项目迁移) 整理,不会自动覆盖业务代码。
|
新生成 `internal/config/loadGen.go` 调用统一加载,纳入生成检查;项目 config.go 只维护字段、默认值和校验,loader.go 用于注册额外格式。JSON/YAML/XML 内置。旧手写 Load 会阻止生成,请先按 [迁移步骤](../../CONFIG.md#v022-迁移旧模式已移除) 整理,不会自动覆盖业务代码。
|
||||||
|
|
||||||
|
`go run . -config etc/config.test.yaml -check-config` 只做配置解析和校验,不初始化日志、数据库或监听。文件中的固定值不受环境自动覆盖;`${NAME}` 为必填引用,`${NAME:-fallback}` 提供备用值。
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"go/ast"
|
|
||||||
goparser "go/parser"
|
|
||||||
gotoken "go/token"
|
gotoken "go/token"
|
||||||
"reflect"
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -69,56 +67,17 @@ func Compile(spec *Spec) (*Compiled, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
resolved := map[string]reflect.Type{}
|
graph, err := newResponseGraph(spec, definitions, scalarTypes, scalarDefs)
|
||||||
resolving := map[string]bool{}
|
if err != nil {
|
||||||
var resolve func(string, int) (reflect.Type, error)
|
return nil, err
|
||||||
var expression func(ast.Expr, int) (reflect.Type, error)
|
}
|
||||||
|
for _, r := range spec.Routes {
|
||||||
expression = func(e ast.Expr, depth int) (reflect.Type, error) {
|
if graph.recursive[r.Request] {
|
||||||
if depth > 32 {
|
return nil, fmt.Errorf("%s: recursive request DTO %s is unsupported; recursive types are supported for responses only", r.Position, r.Request)
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
resolved := map[string]reflect.Type{}
|
||||||
|
var resolve func(string, int) (reflect.Type, error)
|
||||||
resolve = func(name string, depth int) (reflect.Type, error) {
|
resolve = func(name string, depth int) (reflect.Type, error) {
|
||||||
if depth > 32 {
|
if depth > 32 {
|
||||||
return nil, errors.New("type nesting exceeds 32")
|
return nil, errors.New("type nesting exceeds 32")
|
||||||
@@ -126,27 +85,13 @@ func Compile(spec *Spec) (*Compiled, error) {
|
|||||||
if t, ok := resolved[name]; ok {
|
if t, ok := resolved[name]; ok {
|
||||||
return t, nil
|
return t, nil
|
||||||
}
|
}
|
||||||
if resolving[name] {
|
|
||||||
return nil, fmt.Errorf("recursive type %s is unsupported", name)
|
|
||||||
}
|
|
||||||
typ, ok := definitions[name]
|
typ, ok := definitions[name]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("unknown type %s", name)
|
return nil, fmt.Errorf("unknown type %s", name)
|
||||||
}
|
}
|
||||||
resolving[name] = true
|
|
||||||
defer delete(resolving, name)
|
|
||||||
var fields []reflect.StructField
|
var fields []reflect.StructField
|
||||||
seen := map[string]bool{}
|
for i, f := range typ.Fields {
|
||||||
for _, f := range typ.Fields {
|
t, err := graph.expression(graph.exprs[name][i], depth+1, resolve)
|
||||||
if !exportedName.MatchString(f.Name) || seen[f.Name] {
|
|
||||||
return nil, fmt.Errorf("%s: invalid or duplicate field %s", name, f.Name)
|
|
||||||
}
|
|
||||||
seen[f.Name] = true
|
|
||||||
expr, err := goparser.ParseExpr(f.Type)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
t, err := expression(expr, depth+1)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("%s.%s: %w", name, f.Name, err)
|
return nil, fmt.Errorf("%s.%s: %w", name, f.Name, err)
|
||||||
}
|
}
|
||||||
@@ -158,6 +103,12 @@ func Compile(spec *Spec) (*Compiled, error) {
|
|||||||
}
|
}
|
||||||
c := &Compiled{Spec: spec, Bindings: map[string]*contract.Binder{}}
|
c := &Compiled{Spec: spec, Bindings: map[string]*contract.Binder{}}
|
||||||
for _, t := range spec.Types {
|
for _, t := range spec.Types {
|
||||||
|
if graph.recursive[t.Name] {
|
||||||
|
if _, err := graph.component(t.Name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
rt, err := resolve(t.Name, 0)
|
rt, err := resolve(t.Name, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -274,7 +225,7 @@ func Compile(spec *Spec) (*Compiled, error) {
|
|||||||
}
|
}
|
||||||
var response *openapi.Schema
|
var response *openapi.Schema
|
||||||
if r.Response != "" {
|
if r.Response != "" {
|
||||||
s, err := responseDataSchema(r.Response, c.Bindings)
|
s, err := responseDataSchema(r.Response, c.Bindings, graph)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -316,6 +267,13 @@ func Compile(spec *Spec) (*Compiled, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
decorateDocument(doc, spec, c.Bindings)
|
decorateDocument(doc, spec, c.Bindings)
|
||||||
|
if len(graph.components) > 0 {
|
||||||
|
components := map[string]any{}
|
||||||
|
for name, schema := range graph.components {
|
||||||
|
components[name] = schema
|
||||||
|
}
|
||||||
|
doc["components"].(map[string]any)["schemas"] = components
|
||||||
|
}
|
||||||
compactDocument(doc)
|
compactDocument(doc)
|
||||||
c.Document, err = json.MarshalIndent(doc, "", " ")
|
c.Document, err = json.MarshalIndent(doc, "", " ")
|
||||||
return c, err
|
return c, err
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ func TestGeneratedConfigurationConsumer(t *testing.T) {
|
|||||||
// The business only changes fields/defaults/validation, not the loading flow.
|
// The business only changes fields/defaults/validation, not the loading flow.
|
||||||
writeFile(t, filepath.Join(dir, "internal/config/config.go"), `package config
|
writeFile(t, filepath.Join(dir, "internal/config/config.go"), `package config
|
||||||
import("git.yuntuo.hk/k2safe/go-omni/config";"git.yuntuo.hk/k2safe/go-omni/logx")
|
import("git.yuntuo.hk/k2safe/go-omni/config";"git.yuntuo.hk/k2safe/go-omni/logx")
|
||||||
type Config struct { Server config.Config `+"`json:\"server\"`"+`;Limit int `+"`json:\"limit\" envDefault:\"10\"`"+` }
|
type Config struct { Server config.Config `+"`json:\"server\"`"+`;Limit int `+"`json:\"limit\"`"+` }
|
||||||
func Default()Config{ c:=config.Default();c.Log.File="./var/log/app.log";c.Log.Rotation=logx.RotationConfig{Enabled:true,MaxSizeMB:10};return Config{Server:c} }
|
func Default()Config{ c:=config.Default();c.Log.File="./var/log/app.log";c.Log.Rotation=logx.RotationConfig{Enabled:true,MaxSizeMB:10};return Config{Server:c,Limit:10} }
|
||||||
func(c Config)Validate()error{if err:=c.Server.Validate();err!=nil{return config.AtField("server",err)};if c.Limit<0{return config.Invalid("limit","must not be negative")};return nil}
|
func(c Config)Validate()error{if err:=c.Server.Validate();err!=nil{return config.AtField("server",err)};if c.Limit<0{return config.Invalid("limit","must not be negative")};return nil}
|
||||||
`)
|
`)
|
||||||
writeFile(t, filepath.Join(dir, "internal/config/loader.go"), `package config
|
writeFile(t, filepath.Join(dir, "internal/config/loader.go"), `package config
|
||||||
@@ -111,3 +111,26 @@ func TestConfigLoadMigrationGuard(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRemovedEnvironmentTagsRejectBeforeGeneration(t *testing.T) {
|
||||||
|
dir := filepath.Join(t.TempDir(), "app")
|
||||||
|
o := Options{Dir: dir, Module: "example.com/migrate", FrameworkDir: frameworkRoot(t)}
|
||||||
|
if _, err := New(o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
o.API = filepath.Join(dir, "api/service.api")
|
||||||
|
path := filepath.Join(dir, "internal/config/config.go")
|
||||||
|
source := readFile(t, path)
|
||||||
|
writeFile(t, path, strings.Replace(source, "json:\"server\"", "json:\"server\" env:\"OLD_SERVER\"", 1))
|
||||||
|
before := snapshot(t, dir)
|
||||||
|
for _, mode := range []string{"dry", "generate", "check"} {
|
||||||
|
o.DryRun = mode == "dry"
|
||||||
|
o.Check = mode == "check"
|
||||||
|
if _, err := Generate(o); err == nil || !strings.Contains(err.Error(), "env/envDefault") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, dir)) {
|
||||||
|
t.Fatal("migration rejection wrote files")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ func prepareMetadata(original *Spec) (*Spec, error) {
|
|||||||
for i := range spec.Errors {
|
for i := range spec.Errors {
|
||||||
spec.Errors[i].BusinessCode = normalized.Definitions[spec.Errors[i].Code].Code
|
spec.Errors[i].BusinessCode = normalized.Definitions[spec.Errors[i].Code].Code
|
||||||
spec.Errors[i].Params = normalized.Definitions[spec.Errors[i].Code].Params
|
spec.Errors[i].Params = normalized.Definitions[spec.Errors[i].Code].Params
|
||||||
|
spec.Errors[i].Translation = normalized.Definitions[spec.Errors[i].Code].Translation
|
||||||
}
|
}
|
||||||
if err := validateErrorPolicies(&spec); err != nil {
|
if err := validateErrorPolicies(&spec); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
59
cmd/go-omni/apigen/corsConsumer_test.go
Normal file
59
cmd/go-omni/apigen/corsConsumer_test.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGeneratedCORSConsumer(t *testing.T) {
|
||||||
|
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
|
||||||
|
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
|
||||||
|
}
|
||||||
|
dir := filepath.Join(t.TempDir(), "app")
|
||||||
|
api := filepath.Join(t.TempDir(), "service.api")
|
||||||
|
writeFile(t, api, "service corsdemo {\n@handler ping\nget /ping\n}\n")
|
||||||
|
o := Options{API: api, Dir: dir, Module: "example.com/corsdemo", FrameworkDir: frameworkRoot(t)}
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(dir, "consumer_test.go"), readFile(t, "testdata/cors/consumer_test.go"))
|
||||||
|
writeFile(t, filepath.Join(dir, "etc/config.yaml"), "server:\n log:\n output: discard\n cors:\n enabled: true\n allowedOrigins: [https://web.example]\n allowedMethods: [GET, POST, PUT]\n allowedHeaders: [Authorization, Content-Type]\n maxAge: 10m\n")
|
||||||
|
runConsumerGo(t, dir, "mod", "tidy")
|
||||||
|
runConsumerGo(t, dir, "test", "-race", "-count=1", "./...")
|
||||||
|
runConsumerGo(t, dir, "build", ".")
|
||||||
|
o.API = filepath.Join(dir, "api/service.api")
|
||||||
|
before := snapshot(t, dir)
|
||||||
|
for _, mode := range []string{"dry", "generate", "check"} {
|
||||||
|
o.DryRun = mode == "dry"
|
||||||
|
o.Check = mode == "check"
|
||||||
|
if err := generateManagedTestProject(t, o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, dir)) {
|
||||||
|
t.Fatal("regeneration changed project files")
|
||||||
|
}
|
||||||
|
if os.Getenv("GO_OMNI_RELEASE_CLI") != "" {
|
||||||
|
cmd := exec.Command("go", "list", "-m", "-json", frameworkModule)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err, string(out))
|
||||||
|
}
|
||||||
|
var dependency struct {
|
||||||
|
Version, Sum string
|
||||||
|
Replace any
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out, &dependency); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if dependency.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dependency.Replace != nil || dependency.Sum == "" {
|
||||||
|
t.Fatal("not a fixed remote consumer")
|
||||||
|
}
|
||||||
|
t.Logf("remote framework %s checksum %s; no local replace", dependency.Version, dependency.Sum)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,14 +11,15 @@ import (
|
|||||||
// ErrorDefinition declares a public, stable error. Generated constructors return
|
// ErrorDefinition declares a public, stable error. Generated constructors return
|
||||||
// a fresh error each time so WithCause cannot mutate a shared singleton.
|
// a fresh error each time so WithCause cannot mutate a shared singleton.
|
||||||
type ErrorDefinition struct {
|
type ErrorDefinition struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
BusinessCode any `json:"businessCode,omitempty"`
|
BusinessCode any `json:"businessCode,omitempty"`
|
||||||
Params []apierrors.Parameter `json:"params,omitempty"`
|
Params []apierrors.Parameter `json:"params,omitempty"`
|
||||||
Catalog string `json:"catalog,omitempty"`
|
Translation *apierrors.Translation `json:"translation,omitempty"`
|
||||||
Status int `json:"status"`
|
Catalog string `json:"catalog,omitempty"`
|
||||||
Code string `json:"code"`
|
Status int `json:"status"`
|
||||||
Message string `json:"message"`
|
Code string `json:"code"`
|
||||||
Position string `json:"-"`
|
Message string `json:"message"`
|
||||||
|
Position string `json:"-"`
|
||||||
}
|
}
|
||||||
type AuthDefinition struct {
|
type AuthDefinition struct {
|
||||||
Name, Type, In, KeyName, Middleware, Description, BearerFormat, Position string
|
Name, Type, In, KeyName, Middleware, Description, BearerFormat, Position string
|
||||||
|
|||||||
@@ -49,11 +49,12 @@ func (p *parser) parseErrorCatalog() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
type entry struct {
|
type entry struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
Code any `json:"code"`
|
Code any `json:"code"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
Params []apierrors.Parameter `json:"params"`
|
Params []apierrors.Parameter `json:"params"`
|
||||||
|
Translation *apierrors.Translation `json:"translation"`
|
||||||
}
|
}
|
||||||
var entries map[string]entry
|
var entries map[string]entry
|
||||||
if err := apierrors.DecodeCatalogJSON([]byte(props["errors"]), &entries); err != nil {
|
if err := apierrors.DecodeCatalogJSON([]byte(props["errors"]), &entries); err != nil {
|
||||||
@@ -75,7 +76,7 @@ func (p *parser) parseErrorCatalog() error {
|
|||||||
if e.Status == 0 {
|
if e.Status == 0 {
|
||||||
e.Status = defaultStatus
|
e.Status = defaultStatus
|
||||||
}
|
}
|
||||||
p.spec.Errors = append(p.spec.Errors, ErrorDefinition{Name: fn, Status: e.Status, Code: e.Key, Message: e.Message, BusinessCode: e.Code, Params: e.Params, Catalog: name, Position: pos})
|
p.spec.Errors = append(p.spec.Errors, ErrorDefinition{Name: fn, Status: e.Status, Code: e.Key, Message: e.Message, BusinessCode: e.Code, Params: e.Params, Translation: e.Translation, Catalog: name, Position: pos})
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -90,7 +91,7 @@ func errorCatalog(spec *Spec, names []string) apierrors.Catalog {
|
|||||||
if names != nil && !selected[e.Name] {
|
if names != nil && !selected[e.Name] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
c.Definitions[e.Code] = apierrors.Definition{Status: e.Status, Code: e.BusinessCode, Message: e.Message, Params: e.Params}
|
c.Definitions[e.Code] = apierrors.Definition{Status: e.Status, Code: e.BusinessCode, Message: e.Message, Params: e.Params, Translation: e.Translation}
|
||||||
}
|
}
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/internal/genfs"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold"
|
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold"
|
||||||
"golang.org/x/mod/modfile"
|
"golang.org/x/mod/modfile"
|
||||||
"golang.org/x/mod/module"
|
"golang.org/x/mod/module"
|
||||||
@@ -21,8 +22,8 @@ import (
|
|||||||
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
|
||||||
// Runtime-only releases need not raise the managed template compatibility floor.
|
// Runtime-only releases need not raise the managed template compatibility floor.
|
||||||
const minimumVersion = "v0.20.0-preview"
|
const minimumVersion = "v0.22.0-preview"
|
||||||
const currentVersion = "v0.21.0-preview"
|
const currentVersion = "v0.30.0-preview"
|
||||||
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
@@ -158,7 +159,7 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
|||||||
if !filepath.IsAbs(path) {
|
if !filepath.IsAbs(path) {
|
||||||
path = filepath.Join(directory, path)
|
path = filepath.Join(directory, path)
|
||||||
}
|
}
|
||||||
if source, err := os.ReadFile(filepath.Join(path, "config", "loader.go")); err == nil && strings.Contains(string(source), "func NewLoader(") {
|
if source, err := os.ReadFile(filepath.Join(path, "config", "references.go")); err == nil && strings.Contains(string(source), "func WithLookupEnv(") {
|
||||||
localCurrent = true
|
localCurrent = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -194,6 +195,13 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
|||||||
}
|
}
|
||||||
seeds["go.mod"] = data
|
seeds["go.mod"] = data
|
||||||
}
|
}
|
||||||
|
capabilityModule := moduleData
|
||||||
|
if seeded, ok := seeds["go.mod"]; ok {
|
||||||
|
capabilityModule = seeded
|
||||||
|
}
|
||||||
|
if err := checkI18nRuntime(c.Spec, capabilityModule, directory); err != nil {
|
||||||
|
return Output{}, err
|
||||||
|
}
|
||||||
if err = module.CheckImportPath(o.Module); err != nil {
|
if err = module.CheckImportPath(o.Module); err != nil {
|
||||||
return Output{}, err
|
return Output{}, err
|
||||||
}
|
}
|
||||||
@@ -338,25 +346,7 @@ func moduleFile(o Options) ([]byte, error) {
|
|||||||
}
|
}
|
||||||
return []byte(text), nil
|
return []byte(text), nil
|
||||||
}
|
}
|
||||||
func canonical(path string) string {
|
func canonical(path string) string { return genfs.Canonical(path) }
|
||||||
path, _ = filepath.Abs(path)
|
|
||||||
candidate := path
|
|
||||||
var tail []string
|
|
||||||
for {
|
|
||||||
if resolved, err := filepath.EvalSymlinks(candidate); err == nil {
|
|
||||||
for i := len(tail) - 1; i >= 0; i-- {
|
|
||||||
resolved = filepath.Join(resolved, tail[i])
|
|
||||||
}
|
|
||||||
return resolved
|
|
||||||
}
|
|
||||||
parent := filepath.Dir(candidate)
|
|
||||||
if parent == candidate {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
tail = append(tail, filepath.Base(candidate))
|
|
||||||
candidate = parent
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map[string][]byte, error) {
|
func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map[string][]byte, error) {
|
||||||
generated, seeds := map[string][]byte{}, map[string][]byte{}
|
generated, seeds := map[string][]byte{}, map[string][]byte{}
|
||||||
|
|||||||
57
cmd/go-omni/apigen/i18nVersion.go
Normal file
57
cmd/go-omni/apigen/i18nVersion.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/mod/modfile"
|
||||||
|
"golang.org/x/mod/semver"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Translation metadata was introduced in this release. Consumers of older
|
||||||
|
// runtimes must upgrade or use an explicitly supported local replacement.
|
||||||
|
const i18nMinimumVersion = "v0.26.0-preview"
|
||||||
|
|
||||||
|
func checkI18nRuntime(spec *Spec, moduleData []byte, directory string) error {
|
||||||
|
needed := false
|
||||||
|
for _, e := range spec.Errors {
|
||||||
|
needed = needed || e.Translation != nil
|
||||||
|
}
|
||||||
|
if !needed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
mf, err := modfile.Parse("go.mod", moduleData, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
version := ""
|
||||||
|
for _, r := range mf.Require {
|
||||||
|
if r.Mod.Path == frameworkModule {
|
||||||
|
version = r.Mod.Version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, r := range mf.Replace {
|
||||||
|
if r.Old.Path != frameworkModule || r.Old.Version != "" && r.Old.Version != version {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.New.Version != "" {
|
||||||
|
version = r.New.Version
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
root := r.New.Path
|
||||||
|
if !filepath.IsAbs(root) {
|
||||||
|
root = filepath.Join(directory, root)
|
||||||
|
}
|
||||||
|
data, e := os.ReadFile(filepath.Join(root, "errors", "localization.go"))
|
||||||
|
if e == nil && strings.Contains(string(data), "type Translation struct") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("translation metadata requires a local framework with errors.Translation support")
|
||||||
|
}
|
||||||
|
if semver.Compare(version, i18nMinimumVersion) < 0 {
|
||||||
|
return fmt.Errorf("translation metadata requires %s or newer; use --framework-dir for unpublished source", i18nMinimumVersion)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
42
cmd/go-omni/apigen/i18n_test.go
Normal file
42
cmd/go-omni/apigen/i18n_test.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestI18nTranslationMetadataAndVersionBoundary(t *testing.T) {
|
||||||
|
spec, err := Parse(filepath.Join(frameworkRoot(t), "examples/contracts/i18n.api"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
compiled, err := Compile(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ref := compiled.Spec.Errors[0].Translation
|
||||||
|
if ref == nil || ref.Namespace != "limits" || ref.Key != "exceeded" {
|
||||||
|
t.Fatal(ref)
|
||||||
|
}
|
||||||
|
if err := checkI18nRuntime(spec, []byte("module example.com/test\ngo 1.22\nrequire "+frameworkModule+" v0.25.0-preview\n"), t.TempDir()); err == nil {
|
||||||
|
t.Fatal("old runtime accepted")
|
||||||
|
}
|
||||||
|
generated, _, err := renderProject(Options{Module: "example.com/test"}, compiled, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(generated["internal/apierrors/errorsGen.go"]), "translation") || !strings.Contains(string(generated["docs/openapi.json"]), "translation") {
|
||||||
|
t.Fatal("translation metadata lost")
|
||||||
|
}
|
||||||
|
bad := readFile(t, filepath.Join(frameworkRoot(t), "examples/contracts/i18n.api"))
|
||||||
|
bad = strings.Replace(bad, `"namespace":"limits"`, `"namespace":""`, 1)
|
||||||
|
parsed, err := parseText(t, bad)
|
||||||
|
if err == nil {
|
||||||
|
_, err = Compile(parsed)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("invalid translation reference accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestI18nGeneratedConsumer(t *testing.T) { testCatalogConsumer(t, "i18n.api", "i18n") }
|
||||||
@@ -63,18 +63,23 @@ func TestAPIInfrastructureConsumers(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
test := `package config
|
test := `package config
|
||||||
import("testing";"os")
|
import("testing";"os";"strings")
|
||||||
func TestConfiguration(t *testing.T){
|
func TestConfiguration(t *testing.T){
|
||||||
t.Setenv("GO_OMNI_DB_DSN","test:test@tcp(127.0.0.1:1)/test")
|
t.Setenv("GO_OMNI_DB_DATABASE","test");t.Setenv("GO_OMNI_DB_USER","test");t.Setenv("GO_OMNI_DB_PASSWORD","test")
|
||||||
t.Setenv("GO_OMNI_STORAGE_SIGNING_KEY","01234567890123456789012345678901")
|
t.Setenv("GO_OMNI_STORAGE_SIGNING_KEY","01234567890123456789012345678901")
|
||||||
t.Setenv("GO_OMNI_STORAGE_ACCESS_KEY","test");t.Setenv("GO_OMNI_STORAGE_SECRET_KEY","test")
|
t.Setenv("GO_OMNI_STORAGE_ACCESS_KEY","test");t.Setenv("GO_OMNI_STORAGE_SECRET_KEY","test")
|
||||||
t.Setenv("GO_OMNI_STORAGE_BUCKET","test");t.Setenv("GO_OMNI_STORAGE_ENDPOINT","ENDPOINT")
|
t.Setenv("GO_OMNI_STORAGE_BUCKET","test");t.Setenv("GO_OMNI_STORAGE_ENDPOINT","ENDPOINT")
|
||||||
t.Setenv("GO_OMNI_STORAGE_ACCOUNT_ID","01234567890123456789012345678901")
|
t.Setenv("GO_OMNI_STORAGE_ACCOUNT_ID","01234567890123456789012345678901")
|
||||||
cfg,err:=Load("../../etc/config.yaml");if err!=nil{t.Fatal(err)}
|
cfg,err:=Load("../../etc/config.yaml");if err!=nil{t.Fatal(err)}
|
||||||
if cfg.Server.Log.File==""{t.Fatal("file logger absent")}
|
if cfg.Server.Log.File==""{t.Fatal("file logger absent")}
|
||||||
t.Setenv("GO_OMNI_LOG_OUTPUT","stdout");cfg,err=Load("");if err!=nil||cfg.Server.Log.File!=""{t.Fatalf("override: %v",err)}
|
t.Setenv("GO_OMNI_LOG_OUTPUT","stdout");t.Setenv("GO_OMNI_SERVER_LOG_FILE","./implicit.log")
|
||||||
t.Setenv("GO_OMNI_SERVER_LOG_FILE","./explicit.log");cfg,err=Load("");if err!=nil||cfg.Server.Log.File!="./explicit.log"{t.Fatalf("canonical log file: %v",err)}
|
cfg,err=Load("../../etc/config.yaml");if err!=nil||cfg.Server.Log.File!=Default().Server.Log.File{t.Fatalf("implicit environment override: %v",err)}
|
||||||
t.Setenv("GO_OMNI_SERVER_LOG_OUTPUT","stderr");cfg,err=Load("");if err!=nil||cfg.Server.Log.Output!="stderr"{t.Fatalf("canonical output: %v",err)}
|
raw,err:=os.ReadFile("../../etc/config.yaml");if err!=nil{t.Fatal(err)}
|
||||||
|
// Add explicit fields while keeping storage and DB requirements.
|
||||||
|
raw=[]byte(strings.Replace(string(raw),"server:\n","server:\n log:\n output: '${OUTPUT:-stdout}'\n file: ''\n rotation:\n enabled: false\n",1))
|
||||||
|
path:="explicit.yaml";if err=os.WriteFile(path,raw,0600);err!=nil{t.Fatal(err)};defer os.Remove(path)
|
||||||
|
cfg,err=Load(path);if err!=nil||cfg.Server.Log.Output!="stdout"||cfg.Server.Log.File!=""||cfg.Server.Log.Rotation.Enabled{t.Fatalf("explicit file override: %v",err)}
|
||||||
|
|
||||||
_=os.Getenv("unused")
|
_=os.Getenv("unused")
|
||||||
}`
|
}`
|
||||||
endpoint := ""
|
endpoint := ""
|
||||||
|
|||||||
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
|
return "*types." + name
|
||||||
}
|
}
|
||||||
func responseDataSchema(value string, bindings map[string]*contract.Binder) (*openapi.Schema, error) {
|
func responseDataSchema(value string, bindings map[string]*contract.Binder, graph *responseGraph) (*openapi.Schema, error) {
|
||||||
kind, name := responseShape(value)
|
kind, name := responseShape(value)
|
||||||
if kind == "null" {
|
if kind == "null" {
|
||||||
return &openapi.Schema{Type: "object", Nullable: true, Enum: []any{nil}}, nil
|
return &openapi.Schema{Type: "object", Nullable: true, Enum: []any{nil}}, nil
|
||||||
@@ -56,11 +56,15 @@ func responseDataSchema(value string, bindings map[string]*contract.Binder) (*op
|
|||||||
}
|
}
|
||||||
return s.Properties["value"], nil
|
return s.Properties["value"], nil
|
||||||
}
|
}
|
||||||
b, ok := bindings[name]
|
var s *openapi.Schema
|
||||||
if !ok {
|
var err error
|
||||||
|
if graph.recursive[name] {
|
||||||
|
s, err = graph.component(name)
|
||||||
|
} else if b := bindings[name]; b != nil {
|
||||||
|
s, err = b.ResponseSchema()
|
||||||
|
} else {
|
||||||
return nil, fmt.Errorf("unknown response DTO %q; use Name, []Name, []string/[]int64/[]bool/[]any, *Name or null", name)
|
return nil, fmt.Errorf("unknown response DTO %q; use Name, []Name, []string/[]int64/[]bool/[]any, *Name or null", name)
|
||||||
}
|
}
|
||||||
s, err := b.ResponseSchema()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -68,6 +72,9 @@ func responseDataSchema(value string, bindings map[string]*contract.Binder) (*op
|
|||||||
case "array":
|
case "array":
|
||||||
return &openapi.Schema{Type: "array", Items: s}, nil
|
return &openapi.Schema{Type: "array", Items: s}, nil
|
||||||
case "nullable":
|
case "nullable":
|
||||||
|
if s.Ref != "" {
|
||||||
|
return nullableReference(s), nil
|
||||||
|
}
|
||||||
s.Nullable = true
|
s.Nullable = true
|
||||||
}
|
}
|
||||||
return s, nil
|
return s, nil
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ import (
|
|||||||
"MODULE/internal/svc"
|
"MODULE/internal/svc"
|
||||||
)
|
)
|
||||||
func main(){
|
func main(){
|
||||||
path:=flag.String("config","etc/config.yaml","configuration file; empty uses defaults and environment")
|
path:=flag.String("config","etc/config.yaml","configuration file; empty uses only Default()")
|
||||||
|
check:=flag.Bool("check-config",false,"validate configuration and exit without starting dependencies")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
if *check {if _,err:=config.Load(*path);err!=nil{log.Fatal(err)};return}
|
||||||
ctx,stop:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)
|
ctx,stop:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
if err:=run(ctx,*path);err!=nil{log.Fatal(err)}
|
if err:=run(ctx,*path);err!=nil{log.Fatal(err)}
|
||||||
@@ -28,12 +30,12 @@ func main(){
|
|||||||
func run(ctx context.Context,path string)(runErr error){
|
func run(ctx context.Context,path string)(runErr error){
|
||||||
cfg,err:=config.Load(path);if err!=nil{return err}
|
cfg,err:=config.Load(path);if err!=nil{return err}
|
||||||
logger,err:=logx.New(cfg.Server.Log);if err!=nil{return err}
|
logger,err:=logx.New(cfg.Server.Log);if err!=nil{return err}
|
||||||
// Unjoined background work may still log. The owning process exits on this
|
// Unjoined HTTP or background work may still log. The owning process exits on this
|
||||||
// error; do not close its borrowed logger before that work has finished.
|
// error; do not close its borrowed logger before that work has finished.
|
||||||
defer func(){if !errors.Is(runErr,lifecycle.ErrResourcesRetained){runErr=errors.Join(runErr,logger.Close())}}()
|
defer func(){if !errors.Is(runErr,lifecycle.ErrResourcesRetained){runErr=errors.Join(runErr,logger.Close())}}()
|
||||||
deps,err:=svc.New(ctx,logger,cfg);if err!=nil{return err}
|
deps,err:=svc.New(ctx,logger,cfg);if err!=nil{return err}
|
||||||
// RunContext drains HTTP requests before returning. This scope owns dependencies
|
// Finish propagates incomplete HTTP draining: stop background work but retain
|
||||||
// and closes them exactly once, before closing the borrowed logger.
|
// shared dependencies when any handler is still running. Otherwise close once.
|
||||||
defer func(){runErr=deps.Finish(runErr)}()
|
defer func(){runErr=deps.Finish(runErr)}()
|
||||||
app,err:=handler.NewApp(deps);if err!=nil{return err}
|
app,err:=handler.NewApp(deps);if err!=nil{return err}
|
||||||
defer func(){runErr=errors.Join(runErr,app.Close())}()
|
defer func(){runErr=errors.Join(runErr,app.Close())}()
|
||||||
@@ -58,7 +60,12 @@ Unimplemented/nil factories fail startup and clean registered dependencies.
|
|||||||
Existing projects retain middleware: handlers until explicitly migrated.
|
Existing projects retain middleware: handlers until explicitly migrated.
|
||||||
Ordinary Logic uses the generated Request/Response types directly. Where an
|
Ordinary Logic uses the generated Request/Response types directly. Where an
|
||||||
independent business type is needed, use ordinary Go or the optional copyx package.
|
independent business type is needed, use ordinary Go or the optional copyx package.
|
||||||
Configuration loading is generator-managed in internal/config/loadGen.go. Extend fields/defaults/Validate in config.go; additional decoders belong in loader.go/NewLoader. JSON/YAML/XML use one pipeline. See the framework CONFIG.md.
|
Configuration loading is generator-managed in internal/config/loadGen.go. Extend fields/defaults/Validate in config.go; additional decoders belong in loader.go/NewLoader. JSON/YAML/XML use explicit ${NAME} / ${NAME:-fallback} references; fixed values are never overridden by inferred environment names. Use -check-config to validate without opening dependencies. Framework validation
|
||||||
|
identifies fields such as server.log.level and database.port. server.log.timeZone
|
||||||
|
optionally selects a per-logger IANA zone. Optional server.cors is loaded and installed
|
||||||
|
automatically before routing/authentication; disabled by default. Configure allowedOrigins,
|
||||||
|
allowedMethods and allowedHeaders in the selected file; no main/handler edits.
|
||||||
|
See the framework CONFIG.md and cors/README.md.
|
||||||
|
|
||||||
There are no mappings/mappingFiles settings or generated mapping files in v0.19.
|
There are no mappings/mappingFiles settings or generated mapping files in v0.19.
|
||||||
See COPY.md in the framework repository for copy policies and migration steps,
|
See COPY.md in the framework repository for copy policies and migration steps,
|
||||||
@@ -77,7 +84,7 @@ Configure server.endpoints to disable or move each built-in route. Health
|
|||||||
responseMode is app by default (project format), or raw for plain health data.
|
responseMode is app by default (project format), or raw for plain health data.
|
||||||
Probe HTTP statuses remain 200/503 even when business errors use HTTP 200.
|
Probe HTTP statuses remain 200/503 even when business errors use HTTP 200.
|
||||||
OpenAPI remains an unwrapped document; disabled endpoints reserve no paths.
|
OpenAPI remains an unwrapped document; disabled endpoints reserve no paths.
|
||||||
Set GO_OMNI_TRANSPORT=native to select the native transport without changing services.
|
The default file explicitly references GO_OMNI_TRANSPORT; set it to native or edit server.transport.
|
||||||
The greeting starter works immediately; other new operation stubs return 501 until
|
The greeting starter works immediately; other new operation stubs return 501 until
|
||||||
their business logic is implemented. Authentication hooks also fail closed with 501.
|
their business logic is implemented. Authentication hooks also fail closed with 501.
|
||||||
|
|
||||||
@@ -139,7 +146,7 @@ Full reference: https://git.yuntuo.hk/k2safe/go-omni/src/branch/main/DEVELOPMENT
|
|||||||
api/ # editable contract and imported types
|
api/ # editable contract and imported types
|
||||||
main.go # GENERATED startup, lifecycle and route registration
|
main.go # GENERATED startup, lifecycle and route registration
|
||||||
etc/config.yaml # lowerCamelCase configuration
|
etc/config.yaml # lowerCamelCase configuration
|
||||||
internal/config/ # defaults -> file -> environment
|
internal/config/ # defaults -> file references -> typed binding -> validation
|
||||||
internal/svc/ # dependency initialization and cleanup
|
internal/svc/ # dependency initialization and cleanup
|
||||||
internal/types/*Gen.go # DTOs split by source API, one types package
|
internal/types/*Gen.go # DTOs split by source API, one types package
|
||||||
internal/svc/runtimeGen.go # GENERATED lifecycle and infrastructure startup
|
internal/svc/runtimeGen.go # GENERATED lifecycle and infrastructure startup
|
||||||
@@ -240,13 +247,16 @@ func infrastructureREADME(o Options) string {
|
|||||||
|
|
||||||
Selected database: %s; storage: %s; logging: %s/%s.
|
Selected database: %s; storage: %s; logging: %s/%s.
|
||||||
MySQL uses database/sql through deps.DB, including InTx and a readiness probe.
|
MySQL uses database/sql through deps.DB, including InTx and a readiness probe.
|
||||||
Use structured database/mysql configuration and GO_OMNI_DB_PASSWORD. Other connection/pool/TLS settings live in etc/config.yaml and GO_OMNI_DB_*.
|
Use structured database/mysql configuration. The generated etc/config.yaml explicitly
|
||||||
|
references GO_OMNI_DB_PASSWORD and other required connection values; add fixed
|
||||||
|
values or explicit references for pool/TLS fields. No implicit environment overlay.
|
||||||
No tables are invented or migrated automatically from request/response DTOs.
|
No tables are invented or migrated automatically from request/response DTOs.
|
||||||
The none/memory selection opens no database; implement repositories per business.
|
The none/memory selection opens no database; implement repositories per business.
|
||||||
|
|
||||||
With storage enabled, services receive deps.Files, Uploads, Downloads and
|
With storage enabled, services receive deps.Files, Uploads, Downloads and
|
||||||
UploadLinks capability interfaces. Local signed GET/HEAD/PUT uses /storage/object;
|
UploadLinks capability interfaces. Local signed GET/HEAD/PUT uses /storage/object;
|
||||||
set GO_OMNI_STORAGE_SIGNING_KEY (32+ bytes) and GO_OMNI_STORAGE_PUBLIC_URL.
|
the generated file references GO_OMNI_STORAGE_SIGNING_KEY (32+ bytes) and
|
||||||
|
GO_OMNI_STORAGE_PUBLIC_URL. File values may instead be fixed explicitly.
|
||||||
Cloud credentials use GO_OMNI_STORAGE_ACCESS_KEY/SECRET_KEY; set bucket and endpoint
|
Cloud credentials use GO_OMNI_STORAGE_ACCESS_KEY/SECRET_KEY; set bucket and endpoint
|
||||||
or R2 accountId as appropriate. Signed URLs grant temporary access: authenticate
|
or R2 accountId as appropriate. Signed URLs grant temporary access: authenticate
|
||||||
and authorize before issuing them. No public upload-link route is added.
|
and authorize before issuing them. No public upload-link route is added.
|
||||||
|
|||||||
@@ -6,28 +6,26 @@ import (
|
|||||||
"example.com/configuration/internal/config"
|
"example.com/configuration/internal/config"
|
||||||
omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
|
omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestGeneratedConfiguration(t *testing.T) {
|
func TestGeneratedConfiguration(t *testing.T) {
|
||||||
for ext, source := range map[string]string{
|
for ext, source := range map[string]string{"json": `{"limit":"${LIMIT:-7}"}`, "yaml": "limit: '${LIMIT:-7}'\n", "xml": "<config><limit>${LIMIT:-7}</limit></config>", "custom": `{"limit":"${LIMIT:-7}"}`} {
|
||||||
"json": `{"limit":7}`, "yaml": "limit: 7\n", "xml": "<config><limit>7</limit></config>", "custom": `{"limit":7}`,
|
|
||||||
} {
|
|
||||||
t.Run(ext, func(t *testing.T) {
|
t.Run(ext, func(t *testing.T) {
|
||||||
|
t.Setenv("LIMIT", "")
|
||||||
|
t.Setenv("GO_OMNI_LIMIT", "999")
|
||||||
path := filepath.Join(t.TempDir(), "app."+ext)
|
path := filepath.Join(t.TempDir(), "app."+ext)
|
||||||
if err := os.WriteFile(path, []byte(source), 0600); err != nil {
|
if err := os.WriteFile(path, []byte(source), 0600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
cfg, err := config.Load(path)
|
cfg, err := config.Load(path)
|
||||||
if err != nil {
|
if err != nil || cfg.Limit != 7 {
|
||||||
t.Fatal(err)
|
t.Fatal(cfg.Limit, err)
|
||||||
}
|
}
|
||||||
if cfg.Limit != 7 {
|
t.Setenv("LIMIT", "0")
|
||||||
t.Fatal(cfg.Limit)
|
|
||||||
}
|
|
||||||
t.Setenv("GO_OMNI_LIMIT", "0")
|
|
||||||
cfg, err = config.Load(path)
|
cfg, err = config.Load(path)
|
||||||
if err != nil || cfg.Limit != 0 {
|
if err != nil || cfg.Limit != 0 {
|
||||||
t.Fatal(cfg.Limit, err)
|
t.Fatal(cfg.Limit, err)
|
||||||
@@ -36,24 +34,80 @@ func TestGeneratedConfiguration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Setenv("GO_OMNI_LOG_OUTPUT", "stderr")
|
t.Setenv("GO_OMNI_LOG_OUTPUT", "stderr")
|
||||||
cfg, err := config.Load("")
|
cfg, err := config.Load("")
|
||||||
if err != nil {
|
if err != nil || cfg.Limit != 10 || cfg.Server.Log.File != "./var/log/app.log" || !cfg.Server.Log.Rotation.Enabled {
|
||||||
t.Fatal(err)
|
t.Fatal("implicit overlay", err)
|
||||||
}
|
|
||||||
if cfg.Limit != 10 || cfg.Server.Log.File != "" || cfg.Server.Log.Rotation.Enabled {
|
|
||||||
t.Fatal("defaults/log normalization", cfg, err)
|
|
||||||
}
|
}
|
||||||
path := filepath.Join(t.TempDir(), "bad.yaml")
|
path := filepath.Join(t.TempDir(), "bad.yaml")
|
||||||
os.WriteFile(path, []byte("limit: -1\n"), 0600)
|
os.WriteFile(path, []byte("limit: '${LIMIT}'\n"), 0600)
|
||||||
// Validation must run before svc.Initialize; the project initializer panics
|
t.Setenv("LIMIT", "-1")
|
||||||
// below if reached, so this also detects accidentally starting dependencies.
|
// svc.Init panics if reached. Both invalid startup and successful check must
|
||||||
|
// stop before opening the configured file logger or project dependencies.
|
||||||
err = run(context.Background(), path)
|
err = run(context.Background(), path)
|
||||||
var diagnostic *omniConfig.Error
|
var d *omniConfig.Error
|
||||||
if !errors.As(err, &diagnostic) || diagnostic.Field != "limit" || diagnostic.File != path || diagnostic.Line != 1 {
|
if !errors.As(err, &d) || d.Field != "limit" || d.File != path || d.Line != 1 || d.Env != "LIMIT" {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Setenv("GO_OMNI_LIMIT", "private-not-a-number")
|
t.Setenv("LIMIT", "private-invalid")
|
||||||
_, err = config.Load(path)
|
_, err = config.Load(path)
|
||||||
if !errors.As(err, &diagnostic) || diagnostic.Env != "GO_OMNI_LIMIT" || strings.Contains(err.Error(), "private-not-a-number") {
|
if !errors.As(err, &d) || d.Env != "LIMIT" || strings.Contains(err.Error(), "private-invalid") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestConfigurationCheckProcess(t *testing.T) {
|
||||||
|
exe := filepath.Join(t.TempDir(), "server")
|
||||||
|
cmd := exec.Command("go", "build", "-o", exe, ".")
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatal(err, string(out))
|
||||||
|
}
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "selected.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte("limit: '${LIMIT:-0}'\nserver:\n address: 127.0.0.1:1\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A broken neighboring default/local/test file must never be layered in.
|
||||||
|
for _, name := range []string{"config.yaml", "config.local.yaml", "config.test.yaml"} {
|
||||||
|
os.WriteFile(filepath.Join(dir, name), []byte("broken: ["), 0600)
|
||||||
|
}
|
||||||
|
cmd = exec.Command(exe, "-config", path, "-check-config")
|
||||||
|
cmd.Dir = dir
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil || len(out) != 0 {
|
||||||
|
t.Fatal(err, string(out))
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "var")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("check opened logger/dependencies")
|
||||||
|
}
|
||||||
|
os.WriteFile(path, []byte("limit: '${MISSING_REQUIRED}'"), 0600)
|
||||||
|
cmd = exec.Command(exe, "-config", path, "-check-config")
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = append(os.Environ(), "MISSING_REQUIRED=")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err == nil || !strings.Contains(string(out), "MISSING_REQUIRED") || strings.Contains(string(out), "panic") {
|
||||||
|
t.Fatal(err, string(out))
|
||||||
|
}
|
||||||
|
cmd = exec.Command(exe, "-config", path)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = append(os.Environ(), "MISSING_REQUIRED=")
|
||||||
|
if out, err := cmd.CombinedOutput(); err == nil || strings.Contains(string(out), "panic") {
|
||||||
|
t.Fatal(err, string(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeneratedModuleFieldDiagnostic(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
|
t.Setenv("EXAMPLE_LOG_LEVEL", "invalid-private-level")
|
||||||
|
if err := os.WriteFile(path, []byte("server:\n log:\n level: '${EXAMPLE_LOG_LEVEL}'\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err := run(context.Background(), path)
|
||||||
|
var d *omniConfig.Error
|
||||||
|
if !errors.As(err, &d) || d.Field != "server.log.level" || d.Line != 3 || d.Env != "EXAMPLE_LOG_LEVEL" || !strings.Contains(d.Reason, "debug") || strings.Contains(err.Error(), "invalid-private-level") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, []byte("server:\n log:\n timeZone: Asia/Shanghai\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil || cfg.Server.Log.TimeZone != "Asia/Shanghai" {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
69
cmd/go-omni/apigen/testdata/cors/consumer_test.go
vendored
Normal file
69
cmd/go-omni/apigen/testdata/cors/consumer_test.go
vendored
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"example.com/corsdemo/internal/config"
|
||||||
|
"example.com/corsdemo/internal/handler"
|
||||||
|
"example.com/corsdemo/internal/svc"
|
||||||
|
omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGeneratedCORSConfiguration(t *testing.T) {
|
||||||
|
cfg, err := config.Load("etc/config.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
logger, err := logx.New(cfg.Server.Log)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer logger.Close()
|
||||||
|
deps, err := svc.New(context.Background(), logger, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer deps.Close()
|
||||||
|
a, err := handler.NewApp(deps)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer a.Close()
|
||||||
|
for _, tc := range []struct {
|
||||||
|
method, path string
|
||||||
|
status int
|
||||||
|
}{{"GET", "/ping", 501}, {"GET", "/missing", 404}, {"PUT", "/ping", 405}, {"GET", "/health/live", 200}, {"GET", "/health/ready", 200}, {"GET", "/openapi.json", 200}, {"OPTIONS", "/ping", 204}} {
|
||||||
|
r := httptest.NewRequest(tc.method, tc.path, nil)
|
||||||
|
r.Header.Set("Origin", "https://web.example")
|
||||||
|
if tc.method == "OPTIONS" {
|
||||||
|
r.Header.Set("Access-Control-Request-Method", "GET")
|
||||||
|
r.Header.Set("Access-Control-Request-Headers", "authorization")
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.ServeHTTP(w, r)
|
||||||
|
if w.Code != tc.status || w.Header().Get("Access-Control-Allow-Origin") != "https://web.example" {
|
||||||
|
t.Fatal(tc, w.Code, w.Header(), w.Body.String())
|
||||||
|
}
|
||||||
|
if tc.status == 204 && (w.Body.Len() != 0 || w.Header().Get("Access-Control-Max-Age") != "600") {
|
||||||
|
t.Fatal(w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Startup config validation uses the normal generated run/Load, before log
|
||||||
|
// files or dependencies. No changes to generated main/HTTP composition.
|
||||||
|
path := filepath.Join(t.TempDir(), "invalid.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte("server:\n cors:\n enabled: true\n allowedOrigins: ['*']\n allowCredentials: true\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err = run(context.Background(), path)
|
||||||
|
var d *omniConfig.Error
|
||||||
|
if !errors.As(err, &d) || d.Field != "server.cors.allowedOrigins[0]" || !strings.Contains(d.Reason, "allowCredentials") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
70
cmd/go-omni/apigen/testdata/i18n/consumer_test.go
vendored
Normal file
70
cmd/go-omni/apigen/testdata/i18n/consumer_test.go
vendored
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package main_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"example.com/errorCatalog/internal/apierrors"
|
||||||
|
"example.com/errorCatalog/internal/handler"
|
||||||
|
"example.com/errorCatalog/internal/svc"
|
||||||
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n/errori18n"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGeneratedTranslationAndParameterCompatibility(t *testing.T) {
|
||||||
|
catalog := apierrors.Catalog()
|
||||||
|
source, err := errori18n.Source(catalog, "en")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
source = append(source, i18n.Document{Version: 1, Locale: "zh-Hans", Namespace: "limits", Messages: map[string]i18n.Message{"exceeded": {Value: i18n.Pattern{Text: "上限:{max}"}}}})
|
||||||
|
b, err := i18n.New(i18n.Config{SourceLocale: "en", DefaultLocale: "en", Locales: []string{"en", "zh-Hans"}}, source...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p, err := httpi18n.New(b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
lc := logx.Default()
|
||||||
|
lc.Writer = io.Discard
|
||||||
|
logger, err := logx.New(lc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer logger.Close()
|
||||||
|
deps, err := svc.New(context.Background(), logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer deps.Close()
|
||||||
|
a := omni.New(omni.WithLanguagePolicy(p), omni.WithErrorLocalizer(errori18n.New(b, nil)))
|
||||||
|
defer a.Close()
|
||||||
|
a.Use(func(next httpx.Handler) httpx.Handler {
|
||||||
|
return func(c *httpx.Context) error { return apierrors.LimitExceeded(9223372036854775807) }
|
||||||
|
})
|
||||||
|
if err := handler.RegisterRoutes(a, deps); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
r := httptest.NewRequest("GET", "/value", nil)
|
||||||
|
r.Header.Set("Accept-Language", "zh-CN")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.ServeHTTP(w, r)
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if w.Code != 200 || body["detail"] != "上限:9223372036854775807" || body["code"] != float64(400001) || body["ok"] != false {
|
||||||
|
t.Fatal(w.Code, body)
|
||||||
|
}
|
||||||
|
if apierrors.LimitExceeded(5).Message != "Limit: 5" {
|
||||||
|
t.Fatal("non-HTTP constructor changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var Opened, Closed, DownloadClosed atomic.Int32
|
var Opened, Closed, DownloadClosed atomic.Int32
|
||||||
|
var SlowReturned atomic.Int32
|
||||||
var Started = make(chan struct{}, 1)
|
var Started = make(chan struct{}, 1)
|
||||||
var Release = make(chan struct{})
|
var Release = make(chan struct{})
|
||||||
var Current atomic.Pointer[ServiceContext]
|
var Current atomic.Pointer[ServiceContext]
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ func TestGeneratedTransfers(t *testing.T) {
|
|||||||
func configFile(t *testing.T, address string, fail bool) string {
|
func configFile(t *testing.T, address string, fail bool) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
path := filepath.Join(t.TempDir(), "config.json")
|
path := filepath.Join(t.TempDir(), "config.json")
|
||||||
data, _ := json.Marshal(map[string]any{"server": map[string]any{"address": address}, "failInit": fail})
|
data, _ := json.Marshal(map[string]any{"server": map[string]any{"address": address, "transport": "${GO_OMNI_TRANSPORT:-nethttp}"}, "failInit": fail})
|
||||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ type countedReader struct{ io.Reader }
|
|||||||
|
|
||||||
func (c *countedReader) Close() error { svc.DownloadClosed.Add(1); return nil }
|
func (c *countedReader) Close() error { svc.DownloadClosed.Add(1); return nil }
|
||||||
func (l *Logic) Slow(context.Context) (*types.Receipt, error) {
|
func (l *Logic) Slow(context.Context) (*types.Receipt, error) {
|
||||||
|
defer svc.SlowReturned.Add(1)
|
||||||
before := svc.Closed.Load()
|
before := svc.Closed.Load()
|
||||||
svc.Started <- struct{}{}
|
svc.Started <- struct{}{}
|
||||||
<-svc.Release
|
<-svc.Release
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func phasedConfig(t *testing.T, address, mode string, fail bool) string {
|
|||||||
if mode == "stuck" {
|
if mode == "stuck" {
|
||||||
drainTimeout = "50ms"
|
drainTimeout = "50ms"
|
||||||
}
|
}
|
||||||
data, _ := json.Marshal(map[string]any{"backgroundMode": mode, "failInit": fail, "server": map[string]any{"address": address, "lifecycle": map[string]string{"stopTimeout": "1s", "drainTimeout": drainTimeout, "cancelTimeout": "50ms"}}})
|
data, _ := json.Marshal(map[string]any{"backgroundMode": mode, "failInit": fail, "server": map[string]any{"address": address, "transport": "${GO_OMNI_TRANSPORT:-nethttp}", "lifecycle": map[string]string{"stopTimeout": "1s", "drainTimeout": drainTimeout, "cancelTimeout": "50ms"}}})
|
||||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"example.com/managed/internal/handler"
|
"example.com/managed/internal/handler"
|
||||||
"example.com/managed/internal/svc"
|
"example.com/managed/internal/svc"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/health"
|
"git.yuntuo.hk/k2safe/go-omni/health"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -229,3 +230,80 @@ func TestGeneratedEndpointConflictCleansUp(t *testing.T) {
|
|||||||
t.Fatal("collision leaked resources or started serving", err)
|
t.Fatal("collision leaked resources or started serving", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGeneratedIncompleteHTTPDrainRetainsDependencies(t *testing.T) {
|
||||||
|
for _, transport := range []string{"nethttp", "native"} {
|
||||||
|
t.Run(transport, func(t *testing.T) {
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
address := l.Addr().String()
|
||||||
|
l.Close()
|
||||||
|
path := filepath.Join(t.TempDir(), "config.json")
|
||||||
|
logFile := filepath.Join(t.TempDir(), "service.log")
|
||||||
|
data, _ := json.Marshal(map[string]any{"server": map[string]any{"address": address, "transport": transport, "shutdownTimeout": "30ms", "http": map[string]string{"shutdownCancelTimeout": "30ms"}, "log": map[string]any{"file": logFile, "rotation": map[string]any{"enabled": true}}}})
|
||||||
|
if err = os.WriteFile(path, data, 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
svc.Started = make(chan struct{}, 1)
|
||||||
|
svc.Release = make(chan struct{})
|
||||||
|
before := svc.Closed.Load()
|
||||||
|
returned := svc.SlowReturned.Load()
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- run(ctx, path) }()
|
||||||
|
waitReady(t, "http://"+address, done)
|
||||||
|
client := &http.Client{Timeout: 3 * time.Second}
|
||||||
|
defer client.CloseIdleConnections()
|
||||||
|
requestDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(requestDone)
|
||||||
|
res, err := client.Get("http://" + address + "/slow")
|
||||||
|
if err == nil {
|
||||||
|
io.Copy(io.Discard, res.Body)
|
||||||
|
res.Body.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
await(t, svc.Started)
|
||||||
|
// Capture the channel, because a later test installs its own fixture.
|
||||||
|
release := svc.Release
|
||||||
|
deps := svc.Current.Load()
|
||||||
|
defer func() {
|
||||||
|
close(release)
|
||||||
|
deadline := time.Now().Add(3 * time.Second)
|
||||||
|
for svc.SlowReturned.Load() == returned {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Error("handler did not exit after test release")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
// The test owns the controlled handler and has now joined it.
|
||||||
|
_ = deps.Logger.Close()
|
||||||
|
}()
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err = <-done:
|
||||||
|
if !errors.Is(err, lifecycle.ErrResourcesRetained) || !errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("unbounded HTTP shutdown")
|
||||||
|
}
|
||||||
|
if err = deps.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) {
|
||||||
|
t.Fatal("deferred Close bypassed retention", err)
|
||||||
|
}
|
||||||
|
if svc.Closed.Load() != before {
|
||||||
|
t.Fatal("generated main closed client during active request")
|
||||||
|
}
|
||||||
|
deps.Logger.Info("retained.http.logger")
|
||||||
|
logs, err := os.ReadFile(logFile)
|
||||||
|
if err != nil || !strings.Contains(string(logs), "retained.http.logger") {
|
||||||
|
t.Fatal("generated main closed logger during active request", err, string(logs))
|
||||||
|
}
|
||||||
|
await(t, requestDone)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,9 @@ import (
|
|||||||
"golang.org/x/mod/semver"
|
"golang.org/x/mod/semver"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -54,6 +56,22 @@ func checkConfigLoadExtension(root string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("parse %s: %w", rel, err)
|
return fmt.Errorf("parse %s: %w", rel, err)
|
||||||
}
|
}
|
||||||
|
var removed bool
|
||||||
|
ast.Inspect(f, func(n ast.Node) bool {
|
||||||
|
field, ok := n.(*ast.Field)
|
||||||
|
if ok && field.Tag != nil {
|
||||||
|
if raw, e := strconv.Unquote(field.Tag.Value); e == nil {
|
||||||
|
tag := reflect.StructTag(raw)
|
||||||
|
_, a := tag.Lookup("env")
|
||||||
|
_, b := tag.Lookup("envDefault")
|
||||||
|
removed = removed || a || b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
if removed {
|
||||||
|
return fmt.Errorf("%s: env/envDefault tags were removed; migrate defaults and explicit file references before generating; see CONFIG.md", rel)
|
||||||
|
}
|
||||||
for _, decl := range f.Decls {
|
for _, decl := range f.Decls {
|
||||||
if fn, ok := decl.(*ast.FuncDecl); ok && fn.Recv == nil && fn.Name.Name == "Load" {
|
if fn, ok := decl.(*ast.FuncDecl); ok && fn.Recv == nil && fn.Name.Name == "Load" {
|
||||||
return fmt.Errorf("%s: migrate the project-owned Load function before generating internal/config/loadGen.go; retain Config/Default/Validate and move decoder options to NewLoader; see CONFIG.md", rel)
|
return fmt.Errorf("%s: migrate the project-owned Load function before generating internal/config/loadGen.go; retain Config/Default/Validate and move decoder options to NewLoader; see CONFIG.md", rel)
|
||||||
|
|||||||
@@ -3,15 +3,18 @@ package apigen
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/projectgen"
|
||||||
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WorkspaceService struct {
|
type WorkspaceService struct {
|
||||||
Dir string `json:"dir"`
|
Dir string `json:"dir"`
|
||||||
|
Kind string `json:"kind,omitempty"`
|
||||||
API string `json:"api,omitempty"`
|
API string `json:"api,omitempty"`
|
||||||
Module string `json:"module,omitempty"`
|
Module string `json:"module,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -19,10 +22,14 @@ type WorkspaceConfig struct {
|
|||||||
Services []WorkspaceService `json:"services"`
|
Services []WorkspaceService `json:"services"`
|
||||||
ErrorCatalogs []WorkspaceCatalog `json:"errorCatalogs,omitempty"`
|
ErrorCatalogs []WorkspaceCatalog `json:"errorCatalogs,omitempty"`
|
||||||
}
|
}
|
||||||
|
type workspaceTarget struct {
|
||||||
|
dir, kind, module, entry string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
// Workspace operates independent Go modules listed in an explicit local file.
|
// Workspace dispatches to each independent project's generator. Directory/schema
|
||||||
// All projects are preflighted before generate/format writes. Each generator
|
// validation precedes any providers; all targets are preflighted before writes.
|
||||||
// transaction is atomic within its project; no cross-module transaction is promised.
|
// Transactions remain per-project, not crash-atomic across the workspace.
|
||||||
func Workspace(path, command string, w io.Writer) error {
|
func Workspace(path, command string, w io.Writer) error {
|
||||||
if command != "generate" && command != "check" && command != "format" {
|
if command != "generate" && command != "check" && command != "format" {
|
||||||
return errors.New("workspace command must be generate, check or format")
|
return errors.New("workspace command must be generate, check or format")
|
||||||
@@ -53,8 +60,8 @@ func Workspace(path, command string, w io.Writer) error {
|
|||||||
return errors.New("workspace must list 1..64 services")
|
return errors.New("workspace must list 1..64 services")
|
||||||
}
|
}
|
||||||
root := canonical(filepath.Dir(real))
|
root := canonical(filepath.Dir(real))
|
||||||
var options []Options
|
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
|
// Validate the entire workspace boundary before resolving or running any target.
|
||||||
for _, service := range cfg.Services {
|
for _, service := range cfg.Services {
|
||||||
if service.Dir == "" || service.Dir == "." {
|
if service.Dir == "" || service.Dir == "." {
|
||||||
return errors.New("workspace requires distinct relative service directories")
|
return errors.New("workspace requires distinct relative service directories")
|
||||||
@@ -62,27 +69,26 @@ func Workspace(path, command string, w io.Writer) error {
|
|||||||
if err = safePath(root, service.Dir+"/.goOmniWorkspaceCheck"); err != nil {
|
if err = safePath(root, service.Dir+"/.goOmniWorkspaceCheck"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
dir := filepath.Join(root, filepath.FromSlash(service.Dir))
|
key := strings.ToLower(filepath.Join(root, filepath.FromSlash(service.Dir)))
|
||||||
key := strings.ToLower(dir)
|
|
||||||
for prior := range seen {
|
for prior := range seen {
|
||||||
if prior == key || strings.HasPrefix(key, prior+string(filepath.Separator)) || strings.HasPrefix(prior, key+string(filepath.Separator)) {
|
if prior == key || strings.HasPrefix(key, prior+string(filepath.Separator)) || strings.HasPrefix(prior, key+string(filepath.Separator)) {
|
||||||
return errors.New("workspace service directories overlap")
|
return errors.New("workspace service directories overlap")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
seen[key] = true
|
seen[key] = true
|
||||||
entry := service.API
|
}
|
||||||
if entry == "" {
|
targets := make([]workspaceTarget, 0, len(cfg.Services))
|
||||||
entry, err = ProjectEntry(dir)
|
for _, service := range cfg.Services {
|
||||||
} else {
|
t := workspaceTarget{dir: filepath.Join(root, filepath.FromSlash(service.Dir)), module: service.Module}
|
||||||
if err = safePath(dir, entry); err != nil {
|
t.kind, t.err = workspaceKind(t.dir, service.Kind, service.API)
|
||||||
return err
|
if t.err == nil && t.kind == "api" {
|
||||||
|
if service.API == "" {
|
||||||
|
t.entry, t.err = ProjectEntry(t.dir)
|
||||||
|
} else if t.err = safePath(t.dir, service.API); t.err == nil {
|
||||||
|
t.entry = filepath.Join(t.dir, filepath.FromSlash(service.API))
|
||||||
}
|
}
|
||||||
entry = filepath.Join(dir, entry)
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
targets = append(targets, t)
|
||||||
return err
|
|
||||||
}
|
|
||||||
options = append(options, Options{API: entry, Dir: dir, Module: service.Module, DryRun: true})
|
|
||||||
}
|
}
|
||||||
var catalogs map[string][]byte
|
var catalogs map[string][]byte
|
||||||
if command != "format" {
|
if command != "format" {
|
||||||
@@ -90,26 +96,41 @@ func Workspace(path, command string, w io.Writer) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for i := range options {
|
|
||||||
options[i].documentCatalogs = catalogs
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
var failures error
|
var failures error
|
||||||
for _, o := range options {
|
report := func(t workspaceTarget, e error) {
|
||||||
if command == "format" {
|
if e != nil {
|
||||||
spec, e := Parse(o.API)
|
failures = errors.Join(failures, fmt.Errorf("%s: %w", t.dir, e))
|
||||||
if e == nil {
|
fmt.Fprintf(w, "FAIL %s: %v\n", t.dir, e)
|
||||||
_, e = Compile(spec)
|
} else if command == "format" && t.kind == "project" {
|
||||||
}
|
fmt.Fprintf(w, "SKIP %s (project has no API contract to format)\n", t.dir)
|
||||||
err = e
|
|
||||||
} else {
|
} else {
|
||||||
_, err = Generate(o)
|
fmt.Fprintf(w, "OK %s\n", t.dir)
|
||||||
}
|
}
|
||||||
if err != nil {
|
}
|
||||||
failures = errors.Join(failures, fmt.Errorf("%s: %w", o.Dir, err))
|
if command == "check" {
|
||||||
|
// Each generator checks its own manifest and managed outputs. An invalid
|
||||||
|
// project must not suppress diagnostics for another independent project.
|
||||||
|
for _, t := range targets {
|
||||||
|
report(t, runWorkspaceTarget(t, command, false, catalogs))
|
||||||
|
}
|
||||||
|
return failures
|
||||||
|
}
|
||||||
|
for i := range targets {
|
||||||
|
t := &targets[i]
|
||||||
|
t.err = runWorkspaceTarget(*t, command, true, catalogs)
|
||||||
|
}
|
||||||
|
for _, t := range targets {
|
||||||
|
if t.err != nil {
|
||||||
|
report(t, t.err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if failures != nil {
|
if failures != nil {
|
||||||
|
for _, t := range targets {
|
||||||
|
if t.err == nil {
|
||||||
|
fmt.Fprintf(w, "SKIP %s (workspace preflight failed; no changes applied)\n", t.dir)
|
||||||
|
}
|
||||||
|
}
|
||||||
return failures
|
return failures
|
||||||
}
|
}
|
||||||
if command == "generate" {
|
if command == "generate" {
|
||||||
@@ -119,26 +140,79 @@ func Workspace(path, command string, w io.Writer) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, o := range options {
|
for _, t := range targets {
|
||||||
switch command {
|
report(t, runWorkspaceTarget(t, command, false, catalogs))
|
||||||
case "format":
|
|
||||||
_, err = FormatProject(o.API, false)
|
|
||||||
case "generate":
|
|
||||||
o.DryRun = false
|
|
||||||
_, err = Generate(o)
|
|
||||||
case "check":
|
|
||||||
_, fmtErr := FormatProject(o.API, true)
|
|
||||||
o.DryRun = false
|
|
||||||
o.Check = true
|
|
||||||
_, genErr := Generate(o)
|
|
||||||
err = errors.Join(fmtErr, genErr)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
failures = errors.Join(failures, fmt.Errorf("%s: %w", o.Dir, err))
|
|
||||||
fmt.Fprintf(w, "FAIL %s\n", o.Dir)
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(w, "OK %s\n", o.Dir)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return failures
|
return failures
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An explicit API entry remains supported before the first API generation.
|
||||||
|
// Without manifests, omitted kind preserves the previous API interpretation.
|
||||||
|
// Manifests of both kinds or a conflicting declaration never silently choose one.
|
||||||
|
func workspaceKind(dir, kind, entry string) (string, error) {
|
||||||
|
if kind != "" && kind != "api" && kind != "project" {
|
||||||
|
return "", fmt.Errorf("unknown workspace kind %q; expected api or project", kind)
|
||||||
|
}
|
||||||
|
detected := ""
|
||||||
|
for _, candidate := range []struct{ kind, path string }{{"api", manifestPath}, {"project", ".goOmni/projectManifest.json"}} {
|
||||||
|
if err := safePath(dir, candidate.path); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, err := os.Lstat(filepath.Join(dir, candidate.path)); err == nil {
|
||||||
|
if detected != "" {
|
||||||
|
return "", errors.New("ambiguous project: both API and project manifests exist")
|
||||||
|
}
|
||||||
|
detected = candidate.kind
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kind != "" && detected != "" && kind != detected {
|
||||||
|
return "", fmt.Errorf("workspace kind %q conflicts with %s manifest", kind, detected)
|
||||||
|
}
|
||||||
|
if kind == "" {
|
||||||
|
kind = detected
|
||||||
|
}
|
||||||
|
if kind == "" {
|
||||||
|
kind = "api"
|
||||||
|
}
|
||||||
|
if kind == "project" && entry != "" {
|
||||||
|
return "", errors.New("workspace project kind does not accept an api entry")
|
||||||
|
}
|
||||||
|
return kind, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWorkspaceTarget(t workspaceTarget, command string, preview bool, catalogs map[string][]byte) error {
|
||||||
|
if t.err != nil {
|
||||||
|
return t.err
|
||||||
|
}
|
||||||
|
if t.kind == "project" {
|
||||||
|
if command == "format" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := projectgen.Generate(projectgen.Options{Dir: t.dir, Module: t.module, DryRun: preview, Check: command == "check"})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
o := Options{API: t.entry, Dir: t.dir, Module: t.module, DryRun: preview, documentCatalogs: catalogs}
|
||||||
|
switch command {
|
||||||
|
case "format":
|
||||||
|
if preview {
|
||||||
|
spec, err := Parse(t.entry)
|
||||||
|
if err == nil {
|
||||||
|
_, err = Compile(spec)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := FormatProject(t.entry, false)
|
||||||
|
return err
|
||||||
|
case "generate":
|
||||||
|
_, err := Generate(o)
|
||||||
|
return err
|
||||||
|
case "check":
|
||||||
|
_, formatErr := FormatProject(t.entry, true)
|
||||||
|
o.Check = true
|
||||||
|
_, generateErr := Generate(o)
|
||||||
|
return errors.Join(formatErr, generateErr)
|
||||||
|
}
|
||||||
|
return errors.New("unsupported workspace command")
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/projectgen"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -13,7 +14,7 @@ import (
|
|||||||
|
|
||||||
func TestWorkspaceSharedGoCatalog(t *testing.T) {
|
func TestWorkspaceSharedGoCatalog(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeFile(t, filepath.Join(root, "go.mod"), fmt.Sprintf("module example.com/catalogs\n\ngo 1.24.0\nrequire %s v0.15.0-preview\nreplace %s => %s\n", frameworkModule, frameworkModule, strconvQuote(frameworkRoot(t))))
|
writeFile(t, filepath.Join(root, "go.mod"), fmt.Sprintf("module example.com/catalogs\n\ngo 1.22.0\nrequire %s v0.15.0-preview\nreplace %s => %s\n", frameworkModule, frameworkModule, strconvQuote(frameworkRoot(t))))
|
||||||
writeFile(t, filepath.Join(root, "internal/apierrors/catalog.go"), `package apierrors
|
writeFile(t, filepath.Join(root, "internal/apierrors/catalog.go"), `package apierrors
|
||||||
import errors "git.yuntuo.hk/k2safe/go-omni/errors"
|
import errors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
func Catalog() errors.Catalog {return errors.Catalog{DefaultStatus:400,Definitions:map[string]errors.Definition{
|
func Catalog() errors.Catalog {return errors.Catalog{DefaultStatus:400,Definitions:map[string]errors.Definition{
|
||||||
@@ -30,8 +31,11 @@ func Catalog() errors.Catalog {return errors.Catalog{DefaultStatus:400,Definitio
|
|||||||
}
|
}
|
||||||
writeFile(t, filepath.Join(dir, "api/omni.imports.json"), `{"roots":{"common":{"path":"../../../contracts"}},"errors":{"source":"@common/errors.json","include":["INVALID"],"routes":{"GET /api/greet/:name":["MISSING"]}}}`)
|
writeFile(t, filepath.Join(dir, "api/omni.imports.json"), `{"roots":{"common":{"path":"../../../contracts"}},"errors":{"source":"@common/errors.json","include":["INVALID"],"routes":{"GET /api/greet/:name":["MISSING"]}}}`)
|
||||||
}
|
}
|
||||||
|
if _, err := projectgen.New(projectgen.Options{Dir: filepath.Join(root, "services/program"), Module: "example.com/program", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
workspace := filepath.Join(root, "omni.workspace.json")
|
workspace := filepath.Join(root, "omni.workspace.json")
|
||||||
writeFile(t, workspace, `{"errorCatalogs":[{"dir":".","package":"example.com/catalogs/internal/apierrors","function":"Catalog","output":"contracts/errors.json"}],"services":[{"dir":"services/api"},{"dir":"services/id"}]}`)
|
writeFile(t, workspace, `{"errorCatalogs":[{"dir":".","package":"example.com/catalogs/internal/apierrors","function":"Catalog","output":"contracts/errors.json"}],"services":[{"dir":"services/api"},{"dir":"services/id"},{"dir":"services/program"}]}`)
|
||||||
if err := Workspace(workspace, "generate", io.Discard); err != nil {
|
if err := Workspace(workspace, "generate", io.Discard); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
226
cmd/go-omni/apigen/workspaceMixed_test.go
Normal file
226
cmd/go-omni/apigen/workspaceMixed_test.go
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/projectgen"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mixedWorkspace(t *testing.T) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
if _, err := New(Options{Dir: filepath.Join(root, "apps/interface"), Module: "example.com/interface", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := projectgen.New(projectgen.Options{Dir: filepath.Join(root, "apps/program"), Module: "example.com/program", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
config := filepath.Join(root, "omni.workspace.json")
|
||||||
|
writeFile(t, config, `{"services":[{"dir":"apps/interface"},{"dir":"apps/program"}]}`)
|
||||||
|
if err := Workspace(config, "format", io.Discard); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := Workspace(config, "generate", io.Discard); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return root, config
|
||||||
|
}
|
||||||
|
func TestWorkspaceMixedKindsAndReadOnlyCheck(t *testing.T) {
|
||||||
|
for _, explicit := range []bool{false, true} {
|
||||||
|
t.Run(map[bool]string{false: "inferred", true: "explicit"}[explicit], func(t *testing.T) {
|
||||||
|
root, config := mixedWorkspace(t)
|
||||||
|
if explicit {
|
||||||
|
writeFile(t, config, `{"services":[{"dir":"apps/interface","kind":"api","module":"example.com/interface"},{"dir":"apps/program","kind":"project","module":"example.com/program"}]}`)
|
||||||
|
}
|
||||||
|
// Workspaces do not manage the Go toolchain's workspace or dependency files.
|
||||||
|
writeFile(t, filepath.Join(root, "go.work"), "go 1.22.0\nuse (\n ./apps/interface\n ./apps/program\n)\n")
|
||||||
|
writeFile(t, filepath.Join(root, "go.work.sum"), "workspace sums marker\n")
|
||||||
|
for _, dir := range []string{"apps/interface", "apps/program"} {
|
||||||
|
writeFile(t, filepath.Join(root, dir, "go.sum"), "module sums marker\n")
|
||||||
|
}
|
||||||
|
runFile := filepath.Join(root, "apps/program/internal/run/run.go")
|
||||||
|
writeFile(t, runFile, readFile(t, runFile)+"\n// project-owned work\n")
|
||||||
|
before := snapshot(t, root)
|
||||||
|
for _, op := range []string{"check", "generate", "check"} {
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := Workspace(config, op, &output); err != nil {
|
||||||
|
t.Fatal(op, err)
|
||||||
|
}
|
||||||
|
if strings.Count(output.String(), "OK ") != 2 {
|
||||||
|
t.Fatal(output.String())
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, root)) {
|
||||||
|
t.Fatal(op, "changed clean workspace or project-owned files")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Formatting only touches API contracts, even when project Go has drift.
|
||||||
|
main := filepath.Join(root, "apps/program/main.go")
|
||||||
|
writeFile(t, main, readFile(t, main)+"\n// drift intentionally not formatted\n")
|
||||||
|
before = snapshot(t, root)
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := Workspace(config, "format", &output); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output.String(), "SKIP ") || !strings.Contains(output.String(), "apps/program") {
|
||||||
|
t.Fatal(output.String())
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, root)) {
|
||||||
|
t.Fatal("format changed a plain Go project")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestWorkspaceMixedFailureAggregation(t *testing.T) {
|
||||||
|
root, config := mixedWorkspace(t)
|
||||||
|
for _, dir := range []string{"apps/interface", "apps/program"} {
|
||||||
|
path := filepath.Join(root, dir, "main.go")
|
||||||
|
writeFile(t, path, readFile(t, path)+"\n// manual drift\n")
|
||||||
|
}
|
||||||
|
before := snapshot(t, root)
|
||||||
|
var output bytes.Buffer
|
||||||
|
err := Workspace(config, "check", &output)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "apps/interface") || !strings.Contains(err.Error(), "apps/program") || strings.Count(output.String(), "FAIL ") != 2 {
|
||||||
|
t.Fatal(err, output.String())
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, root)) {
|
||||||
|
t.Fatal("failed check wrote files")
|
||||||
|
}
|
||||||
|
// Identity failures also keep unrelated project diagnostics.
|
||||||
|
writeFile(t, config, `{"services":[{"dir":"apps/interface","kind":"project"},{"dir":"apps/program"}]}`)
|
||||||
|
output.Reset()
|
||||||
|
before = snapshot(t, root)
|
||||||
|
err = Workspace(config, "check", &output)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "conflicts") || strings.Count(output.String(), "FAIL ") != 2 {
|
||||||
|
t.Fatal(err, output.String())
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, root)) {
|
||||||
|
t.Fatal("identity failure changed workspace")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestWorkspaceMixedPreflightBeforeAnyWrites(t *testing.T) {
|
||||||
|
for _, broken := range []string{"apps/interface", "apps/program"} {
|
||||||
|
t.Run(broken, func(t *testing.T) {
|
||||||
|
root, config := mixedWorkspace(t)
|
||||||
|
stale := "apps/interface"
|
||||||
|
if stale == broken {
|
||||||
|
stale = "apps/program"
|
||||||
|
}
|
||||||
|
// The first valid generator could repair its missing output, but must not
|
||||||
|
// write if another project's ownership check will fail.
|
||||||
|
if err := os.Remove(filepath.Join(root, stale, "main.go")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(root, broken, "main.go")
|
||||||
|
writeFile(t, path, readFile(t, path)+"\n// manual change\n")
|
||||||
|
before := snapshot(t, root)
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := Workspace(config, "generate", &output); err == nil {
|
||||||
|
t.Fatal("bad preflight accepted")
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, root)) {
|
||||||
|
t.Fatal("partial regeneration before failed preflight")
|
||||||
|
}
|
||||||
|
if !strings.Contains(output.String(), "SKIP ") || !strings.Contains(output.String(), "FAIL ") {
|
||||||
|
t.Fatal(output.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
root, config := mixedWorkspace(t)
|
||||||
|
for _, dir := range []string{"apps/interface", "apps/program"} {
|
||||||
|
if err := os.Remove(filepath.Join(root, dir, "main.go")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := Workspace(config, "generate", io.Discard); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := Workspace(config, "check", io.Discard); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestWorkspaceKindGuardsAndLegacyEntry(t *testing.T) {
|
||||||
|
root, config := mixedWorkspace(t)
|
||||||
|
for _, tc := range []struct{ services, needle string }{
|
||||||
|
{`[{"dir":"apps/program","kind":"worker"}]`, "unknown workspace kind"},
|
||||||
|
{`[{"dir":"apps/program","kind":"api"}]`, "conflicts"},
|
||||||
|
{`[{"dir":"apps/interface","kind":"project"}]`, "conflicts"},
|
||||||
|
{`[{"dir":"apps/program","api":"api/service.api"}]`, "does not accept"},
|
||||||
|
{`[{"dir":"apps/program","kind":"project","api":"api/service.api"}]`, "does not accept"},
|
||||||
|
{`[{"dir":"apps/program","module":"example.com/other"}]`, "module differs"},
|
||||||
|
{`[{"dir":"../outside","kind":"project"}]`, "unsafe"},
|
||||||
|
{`[{"dir":"apps/program"},{"dir":"apps/program/nested","kind":"project"}]`, "overlap"},
|
||||||
|
{`[{"dir":"apps/program","kind":"project","kind":"api"}]`, "duplicate"},
|
||||||
|
} {
|
||||||
|
writeFile(t, config, `{"services":`+tc.services+`}`)
|
||||||
|
before := snapshot(t, root)
|
||||||
|
err := Workspace(config, "check", io.Discard)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.needle) {
|
||||||
|
t.Fatal(tc.services, err)
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, root)) {
|
||||||
|
t.Fatal("invalid kind wrote files")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeFile(t, config, `{"services":[{"dir":"apps/program"}]}`)
|
||||||
|
other := filepath.Join(root, "apps/program/.goOmni/apiManifest.json")
|
||||||
|
writeFile(t, other, "{}")
|
||||||
|
if err := Workspace(config, "check", io.Discard); err == nil || !strings.Contains(err.Error(), "both API and project") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Remove(other); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// A malformed manifest must be routed to its owner, never fall back to API.
|
||||||
|
writeFile(t, filepath.Join(root, "apps/program/.goOmni/projectManifest.json"), "invalid")
|
||||||
|
if err := Workspace(config, "check", io.Discard); err == nil || !strings.Contains(err.Error(), "invalid project manifest") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Explicit API entry outside a managed project preserves the original path.
|
||||||
|
legacy := filepath.Join(root, "legacy")
|
||||||
|
writeFile(t, filepath.Join(legacy, "service.api"), "service legacy {\n@handler ping\nget /ping\n}\n")
|
||||||
|
writeFile(t, config, `{"services":[{"dir":"legacy","api":"service.api","module":"example.com/legacy"}]}`)
|
||||||
|
if err := Workspace(config, "generate", io.Discard); err != nil {
|
||||||
|
t.Fatal("legacy API entry", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestWorkspaceManifestSymlinkRejected(t *testing.T) {
|
||||||
|
root, config := mixedWorkspace(t)
|
||||||
|
path := filepath.Join(root, "apps/program/.goOmni/projectManifest.json")
|
||||||
|
target := filepath.Join(t.TempDir(), "manifest.json")
|
||||||
|
writeFile(t, target, readFile(t, path))
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(target, path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before := snapshot(t, root)
|
||||||
|
for _, op := range []string{"check", "format", "generate"} {
|
||||||
|
if err := Workspace(config, op, io.Discard); err == nil || !strings.Contains(err.Error(), "symlink") {
|
||||||
|
t.Fatal(op, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, root)) {
|
||||||
|
t.Fatal("followed symlink")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestWorkspaceProjectOnly(t *testing.T) {
|
||||||
|
root, config := mixedWorkspace(t)
|
||||||
|
raw, _ := json.Marshal(WorkspaceConfig{Services: []WorkspaceService{{Dir: "apps/program"}}})
|
||||||
|
writeFile(t, config, string(raw))
|
||||||
|
before := snapshot(t, root)
|
||||||
|
for _, op := range []string{"format", "generate", "check"} {
|
||||||
|
if err := Workspace(config, op, io.Discard); err != nil {
|
||||||
|
t.Fatal(op, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, root)) {
|
||||||
|
t.Fatal("plain workspace changed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/internal/genfs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -244,44 +245,13 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
|
|||||||
}
|
}
|
||||||
return result, checkErr
|
return result, checkErr
|
||||||
}
|
}
|
||||||
// A rollback restores bytes/modes if an ordinary filesystem write fails.
|
transaction := make([]genfs.Change, 0, len(changes))
|
||||||
// File replacement is atomic; the whole directory is not crash-atomic.
|
|
||||||
var applied []change
|
|
||||||
for _, c := range changes {
|
for _, c := range changes {
|
||||||
if err = safePath(root, c.name); err == nil {
|
transaction = append(transaction, genfs.Change{Name: c.name, Before: c.before, After: c.after, Existed: c.existed, Remove: c.remove, Mode: c.mode})
|
||||||
current, e := os.ReadFile(filepath.Join(root, filepath.FromSlash(c.name)))
|
|
||||||
if c.existed {
|
|
||||||
if e != nil || !bytes.Equal(current, c.before) {
|
|
||||||
err = fmt.Errorf("file changed during generation: %s", c.name)
|
|
||||||
}
|
|
||||||
} else if !errors.Is(e, os.ErrNotExist) {
|
|
||||||
err = fmt.Errorf("file appeared during generation: %s", c.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
if c.remove {
|
|
||||||
err = os.Remove(filepath.Join(root, filepath.FromSlash(c.name)))
|
|
||||||
} else {
|
|
||||||
err = atomicWrite(root, c.name, c.after, c.mode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
for i := len(applied) - 1; i >= 0; i-- {
|
|
||||||
previous := applied[i]
|
|
||||||
var rollback error
|
|
||||||
if previous.existed {
|
|
||||||
rollback = atomicWrite(root, previous.name, previous.before, previous.mode)
|
|
||||||
} else {
|
|
||||||
rollback = os.Remove(filepath.Join(root, filepath.FromSlash(previous.name)))
|
|
||||||
}
|
|
||||||
err = errors.Join(err, rollback)
|
|
||||||
}
|
|
||||||
return result, err
|
|
||||||
}
|
|
||||||
applied = append(applied, c)
|
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, genfs.Apply(root, transaction)
|
||||||
}
|
}
|
||||||
|
|
||||||
func digest(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }
|
func digest(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }
|
||||||
func ownedPath(name string) bool {
|
func ownedPath(name string) bool {
|
||||||
if name == "internal/config/loadGen.go" {
|
if name == "internal/config/loadGen.go" {
|
||||||
@@ -298,52 +268,7 @@ func ownedPath(name string) bool {
|
|||||||
}
|
}
|
||||||
return strings.HasPrefix(name, "internal/handler/") && strings.HasSuffix(name, "/handlersGen.go")
|
return strings.HasPrefix(name, "internal/handler/") && strings.HasSuffix(name, "/handlersGen.go")
|
||||||
}
|
}
|
||||||
func safePath(root, name string) error {
|
func safePath(root, name string) error { return genfs.SafePath(root, name) }
|
||||||
if filepath.IsAbs(name) || strings.Contains(name, "\\") || filepath.ToSlash(filepath.Clean(name)) != name || name == "." || strings.HasPrefix(name, "../") {
|
|
||||||
return fmt.Errorf("api: unsafe output path %q", name)
|
|
||||||
}
|
|
||||||
path := root
|
|
||||||
parts := strings.Split(name, "/")
|
|
||||||
for i, part := range parts {
|
|
||||||
path = filepath.Join(path, part)
|
|
||||||
info, err := os.Lstat(path)
|
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if info.Mode()&os.ModeSymlink != 0 {
|
|
||||||
return fmt.Errorf("api: refusing symlink %s", path)
|
|
||||||
}
|
|
||||||
if i < len(parts)-1 && !info.IsDir() {
|
|
||||||
return fmt.Errorf("api: expected directory %s", path)
|
|
||||||
}
|
|
||||||
if i == len(parts)-1 && !info.Mode().IsRegular() {
|
|
||||||
return fmt.Errorf("api: expected regular file %s", path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func atomicWrite(root, name string, data []byte, mode os.FileMode) error {
|
func atomicWrite(root, name string, data []byte, mode os.FileMode) error {
|
||||||
target := filepath.Join(root, filepath.FromSlash(name))
|
return genfs.AtomicWrite(root, name, data, mode)
|
||||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
f, err := os.CreateTemp(filepath.Dir(target), ".apiGen-*")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer os.Remove(f.Name())
|
|
||||||
if err = f.Chmod(mode); err == nil {
|
|
||||||
_, err = f.Write(data)
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
err = f.Sync()
|
|
||||||
}
|
|
||||||
err = errors.Join(err, f.Close())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.Rename(f.Name(), target)
|
|
||||||
}
|
}
|
||||||
|
|||||||
195
cmd/go-omni/i18n.go
Normal file
195
cmd/go-omni/i18n.go
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n/errori18n"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n/i18ntool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type i18nFlags []string
|
||||||
|
|
||||||
|
func (v *i18nFlags) String() string { return strings.Join(*v, ",") }
|
||||||
|
func (v *i18nFlags) Set(s string) error {
|
||||||
|
if s == "" {
|
||||||
|
return errors.New("empty value")
|
||||||
|
}
|
||||||
|
*v = append(*v, s)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runI18n(args []string) error { return runI18nTo(args, os.Stdout) }
|
||||||
|
func runI18nTo(args []string, w io.Writer) error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return errors.New("expected check, export, types or extract")
|
||||||
|
}
|
||||||
|
fs := flag.NewFlagSet("i18n "+args[0], flag.ContinueOnError)
|
||||||
|
fs.SetOutput(io.Discard)
|
||||||
|
var resources, namespaces, goFiles i18nFlags
|
||||||
|
configPath := fs.String("config", "", "i18n Config JSON file")
|
||||||
|
locale := fs.String("locale", "", "selected/source locale")
|
||||||
|
catalog := fs.String("catalog", "", "exported errors.Catalog JSON (extract only)")
|
||||||
|
fs.Var(&resources, "resource", "resource JSON file (repeatable)")
|
||||||
|
fs.Var(&namespaces, "namespace", "export namespace (repeatable)")
|
||||||
|
fs.Var(&goFiles, "go", "Go source file (repeatable)")
|
||||||
|
if err := fs.Parse(args[1:]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if fs.NArg() != 0 {
|
||||||
|
return errors.New("unexpected positional arguments")
|
||||||
|
}
|
||||||
|
encode := func(v any) error { e := json.NewEncoder(w); e.SetIndent("", " "); return e.Encode(v) }
|
||||||
|
if args[0] == "extract" && *catalog != "" {
|
||||||
|
if len(goFiles) > 0 || len(resources) > 0 || *configPath != "" || len(namespaces) != 1 || *locale == "" {
|
||||||
|
return errors.New("catalog extraction requires --catalog, --locale and exactly one --namespace")
|
||||||
|
}
|
||||||
|
raw, err := readI18nFile(*catalog)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var c apierrors.Catalog
|
||||||
|
if err := apierrors.DecodeCatalogJSON(raw, &c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
docs, err := errori18n.Source(errori18n.Bind(c, namespaces[0]), *locale)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, doc := range docs {
|
||||||
|
if doc.Namespace == namespaces[0] {
|
||||||
|
return encode(doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.New("catalog has no messages in the selected namespace")
|
||||||
|
}
|
||||||
|
if *catalog != "" {
|
||||||
|
return errors.New("--catalog is only supported by extract")
|
||||||
|
}
|
||||||
|
if args[0] == "extract" {
|
||||||
|
if len(goFiles) == 0 || len(resources) > 0 || len(namespaces) > 0 || *configPath != "" || *locale != "" {
|
||||||
|
return errors.New("Go extraction requires one or more --go files")
|
||||||
|
}
|
||||||
|
out := i18ntool.Extraction{References: []i18ntool.Reference{}, Findings: []i18ntool.Finding{}}
|
||||||
|
for _, path := range goFiles {
|
||||||
|
raw, err := readI18nFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result, err := i18ntool.ExtractGo(path, raw)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.References = append(out.References, result.References...)
|
||||||
|
out.Findings = append(out.Findings, result.Findings...)
|
||||||
|
}
|
||||||
|
return encode(out)
|
||||||
|
}
|
||||||
|
if args[0] != "check" && args[0] != "export" && args[0] != "types" {
|
||||||
|
return errors.New("expected check, export, types or extract")
|
||||||
|
}
|
||||||
|
if *configPath == "" || len(resources) == 0 {
|
||||||
|
return errors.New("--config and at least one --resource are required")
|
||||||
|
}
|
||||||
|
data, err := readI18nFile(*configPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var cfg i18n.Config
|
||||||
|
if err := i18n.DecodeJSON(data, &cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
docs := []i18n.Document{}
|
||||||
|
total := 0
|
||||||
|
for _, path := range resources {
|
||||||
|
raw, err := readI18nFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
total += len(raw)
|
||||||
|
if total > 4<<20 {
|
||||||
|
return errors.New("resources exceed 4 MiB")
|
||||||
|
}
|
||||||
|
var doc i18n.Document
|
||||||
|
if err := i18n.DecodeJSON(raw, &doc); err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
docs = append(docs, doc)
|
||||||
|
}
|
||||||
|
bundle, err := i18n.New(cfg, docs...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "export":
|
||||||
|
if len(goFiles) > 0 {
|
||||||
|
return errors.New("--go is only supported by check/extract")
|
||||||
|
}
|
||||||
|
return bundle.Export(w, *locale, namespaces...)
|
||||||
|
case "types":
|
||||||
|
if *locale != "" || len(goFiles) > 0 {
|
||||||
|
return errors.New("types does not accept --locale or --go")
|
||||||
|
}
|
||||||
|
return bundle.TypeScript(w, namespaces...)
|
||||||
|
case "check":
|
||||||
|
if *locale != "" || len(namespaces) > 0 {
|
||||||
|
return errors.New("check validates all configured locales and namespaces")
|
||||||
|
}
|
||||||
|
findings := bundle.Check()
|
||||||
|
for _, path := range goFiles {
|
||||||
|
raw, e := readI18nFile(path)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
ex, e := i18ntool.ExtractGo(path, raw)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
for _, r := range ex.References {
|
||||||
|
if _, ok := bundle.Schema(i18n.Ref{Namespace: r.Namespace, Key: r.Key}); !ok {
|
||||||
|
findings = append(findings, i18n.Diagnostic{Kind: "missing", Namespace: r.Namespace, Key: r.Key, Problem: fmt.Sprintf("referenced by %s:%d", r.File, r.Line)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, f := range ex.Findings {
|
||||||
|
findings = append(findings, i18n.Diagnostic{Kind: "dynamic", Problem: fmt.Sprintf("%s:%d: %s", f.File, f.Line, f.Problem)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := encode(findings); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(findings) > 0 {
|
||||||
|
return errors.New("internationalization checks failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func readI18nFile(path string) ([]byte, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
s, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !s.Mode().IsRegular() {
|
||||||
|
return nil, errors.New("expected a regular file")
|
||||||
|
}
|
||||||
|
b, err := io.ReadAll(io.LimitReader(f, (1<<20)+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(b) > 1<<20 {
|
||||||
|
return nil, errors.New("file exceeds 1 MiB")
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
80
cmd/go-omni/i18n_test.go
Normal file
80
cmd/go-omni/i18n_test.go
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestI18nCommandsAreReadOnlyAndCheckMissing(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
files := map[string]string{
|
||||||
|
"config.json": `{"sourceLocale":"en","defaultLocale":"en","locales":["en","fr"]}`,
|
||||||
|
"en.json": `{"version":1,"namespace":"ui","locale":"en","messages":{"hello":{"params":{"type":"string"},"value":{"text":"Hello {type}"}}}}`,
|
||||||
|
"fr.json": `{"version":1,"namespace":"ui","locale":"fr","messages":{"hello":{"value":{"text":"Bonjour {type}"}}}}`,
|
||||||
|
"use.go": "package demo\nimport words \"git.yuntuo.hk/k2safe/go-omni/i18n\"\nvar key=words.Ref{Namespace:\"ui\",Key:\"hello\"}\n",
|
||||||
|
}
|
||||||
|
for path, data := range files {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, path), []byte(data), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
base := []string{"--config", filepath.Join(dir, "config.json"), "--resource", filepath.Join(dir, "en.json"), "--resource", filepath.Join(dir, "fr.json")}
|
||||||
|
var out bytes.Buffer
|
||||||
|
args := append([]string{"check"}, base...)
|
||||||
|
args = append(args, "--go", filepath.Join(dir, "use.go"))
|
||||||
|
if err := runI18nTo(args, &out); err != nil || strings.TrimSpace(out.String()) != "[]" {
|
||||||
|
t.Fatal(err, out.String())
|
||||||
|
}
|
||||||
|
out.Reset()
|
||||||
|
args = append([]string{"export"}, base...)
|
||||||
|
args = append(args, "--locale", "fr", "--namespace", "ui")
|
||||||
|
if err := runI18nTo(args, &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var pack i18n.Pack
|
||||||
|
if err := json.Unmarshal(out.Bytes(), &pack); err != nil || pack.Locale != "fr" || len(pack.Documents) != 2 {
|
||||||
|
t.Fatal(err, pack)
|
||||||
|
}
|
||||||
|
out.Reset()
|
||||||
|
args = append([]string{"types"}, base...)
|
||||||
|
args = append(args, "--namespace", "ui")
|
||||||
|
if err := runI18nTo(args, &out); err != nil || !strings.Contains(out.String(), `"type": string`) {
|
||||||
|
t.Fatal(err, out.String())
|
||||||
|
}
|
||||||
|
out.Reset()
|
||||||
|
if err := runI18nTo([]string{"extract", "--go", filepath.Join(dir, "use.go")}, &out); err != nil || !strings.Contains(out.String(), `"key": "hello"`) {
|
||||||
|
t.Fatal(err, out.String())
|
||||||
|
}
|
||||||
|
for path, want := range files {
|
||||||
|
got, _ := os.ReadFile(filepath.Join(dir, path))
|
||||||
|
if string(got) != want {
|
||||||
|
t.Fatal("command mutated", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Reset()
|
||||||
|
args = append([]string{"check"}, base[:4]...)
|
||||||
|
if err := runI18nTo(args, &out); err == nil || !strings.Contains(out.String(), "direct translation missing") {
|
||||||
|
t.Fatal(err, out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestI18nCatalogExtractionPreservesLiteralBraces(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "errors.json")
|
||||||
|
if err := os.WriteFile(path, []byte(`{"definitions":{"FIXED":{"code":1,"message":"${NAME}"}}}`), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := runI18nTo([]string{"extract", "--catalog", path, "--namespace", "errors", "--locale", "en"}, &out); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var d i18n.Document
|
||||||
|
if err := i18n.DecodeJSON(out.Bytes(), &d); err != nil || !d.Messages["FIXED"].Value.Literal || d.Messages["FIXED"].Value.Text != "${NAME}" {
|
||||||
|
t.Fatal(err, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
133
cmd/go-omni/internal/genfs/files.go
Normal file
133
cmd/go-omni/internal/genfs/files.go
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
// Package genfs shares guarded filesystem operations between project generators.
|
||||||
|
package genfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SafePath(root, name string) error {
|
||||||
|
if filepath.IsAbs(name) || strings.Contains(name, "\\") || filepath.ToSlash(filepath.Clean(name)) != name || name == "." || strings.HasPrefix(name, "../") {
|
||||||
|
return fmt.Errorf("generation: unsafe output path %q", name)
|
||||||
|
}
|
||||||
|
path := root
|
||||||
|
parts := strings.Split(name, "/")
|
||||||
|
for i, part := range parts {
|
||||||
|
path = filepath.Join(path, part)
|
||||||
|
info, err := os.Lstat(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return fmt.Errorf("generation: refusing symlink %s", path)
|
||||||
|
}
|
||||||
|
if i < len(parts)-1 && !info.IsDir() {
|
||||||
|
return fmt.Errorf("generation: expected directory %s", path)
|
||||||
|
}
|
||||||
|
if i == len(parts)-1 && !info.Mode().IsRegular() {
|
||||||
|
return fmt.Errorf("generation: expected regular file %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func AtomicWrite(root, name string, data []byte, mode os.FileMode) error {
|
||||||
|
target := filepath.Join(root, filepath.FromSlash(name))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.CreateTemp(filepath.Dir(target), ".omniGen-*")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.Remove(f.Name())
|
||||||
|
if err = f.Chmod(mode); err == nil {
|
||||||
|
_, err = f.Write(data)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
err = f.Sync()
|
||||||
|
}
|
||||||
|
err = errors.Join(err, f.Close())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(f.Name(), target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change records the preflight snapshot used for conflict detection and rollback.
|
||||||
|
type Change struct {
|
||||||
|
Name string
|
||||||
|
Before, After []byte
|
||||||
|
Existed, Remove bool
|
||||||
|
Mode os.FileMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply replaces individual files atomically and rolls back ordinary write failures.
|
||||||
|
// Callers serialize generators with a project lock. This is not crash-atomic across files.
|
||||||
|
func Apply(root string, changes []Change) error {
|
||||||
|
var applied []Change
|
||||||
|
for _, c := range changes {
|
||||||
|
err := SafePath(root, c.Name)
|
||||||
|
if err == nil {
|
||||||
|
current, e := os.ReadFile(filepath.Join(root, filepath.FromSlash(c.Name)))
|
||||||
|
if c.Existed {
|
||||||
|
if e != nil || !bytes.Equal(current, c.Before) {
|
||||||
|
err = fmt.Errorf("file changed during generation: %s", c.Name)
|
||||||
|
}
|
||||||
|
} else if !errors.Is(e, os.ErrNotExist) {
|
||||||
|
err = fmt.Errorf("file appeared during generation: %s", c.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
if c.Remove {
|
||||||
|
err = os.Remove(filepath.Join(root, filepath.FromSlash(c.Name)))
|
||||||
|
} else {
|
||||||
|
err = AtomicWrite(root, c.Name, c.After, c.Mode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
for i := len(applied) - 1; i >= 0; i-- {
|
||||||
|
previous := applied[i]
|
||||||
|
if e := SafePath(root, previous.Name); e != nil {
|
||||||
|
err = errors.Join(err, e)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if previous.Existed {
|
||||||
|
err = errors.Join(err, AtomicWrite(root, previous.Name, previous.Before, previous.Mode))
|
||||||
|
} else {
|
||||||
|
err = errors.Join(err, os.Remove(filepath.Join(root, filepath.FromSlash(previous.Name))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
applied = append(applied, c)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical resolves existing parent symlinks even for an output not yet created.
|
||||||
|
func Canonical(path string) string {
|
||||||
|
path, _ = filepath.Abs(path)
|
||||||
|
candidate := path
|
||||||
|
var tail []string
|
||||||
|
for {
|
||||||
|
if resolved, err := filepath.EvalSymlinks(candidate); err == nil {
|
||||||
|
for i := len(tail) - 1; i >= 0; i-- {
|
||||||
|
resolved = filepath.Join(resolved, tail[i])
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
parent := filepath.Dir(candidate)
|
||||||
|
if parent == candidate {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
tail = append(tail, filepath.Base(candidate))
|
||||||
|
candidate = parent
|
||||||
|
}
|
||||||
|
}
|
||||||
47
cmd/go-omni/internal/genfs/files_test.go
Normal file
47
cmd/go-omni/internal/genfs/files_test.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package genfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConflictRollsBackEarlierWrites(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "one"), []byte("before"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "two"), []byte("concurrent"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err := Apply(root, []Change{{Name: "one", Before: []byte("before"), After: []byte("after"), Existed: true, Mode: 0600}, {Name: "new", After: []byte("created"), Mode: 0644}, {Name: "two", Before: []byte("stale"), After: []byte("after"), Existed: true, Mode: 0644}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("conflict accepted")
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(filepath.Join(root, "one"))
|
||||||
|
info, _ := os.Stat(filepath.Join(root, "one"))
|
||||||
|
if string(data) != "before" || info.Mode().Perm() != 0600 {
|
||||||
|
t.Fatal("rollback did not restore bytes/mode")
|
||||||
|
}
|
||||||
|
if _, err = os.Stat(filepath.Join(root, "new")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("new output survived rollback")
|
||||||
|
}
|
||||||
|
data, _ = os.ReadFile(filepath.Join(root, "two"))
|
||||||
|
if string(data) != "concurrent" {
|
||||||
|
t.Fatal("concurrent edit overwritten")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestUnsafePaths(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
for _, name := range []string{"../escape", "a/../../escape", "/outside", "a\\b", ".", "a/../b"} {
|
||||||
|
if SafePath(root, name) == nil {
|
||||||
|
t.Fatal(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := os.Symlink(t.TempDir(), filepath.Join(root, "link")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if SafePath(root, "link/file") == nil {
|
||||||
|
t.Fatal("followed symlink")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,16 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch os.Args[1] {
|
switch os.Args[1] {
|
||||||
|
case "project":
|
||||||
|
if err := runProject(os.Args[2:]); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "go-omni project:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
case "i18n":
|
||||||
|
if err := runI18n(os.Args[2:]); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "go-omni i18n:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
case "workspace":
|
case "workspace":
|
||||||
if err := runWorkspace(os.Args[2:]); err != nil {
|
if err := runWorkspace(os.Args[2:]); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "go-omni workspace:", err)
|
fmt.Fprintln(os.Stderr, "go-omni workspace:", err)
|
||||||
@@ -170,6 +180,8 @@ func usage() {
|
|||||||
fmt.Fprintln(os.Stderr, "GoOmni project tool")
|
fmt.Fprintln(os.Stderr, "GoOmni project tool")
|
||||||
fmt.Fprintln(os.Stderr, "")
|
fmt.Fprintln(os.Stderr, "")
|
||||||
fmt.Fprintln(os.Stderr, "Usage:")
|
fmt.Fprintln(os.Stderr, "Usage:")
|
||||||
|
fmt.Fprintln(os.Stderr, " go-omni project <new|generate|check> Generate a transport-independent Go program")
|
||||||
|
fmt.Fprintln(os.Stderr, " go-omni i18n <check|export|types|extract> Validate and export independent language resources")
|
||||||
fmt.Fprintln(os.Stderr, " go-omni migrate [--dir migrations] [--apply] [--dsn-env GO_OMNI_DB_DSN]")
|
fmt.Fprintln(os.Stderr, " go-omni migrate [--dir migrations] [--apply] [--dsn-env GO_OMNI_DB_DSN]")
|
||||||
fmt.Fprintln(os.Stderr, " go-omni new [--module <module>] <directory>")
|
fmt.Fprintln(os.Stderr, " go-omni new [--module <module>] <directory>")
|
||||||
fmt.Fprintln(os.Stderr, " go-omni new --api <file.api> [--module <module>] <directory>")
|
fmt.Fprintln(os.Stderr, " go-omni new --api <file.api> [--module <module>] <directory>")
|
||||||
@@ -211,6 +223,9 @@ func runAdd(args []string) error {
|
|||||||
return fmt.Errorf("unexpected argument %q", arg)
|
return fmt.Errorf("unexpected argument %q", arg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if _, err := os.Lstat(filepath.Join(directory, ".goOmni", "projectManifest.json")); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return errors.New("plain Go projects add packages as ordinary Go code; use project generate for infrastructure")
|
||||||
|
}
|
||||||
if _, err := os.Stat(filepath.Join(directory, ".goOmni", "apiManifest.json")); err == nil {
|
if _, err := os.Stat(filepath.Join(directory, ".goOmni", "apiManifest.json")); err == nil {
|
||||||
return errors.New("managed API projects add modules through .api imports and api generate")
|
return errors.New("managed API projects add modules through .api imports and api generate")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
||||||
for _, flag := range [][]string{{"--framework-version", "v0.21.0-preview"}, {"--framework-version=v0.21.0-preview"}} {
|
for _, flag := range [][]string{{"--framework-version", "v0.22.0-preview"}, {"--framework-version=v0.22.0-preview"}} {
|
||||||
project := filepath.Join(t.TempDir(), "billing")
|
project := filepath.Join(t.TempDir(), "billing")
|
||||||
args := append([]string{"--module", "example.com/billing"}, flag...)
|
args := append([]string{"--module", "example.com/billing"}, flag...)
|
||||||
if err := runNew(append(args, project)); err != nil {
|
if err := runNew(append(args, project)); err != nil {
|
||||||
@@ -18,7 +18,7 @@ func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(string(content), "go-omni v0.21.0-preview") || strings.Contains(string(content), "replace ") {
|
if !strings.Contains(string(content), "go-omni v0.22.0-preview") || strings.Contains(string(content), "replace ") {
|
||||||
t.Fatalf("CLI did not pin remote dependency: %s", content)
|
t.Fatalf("CLI did not pin remote dependency: %s", content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
102
cmd/go-omni/project.go
Normal file
102
cmd/go-omni/project.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/projectgen"
|
||||||
|
)
|
||||||
|
|
||||||
|
func runProject(args []string) error {
|
||||||
|
if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {
|
||||||
|
projectUsage()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
command := args[0]
|
||||||
|
if command != "new" && command != "generate" && command != "check" {
|
||||||
|
return fmt.Errorf("unknown project command %q", command)
|
||||||
|
}
|
||||||
|
o := projectgen.Options{Check: command == "check"}
|
||||||
|
for i := 1; i < len(args); i++ {
|
||||||
|
arg := args[i]
|
||||||
|
if arg == "--help" || arg == "-h" {
|
||||||
|
projectUsage()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if arg == "--check" || arg == "--dry-run" {
|
||||||
|
if command != "generate" {
|
||||||
|
return fmt.Errorf("%s belongs to project generate", arg)
|
||||||
|
}
|
||||||
|
if arg == "--check" {
|
||||||
|
o.Check = true
|
||||||
|
} else {
|
||||||
|
o.DryRun = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(arg, "-") {
|
||||||
|
if command != "new" || o.Dir != "" {
|
||||||
|
return fmt.Errorf("unexpected argument %q", arg)
|
||||||
|
}
|
||||||
|
o.Dir = arg
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, value, inline := strings.Cut(arg, "=")
|
||||||
|
switch name {
|
||||||
|
case "--dir", "--module", "--framework-dir", "--framework-version":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown project option %q", name)
|
||||||
|
}
|
||||||
|
if command != "new" && name != "--dir" {
|
||||||
|
return fmt.Errorf("%s belongs to project new", name)
|
||||||
|
}
|
||||||
|
if !inline {
|
||||||
|
if i+1 >= len(args) || strings.HasPrefix(args[i+1], "-") {
|
||||||
|
return fmt.Errorf("%s requires a value", name)
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
value = args[i]
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return fmt.Errorf("%s requires a value", name)
|
||||||
|
}
|
||||||
|
switch name {
|
||||||
|
case "--dir":
|
||||||
|
if o.Dir != "" {
|
||||||
|
return errors.New("project directory specified more than once")
|
||||||
|
}
|
||||||
|
o.Dir = value
|
||||||
|
case "--module":
|
||||||
|
o.Module = value
|
||||||
|
case "--framework-dir":
|
||||||
|
o.FrameworkDir = value
|
||||||
|
case "--framework-version":
|
||||||
|
o.FrameworkVersion = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out projectgen.Output
|
||||||
|
var err error
|
||||||
|
if command == "new" {
|
||||||
|
out, err = projectgen.New(o)
|
||||||
|
} else {
|
||||||
|
out, err = projectgen.Generate(o)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if o.DryRun {
|
||||||
|
for _, change := range out.Changes {
|
||||||
|
fmt.Printf("%-7s %s\n", change.Action, change.Path)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fmt.Printf("project %s: %d file changes\n", command, len(out.Changes))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func projectUsage() {
|
||||||
|
fmt.Println("go-omni project new <directory> [--module <module>] [--framework-version <version>|--framework-dir <directory>]")
|
||||||
|
fmt.Println("go-omni project generate [--dir <project>] [--check|--dry-run]")
|
||||||
|
fmt.Println("go-omni project check [--dir <project>]")
|
||||||
|
fmt.Println("Creates a plain Go program with config, logging, dependency lifecycle and an editable Run entry.")
|
||||||
|
}
|
||||||
27
cmd/go-omni/project_test.go
Normal file
27
cmd/go-omni/project_test.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProjectCLI(t *testing.T) {
|
||||||
|
root := filepath.Join(t.TempDir(), "program")
|
||||||
|
for _, args := range [][]string{{"new", root, "--module", "example.com/program"}, {"generate", "--dir", root, "--dry-run"}, {"generate", "--dir", root}, {"check", "--dir", root}, {"generate", "--dir", root, "--check"}} {
|
||||||
|
if err := runProject(args); err != nil {
|
||||||
|
t.Fatal(args, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := runAdd([]string{"item", "--dir", root}); err == nil {
|
||||||
|
t.Fatal("add adopted plain project")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(root, "internal/item")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("business module generated")
|
||||||
|
}
|
||||||
|
for _, args := range [][]string{{"new"}, {"new", "a", "b"}, {"new", "--api", "x"}, {"check", "--dry-run"}, {"generate", "--check", "--dry-run"}, {"generate", "--module", "x"}, {"new", "--module"}, {"new", "--dir="}, {"new", "--dir", "x", "y"}, {"unknown"}} {
|
||||||
|
if err := runProject(args); err == nil {
|
||||||
|
t.Fatal("accepted", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
190
cmd/go-omni/projectgen/consumer_test.go
Normal file
190
cmd/go-omni/projectgen/consumer_test.go
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
package projectgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGeneratedProgramConsumer(t *testing.T) {
|
||||||
|
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
|
||||||
|
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
|
||||||
|
}
|
||||||
|
root := filepath.Join(t.TempDir(), "program")
|
||||||
|
cli := os.Getenv("GO_OMNI_RELEASE_CLI")
|
||||||
|
if cli != "" {
|
||||||
|
runCommand(t, "", cli, "project", "new", root, "--module", "example.com/plain")
|
||||||
|
} else {
|
||||||
|
framework, err := filepath.Abs("../../..")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = New(Options{Dir: root, Module: "example.com/plain", FrameworkDir: framework}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Only project-owned files are customized. All entry/loading/lifecycle code
|
||||||
|
// remains exactly what the local or downloaded CLI generated.
|
||||||
|
svc := get(t, root, "internal/svc/serviceContext.go")
|
||||||
|
svc = strings.Replace(svc, "return nil", "return Initialize(ctx, c)", 1)
|
||||||
|
svc += "\nvar Initialize = func(context.Context, *ServiceContext) error { return nil }\n"
|
||||||
|
put(t, root, "internal/svc/serviceContext.go", svc)
|
||||||
|
put(t, root, "internal/run/run.go", `package run
|
||||||
|
import("context";"errors";"os";"example.com/plain/internal/svc")
|
||||||
|
var Execute=func(ctx context.Context,deps *svc.ServiceContext)error{
|
||||||
|
switch os.Getenv("GO_OMNI_PROGRAM_TEST_MODE") {case "error":return errors.New("process failure marker");case "panic":panic("process panic marker");case "unresponsive":select{}}
|
||||||
|
<-ctx.Done();return nil
|
||||||
|
}
|
||||||
|
func Run(ctx context.Context,deps *svc.ServiceContext)error{return Execute(ctx,deps)}
|
||||||
|
`)
|
||||||
|
put(t, root, "consumer_test.go", get(t, "testdata", "consumer_test.go"))
|
||||||
|
runCommand(t, root, "go", "mod", "tidy")
|
||||||
|
runCommand(t, root, "go", "test", "-race", "-count=1", "./...")
|
||||||
|
runCommand(t, root, "go", "vet", "./...")
|
||||||
|
binary := filepath.Join(root, "program")
|
||||||
|
runCommand(t, root, "go", "build", "-race", "-o", binary, ".")
|
||||||
|
before := snapshot(t, root)
|
||||||
|
for _, mode := range []string{"generate", "check", "dry-run"} {
|
||||||
|
if cli != "" {
|
||||||
|
args := []string{"project", mode, "--dir", root}
|
||||||
|
if mode == "dry-run" {
|
||||||
|
args = []string{"project", "generate", "--dir", root, "--dry-run"}
|
||||||
|
}
|
||||||
|
runCommand(t, root, cli, args...)
|
||||||
|
} else {
|
||||||
|
if _, err := Generate(Options{Dir: root, Check: mode == "check", DryRun: mode == "dry-run"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
after := snapshot(t, root)
|
||||||
|
for name, data := range before {
|
||||||
|
if after[name] != data {
|
||||||
|
t.Fatal("consumer changed during regeneration", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Validation must not open even the configured log file.
|
||||||
|
logfile := filepath.Join(root, "check-must-not-open.log")
|
||||||
|
cfg := filepath.Join(root, "check.json")
|
||||||
|
raw, _ := json.Marshal(map[string]any{"log": map[string]any{"file": logfile}})
|
||||||
|
put(t, root, "check.json", string(raw))
|
||||||
|
runCommand(t, root, binary, "-config", cfg, "-check-config")
|
||||||
|
if _, err := os.Stat(logfile); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("check-config opened logger", err)
|
||||||
|
}
|
||||||
|
put(t, root, "check.json", `{"log":{"level":"invalid"}}`)
|
||||||
|
cmd := exec.Command(binary, "-config", cfg, "-check-config")
|
||||||
|
cmd.Dir = root
|
||||||
|
if data, err := cmd.CombinedOutput(); err == nil || !strings.Contains(string(data), "log.level") {
|
||||||
|
t.Fatal("invalid config accepted", string(data), err)
|
||||||
|
}
|
||||||
|
for _, sig := range []os.Signal{os.Interrupt, syscall.SIGTERM} {
|
||||||
|
testProcess(t, root, binary, sig, "")
|
||||||
|
}
|
||||||
|
for _, mode := range []string{"error", "panic", "unresponsive"} {
|
||||||
|
var sig os.Signal
|
||||||
|
if mode == "unresponsive" {
|
||||||
|
sig = syscall.SIGTERM
|
||||||
|
}
|
||||||
|
testProcess(t, root, binary, sig, mode)
|
||||||
|
}
|
||||||
|
if cli != "" {
|
||||||
|
data := runCommand(t, root, "go", "list", "-m", "-json", frameworkModule)
|
||||||
|
var dep struct {
|
||||||
|
Version, Sum string
|
||||||
|
Replace any
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(data), &dep); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if dep.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dep.Replace != nil || dep.Sum == "" {
|
||||||
|
t.Fatal("not a fixed remote consumer", dep)
|
||||||
|
}
|
||||||
|
t.Logf("remote framework %s checksum %s; no local replace", dep.Version, dep.Sum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func runCommand(t *testing.T, dir, name string, args ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
if dir != "" {
|
||||||
|
cmd.Dir = dir
|
||||||
|
}
|
||||||
|
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s %v: %v\n%s", name, args, err, output)
|
||||||
|
}
|
||||||
|
t.Logf("%s %v\n%s", name, args, output)
|
||||||
|
return string(output)
|
||||||
|
}
|
||||||
|
func testProcess(t *testing.T, root, binary string, sig os.Signal, mode string) {
|
||||||
|
t.Helper()
|
||||||
|
logfile, err := os.CreateTemp(root, "signal-*.log")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer logfile.Close()
|
||||||
|
cfg := filepath.Join(root, "process.json")
|
||||||
|
put(t, root, "process.json", `{"lifecycle":{"stopTimeout":"100ms","drainTimeout":"30ms","cancelTimeout":"100ms"}}`)
|
||||||
|
cmd := exec.Command(binary, "-config", cfg)
|
||||||
|
cmd.Env = append(os.Environ(), "GO_OMNI_PROGRAM_TEST_MODE="+mode)
|
||||||
|
cmd.Dir = root
|
||||||
|
cmd.Stdout = logfile
|
||||||
|
cmd.Stderr = logfile
|
||||||
|
if err = cmd.Start(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { done <- cmd.Wait() }()
|
||||||
|
defer func() { _ = cmd.Process.Kill() }()
|
||||||
|
deadline := time.Now().Add(10 * time.Second)
|
||||||
|
for {
|
||||||
|
data, _ := os.ReadFile(logfile.Name())
|
||||||
|
if strings.Contains(string(data), "program.starting") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("program did not start", string(data))
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if path, err := exec.LookPath("lsof"); err == nil {
|
||||||
|
output, _ := exec.Command(path, "-nP", "-a", "-p", strconv.Itoa(cmd.Process.Pid), "-i").CombinedOutput()
|
||||||
|
if strings.Contains(string(output), "TCP") || strings.Contains(string(output), "UDP") {
|
||||||
|
t.Fatal("starter opened network socket", string(output))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if sig != nil {
|
||||||
|
if err = cmd.Process.Signal(sig); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case err = <-done:
|
||||||
|
data, _ := os.ReadFile(logfile.Name())
|
||||||
|
if mode == "" && err != nil {
|
||||||
|
t.Fatal("signal exit failed", err, string(data))
|
||||||
|
}
|
||||||
|
if mode != "" {
|
||||||
|
exit, ok := err.(*exec.ExitError)
|
||||||
|
if !ok || exit.ExitCode() != 1 {
|
||||||
|
t.Fatal("abnormal process exit status", err, string(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mode == "unresponsive" && !strings.Contains(string(data), "shared resources retained") {
|
||||||
|
t.Fatal("timeout did not retain resources", string(data))
|
||||||
|
}
|
||||||
|
t.Logf("process mode=%q signal=%v passed", mode, sig)
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("signal exit timed out")
|
||||||
|
}
|
||||||
|
}
|
||||||
359
cmd/go-omni/projectgen/generate.go
Normal file
359
cmd/go-omni/projectgen/generate.go
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
// Package projectgen generates transport-independent Go programs.
|
||||||
|
package projectgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"embed"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"go/format"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime/debug"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/internal/genfs"
|
||||||
|
"golang.org/x/mod/modfile"
|
||||||
|
"golang.org/x/mod/module"
|
||||||
|
"golang.org/x/mod/semver"
|
||||||
|
)
|
||||||
|
|
||||||
|
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
const currentVersion = "v0.30.0-preview"
|
||||||
|
const minimumVersion = "v0.28.0-preview"
|
||||||
|
const manifestPath = ".goOmni/projectManifest.json"
|
||||||
|
const lockPath = ".goOmni/project.lock"
|
||||||
|
const header = "// Code generated by go-omni project. DO NOT EDIT.\n"
|
||||||
|
|
||||||
|
//go:embed templates/*
|
||||||
|
var templates embed.FS
|
||||||
|
|
||||||
|
var managed = map[string]bool{"main.go": true, "internal/config/loadGen.go": true, "internal/svc/runtimeGen.go": true}
|
||||||
|
var seeds = []string{"go.mod", ".gitignore", "README.md", "etc/config.yaml", "internal/config/config.go", "internal/config/loader.go", "internal/svc/serviceContext.go", "internal/run/run.go"}
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
Dir, Module, FrameworkDir, FrameworkVersion string
|
||||||
|
Check, DryRun bool
|
||||||
|
}
|
||||||
|
type FileChange struct{ Path, Action string }
|
||||||
|
type Output struct{ Changes []FileChange }
|
||||||
|
type manifest struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
TemplateVersion string `json:"templateVersion"`
|
||||||
|
Module string `json:"module"`
|
||||||
|
Generated map[string]string `json:"generated"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// New only accepts an empty directory. FrameworkDir is an explicit development opt-in.
|
||||||
|
func New(o Options) (Output, error) {
|
||||||
|
if o.Dir == "" || o.Check || o.DryRun {
|
||||||
|
return Output{}, errors.New("project new requires a directory and does not accept --check/--dry-run")
|
||||||
|
}
|
||||||
|
if entries, err := os.ReadDir(o.Dir); err == nil && len(entries) > 0 {
|
||||||
|
return Output{}, errors.New("project new requires an empty directory")
|
||||||
|
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return Output{}, err
|
||||||
|
}
|
||||||
|
if o.Module == "" {
|
||||||
|
o.Module = filepath.Base(genfs.Canonical(o.Dir))
|
||||||
|
}
|
||||||
|
if err := module.CheckImportPath(o.Module); err != nil {
|
||||||
|
return Output{}, err
|
||||||
|
}
|
||||||
|
if _, err := moduleFile(o, genfs.Canonical(o.Dir), true); err != nil {
|
||||||
|
return Output{}, err
|
||||||
|
}
|
||||||
|
return generate(o, true)
|
||||||
|
}
|
||||||
|
func Generate(o Options) (Output, error) { return generate(o, false) }
|
||||||
|
|
||||||
|
func generate(o Options, create bool) (out Output, err error) {
|
||||||
|
if o.Check && o.DryRun {
|
||||||
|
return out, errors.New("--check and --dry-run cannot be combined")
|
||||||
|
}
|
||||||
|
if o.Dir == "" {
|
||||||
|
o.Dir = "."
|
||||||
|
}
|
||||||
|
root, err := filepath.Abs(o.Dir)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if info, e := os.Lstat(root); e == nil {
|
||||||
|
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return out, errors.New("project directory must be a real directory")
|
||||||
|
}
|
||||||
|
} else if !create || !errors.Is(e, os.ErrNotExist) {
|
||||||
|
return out, e
|
||||||
|
}
|
||||||
|
root = genfs.Canonical(root)
|
||||||
|
for _, name := range append(append([]string{}, seeds...), manifestPath, lockPath) {
|
||||||
|
if err = genfs.SafePath(root, name); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name := range managed {
|
||||||
|
if err = genfs.SafePath(root, name); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, other := range []string{".goOmni/apiManifest.json", "api/service.api"} {
|
||||||
|
if _, e := os.Lstat(filepath.Join(root, other)); !errors.Is(e, os.ErrNotExist) {
|
||||||
|
return out, errors.New("project generator requires its own project; existing API layout is not adopted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !o.Check && !o.DryRun {
|
||||||
|
if err = os.MkdirAll(filepath.Join(root, ".goOmni"), 0755); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
lock, e := os.OpenFile(filepath.Join(root, lockPath), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
||||||
|
if e != nil {
|
||||||
|
return out, fmt.Errorf("acquire project.lock (check for a running generator before removing a stale lock): %w", e)
|
||||||
|
}
|
||||||
|
_, err = fmt.Fprintf(lock, "pid=%d\n", os.Getpid())
|
||||||
|
err = errors.Join(err, lock.Close())
|
||||||
|
defer os.Remove(filepath.Join(root, lockPath))
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var old manifest
|
||||||
|
oldData, readErr := os.ReadFile(filepath.Join(root, manifestPath))
|
||||||
|
if create {
|
||||||
|
if !errors.Is(readErr, os.ErrNotExist) {
|
||||||
|
return out, errors.New("project manifest appeared during creation")
|
||||||
|
}
|
||||||
|
// Recheck after taking the lock; never adopt files created concurrently.
|
||||||
|
entries, e := os.ReadDir(root)
|
||||||
|
if e != nil {
|
||||||
|
return out, e
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.Name() != ".goOmni" {
|
||||||
|
return out, errors.New("project directory changed during creation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries, e = os.ReadDir(filepath.Join(root, ".goOmni"))
|
||||||
|
if e != nil {
|
||||||
|
return out, e
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.Name() != "project.lock" {
|
||||||
|
return out, errors.New("project metadata appeared during creation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if readErr != nil {
|
||||||
|
return out, fmt.Errorf("read project manifest (create with project new first): %w", readErr)
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(oldData))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err = decoder.Decode(&old); err != nil {
|
||||||
|
return out, fmt.Errorf("invalid project manifest: %w", err)
|
||||||
|
}
|
||||||
|
var extra any
|
||||||
|
if decoder.Decode(&extra) != io.EOF {
|
||||||
|
return out, errors.New("invalid trailing project manifest data")
|
||||||
|
}
|
||||||
|
if old.Version != 1 || old.Kind != "project" || old.Module == "" || len(old.Generated) != len(managed) || !semver.IsValid(old.TemplateVersion) || semver.Compare(old.TemplateVersion, minimumVersion) > 0 {
|
||||||
|
return out, errors.New("unsupported project manifest; use a compatible CLI")
|
||||||
|
}
|
||||||
|
if o.Module != "" && o.Module != old.Module {
|
||||||
|
return out, errors.New("module differs from project manifest")
|
||||||
|
}
|
||||||
|
o.Module = old.Module
|
||||||
|
for name := range managed {
|
||||||
|
if len(old.Generated[name]) != 64 {
|
||||||
|
return out, fmt.Errorf("invalid manifest ownership for %s", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if o.Module == "" {
|
||||||
|
o.Module = filepath.Base(root)
|
||||||
|
}
|
||||||
|
if err = module.CheckImportPath(o.Module); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
moduleData, err := moduleFile(o, root, create)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
rendered, err := render(o.Module, moduleData)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
next := manifest{Version: 1, Kind: "project", TemplateVersion: minimumVersion, Module: o.Module, Generated: map[string]string{}}
|
||||||
|
var changes []genfs.Change
|
||||||
|
names := make([]string, 0, len(rendered))
|
||||||
|
for name := range rendered {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
for _, name := range names {
|
||||||
|
target := filepath.Join(root, filepath.FromSlash(name))
|
||||||
|
before, e := os.ReadFile(target)
|
||||||
|
exists := e == nil
|
||||||
|
if e != nil && !errors.Is(e, os.ErrNotExist) {
|
||||||
|
return out, e
|
||||||
|
}
|
||||||
|
generated := managed[name]
|
||||||
|
if !generated && !create {
|
||||||
|
if !exists {
|
||||||
|
return out, fmt.Errorf("project-owned file %s is missing; restore it explicitly", name)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if generated {
|
||||||
|
next.Generated[name] = digest(rendered[name])
|
||||||
|
if exists && (create || digest(before) != old.Generated[name]) {
|
||||||
|
return out, fmt.Errorf("generated file %s was edited; move custom code to project-owned files", name)
|
||||||
|
}
|
||||||
|
} else if exists {
|
||||||
|
return out, fmt.Errorf("refusing to adopt %s", name)
|
||||||
|
}
|
||||||
|
if exists && bytes.Equal(before, rendered[name]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mode := os.FileMode(0644)
|
||||||
|
if exists {
|
||||||
|
info, e := os.Stat(target)
|
||||||
|
if e != nil {
|
||||||
|
return out, e
|
||||||
|
}
|
||||||
|
mode = info.Mode().Perm()
|
||||||
|
}
|
||||||
|
changes = append(changes, genfs.Change{Name: name, Before: before, After: rendered[name], Existed: exists, Mode: mode})
|
||||||
|
}
|
||||||
|
data, err := json.MarshalIndent(next, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
data = append(data, '\n')
|
||||||
|
if !bytes.Equal(oldData, data) {
|
||||||
|
changes = append(changes, genfs.Change{Name: manifestPath, Before: oldData, After: data, Existed: oldData != nil, Mode: 0644})
|
||||||
|
}
|
||||||
|
for _, c := range changes {
|
||||||
|
action := "create"
|
||||||
|
if c.Existed {
|
||||||
|
action = "update"
|
||||||
|
}
|
||||||
|
out.Changes = append(out.Changes, FileChange{c.Name, action})
|
||||||
|
}
|
||||||
|
sort.Slice(out.Changes, func(i, j int) bool { return out.Changes[i].Path < out.Changes[j].Path })
|
||||||
|
if o.DryRun {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
if o.Check {
|
||||||
|
if len(changes) > 0 {
|
||||||
|
return out, fmt.Errorf("project generation drift: %v", out.Changes)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
return out, genfs.Apply(root, changes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func moduleFile(o Options, root string, create bool) ([]byte, error) {
|
||||||
|
if !create {
|
||||||
|
data, err := os.ReadFile(filepath.Join(root, "go.mod"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mf, err := modfile.Parse("go.mod", data, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if mf.Module == nil || mf.Module.Mod.Path != o.Module {
|
||||||
|
return nil, errors.New("go.mod module differs from project manifest")
|
||||||
|
}
|
||||||
|
if o.FrameworkDir != "" || o.FrameworkVersion != "" {
|
||||||
|
return nil, errors.New("framework options belong to project new; update go.mod explicitly")
|
||||||
|
}
|
||||||
|
for _, r := range mf.Require {
|
||||||
|
if r.Mod.Path == frameworkModule && semver.Compare(r.Mod.Version, minimumVersion) >= 0 {
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("go.mod must require go-omni %s or newer", minimumVersion)
|
||||||
|
}
|
||||||
|
version := o.FrameworkVersion
|
||||||
|
if version == "" {
|
||||||
|
version = currentVersion
|
||||||
|
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Path == frameworkModule && info.Main.Sum != "" && semver.IsValid(info.Main.Version) {
|
||||||
|
version = info.Main.Version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !semver.IsValid(version) || semver.Major(version) != "v0" && semver.Major(version) != "v1" || semver.Compare(version, minimumVersion) < 0 {
|
||||||
|
return nil, fmt.Errorf("framework version must be %s or newer", minimumVersion)
|
||||||
|
}
|
||||||
|
mf := new(modfile.File)
|
||||||
|
if err := mf.AddModuleStmt(o.Module); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := mf.AddGoStmt("1.22.0"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := mf.AddRequire(frameworkModule, version); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if o.FrameworkDir != "" {
|
||||||
|
local, err := filepath.Abs(o.FrameworkDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(local, "go.mod"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
source, err := modfile.Parse("go.mod", data, nil)
|
||||||
|
if err != nil || source.Module == nil || source.Module.Mod.Path != frameworkModule {
|
||||||
|
return nil, errors.New("--framework-dir must contain go-omni")
|
||||||
|
}
|
||||||
|
local = genfs.Canonical(local)
|
||||||
|
rel, err := filepath.Rel(root, local)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
if !strings.HasPrefix(rel, ".") {
|
||||||
|
rel = "./" + rel
|
||||||
|
}
|
||||||
|
if err = mf.AddReplace(frameworkModule, "", rel, ""); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mf.Format()
|
||||||
|
}
|
||||||
|
func render(moduleName string, moduleData []byte) (map[string][]byte, error) {
|
||||||
|
files := map[string][]byte{"go.mod": moduleData}
|
||||||
|
paths := append([]string{}, seeds[1:]...)
|
||||||
|
for name := range managed {
|
||||||
|
paths = append(paths, name)
|
||||||
|
}
|
||||||
|
for _, name := range paths {
|
||||||
|
raw, err := templates.ReadFile("templates/" + strings.ReplaceAll(name, "/", "_") + ".tmpl")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := strings.ReplaceAll(string(raw), "\"PROJECT_MODULE\"", strconv.Quote(moduleName))
|
||||||
|
s = strings.ReplaceAll(s, "PROJECT_MODULE/", moduleName+"/")
|
||||||
|
if managed[name] {
|
||||||
|
s = header + s
|
||||||
|
}
|
||||||
|
data := []byte(s)
|
||||||
|
if strings.HasSuffix(name, ".go") {
|
||||||
|
data, err = format.Source(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("format %s: %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files[name] = data
|
||||||
|
}
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
func digest(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }
|
||||||
215
cmd/go-omni/projectgen/generate_test.go
Normal file
215
cmd/go-omni/projectgen/generate_test.go
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
package projectgen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func put(t *testing.T, root, name, content string) {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(root, name)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, []byte(content), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func get(t *testing.T, root, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
data, err := os.ReadFile(filepath.Join(root, name))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
func snapshot(t *testing.T, root string) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
files := map[string]string{}
|
||||||
|
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !d.IsDir() {
|
||||||
|
data, e := os.ReadFile(path)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(root, path)
|
||||||
|
files[rel] = string(data)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
func fresh(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
root := filepath.Join(t.TempDir(), "program")
|
||||||
|
if _, err := New(Options{Dir: root, Module: "example.com/program"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOwnershipAndDrift(t *testing.T) {
|
||||||
|
root := fresh(t)
|
||||||
|
files := snapshot(t, root)
|
||||||
|
for name, source := range files {
|
||||||
|
if strings.HasSuffix(name, ".api") || strings.Contains(name, "handler") || strings.Contains(name, "openapi") {
|
||||||
|
t.Fatal("unexpected API output", name)
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(name, ".go") {
|
||||||
|
for _, forbidden := range []string{"net/http", "httpx", "middleware", "ListenAndServe", "database/", "queue/", "task.New"} {
|
||||||
|
if strings.Contains(source, forbidden) {
|
||||||
|
t.Fatal(name, forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(files["go.mod"], "replace") || !strings.Contains(files["go.mod"], currentVersion) {
|
||||||
|
t.Fatal("default dependency must be remote and fixed")
|
||||||
|
}
|
||||||
|
for _, name := range seeds {
|
||||||
|
if name != "go.mod" {
|
||||||
|
put(t, root, name, get(t, root, name)+"\n// project edit\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
before := snapshot(t, root)
|
||||||
|
for _, o := range []Options{{Dir: root}, {Dir: root, Check: true}, {Dir: root, DryRun: true}} {
|
||||||
|
out, err := Generate(o)
|
||||||
|
if err != nil || len(out.Changes) != 0 {
|
||||||
|
t.Fatal(out, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(before, snapshot(t, root)) {
|
||||||
|
t.Fatal("project files changed")
|
||||||
|
}
|
||||||
|
original := get(t, root, "main.go")
|
||||||
|
put(t, root, "main.go", original+"\n// manual edit\n")
|
||||||
|
for _, o := range []Options{{Dir: root}, {Dir: root, Check: true}, {Dir: root, DryRun: true}} {
|
||||||
|
if _, err := Generate(o); err == nil || !strings.Contains(err.Error(), "was edited") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
put(t, root, "main.go", original)
|
||||||
|
if err := os.Remove(filepath.Join(root, "main.go")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before = snapshot(t, root)
|
||||||
|
if _, err := Generate(Options{Dir: root, Check: true}); err == nil {
|
||||||
|
t.Fatal("missing output accepted")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(before, snapshot(t, root)) {
|
||||||
|
t.Fatal("check wrote files")
|
||||||
|
}
|
||||||
|
out, err := Generate(Options{Dir: root, DryRun: true})
|
||||||
|
if err != nil || len(out.Changes) != 1 {
|
||||||
|
t.Fatal(out, err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(before, snapshot(t, root)) {
|
||||||
|
t.Fatal("dry-run wrote files")
|
||||||
|
}
|
||||||
|
if _, err = Generate(Options{Dir: root}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if get(t, root, "main.go") != original {
|
||||||
|
t.Fatal("missing output not restored")
|
||||||
|
}
|
||||||
|
if err = os.Remove(filepath.Join(root, "internal/run/run.go")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = Generate(Options{Dir: root}); err == nil || !strings.Contains(err.Error(), "project-owned") {
|
||||||
|
t.Fatal("silently recreated handwritten file", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordedTemplateUpgrade(t *testing.T) {
|
||||||
|
root := fresh(t)
|
||||||
|
var m manifest
|
||||||
|
if err := json.Unmarshal([]byte(get(t, root, manifestPath)), &m); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
original := get(t, root, "main.go")
|
||||||
|
older := original + "\n// previous generator baseline\n"
|
||||||
|
put(t, root, "main.go", older)
|
||||||
|
m.Generated["main.go"] = digest([]byte(older))
|
||||||
|
data, _ := json.Marshal(m)
|
||||||
|
put(t, root, manifestPath, string(data))
|
||||||
|
before := snapshot(t, root)
|
||||||
|
if _, err := Generate(Options{Dir: root, Check: true}); err == nil {
|
||||||
|
t.Fatal("stale baseline accepted")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(before, snapshot(t, root)) {
|
||||||
|
t.Fatal("check mutated baseline")
|
||||||
|
}
|
||||||
|
if _, err := Generate(Options{Dir: root}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if get(t, root, "main.go") != original {
|
||||||
|
t.Fatal("template was not updated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestProjectGuards(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, needle string
|
||||||
|
mutate func(*testing.T, string)
|
||||||
|
}{
|
||||||
|
{"module", "module differs", func(t *testing.T, r string) {
|
||||||
|
put(t, r, "go.mod", strings.Replace(get(t, r, "go.mod"), "example.com/program", "example.com/other", 1))
|
||||||
|
}},
|
||||||
|
{"oldFramework", "must require", func(t *testing.T, r string) {
|
||||||
|
put(t, r, "go.mod", strings.ReplaceAll(get(t, r, "go.mod"), currentVersion, "v0.22.0-preview"))
|
||||||
|
}},
|
||||||
|
{"manifest", "unsupported", func(t *testing.T, r string) { put(t, r, manifestPath, `{"version":1,"kind":"api"}`) }},
|
||||||
|
{"lock", "project.lock", func(t *testing.T, r string) { put(t, r, lockPath, "occupied") }},
|
||||||
|
{"otherGenerator", "existing API", func(t *testing.T, r string) { put(t, r, ".goOmni/apiManifest.json", "{}") }},
|
||||||
|
{"symlink", "symlink", func(t *testing.T, r string) {
|
||||||
|
if e := os.Remove(filepath.Join(r, "main.go")); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
target := filepath.Join(t.TempDir(), "outside.go")
|
||||||
|
put(t, filepath.Dir(target), "outside.go", "outside")
|
||||||
|
if e := os.Symlink(target, filepath.Join(r, "main.go")); e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
root := fresh(t)
|
||||||
|
tc.mutate(t, root)
|
||||||
|
before := snapshot(t, root)
|
||||||
|
if _, err := Generate(Options{Dir: root}); err == nil || !strings.Contains(err.Error(), tc.needle) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(before, snapshot(t, root)) {
|
||||||
|
t.Fatal("failed generation changed files")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
root := fresh(t)
|
||||||
|
before := snapshot(t, root)
|
||||||
|
if _, err := New(Options{Dir: root}); err == nil {
|
||||||
|
t.Fatal("new overwrote project")
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(before, snapshot(t, root)) {
|
||||||
|
t.Fatal("new mutated project")
|
||||||
|
}
|
||||||
|
if _, err := Generate(Options{Dir: t.TempDir(), Check: true}); err == nil {
|
||||||
|
t.Fatal("adopted unmanaged directory")
|
||||||
|
}
|
||||||
|
for _, version := range []string{"v0.27.0-preview", "latest", "v2.0.0", "v0.28.0-preview\nreplace bad"} {
|
||||||
|
if _, err := New(Options{Dir: filepath.Join(t.TempDir(), "app"), FrameworkVersion: version}); err == nil {
|
||||||
|
t.Fatal("invalid version", version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := New(Options{Dir: filepath.Join(t.TempDir(), "app"), Module: "example.com/a\nreplace bad"}); err == nil {
|
||||||
|
t.Fatal("invalid module")
|
||||||
|
}
|
||||||
|
}
|
||||||
6
cmd/go-omni/projectgen/templates/.gitignore.tmpl
Normal file
6
cmd/go-omni/projectgen/templates/.gitignore.tmpl
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/program
|
||||||
|
/var/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
/.goOmni/project.lock
|
||||||
55
cmd/go-omni/projectgen/templates/README.md.tmpl
Normal file
55
cmd/go-omni/projectgen/templates/README.md.tmpl
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
# Go program
|
||||||
|
|
||||||
|
This project has no HTTP listener, routes, tasks or external dependencies configured.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go mod tidy
|
||||||
|
go run . -check-config
|
||||||
|
go run .
|
||||||
|
# SIGINT/SIGTERM stops the program.
|
||||||
|
go build -o program .
|
||||||
|
go-omni project generate --dry-run
|
||||||
|
go-omni project generate
|
||||||
|
go-omni project check
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the same fixed CLI and framework version. Local --framework-dir is only for
|
||||||
|
explicit source development; normal creation pins the installed CLI version.
|
||||||
|
|
||||||
|
Edit internal/config/config.go (Config/Default/Validate), etc/config.yaml and
|
||||||
|
internal/config/loader.go for optional decoders. JSON/YAML/XML resolve only
|
||||||
|
explicit ${NAME} / ${NAME:-fallback}; fixed values are never implicitly overridden.
|
||||||
|
-check-config validates without opening the logger or dependencies.
|
||||||
|
|
||||||
|
Add dependencies in internal/svc/serviceContext.go, registering OnClose immediately.
|
||||||
|
Register all OnStop/OnDrain hooks in Init, before starting work. Paired hooks use the
|
||||||
|
same name. OnStop stops acquisition; OnDrain returns nil only after all processing,
|
||||||
|
acknowledgements and defers finish. Runtime executes stop, then drain, then resource
|
||||||
|
cleanup in reverse registration order. Callbacks within a phase run concurrently;
|
||||||
|
combine components with ordering requirements in a single hook. Accepted work uses
|
||||||
|
WorkContext; Context is the early stop notification. No automatic retry or restart
|
||||||
|
policy is installed. Adapt external components according to their actual semantics.
|
||||||
|
|
||||||
|
Write program behavior in internal/run/run.go. Return nil when a one-shot run finishes;
|
||||||
|
return an error for fatal failure. The default Run waits for shutdown. Components may
|
||||||
|
call RequestShutdown(err) to report fatal failure. Signal or Run completion starts
|
||||||
|
shutdown; the entry waits for Run itself and registered background work. Do not make
|
||||||
|
Run wait for resource cleanup: cleanup follows Run completion. Do not register hooks
|
||||||
|
concurrently from Run. The drain name go-omni/project/run is reserved.
|
||||||
|
|
||||||
|
Run/Init panics are converted to startup/run failures and registered resources are
|
||||||
|
cleaned safely. Panics in arbitrary project goroutines must be handled by the project.
|
||||||
|
Unresponsive work is bounded by lifecycle timeouts: unfinished work causes
|
||||||
|
ErrResourcesRetained, skips shared resource/logger cleanup and exits unsuccessfully.
|
||||||
|
Go cannot forcibly stop a goroutine. SIGKILL/process crashes cannot run cleanup.
|
||||||
|
|
||||||
|
main.go, internal/config/loadGen.go and internal/svc/runtimeGen.go are generator-owned.
|
||||||
|
Manual edits cause a conflict. Other starter files are project-owned and preserved;
|
||||||
|
missing project-owned files require explicit restoration. Check is read-only and
|
||||||
|
reports missing or stale generated files. It does not compile or execute project code.
|
||||||
|
Commit .goOmni/projectManifest.json with the project. Generation uses a project lock,
|
||||||
|
preflight checks and per-file atomic replacement with rollback for ordinary write errors;
|
||||||
|
it is not crash-atomic across files. Avoid editing files during generation.
|
||||||
|
|
||||||
|
Full guide: https://git.yuntuo.hk/k2safe/go-omni/src/branch/main/PROGRAM.md
|
||||||
9
cmd/go-omni/projectgen/templates/etc_config.yaml.tmpl
Normal file
9
cmd/go-omni/projectgen/templates/etc_config.yaml.tmpl
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Project-owned. Environment references are explicit: ${NAME} or ${NAME:-fallback}.
|
||||||
|
log:
|
||||||
|
level: info
|
||||||
|
format: json
|
||||||
|
output: stderr
|
||||||
|
lifecycle:
|
||||||
|
stopTimeout: 5s
|
||||||
|
drainTimeout: 10s
|
||||||
|
cancelTimeout: 5s
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config, Default and Validate are project-owned. Add project settings here.
|
||||||
|
type Config struct {
|
||||||
|
Log logx.Config `json:"log"`
|
||||||
|
Lifecycle lifecycle.ShutdownConfig `json:"lifecycle"`
|
||||||
|
}
|
||||||
|
func Default() Config {
|
||||||
|
log:=logx.Default();log.Service="PROJECT_MODULE"
|
||||||
|
return Config{Log:log,Lifecycle:lifecycle.DefaultShutdownConfig()}
|
||||||
|
}
|
||||||
|
func(c Config)Validate()error {
|
||||||
|
if err:=c.Log.Validate();err!=nil{return omniConfig.AtField("log",err)}
|
||||||
|
if err:=c.Lifecycle.Validate();err!=nil{return omniConfig.AtField("lifecycle",err)}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package config
|
||||||
|
import omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
|
func Load(path string)(Config,error){
|
||||||
|
loader,err:=NewLoader();if err!=nil{return Config{},err}
|
||||||
|
return omniConfig.LoadProject(path,Default(),loader)
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package config
|
||||||
|
import omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
|
// NewLoader is project-owned. Register additional decoders here if needed.
|
||||||
|
func NewLoader()(*omniConfig.Loader,error){return omniConfig.NewLoader()}
|
||||||
13
cmd/go-omni/projectgen/templates/internal_run_run.go.tmpl
Normal file
13
cmd/go-omni/projectgen/templates/internal_run_run.go.tmpl
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package run
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"PROJECT_MODULE/internal/svc"
|
||||||
|
)
|
||||||
|
// Run is project-owned. Return nil for normal completion, or an error to fail.
|
||||||
|
// The starter waits for shutdown; a one-shot program can simply return when done.
|
||||||
|
// Register background stop/drain hooks in svc.Init, not concurrently here.
|
||||||
|
// Accepted background work uses deps.WorkContext(), not the early-stop ctx.
|
||||||
|
func Run(ctx context.Context,deps *svc.ServiceContext)error {
|
||||||
|
<-ctx.Done()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package svc
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
"PROJECT_MODULE/internal/config"
|
||||||
|
)
|
||||||
|
func New(ctx context.Context,logger *logx.Logger,cfg config.Config)(result *ServiceContext,err error){
|
||||||
|
if ctx==nil{ctx=context.Background()}
|
||||||
|
if err=ctx.Err();err!=nil{return nil,errors.Join(err,lifecycle.Cause(ctx))}
|
||||||
|
if logger==nil{return nil,errors.New("service context requires a logger")}
|
||||||
|
if err=cfg.Validate();err!=nil{return nil,err}
|
||||||
|
runtime,err:=lifecycle.NewRuntimeWithConfig(logx.WithLogger(ctx,logger),cfg.Lifecycle);if err!=nil{return nil,err}
|
||||||
|
c:=&ServiceContext{Runtime:runtime,Config:cfg,Logger:logger}
|
||||||
|
defer func(){
|
||||||
|
if p:=recover();p!=nil{err=fmt.Errorf("dependency initialization panic: %v",p)}
|
||||||
|
if err!=nil{result=nil;err=c.Finish(err)}
|
||||||
|
}()
|
||||||
|
if err=c.Init(c.Context());err!=nil{return nil,err}
|
||||||
|
return c,nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package svc
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
"PROJECT_MODULE/internal/config"
|
||||||
|
)
|
||||||
|
// ServiceContext is project-owned. Add dependencies without global variables.
|
||||||
|
// Runtime owns registered resources; Logger is borrowed from the entry point.
|
||||||
|
type ServiceContext struct {
|
||||||
|
*lifecycle.Runtime
|
||||||
|
Config config.Config
|
||||||
|
Logger *logx.Logger
|
||||||
|
}
|
||||||
|
// Init runs before Run. Register resources immediately with OnClose.
|
||||||
|
// Register paired OnStop/OnDrain hooks before exposing background work.
|
||||||
|
// Use WorkContext for accepted work; Context is the early stop notification.
|
||||||
|
// OnDrain returns nil only after processing and final cleanup have completed.
|
||||||
|
// Complete all registrations here, before Run can race with shutdown.
|
||||||
|
// The drain name "go-omni/project/run" is reserved for the generated entry.
|
||||||
|
func(c *ServiceContext)Init(ctx context.Context)error{return nil}
|
||||||
60
cmd/go-omni/projectgen/templates/main.go.tmpl
Normal file
60
cmd/go-omni/projectgen/templates/main.go.tmpl
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
"PROJECT_MODULE/internal/config"
|
||||||
|
program "PROJECT_MODULE/internal/run"
|
||||||
|
"PROJECT_MODULE/internal/svc"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
path:=flag.String("config","etc/config.yaml","configuration file; empty uses Default()")
|
||||||
|
check:=flag.Bool("check-config",false,"validate configuration without initializing dependencies")
|
||||||
|
flag.Parse()
|
||||||
|
if *check {if _,err:=config.Load(*path);err!=nil{log.Fatal(err)};return}
|
||||||
|
ctx,stop:=signal.NotifyContext(context.Background(),os.Interrupt,syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
if err:=run(ctx,*path);err!=nil{log.Fatal(err)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func run(ctx context.Context,path string)(runErr error) {
|
||||||
|
cfg,err:=config.Load(path);if err!=nil{return err}
|
||||||
|
logger,err:=logx.New(cfg.Log);if err!=nil{return err}
|
||||||
|
var deps *svc.ServiceContext
|
||||||
|
defer func(){
|
||||||
|
if p:=recover();p!=nil{runErr=fmt.Errorf("program initialization panic: %v",p)}
|
||||||
|
if deps!=nil{runErr=deps.Finish(runErr)}
|
||||||
|
// Unjoined work may still use its dependencies and logger. The process exits
|
||||||
|
// unsuccessfully; resource retention never means the work completed.
|
||||||
|
if !errors.Is(runErr,lifecycle.ErrResourcesRetained){runErr=errors.Join(runErr,logger.Close())}
|
||||||
|
}()
|
||||||
|
deps,err=svc.New(ctx,logger,cfg);if err!=nil{return err}
|
||||||
|
if deps.Context().Err()!=nil{return nil}
|
||||||
|
done:=make(chan struct{})
|
||||||
|
// This reserved drain proves Run has actually returned, including its defers.
|
||||||
|
// Runtime bounds the wait even when Run ignores cancellation.
|
||||||
|
deps.OnDrain("go-omni/project/run",func(context.Context)error{<-done;return nil})
|
||||||
|
logger.InfoContext(deps.Context(),"program.starting")
|
||||||
|
go func(){
|
||||||
|
var result error
|
||||||
|
returned:=false
|
||||||
|
defer func(){
|
||||||
|
if p:=recover();p!=nil{result=fmt.Errorf("program run panic: %v",p)} else if !returned{result=errors.New("program run exited without returning")}
|
||||||
|
deps.RequestShutdown(result)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
result=program.Run(deps.Context(),deps)
|
||||||
|
returned=true
|
||||||
|
}()
|
||||||
|
<-deps.Context().Done()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
283
cmd/go-omni/projectgen/testdata/consumer_test.go
vendored
Normal file
283
cmd/go-omni/projectgen/testdata/consumer_test.go
vendored
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"example.com/plain/internal/config"
|
||||||
|
program "example.com/plain/internal/run"
|
||||||
|
"example.com/plain/internal/svc"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setup(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
svc.Initialize = func(context.Context, *svc.ServiceContext) error { return nil }
|
||||||
|
program.Execute = func(context.Context, *svc.ServiceContext) error { return nil }
|
||||||
|
t.Cleanup(func() {
|
||||||
|
svc.Initialize = func(context.Context, *svc.ServiceContext) error { return nil }
|
||||||
|
program.Execute = func(context.Context, *svc.ServiceContext) error { return nil }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
func shortConfig(t *testing.T, logfile string) string {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
|
text := "log:\n output: discard\nlifecycle:\n stopTimeout: 100ms\n drainTimeout: 30ms\n cancelTimeout: 100ms\n"
|
||||||
|
if logfile != "" {
|
||||||
|
text = "log:\n file: " + logfile + "\nlifecycle:\n stopTimeout: 100ms\n drainTimeout: 30ms\n cancelTimeout: 100ms\n"
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, []byte(text), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFiniteRunAndCleanupOrder(t *testing.T) {
|
||||||
|
setup(t)
|
||||||
|
var events []string
|
||||||
|
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
|
||||||
|
events = append(events, "init")
|
||||||
|
c.OnClose(func() error { events = append(events, "first"); return nil })
|
||||||
|
c.OnClose(func() error { events = append(events, "second"); return nil })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
program.Execute = func(context.Context, *svc.ServiceContext) error {
|
||||||
|
defer func() { events = append(events, "run defer") }()
|
||||||
|
events = append(events, "run")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := run(context.Background(), ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(events, []string{"init", "run", "run defer", "second", "first"}) {
|
||||||
|
t.Fatal(events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestInitializationAndRunFailures(t *testing.T) {
|
||||||
|
for _, stage := range []string{"init error", "init panic", "run error", "run panic", "run goexit", "component failure"} {
|
||||||
|
t.Run(stage, func(t *testing.T) {
|
||||||
|
setup(t)
|
||||||
|
boom := errors.New("failure marker")
|
||||||
|
var closed atomic.Int32
|
||||||
|
var started atomic.Bool
|
||||||
|
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
|
||||||
|
c.OnClose(func() error { closed.Add(1); return nil })
|
||||||
|
if stage == "init error" {
|
||||||
|
return boom
|
||||||
|
}
|
||||||
|
if stage == "init panic" {
|
||||||
|
panic(boom)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
program.Execute = func(ctx context.Context, c *svc.ServiceContext) error {
|
||||||
|
started.Store(true)
|
||||||
|
switch stage {
|
||||||
|
case "run error":
|
||||||
|
return boom
|
||||||
|
case "run panic":
|
||||||
|
panic(boom)
|
||||||
|
case "run goexit":
|
||||||
|
runtime.Goexit()
|
||||||
|
case "component failure":
|
||||||
|
c.RequestShutdown(boom)
|
||||||
|
<-ctx.Done()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := run(context.Background(), "")
|
||||||
|
if err == nil || closed.Load() != 1 {
|
||||||
|
t.Fatal(stage, err, closed.Load())
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(stage, "init") && started.Load() {
|
||||||
|
t.Fatal("run started after failed initialization")
|
||||||
|
}
|
||||||
|
if strings.Contains(stage, "error") || stage == "component failure" {
|
||||||
|
if !errors.Is(err, boom) {
|
||||||
|
t.Fatal("failure cause lost", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestGracefulStopDrainsAcknowledgementBeforeClose(t *testing.T) {
|
||||||
|
setup(t)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
ready := make(chan struct{})
|
||||||
|
stop := make(chan struct{})
|
||||||
|
done := make(chan struct{})
|
||||||
|
var closed, acked atomic.Bool
|
||||||
|
var canceledEarly atomic.Bool
|
||||||
|
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
|
||||||
|
c.OnClose(func() error {
|
||||||
|
if !acked.Load() {
|
||||||
|
return errors.New("closed before acknowledgement")
|
||||||
|
}
|
||||||
|
closed.Store(true)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
c.OnStop("component", func(context.Context) error { close(stop); return nil })
|
||||||
|
c.OnDrain("component", func(context.Context) error { <-done; return nil })
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
<-stop
|
||||||
|
if c.WorkContext().Err() != nil {
|
||||||
|
canceledEarly.Store(true)
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
acked.Store(true)
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
program.Execute = func(ctx context.Context, c *svc.ServiceContext) error { close(ready); <-ctx.Done(); return nil }
|
||||||
|
result := make(chan error, 1)
|
||||||
|
go func() { result <- run(ctx, "") }()
|
||||||
|
<-ready
|
||||||
|
cancel()
|
||||||
|
if err := <-result; err != nil || !closed.Load() || canceledEarly.Load() {
|
||||||
|
t.Fatal(err, closed.Load(), canceledEarly.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestInitializationFailureDrainsStartedWork(t *testing.T) {
|
||||||
|
setup(t)
|
||||||
|
done := make(chan struct{})
|
||||||
|
stop := make(chan struct{})
|
||||||
|
var closed atomic.Bool
|
||||||
|
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
|
||||||
|
c.OnClose(func() error {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
closed.Store(true)
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return errors.New("resource closed while starting work still active")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
c.OnStop("partial", func(context.Context) error { close(stop); return nil })
|
||||||
|
c.OnDrain("partial", func(context.Context) error { <-done; return nil })
|
||||||
|
go func() { defer close(done); <-stop; time.Sleep(10 * time.Millisecond) }()
|
||||||
|
return errors.New("later dependency failed")
|
||||||
|
}
|
||||||
|
if err := run(context.Background(), ""); err == nil || !closed.Load() {
|
||||||
|
t.Fatal(err, closed.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestCancellationGraceJoinsWork(t *testing.T) {
|
||||||
|
setup(t)
|
||||||
|
done := make(chan struct{})
|
||||||
|
var closed atomic.Bool
|
||||||
|
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
|
||||||
|
c.OnClose(func() error { closed.Store(true); return nil })
|
||||||
|
c.OnStop("cooperative", func(context.Context) error { return nil })
|
||||||
|
c.OnDrain("cooperative", func(context.Context) error { <-done; return nil })
|
||||||
|
go func() { <-c.WorkContext().Done(); close(done) }()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err := run(context.Background(), shortConfig(t, ""))
|
||||||
|
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, lifecycle.ErrResourcesRetained) || !closed.Load() {
|
||||||
|
t.Fatal(err, closed.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestUnjoinedWorkRetainsDependenciesAndLogger(t *testing.T) {
|
||||||
|
for _, which := range []string{"run", "background", "init failure"} {
|
||||||
|
t.Run(which, func(t *testing.T) {
|
||||||
|
setup(t)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
release := make(chan struct{})
|
||||||
|
joined := make(chan struct{})
|
||||||
|
ready := make(chan struct{})
|
||||||
|
var closed atomic.Bool
|
||||||
|
var held *svc.ServiceContext
|
||||||
|
logfile := filepath.Join(t.TempDir(), "retained.log")
|
||||||
|
svc.Initialize = func(ctx context.Context, c *svc.ServiceContext) error {
|
||||||
|
held = c
|
||||||
|
c.OnClose(func() error { closed.Store(true); return nil })
|
||||||
|
if which != "run" {
|
||||||
|
c.OnStop("stuck", func(context.Context) error { return nil })
|
||||||
|
c.OnDrain("stuck", func(context.Context) error { <-joined; return nil })
|
||||||
|
go func() { defer close(joined); <-release; c.Logger.Info("after shutdown deadline") }()
|
||||||
|
}
|
||||||
|
if which == "init failure" {
|
||||||
|
return errors.New("initialization failed")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
program.Execute = func(ctx context.Context, c *svc.ServiceContext) error {
|
||||||
|
close(ready)
|
||||||
|
if which == "run" {
|
||||||
|
defer close(joined)
|
||||||
|
<-release
|
||||||
|
c.Logger.Info("after shutdown deadline")
|
||||||
|
} else {
|
||||||
|
<-ctx.Done()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make(chan error, 1)
|
||||||
|
go func() { result <- run(ctx, shortConfig(t, logfile)) }()
|
||||||
|
if which != "init failure" {
|
||||||
|
<-ready
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
select {
|
||||||
|
case err = <-result:
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("shutdown hung")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, lifecycle.ErrResourcesRetained) || closed.Load() {
|
||||||
|
t.Fatal(err, closed.Load())
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
<-joined
|
||||||
|
// In the run case the entry's completion notification follows the user defer.
|
||||||
|
// Close is idempotent and must retain resources even after late completion.
|
||||||
|
if err = held.Close(); !errors.Is(err, lifecycle.ErrResourcesRetained) || closed.Load() {
|
||||||
|
t.Fatal("retention was lost", err)
|
||||||
|
}
|
||||||
|
data, _ := os.ReadFile(logfile)
|
||||||
|
if !strings.Contains(string(data), "after shutdown deadline") {
|
||||||
|
t.Fatal("logger closed while work still running", string(data))
|
||||||
|
}
|
||||||
|
_ = held.Logger.Close()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestExplicitConfigurationAndPreflight(t *testing.T) {
|
||||||
|
setup(t)
|
||||||
|
t.Setenv("PLAIN_LEVEL", "debug")
|
||||||
|
t.Setenv("LOG_LEVEL", "error")
|
||||||
|
p := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
|
if err := os.WriteFile(p, []byte("log:\n level: ${PLAIN_LEVEL}\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := config.Load(p)
|
||||||
|
if err != nil || cfg.Log.Level != "debug" {
|
||||||
|
t.Fatal(cfg.Log.Level, err)
|
||||||
|
}
|
||||||
|
if err = os.WriteFile(p, []byte("log:\n level: info\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err = config.Load(p)
|
||||||
|
if err != nil || cfg.Log.Level != "info" {
|
||||||
|
t.Fatal("implicit environment override", err)
|
||||||
|
}
|
||||||
|
var initCalled atomic.Bool
|
||||||
|
svc.Initialize = func(context.Context, *svc.ServiceContext) error { initCalled.Store(true); return nil }
|
||||||
|
if err = os.WriteFile(p, []byte("lifecycle:\n drainTimeout: -1s\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = run(context.Background(), p); err == nil || initCalled.Load() {
|
||||||
|
t.Fatal("invalid configuration initialized dependencies", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,9 +66,13 @@ func TestGenerateModuleProfiles(t *testing.T) {
|
|||||||
t.Fatal("replacement does not resolve", err)
|
t.Fatal("replacement does not resolve", err)
|
||||||
}
|
}
|
||||||
cfg, _ := os.ReadFile(filepath.Join(project, "etc/config.yaml"))
|
cfg, _ := os.ReadFile(filepath.Join(project, "etc/config.yaml"))
|
||||||
if strings.Contains(string(cfg), "secretKey:") || strings.Contains(string(cfg), "filesToken:") {
|
for _, line := range strings.Split(string(cfg), "\n") {
|
||||||
t.Fatal("secret defaults emitted")
|
key, value, ok := strings.Cut(strings.TrimSpace(line), ":")
|
||||||
|
if ok && (key == "secretKey" || key == "filesToken" || key == "signingKey") && !strings.Contains(value, "${GO_OMNI_") {
|
||||||
|
t.Fatal("secret must be an explicit reference")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,6 +80,7 @@ func TestModuleOptionsRejectBeforeWriting(t *testing.T) {
|
|||||||
for _, options := range []Options{
|
for _, options := range []Options{
|
||||||
{LogOutput: "invalid"}, {LogFormat: "xml"}, {Storage: "ftp"},
|
{LogOutput: "invalid"}, {LogFormat: "xml"}, {Storage: "ftp"},
|
||||||
{Storage: "local", FrameworkVersion: "v0.6.0-preview"},
|
{Storage: "local", FrameworkVersion: "v0.6.0-preview"},
|
||||||
|
{FrameworkVersion: "v0.21.0-preview"},
|
||||||
{Module: "example.com/x\nrequire malicious v1.0.0"},
|
{Module: "example.com/x\nrequire malicious v1.0.0"},
|
||||||
} {
|
} {
|
||||||
options.Dir = filepath.Join(t.TempDir(), "project")
|
options.Dir = filepath.Join(t.TempDir(), "project")
|
||||||
|
|||||||
@@ -34,9 +34,13 @@ type Options struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var files = map[string]string{
|
var files = map[string]string{
|
||||||
|
"internal/settings/config.go": "templates/modules-settings.go.tmpl",
|
||||||
|
"internal/settings/config_test.go": "templates/modules-settings_test.go.tmpl",
|
||||||
|
"etc/config.yaml": "templates/modules-config.yaml.tmpl",
|
||||||
|
|
||||||
"go.mod": "templates/go.mod.tmpl",
|
"go.mod": "templates/go.mod.tmpl",
|
||||||
"README.md": "templates/README.md.tmpl",
|
"README.md": "templates/README.md.tmpl",
|
||||||
"cmd/server/main.go": "templates/cmd-server-main.go.tmpl",
|
"cmd/server/main.go": "templates/modules-main.go.tmpl",
|
||||||
"internal/model/user.go": "templates/internal-model-user.go.tmpl",
|
"internal/model/user.go": "templates/internal-model-user.go.tmpl",
|
||||||
"internal/model/errors.go": "templates/internal-model-errors.go.tmpl",
|
"internal/model/errors.go": "templates/internal-model-errors.go.tmpl",
|
||||||
"internal/repository/users.go": "templates/internal-repository-users.go.tmpl",
|
"internal/repository/users.go": "templates/internal-repository-users.go.tmpl",
|
||||||
@@ -109,7 +113,7 @@ func Generate(options Options) error {
|
|||||||
if options.FrameworkDir != "" || options.FrameworkVersion == "" {
|
if options.FrameworkDir != "" || options.FrameworkVersion == "" {
|
||||||
replace = frameworkReplace(directory, options.FrameworkDir)
|
replace = frameworkReplace(directory, options.FrameworkDir)
|
||||||
}
|
}
|
||||||
if modules && replace == "" && semver.Compare(version, modulesFrameworkVersion) < 0 && version != installedFrameworkVersion() {
|
if replace == "" && semver.Compare(version, modulesFrameworkVersion) < 0 && version != installedFrameworkVersion() {
|
||||||
return fmt.Errorf("module templates require %s or newer; use --framework-dir for unpublished source", modulesFrameworkVersion)
|
return fmt.Errorf("module templates require %s or newer; use --framework-dir for unpublished source", modulesFrameworkVersion)
|
||||||
}
|
}
|
||||||
data := templateData{
|
data := templateData{
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ func TestGenerateCreatesServiceLayout(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read generated server: %v", err)
|
t.Fatalf("read generated server: %v", err)
|
||||||
}
|
}
|
||||||
for _, expected := range []string{"config.LoadFromEnv(\"\")", "logx.SetDefault(logger)", "omni.WithConfig(settings)", "servicecontext.New(ctx, logger)", "services.Health.ReadinessHandler()", "application.RunContext(ctx, settings.Address)"} {
|
for _, expected := range []string{"settings.Load(path)", "logx.SetDefault(logger)", "omni.WithConfig(cfg.Server)", "servicecontext.New(ctx, logger, cfg)", "services.Health.ReadinessHandler()", "application.RunContext(ctx, cfg.Server.Address)"} {
|
||||||
if !strings.Contains(string(mainContent), expected) {
|
if !strings.Contains(string(mainContent), expected) {
|
||||||
t.Fatalf("generated server missing %q: %s", expected, mainContent)
|
t.Fatalf("generated server missing %q: %s", expected, mainContent)
|
||||||
}
|
}
|
||||||
@@ -110,7 +110,7 @@ func TestFrameworkReplaceUsesCanonicalPaths(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateRemoteProjectPinsVersion(t *testing.T) {
|
func TestGenerateRemoteProjectPinsVersion(t *testing.T) {
|
||||||
for _, version := range []string{"v0.1.0", "v1.2.3-rc.1", "v0.0.0-20260920140624-8b19aa79eea0"} {
|
for _, version := range []string{"v0.22.0-preview", "v1.2.3-rc.1", "v0.22.1-0.20260926000000-8b19aa79eea0"} {
|
||||||
t.Run(version, func(t *testing.T) {
|
t.Run(version, func(t *testing.T) {
|
||||||
project := filepath.Join(t.TempDir(), "billing")
|
project := filepath.Join(t.TempDir(), "billing")
|
||||||
if err := Generate(Options{Dir: project, Module: "example.com/billing", FrameworkVersion: version}); err != nil {
|
if err := Generate(Options{Dir: project, Module: "example.com/billing", FrameworkVersion: version}); err != nil {
|
||||||
|
|||||||
@@ -32,46 +32,44 @@ go mod tidy
|
|||||||
go run ./cmd/server
|
go run ./cmd/server
|
||||||
```
|
```
|
||||||
|
|
||||||
运行时配置从 `GO_OMNI_*` 环境变量加载,例如:
|
配置入口是 `internal/settings.Config`、`Default()`、`Validate()` 和 `etc/config.yaml`。
|
||||||
|
只有文件里的 `${NAME}` / `${NAME:-fallback}` 会读取环境变量,固定值不受进程环境覆盖。生成的文件已声明地址、网络层,以及所选数据库/存储的必要引用;其他参数直接写进文件。
|
||||||
|
|
||||||
```sh
|
```yaml
|
||||||
GO_OMNI_ENABLE_LOGGER=true GO_OMNI_LOG_LEVEL=debug go run ./cmd/server
|
server:
|
||||||
|
address: "${GO_OMNI_HTTP_ADDR:-127.0.0.1:8080}"
|
||||||
|
transport: "${GO_OMNI_TRANSPORT:-nethttp}"
|
||||||
|
log:
|
||||||
|
level: debug
|
||||||
|
shutdownTimeout: 10s
|
||||||
```
|
```
|
||||||
|
|
||||||
`GO_OMNI_HTTP_ADDR` 可以修改监听地址。生成项目默认提供 `/health/live` 和
|
用 `-config` 选择一个 YAML/JSON/XML 文件;`-config ''` 只使用 Default();`-check-config` 仅加载和校验,不创建依赖或监听:
|
||||||
`/health/ready`,日志、请求 ID 和优雅退出等基础能力由 GoOmni 统一初始化;业务依赖集中放在项目自己的 `internal/servicecontext`。
|
|
||||||
|
|
||||||
服务使用 `signal.NotifyContext` 处理 `SIGINT` / `SIGTERM`。部署时构建并运行服务二进制,
|
|
||||||
使信号直接到达服务进程:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
go run ./cmd/server -config etc/config.yaml -check-config
|
||||||
go build -o server ./cmd/server
|
go build -o server ./cmd/server
|
||||||
./server
|
./server -config etc/config.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
`GO_OMNI_SHUTDOWN_TIMEOUT` 控制等待请求完成的时间,默认 `10s`。
|
服务通过 `signal.NotifyContext` 处理 `SIGINT` / `SIGTERM`,先排空 HTTP 请求,再关闭依赖和日志。生成项目默认提供 `/health/live` 和 `/health/ready`,业务依赖集中在 `internal/servicecontext`。
|
||||||
|
|
||||||
使用支持统一网络配置的框架版本时,设置 `GO_OMNI_TRANSPORT=native` 即可切换为自研 HTTP/1.1;
|
通过文件中的引用设置 `GO_OMNI_TRANSPORT=native` 可切换自研 HTTP/1.1,默认 nethttp。HTTPS 在文件的 `server.tls.certFile` / `server.tls.keyFile` 成对配置;实验 HTTP/2 选择 `http2` 且必须配置 TLS。不会自动读取未在文件声明的 TLS 环境变量。
|
||||||
默认 `nethttp`。`GO_OMNI_TLS_CERT_FILE` 和 `GO_OMNI_TLS_KEY_FILE` 配对启用 HTTPS。
|
|
||||||
实验版 HTTP/2 使用 `GO_OMNI_TRANSPORT=http2`,必须同时配置 TLS 证书和私钥。
|
|
||||||
业务 Handler / Service / Repository 不需要修改;三种适配器共用上述退出等待时间。
|
|
||||||
{{if eq .Database "mysql"}}
|
{{if eq .Database "mysql"}}
|
||||||
`/health/ready` 已注册数据库探针:连接失败返回 `503`,数据库恢复后重新返回 `200`。
|
`/health/ready` 已注册数据库探针,连接失败返回 503,恢复后返回 200;`/health/live` 只检查进程存活。连接池参数直接写入 `database`,缺失字段保留 Default():
|
||||||
`/health/live` 始终只检查服务存活。`GO_OMNI_DB_PING_TIMEOUT` 限制初始化检查和数据库探针的等待时间。
|
|
||||||
退出时先排空 HTTP 请求,再关闭数据库连接池,最后关闭日志。
|
|
||||||
|
|
||||||
数据库连接池参数:
|
```yaml
|
||||||
|
database:
|
||||||
```text
|
driver: mysql
|
||||||
GO_OMNI_DB_MAX_OPEN_CONNS=10
|
dsn: "${GO_OMNI_DB_DSN}"
|
||||||
GO_OMNI_DB_MAX_IDLE_CONNS=5
|
maxOpenConns: 10
|
||||||
GO_OMNI_DB_CONN_MAX_LIFETIME=30m
|
maxIdleConns: 5
|
||||||
GO_OMNI_DB_CONN_MAX_IDLE_TIME=5m
|
connMaxLifetime: 30m
|
||||||
GO_OMNI_DB_PING_TIMEOUT=5s
|
connMaxIdleTime: 5m
|
||||||
|
pingTimeout: 5s
|
||||||
```
|
```
|
||||||
|
|
||||||
`MAX_IDLE_CONNS=0` 禁用空闲连接,`MAX_OPEN_CONNS=0` 表示不限制连接数。
|
`maxIdleConns: 0` 禁用空闲连接;此项目要求 maxOpenConns 为正数。项目启用 MySQL parseTime,并按 UTC 读取 DATETIME;JSON 字段保持 lowerCamelCase。
|
||||||
项目会启用 MySQL `parseTime` 并按 UTC 读取 DATETIME 字段,JSON 字段保持 lowerCamelCase。
|
|
||||||
|
|
||||||
事务内复用 Repository(在业务代码中导入 `database/sql` 和项目的 `internal/repository`):
|
事务内复用 Repository(在业务代码中导入 `database/sql` 和项目的 `internal/repository`):
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
)
|
)
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server config.Config `json:"server"`
|
Server config.Config `json:"server"`
|
||||||
{{if eq .Database "mysql"}}Database mysqladapter.Config `json:"database" env:"GO_OMNI_DB"`{{end}}
|
{{if eq .Database "mysql"}}Database mysqladapter.Config `json:"database"`{{end}}
|
||||||
{{if ne .Storage "none"}}Storage StorageConfig `json:"storage" env:"GO_OMNI_STORAGE"`{{end}}
|
{{if ne .Storage "none"}}Storage StorageConfig `json:"storage"`{{end}}
|
||||||
}
|
}
|
||||||
func Default() Config {
|
func Default() Config {
|
||||||
server:=config.Default()
|
server:=config.Default()
|
||||||
@@ -54,12 +54,12 @@ type StorageConfig struct {
|
|||||||
ContentTypes []string `json:"contentTypes"`
|
ContentTypes []string `json:"contentTypes"`
|
||||||
LinkTTL time.Duration `json:"linkTtl"`
|
LinkTTL time.Duration `json:"linkTtl"`
|
||||||
RequestTimeout time.Duration `json:"requestTimeout"`
|
RequestTimeout time.Duration `json:"requestTimeout"`
|
||||||
// Secrets are environment-only and cannot be populated by config files.
|
// File values may explicitly reference environment variables. Never log this struct.
|
||||||
FilesToken string `json:"-" env:"GO_OMNI_FILES_TOKEN,secret"`
|
FilesToken string `json:"filesToken"`
|
||||||
SigningKey string `json:"-" env:"GO_OMNI_STORAGE_SIGNING_KEY,secret"`
|
SigningKey string `json:"signingKey"`
|
||||||
AccessKey string `json:"-" env:"GO_OMNI_STORAGE_ACCESS_KEY,secret"`
|
AccessKey string `json:"accessKey"`
|
||||||
SecretKey string `json:"-" env:"GO_OMNI_STORAGE_SECRET_KEY,secret"`
|
SecretKey string `json:"secretKey"`
|
||||||
SessionToken string `json:"-" env:"GO_OMNI_STORAGE_SESSION_TOKEN,secret"`
|
SessionToken string `json:"sessionToken"`
|
||||||
}
|
}
|
||||||
func (s StorageConfig) Policy()storage.UploadPolicy{return storage.UploadPolicy{MaxBytes:s.MaxBytes,ContentTypes:s.ContentTypes}}
|
func (s StorageConfig) Policy()storage.UploadPolicy{return storage.UploadPolicy{MaxBytes:s.MaxBytes,ContentTypes:s.ContentTypes}}
|
||||||
func (s StorageConfig) Validate()error {
|
func (s StorageConfig) Validate()error {
|
||||||
@@ -67,14 +67,14 @@ func (s StorageConfig) Validate()error {
|
|||||||
if err:=s.Policy().Validate();err!=nil{return err}
|
if err:=s.Policy().Validate();err!=nil{return err}
|
||||||
if s.LinkTTL<time.Second || s.LinkTTL>time.Hour{return errors.New("storage.linkTtl must be 1s..1h")}
|
if s.LinkTTL<time.Second || s.LinkTTL>time.Hour{return errors.New("storage.linkTtl must be 1s..1h")}
|
||||||
if s.RequestTimeout<=0 || s.RequestTimeout>5*time.Minute{return errors.New("storage.requestTimeout must be >0 and <=5m")}
|
if s.RequestTimeout<=0 || s.RequestTimeout>5*time.Minute{return errors.New("storage.requestTimeout must be >0 and <=5m")}
|
||||||
{{if not .API}}if len(s.FilesToken)<32 || strings.ContainsAny(s.FilesToken," \t\r\n") {return errors.New("set GO_OMNI_FILES_TOKEN to a token of at least 32 bytes without whitespace")}{{end}}
|
{{if not .API}}if len(s.FilesToken)<32 || strings.ContainsAny(s.FilesToken," \t\r\n") {return errors.New("storage.filesToken requires at least 32 bytes without whitespace")}{{end}}
|
||||||
{{if eq .Storage "local"}}
|
{{if eq .Storage "local"}}
|
||||||
if s.Root=="" {return errors.New("storage.root is required")}
|
if s.Root=="" {return errors.New("storage.root is required")}
|
||||||
u,err:=url.Parse(s.PublicURL)
|
u,err:=url.Parse(s.PublicURL)
|
||||||
if err!=nil || u.Host=="" || u.Hostname()=="" || (u.Scheme!="http" && u.Scheme!="https") || u.User!=nil || u.RawQuery!="" || u.Fragment!="" || u.Path!="/storage/object" {return errors.New("storage.publicUrl must be an absolute HTTP(S) URL ending in /storage/object")}
|
if err!=nil || u.Host=="" || u.Hostname()=="" || (u.Scheme!="http" && u.Scheme!="https") || u.User!=nil || u.RawQuery!="" || u.Fragment!="" || u.Path!="/storage/object" {return errors.New("storage.publicUrl must be an absolute HTTP(S) URL ending in /storage/object")}
|
||||||
if len(s.SigningKey)<32 {return errors.New("set GO_OMNI_STORAGE_SIGNING_KEY to a stable secret of at least 32 bytes")}
|
if len(s.SigningKey)<32 {return errors.New("storage.signingKey requires a stable secret of at least 32 bytes")}
|
||||||
{{else}}
|
{{else}}
|
||||||
if s.Bucket=="" || s.Region=="" || s.AccessKey=="" || s.SecretKey=="" {return errors.New("storage bucket, region and GO_OMNI_STORAGE_ACCESS_KEY/SECRET_KEY are required")}
|
if s.Bucket=="" || s.Region=="" || s.AccessKey=="" || s.SecretKey=="" {return errors.New("storage bucket, region, accessKey and secretKey are required")}
|
||||||
{{if eq .Storage "r2"}}
|
{{if eq .Storage "r2"}}
|
||||||
if !regexp.MustCompile(`^[a-fA-F0-9]{32}$`).MatchString(s.AccountID) || s.Region!="auto" || !s.Secure {return errors.New("R2 requires a 32-hex accountId, region auto and secure=true")}
|
if !regexp.MustCompile(`^[a-fA-F0-9]{32}$`).MatchString(s.AccountID) || s.Region!="auto" || !s.Secure {return errors.New("R2 requires a 32-hex accountId, region auto and secure=true")}
|
||||||
if s.Endpoint!=""{return errors.New("this R2 profile derives endpoint from accountId; customize servicecontext for a jurisdiction-specific endpoint")}
|
if s.Endpoint!=""{return errors.New("this R2 profile derives endpoint from accountId; customize servicecontext for a jurisdiction-specific endpoint")}
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
|
import omniConfig "git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
|
// Load resolves explicit file references, binds onto Default and validates.
|
||||||
// Load is maintained by the generator. Extend Config/Default/Validate in
|
// Ordinary projects only edit Config, Default, Validate and the selected file.
|
||||||
// config.go, or NewLoader in loader.go; do not duplicate the loading pipeline.
|
func Load(path string)(Config,error){
|
||||||
func Load(path string) (Config, error) {
|
loader,err:=NewLoader();if err!=nil{return Config{},err}
|
||||||
loader,err:=NewLoader()
|
return omniConfig.LoadProject(path,Default(),loader)
|
||||||
if err!=nil{return Config{},err}
|
|
||||||
return omniConfig.LoadProject(path,Default(),omniConfig.EnvOptions{
|
|
||||||
Prefix:"GO_OMNI_",Empty:"ignore",Aliases:omniConfig.ServerEnvAliases("GO_OMNI_","server"),
|
|
||||||
},loader)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"os/signal"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
|
|
||||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
|
||||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
|
||||||
"{{.Module}}/internal/handler"
|
|
||||||
"{{.Module}}/internal/service"
|
|
||||||
"{{.Module}}/internal/servicecontext"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
if err := run(); err != nil {
|
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func run() error {
|
|
||||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
||||||
defer stop()
|
|
||||||
|
|
||||||
settings, err := config.LoadFromEnv("")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(settings.Log.Service) == "" {
|
|
||||||
settings.Log.Service = "{{.Module}}"
|
|
||||||
}
|
|
||||||
logger, err := logx.New(settings.Log)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer logger.Close()
|
|
||||||
// Access logs and business logs share the same configured sink.
|
|
||||||
logx.SetDefault(logger)
|
|
||||||
|
|
||||||
services, err := servicecontext.New(ctx, logger)
|
|
||||||
if err != nil { return err }
|
|
||||||
defer services.Close()
|
|
||||||
userService := service.NewUsers(services)
|
|
||||||
userHandler := handler.NewUsers(userService)
|
|
||||||
|
|
||||||
application := omni.New(omni.WithConfig(settings))
|
|
||||||
application.OnShutdown(logger.Close)
|
|
||||||
application.OnShutdown(services.Close)
|
|
||||||
application.GET("/health/live", services.Health.LivenessHandler())
|
|
||||||
application.GET("/health/ready", services.Health.ReadinessHandler())
|
|
||||||
userHandler.RegisterRoutes(application)
|
|
||||||
return application.RunContext(ctx, settings.Address)
|
|
||||||
}
|
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
"{{.Module}}/internal/model"
|
"{{.Module}}/internal/model"
|
||||||
|
"{{.Module}}/internal/settings"
|
||||||
"{{.Module}}/internal/repository"
|
"{{.Module}}/internal/repository"
|
||||||
"{{.Module}}/internal/servicecontext"
|
"{{.Module}}/internal/servicecontext"
|
||||||
)
|
)
|
||||||
@@ -23,15 +24,12 @@ func databaseContext(t *testing.T) *servicecontext.Context {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
dsn := os.Getenv("GO_OMNI_TEST_MYSQL_DSN")
|
dsn := os.Getenv("GO_OMNI_TEST_MYSQL_DSN")
|
||||||
if dsn == "" { t.Skip("set GO_OMNI_TEST_MYSQL_DSN to a dedicated test database") }
|
if dsn == "" { t.Skip("set GO_OMNI_TEST_MYSQL_DSN to a dedicated test database") }
|
||||||
for _, key := range []string{"MAX_OPEN_CONNS", "MAX_IDLE_CONNS", "CONN_MAX_LIFETIME", "CONN_MAX_IDLE_TIME", "PING_TIMEOUT"} {
|
|
||||||
t.Setenv("GO_OMNI_DB_"+key, "")
|
|
||||||
}
|
|
||||||
t.Setenv("GO_OMNI_DB_DSN", dsn)
|
t.Setenv("GO_OMNI_DB_DSN", dsn)
|
||||||
t.Setenv("GO_OMNI_DB_DRIVER", "mysql")
|
|
||||||
logger, err := logx.New(logx.Config{Writer: io.Discard})
|
logger, err := logx.New(logx.Config{Writer: io.Discard})
|
||||||
if err != nil { t.Fatal(err) }
|
if err != nil { t.Fatal(err) }
|
||||||
t.Cleanup(func() { _ = logger.Close() })
|
t.Cleanup(func() { _ = logger.Close() })
|
||||||
services, err := servicecontext.New(context.Background(), logger)
|
cfg,err:=settings.Load("../../etc/config.yaml");if err!=nil{t.Fatal(err)}
|
||||||
|
services, err := servicecontext.New(context.Background(), logger,cfg)
|
||||||
if err != nil { t.Fatal(err) }
|
if err != nil { t.Fatal(err) }
|
||||||
t.Cleanup(func() { _ = services.Close() })
|
t.Cleanup(func() { _ = services.Close() })
|
||||||
return services
|
return services
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package servicecontext
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"{{.Module}}/internal/settings"
|
||||||
{{if and .Modules (ne .Storage "none")}}"errors"
|
{{if and .Modules (ne .Storage "none")}}"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -38,16 +39,16 @@ type Context struct {
|
|||||||
|
|
||||||
// New initializes the project's dependencies before the HTTP listener opens.
|
// New initializes the project's dependencies before the HTTP listener opens.
|
||||||
// The caller owns Logger; Close releases only resources created here.
|
// The caller owns Logger; Close releases only resources created here.
|
||||||
func New(ctx context.Context, logger *logx.Logger) (*Context, error) {
|
func New(ctx context.Context, logger *logx.Logger, project settings.Config) (*Context, error) {
|
||||||
if ctx == nil { ctx = context.Background() }
|
if ctx == nil { ctx = context.Background() }
|
||||||
if err := ctx.Err(); err != nil { return nil, err }
|
if err := ctx.Err(); err != nil { return nil, err }
|
||||||
checker, err := health.New()
|
checker, err := health.New()
|
||||||
if err != nil { return nil, err }
|
if err != nil { return nil, err }
|
||||||
services := &Context{Logger: logger, Health: checker}
|
services := &Context{Logger: logger, Health: checker}
|
||||||
{{if eq .Database "mysql"}}
|
{{if eq .Database "mysql"}}
|
||||||
cfg, err := database.ConfigFromEnv("")
|
cfg:=project.Database
|
||||||
if err != nil { return nil, err }
|
if err:=cfg.Validate();err!=nil{return nil,err}
|
||||||
if cfg.Driver != "mysql" { return nil, fmt.Errorf("this project requires GO_OMNI_DB_DRIVER=mysql") }
|
if cfg.Driver != "mysql" { return nil, fmt.Errorf("this project requires database.driver=mysql") }
|
||||||
dsn, err := mysql.ParseDSN(cfg.DSN)
|
dsn, err := mysql.ParseDSN(cfg.DSN)
|
||||||
if err != nil { return nil, fmt.Errorf("parse mysql connection settings: %w", err) }
|
if err != nil { return nil, fmt.Errorf("parse mysql connection settings: %w", err) }
|
||||||
if dsn.DBName == "" { return nil, fmt.Errorf("mysql connection settings require a database name") }
|
if dsn.DBName == "" { return nil, fmt.Errorf("mysql connection settings require a database name") }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
本项目选用日志输出 `{{.LogOutput}}`、格式 `{{.LogFormat}}`、对象存储 `{{.Storage}}`。数据库选择 `{{.Database}}` 独立于对象存储。
|
本项目选用日志输出 `{{.LogOutput}}`、格式 `{{.LogFormat}}`、对象存储 `{{.Storage}}`。数据库选择 `{{.Database}}` 独立于对象存储。
|
||||||
|
|
||||||
配置入口为 `etc/config.yaml`,通过 `go run ./cmd/server -config /path/to/config.yaml` 指定其他 JSON/YAML 文件;`-config ''` 只用代码默认值和环境变量。优先级:默认值 → 文件 → 非空环境变量。未知字段、无效参数会在监听前拒绝。
|
配置入口为 `etc/config.yaml`,通过 `go run ./cmd/server -config /path/to/config.yaml` 指定其他 JSON/YAML/XML 文件;`-config ''` 只用代码默认值。文件用 `${NAME}` 或 `${NAME:-fallback}` 显式引用变量,固定值不受隐式环境覆盖;`-check-config` 只检查、不初始化依赖。未知字段、无效参数会在监听前拒绝。
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go mod tidy
|
go mod tidy
|
||||||
@@ -24,7 +24,7 @@ go run ./cmd/server
|
|||||||
|
|
||||||
{{if eq .Database "mysql"}}还需按 README 配置独立 MySQL 数据库并应用 schema.sql;应用不自动迁移。{{end}}
|
{{if eq .Database "mysql"}}还需按 README 配置独立 MySQL 数据库并应用 schema.sql;应用不自动迁移。{{end}}
|
||||||
|
|
||||||
默认仅监听 `127.0.0.1:8080`。`GO_OMNI_HTTP_ADDR` 覆盖地址;本地签名 URL 的外部地址通过 `GO_OMNI_STORAGE_PUBLIC_URL` 单独配置,始终以 `/storage/object` 结尾,不使用请求 Host 自动拼接可信链接。
|
默认仅监听 `127.0.0.1:8080`。文件中的 `${GO_OMNI_HTTP_ADDR:-127.0.0.1:8080}` 设置地址;本地签名 URL 的外部地址通过 `GO_OMNI_STORAGE_PUBLIC_URL` 单独配置,始终以 `/storage/object` 结尾,不使用请求 Host 自动拼接可信链接。
|
||||||
|
|
||||||
## 日志
|
## 日志
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ curl -H 'Content-Type: application/json' -H 'X-Request-ID: example-request' \
|
|||||||
-d '{"name":"Example","email":"example@example.com"}' http://127.0.0.1:8080/users
|
-d '{"name":"Example","email":"example@example.com"}' http://127.0.0.1:8080/users
|
||||||
```
|
```
|
||||||
|
|
||||||
`GO_OMNI_LOG_LEVEL`、`GO_OMNI_LOG_FORMAT`、`GO_OMNI_LOG_FILE` 覆盖对应项;显式设置 `GO_OMNI_LOG_OUTPUT=stdout` 且未设置 LOG_FILE 时,切换到控制台并关闭文件轮转。慢请求阈值为 500ms,可通过 `GO_OMNI_SLOW_REQUEST_THRESHOLD` 调整。字段脱敏不扫描 msg/error 中的自由文本;不要把凭据或签名 URL 拼到日志中。
|
日志参数直接写在 `server.log`,例如 `level: debug`;也可显式写 `level: "${LOG_LEVEL:-info}"`。切换文件日志为控制台时,明确设置 `file: ""`、`output: stdout` 和 `rotation.enabled: false`,不会隐式重置其他字段。慢请求阈值使用 `server.slowRequestThreshold`,默认 500ms。字段脱敏不扫描 msg/error 自由文本;不要把凭据或签名 URL 拼到日志中。
|
||||||
|
|
||||||
{{if ne .Storage "none"}}## 文件示例
|
{{if ne .Storage "none"}}## 文件示例
|
||||||
|
|
||||||
@@ -66,18 +66,18 @@ curl -H "Authorization: Bearer $GO_OMNI_FILES_TOKEN" -H 'Content-Type: applicati
|
|||||||
|
|
||||||
响应包括随机 `data.key` 和 `data.request`(method/url/headers/expiresAt)。按返回的 method 和 headers 用原始文件字节上传到 URL;不能额外包 FormData。浏览器自动填写 Content-Length,其余签名 Header 原样发送;云桶需配置允许业务域名的 CORS。云端直传只绑定声明类型,实际内容需上传后验证;本地和 API 中转上传会检查允许类型及前缀嗅探结果。未完成内容验证前不要把暂存文件当作可信文件公开。
|
响应包括随机 `data.key` 和 `data.request`(method/url/headers/expiresAt)。按返回的 method 和 headers 用原始文件字节上传到 URL;不能额外包 FormData。浏览器自动填写 Content-Length,其余签名 Header 原样发送;云桶需配置允许业务域名的 CORS。云端直传只绑定声明类型,实际内容需上传后验证;本地和 API 中转上传会检查允许类型及前缀嗅探结果。未完成内容验证前不要把暂存文件当作可信文件公开。
|
||||||
|
|
||||||
| 参数 | 默认值 / 环境变量 |
|
| 文件字段 | 默认值 / 含义 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 大小 | 32 MiB / GO_OMNI_STORAGE_MAX_BYTES |
|
| `storage.maxBytes` | 32 MiB |
|
||||||
| 类型 | text/plain、image/png、image/jpeg、application/pdf / GO_OMNI_STORAGE_CONTENT_TYPES(逗号分隔) |
|
| `storage.contentTypes` | text/plain、image/png、image/jpeg、application/pdf;文件数组 |
|
||||||
| 链接有效期 | 5m / GO_OMNI_STORAGE_LINK_TTL(1s..1h) |
|
| `storage.linkTtl` | 5m,允许 1s..1h |
|
||||||
| 请求超时 | 30s / GO_OMNI_STORAGE_REQUEST_TIMEOUT(>0 且 <=5m) |
|
| `storage.requestTimeout` | 30s,大于 0 且不超过 5m |
|
||||||
| 本地目录 | var/files / GO_OMNI_STORAGE_ROOT |
|
| `storage.root` | var/files |
|
||||||
| 本地外部 URL | http://127.0.0.1:8080/storage/object / GO_OMNI_STORAGE_PUBLIC_URL |
|
| `storage.publicUrl` | http://127.0.0.1:8080/storage/object |
|
||||||
| 云配置 | GO_OMNI_STORAGE_ENDPOINT、REGION、BUCKET、ACCOUNT_ID、SECURE |
|
| `storage.endpoint/region/bucket/accountId/secure` | 对应平台连接配置 |
|
||||||
| 环境密钥 | GO_OMNI_FILES_TOKEN、GO_OMNI_STORAGE_SIGNING_KEY、GO_OMNI_STORAGE_ACCESS_KEY、SECRET_KEY、SESSION_TOKEN |
|
| `storage.filesToken/signingKey/accessKey/secretKey/sessionToken` | 文件显式引用环境变量提供凭据 |
|
||||||
|
|
||||||
全部 JSON/YAML 字段使用 lowerCamelCase。密钥字段仅允许环境变量,配置文件中写入会报未知字段;不要把运行环境文件提交到仓库。S3 的 endpoint 为 host[:port],OSS 的可选 endpoint 为 HTTPS origin;R2 从 accountId 派生官方 endpoint,region 为 auto。仅 S3/MinIO 的本地测试可设置 SECURE=false。
|
全部 JSON/YAML/XML 字段使用 lowerCamelCase。文件可使用固定值或显式环境引用,不按字段名自动推导环境变量;凭据建议使用 `${NAME}`,不把含秘密的配置提交到仓库。S3 endpoint 为 host[:port],OSS 可选 endpoint 为 HTTPS origin;R2 从 accountId 派生官方 endpoint,region 为 auto。仅 S3/MinIO 本地测试可设置 `storage.secure: false`。
|
||||||
|
|
||||||
错误响应:未授权 401、无 Content-Length 411、超限 413、类型不允许 415、长度不符/非法 key 400、对象不存在 404、存储超时 504,其余内部错误统一隐藏细节。底层签名入口遵循存储协议返回 201/403 等状态,流式传输且访问日志保留实际状态。
|
错误响应:未授权 401、无 Content-Length 411、超限 413、类型不允许 415、长度不符/非法 key 400、对象不存在 404、存储超时 504,其余内部错误统一隐藏细节。底层签名入口遵循存储协议返回 201/403 等状态,流式传输且访问日志保留实际状态。
|
||||||
|
|
||||||
|
|||||||
@@ -1,68 +1,30 @@
|
|||||||
# Non-secret settings only. Environment variables override these values.
|
# Only fields listed here override Default(). Environment names are explicit.
|
||||||
server:
|
server:
|
||||||
address: '127.0.0.1:8080'
|
# Optional cross-origin access: server.cors (disabled by default). See framework cors/README.md.
|
||||||
enableLogger: true
|
address: "${GO_OMNI_HTTP_ADDR:-127.0.0.1:8080}"
|
||||||
slowRequestThreshold: 500ms
|
transport: "${GO_OMNI_TRANSPORT:-nethttp}"
|
||||||
shutdownTimeout: 10s
|
|
||||||
{{if .API}} lifecycle:
|
|
||||||
stopTimeout: 5s
|
|
||||||
drainTimeout: 10s
|
|
||||||
cancelTimeout: 5s
|
|
||||||
endpoints:
|
|
||||||
liveness:
|
|
||||||
enabled: true
|
|
||||||
path: /health/live
|
|
||||||
responseMode: app
|
|
||||||
readiness:
|
|
||||||
enabled: true
|
|
||||||
path: /health/ready
|
|
||||||
responseMode: app
|
|
||||||
openapi:
|
|
||||||
enabled: true
|
|
||||||
path: /openapi.json
|
|
||||||
{{end}}
|
|
||||||
log:
|
|
||||||
service: {{printf "%q" .Module}}
|
|
||||||
level: info
|
|
||||||
format: {{.LogFormat}}
|
|
||||||
{{if eq .LogOutput "file"}} file: './var/log/server.log'
|
|
||||||
rotation:
|
|
||||||
enabled: true
|
|
||||||
maxSizeMb: 100
|
|
||||||
maxBackups: 10
|
|
||||||
maxAgeDays: 7
|
|
||||||
compress: true
|
|
||||||
{{else}} output: {{.LogOutput}}
|
|
||||||
{{end}} redactKeys: []
|
|
||||||
{{if ne .Storage "none"}}storage:
|
{{if ne .Storage "none"}}storage:
|
||||||
provider: {{.Storage}}
|
{{if not .API}} filesToken: "${GO_OMNI_FILES_TOKEN}"
|
||||||
maxBytes: 33554432
|
{{end}}{{if eq .Storage "local"}} root: "${GO_OMNI_STORAGE_ROOT:-var/files}"
|
||||||
contentTypes: ['text/plain', 'image/png', 'image/jpeg', 'application/pdf']
|
publicUrl: "${GO_OMNI_STORAGE_PUBLIC_URL:-http://127.0.0.1:8080/storage/object}"
|
||||||
linkTtl: 5m
|
signingKey: "${GO_OMNI_STORAGE_SIGNING_KEY}"
|
||||||
requestTimeout: 30s
|
{{else}} bucket: "${GO_OMNI_STORAGE_BUCKET}"
|
||||||
{{if eq .Storage "local"}} root: './var/files'
|
accessKey: "${GO_OMNI_STORAGE_ACCESS_KEY}"
|
||||||
publicUrl: 'http://127.0.0.1:8080/storage/object'
|
secretKey: "${GO_OMNI_STORAGE_SECRET_KEY}"
|
||||||
{{else}} bucket: '' # set GO_OMNI_STORAGE_BUCKET
|
sessionToken: "${GO_OMNI_STORAGE_SESSION_TOKEN:-}"
|
||||||
secure: true
|
{{if eq .Storage "r2"}} accountId: "${GO_OMNI_STORAGE_ACCOUNT_ID}"
|
||||||
{{if eq .Storage "r2"}} accountId: '' # set GO_OMNI_STORAGE_ACCOUNT_ID
|
{{else if eq .Storage "s3"}} endpoint: "${GO_OMNI_STORAGE_ENDPOINT}"
|
||||||
region: auto
|
region: "${GO_OMNI_STORAGE_REGION:-us-east-1}"
|
||||||
{{else if eq .Storage "s3"}} endpoint: '' # set GO_OMNI_STORAGE_ENDPOINT (host[:port])
|
{{else}} region: "${GO_OMNI_STORAGE_REGION:-cn-hangzhou}"
|
||||||
region: us-east-1
|
|
||||||
{{else}} region: cn-hangzhou
|
|
||||||
endpoint: '' # empty uses the official regional endpoint
|
|
||||||
{{end}}{{end}}{{end}}
|
{{end}}{{end}}{{end}}
|
||||||
|
|
||||||
{{if and .API (eq .Database "mysql")}}database:
|
{{if and .API (eq .Database "mysql")}}database:
|
||||||
host: 127.0.0.1
|
host: "${GO_OMNI_DB_HOST:-127.0.0.1}"
|
||||||
port: 3306
|
port: "${GO_OMNI_DB_PORT:-3306}"
|
||||||
database: '' # GO_OMNI_DB_DATABASE
|
database: "${GO_OMNI_DB_DATABASE}"
|
||||||
user: '' # GO_OMNI_DB_USER; password via GO_OMNI_DB_PASSWORD
|
user: "${GO_OMNI_DB_USER}"
|
||||||
tls:
|
password: "${GO_OMNI_DB_PASSWORD}"
|
||||||
mode: required
|
{{end}}
|
||||||
pool:
|
{{if and (not .API) (eq .Database "mysql")}}database:
|
||||||
maxOpenConns: 10
|
driver: mysql
|
||||||
maxIdleConns: 5
|
dsn: "${GO_OMNI_DB_DSN}"
|
||||||
connMaxLifetime: 30m
|
|
||||||
connMaxIdleTime: 5m
|
|
||||||
pingTimeout: 5s
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main(){
|
func main(){
|
||||||
path:=flag.String("config","etc/config.yaml","JSON/YAML settings; empty uses defaults and environment")
|
path:=flag.String("config","etc/config.yaml","configuration file; empty uses only Default()")
|
||||||
|
check:=flag.Bool("check-config",false,"validate without starting dependencies")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
if *check {if _,err:=settings.Load(*path);err!=nil{log.Fatal(err)};return}
|
||||||
if err:=run(*path);err!=nil{log.Fatal(err)}
|
if err:=run(*path);err!=nil{log.Fatal(err)}
|
||||||
}
|
}
|
||||||
func run(path string)(runErr error){
|
func run(path string)(runErr error){
|
||||||
@@ -31,7 +33,7 @@ func run(path string)(runErr error){
|
|||||||
if err!=nil{return err}
|
if err!=nil{return err}
|
||||||
defer func(){runErr=errors.Join(runErr,logger.Close())}()
|
defer func(){runErr=errors.Join(runErr,logger.Close())}()
|
||||||
logx.SetDefault(logger)
|
logx.SetDefault(logger)
|
||||||
services,err:=servicecontext.New(ctx,logger)
|
services,err:=servicecontext.New(ctx,logger,cfg)
|
||||||
if err!=nil{return err}
|
if err!=nil{return err}
|
||||||
defer func(){runErr=errors.Join(runErr,services.Close())}()
|
defer func(){runErr=errors.Join(runErr,services.Close())}()
|
||||||
{{if ne .Storage "none"}}if err=services.InitStorage(ctx,cfg.Storage);err!=nil{return err}{{end}}
|
{{if ne .Storage "none"}}if err=services.InitStorage(ctx,cfg.Storage);err!=nil{return err}{{end}}
|
||||||
|
|||||||
@@ -3,21 +3,19 @@ package settings
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
{{if ne .Storage "none"}}"fmt"
|
{{if ne .Storage "none"}}
|
||||||
{{if ne .Storage "r2"}}"net/url"{{else}}"regexp"{{end}}
|
{{if ne .Storage "r2"}}"net/url"{{else}}"regexp"{{end}}
|
||||||
"strconv"
|
|
||||||
"git.yuntuo.hk/k2safe/go-omni/storage"{{end}}
|
"git.yuntuo.hk/k2safe/go-omni/storage"{{end}}
|
||||||
{{if and .API (eq .Database "mysql")}}"git.yuntuo.hk/k2safe/go-omni/database"{{end}}
|
{{if eq .Database "mysql"}}"git.yuntuo.hk/k2safe/go-omni/database"{{end}}
|
||||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
{{if eq .LogOutput "file"}}"git.yuntuo.hk/k2safe/go-omni/logx"{{end}}
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Server config.Config `json:"server"`
|
Server config.Config `json:"server"`
|
||||||
{{if and .API (eq .Database "mysql")}}Database database.Config `json:"database"`{{end}}
|
{{if eq .Database "mysql"}}Database database.Config `json:"database"`{{end}}
|
||||||
{{if ne .Storage "none"}}Storage StorageConfig `json:"storage"`{{end}}
|
{{if ne .Storage "none"}}Storage StorageConfig `json:"storage"`{{end}}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +29,8 @@ func Default() Config {
|
|||||||
{{if eq .LogOutput "file"}}server.Log.File="./var/log/server.log"
|
{{if eq .LogOutput "file"}}server.Log.File="./var/log/server.log"
|
||||||
server.Log.Rotation=logx.RotationConfig{Enabled:true,MaxSizeMB:100,MaxBackups:10,MaxAgeDays:7,Compress:true}
|
server.Log.Rotation=logx.RotationConfig{Enabled:true,MaxSizeMB:100,MaxBackups:10,MaxAgeDays:7,Compress:true}
|
||||||
{{else}}server.Log.Output={{printf "%q" .LogOutput}}{{end}}
|
{{else}}server.Log.Output={{printf "%q" .LogOutput}}{{end}}
|
||||||
return Config{Server:server{{if and .API (eq .Database "mysql")}},Database:database.DefaultConfig(){{end}}{{if ne .Storage "none"}},Storage:StorageConfig{
|
{{if eq .Database "mysql"}}db:=database.DefaultConfig();db.Driver="mysql"{{end}}
|
||||||
|
return Config{Server:server{{if eq .Database "mysql"}},Database:db{{end}}{{if ne .Storage "none"}},Storage:StorageConfig{
|
||||||
Provider:{{printf "%q" .Storage}}, Root:"./var/files", PublicURL:"http://127.0.0.1:8080/storage/object",
|
Provider:{{printf "%q" .Storage}}, Root:"./var/files", PublicURL:"http://127.0.0.1:8080/storage/object",
|
||||||
Secure:true, MaxBytes:32<<20, ContentTypes:[]string{"text/plain","image/png","image/jpeg","application/pdf"},
|
Secure:true, MaxBytes:32<<20, ContentTypes:[]string{"text/plain","image/png","image/jpeg","application/pdf"},
|
||||||
LinkTTL:5*time.Minute, RequestTimeout:30*time.Second,
|
LinkTTL:5*time.Minute, RequestTimeout:30*time.Second,
|
||||||
@@ -41,27 +40,10 @@ func Default() Config {
|
|||||||
}{{end}}}
|
}{{end}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load uses defaults -> strict YAML/JSON -> nonempty environment variables.
|
// Load uses Default(), the selected file's explicit references, and Validate.
|
||||||
// Pass an empty path to use only defaults and environment.
|
func Load(path string)(Config,error){return config.LoadProject(path,Default())}
|
||||||
func Load(path string)(Config,error) {
|
|
||||||
cfg:=Default()
|
|
||||||
var err error
|
|
||||||
if path!="" { cfg,err=config.DecodeFile(path,cfg);if err!=nil{return Config{},err} }
|
|
||||||
cfg.Server,err=config.ApplyEnv(cfg.Server,"")
|
|
||||||
if err!=nil{return Config{},err}
|
|
||||||
// A stream explicitly selected by environment overrides a file profile.
|
|
||||||
if output:=strings.TrimSpace(os.Getenv("GO_OMNI_LOG_OUTPUT"));output!="" && strings.TrimSpace(os.Getenv("GO_OMNI_LOG_FILE"))=="" {
|
|
||||||
cfg.Server.Log.File="";cfg.Server.Log.Rotation=logx.RotationConfig{}
|
|
||||||
}
|
|
||||||
{{if ne .Storage "none"}}
|
|
||||||
if err=cfg.Storage.applyEnv();err!=nil{return Config{},err}
|
|
||||||
{{end}}
|
|
||||||
{{if and .API (eq .Database "mysql")}}cfg.Database,err=database.ApplyEnv(cfg.Database,"");if err!=nil{return Config{},err}{{end}}
|
|
||||||
if err=cfg.Validate();err!=nil{return Config{},err}
|
|
||||||
return cfg,nil
|
|
||||||
}
|
|
||||||
func (c Config) Validate()error {
|
func (c Config) Validate()error {
|
||||||
{{if and .API (eq .Database "mysql")}}
|
{{if eq .Database "mysql"}}
|
||||||
if err:=c.Database.Validate();err!=nil{return err}
|
if err:=c.Database.Validate();err!=nil{return err}
|
||||||
if c.Database.Driver!="mysql" || c.Database.MaxOpenConns<1 {return errors.New("database requires driver mysql and a positive maxOpenConns")}
|
if c.Database.Driver!="mysql" || c.Database.MaxOpenConns<1 {return errors.New("database requires driver mysql and a positive maxOpenConns")}
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -85,45 +67,27 @@ type StorageConfig struct {
|
|||||||
ContentTypes []string `json:"contentTypes"`
|
ContentTypes []string `json:"contentTypes"`
|
||||||
LinkTTL time.Duration `json:"linkTtl"`
|
LinkTTL time.Duration `json:"linkTtl"`
|
||||||
RequestTimeout time.Duration `json:"requestTimeout"`
|
RequestTimeout time.Duration `json:"requestTimeout"`
|
||||||
// Secrets are environment-only and cannot be populated by config files.
|
// Use explicit environment references in the configuration file for secrets.
|
||||||
FilesToken string `json:"-"`
|
FilesToken string `json:"filesToken"`
|
||||||
SigningKey string `json:"-"`
|
SigningKey string `json:"signingKey"`
|
||||||
AccessKey string `json:"-"`
|
AccessKey string `json:"accessKey"`
|
||||||
SecretKey string `json:"-"`
|
SecretKey string `json:"secretKey"`
|
||||||
SessionToken string `json:"-"`
|
SessionToken string `json:"sessionToken"`
|
||||||
}
|
}
|
||||||
func (s StorageConfig) Policy()storage.UploadPolicy{return storage.UploadPolicy{MaxBytes:s.MaxBytes,ContentTypes:s.ContentTypes}}
|
func (s StorageConfig) Policy()storage.UploadPolicy{return storage.UploadPolicy{MaxBytes:s.MaxBytes,ContentTypes:s.ContentTypes}}
|
||||||
func (s *StorageConfig) applyEnv()error {
|
|
||||||
values:=map[string]*string{
|
|
||||||
"GO_OMNI_STORAGE_ROOT":&s.Root,"GO_OMNI_STORAGE_PUBLIC_URL":&s.PublicURL,
|
|
||||||
"GO_OMNI_STORAGE_ENDPOINT":&s.Endpoint,"GO_OMNI_STORAGE_REGION":&s.Region,
|
|
||||||
"GO_OMNI_STORAGE_BUCKET":&s.Bucket,"GO_OMNI_STORAGE_ACCOUNT_ID":&s.AccountID,
|
|
||||||
"GO_OMNI_STORAGE_SIGNING_KEY":&s.SigningKey,"GO_OMNI_FILES_TOKEN":&s.FilesToken,
|
|
||||||
"GO_OMNI_STORAGE_ACCESS_KEY":&s.AccessKey,"GO_OMNI_STORAGE_SECRET_KEY":&s.SecretKey,
|
|
||||||
"GO_OMNI_STORAGE_SESSION_TOKEN":&s.SessionToken,
|
|
||||||
}
|
|
||||||
for name,target:=range values {if value:=strings.TrimSpace(os.Getenv(name));value!=""{*target=value}}
|
|
||||||
for name,target:=range map[string]*time.Duration{"GO_OMNI_STORAGE_LINK_TTL":&s.LinkTTL,"GO_OMNI_STORAGE_REQUEST_TIMEOUT":&s.RequestTimeout} {
|
|
||||||
if value:=strings.TrimSpace(os.Getenv(name));value!="" {v,err:=time.ParseDuration(value);if err!=nil{return fmt.Errorf("%s must be a duration",name)};*target=v}
|
|
||||||
}
|
|
||||||
if value:=os.Getenv("GO_OMNI_STORAGE_MAX_BYTES");value!=""{v,err:=strconv.ParseInt(value,10,64);if err!=nil{return errors.New("GO_OMNI_STORAGE_MAX_BYTES must be an integer")};s.MaxBytes=v}
|
|
||||||
if value:=os.Getenv("GO_OMNI_STORAGE_SECURE");value!=""{v,err:=strconv.ParseBool(value);if err!=nil{return errors.New("GO_OMNI_STORAGE_SECURE must be a boolean")};s.Secure=v}
|
|
||||||
if value:=strings.TrimSpace(os.Getenv("GO_OMNI_STORAGE_CONTENT_TYPES"));value!="" {s.ContentTypes=strings.Split(value,",");for i:=range s.ContentTypes{s.ContentTypes[i]=strings.TrimSpace(s.ContentTypes[i])}}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
func (s StorageConfig) Validate()error {
|
func (s StorageConfig) Validate()error {
|
||||||
if s.Provider!={{printf "%q" .Storage}} {return errors.New("storage.provider must match the adapter selected at generation")}
|
if s.Provider!={{printf "%q" .Storage}} {return errors.New("storage.provider must match the adapter selected at generation")}
|
||||||
if err:=s.Policy().Validate();err!=nil{return err}
|
if err:=s.Policy().Validate();err!=nil{return err}
|
||||||
if s.LinkTTL<time.Second || s.LinkTTL>time.Hour{return errors.New("storage.linkTtl must be 1s..1h")}
|
if s.LinkTTL<time.Second || s.LinkTTL>time.Hour{return errors.New("storage.linkTtl must be 1s..1h")}
|
||||||
if s.RequestTimeout<=0 || s.RequestTimeout>5*time.Minute{return errors.New("storage.requestTimeout must be >0 and <=5m")}
|
if s.RequestTimeout<=0 || s.RequestTimeout>5*time.Minute{return errors.New("storage.requestTimeout must be >0 and <=5m")}
|
||||||
{{if not .API}}if len(s.FilesToken)<32 || strings.ContainsAny(s.FilesToken," \t\r\n") {return errors.New("set GO_OMNI_FILES_TOKEN to a token of at least 32 bytes without whitespace")}{{end}}
|
{{if not .API}}if len(s.FilesToken)<32 || strings.ContainsAny(s.FilesToken," \t\r\n") {return errors.New("storage.filesToken requires at least 32 bytes without whitespace")}{{end}}
|
||||||
{{if eq .Storage "local"}}
|
{{if eq .Storage "local"}}
|
||||||
if s.Root=="" {return errors.New("storage.root is required")}
|
if s.Root=="" {return errors.New("storage.root is required")}
|
||||||
u,err:=url.Parse(s.PublicURL)
|
u,err:=url.Parse(s.PublicURL)
|
||||||
if err!=nil || u.Host=="" || u.Hostname()=="" || (u.Scheme!="http" && u.Scheme!="https") || u.User!=nil || u.RawQuery!="" || u.Fragment!="" || u.Path!="/storage/object" {return errors.New("storage.publicUrl must be an absolute HTTP(S) URL ending in /storage/object")}
|
if err!=nil || u.Host=="" || u.Hostname()=="" || (u.Scheme!="http" && u.Scheme!="https") || u.User!=nil || u.RawQuery!="" || u.Fragment!="" || u.Path!="/storage/object" {return errors.New("storage.publicUrl must be an absolute HTTP(S) URL ending in /storage/object")}
|
||||||
if len(s.SigningKey)<32 {return errors.New("set GO_OMNI_STORAGE_SIGNING_KEY to a stable secret of at least 32 bytes")}
|
if len(s.SigningKey)<32 {return errors.New("storage.signingKey requires a stable secret of at least 32 bytes")}
|
||||||
{{else}}
|
{{else}}
|
||||||
if s.Bucket=="" || s.Region=="" || s.AccessKey=="" || s.SecretKey=="" {return errors.New("storage bucket, region and GO_OMNI_STORAGE_ACCESS_KEY/SECRET_KEY are required")}
|
if s.Bucket=="" || s.Region=="" || s.AccessKey=="" || s.SecretKey=="" {return errors.New("storage bucket, region, accessKey and secretKey are required")}
|
||||||
{{if eq .Storage "r2"}}
|
{{if eq .Storage "r2"}}
|
||||||
if !regexp.MustCompile(`^[a-fA-F0-9]{32}$`).MatchString(s.AccountID) || s.Region!="auto" || !s.Secure {return errors.New("R2 requires a 32-hex accountId, region auto and secure=true")}
|
if !regexp.MustCompile(`^[a-fA-F0-9]{32}$`).MatchString(s.AccountID) || s.Region!="auto" || !s.Secure {return errors.New("R2 requires a 32-hex accountId, region auto and secure=true")}
|
||||||
if s.Endpoint!=""{return errors.New("this R2 profile derives endpoint from accountId; customize servicecontext for a jurisdiction-specific endpoint")}
|
if s.Endpoint!=""{return errors.New("this R2 profile derives endpoint from accountId; customize servicecontext for a jurisdiction-specific endpoint")}
|
||||||
|
|||||||
@@ -21,18 +21,18 @@ func environment(t *testing.T){
|
|||||||
}
|
}
|
||||||
func TestSettingsPrecedenceAndStrictness(t *testing.T){
|
func TestSettingsPrecedenceAndStrictness(t *testing.T){
|
||||||
environment(t)
|
environment(t)
|
||||||
|
{{if eq .Database "mysql"}}t.Setenv("GO_OMNI_DB_DSN","test:secret@tcp(localhost)/demo"){{end}}
|
||||||
|
source,err:=os.ReadFile("../../etc/config.yaml");if err!=nil{t.Fatal(err)}
|
||||||
path:=filepath.Join(t.TempDir(),"config.yaml")
|
path:=filepath.Join(t.TempDir(),"config.yaml")
|
||||||
if err:=os.WriteFile(path,[]byte("server:\n address: '127.0.0.1:8123'\n log:\n level: debug\n"),0600);err!=nil{t.Fatal(err)}
|
source=[]byte(strings.Replace(string(source),"${GO_OMNI_HTTP_ADDR:-127.0.0.1:8080}","127.0.0.1:8123",1))
|
||||||
|
if err=os.WriteFile(path,source,0600);err!=nil{t.Fatal(err)}
|
||||||
t.Setenv("GO_OMNI_HTTP_ADDR","127.0.0.1:9123")
|
t.Setenv("GO_OMNI_HTTP_ADDR","127.0.0.1:9123")
|
||||||
t.Setenv("GO_OMNI_LOG_OUTPUT","stdout")
|
t.Setenv("GO_OMNI_LOG_OUTPUT","stdout")
|
||||||
cfg,err:=Load(path);if err!=nil{t.Fatal(err)}
|
cfg,err:=Load(path);if err!=nil{t.Fatal(err)}
|
||||||
if cfg.Server.Address!="127.0.0.1:9123" || cfg.Server.Log.Level!="debug" || cfg.Server.Log.Output!="stdout" || cfg.Server.Log.File!="" || cfg.Server.Log.Rotation.Enabled {t.Fatal("configuration precedence failed")}
|
initial:=Default()
|
||||||
for _,body:=range []string{"unexpected: true","server:\n slowRequestThreshold: -1s","server:\n log:\n format: invalid"{{if ne .Storage "none"}},"storage:\n filesToken: forbidden","storage:\n maxBytes: -1","storage:\n provider: unsupported"{{end}}}{
|
if cfg.Server.Address!="127.0.0.1:8123"||cfg.Server.Log.Output!=initial.Server.Log.Output||cfg.Server.Log.File!=initial.Server.Log.File {t.Fatal("implicit overwrite")}
|
||||||
if err:=os.WriteFile(path,[]byte(body),0600);err!=nil{t.Fatal(err)}
|
for _,body:=range []string{"unexpected: true","server:\n slowRequestThreshold: -1s","server:\n log:\n format: invalid"}{
|
||||||
if _,err:=Load(path);err==nil{t.Fatal("invalid configuration accepted")}
|
if err=os.WriteFile(path,[]byte(body),0600);err!=nil{t.Fatal(err)}
|
||||||
|
if _,err=Load(path);err==nil{t.Fatal("invalid configuration accepted")}
|
||||||
}
|
}
|
||||||
{{if ne .Storage "none"}}
|
|
||||||
t.Setenv("GO_OMNI_FILES_TOKEN","")
|
|
||||||
if _,err:=Load("");err==nil{t.Fatal("missing file token accepted")}
|
|
||||||
{{end}}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,11 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
|||||||
// Source builds can carry an unpublished VCS version (including +dirty).
|
// Source builds can carry an unpublished VCS version (including +dirty).
|
||||||
// Keep this fallback in sync with the next release; installed CLIs use their
|
// Keep this fallback in sync with the next release; installed CLIs use their
|
||||||
// downloaded module version, which also has a module checksum.
|
// downloaded module version, which also has a module checksum.
|
||||||
const defaultFrameworkVersion = "v0.21.0-preview"
|
const defaultFrameworkVersion = "v0.30.0-preview"
|
||||||
|
|
||||||
// This target is not a claim that the release already exists. Source users
|
// This target is not a claim that the release already exists. Source users
|
||||||
// select a local framework checkout until the module templates are published.
|
// select a local framework checkout until the module templates are published.
|
||||||
const modulesFrameworkVersion = "v0.7.0-preview"
|
const modulesFrameworkVersion = "v0.22.0-preview"
|
||||||
|
|
||||||
func installedFrameworkVersion() string {
|
func installedFrameworkVersion() string {
|
||||||
info, _ := debug.ReadBuildInfo()
|
info, _ := debug.ReadBuildInfo()
|
||||||
|
|||||||
162
cmd/go-omni/workspace_test.go
Normal file
162
cmd/go-omni/workspace_test.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The same consumer is exercised with a source-built CLI before release and a
|
||||||
|
// downloaded CLI after release. Project modules are always independently built.
|
||||||
|
func TestMixedWorkspaceConsumer(t *testing.T) {
|
||||||
|
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
|
||||||
|
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
|
||||||
|
}
|
||||||
|
root := t.TempDir()
|
||||||
|
cli := os.Getenv("GO_OMNI_RELEASE_CLI")
|
||||||
|
local := cli == ""
|
||||||
|
framework, err := filepath.Abs("../..")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if local {
|
||||||
|
cli = filepath.Join(t.TempDir(), "go-omni")
|
||||||
|
workspaceCommand(t, "", nil, false, "go", "build", "-o", cli, ".")
|
||||||
|
}
|
||||||
|
modulePath := "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
for _, kind := range []string{"api", "project"} {
|
||||||
|
dir := filepath.Join(root, kind)
|
||||||
|
args := []string{kind, "new", dir, "--module", "example.com/" + kind}
|
||||||
|
if local {
|
||||||
|
args = append(args, "--framework-dir", framework)
|
||||||
|
}
|
||||||
|
workspaceCommand(t, root, nil, false, cli, args...)
|
||||||
|
workspaceCommand(t, dir, nil, false, "go", "mod", "tidy")
|
||||||
|
data := workspaceCommand(t, dir, nil, false, "go", "list", "-m", "-json", modulePath)
|
||||||
|
if !local {
|
||||||
|
var dep struct {
|
||||||
|
Version, Sum string
|
||||||
|
Replace any
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(data), &dep); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if dep.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dep.Replace != nil || dep.Sum == "" {
|
||||||
|
t.Fatal("consumer is not a fixed remote dependency", dep)
|
||||||
|
}
|
||||||
|
t.Logf("%s remote framework %s checksum %s; no local replace", kind, dep.Version, dep.Sum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write := func(name, data string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(filepath.Join(root, name), []byte(data), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write("omni.workspace.json", `{"services":[{"dir":"api","kind":"api"},{"dir":"project","kind":"project"}]}`)
|
||||||
|
for _, op := range []string{"format", "generate", "check"} {
|
||||||
|
output := workspaceCommand(t, root, nil, false, cli, "workspace", op)
|
||||||
|
if op == "format" && !strings.Contains(output, "SKIP ") {
|
||||||
|
t.Fatal("missing project format skip", output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Run the actual Go multi-module toolchain independently of generation.
|
||||||
|
workspaceCommand(t, root, nil, false, "go", "work", "init", "./api", "./project")
|
||||||
|
work, err := filepath.EvalSymlinks(filepath.Join(root, "go.work"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
workspaceCommand(t, root, []string{"GOWORK=" + work}, false, "go", "test", "-race", "./api/...", "./project/...")
|
||||||
|
workspaceCommand(t, root, []string{"GOWORK=" + work}, false, "go", "build", "./api/...", "./project/...")
|
||||||
|
before := workspaceSnapshot(t, root)
|
||||||
|
workspaceCommand(t, root, nil, false, cli, "workspace", "check")
|
||||||
|
if !reflect.DeepEqual(before, workspaceSnapshot(t, root)) {
|
||||||
|
t.Fatal("check modified Go workspace/module files")
|
||||||
|
}
|
||||||
|
// Omitted kinds discover both released manifests as well.
|
||||||
|
write("omni.workspace.json", `{"services":[{"dir":"api"},{"dir":"project"}]}`)
|
||||||
|
workspaceCommand(t, root, nil, false, cli, "workspace", "check")
|
||||||
|
originals := map[string]string{}
|
||||||
|
for _, kind := range []string{"api", "project"} {
|
||||||
|
name := kind + "/main.go"
|
||||||
|
data, err := os.ReadFile(filepath.Join(root, name))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
originals[name] = string(data)
|
||||||
|
write(name, string(data)+"\n// intentional drift\n")
|
||||||
|
}
|
||||||
|
before = workspaceSnapshot(t, root)
|
||||||
|
output := workspaceCommand(t, root, nil, true, cli, "workspace", "check")
|
||||||
|
if strings.Count(output, "FAIL ") != 2 {
|
||||||
|
t.Fatal("did not aggregate both projects", output)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(before, workspaceSnapshot(t, root)) {
|
||||||
|
t.Fatal("failed check wrote files")
|
||||||
|
}
|
||||||
|
// The healthy project's missing output must stay missing when another fails.
|
||||||
|
write("api/main.go", originals["api/main.go"])
|
||||||
|
if err := os.Remove(filepath.Join(root, "api/main.go")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
before = workspaceSnapshot(t, root)
|
||||||
|
workspaceCommand(t, root, nil, true, cli, "workspace", "generate")
|
||||||
|
if !reflect.DeepEqual(before, workspaceSnapshot(t, root)) {
|
||||||
|
t.Fatal("partial workspace generation")
|
||||||
|
}
|
||||||
|
write("project/main.go", originals["project/main.go"])
|
||||||
|
workspaceCommand(t, root, nil, false, cli, "workspace", "generate")
|
||||||
|
workspaceCommand(t, root, nil, false, cli, "workspace", "check")
|
||||||
|
}
|
||||||
|
func workspaceCommand(t *testing.T, dir string, extra []string, wantFailure bool, name string, args ...string) string {
|
||||||
|
t.Helper()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
|
if dir != "" {
|
||||||
|
cmd.Dir = dir
|
||||||
|
}
|
||||||
|
cmd.Env = append(append(os.Environ(), "GOWORK=off"), extra...)
|
||||||
|
data, err := cmd.CombinedOutput()
|
||||||
|
if wantFailure {
|
||||||
|
exit, ok := err.(*exec.ExitError)
|
||||||
|
if !ok || exit.ExitCode() != 1 {
|
||||||
|
t.Fatalf("expected exit 1 from %s %v: %v\n%s", name, args, err, data)
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
t.Fatalf("%s %v: %v\n%s", name, args, err, data)
|
||||||
|
}
|
||||||
|
t.Logf("%s %v\n%s", name, args, data)
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
func workspaceSnapshot(t *testing.T, root string) map[string]string {
|
||||||
|
t.Helper()
|
||||||
|
files := map[string]string{}
|
||||||
|
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !d.IsDir() {
|
||||||
|
data, e := os.ReadFile(path)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
rel, e := filepath.Rel(root, path)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
files[rel] = string(data)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return files
|
||||||
|
}
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestUnifiedEnvAliases(t *testing.T) {
|
|
||||||
type project struct {
|
|
||||||
Server Config `json:"server"`
|
|
||||||
Label string `json:"label"`
|
|
||||||
}
|
|
||||||
base := project{Server: Default(), Label: "base"}
|
|
||||||
for _, policy := range []string{"allow", "ignore", "reject"} {
|
|
||||||
t.Run(policy, func(t *testing.T) {
|
|
||||||
env := map[string]string{"TEST_HTTP_ADDR": "127.0.0.1:8099", "TEST_LOG_SERVICE": "", "TEST_SERVICE": "", "TEST_LABEL": ""}
|
|
||||||
opts := EnvOptions{Prefix: "TEST_", Empty: policy, Aliases: ServerEnvAliases("TEST_", "server"), Lookup: func(k string) (string, bool) { v, ok := env[k]; return v, ok }}
|
|
||||||
got, err := OverlayEnv(base, opts)
|
|
||||||
if policy == "reject" {
|
|
||||||
if err == nil || !strings.Contains(err.Error(), "empty") {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got.Server.Address != "127.0.0.1:8099" {
|
|
||||||
t.Fatal("alias ignored")
|
|
||||||
}
|
|
||||||
if policy == "allow" && got.Label != "" || policy == "ignore" && got.Label != "base" {
|
|
||||||
t.Fatal("empty policy differs")
|
|
||||||
}
|
|
||||||
env["TEST_SERVER_ADDRESS"] = "127.0.0.1:9001"
|
|
||||||
env["TEST_HTTP_READ_TIMEOUT"] = "3s"
|
|
||||||
env["TEST_ENABLE_LOGGER"] = "false"
|
|
||||||
env["TEST_TRUSTED_PROXIES"] = `["127.0.0.1"]`
|
|
||||||
got, err = OverlayEnv(base, opts)
|
|
||||||
if err != nil || got.Server.Address != "127.0.0.1:9001" || got.Server.HTTP.ReadTimeout != 3*time.Second || got.Server.EnableLogger || len(got.Server.TrustedProxies) != 1 {
|
|
||||||
t.Fatal(got, err)
|
|
||||||
}
|
|
||||||
env["TEST_SERVER_ADDRESS"] = ""
|
|
||||||
got, err = OverlayEnv(base, opts)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
expected := ""
|
|
||||||
if policy == "ignore" {
|
|
||||||
expected = base.Server.Address
|
|
||||||
}
|
|
||||||
if got.Server.Address != expected {
|
|
||||||
t.Fatal("empty canonical unexpectedly fell through to alias")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if base.Server.Address != ":8080" || base.Label != "base" {
|
|
||||||
t.Fatal("defaults mutated")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func TestAliasTypeErrorsDoNotExposeValue(t *testing.T) {
|
|
||||||
opts := EnvOptions{Prefix: "X_", Empty: "reject", Aliases: ServerEnvAliases("X_", ""), Lookup: func(k string) (string, bool) { return "private-invalid", k == "X_HTTP_READ_TIMEOUT" }}
|
|
||||||
if _, err := OverlayEnv(Default(), opts); err == nil || strings.Contains(err.Error(), "private-invalid") {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -59,6 +59,20 @@ func (b *documentBinder) bind(v any, dst reflect.Value, path string, depth int,
|
|||||||
if err := b.step(path, depth); err != nil {
|
if err := b.step(path, depth); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if text, ok := v.(resolvedText); ok {
|
||||||
|
if dst.Kind() != reflect.Pointer && dst.Type() != durationType && dst.CanAddr() && !dst.Addr().Type().Implements(jsonUnmarshalerType) && !dst.Addr().Type().Implements(textType) {
|
||||||
|
switch dst.Kind() {
|
||||||
|
case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Float32, reflect.Float64:
|
||||||
|
if err := parseTextValue(dst, string(text)); err != nil {
|
||||||
|
return b.doc.diagnostic(path, "expected a valid "+dst.Type().String(), err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dst.Kind() != reflect.Pointer {
|
||||||
|
v = string(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return b.decode(nil, dst, path)
|
return b.decode(nil, dst, path)
|
||||||
} // Preserve encoding/json null semantics.
|
} // Preserve encoding/json null semantics.
|
||||||
@@ -78,7 +92,7 @@ func (b *documentBinder) bind(v any, dst reflect.Value, path string, depth int,
|
|||||||
default:
|
default:
|
||||||
return b.decode(v, dst, path)
|
return b.decode(v, dst, path)
|
||||||
}
|
}
|
||||||
if err := parseEnvValue(dst, strings.TrimSpace(raw)); err != nil {
|
if err := parseTextValue(dst, strings.TrimSpace(raw)); err != nil {
|
||||||
return b.doc.diagnostic(path, "expected a duration such as 5s", err)
|
return b.doc.diagnostic(path, "expected a duration such as 5s", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -102,7 +116,7 @@ func (b *documentBinder) bind(v any, dst reflect.Value, path string, depth int,
|
|||||||
}
|
}
|
||||||
switch dst.Kind() {
|
switch dst.Kind() {
|
||||||
case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Float32, reflect.Float64:
|
case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Float32, reflect.Float64:
|
||||||
if err := parseEnvValue(dst, strings.TrimSpace(string(text))); err != nil {
|
if err := parseTextValue(dst, strings.TrimSpace(string(text))); err != nil {
|
||||||
return b.doc.diagnostic(path, "expected a valid "+dst.Type().String(), err)
|
return b.doc.diagnostic(path, "expected a valid "+dst.Type().String(), err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -143,7 +157,7 @@ func (b *documentBinder) bind(v any, dst reflect.Value, path string, depth int,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
return b.doc.diagnostic(child, "unknown field", nil)
|
return b.doc.diagnostic(child, unknownFieldReason(name, fields), nil)
|
||||||
}
|
}
|
||||||
if used[canonical] {
|
if used[canonical] {
|
||||||
return b.doc.diagnostic(child, "duplicate field binding", nil)
|
return b.doc.diagnostic(child, "duplicate field binding", nil)
|
||||||
@@ -237,6 +251,8 @@ func (b *documentBinder) dynamic(v any, path string, depth int) (any, error) {
|
|||||||
switch x := v.(type) {
|
switch x := v.(type) {
|
||||||
case Text:
|
case Text:
|
||||||
return string(x), nil
|
return string(x), nil
|
||||||
|
case resolvedText:
|
||||||
|
return string(x), nil
|
||||||
case map[string]any:
|
case map[string]any:
|
||||||
out := make(map[string]any, len(x))
|
out := make(map[string]any, len(x))
|
||||||
for _, k := range sortedKeys(x) {
|
for _, k := range sortedKeys(x) {
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.yuntuo.hk/k2safe/go-omni/contract"
|
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/cors"
|
||||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/i18n/httpi18n"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||||
@@ -28,6 +30,7 @@ type TLSConfig struct {
|
|||||||
|
|
||||||
// Config controls the default runtime behavior of an App.
|
// Config controls the default runtime behavior of an App.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
CORS cors.Config `json:"cors"`
|
||||||
Request contract.RequestPolicy `json:"request"`
|
Request contract.RequestPolicy `json:"request"`
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
Transport string `json:"transport"`
|
Transport string `json:"transport"`
|
||||||
@@ -45,6 +48,8 @@ type Config struct {
|
|||||||
Response response.Config `json:"response"`
|
Response response.Config `json:"response"`
|
||||||
ResponseEncoder response.Encoder `json:"-"`
|
ResponseEncoder response.Encoder `json:"-"`
|
||||||
LogInstance *logx.Logger `json:"-"`
|
LogInstance *logx.Logger `json:"-"`
|
||||||
|
LanguagePolicy *httpi18n.Policy `json:"-"`
|
||||||
|
ErrorLocalizer apierrors.Localizer `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Default() Config {
|
func Default() Config {
|
||||||
@@ -61,6 +66,9 @@ func Default() Config {
|
|||||||
|
|
||||||
// Validate checks configuration values before an application starts.
|
// Validate checks configuration values before an application starts.
|
||||||
func (c Config) Validate() error {
|
func (c Config) Validate() error {
|
||||||
|
if err := c.CORS.Validate(); err != nil {
|
||||||
|
return AtField("cors", err)
|
||||||
|
}
|
||||||
if err := c.Lifecycle.Validate(); err != nil {
|
if err := c.Lifecycle.Validate(); err != nil {
|
||||||
return AtField("lifecycle", err)
|
return AtField("lifecycle", err)
|
||||||
}
|
}
|
||||||
@@ -188,12 +196,14 @@ func WithTrustedProxies(proxies ...string) Option {
|
|||||||
// WithConfig applies a complete configuration loaded from a file or
|
// WithConfig applies a complete configuration loaded from a file or
|
||||||
// environment. Later options can still override individual fields.
|
// environment. Later options can still override individual fields.
|
||||||
func WithConfig(settings Config) Option {
|
func WithConfig(settings Config) Option {
|
||||||
|
settings.CORS = settings.CORS.Clone()
|
||||||
settings.Response = settings.Response.Clone()
|
settings.Response = settings.Response.Clone()
|
||||||
settings.Errors = settings.Errors.Clone()
|
settings.Errors = settings.Errors.Clone()
|
||||||
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
|
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
|
||||||
settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...)
|
settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...)
|
||||||
return func(cfg *Config) {
|
return func(cfg *Config) {
|
||||||
*cfg = settings
|
*cfg = settings
|
||||||
|
cfg.CORS = settings.CORS.Clone()
|
||||||
cfg.Response = settings.Response.Clone()
|
cfg.Response = settings.Response.Clone()
|
||||||
cfg.Errors = settings.Errors.Clone()
|
cfg.Errors = settings.Errors.Clone()
|
||||||
cfg.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
|
cfg.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
|
||||||
@@ -201,6 +211,12 @@ func WithConfig(settings Config) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithCORS installs an app-local CORS policy, before routing and authentication.
|
||||||
|
func WithCORS(settings cors.Config) Option {
|
||||||
|
settings = settings.Clone()
|
||||||
|
return func(cfg *Config) { cfg.CORS = settings.Clone() }
|
||||||
|
}
|
||||||
|
|
||||||
// WithResponse configures this application's default JSON envelope.
|
// WithResponse configures this application's default JSON envelope.
|
||||||
func WithResponse(settings response.Config) Option {
|
func WithResponse(settings response.Config) Option {
|
||||||
settings = settings.Clone()
|
settings = settings.Clone()
|
||||||
@@ -222,7 +238,8 @@ func WithLogConfig(settings logx.Config) Option {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithShutdownTimeout controls how long RunContext waits for active requests.
|
// WithShutdownTimeout controls the graceful HTTP drain budget. After timeout,
|
||||||
|
// HTTP.ShutdownCancelTimeout separately bounds the wait for canceled handlers.
|
||||||
func WithShutdownTimeout(timeout time.Duration) Option {
|
func WithShutdownTimeout(timeout time.Duration) Option {
|
||||||
return func(cfg *Config) {
|
return func(cfg *Config) {
|
||||||
if timeout > 0 {
|
if timeout > 0 {
|
||||||
@@ -240,6 +257,15 @@ func WithErrors(catalog apierrors.Catalog) Option {
|
|||||||
// WithLogInstance borrows a logger for this App without changing process logging.
|
// WithLogInstance borrows a logger for this App without changing process logging.
|
||||||
func WithLogInstance(logger *logx.Logger) Option { return func(c *Config) { c.LogInstance = logger } }
|
func WithLogInstance(logger *logx.Logger) Option { return func(c *Config) { c.LogInstance = logger } }
|
||||||
|
|
||||||
|
// WithLanguagePolicy borrows an immutable HTTP language policy. Resource loading
|
||||||
|
// remains explicit in the project; configuration strings are not dictionaries.
|
||||||
|
func WithLanguagePolicy(policy *httpi18n.Policy) Option {
|
||||||
|
return func(c *Config) { c.LanguagePolicy = policy }
|
||||||
|
}
|
||||||
|
func WithErrorLocalizer(localizer apierrors.Localizer) Option {
|
||||||
|
return func(c *Config) { c.ErrorLocalizer = localizer }
|
||||||
|
}
|
||||||
|
|
||||||
func WithRequestPolicy(policy contract.RequestPolicy) Option {
|
func WithRequestPolicy(policy contract.RequestPolicy) Option {
|
||||||
return func(c *Config) { c.Request = policy }
|
return func(c *Config) { c.Request = policy }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,132 +1,6 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import "testing"
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestLoadFromEnvUsesDefaults(t *testing.T) {
|
|
||||||
for _, key := range []string{
|
|
||||||
"GO_OMNI_HTTP_ADDR", "GO_OMNI_REQUEST_ID_HEADER", "GO_OMNI_ENABLE_LOGGER",
|
|
||||||
"GO_OMNI_TRUSTED_PROXIES",
|
|
||||||
"GO_OMNI_TRANSPORT", "GO_OMNI_TLS_CERT_FILE", "GO_OMNI_TLS_KEY_FILE",
|
|
||||||
"GO_OMNI_LOG_LEVEL", "GO_OMNI_LOG_FORMAT", "GO_OMNI_LOG_OUTPUT",
|
|
||||||
"GO_OMNI_LOG_FILE", "GO_OMNI_SERVICE", "GO_OMNI_ENVIRONMENT",
|
|
||||||
"GO_OMNI_LOG_ADD_SOURCE", "GO_OMNI_SHUTDOWN_TIMEOUT",
|
|
||||||
"GO_OMNI_SLOW_REQUEST_THRESHOLD",
|
|
||||||
} {
|
|
||||||
t.Setenv(key, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg, err := LoadFromEnv("")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if cfg.Address != ":8080" || cfg.Transport != TransportNetHTTP || cfg.TLS != (TLSConfig{}) || cfg.RequestIDHeader != "X-Request-ID" || cfg.ShutdownTimeout != 10*time.Second {
|
|
||||||
t.Fatalf("unexpected defaults: %+v", cfg)
|
|
||||||
}
|
|
||||||
if cfg.EnableLogger || cfg.Log.Level != "info" || cfg.Log.Format != "json" || cfg.Log.Output != "stderr" {
|
|
||||||
t.Fatalf("unexpected logger defaults: %+v", cfg.Log)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestTransportEnvironment(t *testing.T) {
|
|
||||||
for _, test := range []struct {
|
|
||||||
name, transport, cert, key, errorField string
|
|
||||||
}{
|
|
||||||
{"default", "", "", "", ""},
|
|
||||||
{"native", "native", "", "", ""},
|
|
||||||
{"nativeTLS", "native", "server.crt", "server.key", ""},
|
|
||||||
{"http2", "http2", "server.crt", "server.key", ""},
|
|
||||||
{"unknown", "typo", "", "", "transport"},
|
|
||||||
{"certOnly", "nethttp", "server.crt", "", "tls"},
|
|
||||||
{"keyOnly", "native", "", "server.key", "tls"},
|
|
||||||
{"http2WithoutTLS", "http2", "", "", "tls"},
|
|
||||||
} {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
t.Setenv("TRANSPORT_TEST_TRANSPORT", test.transport)
|
|
||||||
t.Setenv("TRANSPORT_TEST_TLS_CERT_FILE", test.cert)
|
|
||||||
t.Setenv("TRANSPORT_TEST_TLS_KEY_FILE", test.key)
|
|
||||||
cfg, err := LoadFromEnv("TRANSPORT_TEST_")
|
|
||||||
if test.errorField != "" {
|
|
||||||
if err == nil || !strings.Contains(err.Error(), test.errorField) {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
want := test.transport
|
|
||||||
if want == "" {
|
|
||||||
want = TransportNetHTTP
|
|
||||||
}
|
|
||||||
if cfg.Transport != want || cfg.TLS.CertFile != test.cert || cfg.TLS.KeyFile != test.key {
|
|
||||||
t.Fatalf("settings: %+v", cfg)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadFromEnvOverridesSettings(t *testing.T) {
|
|
||||||
t.Setenv("GO_OMNI_HTTP_ADDR", "127.0.0.1:9090")
|
|
||||||
t.Setenv("GO_OMNI_REQUEST_ID_HEADER", "X-Correlation-ID")
|
|
||||||
t.Setenv("GO_OMNI_TRUSTED_PROXIES", "127.0.0.1, 10.0.0.0/8")
|
|
||||||
t.Setenv("GO_OMNI_ENABLE_LOGGER", "true")
|
|
||||||
t.Setenv("GO_OMNI_LOG_LEVEL", "debug")
|
|
||||||
t.Setenv("GO_OMNI_LOG_FORMAT", "text")
|
|
||||||
t.Setenv("GO_OMNI_LOG_OUTPUT", "stdout")
|
|
||||||
t.Setenv("GO_OMNI_SERVICE", "billing")
|
|
||||||
t.Setenv("GO_OMNI_ENVIRONMENT", "test")
|
|
||||||
t.Setenv("GO_OMNI_LOG_ADD_SOURCE", "true")
|
|
||||||
t.Setenv("GO_OMNI_SHUTDOWN_TIMEOUT", "3s")
|
|
||||||
t.Setenv("GO_OMNI_SLOW_REQUEST_THRESHOLD", "500ms")
|
|
||||||
|
|
||||||
cfg, err := LoadFromEnv("")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if cfg.Address != "127.0.0.1:9090" || cfg.RequestIDHeader != "X-Correlation-ID" || !cfg.EnableLogger || len(cfg.TrustedProxies) != 2 || cfg.TrustedProxies[1] != "10.0.0.0/8" {
|
|
||||||
t.Fatalf("unexpected application settings: %+v", cfg)
|
|
||||||
}
|
|
||||||
if cfg.ShutdownTimeout != 3*time.Second || cfg.Log.Level != "debug" || cfg.Log.Format != "text" || cfg.Log.Output != "stdout" || cfg.Log.Service != "billing" || cfg.Log.Environment != "test" || !cfg.Log.AddSource {
|
|
||||||
t.Fatalf("unexpected logger settings: %+v", cfg.Log)
|
|
||||||
}
|
|
||||||
if cfg.SlowRequestThreshold != 500*time.Millisecond {
|
|
||||||
t.Fatal(cfg.SlowRequestThreshold)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLoadFromEnvRejectsInvalidValues(t *testing.T) {
|
|
||||||
t.Setenv("GO_OMNI_ENABLE_LOGGER", "sometimes")
|
|
||||||
if _, err := LoadFromEnv(""); err == nil || !strings.Contains(err.Error(), "ENABLE_LOGGER") {
|
|
||||||
t.Fatalf("expected invalid logger flag error, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("GO_OMNI_ENABLE_LOGGER", "")
|
|
||||||
t.Setenv("GO_OMNI_SHUTDOWN_TIMEOUT", "later")
|
|
||||||
if _, err := LoadFromEnv(""); err == nil || !strings.Contains(err.Error(), "SHUTDOWN_TIMEOUT") {
|
|
||||||
t.Fatalf("expected invalid timeout error, got %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Setenv("GO_OMNI_SHUTDOWN_TIMEOUT", "1s")
|
|
||||||
t.Setenv("GO_OMNI_TRUSTED_PROXIES", "proxy.example.com")
|
|
||||||
if _, err := LoadFromEnv(""); err == nil || !strings.Contains(err.Error(), "trustedProxies") {
|
|
||||||
t.Fatalf("expected invalid trusted proxy error, got %v", err)
|
|
||||||
}
|
|
||||||
t.Setenv("GO_OMNI_TRUSTED_PROXIES", "")
|
|
||||||
cfg := Default()
|
|
||||||
cfg.Address = ""
|
|
||||||
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "address") {
|
|
||||||
t.Fatalf("expected invalid address error, got %v", err)
|
|
||||||
}
|
|
||||||
cfg = Default()
|
|
||||||
cfg.RequestIDHeader = " "
|
|
||||||
if err := cfg.Validate(); err == nil || !strings.Contains(err.Error(), "requestIdHeader") {
|
|
||||||
t.Fatalf("expected invalid request header error, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithConfigAndWithLogConfig(t *testing.T) {
|
func TestWithConfigAndWithLogConfig(t *testing.T) {
|
||||||
settings := Default()
|
settings := Default()
|
||||||
@@ -147,23 +21,3 @@ func TestWithConfigAndWithLogConfig(t *testing.T) {
|
|||||||
t.Fatalf("log config was not replaced: %+v", cfg.Log)
|
t.Fatalf("log config was not replaced: %+v", cfg.Log)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyEnvPreservesProjectDefaults(t *testing.T) {
|
|
||||||
cfg := Default()
|
|
||||||
cfg.Address = "127.0.0.1:8123"
|
|
||||||
cfg.Log.RedactKeys = []string{"bankAccount"}
|
|
||||||
cfg.TrustedProxies = []string{"127.0.0.1"}
|
|
||||||
t.Setenv("OVERLAY_TEST_LOG_LEVEL", "debug")
|
|
||||||
got, err := ApplyEnv(cfg, "OVERLAY_TEST_")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got.Address != cfg.Address || got.Log.Level != "debug" {
|
|
||||||
t.Fatal(got)
|
|
||||||
}
|
|
||||||
got.Log.RedactKeys[0] = "changed"
|
|
||||||
got.TrustedProxies[0] = "192.0.2.1"
|
|
||||||
if cfg.Log.RedactKeys[0] != "bankAccount" || cfg.TrustedProxies[0] != "127.0.0.1" {
|
|
||||||
t.Fatal("caller-owned defaults mutated")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
51
config/cors_test.go
Normal file
51
config/cors_test.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package config_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type corsProject struct {
|
||||||
|
Server config.Config `json:"server"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c corsProject) Validate() error { return config.AtField("server", c.Server.Validate()) }
|
||||||
|
|
||||||
|
func TestCORSConfiguration(t *testing.T) {
|
||||||
|
t.Setenv("WEB_ORIGIN", "https://web.example")
|
||||||
|
for ext, source := range map[string]string{
|
||||||
|
"yaml": "server:\n cors:\n enabled: true\n allowedOrigins:\n - '${WEB_ORIGIN}'\n allowedMethods: [GET, POST]\n allowedHeaders: [Authorization, Content-Type]\n maxAge: 10m\n",
|
||||||
|
"json": `{"server":{"cors":{"enabled":true,"allowedOrigins":["${WEB_ORIGIN}"],"allowedMethods":["GET","POST"],"allowedHeaders":["Authorization","Content-Type"],"maxAge":"10m"}}}`,
|
||||||
|
"xml": `<config><server><cors><enabled type="bool">true</enabled><allowedOrigins type="array"><item>${WEB_ORIGIN}</item></allowedOrigins><allowedMethods type="array"><item>GET</item><item>POST</item></allowedMethods><allowedHeaders type="array"><item>Authorization</item><item>Content-Type</item></allowedHeaders><maxAge>10m</maxAge></cors></server></config>`,
|
||||||
|
} {
|
||||||
|
t.Run(ext, func(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "config."+ext)
|
||||||
|
if err := os.WriteFile(path, []byte(source), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := config.LoadProject(path, corsProject{Server: config.Default()})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !cfg.Server.CORS.Enabled || cfg.Server.CORS.AllowedOrigins[0] != "https://web.example" || cfg.Server.CORS.MaxAge != 10*time.Minute {
|
||||||
|
t.Fatal(cfg.Server.CORS)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
path := filepath.Join(t.TempDir(), "bad.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte("server:\n cors:\n enabled: true\n allowedOrigins:\n - '${WEB_ORIGIN}'\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("WEB_ORIGIN", "https://user:secret@web.example")
|
||||||
|
_, err := config.LoadProject(path, corsProject{Server: config.Default()})
|
||||||
|
var d *config.Error
|
||||||
|
if !errors.As(err, &d) || d.Field != "server.cors.allowedOrigins[0]" || d.Env != "WEB_ORIGIN" || d.Line != 5 || strings.Contains(err.Error(), "secret") {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/internal/configdiag"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Error is a value-free diagnostic. Cause may contain private parser details;
|
// Error is a value-free diagnostic. Cause may contain private parser details;
|
||||||
@@ -56,6 +58,10 @@ func validationError(err error) *Error {
|
|||||||
out.cause = err
|
out.cause = err
|
||||||
return &out
|
return &out
|
||||||
}
|
}
|
||||||
|
var nested *configdiag.Error
|
||||||
|
if errors.As(err, &nested) {
|
||||||
|
return &Error{Field: nested.Field, Reason: nested.Reason, cause: err}
|
||||||
|
}
|
||||||
var c *ConfigError
|
var c *ConfigError
|
||||||
if errors.As(err, &c) {
|
if errors.As(err, &c) {
|
||||||
return &Error{Field: c.Field, Reason: c.Message, cause: err}
|
return &Error{Field: c.Field, Reason: c.Message, cause: err}
|
||||||
@@ -82,8 +88,9 @@ type Position struct{ Line, Column int }
|
|||||||
// string, bool, json.Number, nil, []any, map[string]any. Text is available for
|
// string, bool, json.Number, nil, []any, map[string]any. Text is available for
|
||||||
// formats such as XML whose scalar tokens have no inherent JSON type.
|
// formats such as XML whose scalar tokens have no inherent JSON type.
|
||||||
type Document struct {
|
type Document struct {
|
||||||
Value any
|
Value any
|
||||||
Positions map[string]Position
|
Positions map[string]Position
|
||||||
|
environments map[string][]string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Text string
|
type Text string
|
||||||
|
|||||||
@@ -18,14 +18,15 @@ func TestEndpointConfiguration(t *testing.T) {
|
|||||||
if cfg.Endpoints.Liveness.Enabled || cfg.Endpoints.OpenAPI.Enabled || !cfg.Endpoints.Readiness.Enabled || cfg.Endpoints.Readiness.ResponseMode != "raw" {
|
if cfg.Endpoints.Liveness.Enabled || cfg.Endpoints.OpenAPI.Enabled || !cfg.Endpoints.Readiness.Enabled || cfg.Endpoints.Readiness.ResponseMode != "raw" {
|
||||||
t.Fatal(cfg.Endpoints)
|
t.Fatal(cfg.Endpoints)
|
||||||
}
|
}
|
||||||
|
refs := configFile(t, "yaml", "endpoints:\n readiness:\n path: ${TEST_ENDPOINTS_READINESS_PATH}\n openapi:\n enabled: ${TEST_ENDPOINTS_OPENAPI_ENABLED}\n")
|
||||||
t.Setenv("TEST_ENDPOINTS_READINESS_PATH", "/internal/ready")
|
t.Setenv("TEST_ENDPOINTS_READINESS_PATH", "/internal/ready")
|
||||||
t.Setenv("TEST_ENDPOINTS_OPENAPI_ENABLED", "true")
|
t.Setenv("TEST_ENDPOINTS_OPENAPI_ENABLED", "true")
|
||||||
cfg, err = ApplyEnv(cfg, "TEST_")
|
cfg, err = LoadProject(refs, cfg)
|
||||||
if err != nil || cfg.Endpoints.Readiness.Path != "/internal/ready" || !cfg.Endpoints.OpenAPI.Enabled {
|
if err != nil || cfg.Endpoints.Readiness.Path != "/internal/ready" || !cfg.Endpoints.OpenAPI.Enabled {
|
||||||
t.Fatal(cfg.Endpoints, err)
|
t.Fatal(cfg.Endpoints, err)
|
||||||
}
|
}
|
||||||
t.Setenv("TEST_ENDPOINTS_OPENAPI_ENABLED", "invalid")
|
t.Setenv("TEST_ENDPOINTS_OPENAPI_ENABLED", "invalid")
|
||||||
if _, err = ApplyEnv(cfg, "TEST_"); err == nil {
|
if _, err = LoadProject(refs, cfg); err == nil {
|
||||||
t.Fatal("invalid bool accepted")
|
t.Fatal("invalid bool accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
package config
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const defaultEnvPrefix = "GO_OMNI_"
|
|
||||||
|
|
||||||
// ServerEnvAliases returns the documented short names for Config fields.
|
|
||||||
// field is the JSON path of Config in a project (usually "server"), or ""
|
|
||||||
// for Config itself. Canonical names win over aliases. Both use the same
|
|
||||||
// EnvOptions.Empty/Lookup and typed parser; collections use JSON syntax.
|
|
||||||
func ServerEnvAliases(prefix, field string) map[string][]string {
|
|
||||||
root := prefix
|
|
||||||
if field != "" {
|
|
||||||
for _, part := range strings.Split(field, ".") {
|
|
||||||
root += envName(part) + "_"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
names := map[string]string{
|
|
||||||
"ADDRESS": "HTTP_ADDR", "TRANSPORT": "TRANSPORT", "TLS_CERT_FILE": "TLS_CERT_FILE", "TLS_KEY_FILE": "TLS_KEY_FILE",
|
|
||||||
"REQUEST_ID_HEADER": "REQUEST_ID_HEADER", "ENABLE_LOGGER": "ENABLE_LOGGER", "SLOW_REQUEST_THRESHOLD": "SLOW_REQUEST_THRESHOLD",
|
|
||||||
"TRUSTED_PROXIES": "TRUSTED_PROXIES", "SHUTDOWN_TIMEOUT": "SHUTDOWN_TIMEOUT",
|
|
||||||
"LOG_LEVEL": "LOG_LEVEL", "LOG_FORMAT": "LOG_FORMAT", "LOG_OUTPUT": "LOG_OUTPUT", "LOG_FILE": "LOG_FILE", "LOG_SERVICE": "SERVICE", "LOG_ENVIRONMENT": "ENVIRONMENT", "LOG_ADD_SOURCE": "LOG_ADD_SOURCE",
|
|
||||||
"HTTP_READ_HEADER_TIMEOUT": "HTTP_READ_HEADER_TIMEOUT", "HTTP_READ_TIMEOUT": "HTTP_READ_TIMEOUT", "HTTP_WRITE_TIMEOUT": "HTTP_WRITE_TIMEOUT", "HTTP_IDLE_TIMEOUT": "HTTP_IDLE_TIMEOUT", "HTTP_MAX_HEADER_BYTES": "HTTP_MAX_HEADER_BYTES",
|
|
||||||
"LIFECYCLE_STOP_TIMEOUT": "LIFECYCLE_STOP_TIMEOUT", "LIFECYCLE_DRAIN_TIMEOUT": "LIFECYCLE_DRAIN_TIMEOUT", "LIFECYCLE_CANCEL_TIMEOUT": "LIFECYCLE_CANCEL_TIMEOUT",
|
|
||||||
}
|
|
||||||
for _, endpoint := range []string{"LIVENESS", "READINESS", "OPENAPI"} {
|
|
||||||
for _, part := range []string{"ENABLED", "PATH", "RESPONSE_MODE"} {
|
|
||||||
if endpoint == "OPENAPI" && part == "RESPONSE_MODE" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := "ENDPOINTS_" + endpoint + "_" + part
|
|
||||||
names[name] = name
|
|
||||||
}
|
|
||||||
}
|
|
||||||
aliases := map[string][]string{}
|
|
||||||
for key, alias := range names {
|
|
||||||
if root+key != prefix+alias {
|
|
||||||
aliases[root+key] = []string{prefix + alias}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return aliases
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadFromEnv loads defaults using the legacy short-name conventions.
|
|
||||||
func LoadFromEnv(prefix string) (Config, error) { return ApplyEnv(Default(), prefix) }
|
|
||||||
|
|
||||||
// ApplyEnv retains legacy whitespace trimming, ignored empty variables and
|
|
||||||
// comma-separated TRUSTED_PROXIES. New projects should use OverlayEnv with
|
|
||||||
// ServerEnvAliases for one explicit empty-value policy across the whole config.
|
|
||||||
func ApplyEnv(defaults Config, prefix string) (Config, error) {
|
|
||||||
prefix = strings.TrimSpace(prefix)
|
|
||||||
if prefix == "" {
|
|
||||||
prefix = defaultEnvPrefix
|
|
||||||
}
|
|
||||||
lookup := func(key string) (string, bool) {
|
|
||||||
raw, ok := os.LookupEnv(key)
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
if ok && raw != "" && key == prefix+"TRUSTED_PROXIES" && !strings.HasPrefix(raw, "[") {
|
|
||||||
values := strings.Split(raw, ",")
|
|
||||||
for i := range values {
|
|
||||||
values[i] = strings.TrimSpace(values[i])
|
|
||||||
}
|
|
||||||
data, _ := json.Marshal(values)
|
|
||||||
raw = string(data)
|
|
||||||
}
|
|
||||||
return raw, ok
|
|
||||||
}
|
|
||||||
defaults.Errors = defaults.Errors.Clone()
|
|
||||||
defaults.TrustedProxies = append([]string(nil), defaults.TrustedProxies...)
|
|
||||||
defaults.Log.RedactKeys = append([]string(nil), defaults.Log.RedactKeys...)
|
|
||||||
cfg, err := OverlayEnv(defaults, EnvOptions{Prefix: prefix, Empty: "ignore", Lookup: lookup, Aliases: ServerEnvAliases(prefix, "")})
|
|
||||||
if err != nil {
|
|
||||||
return Config{}, err
|
|
||||||
}
|
|
||||||
if err = cfg.Validate(); err != nil {
|
|
||||||
return Config{}, err
|
|
||||||
}
|
|
||||||
return cfg, nil
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,7 @@ func TestCatalogConfigFiles(t *testing.T) {
|
|||||||
if err := os.WriteFile(path, []byte(file.body), 0600); err != nil {
|
if err := os.WriteFile(path, []byte(file.body), 0600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
cfg, err := config.LoadFile(path, "CATALOG_FIXTURE_")
|
cfg, err := config.LoadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ func TestCatalogConfigFiles(t *testing.T) {
|
|||||||
if err := os.WriteFile(path, []byte(body), 0600); err != nil {
|
if err := os.WriteFile(path, []byte(body), 0600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if _, err := config.LoadFile(path, "CATALOG_FIXTURE_"); err == nil {
|
if _, err := config.LoadFile(path); err == nil {
|
||||||
t.Fatal("accepted invalid catalog", body)
|
t.Fatal("accepted invalid catalog", body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ func TestCatalogReloadSnapshotIsolation(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer logger.Close()
|
defer logger.Close()
|
||||||
reloader, err := config.NewReloader(path, "CATALOG_RELOAD_", logger)
|
reloader, err := config.NewReloader(path, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user