Compare commits
10 Commits
v0.12.0-pr
...
v0.16.0-pr
| Author | SHA1 | Date | |
|---|---|---|---|
| 64e3bd6cfc | |||
| 1f2d8b6a6a | |||
| 16deead70e | |||
| adcb6f60b6 | |||
| b8ab6b3521 | |||
| da8356e18b | |||
| f5478cdc76 | |||
| f9afb8f8dd | |||
| f0991d3ff2 | |||
| 8a348951eb |
26
API.md
26
API.md
@@ -6,6 +6,8 @@
|
||||
|
||||
本阶段目标:从 `.api` 定义生成可运行的 Go 项目;定义、绑定、校验与 OpenAPI 保持一致;重复生成不覆盖业务实现;业务代码继续独立于网络层。实现入口是 `cmd/go-omni/apigen`,运行时契约编译器是 `contract`。
|
||||
|
||||
v0.14 的 [完整开发指南](DEVELOPMENT.md) 说明响应根类型、`@request`、multipart 字段、注释/格式化、Go 错误目录导出与共享契约。
|
||||
|
||||
## 与 go-zero 的对应关系
|
||||
|
||||
调研参考了 go-zero 官方的 [API DSL](https://go-zero.dev/reference/api-dsl/)、[参数规则](https://go-zero.dev/reference/api-dsl/parameter/)、[goctl API](https://go-zero.dev/reference/cli-guide/api/) 和 [项目结构](https://go-zero.dev/concepts/project-structure/)。借鉴其声明接口后生成项目的工作方式:统一的类型定义、路由声明、分组、前缀和命名中间件。实现独立编写,不引入 go-zero 或 Gin 运行时依赖。
|
||||
@@ -19,7 +21,7 @@
|
||||
| CI 检查生成产物是否过期 | `api generate --api user.api --dir . --check` |
|
||||
| 业务逻辑目录 | `internal/logic/<group>`,使用标准 context.Context |
|
||||
| 依赖装配 | `internal/svc` |
|
||||
| 接口文档 | 自动生成 `docs/openapi.json`,同时提供 `/openapi.json` |
|
||||
| 接口文档 | 自动生成 `docs/openapi.json`,HTTP 端点由 `server.endpoints.openapi` 配置 |
|
||||
|
||||
这是明确限定的 go-zero 风格方言,**不是完整 goctl 兼容实现**。不支持的声明和校验标签会在生成前报错。
|
||||
|
||||
@@ -61,7 +63,7 @@ go-omni new --api user.api --module example.com/userService \
|
||||
|
||||
初次选项记录在 `.goOmni/apiManifest.json`,以后直接 `api generate` 会继承。已有项目通过参数切换选项会报错,因为初始化代码属于业务方;可在新目录生成对照后手动迁移。`--force` 不允许覆盖 API 项目。new 与 api new 统一生成契约项目,不再使用旧式 CRUD 目录。
|
||||
|
||||
MySQL 密钥只从 `GO_OMNI_DB_DSN` 获取,非秘密池配置位于 YAML,环境变量优先。存储使用原有 `GO_OMNI_STORAGE_*`;API 项目不会额外生成公开的签名发放接口,鉴权和对象权限由业务实现。初始化失败会清理已打开资源,退出清理可重复调用。
|
||||
MySQL 使用官方 `database/mysql` 结构化适配器,密码从 `GO_OMNI_DB_PASSWORD` 覆盖,非秘密池配置位于 YAML,环境变量优先。存储使用原有 `GO_OMNI_STORAGE_*`;API 项目不会额外生成公开的签名发放接口,鉴权和对象权限由业务实现。初始化失败会清理已打开资源,退出清理可重复调用。
|
||||
|
||||
HTTP 请求类型不能推导数据库表结构;迁移和 Repository 由业务自己维护。当前目录的完整示例见 [managedService](examples/managedService/README.md)。MySQL、事务审计和鉴权实现可参考 [旧目录用户管理示例](examples/userService/README.md),接入时按 [项目指南](PROJECT.md) 装配到 config/svc/logic。
|
||||
|
||||
@@ -99,21 +101,21 @@ service user-api {
|
||||
|
||||
- 可省略的 `syntax = "v1"`;`info` 中的 title、version 和描述性元数据。
|
||||
- 单独的 `type X { ... }` 与 `type (...)`;基础标量、指针、嵌套命名类型、切片、字符串键 map。嵌套对象只使用 JSON 字段。
|
||||
- 相对 `.api` 导入及 `import (...)`,重复导入去重;拒绝循环和越过入口目录的导入。单文件 1 MiB、总计 8 MiB、最多 64 个文件,限制导入/类型深度及 schema 展开规模。
|
||||
- 相对 `.api` 导入及 `import (...)`,重复导入去重;拒绝循环和未授权的目录越界。跨服务共享须在 `omni.imports.json` 声明 roots,见 [共享契约](DEVELOPMENT.md#两个服务共享契约)。单文件 1 MiB、总计 8 MiB、最多 64 个文件,限制导入/类型深度及 schema 展开规模。
|
||||
- 同一 service 名称的多个块;`@server` 的 group、prefix、middleware、auth、errors、response,group 可以写 `admin/user`。
|
||||
- 每个路由必须有 `@handler`;可选单行/多行 `@doc`、`@auth`、`@errors`、`@response`;GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS。
|
||||
- 可省略请求或响应;有响应默认 200,无响应默认 204。Omni 扩展 `@status 201`、`@status 202`;204 不允许响应类型,HEAD 不允许响应体,GET/HEAD 不允许 JSON 请求体。
|
||||
- 每个请求字段只使用一个来源:`json`、`path`、`form`、`header`。本版 `form` 明确表示 URL query,不解析 multipart 或表单 body。query 切片用重复键;header/path 使用标量。
|
||||
- 每个请求字段只使用一个来源:`json`、`path`、`form`、`header`,或声明式上传的 `multipart`、`multipartJSON`、`file`。`form` 始终表示 URL query;query 切片用重复键,header/path 使用标量。上传示例与限额见 [声明式 multipart](DEVELOPMENT.md#声明式-multipart)。
|
||||
- 来源标签中的 `optional` / `omitempty`、标量 `default`、标量 `options=a|b`、数值 `range=[1:100]`(支持开区间和单边界)。
|
||||
- `binding` 的 `required`、`min`、`max`、`len`、`email`。长度按 Unicode 字符或切片元素数;范围边界必须有限且位于 ±2^53 内。
|
||||
|
||||
暂不支持匿名嵌入、别名、递归类型、未声明的外部 Go 类型、自定义 JSON 编解码、`[]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 与业务项目中的框架依赖需一起升级。
|
||||
|
||||
## 业务错误:声明、代码和文档共用一份定义
|
||||
|
||||
**错误目录也可以只在 Go 中维护。** 从 `v0.11.1-preview` 起,生成路由没有声明错误列表时,继承 `omni.WithErrors(catalog)` 注册的 App 目录;`.api` 继续定义接口和返回格式,无需重复登记错误。全局中间件提前返回同样适用。显式引用 `.api` 错误时才使用路由独立目录,规则见 [目录继承](errors/README.md)。CLI 不执行运行时 Go 配置,因此静态文档不会自动枚举只在 Go 中定义的具体错误。
|
||||
**错误目录也可以只在 Go 中维护。** 从 `v0.11.1-preview` 起,生成路由没有声明错误列表时,继承 `omni.WithErrors(catalog)` 注册的 App 目录;`.api` 继续定义接口和返回格式,无需重复登记错误。全局中间件提前返回同样适用。显式引用 `.api` 错误时才使用路由独立目录,规则见 [目录继承](errors/README.md)。CLI 不执行项目 Go 配置;可显式用 `errors.ExportCatalog` 导出 `omni.errors.json`,供文档合并同一目录,见 [导出步骤](DEVELOPMENT.md#字段文档格式化go-错误目录导出)。
|
||||
|
||||
从 `v0.10.0-preview` 起,推荐用 `errorCatalog` 同时定义逻辑标识、对外业务码、提示和 HTTP 状态:
|
||||
|
||||
@@ -258,11 +260,11 @@ go test ./...
|
||||
- 动态 JSON 字段 `any` / `interface{}`,包含切片和 map;外围契约仍严格。
|
||||
- `scalar Name (...)` 显式引用项目类型,声明 JSON 输入/输出类型及文本绑定;支持已有 JSON/Text 转换方法。
|
||||
- `@body (mode: json/raw/multipart; maxBytes: ...)`:按路由限制请求体。
|
||||
- `@binding custom` 生成保留的绑定 hook;`@handlerMode manual` 生成保留的整个 Handler,仅用于自定义协议。raw/multipart 默认生成自动 Handler,将受限流交给 Logic。
|
||||
- `@binding custom` 生成保留的绑定 hook;`@handlerMode manual` 生成保留的整个 Handler,仅用于自定义协议。raw 与未声明上传字段的 multipart 将受限流交给 Logic;声明了上传字段时则交给 Logic 已校验的 DTO。
|
||||
- `@produces "text/plain"` 等声明无 DTO 成功输出;自动模式的 Logic 返回 `*httpx.Stream`,Handler 负责流式发送和关闭。默认状态 200。manual 模式保留项目自己的输出实现。
|
||||
- 自动 multipart Handler 检查未读部分和结束边界;损坏/截断默认返回 400,超限为 413。公共状态、码和文案仍可用项目错误目录配置,见 [流式接入与错误处理](PROJECT.md#自动上传回调与下载)。
|
||||
|
||||
迁移扩展起始于 v0.9.0,原有手写扩展说明见 [迁移扩展指南](MIGRATION.md)。当前根目录入口和自动流式 Handler 要求 CLI 与框架依赖一起使用 v0.12.0-preview;版本说明和新目录维护规则见 [项目指南](PROJECT.md)。不兼容全部 goctl 语法。
|
||||
迁移扩展起始于 v0.9.0,原有手写扩展说明见 [迁移扩展指南](MIGRATION.md)。当前根目录入口和自动流式 Handler 要求 CLI 与框架依赖一起使用 v0.14.0-preview;版本说明和新目录维护规则见 [项目指南](PROJECT.md)。不兼容全部 goctl 语法。
|
||||
|
||||
## 参数语义
|
||||
|
||||
@@ -270,9 +272,9 @@ go test ./...
|
||||
|
||||
业务需要区分“没传”和“传了零值”时使用 `*T` 加 optional。指针允许 JSON null;普通非指针字段拒绝 null(动态 `any` 允许);`binding:"required"` 拒绝指针 null。缺失与 null 都会得到 nil,本版没有三态 Nullable 类型。响应端的 `optional` 不等于 Go 编码器的 `omitempty`;需要省略零值时显式使用 `omitempty`。
|
||||
|
||||
JSON 必须是一个 object,Content-Type 为 application/json。默认最多 1 MiB,包含 chunked/未知长度和尾部空白;超限返回 413。重复 JSON key、未知字段、多个 JSON 值、标量 query/header 重复值、格式和约束错误返回 400;错误 Content-Type 返回 415。失败不会部分修改目标 DTO;错误详情只保留在内部 cause,公共响应不回显原始参数。
|
||||
普通 JSON 请求体必须是一个 object,Content-Type 为 application/json;响应根类型另支持数组、nullable 对象和 null。默认最多 1 MiB,包含 chunked/未知长度和尾部空白;超限返回 413。重复 JSON key、未知字段、多个 JSON 值、标量 query/header 重复值、格式和约束错误返回 400;错误 Content-Type 返回 415。失败不会部分修改目标 DTO;错误详情只保留在内部 cause,公共响应不回显原始参数。
|
||||
|
||||
`contract.Binder` 在服务启动时编译,随后并发复用,不会每次请求重新分析标签。CLI 用相同编译器产生文档,因此来源、必填、默认、nullable、枚举、范围和长度具有同一来源。响应类型和约束由业务实现遵守,不在每次响应上再次校验;非 nullable 集合请返回空集合而不是 nil。
|
||||
`contract.Binder` 在服务启动时编译,随后并发复用,不会每次请求重新分析标签。CLI 用相同编译器产生文档,因此来源、必填、默认、nullable、枚举、范围和长度具有同一来源。响应类型和约束由业务实现遵守,不在每次响应上再次校验;嵌套的非 nullable 集合请返回空集合而不是 nil,根 `returns ([]Item)` 会把 nil slice 规范为 `[]`。
|
||||
|
||||
## 生成目录和业务边界
|
||||
|
||||
@@ -282,7 +284,7 @@ main.go # 启动、日志、优雅退出
|
||||
etc/config.yaml
|
||||
internal/config/
|
||||
internal/svc/ # 依赖初始化和释放
|
||||
internal/types/typesGen.go
|
||||
internal/types/*Gen.go
|
||||
internal/apierrors/errorsGen.go # 声明错误后生成
|
||||
internal/handler/routesGen.go
|
||||
internal/handler/openapiGen.go
|
||||
@@ -327,3 +329,5 @@ python3 scripts/verifyManagedProject.py
|
||||
```
|
||||
|
||||
Go 测试覆盖契约拒绝规则、多源绑定、零值/缺失、默认/范围/嵌套集合、文档约束、幂等更新、只读检查、业务保留、删除路由、路径边界及生成项目编译。Python 验收会在临时目录生成项目,分别启动 nethttp/native,验证真实 HTTP 请求、中间件、日志 requestId 和优雅退出;结束后清理自己创建的进程和临时文件。结果记录在 `performance/managedProjectLocal.json`;完整记录见 [第一版验收](performance/managedProjectAcceptance.md)。
|
||||
|
||||
内置健康与 OpenAPI 路由可通过配置禁用或修改路径,`.api` 不再固定保留 `/health/live`、`/health/ready`、`/openapi.json`。启用的内置路径与业务 GET 路由重叠时,生成的 NewApp 在监听前返回配置错误;禁用后可以由 `.api` 自己维护这些路径。见 [内置接口配置](PROJECT.md#配置内置接口)。
|
||||
|
||||
26
BUSINESS_DEVELOPMENT.md
Normal file
26
BUSINESS_DEVELOPMENT.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 通用业务开发能力完善计划
|
||||
|
||||
本轮目标:业务维护契约、配置、依赖装配与业务逻辑,框架持续维护 HTTP 接入、契约校验、通用运行期与文档。按实际生成项目和可删除的业务适配点验收,不写入 eTax 业务规则。
|
||||
|
||||
## 实施与验收清单
|
||||
|
||||
- [x] 运行期:资源登记、逆序释放、幂等关闭、失败回滚、故障原因保留归框架;生成入口与项目依赖初始化边界明确。多 App 日志隔离、标准 log.Logger 适配、可信请求元数据与依赖中间件示例。
|
||||
- [x] 配置:项目完整结构体环境覆盖;默认值→文件→环境;嵌套、duration、slice、显式零值、空值策略、必填与脱敏;日志无副作用完整校验。
|
||||
- [x] 数据库:官方 MySQL 结构化配置/TLS/驱动日志适配,继续复用 OpenConnector 和唯一连接池;独立启动检查、失败清理、生成器接入;不内置版本/表结构/业务错误策略。
|
||||
- [x] 响应契约:数组、可空对象、成功 null、独立 HTTP 204;Logic/Handler/OpenAPI/漂移检查一致。
|
||||
- [x] 请求契约:未知 query、JSON UTF-8/charset、无 Body/必填空对象/可选 Body,App 默认与路由覆盖;scalar/any/数组/大小限制一致,普通 JSON 只绑定一次。
|
||||
- [x] 上传:声明文件/文本/JSON 字段,重复/未知/必填/单字段/单文件/总量限制;有界暂存与清理,调用 Logic 前完成普通表单校验;保留流式扩展与 Complete 约定。
|
||||
- [x] 文档工具:字段注释、example/枚举描述进入 DTO 与 OpenAPI;保注释、幂等格式化及 check;显式 Go 错误目录元数据导出与文档合并,不自动执行项目代码。
|
||||
- [x] 共享契约:受控 roots/摘要、循环/深度/文件数/路径保护;分源文件 DTO、多个独立 module 的工作区检查;版本提示与基础模板升级预览,保留业务文件。
|
||||
- [x] 集成:实际生成项目验证新能力、连续生成、业务文件保留、失败清理、nethttp/native 请求与退出;现有错误继承/参数/上传下载回归。
|
||||
- [x] 最终回归:全量 race(含生成器集成)、vet、build、格式化/链接检查;使用说明、迁移步骤及验收报告完整,发布后远程无 replace 验收。
|
||||
|
||||
已发布 `v0.14.0-preview`。逐项验收、可替换的业务适配点与远程报告见 [验收记录](reports/businessDevelopmentAcceptance.md);接入和升级见 [开发指南](DEVELOPMENT.md)。
|
||||
|
||||
## 核心约定
|
||||
|
||||
响应与错误码由项目配置。字段与请求策略由契约表达;基础设施采用可选适配器,保留标准 Connector。所有业务依赖在请求排空后关闭;框架不重试写操作、不自动迁移业务表、不关闭借用资源。
|
||||
|
||||
普通 multipart 为保证进入 Logic 前完成校验,以有界复制暂存到临时文件;框架清理自己创建的资源。纯流式路径明确由调用方在提交持久副作用前完成输入检查。
|
||||
|
||||
不修改 eTax/ePay 业务,不连接既有业务服务器。真实基础设施如需验证,只使用本轮隔离资源。验收报告明确区分单元测试、生成消费者测试与真实服务测试。
|
||||
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## v0.13.0-preview(2026-09-24)
|
||||
|
||||
- 新增通用 lifecycle.Controller,svc.RequestShutdown 支持依赖主动通知正常停止或致命故障,通知不阻塞,首个故障原因保留;生成入口排空 HTTP 后关闭依赖与日志,故障退出码为 1。
|
||||
- App.RunContext、ServeContext、RunWith 保留自定义 context 取消原因;正常信号取消仍正常返回。
|
||||
- 健康检查默认使用 App 响应模板和错误目录,支持 raw JSON;HTTP 状态保持 200/503,避免业务 HTTP 200 策略掩盖未就绪。内部探针原因仅供日志,失败状态继续记录到日志/指标。
|
||||
- 生成项目通过 server.endpoints 配置 liveness/readiness/openapi 的开关与路径;全局中间件继续生效,OpenAPI 保持原始规范。禁用端点不保留路径,启用路径与业务 GET/参数/通配路由冲突时在监听前报错。
|
||||
- 生成器最低框架版本更新到 v0.13.0-preview;manifest v2 和业务文件保持。v0.12 项目需要显式升级其 svc 扩展,main/Handler 由重新生成更新。
|
||||
- 增加故障退出、排空/清理顺序、初始化期间通知、子进程退出码、健康格式、损坏编码器、端点禁用/改名/冲突的回归验收。
|
||||
|
||||
## v0.12.0-preview(2026-09-24)
|
||||
|
||||
- 生成项目统一采用根目录 main.go 和 config/svc/logic/handler;new 与 api new 共用受管理生成器,manifest v2 不兼容旧目录。
|
||||
|
||||
319
DEVELOPMENT.md
Normal file
319
DEVELOPMENT.md
Normal file
@@ -0,0 +1,319 @@
|
||||
# 通用业务开发指南(v0.16.0-preview)
|
||||
|
||||
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
|
||||
|
||||
## 返回数组、可空对象和 null
|
||||
|
||||
```text
|
||||
@handler list
|
||||
get /items returns ([]Item)
|
||||
|
||||
@handler find
|
||||
get /item returns (*Item)
|
||||
|
||||
@handler save
|
||||
post /item returns (null)
|
||||
|
||||
@handler remove
|
||||
delete /item
|
||||
```
|
||||
|
||||
| 契约 | Logic 返回 | 成功数据 |
|
||||
| --- | --- | --- |
|
||||
| `returns (Item)` | `(*types.Item, error)` | 对象;nil 被视为实现错误 |
|
||||
| `returns ([]Item)` | `([]types.Item, error)` | 对象数组;nil 统一输出 `[]` |
|
||||
| `returns ([]string)` | `([]string, error)` | 字符串数组 |
|
||||
| `returns ([]int64)` / `([]bool)` | `([]int64, error)` / `([]bool, error)` | 数值 / 布尔数组 |
|
||||
| `returns ([]any)` | `([]any, error)` | 动态 JSON 数组,元素允许对象、数组、标量和 null |
|
||||
| `returns (*Item)` | `(*types.Item, error)` | 对象或 `null` |
|
||||
| `returns (null)` | `error` | HTTP 200 和空业务数据 |
|
||||
| 不写 returns | `error` | HTTP 204,无响应体 |
|
||||
|
||||
统一响应中的空数据是否显示,仍遵循项目模板的 `dataMode`;需要 `data:null` 就设置 `dataMode: always`。`@response raw` 输出业务数据本身,包括 `[]`、对象或 `null`。可用 `@status 201/202`;204 不能声明响应类型。嵌套字段继续支持既有 slice/map/pointer/scalar/any。
|
||||
|
||||
基础类型数组还支持 int/int8/int16/int32、uint/uint16/uint32/uint64、float32/float64。nil 根数组都输出 `[]`;`[]any` 在 OpenAPI 中是 `type: array, items: {}`,不限制元素形状,但元素必须可被标准 JSON 编码器序列化。`[]uint8`(Go 的 byte slice)会编码为 base64,故明确拒绝;数字数组可用 `[]int32`,二进制用下载接口。当前不支持根嵌套数组或根自定义 scalar 数组。
|
||||
|
||||
## 契约驱动的请求策略
|
||||
|
||||
入口文件可以声明默认策略,单个接口按需覆盖:
|
||||
|
||||
```text
|
||||
@app (
|
||||
defaultResponse: CommonResponse
|
||||
request: {"unknownQuery":"reject", "utf8":"reject", "charset":"utf8"}
|
||||
)
|
||||
|
||||
@handler create
|
||||
@request (body: required)
|
||||
post /items (CreateReq) returns (Item)
|
||||
|
||||
@handler touch
|
||||
@request (body: emptyObject)
|
||||
post /touch returns (null)
|
||||
```
|
||||
|
||||
- `unknownQuery: reject|ignore`:是否拒绝未声明的 URL 查询字段;重复标量仍拒绝,数组用重复的同名 query。
|
||||
- `utf8: reject|replace`:拒绝原始非法 UTF-8,或采用 JSON 的替换字符行为。不是只检查解码后的字符串。
|
||||
- `charset: utf8|any`:`utf8` 仅接受未声明 charset 或 `utf-8`;不会对其他编码做转码。
|
||||
- `body: auto|required|optional|none|emptyObject`:auto 按 JSON 字段推断;required 要求一个对象;optional 允许完全没有 body,但提供 body 后仍完整校验;none 禁止任何 body 字节;emptyObject 必须是 `{}`。
|
||||
|
||||
未设置的项继承 App 的 `config.Config.Request`,最后采用既有默认行为。`.api` 的 `@app` 显式字段优先于项目配置;路由 `@request` 优先于 App。raw/multipart 使用各自解析流程,不能套 JSON body 策略。普通 JSON 只解析一次;重复/未知 JSON 字段、约束、scalar 和 `any` 仍使用同一编译器。自定义 binder/manual Handler 的输入逻辑由项目显式负责。
|
||||
|
||||
没有 body 与提供 `{}` 不等价。optional 无 body 时 JSON 字段保留零值;需要业务区分是否出现某字段时使用 optional 指针字段。raw/multipart 不继承 App 的 JSON body 策略;GET/HEAD 如遇 App 的 required/optional/emptyObject 默认值,须明确覆盖为 auto 或 none。参数校验不会替代企业权限、记账规则或数据库约束。
|
||||
|
||||
## 声明式 multipart
|
||||
|
||||
```text
|
||||
type UploadReq {
|
||||
Title string `multipart:"title" binding:"required,max=80"`
|
||||
Metadata AttachmentMeta `multipartJSON:"metadata,maxBytes=16384"`
|
||||
Attachment File `file:"attachment,maxBytes=10485760"`
|
||||
Extras []File `file:"extras,optional,maxFiles=3,maxBytes=1048576"`
|
||||
}
|
||||
type AttachmentMeta {
|
||||
Category string `json:"category"`
|
||||
}
|
||||
|
||||
@handler upload
|
||||
@body (mode: multipart; maxBytes: 16777216; maxParts: 8; maxFieldBytes: 65536)
|
||||
post /attachments (UploadReq) returns (AttachmentResp)
|
||||
```
|
||||
|
||||
Logic 直接得到 `*types.UploadReq`,无需维护 Handler。`form` 仍表示 URL query;`multipart` 表示文本表单字段,可用基础类型、文本 scalar 和重复 scalar 数组;`multipartJSON` 是一个严格解码的 JSON 表单字段,可包含对象、数组和已声明的 scalar。
|
||||
|
||||
`File` 提供 `Name`、`ContentType`、`Size`、`Present()` 和 `Open() (io.ReadCloser, error)`。文件名和媒体类型属于客户端元数据,项目继续决定内容检测、存储权限和目标对象名。不要把客户端文件名直接用作存储路径。
|
||||
|
||||
```go
|
||||
reader, err := req.Attachment.Open()
|
||||
if err != nil { return nil, err }
|
||||
defer reader.Close()
|
||||
// 将 reader 传给项目选定的流式存储接口;不要先 io.ReadAll 整个大文件。
|
||||
```
|
||||
|
||||
所有声明字段、重复/未知字段、文件数量和各级限额都会在进入 Logic 前校验。缺少必填项、重复标量/JSON 字段、文件与文本类型混用、畸形 JSON 均失败。JSON 的类型、未知字段和重复 key 检查与普通 JSON 一致。
|
||||
|
||||
默认总量 1 MiB、最多 128 个 part、单文本/JSON 字段 64 KiB;单文件默认受总量约束,文件数组默认最多 16 个。`@body` 可设置 `maxBytes/maxParts/maxFieldBytes/maxFileBytes`;字段上的 `maxBytes/maxFiles` 再收紧对应限额。总量包括 MIME 头和结尾数据;传输编码的 part 被拒绝,避免字节含义不明确。
|
||||
|
||||
文件使用有界复制写入权限为 0600 的临时文件,文本/JSON 字段在各自限额内缓冲。生成 Handler 返回或 panic 时关闭打开的文件 reader 并删除临时文件;不能把请求的 File 留到异步任务使用。失败不会交付半个 DTO,也不会进入 Logic。临时磁盘容量、并发请求上限与 HTTP 超时由部署配置共同约束。
|
||||
|
||||
不声明 multipart 字段时,保留原来的 `body *httpx.Body` 流式入口,不创建临时文件。该方式适合特殊协议;项目应在不可撤销副作用之前完成校验并调用 `body.Complete()`。下载和原始回调继续使用 `@produces` / `@body(mode: raw)`。
|
||||
|
||||
## 完整业务配置的环境覆盖
|
||||
|
||||
生成项目默认执行:代码与 `envDefault` 默认值 → JSON/YAML 文件 → 全结构体环境覆盖 → 既有框架环境别名 → `Validate`。增加业务字段不需要另外写 `os.Getenv`。
|
||||
|
||||
```go
|
||||
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。日志输出的显式完整名称也优先于短名称。
|
||||
|
||||
## 依赖、日志与中间件
|
||||
|
||||
`internal/svc/runtimeGen.go` 持续由生成器维护。项目的 ServiceContext 嵌入 `*lifecycle.Runtime`,只在 `Init(ctx)` 中装配额外依赖:
|
||||
|
||||
```go
|
||||
func (c *ServiceContext) Init(ctx context.Context) error {
|
||||
client, err := NewClient(c.Config.Client)
|
||||
if err != nil { return err }
|
||||
c.Client = client
|
||||
c.OnClose(client.Close)
|
||||
c.Middlewares["RequireAccess"] = middleware.NewRequireAccess(client)
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
每个资源初始化成功就登记清理;后续初始化失败自动逆序回滚。正常退出先排空 HTTP 请求,再执行后台组件的 Stop/Drain 阶段,确认收尾后逆序关闭依赖,最后关闭日志。关闭幂等,单个清理函数 panic/返回错误不会跳过其他清理;初始化 panic 清理后继续传播。后台组件按 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭) 登记;无法确认收尾完成时保留共享依赖并异常退出,不能把取消等同于任务已经结束。
|
||||
|
||||
中间件用业务所需的小接口接收依赖,避免反向导入 svc:
|
||||
|
||||
```go
|
||||
// internal/middleware/accessFactory.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
)
|
||||
|
||||
type AccessChecker interface {
|
||||
CheckAccess(context.Context, string) error
|
||||
}
|
||||
func NewRequireAccess(checker AccessChecker) httpx.Middleware {
|
||||
return func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
if err := checker.CheckAccess(c.Context(), c.Header("Authorization")); err != nil {
|
||||
return err
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`.api` 声明对应 middleware 后,保留生成的初始 hook,并另建上述工厂;在 Init 用工厂替换 registry 的同名项即可。该包只依赖接口和 HTTP 抽象,不导入 svc,也不把数据库藏到 context 里。
|
||||
|
||||
生成入口为每个 App 注入独立 Logger,不修改进程默认 Logger。业务可用 `c.Logger.InfoContext(...)`,或请求链中的 `logx.InfoContext(ctx,...)`。标准库/第三方需要 `*log.Logger` 时使用 `c.Logger.Standard(slog.LevelWarn)`;适配器借用原 Logger,不负责关闭。手写多 App 用 `omni.WithLogInstance(logger)` 显式注入。
|
||||
|
||||
`httpx.Metadata(ctx)` 在业务的标准 context 中提供可信代理规则解析后的 ClientIP、HTTP Method 和路由模板;不放入凭据、完整 URL、查询或 body。业务 userId/tenantId 等仍由项目选择,框架不预设身份模型。
|
||||
|
||||
`middleware.ContextErrors("REQUEST_CANCELED", "REQUEST_TIMEOUT")` 可将原始取消/超时错误映射到项目错误目录;显式 APIError 优先,内部 cause 链保留。状态、业务码与提示仍在项目 Catalog 配置。
|
||||
|
||||
## 字段文档、格式化、Go 错误目录导出
|
||||
|
||||
```text
|
||||
type Item {
|
||||
// 企业名称
|
||||
Name string `json:"name" example:"示例企业"`
|
||||
State int `json:"state,options=0|1" example:"1" enumDescriptions:"[\"禁用\",\"启用\"]"` // 状态
|
||||
}
|
||||
```
|
||||
|
||||
字段前/同行注释同步到 Go 注释和 OpenAPI description。显式 `description` 标签优先;example 的基础字段值也检查类型和约束,枚举说明按声明值顺序填写。字符串 example 直接写内容,其他类型写 JSON。自定义 scalar 的示例不能替代其项目解码器验证;输入输出类型不同时,不把输入示例冒充输出示例。
|
||||
|
||||
```sh
|
||||
go-omni api format --api api/service.api
|
||||
go-omni api format --api api/service.api --check
|
||||
go-omni api generate
|
||||
go-omni api generate --check
|
||||
```
|
||||
|
||||
格式化保留注释、token 和多行字面量,规范四空格缩进、空行、行首尾空白及相邻单行字段的类型/标签/行尾注释对齐;结果幂等。先校验完整契约,check 不写文件;导入的共享目录不被某个服务的格式化命令修改。DTO 按源 `.api` 输出 `dto<Source><摘要>Gen.go`,都属于 `types` 包,跨文件引用无需改包名。
|
||||
|
||||
Go Catalog 是错误的唯一来源时,优先使用 workspace 的显式导出配置,不再维护导出/复制脚本。AppOptions 仍通过 `omni.WithErrors(apierrors.Catalog())` 注册同一个 Go 函数,运行期只注册一次。单个项目也可继续使用 `errors.ExportCatalog` 导出入口目录的 `api/omni.errors.json`;此文件和下面的共享 source 二选一。
|
||||
|
||||
OpenAPI 自动把有收益的重复响应、schema 和示例移到 `components.responses/schemas/examples`,通过标准 `$ref` 引用。HTTP 200 同时含成功和业务失败时保留两种 schema、业务码、模板参数示例和 data:null;业务响应内容不会改变。消费 OpenAPI 的工具需要支持标准本地引用。
|
||||
|
||||
## 两个服务共享契约
|
||||
|
||||
```text
|
||||
workspace/
|
||||
├── contracts/common/response.api
|
||||
├── services/api/api/service.api
|
||||
├── services/api/api/omni.imports.json
|
||||
├── services/id/api/service.api
|
||||
├── services/id/api/omni.imports.json
|
||||
└── omni.workspace.json
|
||||
```
|
||||
|
||||
两个服务的 `omni.imports.json` 可以分别声明:
|
||||
|
||||
```json
|
||||
{"roots":{"common":{"path":"../../../contracts/common","version":"1.0.0"}}}
|
||||
```
|
||||
|
||||
```text
|
||||
import "@common/response.api"
|
||||
```
|
||||
|
||||
只读取入口目录和显式声明的根。保留真实路径检查、symlink 越界拒绝、循环检测、32 层、64 文件、单文件 1 MiB/总量 8 MiB 限额;最多 16 个共享根。不会下载依赖或复制共享定义进服务。移动目录时显式更新 roots。
|
||||
|
||||
`version` 是项目管理的版本标签。生成文档记录每个文件 SHA-256,以及每个共享根所导入文件集合的摘要;可把文档中的 `x-omni-contract-sources.shared.common.sha256` 填入 root 的 `sha256`,固定输入集合,内容变化后必须显式更新。摘要算法是对“该根内相对路径→文件 SHA-256”的键排序 JSON 对象再做 SHA-256,未导入的文件不参与。
|
||||
|
||||
工作区清单:
|
||||
|
||||
```json
|
||||
{"services":[{"dir":"services/api"},{"dir":"services/id"}]}
|
||||
```
|
||||
|
||||
```sh
|
||||
go-omni workspace format --file omni.workspace.json
|
||||
go-omni workspace generate --file omni.workspace.json
|
||||
go-omni workspace check --file omni.workspace.json
|
||||
```
|
||||
|
||||
各服务保持独立 module;可显式填写各服务的 `api`(相对服务目录)和 `module`。所有服务预检通过后再写;每个服务独立提交文件事务,不承诺跨项目的崩溃原子性。check 汇总格式与生成漂移;业务编译/测试仍在各 module 执行。
|
||||
|
||||
### 同一份 Go 错误目录供多个服务生成
|
||||
|
||||
在 workspace 清单显式声明提供目录的 Go 包和函数:
|
||||
|
||||
```json
|
||||
{
|
||||
"errorCatalogs": [{
|
||||
"dir": ".",
|
||||
"package": "example.com/project/internal/apierrors",
|
||||
"function": "Catalog",
|
||||
"output": "contracts/common/errors.json"
|
||||
}],
|
||||
"services": [{"dir":"services/api"}, {"dir":"services/id"}]
|
||||
}
|
||||
```
|
||||
|
||||
`dir` 相对 workspace,必须包含 go.mod;函数签名为 `func Catalog() errors.Catalog`。允许 module 内的 internal 包。先准备 output 的父目录。生成器构建一个临时导出程序,使用 Go overlay、临时 go.mod/go.sum 和 `-mod=readonly`,不会往项目增加导出命令或修改 go.mod/go.sum。最多 16 个提供方,每个有 90 秒构建/执行总时限和 1 MiB stdout 限制。错误消息不回显程序输出。
|
||||
|
||||
只有显式运行 **workspace generate/check** 会执行清单指定的 Go 包及其依赖初始化;不要指向不信任的包。提供目录的函数应只构造目录,不连接数据库、启动服务、写 stdout 或修改项目。框架不扫描其他包,也不通过 shell 执行配置字符串。普通 api validate/export/generate 和 workspace format 只读取现有元数据。
|
||||
|
||||
各服务在自己的 `api/omni.imports.json` 引用共享 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"roots": {"common":{"path":"../../../contracts/common","version":"1.0.0"}},
|
||||
"errors": {
|
||||
"source": "@common/errors.json",
|
||||
"include": ["USERNAME_INVALID", "INTERNAL_ERROR"],
|
||||
"routes": {"GET /permissions":["INTERNAL_ERROR"]}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`include` 省略或 null 表示全部目录,`[]` 表示不列业务错误;routes 按 **大写方法 + 空格 + .api 原路径**(如 `GET /users/:id`)覆盖服务级列表。错误标识、路由拼错或重复标识会明确报错。路由已经通过 `.api @errors` 声明独立运行期目录时,不能再给它设置文档 routes 列表。
|
||||
|
||||
**这些列表只决定文档展示,不修改 App 错误目录、路由继承或运行期可返回的错误。** `.api` 不需要重复定义 Go 错误表。状态、业务码、提示和模板参数仍来自同一 Catalog;特殊自定义 Encoder 的文档由项目显式扩展。
|
||||
|
||||
```sh
|
||||
# 首次生成导出一次共享目录,所有服务预检通过才写入
|
||||
# 共享 JSON 不复制到各服务;已有 api/omni.errors.json 请显式移除后改用 source。
|
||||
go-omni workspace generate --file omni.workspace.json
|
||||
go-omni workspace format --file omni.workspace.json
|
||||
go-omni workspace generate --file omni.workspace.json
|
||||
go-omni workspace check --file omni.workspace.json
|
||||
```
|
||||
|
||||
共享 JSON 应纳入版本控制。check 在内存重新导出并比较 JSON,然后检查所有服务;缺失或过期时失败,不更新项目文件。Go 构建缓存仍可能更新;显式提供方的自有副作用不属于生成器可隔离范围。业务 CI 仍执行各 module 的编译与测试。目录函数本身有变化时重新 generate,受控共享根的固定 SHA256 如需改变仍由项目显式更新。
|
||||
|
||||
## v0.13 项目升级
|
||||
|
||||
先使用与目标框架匹配的 CLI;CLI 会提示与 go.mod 不同的版本。当前生成模板至少需要 v0.16.0-preview(或显式引用当前源码)。
|
||||
|
||||
```sh
|
||||
go-omni api upgrade --dir . --dry-run
|
||||
go-omni api upgrade --dir . --preview .goOmni/upgradePreview
|
||||
# 只有标为 safe 的原始模板才会自动替换;review 阻止全部写入。
|
||||
go-omni api upgrade --dir . --apply
|
||||
go mod tidy
|
||||
go-omni api generate --check
|
||||
go test ./...
|
||||
go build .
|
||||
```
|
||||
|
||||
自动迁移只替换与 v0.13 原始模板一致的配置/svc 基础文件、移除原始 close.go,并更新最低依赖及生成产物;保留 Logic、Repository、AppOptions、独立命令。迁移失败回滚已改的项目文件。存在业务修改时,仅输出这些基础文件的候选版本和清单,不生成整棵业务代码让人重新拼接。
|
||||
|
||||
定制项目按预览定点迁移:保留依赖字段,把初始化移到 Init,嵌入 Runtime,清理改为 OnClose 登记;移除旧 New/Close/OnClose;合并业务配置和结构化 MySQL 配置,更新 go.mod,再正常 generate。生成器会拒绝遗留构造/关闭实现,避免重复函数或悄悄绕过清理。v0.12 及更早的定制结构不能被精确自动合并,使用同一预览逐项迁移并编译验证。
|
||||
73
PROJECT.md
73
PROJECT.md
@@ -1,6 +1,6 @@
|
||||
# 生成项目与文件维护边界
|
||||
|
||||
v0.12.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
|
||||
v0.16.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
|
||||
|
||||
## 创建和日常开发
|
||||
|
||||
@@ -47,7 +47,7 @@ ERROR [logicSignature] internal/logic/greet/greet.go:12 (GET /api/greet/:name):
|
||||
| `internal/config/config.go` | 自定义配置字段、加载、默认值与校验 |
|
||||
| `internal/svc/serviceContext.go` | 依赖字段和初始化,持有加载后的 Config |
|
||||
| `internal/svc/options.go` | AppOptions、GlobalMiddleware;注册 Go 错误目录和全局扩展 |
|
||||
| `internal/svc/close.go` | 资源关闭;OnClose 为自定义资源提供标准清理入口 |
|
||||
| `internal/svc/databaseOptions.go` | 可选 MySQL 驱动与项目启动检查扩展 |
|
||||
| `internal/svc/storage.go` | 选用存储时生成,之后保留 |
|
||||
| `internal/logic/<group>/*.go` | 业务方法,使用标准 context.Context |
|
||||
| `internal/repository/<group>/*.go` | 数据访问,按需实现 |
|
||||
@@ -58,7 +58,7 @@ ERROR [logicSignature] internal/logic/greet/greet.go:12 (GET /api/greet/:name):
|
||||
|
||||
以上骨架只补缺失文件,既有内容按字节保留。删除已废弃接口后,对应业务文件也不会被自动删除;由开发者整理编译器指出的失效类型或签名。
|
||||
|
||||
生成器持续管理:`main.go`、`internal/handler/*Gen.go`、分组的 `handlersGen.go`、`internal/types/*Gen.go`、中间件 `registryGen.go`、`docs/openapi.json`、`.goOmni/apiManifest.json`。如果在 `.api` 里选择声明错误,还会管理 `internal/apierrors/errorsGen.go`。文件标注 DO NOT EDIT;手改会被拒绝覆盖,需先移走自定义内容。
|
||||
生成器持续管理:`internal/svc/runtimeGen.go`、`main.go`、`internal/handler/*Gen.go`、分组的 `handlersGen.go`、`internal/types/*Gen.go`、中间件 `registryGen.go`、`docs/openapi.json`、`.goOmni/apiManifest.json`。如果在 `.api` 里选择声明错误,还会管理 `internal/apierrors/errorsGen.go`。文件标注 DO NOT EDIT;手改会被拒绝覆盖,需先移走自定义内容。
|
||||
|
||||
Logic 实例在注册时创建并由请求并发复用;请求数据用方法参数和局部变量传递,不存入共享 Logic/svc 字段。
|
||||
|
||||
@@ -66,16 +66,16 @@ Logic 实例在注册时创建并由请求并发复用;请求数据用方法
|
||||
|
||||
## 依赖与启动扩展
|
||||
|
||||
`svc.New(ctx, logger, cfg)` 初始化依赖,可返回错误;Logger 由入口创建并借用。新增客户端时只需要扩展配置和 svc:
|
||||
`svc.New(ctx, logger, cfg)` 初始化依赖,可返回错误;Logger 由入口创建并借用。New 本身由生成器维护;新增客户端时扩展配置和 svc 的 `Init(ctx) error`:
|
||||
|
||||
```go
|
||||
client, err := newClient(cfg.Client)
|
||||
if err != nil { return nil, err }
|
||||
client, err := newClient(c.Config.Client)
|
||||
if err != nil { return err }
|
||||
c.Client = client
|
||||
c.OnClose(client.Close) // 创建成功后立即登记,后续失败也能释放
|
||||
```
|
||||
|
||||
`New` 的失败清理骨架已生成。OnClose 按登记的相反顺序执行;Close 幂等。项目不要重复关闭同一资源,借用的 Logger 也不应由 svc 关闭。构造器在返回错误前若创建了尚未交付的资源,该构造器自己负责释放。
|
||||
ServiceContext 嵌入 `*lifecycle.Runtime`;框架维护锁、清理栈和失败回滚,项目不再复制 Close/OnClose 实现。OnClose 按登记的相反顺序执行;Close 幂等。项目不要重复关闭同一资源,借用的 Logger 也不应由 svc 关闭。构造器在返回错误前若创建了尚未交付的资源,该构造器自己负责释放。
|
||||
|
||||
错误注册写在 `internal/svc/options.go`,错误表可以来自任意项目共用包:
|
||||
|
||||
@@ -85,7 +85,7 @@ func (c *ServiceContext) AppOptions() []omni.Option {
|
||||
}
|
||||
```
|
||||
|
||||
业务调用 `omnierrors.ByCode(apierrors.CodeUsernameInvalid)`,可接 `WithParams` / `WithCause`。无需在 `.api` 重复错误目录。未声明独立错误目录的路由继承 App 目录;独立目录不与 App 合并。静态 OpenAPI 不执行 Go,不能自动枚举仅存在于 Go 中的错误目录。
|
||||
业务调用 `omnierrors.ByCode(apierrors.CodeUsernameInvalid)`,可接 `WithParams` / `WithCause`。无需在 `.api` 重复错误目录。未声明独立错误目录的路由继承 App 目录;独立目录不与 App 合并。普通 `.api` 编译不执行 Go;可在 workspace 显式配置 Catalog 函数,统一导出共享文档元数据,也可沿用 `api/omni.errors.json`,详见 [元数据导出](DEVELOPMENT.md#字段文档格式化go-错误目录导出)。
|
||||
|
||||
`GlobalMiddleware()` 提供全局中间件;`Middlewares` 映射提供 `.api` 声明的具名中间件。全部显式调用,不依赖项目 init 注册,也不要求 svc 导入 handler。框架请求链按原有次序运行。
|
||||
|
||||
@@ -93,7 +93,58 @@ func (c *ServiceContext) AppOptions() []omni.Option {
|
||||
|
||||
最终配置校验包含自定义响应编码器与错误目录的组合。码类型/映射冲突、编码器已绑定另一份目录等问题在创建 App 前返回错误,进入入口的清理流程,不等到构造 App 时才 panic。
|
||||
|
||||
日志在 AppOptions 前初始化,其配置应放在 Config 中。退出信号先触发 HTTP 排空;达到 shutdownTimeout 后按框架规则强制关闭连接。请求仍依赖的后台任务/客户端不能直接绑定信号 context 提前关闭,应持有独立生命周期,在 OnClose 中取消。业务必须响应请求 context 取消;忽略取消的 goroutine 无法保证在超时前结束。
|
||||
日志在 AppOptions 前初始化,其配置应放在 Config 中。退出信号先触发 HTTP 排空;达到 shutdownTimeout 后按框架规则强制关闭连接。请求仍依赖的后台任务/客户端不能直接绑定信号 context 提前关闭,应持有独立生命周期,使用 WorkContext 并登记成对的 OnStop/OnDrain,详见 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭)。业务必须响应请求 context 取消;忽略取消的 goroutine 无法保证在超时前结束。
|
||||
|
||||
## 依赖主动申请退出
|
||||
|
||||
从 v0.13.0-preview 起,生成的 svc 提供 `RequestShutdown(err)`。数据库客户端、节点租约、后台任务等发现不可恢复故障时,由项目决定是否退出:
|
||||
|
||||
```go
|
||||
// 在 svc 的依赖监控回调中调用;err 是实际故障原因。
|
||||
c.RequestShutdown(err)
|
||||
```
|
||||
|
||||
这只是通知,立即返回,不等待 HTTP 排空,也不直接关闭数据库。生成入口停止接收新请求,等待在途请求完成;超过 `shutdownTimeout` 后取消请求并关闭连接,再执行 svc.Close,最后关闭日志。非空故障原因保留在返回错误链中,进程退出码为 1;`RequestShutdown(nil)` 或正常 SIGINT/SIGTERM 在没有其他故障时退出码为 0。
|
||||
|
||||
多个依赖可并发通知,关闭流程仅执行一次,首个非空故障原因保留。正常信号已开始排空时收到的故障也不会被清理动作覆盖。初始化期间的通知同样有效,不依赖 HTTP 监听已经启动。后台监控任务应通过 OnStop/OnDrain 停止并等待,再进入资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。
|
||||
|
||||
`Lifecycle *lifecycle.Controller` 是 svc 持有的通用控制器,初始化时创建,生成入口使用它的 Context。该 Context 表示服务正在停止,不代表数据库已经关闭。不能把它当作在途请求的 Context,也不要在它取消时立刻关闭请求仍依赖的资源。发号器失去租约后拒绝发号等业务安全规则仍由项目实现。
|
||||
|
||||
## 配置内置接口
|
||||
|
||||
生成项目在 `etc/config.yaml` 明确列出以下默认配置;可以独立关闭、修改路径,或让健康接口返回原始 JSON:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
endpoints:
|
||||
liveness:
|
||||
enabled: true
|
||||
path: /health/live
|
||||
responseMode: app
|
||||
readiness:
|
||||
enabled: true
|
||||
path: /health/ready
|
||||
responseMode: app
|
||||
openapi:
|
||||
enabled: true
|
||||
path: /openapi.json
|
||||
```
|
||||
|
||||
`responseMode: app` 使用项目 App 的响应模板/自定义 Encoder。健康成功使用项目成功码和成功提示;未就绪的 `NOT_READY` 可在项目 Go 错误目录配置业务码和公开提示,无需在 `.api` 再声明。
|
||||
|
||||
健康探针的 HTTP 状态固定遵循探针语义:存活/就绪为 200,未就绪为 503;项目错误目录或自定义编码器不能把未就绪改为 HTTP 200。响应模板 `${status}` 同样使用探针状态。失败继续进入业务失败日志/指标,探针底层原因保留在错误链供日志使用,不直接返回给调用方。
|
||||
|
||||
`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 的最终有效配置决定是否注册。
|
||||
|
||||
关闭或改名后,旧路径不再由框架保留,项目可以在 `.api` 自己声明。启用的内置 GET 路由与业务路由相同或被业务参数/通配路径覆盖时,在创建 App、开始监听之前报错;不会覆盖业务路由。内置接口仍经过全局中间件;项目在鉴权中间件中明确决定这些路径的访问规则,框架不自动绕过鉴权。
|
||||
|
||||
<a id="从-v012-生成项目升级"></a>
|
||||
|
||||
## 从 v0.13 或更早版本升级
|
||||
|
||||
请按 [v0.14 定点迁移说明](DEVELOPMENT.md#v013-项目升级) 使用 `api upgrade`。原始模板可以自动升级;含业务修改的基础文件必须参考局部预览合并。旧 New/Close/OnClose 移到框架 Runtime,项目依赖初始化保留在 Init。不要继续向项目里复制生命周期锁和清理栈。
|
||||
|
||||
## App 默认响应格式
|
||||
|
||||
@@ -137,7 +188,7 @@ post /callback
|
||||
get /download (DownloadReq)
|
||||
```
|
||||
|
||||
- raw/multipart 的 Logic 增加 `body *httpx.Body` 参数。`body.Raw` 是受路由上限约束的原始字节流;`body.Header` 是请求头副本。
|
||||
- 以下为未声明 multipart 字段的流式用法;声明式绑定见 [上传字段](DEVELOPMENT.md#声明式-multipart)。raw/流式 multipart 的 Logic 增加 `body *httpx.Body` 参数。`body.Raw` 是受路由上限约束的原始字节流;`body.Header` 是请求头副本。
|
||||
- multipart 用 `body.Parts.NextPart()` 顺序读取,关闭每个 Part;不调用 ParseMultipartForm,不产生上传临时文件,也不把整个文件放入内存。每个字段/文件的业务要求由 Logic 校验。请求 DTO 可定义 path/form/header;`form` 仍是 URL 查询参数,不能用 json DTO 重复读取上传体。
|
||||
- Parts 是 `*httpx.MultipartReader`,返回的 `*httpx.MultipartPart` 提供 Read、Close、Header、FormName、FileName;NextRawPart 可读取未做 quoted-printable 解码的部分。NextPart 自动关闭上一个未读完的 Part;Close 会报告读取错误。不要混用 Parts 与 Raw 读取同一个 multipart 请求。
|
||||
- multipart 缺少结束边界、头部/内容损坏默认返回 `400 / INVALID_MULTIPART`;超过路由限额仍为 413,媒体类型/缺少 boundary 为 415。框架只提供通用标识和默认提示,项目可以在 Go 错误目录中配置自己的状态、业务码和文案。内部解析原因保留在错误链,不进入公开响应;Logic 自己返回的数据库等错误不会被统一改成 400。
|
||||
@@ -157,7 +208,7 @@ get /download (DownloadReq)
|
||||
|
||||
## 已有服务接入步骤
|
||||
|
||||
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.12.0-preview:
|
||||
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.16.0-preview:
|
||||
|
||||
1. 先保留旧服务,整理实际路由及现有响应、鉴权和超时要求。在新目录执行 `go-omni new --api /path/to/service.api --module example.com/service ./service`;不对旧入口强制覆盖。
|
||||
2. 在 `.api` 中选择 `@app(defaultResponse: ...)`,显式保留原项目的字段、成功码、错误码和 `dataMode`。需要独立回执的路由单独声明 raw/produces。普通路由、404/405 和提前返回的中间件一起验收。
|
||||
|
||||
26
README.md
26
README.md
@@ -2,12 +2,20 @@
|
||||
|
||||
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
|
||||
|
||||
**从这份 README 开始接入。** 当前版本 **`v0.12.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
||||
**从这份 README 开始接入。** 当前版本 **`v0.16.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
|
||||
|
||||
Go **1.22+**。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。
|
||||
|
||||
响应字段、业务码、登录方式、权限规则和数据库表结构由接入项目决定。文中的 `code/msg/data`、`200/9999` 都是示例配置,可以替换。
|
||||
|
||||
本版补齐 `returns([]string/[]int64/[]bool/[]any)`、OpenAPI 公共引用、workspace 一次导出共享 Go 错误目录,以及统一环境变量别名覆盖。详细配置与升级步骤见 [业务开发指南](DEVELOPMENT.md)。
|
||||
|
||||
已有通用开发能力见 [业务开发指南](DEVELOPMENT.md):数组/null、严格请求策略、声明式上传、完整配置环境覆盖、资源生命周期、共享契约和升级工具。MySQL 直接使用 [官方适配器](database/mysql/README.md)。
|
||||
|
||||
后台组件可在 svc 登记 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭),先停止接收工作并等待收尾,再关闭共享资源;无需绑定队列或 ORM。
|
||||
|
||||
使用 GORM 的项目可以选择 [gormx 适配器](database/gormx/README.md),复用数据库初始化和连接池,并将 SQL 日志接入 logx。
|
||||
|
||||
## 选择接入方式
|
||||
|
||||
| 你的项目 | 从哪里开始 |
|
||||
@@ -28,7 +36,7 @@ module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可
|
||||
|
||||
```sh
|
||||
export GOPRIVATE="$(go env GOPRIVATE),git.yuntuo.hk"
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.12.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.16.0-preview
|
||||
export PATH="$(go env GOPATH)/bin:$PATH"
|
||||
go-omni --help
|
||||
```
|
||||
@@ -72,7 +80,7 @@ hello/
|
||||
├── internal/
|
||||
│ ├── config/ # 配置读取
|
||||
│ ├── svc/ # 数据库、日志、外部客户端等依赖
|
||||
│ ├── types/typesGen.go # 生成的请求与业务返回类型
|
||||
│ ├── types/*Gen.go # 按 .api 文件生成 DTO
|
||||
│ ├── handler/ # 生成的绑定、响应策略、路由及文档
|
||||
│ ├── logic/greet/ # 在这里实现业务逻辑
|
||||
│ ├── repository/greet/ # 在这里实现数据访问
|
||||
@@ -175,7 +183,7 @@ go-omni new --api catalog.api \
|
||||
--log-output file --log-format json ./catalogFull
|
||||
```
|
||||
|
||||
该命令在 `catalog.api` 所在目录执行,目标仍需是新目录。数据库迁移、表结构和 Repository 由项目维护,API 模板不自动建表;MySQL DSN 通过 `GO_OMNI_DB_DSN` 提供。存储还可选 `s3`、`r2`、`oss`,按生成项目的配置说明提供桶、端点和凭据。完整生成项目示例见 [managedService](examples/managedService/README.md)。
|
||||
该命令在 `catalog.api` 所在目录执行,目标仍需是新目录。数据库迁移、表结构和 Repository 由项目维护,API 模板不自动建表;MySQL 使用结构化配置,密码通过 `GO_OMNI_DB_PASSWORD` 提供。存储还可选 `s3`、`r2`、`oss`,按生成项目的配置说明提供桶、端点和凭据。完整生成项目示例见 [managedService](examples/managedService/README.md)。
|
||||
|
||||
`go-omni new` 与 `go-omni api new` 统一生成契约项目。需要手写项目时,可直接引用框架包;生成器不再提供另一套 CRUD 启动目录。
|
||||
|
||||
@@ -184,7 +192,7 @@ go-omni new --api catalog.api \
|
||||
在业务项目的 **go.mod 所在目录**执行:
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.12.0-preview
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.16.0-preview
|
||||
```
|
||||
|
||||
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
|
||||
@@ -382,7 +390,7 @@ return apierrors.TextTooLong("企业名称", 100)
|
||||
|
||||
生成项目在 `internal/svc` 中创建依赖,提供给 Logic/Repository,并用 `c.OnClose(db.Close)` 登记自有资源。生成的 main 在 HTTP 排空后关闭 svc,再关闭日志。直接使用框架的手写项目则可以用 `app.OnShutdown(db.Close)`;不要对同一资源重复登记。
|
||||
|
||||
已有项目使用 `driver.Connector` 时,直接 `database.OpenConnector(ctx, connector, database.DefaultPoolConfig())`。MySQL 等驱动的独立 TLS、Logger、时区与拨号参数由项目构造 Connector;框架负责连接池、启动 Ping、健康检查及事务。无需转成 DSN,也无需注册全局 TLS/Logger。具体示例和资源归属见 [Connector 接入](database/README.md#connector-接入v091-preview)。
|
||||
已有项目使用 `driver.Connector` 时,直接 `database.OpenConnector(ctx, connector, database.DefaultPoolConfig())`。MySQL 可直接用 [database/mysql](database/mysql/README.md) 配置独立 TLS、Logger、时区与拨号参数;任意驱动也可由项目构造 Connector。框架负责连接池、启动 Ping、健康检查及事务。无需转成 DSN,也无需注册全局 TLS/Logger。具体示例和资源归属见 [Connector 接入](database/README.md#connector-接入v091-preview)。
|
||||
|
||||
| 能力 | 包与接入说明 |
|
||||
| --- | --- |
|
||||
@@ -409,7 +417,7 @@ API 项目的数据库迁移需要单独维护并显式执行;启动不会自
|
||||
| `GO_OMNI_SHUTDOWN_TIMEOUT` | 优雅退出等待时间,默认 `10s` |
|
||||
| `GO_OMNI_ENABLE_LOGGER` / `GO_OMNI_LOG_LEVEL` | 访问日志开关、日志级别 |
|
||||
| `GO_OMNI_TRUSTED_PROXIES` | 可解析转发地址头的可信代理 IP/CIDR,逗号分隔 |
|
||||
| `GO_OMNI_DB_DSN` | 选择 MySQL 初始化时的数据库连接配置 |
|
||||
| `GO_OMNI_DB_HOST/USER/PASSWORD/DATABASE` | 官方 MySQL 适配器的结构化连接配置 |
|
||||
|
||||
例如在生成项目中切换 native:
|
||||
|
||||
@@ -435,6 +443,8 @@ server:
|
||||
|
||||
自研网络层通过 `RunWith(ctx, runner)` 接入监听与连接生命周期,通过 `NewWithRouter(router)` 替换路由;业务 Handler/Service 不需要导入具体适配器。细节与验收契约见 [网络适配层](transport/README.md)。
|
||||
|
||||
生成项目的依赖可调用 `svcCtx.RequestShutdown(err)` 主动申请退出,框架排空请求、清理依赖后保留故障原因并以非零状态退出。健康检查与 OpenAPI 的开关、路径通过 `server.endpoints` 配置,健康响应默认沿用项目公共体;未就绪保持 HTTP 503。完整配置和 v0.12 项目的升级步骤见 [项目指南](PROJECT.md#依赖主动申请退出)。
|
||||
|
||||
`Run` 处理 SIGINT/SIGTERM;`RunContext` 接收调用方控制的退出信号。服务先停止接收新请求,等待活跃请求完成,再清理资源;超时会取消请求并关闭连接,业务代码仍需响应 context 取消。
|
||||
|
||||
## 日常开发与升级
|
||||
@@ -452,7 +462,7 @@ go test ./...
|
||||
|
||||
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
|
||||
|
||||
CLI 与业务 module 固定使用 `v0.12.0-preview`。新布局不兼容旧 manifest;旧试验项目需在新目录生成并搬入业务文件,不能直接覆盖手写入口。新布局内部升级再执行上述生成与测试步骤,手写框架项目只需依赖升级及业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
||||
CLI 与业务 module 固定使用 `v0.16.0-preview`。v0.14.x 项目升级依赖与 CLI 后重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
||||
|
||||
## 按需深入
|
||||
|
||||
|
||||
139
RELEASE.md
139
RELEASE.md
@@ -1,4 +1,139 @@
|
||||
# v0.12.0-preview
|
||||
# v0.16.0-preview
|
||||
|
||||
补齐通用契约与生成体验,业务响应协议继续由项目定义:
|
||||
|
||||
- `returns([]string/[]int64/[]bool/[]any)` 及其他支持的基础类型数组,贯通 Logic、Handler、OpenAPI 和生成检查。nil 根数组输出 `[]`;DTO 数组、nullable、null、raw 和 204 保持原语义。字节数组明确拒绝 base64/数组歧义。
|
||||
- OpenAPI 以标准 components / `$ref` 共享重复响应、schema、示例;保留同一 HTTP 200 下的成功与失败、模板参数、数字业务码和 data:null。
|
||||
- workspace 显式配置 Go Catalog 提供方,一次导出共享 JSON,多服务通过受控根引用。支持服务/路由的文档错误列表,运行期 App 目录继承不变。check 核对导出漂移并保持项目文件不变。
|
||||
- EnvOptions.Aliases + ServerEnvAliases 统一完整配置与框架短名称的 Lookup、类型转换、空值规则。完整名称优先,显式空值不等于未设置;旧 ApplyEnv 的空值/逗号列表行为保留。
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.16.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.16.0-preview
|
||||
go-omni api generate
|
||||
go-omni api generate --check
|
||||
go test ./...
|
||||
```
|
||||
|
||||
旧项目 config/Logic/svc 继续由开发者维护,不会被重新生成覆盖。已有环境配置需按 [一次覆盖完整配置](DEVELOPMENT.md#一次覆盖完整配置与-http日志短名称) 合并 OverlayEnv 与 ApplyEnv;共享错误目录接入见 [workspace 配置](DEVELOPMENT.md#同一份-go-错误目录供多个服务生成)。已有手写权限 Handler 切换自动生成前,先迁出实现并显式移除 Manual 文件,生成器不会静默删除。
|
||||
|
||||
全量 race(含实际生成消费者)、vet、build、根入口/退出/再生成验证通过。eTax 只读契约验收:API 文档 20,751,149 → 847,171 字节,ID 文档 1,008,210 → 392,481 字节;70 个操作展开引用后与原文档完全一致。详见 [验收记录](reports/contractFollowupAcceptance.md)。这不代表已完成业务项目迁移。
|
||||
|
||||
---
|
||||
|
||||
# v0.15.0-preview(历史版本)
|
||||
|
||||
增加通用后台分阶段关闭:生成入口先完成 HTTP 排空,再运行全部 OnStop、全部 OnDrain,确认组件收尾后逆序释放 OnClose 资源,最后关闭日志。适用于任意后台组件;不引入 Orbit,不改变业务事务或任务一致性策略。
|
||||
|
||||
- svc 登记成对、同名的 OnStop/OnDrain,Context 用于早期退出通知,WorkContext 供已接受工作在收尾期间使用。同一阶段并发执行,存在内部先后依赖的组件组合登记。
|
||||
- server.lifecycle 配置 stopTimeout、drainTimeout、cancelTimeout。阶段超时取消工作并给未返回钩子收尾窗口;错误与阶段/组件名称可通过错误链追踪。
|
||||
- 不能确认后台已经结束时,返回 ErrResourcesRetained,保留共享资源和借用日志,生成进程非零退出。未返回的 Stop 不会与自己的 Drain 重叠;错误、panic 不阻止其他组件尝试收尾。
|
||||
- task.Runner 增加 StopAccepting/Wait,原 Shutdown/Close 继续可用。核心 lifecycle 不依赖 task 或其他适配器。
|
||||
- 初始化和监听失败复用同一流程;重复关闭只执行一次。生成器继续保留配置、svc 和业务代码。
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.15.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.15.0-preview
|
||||
go-omni api generate
|
||||
go-omni api generate --check
|
||||
go test ./...
|
||||
```
|
||||
|
||||
v0.14.x 无需重写 svc/config;生成器更新 main/runtimeGen,未配置时使用默认时限。也可 api upgrade --dry-run/--apply 更新依赖与生成文件,保留已有项目扩展。需要阶段保证的旧后台 OnClose 登记,应显式迁到 Stop/Drain;v0.13 及更早版本仍按原迁移指南处理。
|
||||
|
||||
全量 race(含实际生成项目)、vet、build 和隔离 MySQL/GORM 验收已通过。发布后的空缓存远程生成项目与真实 MySQL/GORM 验收也通过,CLI/框架同版且无本地 replace;标签固定在 `16deead70e860052c5b629511207578c03f9b2cb`。详见 [生命周期接入](lifecycle/README.md#后台组件分阶段关闭) 与 [验收记录](reports/phasedLifecycleAcceptance.md)。Go 无法强制结束任意 goroutine;OnClose 仍须能返回,HTTP 处理器仍须响应取消。此版不会将未知的第三方组件自动改造成优雅关闭组件。
|
||||
|
||||
---
|
||||
|
||||
# v0.14.1-preview(历史版本)
|
||||
|
||||
增加可选 `database/gormx`:GORM 复用 go-omni 已初始化的标准池、TLS、限额、健康检查和关闭流程,业务继续使用原生 GORM 模型与事务。核心 App/数据库包的编译依赖链不引入 GORM。
|
||||
|
||||
在 svc.Init 中调用 `gormx.MySQL(ctx, c.DB, gormx.WithLogger(c.Logger))`;通用 Open 工厂保留其他方言入口。适配器保护借用池不被初始化失败路径关闭,并为初始化查询设置时限。查询使用 WithContext,SQL 日志接入 logx,请求标识、慢查询和错误分类贯通;默认不输出绑定参数与驱动完整错误文本。
|
||||
|
||||
v0.14.0 项目更新 module 与 CLI 即可,不改生成目录或 main。模板最低运行时保持 v0.14.0;修正新版 CLI 把最低兼容版本误当自身版本、导致同版消费者出现版本提示的问题。其他数据库方言和管理额外池的插件未纳入本次实库验收,原生 PrepareStmt 的生命周期限制见 [接入说明](database/gormx/README.md)。
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.14.1-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.1-preview
|
||||
```
|
||||
|
||||
全量 race(含生成消费者)、vet、build,以及隔离 MySQL 的事务/取消/日志/生成项目接入通过。发布后的空缓存远程 MySQL/GORM 和生成项目验收均通过,无本地 replace,CLI 与框架使用同版。标签固定在 `b8ab6b35219d53d6de392c920fe7ecbe9a76dade`,详见 [GORM 验收](reports/gormAcceptance.md)。
|
||||
|
||||
---
|
||||
|
||||
# v0.14.0-preview(历史版本)
|
||||
|
||||
本版补齐契约驱动的业务开发与通用基础设施。项目继续维护接口、配置、依赖装配、Logic、SQL 和业务规则;生成器维护入口、请求绑定、路由、响应与文档。
|
||||
|
||||
## 安装与升级
|
||||
|
||||
```sh
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.0-preview
|
||||
go-omni new --module example.com/billing ./billing
|
||||
```
|
||||
|
||||
新生成器与运行时一起升级。已有 v0.13 项目先运行 `go-omni api upgrade --dry-run`,再按 [定点迁移步骤](DEVELOPMENT.md#v013-项目升级) 处理;不会静默覆盖自定义 svc/config。运行期的新构造器由生成器维护,项目初始化迁入 `svc.Init`,使用 `OnClose` 登记资源。
|
||||
|
||||
## 本版能力
|
||||
|
||||
- `.api` 支持根数组、可空对象、成功 null;真正 204 独立保留。生成 Logic、Handler、OpenAPI 和漂移检查使用相同语义。
|
||||
- 请求契约控制未知 query、原始 UTF-8、charset、必须/可选/禁止 body 和严格空对象。路由覆盖 App 策略,普通 JSON 只解析一次。
|
||||
- multipart 声明文件、文本和 JSON 表单字段。Logic 收到校验后的 DTO;文件有界暂存、流式读取、请求结束清理,保留底层 raw/multipart/下载扩展。
|
||||
- 完整项目配置支持环境覆盖、显式零值、空值策略、必填、嵌套及复杂类型。独立校验先于日志和连接创建。
|
||||
- `lifecycle.Runtime` 统一逆序清理、幂等关闭、失败回滚及故障保留;App Logger 独立,支持标准 `log.Logger` 与可信请求元数据。提供依赖中间件装配示例和可选 context 错误映射。
|
||||
- 官方 `database/mysql` 适配器提供结构化配置、每连接池独立 TLS/mTLS、驱动日志、启动检查与失败清理。继续返回同一 `database.DB`,不自动迁移表或重试业务事务。
|
||||
- 字段注释、示例和枚举说明进入 DTO/OpenAPI;`api format` 幂等格式化。Go 错误目录可显式导出供文档使用,无需复制错误定义。
|
||||
- 显式共享契约 roots、来源摘要、分文件 DTO、多服务 workspace 命令、基础模板升级预览;保留目录边界及业务文件所有权。
|
||||
|
||||
详见 [业务开发指南](DEVELOPMENT.md)、[数据库接入](database/mysql/README.md) 和 [验收记录](reports/businessDevelopmentAcceptance.md)。
|
||||
|
||||
全量 race(含生成项目)、vet、build 与本地进程验收通过。发布后的空缓存远程消费者和隔离 MySQL 验收也均通过;CLI/框架同版且无本地 replace。标签固定在 `f5478cdc7662704b164637649305361e52f7c598`,报告单独提交。
|
||||
|
||||
仍需业务维护:契约、配置、svc 依赖创建/探针/清理登记、错误目录、Logic、Repository 和权限中间件。特殊协议可使用保留的扩展点。声明式上传的暂存磁盘和并发量需配置容量;共享契约版本标签由项目管理,框架校验摘要但不下载依赖。此版仍为 preview,不代表 eTax/ePay 已完成业务迁移。
|
||||
|
||||
---
|
||||
|
||||
# v0.13.0-preview(历史版本)
|
||||
|
||||
本版补齐项目依赖主动退出与内置接口配置。ID 租约、数据库或后台任务等故障由项目判断,框架通过统一生命周期完成退出;不内置业务规则。
|
||||
|
||||
## 使用与升级
|
||||
|
||||
```sh
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.13.0-preview
|
||||
# 在业务 module 中执行
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.13.0-preview
|
||||
go-omni api generate
|
||||
```
|
||||
|
||||
新项目自动获得全部扩展。v0.12 的 config/svc 属于项目文件,重新生成会保留它们;需按 [升级步骤](PROJECT.md#从-v012-生成项目升级) 给 svc 补入 Lifecycle、RequestShutdown 与清理衔接,main 和 Handler 由生成器更新。旧 v0.11 目录仍按原迁移规则在新目录生成。
|
||||
|
||||
## 行为
|
||||
|
||||
- `svc.RequestShutdown(err)` 非阻塞通知:停止接收请求、排空、关闭依赖和日志。首个非空故障保留,故障退出码为 1;正常停止在没有其他故障时退出码为 0。初始化期间通知、并发通知和排空期间报告故障均可处理。
|
||||
- App 生命周期入口保留异常 context 取消原因;符合 context.Canceled 的信号取消保持正常停止。生成入口还读取 Controller.Err,保留显式依赖故障,即使该故障包装了 context.Canceled。
|
||||
- `/health/ready` 修复绕过响应配置的问题,默认与 liveness 一起使用项目 App 的响应格式。就绪成功使用项目成功码与提示;未就绪使用 `NOT_READY` 的项目错误定义,同时固定 HTTP 503。raw 模式只返回健康数据;内部探针原因仅供日志。
|
||||
- `server.endpoints` 控制三个内置 GET 接口的开关和路径,健康端点另有 responseMode。支持环境变量覆盖。OpenAPI 保持原始规范,不包业务公共体;关闭 HTTP 端点不影响生成文档。
|
||||
- `.api` 不再固定保留三个默认路径;启用的内置路径与业务 GET/参数/通配路由重叠时,启动前返回明确错误并清理已创建的依赖。关闭或改名后允许项目接管旧路径。全局中间件继续执行。
|
||||
|
||||
## 验收
|
||||
|
||||
源码测试和实际生成项目验证记录见 [生命周期与端点验收](performance/lifecycleEndpointsAcceptance.md)。复验命令:
|
||||
|
||||
```sh
|
||||
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
python3 scripts/verifyManagedProject.py --report performance/lifecycleEndpointsLocal.json
|
||||
python3 scripts/verifyManagedRelease.py --version v0.13.0-preview --report performance/lifecycleEndpointsRemote.json
|
||||
```
|
||||
|
||||
发布后的远程空缓存验收已通过,耗时 26.55 秒;[机器报告](performance/lifecycleEndpointsRemote.json) 确认 CLI 和生成项目使用同版远程 module,无本地 replace。报告单独提交,发布标签保持在代码提交 `f0991d3ff248e4cb32812c368f0f8ba28740a6c7`。此版仍为 preview;具体业务的租约失效保护、鉴权与事务回归由接入项目验证。资源清理顺序不代表能强行结束忽略 context 取消的业务 goroutine;项目 Close 必须停止并等待自己启动的后台任务。
|
||||
|
||||
---
|
||||
|
||||
# v0.12.0-preview(历史版本)
|
||||
|
||||
本版用于固定版本开展业务接入。生成器统一维护服务根目录 main.go、路由、HTTP Handler、DTO 和 OpenAPI,业务方维护 `.api`、config、svc、logic、repository 与项目中间件。目录不再生成 cmd/server、settings、servicecontext 或 service。
|
||||
|
||||
@@ -26,7 +161,7 @@ go run .
|
||||
|
||||
## 验收与范围
|
||||
|
||||
发布前全量 race 测试、go vet、go build 和本地实际生成项目进程验收通过,记录见 [生成项目验收](performance/managedProjectAcceptance.md)。远程发布后使用以下命令独立验收安装产物;结果另行提交至仓库,不改写已发布标签:
|
||||
发布前全量 race 测试、go vet、go build 和本地实际生成项目进程验收通过。发布后的空缓存远程验收也已通过,记录见 [生成项目验收](performance/managedProjectAcceptance.md) 和 [远程产物报告](performance/managedProjectRemote.json)。报告作为单独提交保存,v0.12.0-preview 标签保持在发布代码提交 `158b8002b355f09237cd12f49d9b66ee78d493f3`,没有改写。复验命令:
|
||||
|
||||
```sh
|
||||
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# GoOmni 成熟度计划
|
||||
|
||||
当前进展:`v0.14.0-preview` 已完成契约驱动的业务开发能力、通用 Runtime/配置和官方 MySQL 适配器,并通过全量 race、实际生成服务及空缓存远程验收。新项目/升级以 [README](README.md) 和 [开发指南](DEVELOPMENT.md) 为准;下方为历史阶段记录,完整本轮验收见 [报告](reports/businessDevelopmentAcceptance.md)。具体 eTax/ePay 业务迁移与业务回归仍由接入项目完成。
|
||||
|
||||
## 1. 新项目启动与发布流程(已完成,v0.1.1)
|
||||
|
||||
- 修正脚手架固定版本引用,保留显式本地开发选项。
|
||||
|
||||
14
app/app.go
14
app/app.go
@@ -14,6 +14,7 @@ import (
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"git.yuntuo.hk/k2safe/go-omni/middleware"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
|
||||
@@ -183,7 +184,8 @@ func (a *App) NewServer(address string) *http.Server {
|
||||
}
|
||||
|
||||
// RunContext serves until the context is canceled, then performs a graceful shutdown.
|
||||
func (a *App) RunContext(ctx context.Context, address ...string) error {
|
||||
func (a *App) RunContext(ctx context.Context, address ...string) (runErr error) {
|
||||
defer func() { runErr = shutdownResult(ctx, runErr) }()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
@@ -205,7 +207,8 @@ func (a *App) RunContext(ctx context.Context, address ...string) error {
|
||||
// RunWith starts a custom server runner while keeping App-owned cleanup in the
|
||||
// same lifecycle. The runner receives the transport-neutral App handler and
|
||||
// owns transport/TLS settings; built-in transport selection is bypassed.
|
||||
func (a *App) RunWith(ctx context.Context, runner Runner, address ...string) error {
|
||||
func (a *App) RunWith(ctx context.Context, runner Runner, address ...string) (runErr error) {
|
||||
defer func() { runErr = shutdownResult(ctx, runErr) }()
|
||||
if runner == nil {
|
||||
return errors.Join(errors.New("go-omni: nil runner"), a.Close())
|
||||
}
|
||||
@@ -218,6 +221,13 @@ func (a *App) RunWith(ctx context.Context, runner Runner, address ...string) err
|
||||
return errors.Join(runner.Run(ctx, a, a.listenAddress(address...)), a.Close())
|
||||
}
|
||||
|
||||
func shutdownResult(ctx context.Context, err error) error {
|
||||
if cause := lifecycle.Cause(ctx); cause != nil && !errors.Is(err, cause) {
|
||||
return errors.Join(err, cause)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) listenAddress(address ...string) string {
|
||||
listenOn := a.config.Address
|
||||
if listenOn == "" {
|
||||
|
||||
119
app/fatalShutdown_test.go
Normal file
119
app/fatalShutdown_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"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/lifecycle"
|
||||
)
|
||||
|
||||
func TestFatalShutdownDrainsAndPreservesCause(t *testing.T) {
|
||||
for _, transport := range []string{config.TransportNetHTTP, config.TransportNative} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
app := New(config.WithTransport(transport), config.WithShutdownTimeout(2*time.Second))
|
||||
control := lifecycle.New(context.Background())
|
||||
defer control.RequestShutdown(nil)
|
||||
entered, release := make(chan struct{}), make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
unblock := func() { releaseOnce.Do(func() { close(release) }) }
|
||||
defer unblock()
|
||||
var closed atomic.Int32
|
||||
closeErr := errors.New("cleanup diagnostic")
|
||||
app.OnShutdown(func() error { closed.Add(1); return closeErr })
|
||||
app.GET("/work", func(c *httpx.Context) error {
|
||||
close(entered)
|
||||
<-release
|
||||
if closed.Load() != 0 {
|
||||
return errors.New("closed during request")
|
||||
}
|
||||
return c.OK("done")
|
||||
})
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- app.ServeContext(control.Context(), listener) }()
|
||||
requestDone := make(chan error, 1)
|
||||
client := http.Client{Timeout: 3 * time.Second}
|
||||
go func() {
|
||||
res, err := client.Get("http://" + listener.Addr().String() + "/work")
|
||||
if err == nil {
|
||||
_, err = io.Copy(io.Discard, res.Body)
|
||||
_ = res.Body.Close()
|
||||
if res.StatusCode != 200 {
|
||||
err = errors.New("request failed")
|
||||
}
|
||||
}
|
||||
requestDone <- err
|
||||
}()
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("request never started")
|
||||
}
|
||||
fatal := errors.New("lease lost")
|
||||
control.RequestShutdown(fatal)
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatal("did not drain", err)
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
if closed.Load() != 0 {
|
||||
t.Fatal("closed dependency before drain")
|
||||
}
|
||||
unblock()
|
||||
select {
|
||||
case err := <-requestDone:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("request stuck")
|
||||
}
|
||||
select {
|
||||
case err := <-done:
|
||||
if !errors.Is(err, fatal) || !errors.Is(err, closeErr) {
|
||||
t.Fatal("lost cause", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("shutdown stuck")
|
||||
}
|
||||
_ = app.Close()
|
||||
if closed.Load() != 1 {
|
||||
t.Fatal("cleanup repeated")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanceledStartupPreservesFault(t *testing.T) {
|
||||
fatal := errors.New("dependency failed during startup")
|
||||
ctx, cancel := context.WithCancelCause(context.Background())
|
||||
cancel(fatal)
|
||||
app := New()
|
||||
if err := app.RunContext(ctx); !errors.Is(err, fatal) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = New().ServeContext(ctx, listener); !errors.Is(err, fatal) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
normal, stop := context.WithCancel(context.Background())
|
||||
stop()
|
||||
if err = New().RunContext(normal); err != nil {
|
||||
t.Fatal("normal stop failed", err)
|
||||
}
|
||||
}
|
||||
58
app/logIsolation_test.go
Normal file
58
app/logIsolation_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package app_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
func TestAppLogAndRequestMetadataIsolation(t *testing.T) {
|
||||
var a, b bytes.Buffer
|
||||
la, _ := logx.New(logx.Config{Writer: &a})
|
||||
lb, _ := logx.New(logx.Config{Writer: &b})
|
||||
apps := []*omni.App{
|
||||
omni.New(omni.WithLogger(true), omni.WithLogInstance(la), omni.WithTrustedProxies("127.0.0.1/32")),
|
||||
omni.New(omni.WithLogger(true), omni.WithLogInstance(lb)),
|
||||
}
|
||||
for i, app := range apps {
|
||||
app.GET("/probe/:id", func(c *httpx.Context) error {
|
||||
ctx := c.Context()
|
||||
meta := httpx.Metadata(ctx)
|
||||
expected := "127.0.0.1"
|
||||
if i == 0 {
|
||||
expected = "203.0.113.7"
|
||||
}
|
||||
if meta.ClientIP != expected || meta.Route != "/probe/:id" || meta.Method != "GET" {
|
||||
t.Error(meta)
|
||||
}
|
||||
logx.InfoContext(ctx, "business", "app", i)
|
||||
clone := c.Clone(context.WithValue(ctx, struct{}{}, true), httptest.NewRecorder())
|
||||
if httpx.Metadata(clone.Context()) != meta {
|
||||
t.Error("metadata lost")
|
||||
}
|
||||
return c.OK(nil)
|
||||
})
|
||||
}
|
||||
for _, app := range apps {
|
||||
r := httptest.NewRequest("GET", "/probe/42", nil)
|
||||
r.RemoteAddr = "127.0.0.1:1234"
|
||||
r.Header.Set("X-Forwarded-For", "203.0.113.7")
|
||||
app.ServeHTTP(httptest.NewRecorder(), r)
|
||||
}
|
||||
for i, out := range []string{a.String(), b.String()} {
|
||||
if !strings.Contains(out, "http.request") || !strings.Contains(out, "business") || strings.Contains(out, `"app":`+string(rune('0'+(1-i)))) {
|
||||
t.Fatal(out)
|
||||
}
|
||||
}
|
||||
_ = apps[0].Close()
|
||||
lb.Info("still open")
|
||||
if !strings.Contains(b.String(), "still open") {
|
||||
t.Fatal("borrowed logger closed")
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,9 @@ package app
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
@@ -26,10 +28,14 @@ func (a *App) adaptResponse(encoder response.Encoder, handler httpx.Handler, mid
|
||||
}
|
||||
}
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
request = request.WithContext(logx.WithLogger(contract.WithRequestPolicy(request.Context(), a.config.Request), a.config.LogInstance))
|
||||
if len(a.config.TrustedProxies) > 0 {
|
||||
request = request.WithContext(httpx.WithClientIPResolver(request.Context(), a.clientIPResolver))
|
||||
}
|
||||
requestContext := httpx.NewContext(request, writer, httpx.RouteParams(request.Context()))
|
||||
requestContext.WithContext(httpx.WithRequestMetadata(request.Context(), httpx.RequestMetadata{
|
||||
ClientIP: requestContext.ClientIP(), Method: request.Method, Route: httpx.RoutePattern(request.Context()),
|
||||
}))
|
||||
if provider, ok := a.responseEncoder.(response.ErrorRegistryProvider); ok {
|
||||
requestContext.SetErrorRegistry(provider.ErrorRegistry())
|
||||
}
|
||||
|
||||
@@ -17,7 +17,8 @@ import (
|
||||
// ServeContext serves a caller-created listener using the configured transport
|
||||
// and TLS. App takes ownership of the listener, including on startup failure,
|
||||
// and runs resource cleanup after the server has stopped.
|
||||
func (a *App) ServeContext(ctx context.Context, listener net.Listener) error {
|
||||
func (a *App) ServeContext(ctx context.Context, listener net.Listener) (runErr error) {
|
||||
defer func() { runErr = shutdownResult(ctx, runErr) }()
|
||||
if listener == nil {
|
||||
return errors.Join(errors.New("go-omni: nil listener"), a.Close())
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
# go-omni CLI
|
||||
|
||||
v0.16 增加基础类型/动态 JSON 数组返回、OpenAPI 公共引用和 workspace Go Catalog 统一导出。见 [接入与筛选规则](../../DEVELOPMENT.md#同一份-go-错误目录供多个服务生成)。
|
||||
|
||||
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)。
|
||||
|
||||
## 创建项目
|
||||
|
||||
```sh
|
||||
# 仓库读取权限与 PATH 配置见根目录 README
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.12.0-preview
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.16.0-preview
|
||||
go-omni new --module example.com/hello ./hello
|
||||
|
||||
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
|
||||
@@ -41,14 +45,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
|
||||
|
||||
## 版本与迁移
|
||||
|
||||
CLI 与框架依赖固定使用 v0.12.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||
CLI 与框架依赖固定使用 v0.16.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
|
||||
|
||||
```sh
|
||||
# 在 go-omni 源码仓库中执行
|
||||
go run ./cmd/go-omni new --module example.com/hello --framework-dir . ../hello
|
||||
```
|
||||
|
||||
新模板需要 v0.12.0-preview 的流式能力与 manifest v2。不兼容旧目录,旧生成项目请在新目录重新生成后移入业务代码,不覆盖手写 main。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||
模板最低需要 v0.16.0-preview 的统一环境变量别名能力,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
|
||||
|
||||
## 数据库迁移命令
|
||||
|
||||
|
||||
@@ -15,16 +15,19 @@ func runAPI(args []string) error {
|
||||
return nil
|
||||
}
|
||||
command := args[0]
|
||||
if command == "upgrade" {
|
||||
return runUpgrade(args[1:])
|
||||
}
|
||||
if command == "go" {
|
||||
command = "generate"
|
||||
}
|
||||
if command == "check" {
|
||||
command = "validate"
|
||||
}
|
||||
if command != "new" && command != "generate" && command != "validate" && command != "inspect" && command != "export" {
|
||||
if command != "new" && command != "generate" && command != "validate" && command != "inspect" && command != "export" && command != "format" {
|
||||
return fmt.Errorf("unknown api command %q", command)
|
||||
}
|
||||
readOnly := command == "validate" || command == "inspect" || command == "export"
|
||||
readOnly := command == "validate" || command == "inspect" || command == "export" || command == "format"
|
||||
o := apigen.Options{}
|
||||
for i := 1; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
@@ -33,7 +36,7 @@ func runAPI(args []string) error {
|
||||
return nil
|
||||
}
|
||||
if arg == "--check" || arg == "--dry-run" {
|
||||
if command != "generate" {
|
||||
if command != "generate" && !(command == "format" && arg == "--check") {
|
||||
return fmt.Errorf("%s belongs to api generate", arg)
|
||||
}
|
||||
if arg == "--check" {
|
||||
@@ -108,6 +111,14 @@ func runAPI(args []string) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if command == "format" {
|
||||
changes, err := apigen.FormatProject(o.API, o.Check)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("API format: %d files updated\n", len(changes))
|
||||
return nil
|
||||
}
|
||||
if readOnly {
|
||||
spec, err := apigen.Parse(o.API)
|
||||
if err != nil {
|
||||
|
||||
@@ -87,7 +87,6 @@ func TestInvalidDefinitions(t *testing.T) {
|
||||
"service user { @handler go\n @status 201\n post /go\n}",
|
||||
"type A { Name string `header:\"Name\"` }\nservice user { @handler go\n get /go returns (A)\n}",
|
||||
"@server (group: ../../bad)\nservice user { @handler go\n get /go\n}",
|
||||
"service user { @handler go\n get /health/ready\n}",
|
||||
"type A { Name string `json:\"name\"`\n Name int `json:\"other\"` }\nservice user { @handler go\n post /go (A)\n}",
|
||||
"service user { @handler go\n get /go\n @handler Go\n get /other\n}",
|
||||
"type A { Value []byte `json:\"value\"` }\nservice user { @handler go\n post /go (A)\n}",
|
||||
|
||||
104
cmd/go-omni/apigen/arrayResponse_test.go
Normal file
104
cmd/go-omni/apigen/arrayResponse_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPrimitiveArrayContracts(t *testing.T) {
|
||||
for _, name := range []string{"string", "bool", "int", "int8", "int16", "int32", "int64", "uint", "uint16", "uint32", "uint64", "float32", "float64", "any"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
spec, err := parseText(t, fmt.Sprintf("service arrays{\n@handler list\nget /list returns ([]%s)\n}", name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := Compile(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
schema := operation(t, document(t, c), "/list", "get")["responses"].(map[string]any)["200"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)
|
||||
data := schema["properties"].(map[string]any)["data"].(map[string]any)
|
||||
if data["type"] != "array" || data["items"] == nil {
|
||||
t.Fatal(data)
|
||||
}
|
||||
if name == "any" && len(data["items"].(map[string]any)) != 0 {
|
||||
t.Fatal("dynamic JSON was constrained")
|
||||
}
|
||||
if responseGoType("[]"+name) != "[]"+name || responseNeedsTypes("[]"+name) {
|
||||
t.Fatal("wrong Go signature/import")
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, name := range []string{"uint8", "File", "Unknown", "[]string"} {
|
||||
spec, err := parseText(t, fmt.Sprintf("service arrays{\n@handler list\nget /list returns ([]%s)\n}", name))
|
||||
if err == nil {
|
||||
_, err = Compile(spec)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("unsupported root accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimitiveArrayConsumer(t *testing.T) {
|
||||
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
|
||||
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
|
||||
}
|
||||
dir := filepath.Join(t.TempDir(), "arrays")
|
||||
o := Options{Dir: dir, API: filepath.Join(frameworkRoot(t), "examples/contracts/arrays.api"), Module: "example.com/arrays", FrameworkDir: frameworkRoot(t)}
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"strings", "integers", "dynamic", "empty", "raw"} {
|
||||
writeFile(t, filepath.Join(dir, "internal/logic/arrays/"+name+".go"), "package arrays\n")
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "internal/logic/arrays/values.go"), readFile(t, "testdata/arrays/values.go"))
|
||||
writeFile(t, filepath.Join(dir, "consumer_test.go"), readFile(t, "testdata/arrays/consumer_test.go"))
|
||||
o.API = filepath.Join(dir, "api/arrays.api")
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(readFile(t, filepath.Join(dir, "internal/logic/arrays/values.go")), "企业") {
|
||||
t.Fatal("logic overwritten")
|
||||
}
|
||||
o.Check = true
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "go", "test", "-race", "-mod=mod", "-count=1", "./...")
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
if os.Getenv("GO_OMNI_RELEASE_CLI") != "" {
|
||||
cmd = exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||
raw, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatal(err, string(raw))
|
||||
}
|
||||
var module struct {
|
||||
Version, Sum string
|
||||
Replace any
|
||||
}
|
||||
if err = json.Unmarshal(raw, &module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if module.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || module.Replace != nil || module.Sum == "" {
|
||||
t.Fatal("not a fixed remote consumer")
|
||||
}
|
||||
t.Logf("remote framework %s checksum %s; no local replace", module.Version, module.Sum)
|
||||
}
|
||||
|
||||
}
|
||||
91
cmd/go-omni/apigen/business_test.go
Normal file
91
cmd/go-omni/apigen/business_test.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBusinessContract(t *testing.T) {
|
||||
spec, err := Parse(filepath.Join(frameworkRoot(t), "examples/contracts/business.api"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := Compile(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err = json.Unmarshal(c.Document, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paths := doc["paths"].(map[string]any)
|
||||
body := paths["/optional"].(map[string]any)["post"].(map[string]any)["requestBody"].(map[string]any)
|
||||
if body["required"] != false {
|
||||
t.Fatal("optional body documented as required")
|
||||
}
|
||||
for _, path := range []string{"/empty", "/absent"} {
|
||||
if paths[path].(map[string]any)["post"].(map[string]any)["x-omni-request-policy"] == nil {
|
||||
t.Fatal("missing policy metadata")
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestBusinessGeneratedProject(t *testing.T) {
|
||||
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
|
||||
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
|
||||
}
|
||||
dir := filepath.Join(t.TempDir(), "business")
|
||||
o := Options{Dir: dir, API: filepath.Join(frameworkRoot(t), "examples/contracts/business.api"), Module: "example.com/business", FrameworkDir: frameworkRoot(t)}
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"upload", "nullable", "nothing", "rawNull", "rawArray", "empty", "absent", "optional", "echo", "tolerant", "noContent"} {
|
||||
writeFile(t, filepath.Join(dir, "internal/logic/demo/"+name+".go"), "package demo\n")
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "internal/logic/demo/list.go"), readFile(t, "testdata/business/operations.go"))
|
||||
writeFile(t, filepath.Join(dir, "consumer_test.go"), readFile(t, "testdata/business/consumer_test.go"))
|
||||
writeFile(t, filepath.Join(dir, "internal/middleware/accessFactory.go"), readFile(t, "testdata/business/accessFactory.go"))
|
||||
writeFile(t, filepath.Join(dir, "internal/svc/client.go"), readFile(t, "testdata/business/client.go"))
|
||||
svcFile := filepath.Join(dir, "internal/svc/serviceContext.go")
|
||||
source := strings.Replace(readFile(t, svcFile), "type ServiceContext struct {", "type ServiceContext struct {\n Client *accessClient", 1)
|
||||
source = strings.Replace(source, "return nil", "return c.initAccess(ctx)", 1)
|
||||
writeFile(t, svcFile, source)
|
||||
o.API = filepath.Join(dir, "api/business.api")
|
||||
o.Check = true
|
||||
if err := generateManagedTestProject(t, o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "go", "test", "-race", "-mod=mod", "-count=1", "./...")
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
if os.Getenv("GO_OMNI_RELEASE_CLI") != "" {
|
||||
cmd = exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||
raw, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatal(err, string(raw))
|
||||
}
|
||||
var module struct {
|
||||
Version, Sum string
|
||||
Replace any
|
||||
}
|
||||
if err = json.Unmarshal(raw, &module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if module.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || module.Replace != nil || module.Sum == "" {
|
||||
t.Fatal("not a fixed remote consumer")
|
||||
}
|
||||
t.Logf("remote framework %s checksum %s; no local replace", module.Version, module.Sum)
|
||||
}
|
||||
}
|
||||
161
cmd/go-omni/apigen/catalogDocument.go
Normal file
161
cmd/go-omni/apigen/catalogDocument.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const catalogDocumentFile = "omni.errors.json"
|
||||
|
||||
// DocumentErrors selects documentation only. Runtime App inheritance and
|
||||
// route-local @errors declarations are never changed by this metadata.
|
||||
// A nil Include lists all errors; [] lists none. Routes override Include.
|
||||
type DocumentErrors struct {
|
||||
Source string `json:"source"`
|
||||
Include []string `json:"include"`
|
||||
Routes map[string][]string `json:"routes,omitempty"`
|
||||
}
|
||||
|
||||
func readDocumentCatalog(root string, spec *Spec, overrides map[string][]byte) error {
|
||||
name := catalogDocumentFile
|
||||
path := filepath.Join(root, name)
|
||||
// Shared metadata uses the same explicitly authorized roots as .api imports.
|
||||
|
||||
if selection := spec.DocumentErrors; selection != nil {
|
||||
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
return errors.New("api: use either local omni.errors.json or shared errors.source, not both")
|
||||
}
|
||||
name = selection.Source
|
||||
alias, sub, ok := strings.Cut(strings.TrimPrefix(name, "@"), "/")
|
||||
declared, found := spec.SharedRoots[alias]
|
||||
if !strings.HasPrefix(name, "@") || !ok || !found || sub == "" || filepath.Ext(sub) != ".json" {
|
||||
return errors.New("api: errors.source must name a JSON file in a declared @root")
|
||||
}
|
||||
if err := safePath(declared.Path, sub); err != nil {
|
||||
return err
|
||||
}
|
||||
path = filepath.Join(declared.Path, filepath.FromSlash(sub))
|
||||
}
|
||||
raw, ok := overrides[path]
|
||||
if !ok {
|
||||
stat, err := os.Lstat(path)
|
||||
if errors.Is(err, os.ErrNotExist) && spec.DocumentErrors == nil {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return errors.New("api: error metadata must be a regular file")
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
raw, err = io.ReadAll(io.LimitReader(f, (1<<20)+1))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(raw) > 1<<20 {
|
||||
return errors.New("api: error metadata exceeds 1 MiB")
|
||||
}
|
||||
var catalog apierrors.Catalog
|
||||
if err := apierrors.DecodeCatalogJSON(raw, &catalog); err != nil {
|
||||
return err
|
||||
}
|
||||
registry, err := apierrors.NewRegistry(catalog)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
normalized := registry.Catalog()
|
||||
spec.DocumentCatalog = &normalized
|
||||
spec.Sources[name] = raw
|
||||
spec.SourcePaths[name] = path
|
||||
return validateDocumentSelection(spec)
|
||||
}
|
||||
|
||||
func documentRouteCatalog(spec *Spec, route Route) apierrors.Catalog {
|
||||
catalog := spec.DocumentCatalog.Clone()
|
||||
if spec.DocumentErrors == nil {
|
||||
return catalog
|
||||
}
|
||||
include := spec.DocumentErrors.Include
|
||||
if selected, ok := spec.DocumentErrors.Routes[strings.ToUpper(route.Method)+" "+route.Path]; ok {
|
||||
include = selected
|
||||
}
|
||||
if include != nil {
|
||||
selected := map[string]apierrors.Definition{}
|
||||
for _, key := range include {
|
||||
selected[key] = catalog.Definitions[key]
|
||||
}
|
||||
catalog.Definitions = selected
|
||||
}
|
||||
return catalog
|
||||
}
|
||||
|
||||
func validateDocumentSelection(spec *Spec) error {
|
||||
cfg := spec.DocumentErrors
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
validate := func(keys []string) error {
|
||||
seen := map[string]bool{}
|
||||
for _, key := range keys {
|
||||
if _, ok := spec.DocumentCatalog.Definitions[key]; !ok {
|
||||
return fmt.Errorf("api: unknown documentation error %q", key)
|
||||
}
|
||||
if seen[key] {
|
||||
return fmt.Errorf("api: duplicate documentation error %q", key)
|
||||
}
|
||||
seen[key] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := validate(cfg.Include); err != nil {
|
||||
return err
|
||||
}
|
||||
routes := map[string]Route{}
|
||||
for _, r := range spec.Routes {
|
||||
routes[strings.ToUpper(r.Method)+" "+r.Path] = r
|
||||
}
|
||||
for key, keys := range cfg.Routes {
|
||||
r, ok := routes[key]
|
||||
if !ok {
|
||||
return fmt.Errorf("api: unknown documentation route %q", key)
|
||||
}
|
||||
if len(r.Errors) > 0 {
|
||||
return fmt.Errorf("api: %s already declares route-local errors", key)
|
||||
}
|
||||
if err := validate(keys); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDocumentCatalog(spec *Spec) error {
|
||||
if spec.DocumentCatalog == nil {
|
||||
return nil
|
||||
}
|
||||
for _, r := range spec.Routes {
|
||||
if len(r.Errors) > 0 {
|
||||
continue
|
||||
}
|
||||
policy, err := response.New(responseConfig(spec, r))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = policy.ValidateErrors(documentRouteCatalog(spec, r)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -40,8 +40,17 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
if prepareErr != nil {
|
||||
return nil, prepareErr
|
||||
}
|
||||
if err := validateDocumentCatalog(spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := spec.RequestPolicy.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
definitions := map[string]Type{}
|
||||
for _, t := range spec.Types {
|
||||
if t.Name == "File" {
|
||||
return nil, errors.New("File is reserved for multipart uploads")
|
||||
}
|
||||
if t.Name == "ContractScalars" && len(spec.Scalars) > 0 {
|
||||
return nil, errors.New("ContractScalars is reserved when declaring scalars")
|
||||
}
|
||||
@@ -61,7 +70,7 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
resolving := map[string]bool{}
|
||||
var resolve func(string, int) (reflect.Type, error)
|
||||
var expression func(ast.Expr, int) (reflect.Type, error)
|
||||
primitives := map[string]reflect.Type{"any": reflect.TypeOf((*any)(nil)).Elem(), "string": reflect.TypeOf(""), "bool": reflect.TypeOf(false), "int": reflect.TypeOf(int(0)), "int8": reflect.TypeOf(int8(0)), "int16": reflect.TypeOf(int16(0)), "int32": reflect.TypeOf(int32(0)), "int64": reflect.TypeOf(int64(0)), "uint": reflect.TypeOf(uint(0)), "uint8": reflect.TypeOf(uint8(0)), "uint16": reflect.TypeOf(uint16(0)), "uint32": reflect.TypeOf(uint32(0)), "uint64": reflect.TypeOf(uint64(0)), "float32": reflect.TypeOf(float32(0)), "float64": reflect.TypeOf(float64(0))}
|
||||
|
||||
expression = func(e ast.Expr, depth int) (reflect.Type, error) {
|
||||
if depth > 32 {
|
||||
return nil, errors.New("type nesting exceeds 32")
|
||||
@@ -71,7 +80,7 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
if t, ok := scalarTypes[e.Name]; ok {
|
||||
return t, nil
|
||||
}
|
||||
if t, ok := primitives[e.Name]; ok {
|
||||
if t, ok := primitiveTypes[e.Name]; ok {
|
||||
return t, nil
|
||||
}
|
||||
return resolve(e.Name, depth+1)
|
||||
@@ -138,7 +147,7 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s.%s: %w", name, f.Name, err)
|
||||
}
|
||||
fields = append(fields, reflect.StructField{Name: f.Name, Type: t, Tag: reflect.StructTag(f.Tag)})
|
||||
fields = append(fields, reflect.StructField{Name: f.Name, Type: t, Tag: reflect.StructTag(fieldTag(f))})
|
||||
}
|
||||
t := reflect.StructOf(fields)
|
||||
resolved[name] = t
|
||||
@@ -162,7 +171,7 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
}
|
||||
handlers, paths, shapes, groups := map[string]bool{}, map[string]bool{}, map[string]string{}, map[string]bool{}
|
||||
middlewareSymbols := map[string]string{}
|
||||
for _, r := range spec.Routes {
|
||||
for routeIndex, r := range spec.Routes {
|
||||
if !goName.MatchString(r.Handler) {
|
||||
return nil, fmt.Errorf("%s: invalid handler name", r.Position)
|
||||
}
|
||||
@@ -218,9 +227,6 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
return nil, fmt.Errorf("duplicate route %s", pathKey)
|
||||
}
|
||||
paths[pathKey] = true
|
||||
if r.Path == "/health/live" || r.Path == "/health/ready" || r.Path == "/openapi.json" {
|
||||
return nil, fmt.Errorf("route %s is reserved by the generated server", r.Path)
|
||||
}
|
||||
if r.Method != strings.ToLower(r.Method) {
|
||||
return nil, errors.New("HTTP methods must be lowercase")
|
||||
}
|
||||
@@ -236,14 +242,26 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
if err := validateRouteExtensions(r); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", r.Position, err)
|
||||
}
|
||||
effectivePolicy := contract.MergePolicy(spec.RequestPolicy, r.RequestPolicy)
|
||||
if (r.Method == "get" || r.Method == "head") && effectivePolicy.Body != "" && effectivePolicy.Body != "auto" && effectivePolicy.Body != "none" {
|
||||
return nil, errors.New("GET/HEAD cannot inherit a JSON body policy; override body with auto or none")
|
||||
}
|
||||
op := openapi.Operation{Method: r.Method, Path: r.Path, OperationID: operationID(r), Summary: r.Summary, Responses: map[int]openapi.Response{}}
|
||||
if r.Request != "" {
|
||||
b, ok := c.Bindings[r.Request]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown request %s", r.Request)
|
||||
}
|
||||
r.BoundMultipart = b.HasMultipart() && !r.CustomBinding && !r.Manual
|
||||
spec.Routes[routeIndex].BoundMultipart = r.BoundMultipart
|
||||
if b.HasMultipart() && r.BodyMode != "multipart" {
|
||||
return nil, errors.New("multipart fields require @body (mode: multipart)")
|
||||
}
|
||||
op.Parameters = b.Parameters()
|
||||
op.Request = b.BodySchema()
|
||||
if op.Request != nil && (effectivePolicy.Body == "none" || effectivePolicy.Body == "emptyObject") {
|
||||
return nil, errors.New("request policy conflicts with JSON fields")
|
||||
}
|
||||
if op.Request != nil && (r.BodyMode == "raw" || r.BodyMode == "multipart") {
|
||||
return nil, errors.New("raw/multipart request DTOs may declare only path/form/header fields; use the generated body stream for content")
|
||||
}
|
||||
@@ -253,11 +271,7 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
}
|
||||
var response *openapi.Schema
|
||||
if r.Response != "" {
|
||||
b, ok := c.Bindings[r.Response]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown response %s", r.Response)
|
||||
}
|
||||
s, err := b.ResponseSchema()
|
||||
s, err := responseDataSchema(r.Response, c.Bindings)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -298,7 +312,8 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
if err = decoder.Decode(&doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
decorateDocument(doc, spec)
|
||||
decorateDocument(doc, spec, c.Bindings)
|
||||
compactDocument(doc)
|
||||
c.Document, err = json.MarshalIndent(doc, "", " ")
|
||||
return c, err
|
||||
}
|
||||
|
||||
@@ -90,7 +90,8 @@ func snapshotContract(spec *Spec) *contractSnapshot {
|
||||
}
|
||||
}
|
||||
visit(r.Request)
|
||||
visit(r.Response)
|
||||
_, responseName := responseShape(r.Response)
|
||||
visit(responseName)
|
||||
op := routeContract{
|
||||
Method: strings.ToUpper(r.Method), Path: r.Path, Group: r.Group, Handler: r.Handler,
|
||||
Request: r.Request, Response: r.Response, BodyMode: r.BodyMode, Produces: r.Produces,
|
||||
@@ -223,14 +224,14 @@ func expectedLogicSignature(r Route) string {
|
||||
if r.Request != "" {
|
||||
params = append(params, "*types."+r.Request)
|
||||
}
|
||||
if r.BodyMode == "raw" || r.BodyMode == "multipart" {
|
||||
if streamsBody(r) {
|
||||
params = append(params, "*httpx.Body")
|
||||
}
|
||||
results := []string{"error"}
|
||||
if r.Produces != "" {
|
||||
results = []string{"*httpx.Stream", "error"}
|
||||
} else if r.Response != "" {
|
||||
results = []string{"*types." + r.Response, "error"}
|
||||
} else if result := responseGoType(r.Response); result != "" {
|
||||
results = []string{result, "error"}
|
||||
}
|
||||
return displaySignature(upper(r.Handler), params, results)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package apigen
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"sort"
|
||||
@@ -119,7 +120,8 @@ func prepareMetadata(original *Spec) (*Spec, error) {
|
||||
return &spec, nil
|
||||
}
|
||||
|
||||
func decorateDocument(doc map[string]any, spec *Spec) {
|
||||
func decorateDocument(doc map[string]any, spec *Spec, bindings map[string]*contract.Binder) {
|
||||
doc["x-omni-contract-sources"] = sourceMetadata(spec)
|
||||
if spec.DefaultResponse != "" {
|
||||
doc["x-omni-default-response"] = spec.DefaultResponse
|
||||
}
|
||||
@@ -144,7 +146,12 @@ func decorateDocument(doc map[string]any, spec *Spec) {
|
||||
}
|
||||
doc["components"].(map[string]any)["securitySchemes"] = schemes
|
||||
definitions := map[string]ErrorDefinition{}
|
||||
registry := apierrors.MustRegistry(errorCatalog(spec, nil))
|
||||
declaredRegistry := apierrors.MustRegistry(errorCatalog(spec, nil))
|
||||
var documentRegistry *apierrors.Registry
|
||||
if spec.DocumentCatalog != nil {
|
||||
documentRegistry = apierrors.MustRegistry(*spec.DocumentCatalog)
|
||||
doc["x-omni-app-error-catalog"] = documentRegistry.Catalog()
|
||||
}
|
||||
for _, e := range spec.Errors {
|
||||
definitions[e.Name] = e
|
||||
}
|
||||
@@ -152,6 +159,7 @@ func decorateDocument(doc map[string]any, spec *Spec) {
|
||||
doc["x-omni-errors"] = spec.Errors
|
||||
}
|
||||
for _, r := range spec.Routes {
|
||||
registry := declaredRegistry
|
||||
policy := response.MustNew(responseConfig(spec, r))
|
||||
cfg := policy.Config()
|
||||
parts := strings.Split(r.Path, "/")
|
||||
@@ -161,7 +169,16 @@ func decorateDocument(doc map[string]any, spec *Spec) {
|
||||
}
|
||||
}
|
||||
op := doc["paths"].(map[string]any)[strings.Join(parts, "/")].(map[string]any)[r.Method].(map[string]any)
|
||||
decorateRequestOptions(op, r)
|
||||
requestRoute := r
|
||||
requestRoute.RequestPolicy = contract.MergePolicy(spec.RequestPolicy, r.RequestPolicy)
|
||||
decorateRequestOptions(op, requestRoute)
|
||||
if b := bindings[r.Request]; b != nil && b.HasMultipart() {
|
||||
encoding := map[string]any{}
|
||||
for _, name := range b.MultipartJSONFields() {
|
||||
encoding[name] = map[string]any{"contentType": "application/json"}
|
||||
}
|
||||
op["requestBody"] = map[string]any{"required": true, "content": map[string]any{"multipart/form-data": map[string]any{"schema": b.MultipartSchema(), "encoding": encoding}}}
|
||||
}
|
||||
if r.ResponseFormat != "" {
|
||||
op["x-omni-response"] = r.ResponseFormat
|
||||
}
|
||||
@@ -193,6 +210,13 @@ func decorateDocument(doc map[string]any, spec *Spec) {
|
||||
e := definitions[name]
|
||||
grouped[e.Status] = append(grouped[e.Status], e)
|
||||
}
|
||||
if len(r.Errors) == 0 && documentRegistry != nil {
|
||||
registry = documentRegistry
|
||||
op["x-omni-error-catalog-scope"] = "app"
|
||||
for key, definition := range documentRouteCatalog(spec, r).Definitions {
|
||||
grouped[definition.Status] = append(grouped[definition.Status], ErrorDefinition{Name: key, Code: key, Status: definition.Status, Message: definition.Message, BusinessCode: definition.Code, Params: definition.Params})
|
||||
}
|
||||
}
|
||||
if len(r.Errors) > 0 {
|
||||
op["x-omni-errors"] = r.Errors
|
||||
}
|
||||
|
||||
86
cmd/go-omni/apigen/documentRefs.go
Normal file
86
cmd/go-omni/apigen/documentRefs.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package apigen
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// compactDocument only replaces standard OpenAPI Reference Object positions.
|
||||
// Payloads (including examples containing a literal "$ref") are never walked.
|
||||
// Mixed HTTP 200 responses retain both success and error alternatives.
|
||||
func compactDocument(doc map[string]any) {
|
||||
type slot struct {
|
||||
parent map[string]any
|
||||
key string
|
||||
}
|
||||
components := doc["components"].(map[string]any)
|
||||
var responses, schemas, examples []slot
|
||||
for _, path := range doc["paths"].(map[string]any) {
|
||||
for _, value := range path.(map[string]any) {
|
||||
op, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rs, _ := op["responses"].(map[string]any)
|
||||
for status := range rs {
|
||||
responses = append(responses, slot{rs, status})
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
factor := func(kind string, slots []slot) {
|
||||
counts := map[string]int{}
|
||||
for _, s := range slots {
|
||||
raw, _ := json.Marshal(s.parent[s.key])
|
||||
counts[string(raw)]++
|
||||
}
|
||||
shared, _ := components[kind].(map[string]any)
|
||||
if shared == nil {
|
||||
shared = map[string]any{}
|
||||
}
|
||||
for _, s := range slots {
|
||||
raw, _ := json.Marshal(s.parent[s.key])
|
||||
count := counts[string(raw)]
|
||||
if count < 2 {
|
||||
continue
|
||||
}
|
||||
name := "Omni_" + digest(raw)
|
||||
ref := map[string]any{"$ref": "#/components/" + kind + "/" + name}
|
||||
refJSON, _ := json.Marshal(ref)
|
||||
// Keep small objects inline if components would increase size.
|
||||
if (count-1)*len(raw) <= count*len(refJSON)+len(name)+4 {
|
||||
continue
|
||||
}
|
||||
shared[name] = s.parent[s.key]
|
||||
s.parent[s.key] = map[string]any{"$ref": "#/components/" + kind + "/" + name}
|
||||
}
|
||||
if len(shared) > 0 {
|
||||
components[kind] = shared
|
||||
}
|
||||
}
|
||||
|
||||
// Share whole responses before counting inner objects. Otherwise a catalog
|
||||
// copied across 100 routes can create hundreds of unnecessary components
|
||||
// that only one shared response ultimately uses.
|
||||
factor("responses", responses)
|
||||
collectMedia := func(value any) {
|
||||
resp := value.(map[string]any)
|
||||
content, _ := resp["content"].(map[string]any)
|
||||
for _, value := range content {
|
||||
media := value.(map[string]any)
|
||||
if media["schema"] != nil {
|
||||
schemas = append(schemas, slot{media, "schema"})
|
||||
}
|
||||
es, _ := media["examples"].(map[string]any)
|
||||
for name := range es {
|
||||
examples = append(examples, slot{es, name})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, s := range responses {
|
||||
collectMedia(s.parent[s.key])
|
||||
}
|
||||
sharedResponses, _ := components["responses"].(map[string]any)
|
||||
for _, resp := range sharedResponses {
|
||||
collectMedia(resp)
|
||||
}
|
||||
factor("examples", examples)
|
||||
factor("schemas", schemas)
|
||||
}
|
||||
107
cmd/go-omni/apigen/documentRefs_test.go
Normal file
107
cmd/go-omni/apigen/documentRefs_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSharedErrorDocumentReferences(t *testing.T) {
|
||||
source := `@app(defaultResponse: Common)
|
||||
response Common(codeType:int32;successCode:0;errorCode:9999;messageField:msg;dataMode:always)
|
||||
service demo {
|
||||
`
|
||||
for i := 0; i < 16; i++ {
|
||||
kind := "string"
|
||||
if i%2 == 0 {
|
||||
kind = "int64"
|
||||
}
|
||||
source += fmt.Sprintf("@handler list%d\nget /list%d returns ([]%s)\n", i, i, kind)
|
||||
}
|
||||
source += "}\n"
|
||||
spec, err := parseText(t, source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog := apierrors.Catalog{DefaultStatus: 400, Definitions: map[string]apierrors.Definition{}}
|
||||
for i := 0; i < 80; i++ {
|
||||
status := 400
|
||||
if i%2 == 0 {
|
||||
status = 200
|
||||
}
|
||||
catalog.Definitions[fmt.Sprintf("ERROR_%d", i)] = apierrors.Definition{Status: status, Code: 400000 + i, Message: strings.Repeat("说明", 40) + "{label}不能超过{max}个字符", Params: []apierrors.Parameter{{Name: "label", Type: "string", Example: "企业名称"}, {Name: "max", Type: "int", Example: 100}}}
|
||||
}
|
||||
spec.DocumentCatalog = &catalog
|
||||
compiled, err := Compile(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var raw map[string]any
|
||||
if err = json.Unmarshal(compiled.Document, &raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
components := raw["components"].(map[string]any)
|
||||
for _, kind := range []string{"responses", "examples"} {
|
||||
if len(components[kind].(map[string]any)) == 0 {
|
||||
t.Fatal("missing shared", kind)
|
||||
}
|
||||
}
|
||||
expanded := document(t, compiled)
|
||||
for i := 0; i < 16; i++ {
|
||||
responses := operation(t, expanded, fmt.Sprintf("/list%d", i), "get")["responses"].(map[string]any)
|
||||
for _, status := range []string{"200", "400"} {
|
||||
media := responses[status].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)
|
||||
if len(media["examples"].(map[string]any)) != 40 {
|
||||
t.Fatal("examples lost")
|
||||
}
|
||||
if status == "200" {
|
||||
choices := media["schema"].(map[string]any)["anyOf"].([]any)
|
||||
if len(choices) != 2 || choices[0].(map[string]any)["properties"].(map[string]any)["data"].(map[string]any)["type"] != "array" {
|
||||
t.Fatal("200 success shape lost")
|
||||
}
|
||||
}
|
||||
for _, example := range media["examples"].(map[string]any) {
|
||||
value := example.(map[string]any)["value"].(map[string]any)
|
||||
if _, ok := value["data"]; !ok || value["data"] != nil || !strings.Contains(value["msg"].(string), "企业名称不能超过100个字符") {
|
||||
t.Fatal(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expandedBytes, _ := json.MarshalIndent(expanded, "", " ")
|
||||
if len(compiled.Document)*5 > len(expandedBytes) {
|
||||
t.Fatalf("poor dedup: %d vs %d", len(compiled.Document), len(expandedBytes))
|
||||
}
|
||||
again, err := Compile(spec)
|
||||
if err != nil || string(again.Document) != string(compiled.Document) {
|
||||
t.Fatal("non-deterministic document", err)
|
||||
}
|
||||
before, _ := json.Marshal(raw)
|
||||
compactDocument(raw)
|
||||
data, _ := json.Marshal(raw)
|
||||
if string(data) != string(before) {
|
||||
t.Fatal("compaction not idempotent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedSchemasAndLiteralExampleReference(t *testing.T) {
|
||||
doc := map[string]any{"components": map[string]any{}, "paths": map[string]any{}}
|
||||
paths := doc["paths"].(map[string]any)
|
||||
for _, name := range []string{"first", "second", "third"} {
|
||||
schema := map[string]any{"type": "object", "description": strings.Repeat("schema description ", 40)}
|
||||
example := map[string]any{"summary": strings.Repeat("example ", 50), "value": map[string]any{"$ref": "literal business field"}}
|
||||
paths["/"+name] = map[string]any{"get": map[string]any{"responses": map[string]any{"200": map[string]any{"description": name, "content": map[string]any{"application/json": map[string]any{"schema": schema, "examples": map[string]any{"same": example}}}}}}}
|
||||
}
|
||||
compactDocument(doc)
|
||||
components := doc["components"].(map[string]any)
|
||||
if len(components["schemas"].(map[string]any)) != 1 {
|
||||
t.Fatal("schema not shared")
|
||||
}
|
||||
for _, example := range components["examples"].(map[string]any) {
|
||||
if example.(map[string]any)["value"].(map[string]any)["$ref"] != "literal business field" {
|
||||
t.Fatal("payload reference interpreted")
|
||||
}
|
||||
}
|
||||
}
|
||||
127
cmd/go-omni/apigen/documentation_test.go
Normal file
127
cmd/go-omni/apigen/documentation_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFieldDocumentationAndFormat(t *testing.T) {
|
||||
source := "// 项目说明\n type Req{\n// 状态\n State int `json:\"state,options=0|1\" example:\"1\" enumDescriptions:\"[\\\"禁用\\\",\\\"启用\\\"]\"` // 状态值\n /* 第一行\n 第二行 */\nName string `json:\"name\" example:\"企业名称\"`\n}\nservice demo{\n @handler echo\npost /echo (Req) returns (Req)\n}\n"
|
||||
formatted, err := Format([]byte(source))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := Format(formatted)
|
||||
if err != nil || !bytes.Equal(formatted, again) {
|
||||
t.Fatal("format is not idempotent", err, string(again))
|
||||
}
|
||||
if !bytes.Contains(formatted, []byte(" // 状态\n State")) {
|
||||
t.Fatal(string(formatted))
|
||||
}
|
||||
spec, err := parseText(t, string(formatted))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := Compile(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if spec.Types[0].Fields[0].Description != "状态\n状态值" || spec.Types[0].Fields[1].Description != "第一行\n第二行" {
|
||||
t.Fatalf("%+v", spec.Types[0].Fields)
|
||||
}
|
||||
schema, err := c.Bindings["Req"].ResponseSchema()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := schema.Properties["state"]
|
||||
if state.Description != "状态\n状态值" || len(state.EnumDescriptions) != 2 || state.Example != json.Number("1") {
|
||||
t.Fatalf("%+v", state)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
entry := filepath.Join(dir, "demo.api")
|
||||
writeFile(t, entry, source)
|
||||
if _, err = FormatProject(entry, true); err == nil {
|
||||
t.Fatal("check accepted drift")
|
||||
}
|
||||
if readFile(t, entry) != source {
|
||||
t.Fatal("check wrote source")
|
||||
}
|
||||
if _, err = FormatProject(entry, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = FormatProject(entry, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project := filepath.Join(t.TempDir(), "demo")
|
||||
if _, err = Generate(Options{API: entry, Dir: project, Module: "example.com/docs", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dto := readFile(t, filepath.Join(project, "internal/types/"+dtoFile("demo.api:1:1")))
|
||||
if !strings.Contains(dto, "// 状态值") || !strings.Contains(dto, `description:"状态\n状态值"`) {
|
||||
t.Fatal(dto)
|
||||
}
|
||||
// Invalid examples fail before project output is created.
|
||||
bad := strings.Replace(source, "example:\"1\"", "example:\"9\"", 1)
|
||||
writeFile(t, entry, bad)
|
||||
if _, err = FormatProject(entry, false); err == nil {
|
||||
t.Fatal("invalid enum example accepted")
|
||||
}
|
||||
if data, _ := os.ReadFile(entry); string(data) != bad {
|
||||
t.Fatal("invalid source changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAlignsFieldsAndPreservesTokens(t *testing.T) {
|
||||
source := []byte("type Req {\n Name string `json:\"name\"` // 名称\n Sequence int64 `json:\"sequence\"` // 编号\n}\n")
|
||||
out, err := Format(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := Format(out)
|
||||
if err != nil || !bytes.Equal(out, again) {
|
||||
t.Fatal("alignment not idempotent", err)
|
||||
}
|
||||
lines := strings.Split(string(out), "\n")
|
||||
if strings.Index(lines[1], "`") != strings.Index(lines[2], "`") || strings.Index(lines[1], "//") != strings.Index(lines[2], "//") {
|
||||
t.Fatal("fields not aligned", string(out))
|
||||
}
|
||||
for _, literal := range []string{"`json:\"name\"`", "`json:\"sequence\"`", "// 名称", "// 编号"} {
|
||||
if !bytes.Contains(out, []byte(literal)) {
|
||||
t.Fatal("literal changed", literal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPolicyCannotCreateGETBody(t *testing.T) {
|
||||
for _, source := range []string{
|
||||
"service demo { @handler get\n @request(body: required)\n get /x }",
|
||||
"@app(request: {\"body\":\"optional\"})\n service demo { @handler get\n get /x }",
|
||||
} {
|
||||
spec, err := parseText(t, source)
|
||||
if err == nil {
|
||||
_, err = Compile(spec)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("GET body policy accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestFormatLiteralPreservation(t *testing.T) {
|
||||
source := []byte("type Demo {\n Body string `json:\"body\"\ndescription:\"literal space\"`\n}\n")
|
||||
out, err := Format(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Contains(out, []byte("`json:\"body\"\ndescription:\"literal space\"`")) {
|
||||
t.Fatal(string(out))
|
||||
}
|
||||
for _, src := range []string{"type X {", "}", "info (title:\"unterminated)"} {
|
||||
if _, err := Format([]byte(src)); err == nil {
|
||||
t.Fatal("invalid input accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
60
cmd/go-omni/apigen/endpoints_test.go
Normal file
60
cmd/go-omni/apigen/endpoints_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConfigurableBuiltinRoutesConsumer(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "endpoints")
|
||||
api := filepath.Join(t.TempDir(), "service.api")
|
||||
writeFile(t, api, "service healthApi {\n @handler customReady\n get /health/ready\n @handler customDoc\n get /openapi.json\n}\n")
|
||||
_, err := Generate(Options{Dir: dir, API: api, Module: "example.com/endpoints", FrameworkDir: frameworkRoot(t)})
|
||||
if err != nil {
|
||||
t.Fatal("built-in paths must be permitted in API contracts", err)
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "endpoints_test.go"), `package main
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"strings"
|
||||
"example.com/endpoints/internal/config"
|
||||
"example.com/endpoints/internal/svc"
|
||||
"example.com/endpoints/internal/handler"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
func TestProjectOwnsDisabledBuiltinPaths(t *testing.T){
|
||||
cfg:=config.Default();cfg.Server.Log.Writer=io.Discard
|
||||
logger,_:=logx.New(cfg.Server.Log);defer logger.Close()
|
||||
deps,err:=svc.New(context.Background(),logger,cfg);if err!=nil{t.Fatal(err)};defer deps.Close()
|
||||
if _,err=handler.NewApp(deps);err==nil || !strings.Contains(err.Error(),"overlaps business"){t.Fatal("expected preflight collision",err)}
|
||||
deps.Config.Server.Endpoints.Readiness.Enabled=false
|
||||
deps.Config.Server.Endpoints.OpenAPI.Enabled=false
|
||||
app,err:=handler.NewApp(deps);if err!=nil{t.Fatal(err)};defer app.Close()
|
||||
for _,path:=range []string{"/health/ready","/openapi.json"}{
|
||||
out:=httptest.NewRecorder();app.ServeHTTP(out,httptest.NewRequest("GET",path,nil))
|
||||
if out.Code!=501{t.Fatal("expected generated business Logic stub",path,out.Code,out.Body.String())}
|
||||
}
|
||||
deps.Config.Server.Endpoints.Readiness.Enabled=true
|
||||
deps.Config.Server.Endpoints.Readiness.Path="/readyz"
|
||||
deps.Config.Server.Endpoints.OpenAPI.Enabled=true
|
||||
deps.Config.Server.Endpoints.OpenAPI.Path="/spec.json"
|
||||
app2,err:=handler.NewApp(deps);if err!=nil{t.Fatal(err)};defer app2.Close()
|
||||
for _,path:=range []string{"/readyz","/spec.json"}{out:=httptest.NewRecorder();app2.ServeHTTP(out,httptest.NewRequest("GET",path,nil));if out.Code!=200{t.Fatal(path,out.Code)}}
|
||||
}
|
||||
`)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "go", "test", "-race", "-mod=mod", "-count=1", "./...")
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatal(err, string(output))
|
||||
}
|
||||
}
|
||||
207
cmd/go-omni/apigen/format.go
Normal file
207
cmd/go-omni/apigen/format.go
Normal file
@@ -0,0 +1,207 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/scanner"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Format normalizes indentation and blank lines without rewriting tokens,
|
||||
// comments, quoted metadata, struct tags or multiline literal contents.
|
||||
func Format(source []byte) ([]byte, error) {
|
||||
if len(source) > 1<<20 {
|
||||
return nil, errors.New("api: source size limit exceeded")
|
||||
}
|
||||
var scan scanner.Scanner
|
||||
scan.Init(bytes.NewReader(source))
|
||||
scan.Mode = scanner.ScanIdents | scanner.ScanInts | scanner.ScanStrings | scanner.ScanRawStrings | scanner.ScanComments
|
||||
var scanErr error
|
||||
scan.Error = func(s *scanner.Scanner, msg string) {
|
||||
if scanErr == nil {
|
||||
scanErr = fmt.Errorf("%s: %s", s.Position, msg)
|
||||
}
|
||||
}
|
||||
depths := map[int]int{}
|
||||
protected := map[int]bool{}
|
||||
depth := 0
|
||||
for k := scan.Scan(); k != scanner.EOF; k = scan.Scan() {
|
||||
line := scan.Position.Line
|
||||
value := scan.TokenText()
|
||||
if k == '}' || k == ')' {
|
||||
depth--
|
||||
if depth < 0 {
|
||||
return nil, errors.New("api: unmatched closing delimiter")
|
||||
}
|
||||
}
|
||||
if _, ok := depths[line]; !ok {
|
||||
depths[line] = depth
|
||||
}
|
||||
// Preserve interior lines exactly, including the closing line of a literal.
|
||||
count := strings.Count(value, "\n")
|
||||
for n := 1; n <= count; n++ {
|
||||
protected[line+n] = true
|
||||
}
|
||||
if k == '{' || k == '(' {
|
||||
depth++
|
||||
}
|
||||
}
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
if depth != 0 {
|
||||
return nil, errors.New("api: unclosed delimiter")
|
||||
}
|
||||
lines := strings.Split(string(source), "\n")
|
||||
out := make([]string, 0, len(lines))
|
||||
outProtected := map[int]bool{}
|
||||
blank := true
|
||||
lastDepth := 0
|
||||
for i, line := range lines {
|
||||
if protected[i+1] {
|
||||
out = append(out, line)
|
||||
outProtected[len(out)] = true
|
||||
blank = false
|
||||
continue
|
||||
}
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
if !blank {
|
||||
out = append(out, "")
|
||||
blank = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
d, ok := depths[i+1]
|
||||
if ok {
|
||||
lastDepth = d
|
||||
}
|
||||
out = append(out, strings.Repeat(" ", lastDepth)+trimmed)
|
||||
blank = false
|
||||
}
|
||||
for len(out) > 0 && out[len(out)-1] == "" {
|
||||
out = out[:len(out)-1]
|
||||
}
|
||||
alignFieldLines(out, outProtected)
|
||||
return []byte(strings.Join(out, "\n") + "\n"), nil
|
||||
}
|
||||
|
||||
// Only complete, single-line fields are aligned. Literal bytes and multiline
|
||||
// comment contents are never candidates, even when they resemble a field.
|
||||
var singleLineField = regexp.MustCompile("^([ \\t]*)([A-Z][A-Za-z0-9_]*)[ \\t]+([^ \\t`]+)[ \\t]+(`[^`]*`)[ \\t]*(//.*)?$")
|
||||
|
||||
func alignFieldLines(lines []string, protected map[int]bool) {
|
||||
for start := 0; start < len(lines); {
|
||||
var fields [][]string
|
||||
widths := [3]int{}
|
||||
end := start
|
||||
for end < len(lines) && !protected[end+1] {
|
||||
parts := singleLineField.FindStringSubmatch(lines[end])
|
||||
if parts == nil || (len(fields) > 0 && parts[1] != fields[0][1]) {
|
||||
break
|
||||
}
|
||||
for col := range widths {
|
||||
if n := utf8.RuneCountInString(parts[col+2]); n > widths[col] {
|
||||
widths[col] = n
|
||||
}
|
||||
}
|
||||
fields = append(fields, parts)
|
||||
end++
|
||||
}
|
||||
for i, parts := range fields {
|
||||
line := parts[1]
|
||||
for col := range widths {
|
||||
line += parts[col+2]
|
||||
if col < 2 || parts[5] != "" {
|
||||
line += strings.Repeat(" ", widths[col]-utf8.RuneCountInString(parts[col+2])+1)
|
||||
}
|
||||
}
|
||||
lines[start+i] = line + parts[5]
|
||||
}
|
||||
if end == start {
|
||||
end++
|
||||
}
|
||||
start = end
|
||||
}
|
||||
}
|
||||
|
||||
// FormatProject validates the full contract before writing any local source.
|
||||
// Check mode is read-only and reports every file needing formatting.
|
||||
func FormatProject(entry string, check bool) ([]string, error) {
|
||||
spec, err := Parse(entry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err = Compile(spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
real, err := filepath.EvalSymlinks(entry)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root := filepath.Dir(real)
|
||||
names := make([]string, 0, len(spec.Sources))
|
||||
for name := range spec.Sources {
|
||||
if strings.HasPrefix(name, "@") || filepath.Ext(name) != ".api" {
|
||||
continue
|
||||
}
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
next := map[string][]byte{}
|
||||
var changed []string
|
||||
for _, name := range names {
|
||||
data, err := Format(spec.Sources[name])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
if !bytes.Equal(data, spec.Sources[name]) {
|
||||
next[name] = data
|
||||
changed = append(changed, name)
|
||||
}
|
||||
}
|
||||
if check {
|
||||
if len(changed) > 0 {
|
||||
return changed, fmt.Errorf("API format drift: %s", strings.Join(changed, ", "))
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
for _, name := range changed {
|
||||
path := filepath.Join(root, filepath.FromSlash(name))
|
||||
old, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return changed, err
|
||||
}
|
||||
if !bytes.Equal(old, spec.Sources[name]) {
|
||||
return changed, fmt.Errorf("API source changed during formatting: %s", name)
|
||||
}
|
||||
stat, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return changed, err
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".omni-format-*")
|
||||
if err != nil {
|
||||
return changed, err
|
||||
}
|
||||
tmp := f.Name()
|
||||
err = f.Chmod(stat.Mode().Perm())
|
||||
if err == nil {
|
||||
_, err = f.Write(next[name])
|
||||
}
|
||||
err = errors.Join(err, f.Close())
|
||||
if err == nil {
|
||||
err = os.Rename(tmp, path)
|
||||
}
|
||||
_ = os.Remove(tmp)
|
||||
if err != nil {
|
||||
return changed, err
|
||||
}
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
@@ -19,10 +19,12 @@ import (
|
||||
)
|
||||
|
||||
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
const minimumVersion = "v0.12.0-preview"
|
||||
const minimumVersion = "v0.16.0-preview"
|
||||
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
||||
|
||||
type Options struct {
|
||||
lockHeld bool
|
||||
documentCatalogs map[string][]byte
|
||||
Database, Storage string
|
||||
API, Dir, Module, FrameworkDir, FrameworkVersion, LogOutput, LogFormat string
|
||||
Check bool
|
||||
@@ -47,7 +49,7 @@ func Generate(o Options) (Output, error) {
|
||||
if o.API == "" || o.Dir == "" {
|
||||
return Output{}, errors.New("--api and --dir are required")
|
||||
}
|
||||
spec, err := Parse(o.API)
|
||||
spec, err := parseWithCatalogs(o.API, o.documentCatalogs)
|
||||
if err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
@@ -95,6 +97,9 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
||||
return Output{}, err
|
||||
}
|
||||
o.Dir = directory
|
||||
if err = checkRuntimeExtension(directory); err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
for _, r := range c.Spec.Routes {
|
||||
if !r.Manual {
|
||||
path := filepath.Join(directory, "internal/handler", r.Group, lower(r.Handler)+"Manual.go")
|
||||
@@ -109,6 +114,7 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
||||
return Output{}, err
|
||||
}
|
||||
seeds := map[string][]byte{}
|
||||
requiredVersion := ""
|
||||
moduleData, readErr := os.ReadFile(filepath.Join(directory, "go.mod"))
|
||||
if readErr == nil {
|
||||
mf, err := modfile.Parse("go.mod", moduleData, nil)
|
||||
@@ -120,7 +126,6 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
||||
}
|
||||
o.Module = mf.Module.Mod.Path
|
||||
found := false
|
||||
requiredVersion := ""
|
||||
for _, r := range mf.Require {
|
||||
if r.Mod.Path == frameworkModule {
|
||||
found = true
|
||||
@@ -141,13 +146,13 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(directory, path)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(path, "httpx", "transfer.go")); err == nil {
|
||||
if source, err := os.ReadFile(filepath.Join(path, "config", "env.go")); err == nil && strings.Contains(string(source), "func ServerEnvAliases(") {
|
||||
localCurrent = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !localCurrent {
|
||||
return Output{}, fmt.Errorf("existing framework dependency predates managed startup and streaming support; use %s or a current local replacement", minimumVersion)
|
||||
return Output{}, fmt.Errorf("existing framework dependency predates the generated configuration helpers; use %s or a current local replacement", minimumVersion)
|
||||
}
|
||||
}
|
||||
if o.FrameworkDir != "" {
|
||||
@@ -191,7 +196,16 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
||||
// from its own api/ tree, including all imports.
|
||||
entry := "api/" + c.Spec.Entry
|
||||
for name, data := range c.Spec.Sources {
|
||||
seeds["api/"+name] = data
|
||||
if !strings.HasPrefix(name, "@") {
|
||||
seeds["api/"+name] = data
|
||||
}
|
||||
}
|
||||
if len(c.Spec.SharedRoots) > 0 {
|
||||
raw, e := sharedSeeds(c.Spec, directory)
|
||||
if e != nil {
|
||||
return Output{}, e
|
||||
}
|
||||
seeds["api/"+importsFile] = raw
|
||||
}
|
||||
if o.API != "" {
|
||||
source, _ := filepath.Abs(o.API)
|
||||
@@ -200,6 +214,7 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
||||
rel, e := filepath.Rel(targetReal, sourceReal)
|
||||
if e == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
entry = filepath.ToSlash(rel)
|
||||
delete(seeds, "api/"+importsFile)
|
||||
for name := range c.Spec.Sources {
|
||||
delete(seeds, "api/"+name)
|
||||
}
|
||||
@@ -208,7 +223,7 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
||||
// Source collisions must never leave a copied contract different from the
|
||||
// generated code. On updates, use the developer-owned API in the project.
|
||||
for name, data := range seeds {
|
||||
if strings.HasSuffix(name, ".api") {
|
||||
if strings.HasSuffix(name, ".api") || filepath.Base(name) == catalogDocumentFile || filepath.Base(name) == importsFile {
|
||||
current, e := os.ReadFile(filepath.Join(directory, filepath.FromSlash(name)))
|
||||
if e == nil && string(current) != string(data) {
|
||||
return Output{}, fmt.Errorf("%s already differs; regenerate from that project API instead", name)
|
||||
@@ -227,7 +242,16 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return apply(directory, o.Module, entry, generated, seeds, o.Check, o.DryRun, o.profile(), snapshotContract(c.Spec))
|
||||
out, err := apply(directory, o.Module, entry, generated, seeds, o.Check, o.DryRun, o.profile(), snapshotContract(c.Spec), o.lockHeld)
|
||||
expectedVersion := minimumVersion
|
||||
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Path == frameworkModule && info.Main.Sum != "" && semver.IsValid(info.Main.Version) {
|
||||
expectedVersion = info.Main.Version
|
||||
}
|
||||
if requiredVersion != "" && requiredVersion != expectedVersion {
|
||||
out.Diagnostics = append(out.Diagnostics, Diagnostic{Severity: "review", Code: "frameworkVersion", Path: "go.mod", Before: requiredVersion, After: expectedVersion, Message: "project dependency differs from this CLI version; use matching CLI/framework versions, or verify the explicit local replacement"})
|
||||
sortDiagnostics(out.Diagnostics)
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func moduleFile(o Options) ([]byte, error) {
|
||||
@@ -336,6 +360,13 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
}
|
||||
|
||||
types := "package types\n\n"
|
||||
needsUpload := false
|
||||
for _, b := range c.Bindings {
|
||||
needsUpload = needsUpload || b.HasMultipart()
|
||||
}
|
||||
if needsUpload {
|
||||
types += fmt.Sprintf("import %q\n", frameworkModule+"/contract")
|
||||
}
|
||||
if len(c.Spec.Scalars) > 0 {
|
||||
types += "import("
|
||||
for i, d := range c.Spec.Scalars {
|
||||
@@ -350,12 +381,28 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
registry += "}}\n"
|
||||
put(generated, "internal/types/scalarsGen.go", generatedHeader+registry)
|
||||
}
|
||||
if needsUpload {
|
||||
types += "type File = contract.File\n"
|
||||
}
|
||||
bySource := map[string]string{}
|
||||
for _, t := range c.Spec.Types {
|
||||
types += "type " + t.Name + " struct {\n"
|
||||
for _, f := range t.Fields {
|
||||
types += fmt.Sprintf("%s %s `%s`\n", f.Name, f.Type, f.Tag)
|
||||
name := dtoFile(t.Position)
|
||||
if bySource[name] == "" {
|
||||
bySource[name] = "package types\n\n"
|
||||
}
|
||||
types += "}\n"
|
||||
text := "type " + t.Name + " struct {\n"
|
||||
for _, f := range t.Fields {
|
||||
if f.Description != "" {
|
||||
for _, line := range strings.Split(f.Description, "\n") {
|
||||
text += "// " + line + "\n"
|
||||
}
|
||||
}
|
||||
text += fmt.Sprintf("%s %s `%s`\n", f.Name, f.Type, fieldTag(f))
|
||||
}
|
||||
bySource[name] += text + "}\n"
|
||||
}
|
||||
for name, text := range bySource {
|
||||
put(generated, "internal/types/"+name, generatedHeader+text)
|
||||
}
|
||||
put(generated, "internal/types/typesGen.go", generatedHeader+types)
|
||||
put(generated, "docs/openapi.json", string(c.Document)+"\n")
|
||||
@@ -408,8 +455,8 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
continue
|
||||
}
|
||||
hasAutomatic = true
|
||||
needsTypes = needsTypes || r.Request != "" || r.Response != ""
|
||||
needsBinder = needsBinder || (r.Request != "" && !r.CustomBinding)
|
||||
needsTypes = needsTypes || r.Request != "" || responseNeedsTypes(r.Response)
|
||||
needsBinder = needsBinder || !r.CustomBinding
|
||||
}
|
||||
text += fmt.Sprintf("%q;%q;", frameworkModule+"/httpx", o.Module+"/internal/svc")
|
||||
if needsTypes {
|
||||
@@ -442,12 +489,18 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
put(seeds, "internal/handler/"+group+"/"+lower(r.Handler)+"Manual.go", fmt.Sprintf("package %s\nimport(%q;apierrors %q)\n// %s owns raw/multipart binding and response output. h.deps holds project dependencies.\n// This file is preserved on regeneration.\nfunc(h *Handler)%s(c *httpx.Context)error{return apierrors.New(501,\"NOT_IMPLEMENTED\",\"manual handler is not implemented\")}\n", packageName, frameworkModule+"/httpx", frameworkModule+"/errors", name, name))
|
||||
continue
|
||||
}
|
||||
if r.Request != "" && !r.CustomBinding {
|
||||
if !r.CustomBinding {
|
||||
scalarArgs := ""
|
||||
if len(c.Spec.Scalars) > 0 {
|
||||
scalarArgs = "types.ContractScalars()..."
|
||||
}
|
||||
text += fmt.Sprintf("var %sRequest = contract.MustNew[types.%s](%s)\n", lower(r.Handler), r.Request, scalarArgs)
|
||||
requestType := "struct{}"
|
||||
if r.Request != "" {
|
||||
requestType = "types." + r.Request
|
||||
} else {
|
||||
scalarArgs = ""
|
||||
}
|
||||
text += fmt.Sprintf("var %sRequest = contract.MustNew[%s](%s)\n", lower(r.Handler), requestType, scalarArgs)
|
||||
}
|
||||
text += fmt.Sprintf("func(h *Handler) %s(c *httpx.Context)error {\n", name)
|
||||
call := "c.Context()"
|
||||
@@ -455,16 +508,32 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
if r.CustomBinding {
|
||||
text += fmt.Sprintf("var req types.%s\nif err:=h.bind%s(c,&req);err!=nil{return err}\n", r.Request, name)
|
||||
put(seeds, "internal/handler/"+group+"/"+lower(r.Handler)+"Bind.go", fmt.Sprintf("package %s\nimport(%q;%q;apierrors %q)\n// bind%s is project-owned; repeated generation preserves it.\nfunc(h *Handler)bind%s(c *httpx.Context,req *types.%s)error{return apierrors.New(501,\"BINDING_NOT_IMPLEMENTED\",\"custom binding is not implemented\")}\n", packageName, frameworkModule+"/httpx", o.Module+"/internal/types", frameworkModule+"/errors", name, name, r.Request))
|
||||
} else if r.BoundMultipart {
|
||||
text += fmt.Sprintf("var req types.%s\ncleanup,err:=%sRequest.BindMultipart(c.Request(),httpx.RouteParams(c.Context()),&req,contract.MultipartOptions{MaxBodyBytes:%d,MaxParts:%d,MaxFieldBytes:%d,MaxFileBytes:%d,Policy:%s});if err!=nil{return err};defer cleanup()\n", r.Request, lower(r.Handler), r.MaxBodyBytes, r.MaxParts, r.MaxFieldBytes, r.MaxFileBytes, policyLiteral(r.RequestPolicy))
|
||||
} else {
|
||||
limit := ""
|
||||
if r.MaxBodyBytes > 0 {
|
||||
limit = fmt.Sprintf(",%d", r.MaxBodyBytes)
|
||||
}
|
||||
text += fmt.Sprintf("var req types.%s\nif err:=%sRequest.Bind(c.Request(),httpx.RouteParams(c.Context()),&req%s);err!=nil{return err}\n", r.Request, lower(r.Handler), limit)
|
||||
policy := r.RequestPolicy
|
||||
if r.BodyMode == "raw" || r.BodyMode == "multipart" {
|
||||
policy.Body = "auto"
|
||||
}
|
||||
text += fmt.Sprintf("var req types.%s\nif err:=%sRequest.BindWithPolicy(c.Request(),httpx.RouteParams(c.Context()),&req,%s%s);err!=nil{return err}\n", r.Request, lower(r.Handler), policyLiteral(policy), limit)
|
||||
}
|
||||
call += ", &req"
|
||||
} else {
|
||||
limit := ""
|
||||
if r.MaxBodyBytes > 0 {
|
||||
limit = fmt.Sprintf(",%d", r.MaxBodyBytes)
|
||||
}
|
||||
policy := r.RequestPolicy
|
||||
if r.BodyMode == "raw" || r.BodyMode == "multipart" {
|
||||
policy.Body = "auto"
|
||||
}
|
||||
text += fmt.Sprintf("if err:=%sRequest.BindWithPolicy(c.Request(),httpx.RouteParams(c.Context()),&struct{}{},%s%s);err!=nil{return err}\n", lower(r.Handler), policyLiteral(policy), limit)
|
||||
}
|
||||
streamingBody := r.BodyMode == "raw" || r.BodyMode == "multipart"
|
||||
streamingBody := streamsBody(r)
|
||||
if streamingBody {
|
||||
text += fmt.Sprintf("body,err:=httpx.NewBody(c.Request(),%q);if err!=nil{return err}\n", r.BodyMode)
|
||||
call += ",body"
|
||||
@@ -475,8 +544,15 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
text += "if err:=body.Complete();err!=nil{if resp!=nil{_ =resp.Close()};return err}\n"
|
||||
}
|
||||
text += fmt.Sprintf("return c.WriteStream(%d,%q,resp)\n}\n", r.Status, r.Produces)
|
||||
} else if r.Response != "" {
|
||||
text += fmt.Sprintf("resp,err:=h.logic.%s(%s);if err!=nil{return err};if resp==nil{return fmt.Errorf(\"%s returned a nil response\")}\n", name, call, name)
|
||||
} else if responseGoType(r.Response) != "" {
|
||||
text += fmt.Sprintf("resp,err:=h.logic.%s(%s);if err!=nil{return err};\n", name, call)
|
||||
kind, _ := responseShape(r.Response)
|
||||
if kind == "object" {
|
||||
text += fmt.Sprintf("if resp==nil{return fmt.Errorf(%q)}\n", name+" returned a nil response")
|
||||
}
|
||||
if kind == "array" {
|
||||
text += "if resp==nil{resp=make(" + responseGoType(r.Response) + ",0)}\n"
|
||||
}
|
||||
if streamingBody {
|
||||
text += "if err:=body.Complete();err!=nil{return err}\n"
|
||||
}
|
||||
@@ -487,10 +563,15 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
if streamingBody {
|
||||
text += "if err:=body.Complete();err!=nil{return err};"
|
||||
}
|
||||
text += "return c.NoContent()\n}\n"
|
||||
if r.Response == "null" {
|
||||
method := map[int]string{200: "OK", 201: "Created", 202: "Accepted"}[r.Status]
|
||||
text += "return c." + method + "(nil)\n}\n"
|
||||
} else {
|
||||
text += "return c.NoContent()\n}\n"
|
||||
}
|
||||
}
|
||||
service := fmt.Sprintf("package %s\nimport(\"context\"; apierrors %q;", packageName, frameworkModule+"/errors")
|
||||
if r.Request != "" || r.Response != "" {
|
||||
if r.Request != "" || responseNeedsTypes(r.Response) {
|
||||
service += fmt.Sprintf("%q;", o.Module+"/internal/types")
|
||||
}
|
||||
if streamingBody || r.Produces != "" {
|
||||
@@ -503,7 +584,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
service += "s.deps.Logger.InfoContext(ctx,\"greet.called\")\nreturn &types.GreetResp{Message:fmt.Sprintf(\"Hello %s\",req.Name)},nil\n}"
|
||||
} else {
|
||||
service += "// TODO: implement business behavior and use s.deps for dependencies.\n"
|
||||
if r.Response != "" || r.Produces != "" {
|
||||
if responseGoType(r.Response) != "" || r.Produces != "" {
|
||||
service += "return nil, "
|
||||
} else {
|
||||
service += "return "
|
||||
@@ -549,7 +630,11 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
return nil, nil, err
|
||||
}
|
||||
for name, data := range infra {
|
||||
seeds[name] = data
|
||||
if strings.HasSuffix(name, "Gen.go") {
|
||||
generated[name] = append([]byte(generatedHeader), data...)
|
||||
} else {
|
||||
seeds[name] = data
|
||||
}
|
||||
}
|
||||
if o.Storage == "local" {
|
||||
for _, r := range c.Spec.Routes {
|
||||
@@ -564,7 +649,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
}
|
||||
func hasResponse(routes []Route) bool {
|
||||
for _, r := range routes {
|
||||
if r.Response != "" && !r.Manual && r.Produces == "" {
|
||||
if kind, _ := responseShape(r.Response); r.Response != "" && kind == "object" && !r.Manual && r.Produces == "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -575,17 +660,41 @@ func signature(r Route) string {
|
||||
if r.Request != "" {
|
||||
s += ",req *types." + r.Request
|
||||
}
|
||||
if r.BodyMode == "raw" || r.BodyMode == "multipart" {
|
||||
if streamsBody(r) {
|
||||
s += ",body *httpx.Body"
|
||||
}
|
||||
s += ") "
|
||||
if r.Produces != "" {
|
||||
return s + "(*httpx.Stream,error)"
|
||||
}
|
||||
if r.Response != "" {
|
||||
s += "(*types." + r.Response + ",error)"
|
||||
if result := responseGoType(r.Response); result != "" {
|
||||
s += "(" + result + ",error)"
|
||||
} else {
|
||||
s += "error"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func streamsBody(r Route) bool {
|
||||
return r.BodyMode == "raw" || (r.BodyMode == "multipart" && !r.BoundMultipart)
|
||||
}
|
||||
|
||||
func dtoFile(position string) string {
|
||||
source := position
|
||||
for i := 0; i < 2; i++ {
|
||||
if idx := strings.LastIndex(source, ":"); idx >= 0 {
|
||||
source = source[:idx]
|
||||
}
|
||||
}
|
||||
stem := strings.TrimSuffix(filepath.Base(source), ".api")
|
||||
var b strings.Builder
|
||||
for _, r := range stem {
|
||||
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
b.WriteString("contract")
|
||||
}
|
||||
return "dto" + upper(b.String()) + digest([]byte(source))[:12] + "Gen.go"
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func checkLogic(root, module string, contract *contractSnapshot, seeds map[strin
|
||||
typeExtensions := false
|
||||
if entries, err := os.ReadDir(filepath.Join(root, "internal/types")); err == nil {
|
||||
for _, e := range entries {
|
||||
if sourceFile(e.Name()) && e.Name() != "typesGen.go" && e.Name() != "scalarsGen.go" {
|
||||
if sourceFile(e.Name()) && !ownedPath("internal/types/"+e.Name()) {
|
||||
typeExtensions = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ func TestManagedGeneratedProject(t *testing.T) {
|
||||
mainBefore := readFile(t, filepath.Join(dir, "main.go"))
|
||||
files := map[string]string{
|
||||
"consumer_test.go": "consumer_test.go", "internal/svc/options.go": "options.go", "internal/svc/client.go": "client.go", "internal/logic/demo/check.go": "operations.go",
|
||||
"shutdown_test.go": "shutdown_test.go",
|
||||
"phases_test.go": "phases_test.go", "internal/svc/background.go": "background.go",
|
||||
}
|
||||
for path, fixture := range files {
|
||||
writeFile(t, filepath.Join(dir, path), readFile(t, filepath.Join("testdata/managed", fixture)))
|
||||
@@ -78,10 +80,10 @@ func TestManagedGeneratedProject(t *testing.T) {
|
||||
writeFile(t, filepath.Join(dir, "internal/logic/demo", name+".go"), "package demo\n")
|
||||
}
|
||||
configPath := filepath.Join(dir, "internal/config/config.go")
|
||||
writeFile(t, configPath, strings.Replace(readFile(t, configPath), "type Config struct {", "type Config struct {\n FailInit bool `json:\"failInit\"`", 1))
|
||||
writeFile(t, configPath, strings.Replace(readFile(t, configPath), "type Config struct {", "type Config struct {\n BackgroundMode string `json:\"backgroundMode\"`\n FailInit bool `json:\"failInit\"`\n ShutdownInit bool `json:\"shutdownInit\"`", 1))
|
||||
svcPath := filepath.Join(dir, "internal/svc/serviceContext.go")
|
||||
source := strings.Replace(readFile(t, svcPath), "type ServiceContext struct {", "type ServiceContext struct {\n Client *Client", 1)
|
||||
source = strings.Replace(source, "return c, nil", "if err=initClient(ctx,c);err!=nil{return nil,err}\n return c,nil", 1)
|
||||
source = strings.Replace(source, "return nil", "return initClient(ctx,c)", 1)
|
||||
writeFile(t, svcPath, source)
|
||||
// Independent tooling stays outside generator ownership.
|
||||
writeFile(t, filepath.Join(dir, "cmd/migrate/main.go"), "package main\nfunc main(){}\n")
|
||||
|
||||
@@ -74,7 +74,7 @@ func document(t *testing.T, c *Compiled) map[string]any {
|
||||
if err := json.Unmarshal(c.Document, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return doc
|
||||
return expandDocumentRefs(t, doc, doc).(map[string]any)
|
||||
}
|
||||
func operation(t *testing.T, doc map[string]any, path, method string) map[string]any {
|
||||
t.Helper()
|
||||
@@ -303,3 +303,21 @@ func TestAuthMetadataWithParameterNamePrefixes(t *testing.T) {
|
||||
t.Fatal("auth metadata lost")
|
||||
}
|
||||
}
|
||||
|
||||
// Existing semantic assertions resolve the standard local references emitted
|
||||
// by the compiler. documentRefs_test additionally checks the actual references.
|
||||
func expandDocumentRefs(t *testing.T, doc map[string]any, v any) any {
|
||||
t.Helper()
|
||||
switch v:=v.(type){
|
||||
case map[string]any:
|
||||
if ref,ok:=v["$ref"].(string);ok && strings.HasPrefix(ref,"#/components/") && len(v)==1 {
|
||||
var target any=doc
|
||||
for _,part:=range strings.Split(strings.TrimPrefix(ref,"#/"),"/"){m,ok:=target.(map[string]any);if !ok{t.Fatal("invalid reference",ref)};target=m[part]}
|
||||
if target==nil{t.Fatal("dangling reference",ref)};return expandDocumentRefs(t,doc,target)
|
||||
}
|
||||
out:=map[string]any{};for k,x:=range v{out[k]=expandDocumentRefs(t,doc,x)};return out
|
||||
case []any:
|
||||
out:=make([]any,len(v));for i,x:=range v{out[i]=expandDocumentRefs(t,doc,x)};return out
|
||||
default:return v
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ package apigen
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -14,6 +16,11 @@ import (
|
||||
)
|
||||
|
||||
type Spec struct {
|
||||
DocumentCatalog *apierrors.Catalog
|
||||
DocumentErrors *DocumentErrors
|
||||
SharedRoots map[string]SharedRoot
|
||||
SourcePaths map[string]string
|
||||
RequestPolicy contract.RequestPolicy
|
||||
DefaultResponse string
|
||||
appDeclared bool
|
||||
Title, Version, Service, Description string
|
||||
@@ -32,8 +39,12 @@ type Type struct {
|
||||
Fields []Field
|
||||
Position string
|
||||
}
|
||||
type Field struct{ Name, Type, Tag string }
|
||||
type Field struct{ Name, Type, Tag, Description string }
|
||||
type Route struct {
|
||||
BoundMultipart bool
|
||||
MaxParts int
|
||||
MaxFieldBytes, MaxFileBytes int64
|
||||
RequestPolicy contract.RequestPolicy
|
||||
Manual, CustomBinding bool
|
||||
BodyMode, Produces string
|
||||
MaxBodyBytes int64
|
||||
@@ -51,6 +62,7 @@ type Route struct {
|
||||
}
|
||||
type token struct {
|
||||
text, position string
|
||||
comment string
|
||||
kind rune
|
||||
space bool
|
||||
}
|
||||
@@ -61,7 +73,9 @@ type parser struct {
|
||||
imports []string
|
||||
}
|
||||
|
||||
func Parse(path string) (*Spec, error) {
|
||||
func Parse(path string) (*Spec, error) { return parseWithCatalogs(path, nil) }
|
||||
|
||||
func parseWithCatalogs(path string, catalogs map[string][]byte) (*Spec, error) {
|
||||
entry, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -71,11 +85,16 @@ func Parse(path string) (*Spec, error) {
|
||||
return nil, err
|
||||
}
|
||||
root := filepath.Dir(entry)
|
||||
spec := &Spec{Version: "1.0.0", Sources: map[string][]byte{}, Entry: filepath.Base(entry)}
|
||||
shared, err := readSharedConfig(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
roots := shared.Roots
|
||||
spec := &Spec{Version: "1.0.0", DocumentErrors: shared.Errors, SharedRoots: roots, SourcePaths: map[string]string{}, Sources: map[string][]byte{}, Entry: filepath.Base(entry)}
|
||||
visiting, visited := map[string]bool{}, map[string]bool{}
|
||||
total := 0
|
||||
var visit func(string, int) error
|
||||
visit = func(name string, depth int) error {
|
||||
var visit func(string, string, int) error
|
||||
visit = func(name, alias string, depth int) error {
|
||||
if depth > 32 || len(visited)+len(visiting) >= 64 {
|
||||
return errors.New("api: import limit exceeded")
|
||||
}
|
||||
@@ -83,9 +102,16 @@ func Parse(path string) (*Spec, error) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(root, real)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return errors.New("api: imports must stay within the entry API directory")
|
||||
sourceRoot := root
|
||||
if alias != "" {
|
||||
sourceRoot = roots[alias].Path
|
||||
}
|
||||
rel, err := sourceWithin(sourceRoot, real)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if alias != "" {
|
||||
rel = "@" + alias + "/" + rel
|
||||
}
|
||||
if filepath.Ext(real) != ".api" {
|
||||
return errors.New("api: expected .api file")
|
||||
@@ -120,18 +146,37 @@ func Parse(path string) (*Spec, error) {
|
||||
return err
|
||||
}
|
||||
spec.Sources[filepath.ToSlash(rel)] = data
|
||||
spec.SourcePaths[filepath.ToSlash(rel)] = real
|
||||
for _, imp := range p.imports {
|
||||
if filepath.IsAbs(imp) || strings.Contains(imp, "\\") {
|
||||
return errors.New("api: imports must use relative slash paths")
|
||||
}
|
||||
if err = visit(filepath.Join(filepath.Dir(real), filepath.FromSlash(imp)), depth+1); err != nil {
|
||||
nextAlias := alias
|
||||
next := filepath.Join(filepath.Dir(real), filepath.FromSlash(imp))
|
||||
if strings.HasPrefix(imp, "@") {
|
||||
var sub string
|
||||
var ok bool
|
||||
nextAlias, sub, ok = strings.Cut(strings.TrimPrefix(imp, "@"), "/")
|
||||
declared, found := roots[nextAlias]
|
||||
if !ok || !found || sub == "" || strings.HasPrefix(sub, "/") || strings.HasPrefix(sub, "../") || filepath.ToSlash(filepath.Clean(sub)) != sub {
|
||||
return errors.New("api: import needs a declared @root and a clean relative path")
|
||||
}
|
||||
next = filepath.Join(declared.Path, filepath.FromSlash(sub))
|
||||
}
|
||||
if err = visit(next, nextAlias, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
visited[real] = true
|
||||
return nil
|
||||
}
|
||||
if err = visit(entry, 0); err != nil {
|
||||
if err = visit(entry, "", 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = readDocumentCatalog(root, spec, catalogs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = validateSharedPins(spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec.Service == "" || len(spec.Routes) == 0 {
|
||||
@@ -146,7 +191,7 @@ func lex(name, source string) ([]token, error) {
|
||||
var s scanner.Scanner
|
||||
s.Init(strings.NewReader(source))
|
||||
s.Filename = name
|
||||
s.Mode = scanner.ScanIdents | scanner.ScanInts | scanner.ScanStrings | scanner.ScanRawStrings | scanner.ScanComments | scanner.SkipComments
|
||||
s.Mode = scanner.ScanIdents | scanner.ScanInts | scanner.ScanStrings | scanner.ScanRawStrings | scanner.ScanComments
|
||||
s.Whitespace ^= 1 << '\n'
|
||||
var scanErr error
|
||||
s.Error = func(s *scanner.Scanner, message string) {
|
||||
@@ -156,9 +201,29 @@ func lex(name, source string) ([]token, error) {
|
||||
}
|
||||
var out []token
|
||||
end := 0
|
||||
pending := ""
|
||||
lastLine := 0
|
||||
lastToken := -1
|
||||
for {
|
||||
kind := s.Scan()
|
||||
out = append(out, token{s.TokenText(), s.Position.String(), kind, s.Position.Offset > end})
|
||||
if kind == scanner.Comment {
|
||||
comment := cleanComment(s.TokenText())
|
||||
if lastToken >= 0 && s.Position.Line == lastLine {
|
||||
out[lastToken].comment = joinComment(out[lastToken].comment, comment)
|
||||
} else {
|
||||
pending = joinComment(pending, comment)
|
||||
}
|
||||
end = s.Position.Offset + len(s.TokenText())
|
||||
continue
|
||||
}
|
||||
current := token{text: s.TokenText(), position: s.Position.String(), kind: kind, space: s.Position.Offset > end}
|
||||
if kind != '\n' {
|
||||
current.comment = pending
|
||||
pending = ""
|
||||
lastLine = s.Position.Line
|
||||
lastToken = len(out)
|
||||
}
|
||||
out = append(out, current)
|
||||
end = s.Position.Offset + len(s.TokenText())
|
||||
if kind == scanner.EOF {
|
||||
break
|
||||
@@ -315,8 +380,20 @@ func (p *parser) parse() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(m) != 1 || m["defaultResponse"] == "" {
|
||||
return p.err("@app requires only defaultResponse: TemplateName")
|
||||
if len(m) == 0 {
|
||||
return p.err("@app requires defaultResponse or request")
|
||||
}
|
||||
for key, value := range m {
|
||||
switch key {
|
||||
case "defaultResponse":
|
||||
case "request":
|
||||
p.spec.RequestPolicy, err = decodeRequestPolicy(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return p.err("unsupported @app property " + key)
|
||||
}
|
||||
}
|
||||
p.spec.DefaultResponse = m["defaultResponse"]
|
||||
p.spec.appDeclared = true
|
||||
@@ -422,6 +499,7 @@ func (p *parser) parseType() error {
|
||||
}
|
||||
typ := Type{Name: name, Position: pos}
|
||||
for p.skip(); p.peek() != "}"; p.skip() {
|
||||
description := p.tokens[p.at].comment
|
||||
name, err := p.ident()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -444,11 +522,13 @@ func (p *parser) parseType() error {
|
||||
if p.tokens[p.at].kind != scanner.RawString {
|
||||
return p.err("field source tag required")
|
||||
}
|
||||
tag, err := strconv.Unquote(p.next().text)
|
||||
tagToken := p.next()
|
||||
description = joinComment(description, tagToken.comment)
|
||||
tag, err := strconv.Unquote(tagToken.text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
typ.Fields = append(typ.Fields, Field{Name: name, Type: expression, Tag: tag})
|
||||
typ.Fields = append(typ.Fields, Field{Name: name, Type: expression, Tag: tag, Description: description})
|
||||
}
|
||||
p.next()
|
||||
p.spec.Types = append(p.spec.Types, typ)
|
||||
@@ -496,6 +576,12 @@ func (p *parser) parseService(server map[string]string) error {
|
||||
}
|
||||
seen[annotation] = true
|
||||
switch annotation {
|
||||
case "request":
|
||||
var props map[string]string
|
||||
props, e = p.metadata()
|
||||
if e == nil {
|
||||
route.RequestPolicy, e = parseRequestPolicy(props)
|
||||
}
|
||||
case "handlerMode":
|
||||
mode, err := p.ident()
|
||||
if err != nil {
|
||||
@@ -589,9 +675,48 @@ func (p *parser) routeType() (string, error) {
|
||||
if err := p.expect("("); err != nil {
|
||||
return "", err
|
||||
}
|
||||
prefix := ""
|
||||
if p.peek() == "[" {
|
||||
p.next()
|
||||
if err := p.expect("]"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
prefix = "[]"
|
||||
} else if p.peek() == "*" {
|
||||
p.next()
|
||||
prefix = "*"
|
||||
}
|
||||
name, err := p.ident()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return name, p.expect(")")
|
||||
return prefix + name, p.expect(")")
|
||||
}
|
||||
|
||||
func joinComment(a, b string) string {
|
||||
if a == "" {
|
||||
return b
|
||||
}
|
||||
if b == "" {
|
||||
return a
|
||||
}
|
||||
return a + "\n" + b
|
||||
}
|
||||
func cleanComment(s string) string {
|
||||
if strings.HasPrefix(s, "//") {
|
||||
return strings.TrimSpace(strings.TrimPrefix(s, "//"))
|
||||
}
|
||||
s = strings.TrimSuffix(strings.TrimPrefix(s, "/*"), "*/")
|
||||
lines := strings.Split(s, "\n")
|
||||
for i, line := range lines {
|
||||
lines[i] = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "*"))
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
}
|
||||
func fieldTag(f Field) string {
|
||||
tags, err := contract.ParseTags(f.Tag)
|
||||
if err != nil || f.Description == "" || tags["description"] != "" {
|
||||
return f.Tag
|
||||
}
|
||||
return f.Tag + " description:" + strconv.Quote(f.Description)
|
||||
}
|
||||
|
||||
@@ -73,6 +73,8 @@ func TestConfiguration(t *testing.T){
|
||||
cfg,err:=Load("../../etc/config.yaml");if err!=nil{t.Fatal(err)}
|
||||
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_SERVER_LOG_FILE","./explicit.log");cfg,err=Load("");if err!=nil||cfg.Server.Log.File!="./explicit.log"{t.Fatalf("canonical log file: %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)}
|
||||
_=os.Getenv("unused")
|
||||
}`
|
||||
endpoint := ""
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"mime"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -16,6 +18,21 @@ func (p *parser) parseBody(r *Route) error {
|
||||
}
|
||||
for key, value := range props {
|
||||
switch key {
|
||||
case "maxParts":
|
||||
r.MaxParts, err = strconv.Atoi(value)
|
||||
if err != nil || r.MaxParts < 1 || r.MaxParts > 10000 {
|
||||
return p.err("maxParts must be 1..10000")
|
||||
}
|
||||
case "maxFieldBytes", "maxFileBytes":
|
||||
limit, e := strconv.ParseInt(value, 10, 64)
|
||||
if e != nil || limit < 1 || limit > 1<<30 {
|
||||
return p.err("part limits must be 1..1073741824 bytes")
|
||||
}
|
||||
if key == "maxFieldBytes" {
|
||||
r.MaxFieldBytes = limit
|
||||
} else {
|
||||
r.MaxFileBytes = limit
|
||||
}
|
||||
case "mode":
|
||||
r.BodyMode = value
|
||||
case "maxBytes":
|
||||
@@ -33,6 +50,16 @@ func (p *parser) parseBody(r *Route) error {
|
||||
return nil
|
||||
}
|
||||
func validateRouteExtensions(r Route) error {
|
||||
if (r.MaxParts > 0 || r.MaxFieldBytes > 0 || r.MaxFileBytes > 0) && r.BodyMode != "multipart" {
|
||||
return errors.New("part limits require multipart mode")
|
||||
}
|
||||
|
||||
if err := r.RequestPolicy.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if (r.BodyMode == "raw" || r.BodyMode == "multipart") && r.RequestPolicy.Body != "" && r.RequestPolicy.Body != "auto" {
|
||||
return errors.New("raw/multipart cannot declare a JSON body policy")
|
||||
}
|
||||
if r.BodyMode != "" && r.BodyMode != "json" && r.BodyMode != "raw" && r.BodyMode != "multipart" {
|
||||
return errors.New("body mode must be json, raw or multipart")
|
||||
}
|
||||
@@ -42,6 +69,9 @@ func validateRouteExtensions(r Route) error {
|
||||
if r.MaxBodyBytes > 0 && (r.Method == "get" || r.Method == "head") {
|
||||
return errors.New("GET/HEAD cannot declare a body limit")
|
||||
}
|
||||
if (r.Method == "get" || r.Method == "head") && r.RequestPolicy.Body != "" && r.RequestPolicy.Body != "auto" && r.RequestPolicy.Body != "none" {
|
||||
return errors.New("GET/HEAD cannot declare a JSON body policy")
|
||||
}
|
||||
if r.Produces != "" {
|
||||
media, params, err := mime.ParseMediaType(r.Produces)
|
||||
if err != nil || len(params) != 0 || media != r.Produces || strings.Contains(media, "*") {
|
||||
@@ -58,6 +88,23 @@ func validateRouteExtensions(r Route) error {
|
||||
}
|
||||
|
||||
func decorateRequestOptions(op map[string]any, r Route) {
|
||||
if r.BodyMode == "raw" || r.BodyMode == "multipart" {
|
||||
r.RequestPolicy.Body = "auto"
|
||||
}
|
||||
op["x-omni-request-policy"] = r.RequestPolicy
|
||||
if r.BodyMode == "multipart" {
|
||||
limits, _ := (contract.MultipartOptions{MaxBodyBytes: r.MaxBodyBytes, MaxParts: r.MaxParts, MaxFieldBytes: r.MaxFieldBytes, MaxFileBytes: r.MaxFileBytes}).Normalized()
|
||||
op["x-omni-multipart-limits"] = map[string]any{"maxBytes": limits.MaxBodyBytes, "maxParts": limits.MaxParts, "maxFieldBytes": limits.MaxFieldBytes, "maxFileBytes": limits.MaxFileBytes}
|
||||
}
|
||||
if r.BodyMode != "raw" && r.BodyMode != "multipart" {
|
||||
if body, ok := op["requestBody"].(map[string]any); ok && r.RequestPolicy.Body == "optional" {
|
||||
body["required"] = false
|
||||
}
|
||||
if _, ok := op["requestBody"]; !ok && (r.RequestPolicy.Body == "emptyObject" || r.RequestPolicy.Body == "required" || r.RequestPolicy.Body == "optional") {
|
||||
op["requestBody"] = map[string]any{"required": r.RequestPolicy.Body != "optional", "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{"type": "object", "additionalProperties": false}}}}
|
||||
}
|
||||
}
|
||||
|
||||
if r.Manual {
|
||||
op["x-omni-handler-mode"] = "manual"
|
||||
}
|
||||
@@ -87,3 +134,21 @@ func decorateRequestOptions(op map[string]any, r Route) {
|
||||
op["responses"].(map[string]any)[strconv.Itoa(r.Status)] = map[string]any{"description": "Success", "content": map[string]any{r.Produces: map[string]any{"schema": schema}}}
|
||||
}
|
||||
}
|
||||
|
||||
func parseRequestPolicy(props map[string]string) (contract.RequestPolicy, error) {
|
||||
data, err := json.Marshal(props)
|
||||
if err != nil {
|
||||
return contract.RequestPolicy{}, err
|
||||
}
|
||||
return decodeRequestPolicy(string(data))
|
||||
}
|
||||
func decodeRequestPolicy(raw string) (contract.RequestPolicy, error) {
|
||||
var p contract.RequestPolicy
|
||||
if err := apierrors.DecodeCatalogJSON([]byte(raw), &p); err != nil {
|
||||
return p, fmt.Errorf("request policy: %w", err)
|
||||
}
|
||||
return p, p.Validate()
|
||||
}
|
||||
func policyLiteral(p contract.RequestPolicy) string {
|
||||
return fmt.Sprintf("contract.RequestPolicy{UnknownQuery:%q,UTF8:%q,Charset:%q,Body:%q}", p.UnknownQuery, p.UTF8, p.Charset, p.Body)
|
||||
}
|
||||
|
||||
78
cmd/go-omni/apigen/responseShape.go
Normal file
78
cmd/go-omni/apigen/responseShape.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||
"git.yuntuo.hk/k2safe/go-omni/openapi"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Response roots allow named DTOs, []DTO, []primitive, *DTO and null.
|
||||
// A nil root slice is always encoded as an empty JSON array.
|
||||
func responseShape(value string) (kind, name string) {
|
||||
if value == "null" {
|
||||
return "null", ""
|
||||
}
|
||||
if strings.HasPrefix(value, "[]") {
|
||||
return "array", strings.TrimPrefix(value, "[]")
|
||||
}
|
||||
if strings.HasPrefix(value, "*") {
|
||||
return "nullable", strings.TrimPrefix(value, "*")
|
||||
}
|
||||
return "object", value
|
||||
}
|
||||
func responseGoType(value string) string {
|
||||
kind, name := responseShape(value)
|
||||
if kind == "null" || value == "" {
|
||||
return ""
|
||||
}
|
||||
if kind == "array" {
|
||||
if primitiveTypes[name] != nil {
|
||||
return "[]" + name
|
||||
}
|
||||
return "[]types." + name
|
||||
}
|
||||
return "*types." + name
|
||||
}
|
||||
func responseDataSchema(value string, bindings map[string]*contract.Binder) (*openapi.Schema, error) {
|
||||
kind, name := responseShape(value)
|
||||
if kind == "null" {
|
||||
return &openapi.Schema{Type: "object", Nullable: true, Enum: []any{nil}}, nil
|
||||
}
|
||||
if kind == "array" && primitiveTypes[name] != nil {
|
||||
if name == "File" {
|
||||
return nil, fmt.Errorf("File is request-only; use a response DTO")
|
||||
}
|
||||
// Reuse the contract compiler, including integer bounds and byte-slice rules.
|
||||
t := reflect.StructOf([]reflect.StructField{{Name: "Value", Type: reflect.SliceOf(primitiveTypes[name]), Tag: `json:"value"`}})
|
||||
b, err := contract.Describe(t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s, err := b.ResponseSchema()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Properties["value"], nil
|
||||
}
|
||||
b, ok := bindings[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown response DTO %q; use Name, []Name, []string/[]int64/[]bool/[]any, *Name or null", name)
|
||||
}
|
||||
s, err := b.ResponseSchema()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch kind {
|
||||
case "array":
|
||||
return &openapi.Schema{Type: "array", Items: s}, nil
|
||||
case "nullable":
|
||||
s.Nullable = true
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
var primitiveTypes = map[string]reflect.Type{"File": reflect.TypeOf(contract.File{}), "any": reflect.TypeOf((*any)(nil)).Elem(), "string": reflect.TypeOf(""), "bool": reflect.TypeOf(false), "int": reflect.TypeOf(int(0)), "int8": reflect.TypeOf(int8(0)), "int16": reflect.TypeOf(int16(0)), "int32": reflect.TypeOf(int32(0)), "int64": reflect.TypeOf(int64(0)), "uint": reflect.TypeOf(uint(0)), "uint8": reflect.TypeOf(uint8(0)), "uint16": reflect.TypeOf(uint16(0)), "uint32": reflect.TypeOf(uint32(0)), "uint64": reflect.TypeOf(uint64(0)), "float32": reflect.TypeOf(float32(0)), "float64": reflect.TypeOf(float64(0))}
|
||||
|
||||
func responseNeedsTypes(value string) bool { return strings.Contains(responseGoType(value), "types.") }
|
||||
151
cmd/go-omni/apigen/shared.go
Normal file
151
cmd/go-omni/apigen/shared.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const importsFile = "omni.imports.json"
|
||||
|
||||
// SharedRoot explicitly authorizes reads from one canonical local directory.
|
||||
// Version is a project-owned label; SHA256 optionally pins the imported source
|
||||
// set, not a remote tag. The CLI never downloads or executes shared contracts.
|
||||
type SharedRoot struct {
|
||||
Path string `json:"path"`
|
||||
Version string `json:"version,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
}
|
||||
type sharedConfig struct {
|
||||
Roots map[string]SharedRoot `json:"roots"`
|
||||
Errors *DocumentErrors `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
func readSharedConfig(root string) (sharedConfig, error) {
|
||||
stat, err := os.Lstat(filepath.Join(root, importsFile))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return sharedConfig{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return sharedConfig{}, err
|
||||
}
|
||||
if !stat.Mode().IsRegular() {
|
||||
return sharedConfig{}, errors.New("api: shared root configuration must be a regular file")
|
||||
}
|
||||
f, err := os.Open(filepath.Join(root, importsFile))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return sharedConfig{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return sharedConfig{}, err
|
||||
}
|
||||
defer f.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(f, 65537))
|
||||
if err != nil {
|
||||
return sharedConfig{}, err
|
||||
}
|
||||
if len(raw) > 65536 {
|
||||
return sharedConfig{}, errors.New("api: shared root config too large")
|
||||
}
|
||||
var cfg sharedConfig
|
||||
if err = apierrors.DecodeCatalogJSON(raw, &cfg); err != nil {
|
||||
return sharedConfig{}, err
|
||||
}
|
||||
if len(cfg.Roots) > 16 {
|
||||
return sharedConfig{}, errors.New("api: at most 16 shared roots")
|
||||
}
|
||||
for name, r := range cfg.Roots {
|
||||
if !lowerName.MatchString(name) || r.Path == "" || len(r.Version) > 128 {
|
||||
return sharedConfig{}, errors.New("api: invalid shared root")
|
||||
}
|
||||
if r.SHA256 != "" && (len(r.SHA256) != 64 || strings.Trim(r.SHA256, "0123456789abcdef") != "") {
|
||||
return sharedConfig{}, errors.New("api: shared sha256 must be 64 lowercase hex characters")
|
||||
}
|
||||
path := r.Path
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(root, path)
|
||||
}
|
||||
r.Path, err = filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
return sharedConfig{}, err
|
||||
}
|
||||
r.Path, err = filepath.Abs(r.Path)
|
||||
if err != nil {
|
||||
return sharedConfig{}, err
|
||||
}
|
||||
stat, err := os.Stat(r.Path)
|
||||
if err != nil {
|
||||
return sharedConfig{}, err
|
||||
}
|
||||
if !stat.IsDir() {
|
||||
return sharedConfig{}, errors.New("api: shared root must be a directory")
|
||||
}
|
||||
cfg.Roots[name] = r
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
func sourceWithin(root, name string) (string, error) {
|
||||
real, err := filepath.EvalSymlinks(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rel, err := filepath.Rel(root, real)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", errors.New("api: imports must stay within their declared contract root")
|
||||
}
|
||||
return filepath.ToSlash(rel), nil
|
||||
}
|
||||
func sharedHash(sources map[string][]byte, alias string) string {
|
||||
values := map[string]string{}
|
||||
for name, data := range sources {
|
||||
if strings.HasPrefix(name, "@"+alias+"/") {
|
||||
values[strings.TrimPrefix(name, "@"+alias+"/")] = digest(data)
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(values)
|
||||
return digest(raw)
|
||||
}
|
||||
func sourceMetadata(spec *Spec) map[string]any {
|
||||
hashes := map[string]string{}
|
||||
for name, data := range spec.Sources {
|
||||
hashes[name] = digest(data)
|
||||
}
|
||||
shared := map[string]any{}
|
||||
for alias, r := range spec.SharedRoots {
|
||||
shared[alias] = map[string]string{"version": r.Version, "sha256": sharedHash(spec.Sources, alias)}
|
||||
}
|
||||
return map[string]any{"files": hashes, "shared": shared}
|
||||
}
|
||||
func sharedSeeds(spec *Spec, directory string) ([]byte, error) {
|
||||
cfg := sharedConfig{Roots: map[string]SharedRoot{}, Errors: spec.DocumentErrors}
|
||||
for name, r := range spec.SharedRoots {
|
||||
rel, err := filepath.Rel(filepath.Join(directory, "api"), r.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Path = filepath.ToSlash(rel)
|
||||
cfg.Roots[name] = r
|
||||
}
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
return append(data, '\n'), err
|
||||
}
|
||||
func validateSharedPins(spec *Spec) error {
|
||||
names := make([]string, 0, len(spec.SharedRoots))
|
||||
for name := range spec.SharedRoots {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
r := spec.SharedRoots[name]
|
||||
if r.SHA256 != "" && r.SHA256 != sharedHash(spec.Sources, name) {
|
||||
return fmt.Errorf("api: shared contract %s checksum differs (got %s)", name, sharedHash(spec.Sources, name))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
96
cmd/go-omni/apigen/shared_test.go
Normal file
96
cmd/go-omni/apigen/shared_test.go
Normal file
@@ -0,0 +1,96 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestControlledSharedContracts(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
shared := filepath.Join(root, "shared")
|
||||
writeFile(t, filepath.Join(shared, "response.api"), "response Common (codeType: int32; successCode: 0; errorCode: 9999; dataMode: always)\ntype Item { Name string `json:\"name\"` }\n")
|
||||
for _, service := range []string{"api", "id"} {
|
||||
dir := filepath.Join(root, service)
|
||||
entry := filepath.Join(dir, "api/service.api")
|
||||
writeFile(t, entry, "import \"@common/response.api\"\n@app(defaultResponse: Common)\nservice demo {\n@handler get\nget /get returns (Item)\n}\n")
|
||||
config := `{"roots":{"common":{"path":"../../shared","version":"1.0.0"}}}`
|
||||
writeFile(t, filepath.Join(dir, "api/omni.imports.json"), config)
|
||||
spec, err := Parse(entry)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(spec.Sources) != 2 || len(spec.SourcePaths) != 2 {
|
||||
t.Fatalf("%+v", spec)
|
||||
}
|
||||
pin := sharedHash(spec.Sources, "common")
|
||||
config = strings.Replace(config, `"version":"1.0.0"`, `"version":"1.0.0","sha256":"`+pin+`"`, 1)
|
||||
writeFile(t, filepath.Join(dir, "api/omni.imports.json"), config)
|
||||
o := Options{Dir: dir, API: entry, Module: "example.com/" + service, FrameworkDir: frameworkRoot(t)}
|
||||
if _, err = Generate(o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
o.Check = true
|
||||
if _, err = Generate(o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = os.Stat(filepath.Join(dir, "api/@common")); !os.IsNotExist(err) {
|
||||
t.Fatal("shared contract copied into service")
|
||||
}
|
||||
document := readFile(t, filepath.Join(dir, "docs/openapi.json"))
|
||||
if !strings.Contains(document, pin) || !strings.Contains(document, "@common/response.api") {
|
||||
t.Fatal("missing provenance")
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "api/omni.imports.json"), strings.Replace(config, pin, strings.Repeat("0", 64), 1))
|
||||
if _, err = Parse(entry); err == nil {
|
||||
t.Fatal("checksum mismatch accepted")
|
||||
}
|
||||
}
|
||||
// An undeclared alias, traversal, symlink escape and cycle are all rejected.
|
||||
for _, imp := range []string{"@missing/response.api", "@common/../outside.api", "../shared/response.api", "@common/escape.api", "@common/cycle.api"} {
|
||||
local := filepath.Join(root, "consumer")
|
||||
writeFile(t, filepath.Join(local, "omni.imports.json"), `{"roots":{"common":{"path":"../shared"}}}`)
|
||||
writeFile(t, filepath.Join(root, "outside.api"), "type Outside {}\n")
|
||||
_ = os.Symlink(filepath.Join(root, "outside.api"), filepath.Join(shared, "escape.api"))
|
||||
writeFile(t, filepath.Join(shared, "cycle.api"), "import \"cycle.api\"\n")
|
||||
entry := filepath.Join(local, "service.api")
|
||||
writeFile(t, entry, "import "+strconvQuote(imp)+"\nservice demo{\n@handler get\nget /get\n}\n")
|
||||
if _, err := Parse(entry); err == nil {
|
||||
t.Fatal("unsafe import accepted", imp)
|
||||
}
|
||||
}
|
||||
}
|
||||
func strconvQuote(value string) string { data, _ := json.Marshal(value); return string(data) }
|
||||
func TestCatalogDocumentExportDoesNotGenerateRuntimeRegistry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
entry := filepath.Join(dir, "service.api")
|
||||
writeFile(t, entry, "@app(defaultResponse: Common)\nresponse Common(codeType: int32; successCode: 0; errorCode: 9999; messageField: msg; dataMode: always)\nservice demo{\n@handler get\nget /get returns (null)\n}\n")
|
||||
var exported bytes.Buffer
|
||||
err := apierrors.ExportCatalog(&exported, apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"TOO_LONG": {Code: 400002, Message: "{label}不能超过{max}个字符", Params: []apierrors.Parameter{{Name: "label", Type: "string", Example: "企业名称"}, {Name: "max", Type: "int", Example: 100}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "omni.errors.json"), exported.String())
|
||||
spec, err := Parse(entry)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := Compile(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(c.Spec.Errors) != 0 || !strings.Contains(string(c.Document), "企业名称不能超过100个字符") {
|
||||
t.Fatal(string(c.Document))
|
||||
}
|
||||
target := filepath.Join(t.TempDir(), "catalog")
|
||||
if _, err = Generate(Options{Dir: target, API: entry, Module: "example.com/catalog", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = os.Stat(filepath.Join(target, "internal/apierrors/errorsGen.go")); !os.IsNotExist(err) {
|
||||
t.Fatal("document metadata became a runtime error registry")
|
||||
}
|
||||
}
|
||||
@@ -13,13 +13,18 @@ import (
|
||||
omni %q
|
||||
%q
|
||||
%q
|
||||
%q
|
||||
%q
|
||||
)
|
||||
// NewApp is generated HTTP composition. The caller owns deps and its logger.
|
||||
func NewApp(deps *svc.ServiceContext)(*omni.App,error){
|
||||
if deps==nil || deps.Health==nil{return nil,fmt.Errorf("service context and health checker required")}
|
||||
if deps==nil || deps.Lifecycle==nil{return nil,fmt.Errorf("service context and lifecycle controller required; see PROJECT.md upgrade instructions")}
|
||||
cfg:=deps.Config.Server
|
||||
cfg.LogInstance=deps.Logger
|
||||
for _,option:=range deps.AppOptions(){if option!=nil{option(&cfg)}}
|
||||
`, frameworkModule, frameworkModule+"/httpx", o.Module+"/internal/svc")
|
||||
`, frameworkModule, frameworkModule+"/httpx", frameworkModule+"/config", frameworkModule+"/health", o.Module+"/internal/svc")
|
||||
source += fmt.Sprintf("cfg.Request=contract.MergePolicy(cfg.Request,%#v)\n", spec.RequestPolicy)
|
||||
source = strings.Replace(source, "\"fmt\"", "\"fmt\";"+fmt.Sprintf("%q", frameworkModule+"/contract"), 1)
|
||||
if spec.DefaultResponse != "" {
|
||||
for _, r := range spec.Responses {
|
||||
if r.Name == spec.DefaultResponse {
|
||||
@@ -28,13 +33,30 @@ func NewApp(deps *svc.ServiceContext)(*omni.App,error){
|
||||
}
|
||||
}
|
||||
}
|
||||
source += `if err:=cfg.Validate();err!=nil{return nil,err}
|
||||
source += "if err:=cfg.Validate();err!=nil{return nil,err}\n"
|
||||
source += "if err:=cfg.Endpoints.ValidateRoutes([]config.EndpointRoute{\n"
|
||||
for _, route := range spec.Routes {
|
||||
source += fmt.Sprintf("{Method:%q,Path:%q},\n", route.Method, route.Path)
|
||||
}
|
||||
if o.Storage == "local" {
|
||||
source += "{Method: \"GET\",Path: \"/storage/object\"},\n"
|
||||
}
|
||||
source += `}...);err!=nil{return nil,err}
|
||||
if (cfg.Endpoints.Liveness.Enabled || cfg.Endpoints.Readiness.Enabled) && deps.Health==nil{return nil,fmt.Errorf("enabled health endpoints require a checker")}
|
||||
app:=omni.New(omni.WithConfig(cfg))
|
||||
app.Use(deps.GlobalMiddleware()...)
|
||||
if err:=RegisterRoutes(app,deps);err!=nil{return nil,err}
|
||||
app.GET("/openapi.json",OpenAPI)
|
||||
app.GET("/health/live",deps.Health.LivenessHandler())
|
||||
app.GET("/health/ready",deps.Health.ReadinessHandler())
|
||||
if err:=RegisterRoutes(app,deps);err!=nil{_ = app.Close();return nil,err}
|
||||
if cfg.Endpoints.OpenAPI.Enabled{app.GET(cfg.Endpoints.OpenAPI.Path,OpenAPI)}
|
||||
if cfg.Endpoints.Liveness.Enabled{
|
||||
var options []health.HandlerOption
|
||||
if cfg.Endpoints.Liveness.ResponseMode=="raw"{options=append(options,health.WithRawResponse())}
|
||||
app.GET(cfg.Endpoints.Liveness.Path,deps.Health.LivenessHandler(options...))
|
||||
}
|
||||
if cfg.Endpoints.Readiness.Enabled{
|
||||
options:=[]health.HandlerOption{health.WithShutdownContext(deps.Lifecycle.Context())}
|
||||
if cfg.Endpoints.Readiness.ResponseMode=="raw"{options=append(options,health.WithRawResponse())}
|
||||
app.GET(cfg.Endpoints.Readiness.Path,deps.Health.ReadinessHandler(options...))
|
||||
}
|
||||
`
|
||||
if o.Storage == "local" {
|
||||
source += `if deps.FileHTTP==nil{return nil,fmt.Errorf("local storage handler required")}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"MODULE/internal/config"
|
||||
"MODULE/internal/handler"
|
||||
"MODULE/internal/svc"
|
||||
@@ -27,16 +28,17 @@ func main(){
|
||||
func run(ctx context.Context,path string)(runErr error){
|
||||
cfg,err:=config.Load(path);if err!=nil{return err}
|
||||
logger,err:=logx.New(cfg.Server.Log);if err!=nil{return err}
|
||||
defer func(){runErr=errors.Join(runErr,logger.Close())}()
|
||||
logx.SetDefault(logger)
|
||||
// Unjoined background work may still log. The owning process exits on this
|
||||
// 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())}}()
|
||||
deps,err:=svc.New(ctx,logger,cfg);if err!=nil{return err}
|
||||
// RunContext drains HTTP requests before returning. This scope owns dependencies
|
||||
// and closes them exactly once, before closing the borrowed logger.
|
||||
defer func(){runErr=errors.Join(runErr,deps.Close())}()
|
||||
defer func(){runErr=deps.Finish(runErr)}()
|
||||
app,err:=handler.NewApp(deps);if err!=nil{return err}
|
||||
defer func(){runErr=errors.Join(runErr,app.Close())}()
|
||||
logger.InfoContext(ctx,"service.starting","address",app.Config().Address)
|
||||
return app.RunContext(ctx)
|
||||
return app.RunContext(deps.Lifecycle.Context())
|
||||
}
|
||||
`,
|
||||
".gitignore": "/server\n/var/\n.env\n.env.*\n!.env.example\n/.goOmni/api.lock\n",
|
||||
@@ -58,6 +60,10 @@ go run .
|
||||
|
||||
GET /health/live and /health/ready check the running service. GET /openapi.json
|
||||
serves the same document as docs/openapi.json. Bind address defaults to localhost.
|
||||
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.
|
||||
Probe HTTP statuses remain 200/503 even when business errors use HTTP 200.
|
||||
OpenAPI remains an unwrapped document; disabled endpoints reserve no paths.
|
||||
Set GO_OMNI_TRANSPORT=native to select the native transport without changing services.
|
||||
The greeting starter works immediately; other new operation stubs return 501 until
|
||||
their business logic is implemented. Authentication hooks also fail closed with 501.
|
||||
@@ -104,6 +110,16 @@ and produce REVIEW notices. This source scan does not execute project code or
|
||||
replace go test ./... . Snapshot-based REVIEW notices describe changes since the
|
||||
last successful generation; they do not persist as an acknowledgement checklist.
|
||||
|
||||
## Contract tools
|
||||
|
||||
api format [--check] preserves comments and normalizes indentation. api upgrade
|
||||
previews changed infrastructure; --apply only replaces recognized prior seeds.
|
||||
Use @request policy overrides and @app(request: {...}) for query/JSON strictness.
|
||||
Explicitly exported api/omni.errors.json documents the Go-owned error catalog;
|
||||
the CLI never executes project Go. omni.imports.json permits named shared roots;
|
||||
workspace format/generate/check processes explicitly listed independent services.
|
||||
Full reference: https://git.yuntuo.hk/k2safe/go-omni/src/branch/main/DEVELOPMENT.md
|
||||
|
||||
## Layout
|
||||
|
||||
`+"```text"+`
|
||||
@@ -112,7 +128,8 @@ main.go # GENERATED startup, lifecycle and route registrati
|
||||
etc/config.yaml # lowerCamelCase configuration
|
||||
internal/config/ # defaults -> file -> environment
|
||||
internal/svc/ # dependency initialization and cleanup
|
||||
internal/types/typesGen.go # generated request/response types
|
||||
internal/types/*Gen.go # DTOs split by source API, one types package
|
||||
internal/svc/runtimeGen.go # GENERATED lifecycle and infrastructure startup
|
||||
internal/apierrors/errorsGen.go # generated fresh error constructors, when declared
|
||||
internal/handler/<group>/ # generated binding and Logic adapters
|
||||
internal/logic/<group>/ # developer-owned business operations
|
||||
@@ -131,7 +148,7 @@ use the same contract. 404/405 and ordinary App routes use the declared App defa
|
||||
No template is selected by declaration/import order. Without @app, existing framework
|
||||
response defaults apply. An explicit API App default takes precedence over response
|
||||
options in svc, keeping generated routes and OpenAPI consistent.
|
||||
Handlers reject nil successful responses. Body DTOs use application/json and have a 1 MiB limit by default.
|
||||
Object responses reject nil; []DTO maps nil to []; *DTO permits null; returns(null) is a successful null value. No returns means 204. Body DTOs use application/json and have a 1 MiB limit by default.
|
||||
Use @body(maxBytes: ...) for route limits. @body(mode: raw|multipart; maxBytes: ...)
|
||||
adds a *httpx.Body argument to Logic, without handwritten handlers. Raw is the exact
|
||||
limited request stream; Parts supports sequential multipart reading with no temp files.
|
||||
@@ -139,11 +156,10 @@ limited request stream; Parts supports sequential multipart reading with no temp
|
||||
Logic result of *httpx.Stream; the handler closes Content and streams it unwrapped.
|
||||
Signature verification and storage policy belong to Logic/middleware. A consumed
|
||||
request stream cannot be reread automatically; use one reader owner or explicitly
|
||||
bounded buffering. Stream reads respect configured server timeouts. Multipart parts
|
||||
are not DTO form fields: form tags remain URL query parameters. The optional manual
|
||||
bounded buffering. Stream reads respect configured server timeouts. Declare multipart/multipartJSON/file DTO fields for validated uploads; without those fields the existing streaming Body is passed. form tags remain URL query parameters. The optional manual
|
||||
handler/custom binder hooks are preserved, and are needed only for custom protocols.
|
||||
|
||||
Fields require exactly one source tag: json, path, form (URL query only), header.
|
||||
Fields require one source tag: json, path, form (URL query), header, multipart, multipartJSON or file. Multipart fields select full automatic binding before Logic; File/[]File are request-scoped disk-backed uploads. Use File.Open to stream them; generated cleanup owns their lifetime.
|
||||
All are required by presence unless optional, omitempty or default is specified.
|
||||
Zero and false are valid present values; binding:"required" rejects scalar zero.
|
||||
Use *T with optional when business logic needs to distinguish missing from zero.
|
||||
@@ -164,16 +180,35 @@ The selected auth runs first; @auth none removes inherited declarative auth.
|
||||
Validation policy remains in the middleware implementation. Public messages must
|
||||
not contain secrets; error causes may be included in server logs.
|
||||
|
||||
svc.New receives the loaded Config and borrowed Logger. Add dependencies there,
|
||||
Generated svc.New receives Config and a borrowed Logger. Add dependencies in svc.Init,
|
||||
then immediately register each cleanup with OnClose. A later initialization failure
|
||||
runs registered cleanups in reverse order. Close is idempotent. GlobalMiddleware
|
||||
provides global hooks; Middlewares supplies named API hooks. AppOptions is evaluated
|
||||
once before route registration; logging is configured earlier through Config.
|
||||
The generated main runs HTTP draining before closing svc resources, then the logger.
|
||||
The generated main runs HTTP draining, all OnStop hooks, all OnDrain hooks, resource
|
||||
OnClose callbacks, then logger close. Stop/drain hooks within a phase run concurrently;
|
||||
combine components with ordering dependencies into one pair. Pair OnStop/OnDrain
|
||||
with the same name. OnDrain returns nil only after final work and acknowledgements
|
||||
have finished. Use WorkContext for accepted background work; Context is an early
|
||||
shutdown notification. server.lifecycle configures stopTimeout (5s), drainTimeout
|
||||
(10s), and cancelTimeout (5s grace per timed-out phase). Zero uses defaults.
|
||||
Timeout/failure cancels WorkContext. If hooks cannot prove completion, resources
|
||||
and the borrowed logger are retained and the generated process exits unsuccessfully.
|
||||
Errors retain their causes. No automatic retry of close or force killing of goroutines.
|
||||
Do not close borrowed resources or attach duplicate cleanup hooks to App. The startup
|
||||
context cancels on signals; long-lived clients/workers that requests depend on need
|
||||
their own lifetime, canceled by OnClose after HTTP draining.
|
||||
|
||||
svc.RequestShutdown(err) lets a dependency report a fatal service failure.
|
||||
It returns immediately. Generated main stops accepting requests, drains them,
|
||||
closes dependencies and logs, then exits nonzero with the original cause.
|
||||
nil requests a normal stop. Concurrent calls keep the first non-nil failure;
|
||||
notifications during initialization or draining are retained. Close must stop
|
||||
and join project-owned monitors before the final shutdown result is returned.
|
||||
The svc lifecycle context signals draining, not permission to close resources
|
||||
still used by active requests. Liveness and readiness are different: shutdown
|
||||
makes readiness 503; it does not turn a running process's liveness into failure.
|
||||
|
||||
Named middleware is composed in declaration order (first is outermost). Implement
|
||||
the generated hooks or replace entries in svc; never bypass an auth
|
||||
hook merely to make the server start. Middleware names do not imply a JWT policy.
|
||||
@@ -191,7 +226,7 @@ func infrastructureREADME(o Options) string {
|
||||
|
||||
Selected database: %s; storage: %s; logging: %s/%s.
|
||||
MySQL uses database/sql through deps.DB, including InTx and a readiness probe.
|
||||
Set GO_OMNI_DB_DSN; pool settings live in etc/config.yaml and GO_OMNI_DB_*.
|
||||
Use structured database/mysql configuration and GO_OMNI_DB_PASSWORD. Other connection/pool/TLS settings live in etc/config.yaml and GO_OMNI_DB_*.
|
||||
No tables are invented or migrated automatically from request/response DTOs.
|
||||
The none/memory selection opens no database; implement repositories per business.
|
||||
|
||||
|
||||
54
cmd/go-omni/apigen/testdata/arrays/consumer_test.go
vendored
Normal file
54
cmd/go-omni/apigen/testdata/arrays/consumer_test.go
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"example.com/arrays/internal/config"
|
||||
"example.com/arrays/internal/handler"
|
||||
"example.com/arrays/internal/svc"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestArrayWire(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Server.Log.Writer = io.Discard
|
||||
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()
|
||||
app, err := handler.NewApp(deps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer app.Close()
|
||||
for path, want := range map[string]string{
|
||||
"/strings": `["企业","备注"]`, "/integers": `[9223372036854775807,-3]`, "/dynamic": `["text",7,true,null,{"name":"企业"},[false,2]]`, "/empty": `[]`, "/raw": `[true,false]`,
|
||||
} {
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
|
||||
got := strings.TrimSpace(w.Body.String())
|
||||
if w.Code != 200 {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if path != "/raw" {
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &obj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = string(obj["data"])
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("%s got %s want %s", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
13
cmd/go-omni/apigen/testdata/arrays/values.go
vendored
Normal file
13
cmd/go-omni/apigen/testdata/arrays/values.go
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
package arrays
|
||||
|
||||
import "context"
|
||||
|
||||
func (s *Logic) Strings(context.Context) ([]string, error) { return []string{"企业", "备注"}, nil }
|
||||
func (s *Logic) Integers(context.Context) ([]int64, error) {
|
||||
return []int64{9223372036854775807, -3}, nil
|
||||
}
|
||||
func (s *Logic) Dynamic(context.Context) ([]any, error) {
|
||||
return []any{"text", 7, true, nil, map[string]any{"name": "企业"}, []any{false, 2}}, nil
|
||||
}
|
||||
func (s *Logic) Empty(context.Context) ([]any, error) { return nil, nil }
|
||||
func (s *Logic) Raw(context.Context) ([]bool, error) { return []bool{true, false}, nil }
|
||||
22
cmd/go-omni/apigen/testdata/business/accessFactory.go
vendored
Normal file
22
cmd/go-omni/apigen/testdata/business/accessFactory.go
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
)
|
||||
|
||||
// This package deliberately imports no svc; the dependency is supplied explicitly.
|
||||
type AccessChecker interface {
|
||||
CheckAccess(context.Context, string) error
|
||||
}
|
||||
|
||||
func NewRequireAccess(checker AccessChecker) httpx.Middleware {
|
||||
return func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
if err := checker.CheckAccess(c.Context(), c.Header("Authorization")); err != nil {
|
||||
return err
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
32
cmd/go-omni/apigen/testdata/business/client.go
vendored
Normal file
32
cmd/go-omni/apigen/testdata/business/client.go
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"example.com/business/internal/middleware"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// A local fixture checks wiring; production authentication is project-owned.
|
||||
type accessClient struct{ closed atomic.Bool }
|
||||
|
||||
func (c *accessClient) Close() error { c.closed.Store(true); return nil }
|
||||
func (c *accessClient) CheckAccess(ctx context.Context, token string) error {
|
||||
if c.closed.Load() {
|
||||
return apierrors.New(503, "CLOSED", "closed")
|
||||
}
|
||||
if httpx.Metadata(ctx).Route == "" {
|
||||
return apierrors.New(500, "NO_METADATA", "missing route")
|
||||
}
|
||||
if token == "reject" {
|
||||
return apierrors.New(403, "DENIED", "denied")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (c *ServiceContext) initAccess(ctx context.Context) error {
|
||||
c.Client = &accessClient{}
|
||||
c.OnClose(c.Client.Close)
|
||||
c.Middlewares["RequireAccess"] = middleware.NewRequireAccess(c.Client)
|
||||
return nil
|
||||
}
|
||||
119
cmd/go-omni/apigen/testdata/business/consumer_test.go
vendored
Normal file
119
cmd/go-omni/apigen/testdata/business/consumer_test.go
vendored
Normal file
@@ -0,0 +1,119 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"example.com/business/internal/config"
|
||||
"example.com/business/internal/handler"
|
||||
"example.com/business/internal/svc"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestShapesAndPolicy(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Server.Log.Writer = io.Discard
|
||||
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()
|
||||
app, err := handler.NewApp(deps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer app.Close()
|
||||
for _, tt := range []struct {
|
||||
method, path, body, media string
|
||||
status int
|
||||
data string
|
||||
raw bool
|
||||
}{
|
||||
{"GET", "/list", "", "", 200, `[]`, false},
|
||||
{"GET", "/list?mode=full&tags=a&tags=b", "", "", 200, `[{"name":"one"}]`, false},
|
||||
{"GET", "/nullable", "", "", 200, `null`, false},
|
||||
{"GET", "/nullable?mode=full", "", "", 200, `{"name":"one"}`, false},
|
||||
{"POST", "/nothing", "", "", 200, `null`, false},
|
||||
{"GET", "/rawNull", "", "", 200, `null`, true},
|
||||
{"GET", "/rawArray", "", "", 200, `[]`, true},
|
||||
{"DELETE", "/noContent", "", "", 204, "", true},
|
||||
{"POST", "/empty", `{}`, "application/json", 200, `null`, false},
|
||||
{"POST", "/empty", ``, "application/json", 400, "", false},
|
||||
{"POST", "/empty", `{"unknown":1}`, "application/json", 400, "", false},
|
||||
{"POST", "/absent", ``, "", 200, `null`, false},
|
||||
{"POST", "/absent", `{}`, "application/json", 400, "", false},
|
||||
{"POST", "/optional", ``, "", 200, `{"name":""}`, false},
|
||||
{"POST", "/optional", `{}`, "application/json", 400, "", false},
|
||||
{"POST", "/echo", `{"name":"ok"}`, "application/json", 200, `{"name":"ok"}`, false},
|
||||
{"POST", "/echo", `{"name":"ok"}`, "application/json; charset=latin1", 415, "", false},
|
||||
{"POST", "/echo", "{\"name\":\"\xff\"}", "application/json", 400, "", false},
|
||||
{"GET", "/list?unknown=1", "", "", 400, "", false},
|
||||
{"GET", "/rawNull?unknown=1", "", "", 400, "", false},
|
||||
{"POST", "/tolerant?unknown=1", "{\"name\":\"\xff\"}", "application/json; charset=latin1", 200, `{"name":"<22>"}`, false},
|
||||
} {
|
||||
t.Run(tt.method+tt.path+tt.body+tt.media, func(t *testing.T) {
|
||||
r := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body))
|
||||
if tt.media != "" {
|
||||
r.Header.Set("Content-Type", tt.media)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, r)
|
||||
if w.Code != tt.status {
|
||||
t.Fatalf("%d %s", w.Code, w.Body)
|
||||
}
|
||||
if tt.status >= 400 {
|
||||
return
|
||||
}
|
||||
data := strings.TrimSpace(w.Body.String())
|
||||
if !tt.raw {
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &obj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v, ok := obj["data"]
|
||||
if !ok {
|
||||
t.Fatal("missing data")
|
||||
}
|
||||
data = string(v)
|
||||
}
|
||||
if data != tt.data {
|
||||
t.Fatalf("data %s expected %s", data, tt.data)
|
||||
}
|
||||
})
|
||||
}
|
||||
var body bytes.Buffer
|
||||
form := multipart.NewWriter(&body)
|
||||
_ = form.WriteField("title", "title")
|
||||
_ = form.WriteField("metadata", `{"name":"meta"}`)
|
||||
f, err := form.CreateFormFile("attachment", "a.txt")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.WriteString(f, "content")
|
||||
_ = form.Close()
|
||||
r := httptest.NewRequest("POST", "/upload", &body)
|
||||
r.Header.Set("Content-Type", form.FormDataContentType())
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, r)
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), "title:meta:content") {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
denied := httptest.NewRequest("GET", "/list", nil)
|
||||
denied.Header.Set("Authorization", "reject")
|
||||
result := httptest.NewRecorder()
|
||||
app.ServeHTTP(result, denied)
|
||||
if result.Code != 403 {
|
||||
t.Fatal("injected middleware did not run", result.Code, result.Body.String())
|
||||
}
|
||||
|
||||
}
|
||||
42
cmd/go-omni/apigen/testdata/business/operations.go
vendored
Normal file
42
cmd/go-omni/apigen/testdata/business/operations.go
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
package demo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"example.com/business/internal/types"
|
||||
"io"
|
||||
)
|
||||
|
||||
func (s *Logic) List(ctx context.Context, req *types.Query) ([]types.Item, error) {
|
||||
if req.Mode == "full" {
|
||||
return []types.Item{{Name: "one"}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (s *Logic) Nullable(ctx context.Context, req *types.Query) (*types.Item, error) {
|
||||
if req.Mode == "full" {
|
||||
return &types.Item{Name: "one"}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
func (s *Logic) Nothing(ctx context.Context) error { return nil }
|
||||
func (s *Logic) RawNull(ctx context.Context) error { return nil }
|
||||
func (s *Logic) RawArray(ctx context.Context) ([]types.Item, error) { return nil, nil }
|
||||
func (s *Logic) Empty(ctx context.Context) error { return nil }
|
||||
func (s *Logic) Absent(ctx context.Context) error { return nil }
|
||||
func (s *Logic) Optional(ctx context.Context, req *types.Item) (*types.Item, error) { return req, nil }
|
||||
func (s *Logic) Echo(ctx context.Context, req *types.Item) (*types.Item, error) { return req, nil }
|
||||
func (s *Logic) Tolerant(ctx context.Context, req *types.Item) (*types.Item, error) { return req, nil }
|
||||
func (s *Logic) NoContent(ctx context.Context) error { return nil }
|
||||
|
||||
func (s *Logic) Upload(ctx context.Context, req *types.Upload) (*types.Item, error) {
|
||||
f, err := req.Attachment.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &types.Item{Name: req.Title + ":" + req.Metadata.Name + ":" + string(data)}, nil
|
||||
}
|
||||
49
cmd/go-omni/apigen/testdata/managed/background.go
vendored
Normal file
49
cmd/go-omni/apigen/testdata/managed/background.go
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
package svc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/task"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type BackgroundState struct {
|
||||
Stop, Drain, Release, Started chan struct{}
|
||||
Wrote atomic.Bool
|
||||
Future *task.Future
|
||||
}
|
||||
|
||||
var Background atomic.Pointer[BackgroundState]
|
||||
|
||||
func initBackground(c *ServiceContext) error {
|
||||
state := &BackgroundState{Stop: make(chan struct{}), Drain: make(chan struct{}), Release: make(chan struct{}), Started: make(chan struct{})}
|
||||
baseline := Closed.Load()
|
||||
runner, err := task.New(task.Config{Workers: 1, QueueSize: 1, TaskTimeout: time.Minute, OnError: func(string, error) {}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.OnStop("jobs", func(ctx context.Context) error { close(state.Stop); return runner.StopAccepting(ctx) })
|
||||
c.OnDrain("jobs", func(ctx context.Context) error { close(state.Drain); return runner.Wait(ctx) })
|
||||
state.Future, err = runner.Submit(c.WorkContext(), "finish", func(ctx context.Context) error {
|
||||
close(state.Started)
|
||||
if c.Config.BackgroundMode == "auto" {
|
||||
<-state.Stop
|
||||
} else {
|
||||
<-state.Release
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if Closed.Load() != baseline {
|
||||
return errors.New("shared client closed before background write")
|
||||
}
|
||||
state.Wrote.Store(true)
|
||||
return nil
|
||||
})
|
||||
if err == nil {
|
||||
<-state.Started
|
||||
}
|
||||
Background.Store(state)
|
||||
return err
|
||||
}
|
||||
14
cmd/go-omni/apigen/testdata/managed/client.go
vendored
14
cmd/go-omni/apigen/testdata/managed/client.go
vendored
@@ -9,16 +9,28 @@ import (
|
||||
var Opened, Closed, DownloadClosed atomic.Int32
|
||||
var Started = make(chan struct{}, 1)
|
||||
var Release = make(chan struct{})
|
||||
var Current atomic.Pointer[ServiceContext]
|
||||
var FatalDependency = errors.New("test dependency cannot continue")
|
||||
|
||||
// This fake client exercises the same lifetime contract as a database/client.
|
||||
type Client struct{}
|
||||
|
||||
func initClient(ctx context.Context, c *ServiceContext) error {
|
||||
Current.Store(c)
|
||||
Opened.Add(1)
|
||||
c.Client = &Client{}
|
||||
c.OnClose(func() error { Closed.Add(1); return nil })
|
||||
c.OnClose(func() error { Closed.Add(1); c.Logger.Info("test.client.closed"); return nil })
|
||||
if c.Config.BackgroundMode != "" {
|
||||
if err := initBackground(c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c.Config.FailInit {
|
||||
return errors.New("client initialization failed")
|
||||
}
|
||||
if c.Config.ShutdownInit {
|
||||
c.RequestShutdown(FatalDependency)
|
||||
return nil
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
@@ -71,9 +71,10 @@ type countedReader struct{ io.Reader }
|
||||
|
||||
func (c *countedReader) Close() error { svc.DownloadClosed.Add(1); return nil }
|
||||
func (l *Logic) Slow(context.Context) (*types.Receipt, error) {
|
||||
before := svc.Closed.Load()
|
||||
svc.Started <- struct{}{}
|
||||
<-svc.Release
|
||||
if svc.Closed.Load() != 0 {
|
||||
if svc.Closed.Load() != before {
|
||||
return nil, errors.New("dependency closed before drain")
|
||||
}
|
||||
return &types.Receipt{Bytes: 1}, nil
|
||||
|
||||
155
cmd/go-omni/apigen/testdata/managed/phases_test.go
vendored
Normal file
155
cmd/go-omni/apigen/testdata/managed/phases_test.go
vendored
Normal file
@@ -0,0 +1,155 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"example.com/managed/internal/svc"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func phasedConfig(t *testing.T, address, mode string, fail bool) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
drainTimeout := "1s"
|
||||
if mode == "stuck" {
|
||||
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"}}})
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
func await(t *testing.T, ch <-chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-ch:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("phase did not arrive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedBackgroundPhases(t *testing.T) {
|
||||
for _, transport := range []string{"nethttp", "native"} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
t.Setenv("GO_OMNI_TRANSPORT", transport)
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
addr := listener.Addr().String()
|
||||
_ = listener.Close()
|
||||
svc.Started = make(chan struct{}, 1)
|
||||
svc.Release = make(chan struct{})
|
||||
before := svc.Closed.Load()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
path := phasedConfig(t, addr, "hold", false)
|
||||
go func() { done <- run(ctx, path) }()
|
||||
waitReady(t, "http://"+addr, done)
|
||||
state := svc.Background.Load()
|
||||
defer func() {
|
||||
select {
|
||||
case <-state.Release:
|
||||
default:
|
||||
close(state.Release)
|
||||
}
|
||||
}()
|
||||
requestDone := make(chan error, 1)
|
||||
go func() {
|
||||
client := http.Client{Timeout: 3 * time.Second}
|
||||
resp, e := client.Get("http://" + addr + "/slow")
|
||||
if e == nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
e = errors.New("HTTP drain failed")
|
||||
}
|
||||
}
|
||||
requestDone <- e
|
||||
}()
|
||||
await(t, svc.Started)
|
||||
deps := svc.Current.Load()
|
||||
deps.RequestShutdown(svc.FatalDependency)
|
||||
if deps.WorkContext().Err() != nil {
|
||||
t.Fatal("accepted work canceled at signal")
|
||||
}
|
||||
select {
|
||||
case <-state.Stop:
|
||||
t.Fatal("stopped background before HTTP finished")
|
||||
default:
|
||||
}
|
||||
close(svc.Release)
|
||||
if err := <-requestDone; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
await(t, state.Drain)
|
||||
if svc.Closed.Load() != before {
|
||||
t.Fatal("client closed during background drain")
|
||||
}
|
||||
close(state.Release)
|
||||
if err := <-done; !errors.Is(err, svc.FatalDependency) || errors.Is(err, lifecycle.ErrResourcesRetained) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !state.Wrote.Load() || svc.Closed.Load() != before+1 {
|
||||
t.Fatal("missing final write or cleanup")
|
||||
}
|
||||
if err := state.Future.Wait(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedBackgroundFailureCleanup(t *testing.T) {
|
||||
for _, failInit := range []bool{false, true} {
|
||||
t.Run(map[bool]string{true: "initialization", false: "listen"}[failInit], func(t *testing.T) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
before := svc.Closed.Load()
|
||||
err = run(context.Background(), phasedConfig(t, l.Addr().String(), "auto", failInit))
|
||||
if err == nil || errors.Is(err, lifecycle.ErrResourcesRetained) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := svc.Background.Load()
|
||||
if !state.Wrote.Load() || svc.Closed.Load() != before+1 {
|
||||
t.Fatal("failure cleanup did not join background first")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedBackgroundTimeoutProcess(t *testing.T) {
|
||||
if path := os.Getenv("OMNI_PHASE_TIMEOUT_CONFIG"); path != "" {
|
||||
os.Args = []string{os.Args[0], "-config", path}
|
||||
main()
|
||||
return
|
||||
}
|
||||
path := phasedConfig(t, "127.0.0.1:0", "stuck", true)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestGeneratedBackgroundTimeoutProcess$")
|
||||
cmd.Env = append(os.Environ(), "OMNI_PHASE_TIMEOUT_CONFIG="+path)
|
||||
output, err := cmd.CombinedOutput()
|
||||
var exit *exec.ExitError
|
||||
if !errors.As(err, &exit) || exit.ExitCode() != 1 || ctx.Err() != nil {
|
||||
t.Fatal(err, string(output))
|
||||
}
|
||||
if strings.Contains(string(output), "test.client.closed") || !strings.Contains(string(output), "shared resources retained") || !strings.Contains(string(output), "client initialization failed") {
|
||||
t.Fatal(string(output))
|
||||
}
|
||||
}
|
||||
231
cmd/go-omni/apigen/testdata/managed/shutdown_test.go
vendored
Normal file
231
cmd/go-omni/apigen/testdata/managed/shutdown_test.go
vendored
Normal file
@@ -0,0 +1,231 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"example.com/managed/internal/config"
|
||||
"example.com/managed/internal/handler"
|
||||
"example.com/managed/internal/svc"
|
||||
"git.yuntuo.hk/k2safe/go-omni/health"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
func TestGeneratedEndpointSettings(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Server.Log.Writer = io.Discard
|
||||
cfg.Server.Endpoints.Liveness.Path = "/livez"
|
||||
cfg.Server.Endpoints.Readiness.Path = "/readyz"
|
||||
cfg.Server.Endpoints.OpenAPI.Enabled = false
|
||||
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()
|
||||
app, err := handler.NewApp(deps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer app.Close()
|
||||
for _, path := range []string{"/health/live", "/health/ready", "/openapi.json"} {
|
||||
if out := response(t, app, "GET", path, nil, nil); out.Code != 404 {
|
||||
t.Fatal(path, out.Code)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/readyz", "/livez"} {
|
||||
out := response(t, app, "GET", path, nil, nil)
|
||||
var data map[string]any
|
||||
_ = json.Unmarshal(out.Body.Bytes(), &data)
|
||||
if out.Code != 200 || data["code"] != float64(0) || data["msg"] != "success" || data["message"] != nil || data["requestId"] != nil {
|
||||
t.Fatal(path, out.Code, out.Body.String())
|
||||
}
|
||||
}
|
||||
deps.RequestShutdown(errors.New("private shutdown cause"))
|
||||
out := response(t, app, "GET", "/readyz", nil, nil)
|
||||
if out.Code != 503 || strings.Contains(out.Body.String(), "private") {
|
||||
t.Fatal(out.Code, out.Body.String())
|
||||
}
|
||||
if out = response(t, app, "GET", "/livez", nil, nil); out.Code != 200 {
|
||||
t.Fatal("liveness should still work")
|
||||
}
|
||||
// NewApp uses the project's latest configuration, including endpoint switches.
|
||||
deps.Config.Server.Endpoints.Readiness.ResponseMode = "raw"
|
||||
deps.Config.Server.Endpoints.Liveness.Enabled = false
|
||||
deps.Config.Server.Endpoints.OpenAPI.Enabled = true
|
||||
deps.Config.Server.Endpoints.OpenAPI.Path = "/spec.json"
|
||||
app2, err := handler.NewApp(deps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer app2.Close()
|
||||
out = response(t, app2, "GET", "/readyz", nil, nil)
|
||||
if out.Code != 503 || strings.Contains(out.Body.String(), `"code"`) || !strings.Contains(out.Body.String(), `"status":"not_ready"`) {
|
||||
t.Fatal(out.Body.String())
|
||||
}
|
||||
out = response(t, app2, "GET", "/spec.json", nil, nil)
|
||||
if out.Code != 200 || !strings.Contains(out.Body.String(), `"openapi":`) || strings.Contains(out.Body.String(), `"msg":"操作成功"`) {
|
||||
t.Fatal("OpenAPI was wrapped")
|
||||
}
|
||||
deps.Config.Server.Endpoints.Readiness.Path = "/check"
|
||||
if _, err = handler.NewApp(deps); err == nil || !strings.Contains(err.Error(), "overlaps business") {
|
||||
t.Fatal("collision accepted", err)
|
||||
}
|
||||
deps.Config.Server.Endpoints.Readiness.Enabled = false
|
||||
if a, err := handler.NewApp(deps); err != nil {
|
||||
t.Fatal("disabled endpoint reserves path", err)
|
||||
} else {
|
||||
_ = a.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedFailedProbe(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Server.Log.Writer = io.Discard
|
||||
logger, _ := logx.New(cfg.Server.Log)
|
||||
defer logger.Close()
|
||||
deps, err := svc.New(context.Background(), logger, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer deps.Close()
|
||||
if err = deps.Health.Add(health.Probe{Name: "database", Check: func(context.Context) error { return errors.New("private connection details") }}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, err := handler.NewApp(deps)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer app.Close()
|
||||
out := response(t, app, "GET", "/health/ready", nil, nil)
|
||||
if out.Code != 503 || strings.Contains(out.Body.String(), "private") || !strings.Contains(out.Body.String(), `"msg":`) {
|
||||
t.Fatal(out.Code, out.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedDependencyShutdown(t *testing.T) {
|
||||
for _, transport := range []string{"nethttp", "native"} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
address := listener.Addr().String()
|
||||
_ = listener.Close()
|
||||
t.Setenv("GO_OMNI_TRANSPORT", transport)
|
||||
svc.Started = make(chan struct{}, 1)
|
||||
svc.Release = make(chan struct{})
|
||||
before := svc.Closed.Load()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
done := make(chan error, 1)
|
||||
path := configFile(t, address, false)
|
||||
go func() { done <- run(ctx, path) }()
|
||||
base := "http://" + address
|
||||
waitReady(t, base, done)
|
||||
deps := svc.Current.Load()
|
||||
requestDone := make(chan error, 1)
|
||||
go func() {
|
||||
client := http.Client{Timeout: 3 * time.Second}
|
||||
res, err := client.Get(base + "/slow")
|
||||
if err == nil {
|
||||
_, _ = io.Copy(io.Discard, res.Body)
|
||||
_ = res.Body.Close()
|
||||
if res.StatusCode != 200 {
|
||||
err = io.ErrUnexpectedEOF
|
||||
}
|
||||
}
|
||||
requestDone <- err
|
||||
}()
|
||||
select {
|
||||
case <-svc.Started:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("request did not start")
|
||||
}
|
||||
deps.RequestShutdown(svc.FatalDependency)
|
||||
// Inspect the same generated readiness handler during draining, even if
|
||||
// the network listener has already stopped accepting new connections.
|
||||
probe, _ := handler.NewApp(deps)
|
||||
out := httptest.NewRecorder()
|
||||
probe.ServeHTTP(out, httptest.NewRequest("GET", "/health/ready", nil))
|
||||
_ = probe.Close()
|
||||
if out.Code != 503 {
|
||||
t.Fatal("draining remained ready", out.Body.String())
|
||||
}
|
||||
if svc.Closed.Load() != before {
|
||||
t.Fatal("dependency closed before drain")
|
||||
}
|
||||
close(svc.Release)
|
||||
select {
|
||||
case err = <-requestDone:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("request stuck")
|
||||
}
|
||||
select {
|
||||
case err = <-done:
|
||||
if !errors.Is(err, svc.FatalDependency) {
|
||||
t.Fatal("fatal cause lost", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("shutdown stuck")
|
||||
}
|
||||
if svc.Closed.Load() != before+1 {
|
||||
t.Fatal("cleanup did not run once")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedFatalProcessExit(t *testing.T) {
|
||||
if path := os.Getenv("OMNI_TEST_FATAL_CONFIG"); path != "" {
|
||||
os.Args = []string{os.Args[0], "-config", path}
|
||||
main()
|
||||
return
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"shutdownInit":true,"server":{"address":"127.0.0.1:0"}}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestGeneratedFatalProcessExit$")
|
||||
cmd.Env = append(os.Environ(), "OMNI_TEST_FATAL_CONFIG="+path)
|
||||
output, err := cmd.CombinedOutput()
|
||||
var exit *exec.ExitError
|
||||
if !errors.As(err, &exit) || exit.ExitCode() != 1 {
|
||||
t.Fatal("fatal shutdown must exit 1", err, string(output))
|
||||
}
|
||||
if !strings.Contains(string(output), svc.FatalDependency.Error()) || strings.Count(string(output), "test.client.closed") != 1 {
|
||||
t.Fatal("missing failure or cleanup", string(output))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedEndpointConflictCleansUp(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(path, []byte(`{"server":{"address":"127.0.0.1:0","endpoints":{"readiness":{"path":"/check"}}}}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := svc.Closed.Load()
|
||||
err := run(context.Background(), path)
|
||||
if err == nil || !strings.Contains(err.Error(), "overlaps business") || svc.Closed.Load() != before+1 {
|
||||
t.Fatal("collision leaked resources or started serving", err)
|
||||
}
|
||||
}
|
||||
302
cmd/go-omni/apigen/upgrade.go
Normal file
302
cmd/go-omni/apigen/upgrade.go
Normal file
@@ -0,0 +1,302 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold"
|
||||
"go/ast"
|
||||
"go/format"
|
||||
goparser "go/parser"
|
||||
gotoken "go/token"
|
||||
"golang.org/x/mod/modfile"
|
||||
"golang.org/x/mod/semver"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type UpgradeChange struct {
|
||||
Path, Action string
|
||||
Manual bool
|
||||
before, after []byte
|
||||
mode os.FileMode
|
||||
exists bool
|
||||
}
|
||||
type UpgradePlan struct {
|
||||
Changes []UpgradeChange
|
||||
Module, Entry string
|
||||
}
|
||||
|
||||
// PlanUpgrade compares infrastructure against the exact previous release seeds.
|
||||
// A custom file is never classified as safe to replace. Logic, middleware,
|
||||
// options and repositories outside the changed templates are left untouched.
|
||||
func PlanUpgrade(dir string) (UpgradePlan, error) {
|
||||
var plan UpgradePlan
|
||||
root := canonical(dir)
|
||||
if err := safePath(root, manifestPath); err != nil {
|
||||
return plan, err
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, manifestPath))
|
||||
if err != nil {
|
||||
return plan, err
|
||||
}
|
||||
var m manifest
|
||||
if err = json.Unmarshal(raw, &m); err != nil {
|
||||
return plan, err
|
||||
}
|
||||
if m.Version != 2 || m.Profile == nil {
|
||||
return plan, errors.New("upgrade requires a managed v0.13+ project")
|
||||
}
|
||||
if filepath.Ext(m.Entry) != ".api" {
|
||||
return plan, errors.New("upgrade requires a valid API entry")
|
||||
}
|
||||
if err = safePath(root, m.Entry); err != nil {
|
||||
return plan, err
|
||||
}
|
||||
plan.Module, plan.Entry = m.Module, m.Entry
|
||||
if semver.IsValid(m.TemplateVersion) && semver.Compare(m.TemplateVersion, minimumVersion) >= 0 {
|
||||
return plan, nil
|
||||
}
|
||||
p := m.Profile
|
||||
legacy, err := scaffold.LegacyAPIInfrastructure(m.Module, p.Database, p.Storage, p.LogOutput, p.LogFormat)
|
||||
if err != nil {
|
||||
return plan, err
|
||||
}
|
||||
current, err := scaffold.APIInfrastructure(m.Module, p.Database, p.Storage, p.LogOutput, p.LogFormat)
|
||||
if err != nil {
|
||||
return plan, err
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for name := range legacy {
|
||||
names[name] = true
|
||||
}
|
||||
for name := range current {
|
||||
if !strings.HasSuffix(name, "Gen.go") {
|
||||
names[name] = true
|
||||
}
|
||||
}
|
||||
// v0.14 already owns config/svc extensions. Only bump the module here;
|
||||
// Generate updates generator-owned main/runtime files without replaying the
|
||||
// v0.13 seed migration over developer configuration and dependency code.
|
||||
if semver.IsValid(m.TemplateVersion) && semver.Compare(m.TemplateVersion, "v0.14.0-preview") >= 0 {
|
||||
names = map[string]bool{}
|
||||
}
|
||||
sorted := make([]string, 0, len(names))
|
||||
for name := range names {
|
||||
sorted = append(sorted, name)
|
||||
}
|
||||
sort.Strings(sorted)
|
||||
for _, name := range sorted {
|
||||
// options.go is unchanged by this release. Its application customizations remain untouched.
|
||||
previous, next := legacy[name], current[name]
|
||||
if strings.HasSuffix(name, ".go") {
|
||||
if previous != nil {
|
||||
previous, err = format.Source(previous)
|
||||
if err != nil {
|
||||
return plan, err
|
||||
}
|
||||
}
|
||||
if next != nil {
|
||||
next, err = format.Source(next)
|
||||
if err != nil {
|
||||
return plan, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if bytes.Equal(previous, next) {
|
||||
continue
|
||||
}
|
||||
if err = safePath(root, name); err != nil {
|
||||
return plan, err
|
||||
}
|
||||
existing, readErr := os.ReadFile(filepath.Join(root, name))
|
||||
if readErr != nil && !errors.Is(readErr, os.ErrNotExist) {
|
||||
return plan, readErr
|
||||
}
|
||||
exists := readErr == nil
|
||||
if bytes.Equal(existing, next) {
|
||||
continue
|
||||
}
|
||||
mode := os.FileMode(0644)
|
||||
if exists {
|
||||
stat, e := os.Stat(filepath.Join(root, name))
|
||||
if e != nil {
|
||||
return plan, e
|
||||
}
|
||||
mode = stat.Mode().Perm()
|
||||
}
|
||||
action := "update"
|
||||
if next == nil {
|
||||
action = "remove"
|
||||
} else if !exists {
|
||||
action = "create"
|
||||
}
|
||||
plan.Changes = append(plan.Changes, UpgradeChange{Path: name, Action: action, Manual: exists && !bytes.Equal(existing, previous), before: existing, after: next, mode: mode, exists: exists})
|
||||
}
|
||||
if err = safePath(root, "go.mod"); err != nil {
|
||||
return plan, err
|
||||
}
|
||||
raw, err = os.ReadFile(filepath.Join(root, "go.mod"))
|
||||
if err != nil {
|
||||
return plan, err
|
||||
}
|
||||
mf, err := modfile.Parse("go.mod", raw, nil)
|
||||
if err != nil {
|
||||
return plan, err
|
||||
}
|
||||
for _, r := range mf.Require {
|
||||
if r.Mod.Path == frameworkModule && semver.Compare(r.Mod.Version, minimumVersion) < 0 {
|
||||
if err = mf.AddRequire(frameworkModule, minimumVersion); err != nil {
|
||||
return plan, err
|
||||
}
|
||||
next, e := mf.Format()
|
||||
if e != nil {
|
||||
return plan, e
|
||||
}
|
||||
stat, e := os.Stat(filepath.Join(root, "go.mod"))
|
||||
if e != nil {
|
||||
return plan, e
|
||||
}
|
||||
plan.Changes = append(plan.Changes, UpgradeChange{Path: "go.mod", Action: "update", before: raw, after: next, exists: true, mode: stat.Mode().Perm()})
|
||||
break
|
||||
}
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// PreviewUpgrade writes only proposed infrastructure files into an empty directory.
|
||||
// It never mutates the service or emits a replacement business tree.
|
||||
func PreviewUpgrade(dir, destination string) (UpgradePlan, error) {
|
||||
plan, err := PlanUpgrade(dir)
|
||||
if err != nil {
|
||||
return plan, err
|
||||
}
|
||||
if entries, e := os.ReadDir(destination); e == nil && len(entries) > 0 {
|
||||
return plan, errors.New("upgrade preview directory must be empty")
|
||||
} else if e != nil && !errors.Is(e, os.ErrNotExist) {
|
||||
return plan, e
|
||||
}
|
||||
for _, c := range plan.Changes {
|
||||
if c.after == nil {
|
||||
continue
|
||||
}
|
||||
if err = safePath(destination, c.Path); err != nil {
|
||||
return plan, err
|
||||
}
|
||||
if err = atomicWrite(destination, c.Path, c.after, 0644); err != nil {
|
||||
return plan, err
|
||||
}
|
||||
}
|
||||
note := "# Infrastructure upgrade preview\n\nOnly untouched v0.13 seed files can be replaced automatically. Custom files require a targeted edit; this preview is never copied over the project automatically.\n\nMove dependency initialization into ServiceContext.Init; embed *lifecycle.Runtime; register cleanup with OnClose. Remove old New/Close/OnClose implementations after preserving their project work in Init or cleanup hooks. Use structured database/mysql.Config and DatabaseOptions. Preserve business configuration fields, AppOptions, middleware and Logic.\n\n"
|
||||
for _, c := range plan.Changes {
|
||||
note += fmt.Sprintf("- %s %s (manual review: %t)\n", c.Action, c.Path, c.Manual)
|
||||
}
|
||||
if err = safePath(destination, "UPGRADE.md"); err != nil {
|
||||
return plan, err
|
||||
}
|
||||
return plan, atomicWrite(destination, "UPGRADE.md", []byte(note), 0644)
|
||||
}
|
||||
|
||||
// ApplyUpgrade applies only recognized seed changes under the same generation
|
||||
// lock. A generation failure restores every changed developer file. Any manual
|
||||
// review item blocks all writes; use PreviewUpgrade and migrate that file first.
|
||||
func ApplyUpgrade(dir string) (plan UpgradePlan, err error) {
|
||||
root := canonical(dir)
|
||||
if err = safePath(root, ".goOmni/api.lock"); err != nil {
|
||||
return plan, err
|
||||
}
|
||||
lock, err := os.OpenFile(filepath.Join(root, ".goOmni/api.lock"), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
|
||||
if err != nil {
|
||||
return plan, err
|
||||
}
|
||||
_ = lock.Close()
|
||||
defer os.Remove(filepath.Join(root, ".goOmni/api.lock"))
|
||||
plan, err = PlanUpgrade(root)
|
||||
if err != nil {
|
||||
return plan, err
|
||||
}
|
||||
for _, c := range plan.Changes {
|
||||
if c.Manual {
|
||||
return plan, fmt.Errorf("upgrade: %s contains project changes; inspect an upgrade preview and migrate it explicitly", c.Path)
|
||||
}
|
||||
}
|
||||
var changed []UpgradeChange
|
||||
defer func() {
|
||||
if err != nil {
|
||||
for i := len(changed) - 1; i >= 0; i-- {
|
||||
c := changed[i]
|
||||
if c.exists {
|
||||
err = errors.Join(err, atomicWrite(root, c.Path, c.before, c.mode))
|
||||
} else {
|
||||
e := os.Remove(filepath.Join(root, c.Path))
|
||||
if !errors.Is(e, os.ErrNotExist) {
|
||||
err = errors.Join(err, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
for _, c := range plan.Changes {
|
||||
if c.after == nil {
|
||||
err = os.Remove(filepath.Join(root, c.Path))
|
||||
} else {
|
||||
err = atomicWrite(root, c.Path, c.after, c.mode)
|
||||
}
|
||||
if err != nil {
|
||||
return plan, err
|
||||
}
|
||||
changed = append(changed, c)
|
||||
}
|
||||
preview, e := Generate(Options{Dir: root, API: filepath.Join(root, plan.Entry), DryRun: true, lockHeld: true})
|
||||
if e != nil {
|
||||
return plan, e
|
||||
}
|
||||
if diagnosticErrors(preview.Diagnostics) > 0 {
|
||||
return plan, errors.New("upgrade: Logic checks failed; resolve diagnostics before applying")
|
||||
}
|
||||
_, err = Generate(Options{Dir: root, API: filepath.Join(root, plan.Entry), lockHeld: true})
|
||||
return plan, err
|
||||
}
|
||||
|
||||
func checkRuntimeExtension(root string) error {
|
||||
directory := filepath.Join(root, "internal/svc")
|
||||
files, err := os.ReadDir(directory)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range files {
|
||||
if !sourceFile(entry.Name()) || strings.HasSuffix(entry.Name(), "Gen.go") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(directory, entry.Name())
|
||||
file, err := goparser.ParseFile(gotoken.NewFileSet(), path, nil, goparser.SkipObjectResolution)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, decl := range file.Decls {
|
||||
fn, ok := decl.(*ast.FuncDecl)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if fn.Recv == nil && fn.Name.Name == "New" {
|
||||
return fmt.Errorf("%s owns New; run go-omni api upgrade --dir %s and move project initialization into ServiceContext.Init", path, root)
|
||||
}
|
||||
if fn.Recv != nil && (fn.Name.Name == "Close" || fn.Name.Name == "OnClose" || fn.Name.Name == "Finish") {
|
||||
receiver := fn.Recv.List[0].Type
|
||||
if p, ok := receiver.(*ast.StarExpr); ok {
|
||||
receiver = p.X
|
||||
}
|
||||
if id, ok := receiver.(*ast.Ident); ok && id.Name == "ServiceContext" {
|
||||
return fmt.Errorf("%s overrides framework resource lifecycle; inspect go-omni api upgrade and register cleanup in Init", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
167
cmd/go-omni/apigen/upgrade_test.go
Normal file
167
cmd/go-omni/apigen/upgrade_test.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold"
|
||||
"go/format"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func oldInfrastructureProject(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := filepath.Join(t.TempDir(), "app")
|
||||
if _, err := New(Options{Dir: dir, Module: "example.com/upgrade", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous, err := scaffold.LegacyAPIInfrastructure("example.com/upgrade", "none", "none", "stderr", "json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, content := range previous {
|
||||
if strings.HasSuffix(name, ".go") {
|
||||
content, err = format.Source(content)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, name), string(content))
|
||||
}
|
||||
var m manifest
|
||||
if err = json.Unmarshal([]byte(readFile(t, filepath.Join(dir, manifestPath))), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delete(m.Generated, "internal/svc/runtimeGen.go")
|
||||
m.TemplateVersion = ""
|
||||
_ = os.Remove(filepath.Join(dir, "internal/svc/runtimeGen.go"))
|
||||
raw, _ := json.Marshal(m)
|
||||
writeFile(t, filepath.Join(dir, manifestPath), string(raw))
|
||||
path := filepath.Join(dir, "go.mod")
|
||||
writeFile(t, path, strings.ReplaceAll(readFile(t, path), minimumVersion, "v0.13.0-preview"))
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestUpgradeRejectsEscapingEntry(t *testing.T) {
|
||||
dir := oldInfrastructureProject(t)
|
||||
var m manifest
|
||||
if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, manifestPath))), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Entry = "../outside.api"
|
||||
raw, _ := json.Marshal(m)
|
||||
writeFile(t, filepath.Join(dir, manifestPath), string(raw))
|
||||
before := snapshot(t, dir)
|
||||
if _, err := ApplyUpgrade(dir); err == nil {
|
||||
t.Fatal("escaping API entry accepted")
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, dir)) {
|
||||
t.Fatal("invalid upgrade wrote project")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeV014PreservesExtensions(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "service")
|
||||
if _, err := New(Options{Dir: dir, Module: "example.com/upgrade", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m manifest
|
||||
if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, manifestPath))), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.TemplateVersion = "v0.14.0-preview"
|
||||
raw, _ := json.Marshal(m)
|
||||
writeFile(t, filepath.Join(dir, manifestPath), string(raw))
|
||||
mod := filepath.Join(dir, "go.mod")
|
||||
writeFile(t, mod, strings.ReplaceAll(readFile(t, mod), minimumVersion, "v0.14.1-preview"))
|
||||
owned := map[string]string{}
|
||||
for _, name := range []string{"internal/svc/serviceContext.go", "internal/config/config.go", "etc/config.yaml"} {
|
||||
path := filepath.Join(dir, name)
|
||||
value := readFile(t, path)
|
||||
if strings.HasSuffix(name, ".go") {
|
||||
value += "\n// project extension kept\n"
|
||||
} else {
|
||||
value += "\n# project configuration kept\n"
|
||||
}
|
||||
writeFile(t, path, value)
|
||||
owned[path] = value
|
||||
}
|
||||
plan, err := PlanUpgrade(dir)
|
||||
if err != nil || len(plan.Changes) != 1 || plan.Changes[0].Path != "go.mod" {
|
||||
t.Fatal(plan, err)
|
||||
}
|
||||
if _, err := ApplyUpgrade(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for path, want := range owned {
|
||||
if readFile(t, path) != want {
|
||||
t.Fatal("developer file modified", path)
|
||||
}
|
||||
}
|
||||
if _, err := Generate(Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), Check: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func TestSafeInfrastructureUpgrade(t *testing.T) {
|
||||
dir := oldInfrastructureProject(t)
|
||||
before := snapshot(t, dir)
|
||||
if _, err := Generate(Options{Dir: dir, API: filepath.Join(dir, "api/service.api")}); err == nil {
|
||||
t.Fatal("legacy constructor accepted")
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, dir)) {
|
||||
t.Fatal("failed generate wrote files")
|
||||
}
|
||||
plan, err := PlanUpgrade(dir)
|
||||
if err != nil || len(plan.Changes) == 0 {
|
||||
t.Fatal(plan, err)
|
||||
}
|
||||
for _, c := range plan.Changes {
|
||||
if c.Manual {
|
||||
t.Fatal(c.Path)
|
||||
}
|
||||
}
|
||||
preview := filepath.Join(t.TempDir(), "preview")
|
||||
if _, err = PreviewUpgrade(dir, preview); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, dir)) {
|
||||
t.Fatal("preview changed project")
|
||||
}
|
||||
logic := readFile(t, filepath.Join(dir, "internal/logic/greet/greet.go"))
|
||||
if _, err = ApplyUpgrade(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = os.Stat(filepath.Join(dir, "internal/svc/close.go")); !os.IsNotExist(err) {
|
||||
t.Fatal("old lifecycle remains")
|
||||
}
|
||||
if readFile(t, filepath.Join(dir, "internal/logic/greet/greet.go")) != logic {
|
||||
t.Fatal("business changed")
|
||||
}
|
||||
if _, err = Generate(Options{Dir: dir, API: filepath.Join(dir, "api/service.api"), Check: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plan, err = PlanUpgrade(dir)
|
||||
if err != nil || len(plan.Changes) != 0 {
|
||||
t.Fatal("upgrade not idempotent", plan, err)
|
||||
}
|
||||
}
|
||||
func TestUpgradePreservesCustomFilesAndRollsBack(t *testing.T) {
|
||||
for _, custom := range []bool{true, false} {
|
||||
dir := oldInfrastructureProject(t)
|
||||
if custom {
|
||||
path := filepath.Join(dir, "internal/svc/serviceContext.go")
|
||||
writeFile(t, path, readFile(t, path)+"\n// custom dependency\n")
|
||||
} else {
|
||||
path := filepath.Join(dir, "internal/logic/greet/greet.go")
|
||||
writeFile(t, path, "package greet;func(s *Logic)Greet(){}")
|
||||
}
|
||||
before := snapshot(t, dir)
|
||||
if _, err := ApplyUpgrade(dir); err == nil {
|
||||
t.Fatal("unsafe upgrade accepted")
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, dir)) {
|
||||
t.Fatal("failed upgrade changed project")
|
||||
}
|
||||
}
|
||||
}
|
||||
144
cmd/go-omni/apigen/workspace.go
Normal file
144
cmd/go-omni/apigen/workspace.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type WorkspaceService struct {
|
||||
Dir string `json:"dir"`
|
||||
API string `json:"api,omitempty"`
|
||||
Module string `json:"module,omitempty"`
|
||||
}
|
||||
type WorkspaceConfig struct {
|
||||
Services []WorkspaceService `json:"services"`
|
||||
ErrorCatalogs []WorkspaceCatalog `json:"errorCatalogs,omitempty"`
|
||||
}
|
||||
|
||||
// Workspace operates independent Go modules listed in an explicit local file.
|
||||
// All projects are preflighted before generate/format writes. Each generator
|
||||
// transaction is atomic within its project; no cross-module transaction is promised.
|
||||
func Workspace(path, command string, w io.Writer) error {
|
||||
if command != "generate" && command != "check" && command != "format" {
|
||||
return errors.New("workspace command must be generate, check or format")
|
||||
}
|
||||
if w == nil {
|
||||
w = io.Discard
|
||||
}
|
||||
real, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stat, err := os.Lstat(real)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !stat.Mode().IsRegular() || stat.Size() > 65536 {
|
||||
return errors.New("workspace configuration must be a regular file of at most 64 KiB")
|
||||
}
|
||||
raw, err := os.ReadFile(real)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var cfg WorkspaceConfig
|
||||
if err = apierrors.DecodeCatalogJSON(raw, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(cfg.Services) == 0 || len(cfg.Services) > 64 {
|
||||
return errors.New("workspace must list 1..64 services")
|
||||
}
|
||||
root := canonical(filepath.Dir(real))
|
||||
var options []Options
|
||||
seen := map[string]bool{}
|
||||
for _, service := range cfg.Services {
|
||||
if service.Dir == "" || service.Dir == "." {
|
||||
return errors.New("workspace requires distinct relative service directories")
|
||||
}
|
||||
if err = safePath(root, service.Dir+"/.goOmniWorkspaceCheck"); err != nil {
|
||||
return err
|
||||
}
|
||||
dir := filepath.Join(root, filepath.FromSlash(service.Dir))
|
||||
key := strings.ToLower(dir)
|
||||
for prior := range seen {
|
||||
if prior == key || strings.HasPrefix(key, prior+string(filepath.Separator)) || strings.HasPrefix(prior, key+string(filepath.Separator)) {
|
||||
return errors.New("workspace service directories overlap")
|
||||
}
|
||||
}
|
||||
seen[key] = true
|
||||
entry := service.API
|
||||
if entry == "" {
|
||||
entry, err = ProjectEntry(dir)
|
||||
} else {
|
||||
if err = safePath(dir, entry); err != nil {
|
||||
return err
|
||||
}
|
||||
entry = filepath.Join(dir, entry)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
options = append(options, Options{API: entry, Dir: dir, Module: service.Module, DryRun: true})
|
||||
}
|
||||
var catalogs map[string][]byte
|
||||
if command != "format" {
|
||||
catalogs, err = prepareWorkspaceCatalogs(root, cfg.ErrorCatalogs, command == "check")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range options {
|
||||
options[i].documentCatalogs = catalogs
|
||||
}
|
||||
}
|
||||
var failures error
|
||||
for _, o := range options {
|
||||
if command == "format" {
|
||||
spec, e := Parse(o.API)
|
||||
if e == nil {
|
||||
_, e = Compile(spec)
|
||||
}
|
||||
err = e
|
||||
} else {
|
||||
_, err = Generate(o)
|
||||
}
|
||||
if err != nil {
|
||||
failures = errors.Join(failures, fmt.Errorf("%s: %w", o.Dir, err))
|
||||
}
|
||||
}
|
||||
if failures != nil {
|
||||
return failures
|
||||
}
|
||||
if command == "generate" {
|
||||
for path, data := range catalogs {
|
||||
if err = writeWorkspaceCatalog(path, data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, o := range options {
|
||||
switch command {
|
||||
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
|
||||
}
|
||||
217
cmd/go-omni/apigen/workspaceCatalog.go
Normal file
217
cmd/go-omni/apigen/workspaceCatalog.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
gotoken "go/token"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"golang.org/x/mod/modfile"
|
||||
"golang.org/x/mod/module"
|
||||
)
|
||||
|
||||
// WorkspaceCatalog is an explicitly trusted Go provider: func() errors.Catalog.
|
||||
// Only workspace generate/check execute providers. Parsing an .api never does.
|
||||
type WorkspaceCatalog struct {
|
||||
Dir string `json:"dir"`
|
||||
Package string `json:"package"`
|
||||
Function string `json:"function"`
|
||||
Output string `json:"output"`
|
||||
}
|
||||
|
||||
type limitedCatalogBuffer struct {
|
||||
bytes.Buffer
|
||||
limit int
|
||||
}
|
||||
|
||||
func (b *limitedCatalogBuffer) Write(p []byte) (int, error) {
|
||||
if len(p) > b.limit-b.Len() {
|
||||
return 0, errors.New("catalog provider output exceeds limit")
|
||||
}
|
||||
return b.Buffer.Write(p)
|
||||
}
|
||||
|
||||
func exportWorkspaceCatalog(root string, cfg WorkspaceCatalog) ([]byte, error) {
|
||||
if cfg.Dir == "" || cfg.Output == "" || !gotoken.IsIdentifier(cfg.Function) || !gotoken.IsExported(cfg.Function) || module.CheckImportPath(cfg.Package) != nil {
|
||||
return nil, errors.New("workspace catalog requires dir, an importable package, an exported function and output")
|
||||
}
|
||||
if err := safePath(root, filepath.ToSlash(filepath.Join(cfg.Dir, ".goOmniExportCheck"))); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := safePath(root, cfg.Output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if filepath.Ext(cfg.Output) != ".json" {
|
||||
return nil, errors.New("workspace catalog output must be .json")
|
||||
}
|
||||
dir := filepath.Join(root, filepath.FromSlash(cfg.Dir))
|
||||
if stat, err := os.Stat(filepath.Join(dir, "go.mod")); err != nil || !stat.Mode().IsRegular() {
|
||||
return nil, errors.New("workspace catalog dir must contain go.mod")
|
||||
}
|
||||
temp, err := os.MkdirTemp("", "go-omni-catalog-*")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(temp)
|
||||
// Overlay a virtual Go file under the provider module. This allows internal
|
||||
// packages without creating files or changing go.mod/go.sum in that module.
|
||||
virtual := filepath.Join(dir, ".goOmniCatalogExport", "main.go")
|
||||
if _, err := os.Lstat(filepath.Dir(virtual)); !errors.Is(err, os.ErrNotExist) {
|
||||
return nil, errors.New("reserved catalog export path already exists")
|
||||
}
|
||||
source := fmt.Sprintf("package main\nimport (\"os\"; provider %q; omnierrors %q)\nfunc main(){if err:=omnierrors.ExportCatalog(os.Stdout,provider.%s());err!=nil{os.Exit(1)}}\n", cfg.Package, frameworkModule+"/errors", cfg.Function)
|
||||
backing := filepath.Join(temp, "main.go")
|
||||
if err = os.WriteFile(backing, []byte(source), 0600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overlay, _ := json.Marshal(map[string]any{"Replace": map[string]string{virtual: backing}})
|
||||
overlayPath := filepath.Join(temp, "overlay.json")
|
||||
if err = os.WriteFile(overlayPath, overlay, 0600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Keep all module metadata writes in the temporary directory as well.
|
||||
// -mod=readonly alone must not be relied on to protect every go.sum update.
|
||||
moduleData, err := os.ReadFile(filepath.Join(dir, "go.mod"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mf, err := modfile.Parse("go.mod", moduleData, nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("workspace catalog has invalid go.mod")
|
||||
}
|
||||
replacements := append([]*modfile.Replace(nil), mf.Replace...)
|
||||
for _, r := range replacements {
|
||||
if r.New.Version == "" && !filepath.IsAbs(r.New.Path) {
|
||||
if err = mf.AddReplace(r.Old.Path, r.Old.Version, filepath.Join(dir, r.New.Path), ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
moduleData, err = mf.Format()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
modPath := filepath.Join(temp, "go.mod")
|
||||
if err = os.WriteFile(modPath, moduleData, 0600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sums, e := os.ReadFile(filepath.Join(dir, "go.sum")); e == nil {
|
||||
if err = os.WriteFile(filepath.Join(temp, "go.sum"), sums, 0600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if !errors.Is(e, os.ErrNotExist) {
|
||||
return nil, e
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
executable := filepath.Join(temp, "catalogExport")
|
||||
// Build first, then execute the binary directly: cancellation kills the
|
||||
// provider itself instead of only terminating a go-run wrapper process.
|
||||
build := exec.CommandContext(ctx, "go", "build", "-mod=readonly", "-modfile", modPath, "-overlay", overlayPath, "-o", executable, virtual)
|
||||
build.Dir = dir
|
||||
build.Env = append(os.Environ(), "GOWORK=off")
|
||||
build.WaitDelay = 2 * time.Second
|
||||
build.Stdout = &limitedCatalogBuffer{limit: 64 << 10}
|
||||
build.Stderr = &limitedCatalogBuffer{limit: 64 << 10}
|
||||
if err = build.Run(); err != nil {
|
||||
return nil, fmt.Errorf("workspace catalog %s.%s: build failed (%v); check module dependencies and func() errors.Catalog signature", cfg.Package, cfg.Function, err)
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, executable)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
stdout := &limitedCatalogBuffer{limit: 1 << 20}
|
||||
stderr := &limitedCatalogBuffer{limit: 64 << 10}
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
if err = cmd.Run(); err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return nil, fmt.Errorf("workspace catalog %s: %w", cfg.Package, ctx.Err())
|
||||
}
|
||||
// Provider output can contain arbitrary application data. Do not echo it.
|
||||
return nil, fmt.Errorf("workspace catalog %s.%s: execution failed (%v); provider must return errors.Catalog without writing stdout", cfg.Package, cfg.Function, err)
|
||||
}
|
||||
var catalog apierrors.Catalog
|
||||
if err = apierrors.DecodeCatalogJSON(stdout.Bytes(), &catalog); err != nil {
|
||||
return nil, errors.New("workspace catalog provider did not emit a valid catalog")
|
||||
}
|
||||
var normalized bytes.Buffer
|
||||
if err = apierrors.ExportCatalog(&normalized, catalog); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalized.Bytes(), nil
|
||||
}
|
||||
|
||||
func prepareWorkspaceCatalogs(root string, configs []WorkspaceCatalog, check bool) (map[string][]byte, error) {
|
||||
if len(configs) > 16 {
|
||||
return nil, errors.New("workspace supports at most 16 catalog providers")
|
||||
}
|
||||
result := map[string][]byte{}
|
||||
for _, cfg := range configs {
|
||||
if err := safePath(root, cfg.Output); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := filepath.Join(root, filepath.FromSlash(cfg.Output))
|
||||
for prior := range result {
|
||||
if strings.EqualFold(prior, path) {
|
||||
return nil, errors.New("workspace catalog outputs overlap")
|
||||
}
|
||||
}
|
||||
data, err := exportWorkspaceCatalog(root, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if check {
|
||||
old, err := os.ReadFile(path)
|
||||
if err != nil || !bytes.Equal(old, data) {
|
||||
return nil, fmt.Errorf("workspace catalog drift: %s; run workspace generate", cfg.Output)
|
||||
}
|
||||
}
|
||||
result[path] = data
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func writeWorkspaceCatalog(path string, data []byte) error {
|
||||
mode := os.FileMode(0644)
|
||||
if stat, err := os.Lstat(path); err == nil {
|
||||
if !stat.Mode().IsRegular() {
|
||||
return errors.New("workspace catalog output must be a regular file")
|
||||
}
|
||||
mode = stat.Mode().Perm()
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
old, err := os.ReadFile(path)
|
||||
if err == nil && bytes.Equal(old, data) {
|
||||
return nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".omni-catalog-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.Remove(f.Name())
|
||||
if err = f.Chmod(mode); err != nil {
|
||||
_ = f.Close()
|
||||
return err
|
||||
}
|
||||
_, writeErr := io.Copy(f, bytes.NewReader(data))
|
||||
err = errors.Join(writeErr, f.Close())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(f.Name(), path)
|
||||
}
|
||||
168
cmd/go-omni/apigen/workspaceCatalog_test.go
Normal file
168
cmd/go-omni/apigen/workspaceCatalog_test.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWorkspaceSharedGoCatalog(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, filepath.Join(root, "go.mod"), fmt.Sprintf("module example.com/catalogs\n\ngo 1.24.0\nrequire %s v0.15.0-preview\nreplace %s => %s\n", frameworkModule, frameworkModule, strconvQuote(frameworkRoot(t))))
|
||||
writeFile(t, filepath.Join(root, "internal/apierrors/catalog.go"), `package apierrors
|
||||
import errors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
func Catalog() errors.Catalog {return errors.Catalog{DefaultStatus:400,Definitions:map[string]errors.Definition{
|
||||
"INVALID":{Code:"INVALID",Message:"invalid input"},"MISSING":{Status:404,Code:"MISSING",Message:"missing"},
|
||||
}}}
|
||||
`)
|
||||
if err := os.MkdirAll(filepath.Join(root, "contracts"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"api", "id"} {
|
||||
dir := filepath.Join(root, "services", name)
|
||||
if _, err := New(Options{Dir: dir, Module: "example.com/" + name, FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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"]}}}`)
|
||||
}
|
||||
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"}]}`)
|
||||
if err := Workspace(workspace, "generate", io.Discard); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Workspace(workspace, "format", io.Discard); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Workspace(workspace, "generate", io.Discard); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := snapshot(t, root)
|
||||
if err := Workspace(workspace, "check", io.Discard); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, root)) {
|
||||
t.Fatal("check wrote project files")
|
||||
}
|
||||
for _, name := range []string{"api", "id"} {
|
||||
entry := filepath.Join(root, "services", name, "api/service.api")
|
||||
spec, err := Parse(entry)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := Compile(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
op := operation(t, document(t, c), "/api/greet/{name}", "get")
|
||||
if op["responses"].(map[string]any)["404"] == nil || len(spec.Errors) != 0 || len(spec.Routes[0].Errors) != 0 {
|
||||
t.Fatal("selection changed runtime catalog")
|
||||
}
|
||||
if _, err = os.Stat(filepath.Join(filepath.Dir(entry), catalogDocumentFile)); !os.IsNotExist(err) {
|
||||
t.Fatal("shared catalog was copied")
|
||||
}
|
||||
}
|
||||
shared := filepath.Join(root, "contracts/errors.json")
|
||||
data := readFile(t, shared)
|
||||
writeFile(t, shared, strings.Replace(data, "invalid input", "stale", 1))
|
||||
before = snapshot(t, root)
|
||||
if err := Workspace(workspace, "check", io.Discard); err == nil || !strings.Contains(err.Error(), "catalog drift") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, root)) {
|
||||
t.Fatal("drift check wrote files")
|
||||
}
|
||||
if err := Workspace(workspace, "generate", io.Discard); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := readFile(t, shared); got != data {
|
||||
t.Fatal("catalog not refreshed")
|
||||
}
|
||||
// Failed provider/preflight may not update even the shared metadata.
|
||||
source := filepath.Join(root, "internal/apierrors/catalog.go")
|
||||
writeFile(t, source, readFile(t, source)+"\ninvalid go source")
|
||||
before = snapshot(t, root)
|
||||
if err := Workspace(workspace, "generate", io.Discard); err == nil {
|
||||
t.Fatal("provider failure ignored")
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, root)) {
|
||||
t.Fatal("provider failure mutated project")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentSelectionBoundaries(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
local := filepath.Join(root, "api")
|
||||
shared := filepath.Join(root, "shared")
|
||||
entry := filepath.Join(local, "service.api")
|
||||
writeFile(t, entry, "service demo {\n@handler list\nget /list returns ([]string)\n}\n")
|
||||
writeFile(t, filepath.Join(shared, "errors.json"), `{"defaultStatus":400,"definitions":{"INVALID":{"code":400002,"message":"invalid"}}}`)
|
||||
for _, cfg := range []string{
|
||||
`{"source":"@common/errors.json","include":["UNKNOWN"]}`,
|
||||
`{"source":"@common/errors.json","routes":{"GET /absent":["INVALID"]}}`,
|
||||
`{"source":"@common/../api/service.api"}`,
|
||||
`{"source":"../shared/errors.json"}`,
|
||||
} {
|
||||
writeFile(t, filepath.Join(local, importsFile), `{"roots":{"common":{"path":"../shared"}},"errors":`+cfg+`}`)
|
||||
if _, err := Parse(entry); err == nil {
|
||||
t.Fatal("bad selection accepted", cfg)
|
||||
}
|
||||
}
|
||||
writeFile(t, filepath.Join(local, importsFile), `{"roots":{"common":{"path":"../shared"}},"errors":{"source":"@common/errors.json","include":[]}}`)
|
||||
spec, err := Parse(entry)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(documentRouteCatalog(spec, spec.Routes[0]).Definitions) != 0 {
|
||||
t.Fatal("empty include ignored")
|
||||
}
|
||||
seeded, err := sharedSeeds(spec, filepath.Join(root, "generated"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cfg sharedConfig
|
||||
if err = json.Unmarshal(seeded, &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Errors.Include == nil || !bytes.Contains(seeded, []byte(`"include": []`)) {
|
||||
t.Fatal("empty selection lost during relocation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceCatalogPathsAndProviderValidation(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeFile(t, filepath.Join(root, "go.mod"), "module example.com/test\ngo 1.24.0\n")
|
||||
good := WorkspaceCatalog{Dir: ".", Package: "example.com/test/internal/errors", Function: "Catalog", Output: "errors.json"}
|
||||
cases := []WorkspaceCatalog{}
|
||||
for _, change := range []func(*WorkspaceCatalog){
|
||||
func(c *WorkspaceCatalog) { c.Dir = "../outside" },
|
||||
func(c *WorkspaceCatalog) { c.Output = "../outside.json" },
|
||||
func(c *WorkspaceCatalog) { c.Package = "example.com/pkg;bad" },
|
||||
func(c *WorkspaceCatalog) { c.Function = "Catalog()" },
|
||||
func(c *WorkspaceCatalog) { c.Output = "go.mod" },
|
||||
} {
|
||||
c := good
|
||||
change(&c)
|
||||
cases = append(cases, c)
|
||||
}
|
||||
for _, c := range cases {
|
||||
if _, err := exportWorkspaceCatalog(root, c); err == nil {
|
||||
t.Fatal("bad provider accepted", c)
|
||||
}
|
||||
}
|
||||
outside := filepath.Join(t.TempDir(), "errors.json")
|
||||
writeFile(t, outside, `{}`)
|
||||
if err := os.Symlink(outside, filepath.Join(root, "errors.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := prepareWorkspaceCatalogs(root, []WorkspaceCatalog{good}, false); err == nil {
|
||||
t.Fatal("symlink output accepted")
|
||||
}
|
||||
if readFile(t, outside) != `{}` {
|
||||
t.Fatal("outside file modified")
|
||||
}
|
||||
}
|
||||
43
cmd/go-omni/apigen/workspace_test.go
Normal file
43
cmd/go-omni/apigen/workspace_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWorkspaceIndependentModules(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"api", "id"} {
|
||||
dir := filepath.Join(root, "services", name)
|
||||
if _, err := New(Options{Dir: dir, Module: "example.com/" + name, FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
config := filepath.Join(root, "omni.workspace.json")
|
||||
writeFile(t, config, `{"services":[{"dir":"services/api"},{"dir":"services/id"}]}`)
|
||||
for _, op := range []string{"format", "generate", "check", "generate", "check"} {
|
||||
if err := Workspace(config, op, io.Discard); err != nil {
|
||||
t.Fatal(op, err)
|
||||
}
|
||||
}
|
||||
// A bad second service must not leave the first service partially regenerated.
|
||||
first := filepath.Join(root, "services/api/api/service.api")
|
||||
writeFile(t, first, strings.Replace(readFile(t, first), "Greet a visitor", "Greet", 1))
|
||||
second := filepath.Join(root, "services/id/api/service.api")
|
||||
writeFile(t, second, "invalid source")
|
||||
before := snapshot(t, root)
|
||||
if err := Workspace(config, "generate", io.Discard); err == nil {
|
||||
t.Fatal("invalid workspace accepted")
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, root)) {
|
||||
t.Fatal("preflight failure changed project")
|
||||
}
|
||||
for _, bad := range []string{`{"services":[{"dir":"../outside"}]}`, `{"services":[{"dir":"services/api"},{"dir":"services/api/nested"}]}`, `{"services":[{"dir":"services/api","dir":"services/id"}]}`} {
|
||||
writeFile(t, config, bad)
|
||||
if err := Workspace(config, "check", io.Discard); err == nil {
|
||||
t.Fatal("invalid workspace accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,13 @@ import (
|
||||
const manifestPath = ".goOmni/apiManifest.json"
|
||||
|
||||
type manifest struct {
|
||||
Profile *projectProfile `json:"profile,omitempty"`
|
||||
Version int `json:"version"`
|
||||
Module string `json:"module"`
|
||||
Entry string `json:"entry"`
|
||||
Generated map[string]string `json:"generated"`
|
||||
Contract *contractSnapshot `json:"contract,omitempty"`
|
||||
TemplateVersion string `json:"templateVersion,omitempty"`
|
||||
Profile *projectProfile `json:"profile,omitempty"`
|
||||
Version int `json:"version"`
|
||||
Module string `json:"module"`
|
||||
Entry string `json:"entry"`
|
||||
Generated map[string]string `json:"generated"`
|
||||
Contract *contractSnapshot `json:"contract,omitempty"`
|
||||
}
|
||||
type change struct {
|
||||
name string
|
||||
@@ -30,7 +31,7 @@ type change struct {
|
||||
mode os.FileMode
|
||||
}
|
||||
|
||||
func apply(root, module, entry string, generated, seeds map[string][]byte, check, dryRun bool, profile projectProfile, contract *contractSnapshot) (Output, error) {
|
||||
func apply(root, module, entry string, generated, seeds map[string][]byte, check, dryRun bool, profile projectProfile, contract *contractSnapshot, lockHeld ...bool) (Output, error) {
|
||||
var result Output
|
||||
if info, err := os.Lstat(root); err == nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
|
||||
@@ -61,7 +62,7 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
|
||||
if err := safePath(root, manifestPath); err != nil {
|
||||
return result, err
|
||||
}
|
||||
if !check && !dryRun {
|
||||
if !check && !dryRun && !(len(lockHeld) > 0 && lockHeld[0]) {
|
||||
if err := os.MkdirAll(filepath.Join(root, ".goOmni"), 0755); err != nil {
|
||||
return result, err
|
||||
}
|
||||
@@ -108,7 +109,7 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
next := manifest{Profile: &profile, Version: 2, Module: module, Entry: entry, Generated: map[string]string{}, Contract: contract}
|
||||
next := manifest{TemplateVersion: minimumVersion, Profile: &profile, Version: 2, Module: module, Entry: entry, Generated: map[string]string{}, Contract: contract}
|
||||
for name, data := range generated {
|
||||
next.Generated[name] = digest(data)
|
||||
}
|
||||
@@ -271,7 +272,10 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
|
||||
}
|
||||
func digest(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }
|
||||
func ownedPath(name string) bool {
|
||||
if name == "main.go" || name == "internal/handler/appGen.go" || name == "internal/handler/responseGen.go" || name == "internal/types/scalarsGen.go" {
|
||||
if strings.HasPrefix(name, "internal/types/dto") && strings.HasSuffix(name, "Gen.go") && goName.MatchString(strings.TrimSuffix(filepath.Base(name), ".go")) {
|
||||
return true
|
||||
}
|
||||
if name == "main.go" || name == "internal/svc/runtimeGen.go" || name == "internal/handler/appGen.go" || name == "internal/handler/responseGen.go" || name == "internal/types/scalarsGen.go" {
|
||||
return true
|
||||
}
|
||||
if name == "internal/apierrors/errorsGen.go" || name == "docs/openapi.json" || name == "internal/types/typesGen.go" || name == "internal/handler/routesGen.go" || name == "internal/handler/openapiGen.go" || name == "internal/middleware/registryGen.go" {
|
||||
|
||||
@@ -18,6 +18,11 @@ func main() {
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "workspace":
|
||||
if err := runWorkspace(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "go-omni workspace:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
case "api":
|
||||
if err := runAPI(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "go-omni api:", err)
|
||||
@@ -170,6 +175,9 @@ func usage() {
|
||||
fmt.Fprintln(os.Stderr, " go-omni new --api <file.api> [--module <module>] <directory>")
|
||||
fmt.Fprintln(os.Stderr, " go-omni api new [--module <module>] <directory> Create a runnable API project")
|
||||
fmt.Fprintln(os.Stderr, " go-omni api generate [--api <file.api>] [--dir <project>] [--check|--dry-run]")
|
||||
fmt.Fprintln(os.Stderr, " go-omni api format [--api <file.api>] [--check]")
|
||||
fmt.Fprintln(os.Stderr, " go-omni api upgrade [--dir <project>] [--dry-run|--preview <directory>|--apply]")
|
||||
fmt.Fprintln(os.Stderr, " go-omni workspace <format|generate|check> [--file omni.workspace.json]")
|
||||
fmt.Fprintln(os.Stderr, " go-omni api inspect [--api <file.api>] Show routes, auth and business errors")
|
||||
fmt.Fprintln(os.Stderr, " go-omni api export [--api <file.api>] Write OpenAPI JSON to stdout")
|
||||
fmt.Fprintln(os.Stderr, " go-omni api validate --api <file.api> Validate definitions and OpenAPI")
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
||||
for _, flag := range [][]string{{"--framework-version", "v0.12.0-preview"}, {"--framework-version=v0.12.0-preview"}} {
|
||||
for _, flag := range [][]string{{"--framework-version", "v0.16.0-preview"}, {"--framework-version=v0.16.0-preview"}} {
|
||||
project := filepath.Join(t.TempDir(), "billing")
|
||||
args := append([]string{"--module", "example.com/billing"}, flag...)
|
||||
if err := runNew(append(args, project)); err != nil {
|
||||
@@ -18,7 +18,7 @@ func TestNewWithRemoteFrameworkVersion(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(content), "go-omni v0.12.0-preview") || strings.Contains(string(content), "replace ") {
|
||||
if !strings.Contains(string(content), "go-omni v0.16.0-preview") || strings.Contains(string(content), "replace ") {
|
||||
t.Fatalf("CLI did not pin remote dependency: %s", content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,17 +8,37 @@ import (
|
||||
// APIInfrastructure renders shared infrastructure without inventing business
|
||||
// tables or routes. The API generator applies these as developer-owned seeds.
|
||||
func APIInfrastructure(module, database, storage, logOutput, logFormat string) (map[string][]byte, error) {
|
||||
return apiInfrastructure(module, database, storage, logOutput, logFormat, false)
|
||||
}
|
||||
|
||||
// LegacyAPIInfrastructure renders only the known v0.13 seed baseline for a safe upgrade.
|
||||
func LegacyAPIInfrastructure(module, database, storage, logOutput, logFormat string) (map[string][]byte, error) {
|
||||
return apiInfrastructure(module, database, storage, logOutput, logFormat, true)
|
||||
}
|
||||
func apiInfrastructure(module, database, storage, logOutput, logFormat string, legacy bool) (map[string][]byte, error) {
|
||||
data := templateData{Module: module, Database: database, Storage: storage, LogOutput: logOutput, LogFormat: logFormat, API: true, Modules: true}
|
||||
files := map[string]string{
|
||||
"internal/svc/close.go": "templates/api-close.go.tmpl",
|
||||
"internal/svc/runtimeGen.go": "templates/api-runtime.go.tmpl",
|
||||
"internal/svc/options.go": "templates/api-options.go.tmpl",
|
||||
"internal/config/config.go": "templates/modules-settings.go.tmpl",
|
||||
"internal/config/config.go": "templates/api-config.go.tmpl",
|
||||
"etc/config.yaml": "templates/modules-config.yaml.tmpl",
|
||||
"internal/svc/serviceContext.go": "templates/api-context.go.tmpl",
|
||||
}
|
||||
if database == "mysql" {
|
||||
files["internal/svc/databaseOptions.go"] = "templates/api-databaseOptions.go.tmpl"
|
||||
}
|
||||
if storage != "none" {
|
||||
files["internal/svc/storage.go"] = "templates/modules-storage.go.tmpl"
|
||||
}
|
||||
if legacy {
|
||||
delete(files, "internal/svc/runtimeGen.go")
|
||||
delete(files, "internal/svc/databaseOptions.go")
|
||||
files["internal/svc/close.go"] = "templates/api-close.go.tmpl"
|
||||
files["internal/config/config.go"] = "templates/modules-settings.go.tmpl"
|
||||
for name, source := range files {
|
||||
files[name] = strings.Replace(source, "templates/", "templates/legacyV013/", 1)
|
||||
}
|
||||
}
|
||||
result := make(map[string][]byte, len(files))
|
||||
for name, source := range files {
|
||||
content, err := render(source, data)
|
||||
|
||||
@@ -4,6 +4,7 @@ import "errors"
|
||||
func(c *Context)Close()error{
|
||||
if c==nil{return nil}
|
||||
c.closeOnce.Do(func(){
|
||||
if c.Lifecycle!=nil{c.Lifecycle.RequestShutdown(nil)}
|
||||
c.closeMu.Lock();c.closed=true;cleanups:=c.cleanups;c.cleanups=nil;c.closeMu.Unlock()
|
||||
for i:=len(cleanups)-1;i>=0;i--{c.closeErr=errors.Join(c.closeErr,cleanups[i]())}
|
||||
{{if ne .Storage "none"}}if c.storageClose!=nil{c.closeErr=errors.Join(c.closeErr,c.storageClose())}{{end}}
|
||||
|
||||
104
cmd/go-omni/scaffold/templates/api-config.go.tmpl
Normal file
104
cmd/go-omni/scaffold/templates/api-config.go.tmpl
Normal file
@@ -0,0 +1,104 @@
|
||||
package settings
|
||||
import (
|
||||
"os"
|
||||
{{if and (not .API) (ne .Storage "none")}}"strings"{{end}}
|
||||
"time"
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
{{if eq .Database "mysql"}}mysqladapter "git.yuntuo.hk/k2safe/go-omni/database/mysql"{{end}}
|
||||
{{if ne .Storage "none"}}"errors"
|
||||
{{if ne .Storage "r2"}}"net/url"{{else}}"regexp"{{end}}
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"{{end}}
|
||||
)
|
||||
type Config struct {
|
||||
Server config.Config `json:"server"`
|
||||
{{if eq .Database "mysql"}}Database mysqladapter.Config `json:"database" env:"GO_OMNI_DB"`{{end}}
|
||||
{{if ne .Storage "none"}}Storage StorageConfig `json:"storage" env:"GO_OMNI_STORAGE"`{{end}}
|
||||
}
|
||||
func Default() Config {
|
||||
server:=config.Default()
|
||||
server.Address="127.0.0.1:8080"
|
||||
server.EnableLogger=true
|
||||
server.SlowRequestThreshold=500*time.Millisecond
|
||||
server.Log.Service={{printf "%q" .Module}}
|
||||
server.Log.Format={{printf "%q" .LogFormat}}
|
||||
{{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}
|
||||
{{else}}server.Log.Output={{printf "%q" .LogOutput}}{{end}}
|
||||
return Config{Server:server{{if and .API (eq .Database "mysql")}},Database:mysqladapter.DefaultConfig(){{end}}{{if ne .Storage "none"}},Storage:StorageConfig{
|
||||
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"},
|
||||
LinkTTL:5*time.Minute, RequestTimeout:30*time.Second,
|
||||
{{if eq .Storage "s3"}}Region:"us-east-1",{{end}}
|
||||
{{if eq .Storage "r2"}}Region:"auto",{{end}}
|
||||
{{if eq .Storage "oss"}}Region:"cn-hangzhou",{{end}}
|
||||
}{{end}}}
|
||||
}
|
||||
|
||||
// Load applies defaults -> file -> typed environment -> validation.
|
||||
// New fields automatically use GO_OMNI_<JSON_PATH>, or an explicit env tag.
|
||||
func Load(path string)(Config,error){
|
||||
cfg:=Default();var err error
|
||||
cfg,err=config.ApplyDefaults(cfg);if err!=nil{return Config{},err}
|
||||
if path!="" {cfg,err=config.DecodeFile(path,cfg);if err!=nil{return Config{},err}}
|
||||
cfg,err=config.OverlayEnv(cfg,config.EnvOptions{Prefix:"GO_OMNI_",Empty:"ignore",Aliases:config.ServerEnvAliases("GO_OMNI_","server")});if err!=nil{return Config{},err}
|
||||
output,outputSet:=os.LookupEnv("GO_OMNI_SERVER_LOG_OUTPUT");if !outputSet {output=os.Getenv("GO_OMNI_LOG_OUTPUT")}
|
||||
file,fileSet:=os.LookupEnv("GO_OMNI_SERVER_LOG_FILE");if !fileSet {file=os.Getenv("GO_OMNI_LOG_FILE")}
|
||||
if output!="" && file=="" {cfg.Server.Log.File="";cfg.Server.Log.Rotation=logx.RotationConfig{}}
|
||||
return cfg,cfg.Validate()
|
||||
}
|
||||
func(c Config)Validate()error{
|
||||
if err:=c.Server.Validate();err!=nil{return err}
|
||||
{{if eq .Database "mysql"}}if err:=c.Database.Validate();err!=nil{return err}{{end}}
|
||||
{{if ne .Storage "none"}}if err:=c.Storage.Validate();err!=nil{return err}{{end}}
|
||||
return nil
|
||||
}
|
||||
{{if ne .Storage "none"}}
|
||||
type StorageConfig struct {
|
||||
Provider string `json:"provider"`
|
||||
Root string `json:"root"`
|
||||
PublicURL string `json:"publicUrl"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Region string `json:"region"`
|
||||
Bucket string `json:"bucket"`
|
||||
AccountID string `json:"accountId"`
|
||||
Secure bool `json:"secure"`
|
||||
MaxBytes int64 `json:"maxBytes"`
|
||||
ContentTypes []string `json:"contentTypes"`
|
||||
LinkTTL time.Duration `json:"linkTtl"`
|
||||
RequestTimeout time.Duration `json:"requestTimeout"`
|
||||
// Secrets are environment-only and cannot be populated by config files.
|
||||
FilesToken string `json:"-" env:"GO_OMNI_FILES_TOKEN,secret"`
|
||||
SigningKey string `json:"-" env:"GO_OMNI_STORAGE_SIGNING_KEY,secret"`
|
||||
AccessKey string `json:"-" env:"GO_OMNI_STORAGE_ACCESS_KEY,secret"`
|
||||
SecretKey string `json:"-" env:"GO_OMNI_STORAGE_SECRET_KEY,secret"`
|
||||
SessionToken string `json:"-" env:"GO_OMNI_STORAGE_SESSION_TOKEN,secret"`
|
||||
}
|
||||
func (s StorageConfig) Policy()storage.UploadPolicy{return storage.UploadPolicy{MaxBytes:s.MaxBytes,ContentTypes:s.ContentTypes}}
|
||||
func (s StorageConfig) Validate()error {
|
||||
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 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 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 eq .Storage "local"}}
|
||||
if s.Root=="" {return errors.New("storage.root is required")}
|
||||
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 len(s.SigningKey)<32 {return errors.New("set GO_OMNI_STORAGE_SIGNING_KEY to a stable secret of at least 32 bytes")}
|
||||
{{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 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 s.Endpoint!=""{return errors.New("this R2 profile derives endpoint from accountId; customize servicecontext for a jurisdiction-specific endpoint")}
|
||||
{{else if eq .Storage "s3"}}
|
||||
u,err:=url.Parse("https://"+s.Endpoint)
|
||||
if err!=nil || s.Endpoint=="" || u.Hostname()=="" || u.User!=nil || u.Path!="" || u.RawQuery!="" || u.Fragment!="" {return errors.New("S3 endpoint must be host[:port] without scheme or path")}
|
||||
{{else if eq .Storage "oss"}}
|
||||
if s.Endpoint!="" {u,err:=url.Parse(s.Endpoint);if err!=nil || u.Scheme!="https" || u.Hostname()=="" || u.User!=nil || u.Path!="" || u.RawQuery!="" || u.Fragment!="" {return errors.New("OSS endpoint must be an HTTPS origin")}}
|
||||
if !s.Secure{return errors.New("OSS requires secure=true")}
|
||||
{{end}}
|
||||
{{end}}
|
||||
return nil
|
||||
}
|
||||
{{end}}
|
||||
@@ -1,24 +1,20 @@
|
||||
package servicecontext
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
{{if ne .Storage "none"}}"net/http"
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"{{end}}
|
||||
{{if eq .Database "mysql"}}"time"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
"github.com/go-sql-driver/mysql"{{end}}
|
||||
{{if eq .Database "mysql"}}"git.yuntuo.hk/k2safe/go-omni/database"{{end}}
|
||||
"git.yuntuo.hk/k2safe/go-omni/health"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"{{.Module}}/internal/settings"
|
||||
"{{.Module}}/internal/middleware"
|
||||
)
|
||||
// Context owns infrastructure, while Logger is borrowed from main.
|
||||
// Add business repositories here; HTTP contracts do not define database tables.
|
||||
// Context holds project dependencies. Runtime owns cleanup and shutdown.
|
||||
// Logger is borrowed; the generated entry closes it after dependencies.
|
||||
type Context struct {
|
||||
*lifecycle.Runtime
|
||||
Config settings.Config
|
||||
cleanups []func()error
|
||||
Logger *logx.Logger
|
||||
Health *health.Checker
|
||||
Middlewares map[string]httpx.Middleware
|
||||
@@ -29,33 +25,11 @@ type Context struct {
|
||||
Downloads storage.Presigner
|
||||
UploadLinks storage.UploadPresigner
|
||||
FileHTTP http.Handler
|
||||
storageClose func()error
|
||||
{{end}}
|
||||
closeMu sync.Mutex
|
||||
closed bool
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
}
|
||||
// Pass the settings loaded by main. The optional form supports lightweight
|
||||
// tests without infrastructure; selected adapters still require valid settings.
|
||||
func New(ctx context.Context,logger *logx.Logger,configs ...settings.Config)(result *Context,err error){
|
||||
if ctx==nil{ctx=context.Background()}
|
||||
if err=ctx.Err();err!=nil{return nil,err}
|
||||
if len(configs)>1{return nil,errors.New("expected at most one configuration")}
|
||||
cfg:=settings.Default();if len(configs)==1{cfg=configs[0]}
|
||||
_=cfg
|
||||
checker,err:=health.New();if err!=nil{return nil,err}
|
||||
c:=&Context{Config:cfg,Logger:logger,Health:checker,Middlewares:middleware.Defaults()}
|
||||
defer func(){if err!=nil{err=errors.Join(err,c.Close())}}()
|
||||
{{if eq .Database "mysql"}}
|
||||
if err=cfg.Validate();err!=nil{return nil,err}
|
||||
dsn,parseErr:=mysql.ParseDSN(cfg.Database.DSN)
|
||||
if parseErr!=nil || dsn.DBName=="" {return nil,errors.New("GO_OMNI_DB_DSN must contain a valid MySQL database name")}
|
||||
dsn.ParseTime=true;dsn.Loc=time.UTC
|
||||
cfg.Database.DSN=dsn.FormatDSN()
|
||||
c.DB,err=database.Open(ctx,cfg.Database);if err!=nil{return nil,err}
|
||||
if err=checker.Add(health.Probe{Name:"database",Timeout:cfg.Database.PingTimeout,Check:c.DB.Health});err!=nil{return nil,err}
|
||||
{{end}}
|
||||
{{if ne .Storage "none"}}if err=c.InitStorage(ctx,cfg.Storage);err!=nil{return nil,err}{{end}}
|
||||
return c,nil
|
||||
}
|
||||
// Init is project-owned. Add dependencies here and immediately register each
|
||||
// cleanup with c.OnClose. Background components use paired OnStop/OnDrain hooks
|
||||
// and WorkContext. Failed initialization stops/drains before releasing resources;
|
||||
// unjoined background work returns ErrResourcesRetained instead of closing its dependencies.
|
||||
// Generated infrastructure is ready here. Never create another App or logger.
|
||||
func(c *Context)Init(ctx context.Context)error {return nil}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package servicecontext
|
||||
import mysqladapter "git.yuntuo.hk/k2safe/go-omni/database/mysql"
|
||||
// DatabaseOptions supplies explicit driver settings or bounded startup checks.
|
||||
// Keep schema/version requirements in this project; do not open another pool.
|
||||
func(c *Context)DatabaseOptions()[]mysqladapter.Option{return nil}
|
||||
34
cmd/go-omni/scaffold/templates/api-runtime.go.tmpl
Normal file
34
cmd/go-omni/scaffold/templates/api-runtime.go.tmpl
Normal file
@@ -0,0 +1,34 @@
|
||||
package servicecontext
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/health"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
{{if eq .Database "mysql"}}mysqladapter "git.yuntuo.hk/k2safe/go-omni/database/mysql"{{end}}
|
||||
"{{.Module}}/internal/settings"
|
||||
"{{.Module}}/internal/middleware"
|
||||
)
|
||||
// New is generator-owned. Customize dependencies in ServiceContext.Init.
|
||||
func New(ctx context.Context,logger *logx.Logger,configs ...settings.Config)(result *Context,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 len(configs)>1{return nil,errors.New("expected at most one configuration")}
|
||||
cfg:=settings.Default();if len(configs)==1{cfg=configs[0]}
|
||||
if err=cfg.Validate();err!=nil{return nil,err}
|
||||
checker,err:=health.New();if err!=nil{return nil,err}
|
||||
runtime,err:=lifecycle.NewRuntimeWithConfig(logx.WithLogger(ctx,logger),cfg.Server.Lifecycle);if err!=nil{return nil,err}
|
||||
c:=&Context{Config:cfg,Logger:logger,Health:checker,Middlewares:middleware.Defaults(),Runtime:runtime}
|
||||
ctx=logx.WithLogger(c.Context(),logger)
|
||||
defer func(){if p:=recover();p!=nil{_ = c.Close();panic(p)};if err!=nil{err=c.Finish(err)}}()
|
||||
{{if eq .Database "mysql"}}
|
||||
options:=append([]mysqladapter.Option{mysqladapter.WithLogger(logger)},c.DatabaseOptions()...)
|
||||
c.DB,err=mysqladapter.Open(ctx,cfg.Database,options...);if err!=nil{return nil,err}
|
||||
c.OnClose(c.DB.Close)
|
||||
if err=checker.Add(health.Probe{Name:"database",Timeout:cfg.Database.Pool.PingTimeout,Check:c.DB.Health});err!=nil{return nil,err}
|
||||
{{end}}
|
||||
{{if ne .Storage "none"}}if err=c.InitStorage(ctx,cfg.Storage);err!=nil{return nil,err}{{end}}
|
||||
if err=c.Init(ctx);err!=nil{return nil,err}
|
||||
return c,nil
|
||||
}
|
||||
23
cmd/go-omni/scaffold/templates/legacyV013/api-close.go.tmpl
Normal file
23
cmd/go-omni/scaffold/templates/legacyV013/api-close.go.tmpl
Normal file
@@ -0,0 +1,23 @@
|
||||
package servicecontext
|
||||
import "errors"
|
||||
// Close is idempotent, including startup failure and graceful shutdown.
|
||||
func(c *Context)Close()error{
|
||||
if c==nil{return nil}
|
||||
c.closeOnce.Do(func(){
|
||||
if c.Lifecycle!=nil{c.Lifecycle.RequestShutdown(nil)}
|
||||
c.closeMu.Lock();c.closed=true;cleanups:=c.cleanups;c.cleanups=nil;c.closeMu.Unlock()
|
||||
for i:=len(cleanups)-1;i>=0;i--{c.closeErr=errors.Join(c.closeErr,cleanups[i]())}
|
||||
{{if ne .Storage "none"}}if c.storageClose!=nil{c.closeErr=errors.Join(c.closeErr,c.storageClose())}{{end}}
|
||||
{{if eq .Database "mysql"}}if c.DB!=nil{c.closeErr=errors.Join(c.closeErr,c.DB.Close())}{{end}}
|
||||
})
|
||||
return c.closeErr
|
||||
}
|
||||
|
||||
// OnClose transfers a resource cleanup to this context immediately after creation.
|
||||
// Hooks run once in reverse order, also when later initialization fails.
|
||||
func(c *Context)OnClose(cleanup func()error) {
|
||||
if cleanup==nil{return}
|
||||
c.closeMu.Lock();defer c.closeMu.Unlock()
|
||||
if c.closed{panic("svc: register cleanup after Close")}
|
||||
c.cleanups=append(c.cleanups,cleanup)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package servicecontext
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
{{if ne .Storage "none"}}"net/http"
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"{{end}}
|
||||
{{if eq .Database "mysql"}}"time"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
"github.com/go-sql-driver/mysql"{{end}}
|
||||
"git.yuntuo.hk/k2safe/go-omni/health"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"{{.Module}}/internal/settings"
|
||||
"{{.Module}}/internal/middleware"
|
||||
)
|
||||
// Context owns infrastructure, while Logger is borrowed from main.
|
||||
// Add business repositories here; HTTP contracts do not define database tables.
|
||||
type Context struct {
|
||||
Lifecycle *lifecycle.Controller
|
||||
Config settings.Config
|
||||
cleanups []func()error
|
||||
Logger *logx.Logger
|
||||
Health *health.Checker
|
||||
Middlewares map[string]httpx.Middleware
|
||||
{{if eq .Database "mysql"}}DB *database.DB{{end}}
|
||||
{{if ne .Storage "none"}}
|
||||
Files storage.Store
|
||||
Uploads *storage.Uploader
|
||||
Downloads storage.Presigner
|
||||
UploadLinks storage.UploadPresigner
|
||||
FileHTTP http.Handler
|
||||
storageClose func()error
|
||||
{{end}}
|
||||
closeMu sync.Mutex
|
||||
closed bool
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
}
|
||||
// Pass the settings loaded by main. The optional form supports lightweight
|
||||
// tests without infrastructure; selected adapters still require valid settings.
|
||||
func New(ctx context.Context,logger *logx.Logger,configs ...settings.Config)(result *Context,err error){
|
||||
if ctx==nil{ctx=context.Background()}
|
||||
if err=ctx.Err();err!=nil{return nil,errors.Join(err,lifecycle.Cause(ctx))}
|
||||
if len(configs)>1{return nil,errors.New("expected at most one configuration")}
|
||||
cfg:=settings.Default();if len(configs)==1{cfg=configs[0]}
|
||||
_=cfg
|
||||
checker,err:=health.New();if err!=nil{return nil,err}
|
||||
c:=&Context{Config:cfg,Logger:logger,Health:checker,Middlewares:middleware.Defaults(),Lifecycle:lifecycle.New(ctx)}
|
||||
ctx=c.Lifecycle.Context()
|
||||
defer func(){if err!=nil{err=errors.Join(err,c.Close());if cause:=c.Lifecycle.Err();cause!=nil && !errors.Is(err,cause){err=errors.Join(err,cause)}}}()
|
||||
{{if eq .Database "mysql"}}
|
||||
if err=cfg.Validate();err!=nil{return nil,err}
|
||||
dsn,parseErr:=mysql.ParseDSN(cfg.Database.DSN)
|
||||
if parseErr!=nil || dsn.DBName=="" {return nil,errors.New("GO_OMNI_DB_DSN must contain a valid MySQL database name")}
|
||||
dsn.ParseTime=true;dsn.Loc=time.UTC
|
||||
cfg.Database.DSN=dsn.FormatDSN()
|
||||
c.DB,err=database.Open(ctx,cfg.Database);if err!=nil{return nil,err}
|
||||
if err=checker.Add(health.Probe{Name:"database",Timeout:cfg.Database.PingTimeout,Check:c.DB.Health});err!=nil{return nil,err}
|
||||
{{end}}
|
||||
{{if ne .Storage "none"}}if err=c.InitStorage(ctx,cfg.Storage);err!=nil{return nil,err}{{end}}
|
||||
return c,nil
|
||||
}
|
||||
|
||||
// RequestShutdown reports a fatal dependency failure, or a normal stop when
|
||||
// err is nil. It returns immediately; generated main drains and cleans up.
|
||||
func(c *Context)RequestShutdown(err error){c.Lifecycle.RequestShutdown(err)}
|
||||
@@ -0,0 +1,11 @@
|
||||
package servicecontext
|
||||
import (
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
)
|
||||
// AppOptions runs before routes are registered. Register project error catalogs
|
||||
// here with omni.WithErrors. It must not create resources or another App.
|
||||
func(c *Context)AppOptions()[]omni.Option{return nil}
|
||||
// GlobalMiddleware runs for all endpoints, including App 404/405 fallbacks.
|
||||
// Named route middleware lives in Middlewares and is selected by the API contract.
|
||||
func(c *Context)GlobalMiddleware()[]httpx.Middleware{return nil}
|
||||
@@ -0,0 +1,59 @@
|
||||
# Non-secret settings only. Environment variables override these values.
|
||||
server:
|
||||
address: '127.0.0.1:8080'
|
||||
enableLogger: true
|
||||
slowRequestThreshold: 500ms
|
||||
shutdownTimeout: 10s
|
||||
{{if .API}} 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:
|
||||
provider: {{.Storage}}
|
||||
maxBytes: 33554432
|
||||
contentTypes: ['text/plain', 'image/png', 'image/jpeg', 'application/pdf']
|
||||
linkTtl: 5m
|
||||
requestTimeout: 30s
|
||||
{{if eq .Storage "local"}} root: './var/files'
|
||||
publicUrl: 'http://127.0.0.1:8080/storage/object'
|
||||
{{else}} bucket: '' # set GO_OMNI_STORAGE_BUCKET
|
||||
secure: true
|
||||
{{if eq .Storage "r2"}} accountId: '' # set GO_OMNI_STORAGE_ACCOUNT_ID
|
||||
region: auto
|
||||
{{else if eq .Storage "s3"}} endpoint: '' # set GO_OMNI_STORAGE_ENDPOINT (host[:port])
|
||||
region: us-east-1
|
||||
{{else}} region: cn-hangzhou
|
||||
endpoint: '' # empty uses the official regional endpoint
|
||||
{{end}}{{end}}{{end}}
|
||||
|
||||
{{if and .API (eq .Database "mysql")}}database:
|
||||
driver: mysql
|
||||
maxOpenConns: 10
|
||||
maxIdleConns: 5
|
||||
connMaxLifetime: 30m
|
||||
connMaxIdleTime: 5m
|
||||
pingTimeout: 5s
|
||||
# GO_OMNI_DB_DSN supplies the secret connection string.
|
||||
{{end}}
|
||||
@@ -0,0 +1,140 @@
|
||||
// Package settings owns this project's configuration; it adds no business identity model.
|
||||
package settings
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
{{if ne .Storage "none"}}"fmt"
|
||||
{{if ne .Storage "r2"}}"net/url"{{else}}"regexp"{{end}}
|
||||
"strconv"
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"{{end}}
|
||||
{{if and .API (eq .Database "mysql")}}"git.yuntuo.hk/k2safe/go-omni/database"{{end}}
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server config.Config `json:"server"`
|
||||
{{if and .API (eq .Database "mysql")}}Database database.Config `json:"database"`{{end}}
|
||||
{{if ne .Storage "none"}}Storage StorageConfig `json:"storage"`{{end}}
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
server:=config.Default()
|
||||
server.Address="127.0.0.1:8080"
|
||||
server.EnableLogger=true
|
||||
server.SlowRequestThreshold=500*time.Millisecond
|
||||
server.Log.Service={{printf "%q" .Module}}
|
||||
server.Log.Format={{printf "%q" .LogFormat}}
|
||||
{{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}
|
||||
{{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{
|
||||
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"},
|
||||
LinkTTL:5*time.Minute, RequestTimeout:30*time.Second,
|
||||
{{if eq .Storage "s3"}}Region:"us-east-1",{{end}}
|
||||
{{if eq .Storage "r2"}}Region:"auto",{{end}}
|
||||
{{if eq .Storage "oss"}}Region:"cn-hangzhou",{{end}}
|
||||
}{{end}}}
|
||||
}
|
||||
|
||||
// Load uses defaults -> strict YAML/JSON -> nonempty environment variables.
|
||||
// Pass an empty path to use only defaults and environment.
|
||||
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 {
|
||||
{{if and .API (eq .Database "mysql")}}
|
||||
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")}
|
||||
{{end}}
|
||||
if err:=c.Server.Validate();err!=nil{return err}
|
||||
if c.Server.Log.Format!="json" && c.Server.Log.Format!="text" {return errors.New("log.format must be json or text")}
|
||||
switch strings.ToLower(c.Server.Log.Level) {case "debug","info","warn","warning","error":default:return errors.New("invalid log.level")}
|
||||
{{if ne .Storage "none"}}return c.Storage.Validate(){{else}}return nil{{end}}
|
||||
}
|
||||
|
||||
{{if ne .Storage "none"}}
|
||||
type StorageConfig struct {
|
||||
Provider string `json:"provider"`
|
||||
Root string `json:"root"`
|
||||
PublicURL string `json:"publicUrl"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Region string `json:"region"`
|
||||
Bucket string `json:"bucket"`
|
||||
AccountID string `json:"accountId"`
|
||||
Secure bool `json:"secure"`
|
||||
MaxBytes int64 `json:"maxBytes"`
|
||||
ContentTypes []string `json:"contentTypes"`
|
||||
LinkTTL time.Duration `json:"linkTtl"`
|
||||
RequestTimeout time.Duration `json:"requestTimeout"`
|
||||
// Secrets are environment-only and cannot be populated by config files.
|
||||
FilesToken string `json:"-"`
|
||||
SigningKey string `json:"-"`
|
||||
AccessKey string `json:"-"`
|
||||
SecretKey string `json:"-"`
|
||||
SessionToken string `json:"-"`
|
||||
}
|
||||
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 {
|
||||
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 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 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 eq .Storage "local"}}
|
||||
if s.Root=="" {return errors.New("storage.root is required")}
|
||||
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 len(s.SigningKey)<32 {return errors.New("set GO_OMNI_STORAGE_SIGNING_KEY to a stable secret of at least 32 bytes")}
|
||||
{{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 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 s.Endpoint!=""{return errors.New("this R2 profile derives endpoint from accountId; customize servicecontext for a jurisdiction-specific endpoint")}
|
||||
{{else if eq .Storage "s3"}}
|
||||
u,err:=url.Parse("https://"+s.Endpoint)
|
||||
if err!=nil || s.Endpoint=="" || u.Hostname()=="" || u.User!=nil || u.Path!="" || u.RawQuery!="" || u.Fragment!="" {return errors.New("S3 endpoint must be host[:port] without scheme or path")}
|
||||
{{else if eq .Storage "oss"}}
|
||||
if s.Endpoint!="" {u,err:=url.Parse(s.Endpoint);if err!=nil || u.Scheme!="https" || u.Hostname()=="" || u.User!=nil || u.Path!="" || u.RawQuery!="" || u.Fragment!="" {return errors.New("OSS endpoint must be an HTTPS origin")}}
|
||||
if !s.Secure{return errors.New("OSS requires secure=true")}
|
||||
{{end}}
|
||||
{{end}}
|
||||
return nil
|
||||
}
|
||||
{{end}}
|
||||
@@ -0,0 +1,79 @@
|
||||
package servicecontext
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
{{if eq .Storage "local"}}
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage/local"
|
||||
{{else}}
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
{{if eq .Storage "oss"}}
|
||||
ossStore "git.yuntuo.hk/k2safe/go-omni/storage/oss"
|
||||
ossSDK "github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss"
|
||||
"github.com/aliyun/alibabacloud-oss-go-sdk-v2/oss/credentials"
|
||||
{{else}}
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage/s3"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
{{end}}{{end}}
|
||||
"git.yuntuo.hk/k2safe/go-omni/storage"
|
||||
"{{.Module}}/internal/settings"
|
||||
)
|
||||
|
||||
// InitStorage is called once, before serving requests. The composition root
|
||||
// owns provider clients; business services receive only capability interfaces.
|
||||
func (c *Context) InitStorage(ctx context.Context,cfg settings.StorageConfig)error {
|
||||
if c==nil || ctx==nil{return errors.New("storage initialization requires context")}
|
||||
c.closeMu.Lock();defer c.closeMu.Unlock()
|
||||
if c.closed{return errors.New("service dependencies are closed")}
|
||||
if err:=ctx.Err();err!=nil{return err}
|
||||
if c.Files!=nil{return errors.New("storage already initialized")}
|
||||
if err:=cfg.Validate();err!=nil{return err}
|
||||
{{if eq .Storage "local"}}
|
||||
files,err:=local.New(cfg.Root,cfg.MaxBytes)
|
||||
if err!=nil{return err}
|
||||
signer,err:=storage.NewUploadSigner(files,cfg.PublicURL,[]byte(cfg.SigningKey),cfg.Policy())
|
||||
if err!=nil{return err}
|
||||
uploader,err:=storage.NewUploader(files,cfg.Policy())
|
||||
if err!=nil{return err}
|
||||
c.Files,c.Uploads,c.Downloads,c.UploadLinks,c.FileHTTP=files,uploader,signer,signer,signer
|
||||
{{else}}
|
||||
// Dedicated bounded pool, never the process-wide default transport.
|
||||
transport:=&http.Transport{
|
||||
Proxy:http.ProxyFromEnvironment,
|
||||
DialContext:(&net.Dialer{Timeout:5*time.Second,KeepAlive:30*time.Second}).DialContext,
|
||||
ForceAttemptHTTP2:true, MaxIdleConns:32,MaxIdleConnsPerHost:8,MaxConnsPerHost:32,
|
||||
IdleConnTimeout:90*time.Second,TLSHandshakeTimeout:5*time.Second,
|
||||
ResponseHeaderTimeout:cfg.RequestTimeout,
|
||||
}
|
||||
initialized:=false
|
||||
defer func(){if !initialized{transport.CloseIdleConnections()}}()
|
||||
{{if eq .Storage "oss"}}
|
||||
sdkConfig:=ossSDK.LoadDefaultConfig().WithRegion(cfg.Region).
|
||||
WithSignatureVersion(ossSDK.SignatureVersionV4).
|
||||
WithAdditionalHeaders([]string{"content-length"}).
|
||||
WithCredentialsProvider(credentials.NewStaticCredentialsProvider(cfg.AccessKey,cfg.SecretKey,cfg.SessionToken)).
|
||||
WithHttpClient(&http.Client{Transport:transport,Timeout:cfg.RequestTimeout})
|
||||
if cfg.Endpoint!=""{sdkConfig.WithEndpoint(cfg.Endpoint)}
|
||||
files,err:=ossStore.New(ossSDK.NewClient(sdkConfig),cfg.Bucket,cfg.MaxBytes)
|
||||
{{else}}
|
||||
creds:=credentials.NewStaticV4(cfg.AccessKey,cfg.SecretKey,cfg.SessionToken)
|
||||
{{if eq .Storage "r2"}}
|
||||
options,err:=s3.R2Config(cfg.AccountID,cfg.Bucket,creds)
|
||||
if err!=nil{return err}
|
||||
options.MaxBytes,options.Transport=cfg.MaxBytes,transport
|
||||
{{else}}
|
||||
options:=s3.Config{Endpoint:cfg.Endpoint,Region:cfg.Region,Bucket:cfg.Bucket,Secure:cfg.Secure,MaxBytes:cfg.MaxBytes,Credentials:creds,Transport:transport}
|
||||
{{end}}
|
||||
files,err:=s3.Open(options)
|
||||
{{end}}
|
||||
if err!=nil{return err}
|
||||
uploader,err:=storage.NewUploader(files,cfg.Policy())
|
||||
if err!=nil{return err}
|
||||
c.Files,c.Uploads,c.Downloads,c.UploadLinks=files,uploader,files,uploader
|
||||
c.storageClose=func()error{transport.CloseIdleConnections();return nil}
|
||||
initialized=true
|
||||
{{end}}
|
||||
return nil
|
||||
}
|
||||
@@ -4,6 +4,23 @@ server:
|
||||
enableLogger: true
|
||||
slowRequestThreshold: 500ms
|
||||
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
|
||||
@@ -36,11 +53,16 @@ server:
|
||||
{{end}}{{end}}{{end}}
|
||||
|
||||
{{if and .API (eq .Database "mysql")}}database:
|
||||
driver: mysql
|
||||
maxOpenConns: 10
|
||||
maxIdleConns: 5
|
||||
connMaxLifetime: 30m
|
||||
connMaxIdleTime: 5m
|
||||
pingTimeout: 5s
|
||||
# GO_OMNI_DB_DSN supplies the secret connection string.
|
||||
host: 127.0.0.1
|
||||
port: 3306
|
||||
database: '' # GO_OMNI_DB_DATABASE
|
||||
user: '' # GO_OMNI_DB_USER; password via GO_OMNI_DB_PASSWORD
|
||||
tls:
|
||||
mode: required
|
||||
pool:
|
||||
maxOpenConns: 10
|
||||
maxIdleConns: 5
|
||||
connMaxLifetime: 30m
|
||||
connMaxIdleTime: 5m
|
||||
pingTimeout: 5s
|
||||
{{end}}
|
||||
|
||||
@@ -25,8 +25,8 @@ import (
|
||||
// owns provider clients; business services receive only capability interfaces.
|
||||
func (c *Context) InitStorage(ctx context.Context,cfg settings.StorageConfig)error {
|
||||
if c==nil || ctx==nil{return errors.New("storage initialization requires context")}
|
||||
c.closeMu.Lock();defer c.closeMu.Unlock()
|
||||
if c.closed{return errors.New("service dependencies are closed")}
|
||||
{{if .API}}if c.Runtime==nil || c.Context().Err()!=nil{return errors.New("service dependencies are closed")}{{else}}c.closeMu.Lock();defer c.closeMu.Unlock()
|
||||
if c.closed{return errors.New("service dependencies are closed")}{{end}}
|
||||
if err:=ctx.Err();err!=nil{return err}
|
||||
if c.Files!=nil{return errors.New("storage already initialized")}
|
||||
if err:=cfg.Validate();err!=nil{return err}
|
||||
@@ -72,7 +72,7 @@ func (c *Context) InitStorage(ctx context.Context,cfg settings.StorageConfig)err
|
||||
uploader,err:=storage.NewUploader(files,cfg.Policy())
|
||||
if err!=nil{return err}
|
||||
c.Files,c.Uploads,c.Downloads,c.UploadLinks=files,uploader,files,uploader
|
||||
c.storageClose=func()error{transport.CloseIdleConnections();return nil}
|
||||
{{if .API}}c.OnClose(func()error{transport.CloseIdleConnections();return nil}){{else}}c.storageClose=func()error{transport.CloseIdleConnections();return nil}{{end}}
|
||||
initialized=true
|
||||
{{end}}
|
||||
return nil
|
||||
|
||||
@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
// Source builds can carry an unpublished VCS version (including +dirty).
|
||||
// Keep this fallback in sync with the next release; installed CLIs use their
|
||||
// downloaded module version, which also has a module checksum.
|
||||
const defaultFrameworkVersion = "v0.12.0-preview"
|
||||
const defaultFrameworkVersion = "v0.16.0-preview"
|
||||
|
||||
// This target is not a claim that the release already exists. Source users
|
||||
// select a local framework checkout until the module templates are published.
|
||||
|
||||
44
cmd/go-omni/upgrade.go
Normal file
44
cmd/go-omni/upgrade.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen"
|
||||
"io"
|
||||
)
|
||||
|
||||
func runUpgrade(args []string) error {
|
||||
flags := flag.NewFlagSet("upgrade", flag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
dir := flags.String("dir", ".", "service directory")
|
||||
apply := flags.Bool("apply", false, "apply only recognized baseline changes")
|
||||
preview := flags.String("preview", "", "write proposed infrastructure into an empty directory")
|
||||
dry := flags.Bool("dry-run", false, "show the upgrade plan")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return errors.New("unexpected upgrade argument")
|
||||
}
|
||||
if *apply && (*preview != "" || *dry) {
|
||||
return errors.New("--apply cannot be combined with preview/dry-run")
|
||||
}
|
||||
var plan apigen.UpgradePlan
|
||||
var err error
|
||||
if *apply {
|
||||
plan, err = apigen.ApplyUpgrade(*dir)
|
||||
} else if *preview != "" {
|
||||
plan, err = apigen.PreviewUpgrade(*dir, *preview)
|
||||
} else {
|
||||
plan, err = apigen.PlanUpgrade(*dir)
|
||||
}
|
||||
for _, c := range plan.Changes {
|
||||
kind := "safe"
|
||||
if c.Manual {
|
||||
kind = "review"
|
||||
}
|
||||
fmt.Printf("%-6s %-6s %s\n", kind, c.Action, c.Path)
|
||||
}
|
||||
return err
|
||||
}
|
||||
25
cmd/go-omni/workspace.go
Normal file
25
cmd/go-omni/workspace.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
func runWorkspace(args []string) error {
|
||||
if len(args) < 1 {
|
||||
return errors.New("usage: go-omni workspace <format|generate|check> --file omni.workspace.json")
|
||||
}
|
||||
flags := flag.NewFlagSet("workspace", flag.ContinueOnError)
|
||||
flags.SetOutput(io.Discard)
|
||||
path := flags.String("file", "omni.workspace.json", "workspace configuration")
|
||||
if err := flags.Parse(args[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if flags.NArg() != 0 {
|
||||
return errors.New("unexpected workspace argument")
|
||||
}
|
||||
return apigen.Workspace(*path, args[0], os.Stdout)
|
||||
}
|
||||
66
config/aliases_test.go
Normal file
66
config/aliases_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
81
config/clone.go
Normal file
81
config/clone.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Clone only configuration data. json:"-" capabilities remain borrowed and
|
||||
// are never traversed (log writers, clients and mutex-bearing runtime objects).
|
||||
// Unexported immutable implementation details such as time.Time are copied.
|
||||
func cloneConfiguration(v reflect.Value, depth int) (reflect.Value, error) {
|
||||
if depth > 64 {
|
||||
return reflect.Value{}, errors.New("config: defaults contain cycles or excessive nesting")
|
||||
}
|
||||
result := reflect.New(v.Type()).Elem()
|
||||
result.Set(v)
|
||||
switch v.Kind() {
|
||||
case reflect.Struct:
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
field := v.Type().Field(i)
|
||||
if field.PkgPath != "" || strings.Split(field.Tag.Get("json"), ",")[0] == "-" {
|
||||
continue
|
||||
}
|
||||
child, err := cloneConfiguration(v.Field(i), depth+1)
|
||||
if err != nil {
|
||||
return reflect.Value{}, err
|
||||
}
|
||||
result.Field(i).Set(child)
|
||||
}
|
||||
case reflect.Pointer:
|
||||
if !v.IsNil() {
|
||||
child, err := cloneConfiguration(v.Elem(), depth+1)
|
||||
if err != nil {
|
||||
return reflect.Value{}, err
|
||||
}
|
||||
result.Set(reflect.New(v.Type().Elem()))
|
||||
result.Elem().Set(child)
|
||||
}
|
||||
case reflect.Interface:
|
||||
if !v.IsNil() {
|
||||
child, err := cloneConfiguration(v.Elem(), depth+1)
|
||||
if err != nil {
|
||||
return reflect.Value{}, err
|
||||
}
|
||||
result.Set(child)
|
||||
}
|
||||
case reflect.Slice:
|
||||
if !v.IsNil() {
|
||||
result = reflect.MakeSlice(v.Type(), v.Len(), v.Len())
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
child, err := cloneConfiguration(v.Index(i), depth+1)
|
||||
if err != nil {
|
||||
return reflect.Value{}, err
|
||||
}
|
||||
result.Index(i).Set(child)
|
||||
}
|
||||
}
|
||||
case reflect.Array:
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
child, err := cloneConfiguration(v.Index(i), depth+1)
|
||||
if err != nil {
|
||||
return reflect.Value{}, err
|
||||
}
|
||||
result.Index(i).Set(child)
|
||||
}
|
||||
case reflect.Map:
|
||||
if !v.IsNil() {
|
||||
result = reflect.MakeMapWithSize(v.Type(), v.Len())
|
||||
iter := v.MapRange()
|
||||
for iter.Next() {
|
||||
child, err := cloneConfiguration(iter.Value(), depth+1)
|
||||
if err != nil {
|
||||
return reflect.Value{}, err
|
||||
}
|
||||
result.SetMapIndex(iter.Key(), child)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
@@ -26,19 +28,23 @@ type TLSConfig struct {
|
||||
|
||||
// Config controls the default runtime behavior of an App.
|
||||
type Config struct {
|
||||
Address string `json:"address"`
|
||||
Transport string `json:"transport"`
|
||||
TLS TLSConfig `json:"tls"`
|
||||
HTTP HTTPConfig `json:"http"`
|
||||
RequestIDHeader string `json:"requestIdHeader"`
|
||||
EnableLogger bool `json:"enableLogger"`
|
||||
SlowRequestThreshold time.Duration `json:"slowRequestThreshold"`
|
||||
TrustedProxies []string `json:"trustedProxies"`
|
||||
ShutdownTimeout time.Duration `json:"shutdownTimeout"`
|
||||
Log logx.Config `json:"log"`
|
||||
Errors apierrors.Catalog `json:"errors"`
|
||||
Response response.Config `json:"response"`
|
||||
ResponseEncoder response.Encoder `json:"-"`
|
||||
Request contract.RequestPolicy `json:"request"`
|
||||
Address string `json:"address"`
|
||||
Transport string `json:"transport"`
|
||||
TLS TLSConfig `json:"tls"`
|
||||
HTTP HTTPConfig `json:"http"`
|
||||
Endpoints EndpointsConfig `json:"endpoints"`
|
||||
RequestIDHeader string `json:"requestIdHeader"`
|
||||
EnableLogger bool `json:"enableLogger"`
|
||||
SlowRequestThreshold time.Duration `json:"slowRequestThreshold"`
|
||||
TrustedProxies []string `json:"trustedProxies"`
|
||||
ShutdownTimeout time.Duration `json:"shutdownTimeout"`
|
||||
Lifecycle lifecycle.ShutdownConfig `json:"lifecycle"`
|
||||
Log logx.Config `json:"log"`
|
||||
Errors apierrors.Catalog `json:"errors"`
|
||||
Response response.Config `json:"response"`
|
||||
ResponseEncoder response.Encoder `json:"-"`
|
||||
LogInstance *logx.Logger `json:"-"`
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
@@ -47,12 +53,26 @@ func Default() Config {
|
||||
Transport: TransportNetHTTP,
|
||||
RequestIDHeader: defaultRequestIDHeader,
|
||||
ShutdownTimeout: 10 * time.Second,
|
||||
Lifecycle: lifecycle.DefaultShutdownConfig(),
|
||||
Endpoints: DefaultEndpoints(),
|
||||
Log: logx.Default(),
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks configuration values before an application starts.
|
||||
func (c Config) Validate() error {
|
||||
if err := c.Lifecycle.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Request.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Log.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Endpoints.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.Errors.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -216,3 +236,10 @@ func WithErrors(catalog apierrors.Catalog) Option {
|
||||
catalog = catalog.Clone()
|
||||
return func(cfg *Config) { cfg.Errors = catalog.Clone() }
|
||||
}
|
||||
|
||||
// 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 WithRequestPolicy(policy contract.RequestPolicy) Option {
|
||||
return func(c *Config) { c.Request = policy }
|
||||
}
|
||||
|
||||
119
config/endpoints.go
Normal file
119
config/endpoints.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// EndpointsConfig controls routes installed by a generated service. A manually
|
||||
// assembled App still registers its own routes explicitly.
|
||||
type EndpointsConfig struct {
|
||||
Liveness HealthEndpointConfig `json:"liveness"`
|
||||
Readiness HealthEndpointConfig `json:"readiness"`
|
||||
OpenAPI EndpointConfig `json:"openapi"`
|
||||
}
|
||||
|
||||
type EndpointConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type HealthEndpointConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Path string `json:"path"`
|
||||
ResponseMode string `json:"responseMode"` // app (default) or raw
|
||||
}
|
||||
|
||||
func DefaultEndpoints() EndpointsConfig {
|
||||
return EndpointsConfig{
|
||||
Liveness: HealthEndpointConfig{Enabled: true, Path: "/health/live", ResponseMode: "app"},
|
||||
Readiness: HealthEndpointConfig{Enabled: true, Path: "/health/ready", ResponseMode: "app"},
|
||||
OpenAPI: EndpointConfig{Enabled: true, Path: "/openapi.json"},
|
||||
}
|
||||
}
|
||||
|
||||
func (c EndpointsConfig) enabled() []struct{ name, path string } {
|
||||
var result []struct{ name, path string }
|
||||
for _, endpoint := range []struct {
|
||||
name string
|
||||
enabled bool
|
||||
path string
|
||||
}{
|
||||
{"liveness", c.Liveness.Enabled, c.Liveness.Path},
|
||||
{"readiness", c.Readiness.Enabled, c.Readiness.Path},
|
||||
{"openapi", c.OpenAPI.Enabled, c.OpenAPI.Path},
|
||||
} {
|
||||
if endpoint.enabled {
|
||||
result = append(result, struct{ name, path string }{endpoint.name, endpoint.path})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (c EndpointsConfig) Validate() error {
|
||||
for _, mode := range []string{c.Liveness.ResponseMode, c.Readiness.ResponseMode} {
|
||||
if mode != "" && mode != "app" && mode != "raw" {
|
||||
return fmt.Errorf("endpoints: responseMode must be app or raw")
|
||||
}
|
||||
}
|
||||
paths := map[string]string{}
|
||||
for _, endpoint := range c.enabled() {
|
||||
p := endpoint.path
|
||||
if !strings.HasPrefix(p, "/") || strings.ContainsAny(p, ":*?#%\\{}") ||
|
||||
strings.IndexFunc(p, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) >= 0 {
|
||||
return fmt.Errorf("endpoints.%s.path must be an absolute literal URL path", endpoint.name)
|
||||
}
|
||||
parts := strings.Split(p[1:], "/")
|
||||
for i, part := range parts {
|
||||
if part == "." || part == ".." || (part == "" && i < len(parts)-1) {
|
||||
return fmt.Errorf("endpoints.%s.path contains an invalid segment", endpoint.name)
|
||||
}
|
||||
}
|
||||
if previous := paths[p]; previous != "" {
|
||||
return fmt.Errorf("endpoints.%s and endpoints.%s share path %s", previous, endpoint.name, p)
|
||||
}
|
||||
paths[p] = endpoint.name
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EndpointRoute describes an already validated business route. Only GET
|
||||
// patterns can overlap these built-in GET endpoints.
|
||||
type EndpointRoute struct{ Method, Path string }
|
||||
|
||||
// ValidateRoutes rejects a built-in route that would shadow a business route
|
||||
// or conflict with its catch-all. Disabled endpoints reserve no paths.
|
||||
func (c EndpointsConfig) ValidateRoutes(routes ...EndpointRoute) error {
|
||||
if err := c.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, endpoint := range c.enabled() {
|
||||
for _, route := range routes {
|
||||
if strings.EqualFold(route.Method, "GET") && endpointOverlap(route.Path, endpoint.path) {
|
||||
return fmt.Errorf("endpoints.%s path %s overlaps business route GET %s; change its path or disable it", endpoint.name, endpoint.path, route.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func endpointOverlap(pattern, path string) bool {
|
||||
a, b := strings.Split(strings.TrimPrefix(pattern, "/"), "/"), strings.Split(path[1:], "/")
|
||||
for i, part := range a {
|
||||
if i >= len(b) {
|
||||
return false
|
||||
}
|
||||
if strings.HasPrefix(part, "*") {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(part, ":") {
|
||||
if b[i] == "" {
|
||||
return false
|
||||
}
|
||||
} else if part != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(a) == len(b)
|
||||
}
|
||||
66
config/endpoints_test.go
Normal file
66
config/endpoints_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEndpointConfiguration(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(path, []byte("endpoints:\n liveness:\n enabled: false\n readiness:\n path: /readyz\n responseMode: raw\n openapi:\n enabled: false\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := DecodeFile(path, Default())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Endpoints.Liveness.Enabled || cfg.Endpoints.OpenAPI.Enabled || !cfg.Endpoints.Readiness.Enabled || cfg.Endpoints.Readiness.ResponseMode != "raw" {
|
||||
t.Fatal(cfg.Endpoints)
|
||||
}
|
||||
t.Setenv("TEST_ENDPOINTS_READINESS_PATH", "/internal/ready")
|
||||
t.Setenv("TEST_ENDPOINTS_OPENAPI_ENABLED", "true")
|
||||
cfg, err = ApplyEnv(cfg, "TEST_")
|
||||
if err != nil || cfg.Endpoints.Readiness.Path != "/internal/ready" || !cfg.Endpoints.OpenAPI.Enabled {
|
||||
t.Fatal(cfg.Endpoints, err)
|
||||
}
|
||||
t.Setenv("TEST_ENDPOINTS_OPENAPI_ENABLED", "invalid")
|
||||
if _, err = ApplyEnv(cfg, "TEST_"); err == nil {
|
||||
t.Fatal("invalid bool accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointRouteConflicts(t *testing.T) {
|
||||
for _, route := range []EndpointRoute{{"GET", "/health/ready"}, {"get", "/health/:name"}, {"GET", "/health/*rest"}, {"GET", "/*rest"}} {
|
||||
cfg := DefaultEndpoints()
|
||||
if err := cfg.ValidateRoutes(route); err == nil {
|
||||
t.Fatal("overlap accepted", route)
|
||||
}
|
||||
cfg.Readiness.Enabled = false
|
||||
cfg.Liveness.Enabled = false
|
||||
cfg.OpenAPI.Enabled = false
|
||||
if err := cfg.ValidateRoutes(route); err != nil {
|
||||
t.Fatal("disabled endpoint reserves path", err)
|
||||
}
|
||||
}
|
||||
if err := DefaultEndpoints().ValidateRoutes(EndpointRoute{"POST", "/health/ready"}, EndpointRoute{"GET", "/other/*rest"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{"", "ready", "//ready", "/a/../ready", "/:ready", "/ready?x=1", "/a%2fb", "/ready\n"} {
|
||||
cfg := DefaultEndpoints()
|
||||
cfg.Readiness.Path = path
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("invalid path", path)
|
||||
}
|
||||
}
|
||||
cfg := DefaultEndpoints()
|
||||
cfg.Readiness.Path = cfg.Liveness.Path
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("duplicate endpoints accepted")
|
||||
}
|
||||
cfg = DefaultEndpoints()
|
||||
cfg.Readiness.ResponseMode = "unknown"
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("invalid mode accepted")
|
||||
}
|
||||
}
|
||||
148
config/env.go
148
config/env.go
@@ -1,115 +1,83 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultEnvPrefix = "GO_OMNI_"
|
||||
|
||||
// LoadFromEnv loads application settings from GO_OMNI_* variables. The
|
||||
// database settings remain owned by database.ConfigFromEnv.
|
||||
func LoadFromEnv(prefix string) (Config, error) {
|
||||
return applyEnv(Default(), prefix)
|
||||
// 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
|
||||
}
|
||||
|
||||
// ApplyEnv overlays nonempty environment values on a caller's defaults or file
|
||||
// settings, then validates the result. Caller-owned slices are not retained.
|
||||
// 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) {
|
||||
defaults.Errors = defaults.Errors.Clone()
|
||||
defaults.TrustedProxies = append([]string(nil), defaults.TrustedProxies...)
|
||||
defaults.Log.RedactKeys = append([]string(nil), defaults.Log.RedactKeys...)
|
||||
return applyEnv(defaults, prefix)
|
||||
}
|
||||
|
||||
func applyEnv(cfg Config, prefix string) (Config, error) {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if prefix == "" {
|
||||
prefix = defaultEnvPrefix
|
||||
}
|
||||
cfg.Address = envString(prefix+"HTTP_ADDR", cfg.Address)
|
||||
cfg.Transport = envString(prefix+"TRANSPORT", cfg.Transport)
|
||||
cfg.TLS.CertFile = envString(prefix+"TLS_CERT_FILE", cfg.TLS.CertFile)
|
||||
cfg.TLS.KeyFile = envString(prefix+"TLS_KEY_FILE", cfg.TLS.KeyFile)
|
||||
cfg.RequestIDHeader = envString(prefix+"REQUEST_ID_HEADER", cfg.RequestIDHeader)
|
||||
if value := strings.TrimSpace(os.Getenv(prefix + "TRUSTED_PROXIES")); value != "" {
|
||||
cfg.TrustedProxies = strings.Split(value, ",")
|
||||
for i := range cfg.TrustedProxies {
|
||||
cfg.TrustedProxies[i] = strings.TrimSpace(cfg.TrustedProxies[i])
|
||||
}
|
||||
}
|
||||
cfg.Log.Level = envString(prefix+"LOG_LEVEL", cfg.Log.Level)
|
||||
cfg.Log.Format = envString(prefix+"LOG_FORMAT", cfg.Log.Format)
|
||||
cfg.Log.Output = envString(prefix+"LOG_OUTPUT", cfg.Log.Output)
|
||||
cfg.Log.File = envString(prefix+"LOG_FILE", cfg.Log.File)
|
||||
cfg.Log.Service = envString(prefix+"SERVICE", cfg.Log.Service)
|
||||
cfg.Log.Environment = envString(prefix+"ENVIRONMENT", cfg.Log.Environment)
|
||||
|
||||
var err error
|
||||
for _, field := range []struct {
|
||||
name string
|
||||
value *time.Duration
|
||||
}{
|
||||
{"HTTP_READ_HEADER_TIMEOUT", &cfg.HTTP.ReadHeaderTimeout},
|
||||
{"HTTP_READ_TIMEOUT", &cfg.HTTP.ReadTimeout},
|
||||
{"HTTP_WRITE_TIMEOUT", &cfg.HTTP.WriteTimeout},
|
||||
{"HTTP_IDLE_TIMEOUT", &cfg.HTTP.IdleTimeout},
|
||||
} {
|
||||
if value := strings.TrimSpace(os.Getenv(prefix + field.name)); value != "" {
|
||||
*field.value, err = time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("%s%s must be a duration: %w", prefix, field.name, err)
|
||||
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
|
||||
}
|
||||
if value := strings.TrimSpace(os.Getenv(prefix + "HTTP_MAX_HEADER_BYTES")); value != "" {
|
||||
cfg.HTTP.MaxHeaderBytes, err = strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("%sHTTP_MAX_HEADER_BYTES must be an integer: %w", prefix, err)
|
||||
}
|
||||
}
|
||||
if cfg.EnableLogger, err = envBool(prefix+"ENABLE_LOGGER", cfg.EnableLogger); err != nil {
|
||||
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 cfg.Log.AddSource, err = envBool(prefix+"LOG_ADD_SOURCE", cfg.Log.AddSource); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if value := strings.TrimSpace(os.Getenv(prefix + "SLOW_REQUEST_THRESHOLD")); value != "" {
|
||||
cfg.SlowRequestThreshold, err = time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("%sSLOW_REQUEST_THRESHOLD must be a duration: %w", prefix, err)
|
||||
}
|
||||
}
|
||||
if value := strings.TrimSpace(os.Getenv(prefix + "SHUTDOWN_TIMEOUT")); value != "" {
|
||||
cfg.ShutdownTimeout, err = time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("%sSHUTDOWN_TIMEOUT must be a duration: %w", prefix, err)
|
||||
}
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
if err = cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func envString(key, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envBool(key string, fallback bool) (bool, error) {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("%s must be a boolean: %w", key, err)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
@@ -22,6 +22,23 @@ import (
|
||||
// field names for both formats, rejects unknown fields and accepts durations
|
||||
// such as "3s". Application-specific validation remains with the caller.
|
||||
func DecodeFile[T any](path string, defaults T) (T, error) {
|
||||
result, err := decodeFile(path, defaults)
|
||||
if err != nil {
|
||||
var zero T
|
||||
return zero, &DecodeError{cause: err}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// DecodeError keeps input values out of public diagnostics while preserving
|
||||
// errors.Is/As for explicitly handled underlying I/O and syntax errors.
|
||||
type DecodeError struct{ cause error }
|
||||
|
||||
func (e *DecodeError) Error() string {
|
||||
return "config: cannot decode file; check its path, format, field names and value types"
|
||||
}
|
||||
func (e *DecodeError) Unwrap() error { return e.cause }
|
||||
func decodeFile[T any](path string, defaults T) (T, error) {
|
||||
var zero T
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
@@ -64,15 +81,13 @@ func DecodeFile[T any](path string, defaults T) (T, error) {
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
// Deep copy defaults so errors cannot partially mutate caller-owned maps.
|
||||
original, err := json.Marshal(defaults)
|
||||
// Clone configuration without serializing borrowed json:"-" capabilities.
|
||||
var result T
|
||||
original, err := cloneConfiguration(reflect.ValueOf(&defaults).Elem(), 0)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
var result T
|
||||
if err = json.Unmarshal(original, &result); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
reflect.ValueOf(&result).Elem().Set(original)
|
||||
dec := json.NewDecoder(bytes.NewReader(encoded))
|
||||
dec.DisallowUnknownFields()
|
||||
if err = dec.Decode(&result); err != nil {
|
||||
@@ -115,6 +130,17 @@ func durations(v any, t reflect.Type) (any, error) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if t.Kind() == reflect.Map && t.Key().Kind() == reflect.String {
|
||||
if values, ok := v.(map[string]any); ok {
|
||||
for key, value := range values {
|
||||
converted, err := durations(value, t.Elem())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
values[key] = converted
|
||||
}
|
||||
}
|
||||
}
|
||||
if t.Kind() == reflect.Slice {
|
||||
if items, ok := v.([]any); ok {
|
||||
for n, item := range items {
|
||||
@@ -133,7 +159,7 @@ func LoadFile(path, prefix string) (Config, error) {
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return applyEnv(cfg, prefix)
|
||||
return ApplyEnv(cfg, prefix)
|
||||
}
|
||||
|
||||
// Reloader only accepts log.level changes. Static changes and invalid files
|
||||
|
||||
37
config/lifecycle_test.go
Normal file
37
config/lifecycle_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/lifecycle"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLifecycleConfiguration(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(path, []byte("lifecycle:\n stopTimeout: 2s\n drainTimeout: 3s\n cancelTimeout: 4s\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.LoadFile(path, "PHASE_TEST_")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Lifecycle != (lifecycle.ShutdownConfig{StopTimeout: 2 * time.Second, DrainTimeout: 3 * time.Second, CancelTimeout: 4 * time.Second}) {
|
||||
t.Fatal(cfg.Lifecycle)
|
||||
}
|
||||
for _, key := range []string{"STOP_TIMEOUT", "DRAIN_TIMEOUT", "CANCEL_TIMEOUT"} {
|
||||
t.Setenv("PHASE_TEST_LIFECYCLE_"+key, "6s")
|
||||
}
|
||||
cfg, err = config.ApplyEnv(cfg, "PHASE_TEST_")
|
||||
if err != nil || cfg.Lifecycle.StopTimeout != 6*time.Second || cfg.Lifecycle.DrainTimeout != 6*time.Second || cfg.Lifecycle.CancelTimeout != 6*time.Second {
|
||||
t.Fatal(cfg.Lifecycle, err)
|
||||
}
|
||||
for _, v := range []string{"bad", "-1s"} {
|
||||
t.Setenv("PHASE_TEST_LIFECYCLE_DRAIN_TIMEOUT", v)
|
||||
if _, err := config.ApplyEnv(cfg, "PHASE_TEST_"); err == nil {
|
||||
t.Fatal("invalid duration accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
302
config/project.go
Normal file
302
config/project.go
Normal file
@@ -0,0 +1,302 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"math"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// EnvOptions controls typed project configuration overlays. Explicit env tags
|
||||
// are absolute names; otherwise JSON names become PREFIX_NESTED_FIELD names.
|
||||
// Empty is allow (default), ignore or reject. No input values appear in errors.
|
||||
type EnvOptions struct {
|
||||
Prefix string
|
||||
Empty string
|
||||
Lookup func(string) (string, bool)
|
||||
// Aliases maps canonical absolute environment names to ordered fallback names.
|
||||
// A present canonical value wins, including an explicit empty value.
|
||||
Aliases map[string][]string
|
||||
}
|
||||
|
||||
// ApplyDefaults applies envDefault tags to zero fields before file overlays.
|
||||
func ApplyDefaults[T any](base T) (T, error) { return projectOverlay(base, EnvOptions{}, true) }
|
||||
|
||||
// LoadProject applies code/tag defaults, a strict optional file, environment,
|
||||
// then the project's Validate method, before any logger or dependency is opened.
|
||||
// envDefault is only applied to zero-valued defaults before reading the file.
|
||||
func LoadProject[T any](path string, defaults T, options EnvOptions) (T, error) {
|
||||
var zero T
|
||||
cfg, err := projectOverlay(defaults, options, true)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
if path != "" {
|
||||
cfg, err = DecodeFile(path, cfg)
|
||||
if err != nil {
|
||||
return zero, errors.New("config: cannot decode file; check its path, format, field names and value types")
|
||||
}
|
||||
}
|
||||
cfg, err = OverlayEnv(cfg, options)
|
||||
if err != nil {
|
||||
return zero, err
|
||||
}
|
||||
if valid, ok := any(&cfg).(interface{ Validate() error }); ok {
|
||||
if err = valid.Validate(); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// OverlayEnv overlays the entire project struct without mutating the input.
|
||||
// Tags: env:"CUSTOM_NAME,required,secret,empty=reject"; env:"-" excludes a
|
||||
// subtree. required means nonempty string/non-nil pointer or collection; false
|
||||
// and zero are valid scalars. Use project Validate for semantic constraints.
|
||||
// Slices/maps use JSON syntax, avoiding ambiguous comma and empty semantics.
|
||||
func OverlayEnv[T any](base T, options EnvOptions) (T, error) {
|
||||
return projectOverlay(base, options, false)
|
||||
}
|
||||
|
||||
var textType = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem()
|
||||
var durationType = reflect.TypeOf(time.Duration(0))
|
||||
|
||||
func projectOverlay[T any](base T, options EnvOptions, defaults bool) (T, error) {
|
||||
var zero T
|
||||
if options.Lookup == nil {
|
||||
options.Lookup = os.LookupEnv
|
||||
}
|
||||
if options.Empty == "" {
|
||||
options.Empty = "allow"
|
||||
}
|
||||
if options.Empty != "allow" && options.Empty != "ignore" && options.Empty != "reject" {
|
||||
return zero, errors.New("config: empty policy must be allow, ignore or reject")
|
||||
}
|
||||
v := reflect.ValueOf(&base).Elem()
|
||||
if v.Kind() != reflect.Struct {
|
||||
return zero, errors.New("config: project configuration must be a struct")
|
||||
}
|
||||
result := reflect.New(v.Type()).Elem()
|
||||
result.Set(v)
|
||||
seen := map[string]bool{}
|
||||
var walk func(reflect.Value, string, int) error
|
||||
walk = func(value reflect.Value, prefix string, depth int) error {
|
||||
if depth > 32 {
|
||||
return errors.New("config: nesting exceeds 32")
|
||||
}
|
||||
for i := 0; i < value.NumField(); i++ {
|
||||
field := value.Type().Field(i)
|
||||
dst := value.Field(i)
|
||||
if !dst.CanSet() || field.PkgPath != "" {
|
||||
continue
|
||||
}
|
||||
tag := strings.Split(field.Tag.Get("env"), ",")
|
||||
if tag[0] == "-" {
|
||||
continue
|
||||
}
|
||||
jsonName := strings.Split(field.Tag.Get("json"), ",")[0]
|
||||
if jsonName == "-" && tag[0] == "" {
|
||||
continue
|
||||
}
|
||||
if jsonName == "" {
|
||||
jsonName = field.Name
|
||||
}
|
||||
key := prefix + envName(jsonName)
|
||||
if tag[0] != "" {
|
||||
key = tag[0]
|
||||
}
|
||||
empty, required := options.Empty, false
|
||||
for _, opt := range tag[1:] {
|
||||
switch opt {
|
||||
case "required":
|
||||
required = true
|
||||
case "secret", "":
|
||||
case "empty=allow":
|
||||
empty = "allow"
|
||||
case "empty=ignore":
|
||||
empty = "ignore"
|
||||
case "empty=reject":
|
||||
empty = "reject"
|
||||
default:
|
||||
return fmt.Errorf("config: invalid env tag for %s", key)
|
||||
}
|
||||
}
|
||||
if dst.Kind() == reflect.Struct && !dst.Addr().Type().Implements(textType) {
|
||||
if err := walk(dst, key+"_", depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if dst.Kind() == reflect.Pointer && dst.Type().Elem().Kind() == reflect.Struct && !dst.Type().Implements(textType) {
|
||||
// Clone before traversing so a failed overlay never edits defaults.
|
||||
child := reflect.New(dst.Type().Elem())
|
||||
if !dst.IsNil() {
|
||||
child.Elem().Set(dst.Elem())
|
||||
}
|
||||
if err := walk(child.Elem(), key+"_", depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
if !dst.IsNil() || !child.Elem().IsZero() {
|
||||
dst.Set(child)
|
||||
}
|
||||
if required && !defaults && dst.IsNil() {
|
||||
return fmt.Errorf("config: %s is required", key)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Unexported runtime capabilities are skipped unless explicitly tagged.
|
||||
if dst.Kind() == reflect.Func || dst.Kind() == reflect.Chan || dst.Kind() == reflect.Interface {
|
||||
if tag[0] != "" {
|
||||
return fmt.Errorf("config: %s has unsupported environment type", key)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if seen[key] {
|
||||
return fmt.Errorf("config: duplicate environment key %s", key)
|
||||
}
|
||||
seen[key] = true
|
||||
raw, present := options.Lookup(key)
|
||||
selectedKey := key
|
||||
if !present && !defaults {
|
||||
for _, alias := range options.Aliases[key] {
|
||||
if raw, present = options.Lookup(alias); present {
|
||||
selectedKey = alias
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if defaults {
|
||||
raw, present = field.Tag.Lookup("envDefault")
|
||||
present = present && dst.IsZero()
|
||||
}
|
||||
if present && raw == "" && !defaults {
|
||||
if empty == "reject" {
|
||||
return fmt.Errorf("config: %s must not be empty", selectedKey)
|
||||
}
|
||||
if empty == "ignore" {
|
||||
present = false
|
||||
}
|
||||
}
|
||||
if present {
|
||||
parsed := reflect.New(dst.Type()).Elem()
|
||||
if err := parseEnvValue(parsed, raw); err != nil {
|
||||
return fmt.Errorf("config: %s must be a valid %s", selectedKey, dst.Type())
|
||||
}
|
||||
dst.Set(parsed)
|
||||
}
|
||||
if required && !defaults && emptyRequired(dst) {
|
||||
return fmt.Errorf("config: %s is required", key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := walk(result, options.Prefix, 0); err != nil {
|
||||
return zero, err
|
||||
}
|
||||
return result.Interface().(T), nil
|
||||
}
|
||||
|
||||
func emptyRequired(v reflect.Value) bool {
|
||||
switch v.Kind() {
|
||||
case reflect.String:
|
||||
return v.Len() == 0
|
||||
case reflect.Pointer, reflect.Map, reflect.Slice:
|
||||
return v.IsNil()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseEnvValue(v reflect.Value, raw string) (err error) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
err = errors.New("invalid custom value")
|
||||
}
|
||||
}()
|
||||
if v.Kind() == reflect.Pointer {
|
||||
v.Set(reflect.New(v.Type().Elem()))
|
||||
return parseEnvValue(v.Elem(), raw)
|
||||
}
|
||||
if v.CanAddr() && v.Addr().Type().Implements(textType) {
|
||||
return v.Addr().Interface().(encoding.TextUnmarshaler).UnmarshalText([]byte(raw))
|
||||
}
|
||||
if v.Type() == durationType {
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err == nil {
|
||||
v.SetInt(int64(d))
|
||||
}
|
||||
return err
|
||||
}
|
||||
switch v.Kind() {
|
||||
case reflect.String:
|
||||
v.SetString(raw)
|
||||
return nil
|
||||
case reflect.Bool:
|
||||
x, err := strconv.ParseBool(raw)
|
||||
if err == nil {
|
||||
v.SetBool(x)
|
||||
}
|
||||
return err
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
x, err := strconv.ParseInt(raw, 10, v.Type().Bits())
|
||||
if err == nil {
|
||||
v.SetInt(x)
|
||||
}
|
||||
return err
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
x, err := strconv.ParseUint(raw, 10, v.Type().Bits())
|
||||
if err == nil {
|
||||
v.SetUint(x)
|
||||
}
|
||||
return err
|
||||
case reflect.Float32, reflect.Float64:
|
||||
x, err := strconv.ParseFloat(raw, v.Type().Bits())
|
||||
if err == nil && (math.IsNaN(x) || math.IsInf(x, 0)) {
|
||||
return errors.New("nonfinite value")
|
||||
}
|
||||
if err == nil {
|
||||
v.SetFloat(x)
|
||||
}
|
||||
return err
|
||||
case reflect.Slice, reflect.Map:
|
||||
var value any
|
||||
if err := apierrors.DecodeCatalogJSON([]byte(raw), &value); err != nil {
|
||||
return err
|
||||
}
|
||||
value, err := durations(value, v.Type())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(encoded))
|
||||
decoder.DisallowUnknownFields()
|
||||
return decoder.Decode(v.Addr().Interface())
|
||||
}
|
||||
return errors.New("unsupported environment value")
|
||||
}
|
||||
|
||||
func envName(s string) string {
|
||||
r := []rune(s)
|
||||
var out strings.Builder
|
||||
for i, c := range r {
|
||||
if i > 0 && unicode.IsUpper(c) && (unicode.IsLower(r[i-1]) || unicode.IsDigit(r[i-1]) || (i+1 < len(r) && unicode.IsLower(r[i+1]))) {
|
||||
out.WriteByte('_')
|
||||
}
|
||||
if c == '-' {
|
||||
c = '_'
|
||||
}
|
||||
out.WriteRune(unicode.ToUpper(c))
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
61
config/projectAdvanced_test.go
Normal file
61
config/projectAdvanced_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestConfigFilePreservesDefaultsWithoutSharingMutableData(t *testing.T) {
|
||||
type cfg struct {
|
||||
Writer io.Writer `json:"-"`
|
||||
Secret string `json:"-"`
|
||||
Values map[string]int64 `json:"values"`
|
||||
Timers map[string]time.Duration `json:"timers"`
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
var writer bytes.Buffer
|
||||
base := cfg{Writer: &writer, Secret: "borrowedSecret", Values: map[string]int64{"old": 7}, ID: 9007199254740993}
|
||||
file := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(file, []byte(`{"values":{"new":2},"timers":{"retry":"2s"}}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
next, err := DecodeFile(file, base)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if next.Writer != &writer || next.Secret != base.Secret || next.ID != base.ID || next.Timers["retry"] != 2*time.Second || len(base.Values) != 1 {
|
||||
t.Fatal("defaults lost or mutated")
|
||||
}
|
||||
next.Values["old"] = 9
|
||||
if base.Values["old"] != 7 {
|
||||
t.Fatal("defaults shared map")
|
||||
}
|
||||
if err = os.WriteFile(file, []byte(`{"values":{"secretValue":"credential-super-secret"}}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = DecodeFile(file, base); err == nil || strings.Contains(err.Error(), "credential-super-secret") {
|
||||
t.Fatal("decode error leaked contents", err)
|
||||
}
|
||||
}
|
||||
func TestEnvCollectionPrecisionAndDurationMaps(t *testing.T) {
|
||||
type cfg struct {
|
||||
IDs []int64 `json:"ids"`
|
||||
Timers map[string]time.Duration `json:"timers"`
|
||||
}
|
||||
lookup := func(key string) (string, bool) {
|
||||
v, ok := map[string]string{"IDS": "[9007199254740993]", "TIMERS": `{"backoff":"250ms"}`}[key]
|
||||
return v, ok
|
||||
}
|
||||
c, err := OverlayEnv(cfg{}, EnvOptions{Lookup: lookup})
|
||||
if err != nil || c.IDs[0] != 9007199254740993 || c.Timers["backoff"] != 250*time.Millisecond {
|
||||
t.Fatal(c, err)
|
||||
}
|
||||
if _, err = OverlayEnv(cfg{}, EnvOptions{Lookup: func(key string) (string, bool) { return `{"x":"1s","x":"2s"}`, key == "TIMERS" }}); err == nil {
|
||||
t.Fatal("duplicate map key accepted")
|
||||
}
|
||||
}
|
||||
124
config/project_test.go
Normal file
124
config/project_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type projectNested struct {
|
||||
ReadTimeout time.Duration `json:"readTimeout"`
|
||||
Names []string `json:"names"`
|
||||
}
|
||||
type projectConfig struct {
|
||||
Nested *projectNested `json:"nested"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Count int `json:"count" envDefault:"12"`
|
||||
Password string `json:"-" env:"ETAX_PASSWORD,secret,required,empty=reject"`
|
||||
Label string `json:"label" env:",empty=ignore"`
|
||||
}
|
||||
|
||||
func (c projectConfig) Validate() error {
|
||||
if c.Count < 0 {
|
||||
return errors.New("count must be nonnegative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func TestProjectConfigurationOrderAndValues(t *testing.T) {
|
||||
f := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(f, []byte("count: 3\nenabled: true\nnested:\n readTimeout: 2s\n names: [file]\nlabel: keep\n"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := map[string]string{"ETAX_PASSWORD": " password ", "APP_ENABLED": "false", "APP_COUNT": "0", "APP_NESTED_READ_TIMEOUT": "7s", "APP_NESTED_NAMES": "[]", "APP_LABEL": ""}
|
||||
options := EnvOptions{Prefix: "APP_", Lookup: func(k string) (string, bool) { v, ok := env[k]; return v, ok }}
|
||||
cfg, err := LoadProject(f, projectConfig{}, options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Enabled || cfg.Count != 0 || cfg.Password != " password " || cfg.Nested.ReadTimeout != 7*time.Second || cfg.Nested.Names == nil || len(cfg.Nested.Names) != 0 || cfg.Label != "keep" {
|
||||
t.Fatal("overlay mismatch")
|
||||
}
|
||||
delete(env, "APP_COUNT")
|
||||
cfg, err = LoadProject("", projectConfig{}, options)
|
||||
if err != nil || cfg.Count != 12 {
|
||||
t.Fatal(cfg.Count, err)
|
||||
}
|
||||
env["APP_COUNT"] = "-1"
|
||||
if _, err = LoadProject(f, projectConfig{}, options); err == nil {
|
||||
t.Fatal("project Validate not called")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectOverlayIsTransactionalAndRedacted(t *testing.T) {
|
||||
base := projectConfig{Nested: &projectNested{ReadTimeout: time.Second, Names: []string{"old"}}, Password: "original"}
|
||||
env := map[string]string{"APP_NESTED_READ_TIMEOUT": "2s", "APP_NESTED_NAMES": "[\"new\"]", "APP_COUNT": "secret-value"}
|
||||
_, err := OverlayEnv(base, EnvOptions{Prefix: "APP_", Lookup: func(k string) (string, bool) { v, ok := env[k]; return v, ok }})
|
||||
if err == nil || strings.Contains(err.Error(), "secret-value") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if base.Nested.ReadTimeout != time.Second || !reflect.DeepEqual(base.Nested.Names, []string{"old"}) {
|
||||
t.Fatal("default pointer modified")
|
||||
}
|
||||
delete(env, "APP_COUNT")
|
||||
env["ETAX_PASSWORD"] = ""
|
||||
if _, err = OverlayEnv(base, EnvOptions{Prefix: "APP_", Lookup: func(k string) (string, bool) { v, ok := env[k]; return v, ok }}); err == nil {
|
||||
t.Fatal("empty secret accepted")
|
||||
}
|
||||
type required struct {
|
||||
Token string `env:"TOKEN,required"`
|
||||
}
|
||||
if _, err = OverlayEnv(required{}, EnvOptions{Lookup: func(string) (string, bool) { return "", false }}); err == nil {
|
||||
t.Fatal("required absent")
|
||||
}
|
||||
type duplicate struct {
|
||||
A string `env:"SAME"`
|
||||
B string `env:"SAME"`
|
||||
}
|
||||
if _, err = OverlayEnv(duplicate{}, EnvOptions{}); err == nil {
|
||||
t.Fatal("duplicate env binding accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentCollectionAndEmptySemantics(t *testing.T) {
|
||||
type sample struct {
|
||||
Values []time.Duration
|
||||
Flag bool
|
||||
Text string
|
||||
Optional *int
|
||||
}
|
||||
cases := []struct {
|
||||
raw string
|
||||
valid bool
|
||||
}{{`["1s","2s"]`, true}, {`["invalid-secret"]`, false}}
|
||||
for _, tc := range cases {
|
||||
cfg, err := OverlayEnv(sample{}, EnvOptions{Lookup: func(k string) (string, bool) {
|
||||
if k == "VALUES" {
|
||||
return tc.raw, true
|
||||
}
|
||||
return "", false
|
||||
}})
|
||||
if (err == nil) != tc.valid {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.valid && !reflect.DeepEqual(cfg.Values, []time.Duration{time.Second, 2 * time.Second}) {
|
||||
t.Fatal(cfg)
|
||||
}
|
||||
if err != nil && strings.Contains(err.Error(), "secret") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, mode := range []string{"allow", "ignore", "reject"} {
|
||||
cfg, err := OverlayEnv(sample{Text: "old"}, EnvOptions{Empty: mode, Lookup: func(k string) (string, bool) { return "", k == "TEXT" }})
|
||||
if mode == "reject" {
|
||||
if err == nil {
|
||||
t.Fatal("empty not rejected")
|
||||
}
|
||||
} else if err != nil || (mode == "allow" && cfg.Text != "") || (mode == "ignore" && cfg.Text != "old") {
|
||||
t.Fatal(cfg, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
172
contract/bind.go
172
contract/bind.go
@@ -1,6 +1,7 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
)
|
||||
@@ -19,9 +21,17 @@ import (
|
||||
// strictly decoded, and limited to 1 MiB unless maxBytes is supplied. Query
|
||||
// parameters never overwrite path/body/header values. form means URL query only.
|
||||
func (b *Binder) Bind(r *http.Request, params map[string]string, target any, maxBytes ...int64) error {
|
||||
return b.BindWithPolicy(r, params, target, RequestPolicy{}, maxBytes...)
|
||||
}
|
||||
|
||||
// BindWithPolicy merges route overrides over the App request policy.
|
||||
func (b *Binder) BindWithPolicy(r *http.Request, params map[string]string, target any, policy RequestPolicy, maxBytes ...int64) error {
|
||||
if b == nil || r == nil || b.schemaOnly {
|
||||
return apierrors.Internal(errors.New("contract: nil binder or request"))
|
||||
}
|
||||
if b.HasMultipart() {
|
||||
return apierrors.Internal(errors.New("multipart fields require BindMultipart"))
|
||||
}
|
||||
v := reflect.ValueOf(target)
|
||||
if v.Kind() != reflect.Pointer || v.IsNil() || v.Elem().Type() != b.typ {
|
||||
return apierrors.Internal(errors.New("contract: target type mismatch"))
|
||||
@@ -33,54 +43,9 @@ func (b *Binder) Bind(r *http.Request, params map[string]string, target any, max
|
||||
if len(maxBytes) == 1 {
|
||||
limit = maxBytes[0]
|
||||
}
|
||||
fresh := reflect.New(b.typ).Elem()
|
||||
body := map[string]any{}
|
||||
if len(b.root.schema.Properties) > 0 {
|
||||
ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil || ct != "application/json" {
|
||||
return apierrors.New(415, "UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json")
|
||||
}
|
||||
if r.Body == nil {
|
||||
return invalid(errors.New("body required"))
|
||||
}
|
||||
if r.ContentLength > limit {
|
||||
return tooLarge()
|
||||
}
|
||||
reader := &io.LimitedReader{R: r.Body, N: limit + 1}
|
||||
dec := json.NewDecoder(reader)
|
||||
dec.UseNumber()
|
||||
value, err := decodeValue(dec, 0)
|
||||
if reader.N == 0 {
|
||||
return tooLarge()
|
||||
}
|
||||
if err != nil {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
return tooLarge()
|
||||
}
|
||||
return invalid(err)
|
||||
}
|
||||
_, err = dec.Token()
|
||||
if reader.N == 0 {
|
||||
return tooLarge()
|
||||
}
|
||||
if err != io.EOF {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
return tooLarge()
|
||||
}
|
||||
return invalid(errors.New("exactly one JSON value required"))
|
||||
}
|
||||
var ok bool
|
||||
body, ok = value.(map[string]any)
|
||||
if !ok {
|
||||
return invalid(errors.New("body must be an object"))
|
||||
}
|
||||
for key := range body {
|
||||
if _, ok = b.root.schema.Properties[key]; !ok {
|
||||
return invalid(fmt.Errorf("unknown body field %s", key))
|
||||
}
|
||||
}
|
||||
policy = MergePolicy(RequestPolicyFromContext(r.Context()), policy)
|
||||
if err := policy.Validate(); err != nil {
|
||||
return apierrors.Internal(err)
|
||||
}
|
||||
query := url.Values{}
|
||||
if r.URL != nil {
|
||||
@@ -90,10 +55,121 @@ func (b *Binder) Bind(r *http.Request, params map[string]string, target any, max
|
||||
return invalid(errors.New("malformed query"))
|
||||
}
|
||||
}
|
||||
if policy.UnknownQuery == "reject" {
|
||||
allowed := map[string]bool{}
|
||||
for _, f := range b.root.fields {
|
||||
if f.source == "form" {
|
||||
allowed[f.name] = true
|
||||
}
|
||||
}
|
||||
for key := range query {
|
||||
if !allowed[key] {
|
||||
return invalid(fmt.Errorf("unknown query field %s", key))
|
||||
}
|
||||
}
|
||||
}
|
||||
fresh := reflect.New(b.typ).Elem()
|
||||
body := map[string]any{}
|
||||
bodyAbsent := false
|
||||
bodyMode := policy.Body
|
||||
if bodyMode == "" || bodyMode == "auto" {
|
||||
if len(b.root.schema.Properties) > 0 {
|
||||
bodyMode = "required"
|
||||
} else {
|
||||
bodyMode = "auto"
|
||||
}
|
||||
}
|
||||
if (bodyMode == "none" || bodyMode == "emptyObject") && len(b.root.schema.Properties) > 0 {
|
||||
return apierrors.Internal(errors.New("request body policy conflicts with JSON fields"))
|
||||
}
|
||||
if bodyMode == "none" {
|
||||
if r.Body != nil {
|
||||
var one [1]byte
|
||||
n, err := io.ReadFull(r.Body, one[:])
|
||||
if n > 0 {
|
||||
return invalid(errors.New("request body is forbidden"))
|
||||
}
|
||||
if err != io.EOF {
|
||||
return invalid(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if bodyMode == "required" || bodyMode == "optional" || bodyMode == "emptyObject" {
|
||||
var bodyReader io.Reader = r.Body
|
||||
if bodyReader == nil {
|
||||
bodyReader = strings.NewReader("")
|
||||
}
|
||||
if r.ContentLength > limit {
|
||||
return tooLarge()
|
||||
}
|
||||
reader := &io.LimitedReader{R: bodyReader, N: limit + 1}
|
||||
var checked io.Reader = reader
|
||||
if policy.UTF8 == "reject" {
|
||||
checked = &utf8Reader{r: checked}
|
||||
}
|
||||
buffered := bufio.NewReader(checked)
|
||||
_, peekErr := buffered.Peek(1)
|
||||
absent := peekErr == io.EOF
|
||||
bodyAbsent = absent
|
||||
if absent && bodyMode != "optional" {
|
||||
return invalid(errors.New("body required"))
|
||||
}
|
||||
if peekErr != nil && !absent {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(peekErr, &maxErr) {
|
||||
return tooLarge()
|
||||
}
|
||||
return invalid(peekErr)
|
||||
}
|
||||
if !absent {
|
||||
ct, mediaParams, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
if err != nil || ct != "application/json" || (policy.Charset == "utf8" && mediaParams["charset"] != "" && !strings.EqualFold(mediaParams["charset"], "utf-8")) {
|
||||
return apierrors.New(415, "UNSUPPORTED_MEDIA_TYPE", "Content-Type must be application/json with an allowed charset")
|
||||
}
|
||||
dec := json.NewDecoder(buffered)
|
||||
dec.UseNumber()
|
||||
value, err := decodeValue(dec, 0)
|
||||
if reader.N == 0 {
|
||||
return tooLarge()
|
||||
}
|
||||
if err != nil {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
return tooLarge()
|
||||
}
|
||||
return invalid(err)
|
||||
}
|
||||
_, err = dec.Token()
|
||||
if reader.N == 0 {
|
||||
return tooLarge()
|
||||
}
|
||||
if err != io.EOF {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
return tooLarge()
|
||||
}
|
||||
return invalid(errors.New("exactly one JSON value required"))
|
||||
}
|
||||
var ok bool
|
||||
body, ok = value.(map[string]any)
|
||||
if !ok {
|
||||
return invalid(errors.New("body must be an object"))
|
||||
}
|
||||
for key := range body {
|
||||
if _, ok = b.root.schema.Properties[key]; !ok {
|
||||
return invalid(fmt.Errorf("unknown body field %s", key))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, f := range b.root.fields {
|
||||
var value any
|
||||
var present bool
|
||||
if f.source == "json" {
|
||||
if bodyAbsent && bodyMode == "optional" {
|
||||
continue
|
||||
}
|
||||
value, present = body[f.name]
|
||||
} else {
|
||||
var raw []string
|
||||
|
||||
@@ -36,6 +36,8 @@ type node struct {
|
||||
}
|
||||
type field struct {
|
||||
index int
|
||||
maxBytes int64
|
||||
maxFiles int
|
||||
name, source string
|
||||
value *node
|
||||
optional bool
|
||||
@@ -113,6 +115,13 @@ func (c compiler) compile(t reflect.Type, root bool, stack map[reflect.Type]bool
|
||||
if t.Kind() != reflect.Pointer && (t.Implements(marshaler) || reflect.PointerTo(t).Implements(marshaler) || t.Implements(unmarshaler) || reflect.PointerTo(t).Implements(unmarshaler)) {
|
||||
return nil, errors.New("contract: custom JSON marshalers require explicit Scalar registration")
|
||||
}
|
||||
if t == uploadType {
|
||||
if root {
|
||||
return nil, errors.New("upload File cannot be a root DTO")
|
||||
}
|
||||
n.schema = &openapi.Schema{Type: "string", Format: "binary"}
|
||||
return n, nil
|
||||
}
|
||||
switch t.Kind() {
|
||||
case reflect.Interface:
|
||||
if t.NumMethod() != 0 {
|
||||
@@ -223,7 +232,7 @@ func (c compiler) compileField(sf reflect.StructField, root bool, stack map[refl
|
||||
var sourceTag string
|
||||
for key, value := range tags {
|
||||
switch key {
|
||||
case "json", "path", "form", "header":
|
||||
case "json", "path", "form", "header", "file", "multipart", "multipartJSON":
|
||||
if value == "-" {
|
||||
return f, errors.New("ignored fields are not part of an API contract")
|
||||
}
|
||||
@@ -232,6 +241,8 @@ func (c compiler) compileField(sf reflect.StructField, root bool, stack map[refl
|
||||
}
|
||||
f.source = key
|
||||
sourceTag = value
|
||||
case "description", "example", "enumDescriptions":
|
||||
// Documentation is validated after the field constraints.
|
||||
case "binding":
|
||||
f.binding = value
|
||||
default:
|
||||
@@ -254,6 +265,45 @@ func (c compiler) compileField(sf reflect.StructField, root bool, stack map[refl
|
||||
return f, err
|
||||
}
|
||||
f.value = n
|
||||
if f.source == "file" {
|
||||
if sf.Type != uploadType && (sf.Type.Kind() != reflect.Slice || sf.Type.Elem() != uploadType) {
|
||||
return f, errors.New("file field requires File or []File")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, option := range parts[1:] {
|
||||
key, v, _ := strings.Cut(option, "=")
|
||||
if seen[key] {
|
||||
return f, errors.New("duplicate file option")
|
||||
}
|
||||
seen[key] = true
|
||||
switch key {
|
||||
case "optional":
|
||||
if option != key {
|
||||
return f, errors.New("optional takes no value")
|
||||
}
|
||||
f.optional = true
|
||||
case "maxBytes":
|
||||
f.maxBytes, err = fieldLimit(v)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
case "maxFiles":
|
||||
f.maxFiles, err = strconv.Atoi(v)
|
||||
if err != nil || f.maxFiles < 1 || f.maxFiles > 10000 || sf.Type.Kind() != reflect.Slice {
|
||||
return f, errors.New("maxFiles requires []File and a limit 1..10000")
|
||||
}
|
||||
default:
|
||||
return f, fmt.Errorf("unsupported file option %q", key)
|
||||
}
|
||||
}
|
||||
if f.binding != "" {
|
||||
return f, errors.New("file constraints use optional/maxBytes/maxFiles")
|
||||
}
|
||||
return f, applyDocumentation(&f, tags)
|
||||
}
|
||||
if containsUpload(n) {
|
||||
return f, errors.New("File requires file source")
|
||||
}
|
||||
opaque := base(n).scalar
|
||||
isList := base(n).typ.Kind() == reflect.Slice && opaque == nil
|
||||
if isList {
|
||||
@@ -263,25 +313,35 @@ func (c compiler) compileField(sf reflect.StructField, root bool, stack map[refl
|
||||
if isList && (f.source == "path" || f.source == "header") {
|
||||
return f, errors.New("path/header values must be scalars")
|
||||
}
|
||||
if f.source != "json" && !opaque.Text {
|
||||
if !jsonSource(f.source) && !opaque.Text {
|
||||
return f, errors.New("custom scalar needs text: true and UnmarshalText for path/query/header")
|
||||
}
|
||||
if f.binding != "" {
|
||||
return f, errors.New("custom scalar constraints belong in its decoder; use optional/pointer for presence and null")
|
||||
}
|
||||
for _, option := range parts[1:] {
|
||||
if strings.HasPrefix(option, "maxBytes=") && multipartSource(f.source) {
|
||||
f.maxBytes, err = fieldLimit(strings.TrimPrefix(option, "maxBytes="))
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if option != "optional" && option != "omitempty" {
|
||||
return f, errors.New("custom scalar supports only optional/omitempty field options")
|
||||
return f, errors.New("custom scalar supports optional/omitempty and multipart maxBytes")
|
||||
}
|
||||
f.optional = true
|
||||
}
|
||||
if f.source == "path" && (f.optional || sf.Type.Kind() == reflect.Pointer) {
|
||||
return f, errors.New("path fields must be required non-pointer values")
|
||||
}
|
||||
if err := applyDocumentation(&f, tags); err != nil {
|
||||
return f, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
scalar := base(n).typ.Kind()
|
||||
if f.source != "json" && !isScalar(scalar) && !(scalar == reflect.Slice && isScalar(base(base(n).elem).typ.Kind())) {
|
||||
if !jsonSource(f.source) && !isScalar(scalar) && !(scalar == reflect.Slice && isScalar(base(base(n).elem).typ.Kind())) {
|
||||
return f, errors.New("path/query/header values must be scalars or scalar slices")
|
||||
}
|
||||
if f.source == "path" && !isScalar(scalar) {
|
||||
@@ -303,6 +363,14 @@ func (c compiler) compileField(sf reflect.StructField, root bool, stack map[refl
|
||||
return f, errors.New("optional takes no value")
|
||||
}
|
||||
f.optional = true
|
||||
case "maxBytes":
|
||||
if !multipartSource(f.source) {
|
||||
return f, errors.New("maxBytes field option is only for multipart")
|
||||
}
|
||||
f.maxBytes, err = fieldLimit(value)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
case "default":
|
||||
if !isScalar(scalar) {
|
||||
return f, errors.New("default requires a scalar")
|
||||
@@ -354,6 +422,9 @@ func (c compiler) compileField(sf reflect.StructField, root bool, stack map[refl
|
||||
return f, fmt.Errorf("default violates validation: %w", err)
|
||||
}
|
||||
}
|
||||
if err := applyDocumentation(&f, tags); err != nil {
|
||||
return f, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
@@ -432,7 +503,7 @@ func (b *Binder) ResponseSchema() (*openapi.Schema, error) {
|
||||
func (b *Binder) Parameters() []openapi.Parameter {
|
||||
var out []openapi.Parameter
|
||||
for _, f := range b.root.fields {
|
||||
if f.source == "json" {
|
||||
if f.source == "json" || multipartSource(f.source) {
|
||||
continue
|
||||
}
|
||||
in := f.source
|
||||
@@ -445,7 +516,7 @@ func (b *Binder) Parameters() []openapi.Parameter {
|
||||
} else if base(f.value).typ.Kind() == reflect.Slice && base(base(f.value).elem).scalar != nil {
|
||||
schema.Items = &openapi.Schema{Type: "string"}
|
||||
}
|
||||
out = append(out, openapi.Parameter{Name: f.name, In: in, Required: !f.optional && !f.hasDefault, Schema: schema})
|
||||
out = append(out, openapi.Parameter{Name: f.name, In: in, Required: !f.optional && !f.hasDefault, Schema: schema, Description: f.value.schema.Description})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -455,3 +526,10 @@ func copySchema(s *openapi.Schema) *openapi.Schema {
|
||||
_ = json.Unmarshal(data, &out)
|
||||
return &out
|
||||
}
|
||||
|
||||
func containsUpload(n *node) bool {
|
||||
if n.typ == uploadType {
|
||||
return true
|
||||
}
|
||||
return n.elem != nil && containsUpload(n.elem)
|
||||
}
|
||||
|
||||
174
contract/documentation.go
Normal file
174
contract/documentation.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Documentation is part of the contract compiler, so CLI and runtime agree.
|
||||
func applyDocumentation(f *field, tags map[string]string) error {
|
||||
s := f.value.schema
|
||||
if f.maxBytes > 0 {
|
||||
v := f.maxBytes
|
||||
s.MaxBytes = &v
|
||||
}
|
||||
if f.source == "file" && f.value.typ.Kind() == reflect.Slice {
|
||||
v := f.maxFiles
|
||||
if v == 0 {
|
||||
v = 16
|
||||
}
|
||||
s.MaxItems = &v
|
||||
}
|
||||
s.Description = tags["description"]
|
||||
if raw, ok := tags["enumDescriptions"]; ok {
|
||||
if err := json.Unmarshal([]byte(raw), &s.EnumDescriptions); err != nil {
|
||||
return errors.New("enumDescriptions must be a JSON string array")
|
||||
}
|
||||
count := len(s.Enum)
|
||||
if s.Nullable && count > 0 && s.Enum[count-1] == nil {
|
||||
count--
|
||||
}
|
||||
if count == 0 || len(s.EnumDescriptions) != count {
|
||||
return errors.New("enumDescriptions must describe every declared enum value")
|
||||
}
|
||||
for _, v := range s.EnumDescriptions {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return errors.New("enum description cannot be empty")
|
||||
}
|
||||
}
|
||||
}
|
||||
if raw, ok := tags["example"]; ok {
|
||||
var value any
|
||||
if base(f.value).typ.Kind() == reflect.String && base(f.value).scalar == nil {
|
||||
value = raw
|
||||
} else {
|
||||
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var err error
|
||||
value, err = decodeValue(decoder, 0)
|
||||
if err != nil {
|
||||
return errors.New("invalid example JSON")
|
||||
}
|
||||
if _, err = decoder.Token(); err != io.EOF {
|
||||
return errors.New("example requires one value")
|
||||
}
|
||||
}
|
||||
// A schema-only custom scalar never executes project decoders.
|
||||
if !documentationHasScalar(f.value) {
|
||||
v := reflect.New(f.value.typ).Elem()
|
||||
if err := setValue(f.value, v, value); err != nil {
|
||||
return fmt.Errorf("example: %w", err)
|
||||
}
|
||||
if err := checkField(*f, v); err != nil {
|
||||
return fmt.Errorf("example: %w", err)
|
||||
}
|
||||
} else if err := checkDocumentationScalarTree(f.value, value); err != nil {
|
||||
return err
|
||||
}
|
||||
s.Example = value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func documentationHasScalar(n *node) bool {
|
||||
if n.scalar != nil {
|
||||
return true
|
||||
}
|
||||
if n.elem != nil && documentationHasScalar(n.elem) {
|
||||
return true
|
||||
}
|
||||
for _, f := range n.fields {
|
||||
if documentationHasScalar(f.value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Metadata never invokes application decoders, including nested scalar types.
|
||||
func checkDocumentationScalarTree(n *node, value any) error {
|
||||
if value == nil {
|
||||
if n.typ.Kind() == reflect.Pointer || n.typ.Kind() == reflect.Interface {
|
||||
return nil
|
||||
}
|
||||
return errors.New("example null requires a nullable field")
|
||||
}
|
||||
if n.typ.Kind() == reflect.Pointer {
|
||||
return checkDocumentationScalarTree(n.elem, value)
|
||||
}
|
||||
if n.scalar != nil {
|
||||
for _, kind := range strings.Split(n.scalar.JSONInput, "|") {
|
||||
if scalarAccepts(strings.TrimSpace(kind), value) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("example does not match scalar input")
|
||||
}
|
||||
if !documentationHasScalar(n) {
|
||||
v := reflect.New(n.typ).Elem()
|
||||
return setValue(n, v, value)
|
||||
}
|
||||
switch n.typ.Kind() {
|
||||
case reflect.Slice:
|
||||
values, ok := value.([]any)
|
||||
if !ok {
|
||||
return errors.New("example requires an array")
|
||||
}
|
||||
if n.schema.MinItems != nil && len(values) < *n.schema.MinItems || n.schema.MaxItems != nil && len(values) > *n.schema.MaxItems {
|
||||
return errors.New("example violates item limits")
|
||||
}
|
||||
for _, v := range values {
|
||||
if err := checkDocumentationScalarTree(n.elem, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case reflect.Map:
|
||||
values, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return errors.New("example requires an object")
|
||||
}
|
||||
for _, v := range values {
|
||||
if err := checkDocumentationScalarTree(n.elem, v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case reflect.Struct:
|
||||
values, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return errors.New("example requires an object")
|
||||
}
|
||||
declared := map[string]bool{}
|
||||
for _, f := range n.fields {
|
||||
declared[f.name] = true
|
||||
v, ok := values[f.name]
|
||||
if !ok {
|
||||
if !f.optional && !f.hasDefault {
|
||||
return errors.New("example missing field")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := checkDocumentationScalarTree(f.value, v); err != nil {
|
||||
return err
|
||||
}
|
||||
if !documentationHasScalar(f.value) {
|
||||
typed := reflect.New(f.value.typ).Elem()
|
||||
if err := setValue(f.value, typed, v); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkField(f, typed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
for name := range values {
|
||||
if !declared[name] {
|
||||
return errors.New("unknown example field")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
55
contract/documentation_test.go
Normal file
55
contract/documentation_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type documentationScalar string
|
||||
|
||||
var documentationDecodeCalls int
|
||||
|
||||
func (v documentationScalar) MarshalJSON() ([]byte, error) { return json.Marshal(string(v)) }
|
||||
func (v *documentationScalar) UnmarshalJSON(raw []byte) error {
|
||||
documentationDecodeCalls++
|
||||
var s string
|
||||
err := json.Unmarshal(raw, &s)
|
||||
*v = documentationScalar(s)
|
||||
return err
|
||||
}
|
||||
func TestDocumentationNeverRunsNestedScalarDecoders(t *testing.T) {
|
||||
type nested struct {
|
||||
ID documentationScalar `json:"id"`
|
||||
}
|
||||
type input struct {
|
||||
Value nested `json:"value" example:"{\"id\":\"123\"}"`
|
||||
}
|
||||
documentationDecodeCalls = 0
|
||||
scalar := ScalarOf[documentationScalar]("string", "string", false)
|
||||
if _, err := New[input](scalar); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Describe(reflect.TypeOf(input{}), scalar); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if documentationDecodeCalls != 0 {
|
||||
t.Fatal("metadata invoked business decoder")
|
||||
}
|
||||
type bad struct {
|
||||
Value nested `json:"value" example:"{\"id\":123}"`
|
||||
}
|
||||
if _, err := Describe(reflect.TypeOf(bad{}), scalar); err == nil {
|
||||
t.Fatal("invalid scalar wire example accepted")
|
||||
}
|
||||
type constrained struct {
|
||||
ID documentationScalar `json:"id"`
|
||||
Count int `json:"count,range=[1:3]"`
|
||||
}
|
||||
type invalidConstraint struct {
|
||||
Value constrained `json:"value" example:"{\"id\":\"123\",\"count\":9}"`
|
||||
}
|
||||
if _, err := Describe(reflect.TypeOf(invalidConstraint{}), scalar); err == nil {
|
||||
t.Fatal("nested scalar bypassed another field's example constraints")
|
||||
}
|
||||
}
|
||||
80
contract/file.go
Normal file
80
contract/file.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// File is a request-scoped upload, staged on disk using a bounded copy buffer.
|
||||
// Name and ContentType are untrusted client metadata. Open streams content;
|
||||
// readers and the temporary file are closed when the generated handler returns.
|
||||
// Persist wanted content before returning. Retaining File does not extend its lifetime.
|
||||
type File struct {
|
||||
Name string
|
||||
ContentType string
|
||||
Size int64
|
||||
state *uploadState
|
||||
}
|
||||
type uploadState struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
closed bool
|
||||
readers map[*uploadReader]bool
|
||||
}
|
||||
type uploadReader struct {
|
||||
file *os.File
|
||||
once sync.Once
|
||||
err error
|
||||
state *uploadState
|
||||
}
|
||||
|
||||
// Present reports whether the optional upload was supplied.
|
||||
func (f File) Present() bool { return f.state != nil }
|
||||
|
||||
func (f File) Open() (io.ReadCloser, error) {
|
||||
if f.state == nil {
|
||||
return nil, errors.New("upload file is absent")
|
||||
}
|
||||
s := f.state
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.closed {
|
||||
return nil, errors.New("upload file is closed")
|
||||
}
|
||||
file, err := os.Open(s.path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := &uploadReader{file: file, state: s}
|
||||
s.readers[r] = true
|
||||
return r, nil
|
||||
}
|
||||
func (r *uploadReader) Read(p []byte) (int, error) { return r.file.Read(p) }
|
||||
func (r *uploadReader) Close() error {
|
||||
r.once.Do(func() { r.err = r.file.Close(); r.state.mu.Lock(); delete(r.state.readers, r); r.state.mu.Unlock() })
|
||||
return r.err
|
||||
}
|
||||
func (f File) cleanup() error {
|
||||
if f.state == nil {
|
||||
return nil
|
||||
}
|
||||
s := f.state
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
readers := make([]*uploadReader, 0, len(s.readers))
|
||||
for r := range s.readers {
|
||||
readers = append(readers, r)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
var err error
|
||||
for _, r := range readers {
|
||||
err = errors.Join(err, r.Close())
|
||||
}
|
||||
return errors.Join(err, os.Remove(s.path))
|
||||
}
|
||||
331
contract/multipart.go
Normal file
331
contract/multipart.go
Normal file
@@ -0,0 +1,331 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/openapi"
|
||||
)
|
||||
|
||||
var uploadType = reflect.TypeOf(File{})
|
||||
|
||||
// MultipartOptions limits raw request bytes (including headers), part count,
|
||||
// and individual decoded part sizes. Zero selects bounded defaults. Files are
|
||||
// spooled to 0600 temporary files; text/JSON parts are buffered up to MaxFieldBytes.
|
||||
type MultipartOptions struct {
|
||||
MaxBodyBytes int64
|
||||
MaxParts int
|
||||
MaxFieldBytes int64
|
||||
MaxFileBytes int64
|
||||
TempDir string
|
||||
Policy RequestPolicy
|
||||
}
|
||||
|
||||
// Normalized returns validated effective protocol limits without allocating resources.
|
||||
func (o MultipartOptions) Normalized() (MultipartOptions, error) {
|
||||
if o.MaxBodyBytes == 0 {
|
||||
o.MaxBodyBytes = DefaultMaxBodyBytes
|
||||
}
|
||||
if o.MaxParts == 0 {
|
||||
o.MaxParts = 128
|
||||
}
|
||||
if o.MaxFieldBytes == 0 {
|
||||
o.MaxFieldBytes = 64 << 10
|
||||
}
|
||||
if o.MaxFileBytes == 0 {
|
||||
o.MaxFileBytes = o.MaxBodyBytes
|
||||
}
|
||||
if o.MaxBodyBytes < 1 || o.MaxBodyBytes > 1<<40 || o.MaxParts < 1 || o.MaxParts > 10000 || o.MaxFieldBytes < 1 || o.MaxFieldBytes > 1<<30 || o.MaxFileBytes < 1 || o.MaxFileBytes > 1<<40 {
|
||||
return o, errors.New("invalid multipart limits")
|
||||
}
|
||||
return o, o.Policy.Validate()
|
||||
}
|
||||
func (b *Binder) HasMultipart() bool {
|
||||
for _, f := range b.root.fields {
|
||||
if multipartSource(f.source) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func multipartSource(s string) bool { return s == "file" || s == "multipart" || s == "multipartJSON" }
|
||||
func jsonSource(s string) bool { return s == "json" || s == "multipartJSON" }
|
||||
|
||||
// MultipartSchema describes form parts. JSON fields get an encoding declaration
|
||||
// in generated OpenAPI; they are decoded with the ordinary strict JSON binder.
|
||||
func (b *Binder) MultipartSchema() *openapi.Schema {
|
||||
s := &openapi.Schema{Type: "object", Properties: map[string]*openapi.Schema{}}
|
||||
no := false
|
||||
s.AdditionalPropertiesAllowed = &no
|
||||
for _, f := range b.root.fields {
|
||||
if multipartSource(f.source) {
|
||||
s.Properties[f.name] = copySchema(f.value.schema)
|
||||
if !f.optional && !f.hasDefault {
|
||||
s.Required = append(s.Required, f.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
func (b *Binder) MultipartJSONFields() []string {
|
||||
var out []string
|
||||
for _, f := range b.root.fields {
|
||||
if f.source == "multipartJSON" {
|
||||
out = append(out, f.name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BindMultipart validates every part before publishing the DTO or entering Logic.
|
||||
// On success the caller must defer cleanup; on failure all created files/readers
|
||||
// are cleaned here. The request's Body remains owned by the HTTP server.
|
||||
func (b *Binder) BindMultipart(r *http.Request, params map[string]string, target any, options MultipartOptions) (cleanup func() error, err error) {
|
||||
if b == nil || r == nil || b.schemaOnly {
|
||||
return nil, apierrors.Internal(errors.New("invalid multipart binder"))
|
||||
}
|
||||
dst := reflect.ValueOf(target)
|
||||
if dst.Kind() != reflect.Pointer || dst.IsNil() || dst.Elem().Type() != b.typ {
|
||||
return nil, apierrors.Internal(errors.New("multipart target mismatch"))
|
||||
}
|
||||
o, err := options.Normalized()
|
||||
if err != nil {
|
||||
return nil, apierrors.Internal(err)
|
||||
}
|
||||
if b.BodySchema() != nil {
|
||||
return nil, apierrors.Internal(errors.New("multipart request cannot contain json source fields; use multipartJSON"))
|
||||
}
|
||||
if r.ContentLength > o.MaxBodyBytes {
|
||||
return nil, tooLarge()
|
||||
}
|
||||
var files []File
|
||||
var once sync.Once
|
||||
var closeErr error
|
||||
release := func() error {
|
||||
once.Do(func() {
|
||||
for i := len(files) - 1; i >= 0; i-- {
|
||||
closeErr = errors.Join(closeErr, files[i].cleanup())
|
||||
}
|
||||
})
|
||||
return closeErr
|
||||
}
|
||||
success := false
|
||||
defer func() {
|
||||
if !success {
|
||||
err = errors.Join(err, release())
|
||||
}
|
||||
}()
|
||||
// Bind query/path/header once, independently of multipart body contents.
|
||||
ordinary := *b
|
||||
root := *b.root
|
||||
root.fields = nil
|
||||
ordinary.root = &root
|
||||
for _, f := range b.root.fields {
|
||||
if !multipartSource(f.source) {
|
||||
root.fields = append(root.fields, f)
|
||||
}
|
||||
}
|
||||
fresh := reflect.New(b.typ)
|
||||
policy := o.Policy
|
||||
policy.Body = "auto"
|
||||
if err = ordinary.BindWithPolicy(r, params, fresh.Interface(), policy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request := *r
|
||||
input := r.Body
|
||||
if input == nil {
|
||||
input = http.NoBody
|
||||
}
|
||||
request.Body = http.MaxBytesReader(nil, input, o.MaxBodyBytes)
|
||||
body, err := httpx.NewBody(&request, "multipart")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fields := map[string]field{}
|
||||
for _, f := range b.root.fields {
|
||||
if multipartSource(f.source) {
|
||||
fields[f.name] = f
|
||||
}
|
||||
}
|
||||
text := map[string][]string{}
|
||||
uploaded := map[string][]File{}
|
||||
seen := map[string]int{}
|
||||
policy = MergePolicy(RequestPolicyFromContext(r.Context()), o.Policy)
|
||||
for count := 0; ; count++ {
|
||||
part, e := body.Parts.NextRawPart()
|
||||
if e == io.EOF {
|
||||
break
|
||||
}
|
||||
if e != nil {
|
||||
return nil, multipartFailure(e)
|
||||
}
|
||||
if count >= o.MaxParts {
|
||||
return nil, tooLarge()
|
||||
}
|
||||
f, ok := fields[part.FormName()]
|
||||
if !ok {
|
||||
return nil, invalid(errors.New("unknown multipart field"))
|
||||
}
|
||||
seen[f.name]++
|
||||
// Reject encodings that would make declared byte limits ambiguous.
|
||||
if part.Header.Get("Content-Transfer-Encoding") != "" {
|
||||
return nil, invalid(errors.New("encoded multipart parts are unsupported"))
|
||||
}
|
||||
_, disposition, e := mime.ParseMediaType(part.Header.Get("Content-Disposition"))
|
||||
if e != nil {
|
||||
return nil, invalid(e)
|
||||
}
|
||||
_, isFile := disposition["filename"]
|
||||
if (f.source == "file") != isFile {
|
||||
return nil, invalid(errors.New("multipart field kind mismatch"))
|
||||
}
|
||||
limit := o.MaxFieldBytes
|
||||
if f.source == "file" {
|
||||
limit = o.MaxFileBytes
|
||||
if f.maxBytes > 0 && f.maxBytes < limit {
|
||||
limit = f.maxBytes
|
||||
}
|
||||
maxFiles := f.maxFiles
|
||||
if maxFiles == 0 {
|
||||
maxFiles = 16
|
||||
}
|
||||
if f.value.typ.Kind() != reflect.Slice {
|
||||
maxFiles = 1
|
||||
}
|
||||
if seen[f.name] > maxFiles {
|
||||
return nil, invalid(errors.New("too many files for multipart field"))
|
||||
}
|
||||
name := path.Base(strings.ReplaceAll(part.FileName(), "\\", "/"))
|
||||
if name == "" || name == "." || len(name) > 255 || !utf8.ValidString(name) || strings.ContainsAny(name, "\x00\r\n") {
|
||||
return nil, invalid(errors.New("invalid upload filename"))
|
||||
}
|
||||
temp, e := os.CreateTemp(o.TempDir, "omni-upload-*")
|
||||
if e != nil {
|
||||
return nil, apierrors.Internal(e)
|
||||
}
|
||||
file := File{Name: name, ContentType: part.Header.Get("Content-Type"), state: &uploadState{path: temp.Name(), readers: map[*uploadReader]bool{}}}
|
||||
files = append(files, file)
|
||||
size, e := io.Copy(temp, io.LimitReader(part, limit+1))
|
||||
e = errors.Join(e, temp.Close())
|
||||
if size > limit {
|
||||
return nil, tooLarge()
|
||||
}
|
||||
if e != nil {
|
||||
return nil, multipartFailure(e)
|
||||
}
|
||||
file.Size = size
|
||||
files[len(files)-1] = file
|
||||
uploaded[f.name] = append(uploaded[f.name], file)
|
||||
} else {
|
||||
if f.maxBytes > 0 && f.maxBytes < limit {
|
||||
limit = f.maxBytes
|
||||
}
|
||||
if seen[f.name] > 1 && (f.source == "multipartJSON" || base(f.value).typ.Kind() != reflect.Slice) {
|
||||
return nil, invalid(errors.New("duplicate multipart field"))
|
||||
}
|
||||
data, e := io.ReadAll(io.LimitReader(part, limit+1))
|
||||
if int64(len(data)) > limit {
|
||||
return nil, tooLarge()
|
||||
}
|
||||
if e != nil {
|
||||
return nil, multipartFailure(e)
|
||||
}
|
||||
if policy.UTF8 == "reject" && !utf8.Valid(data) {
|
||||
return nil, invalid(errUTF8)
|
||||
}
|
||||
if policy.Charset == "utf8" && part.Header.Get("Content-Type") != "" {
|
||||
_, p, e := mime.ParseMediaType(part.Header.Get("Content-Type"))
|
||||
if e != nil || (p["charset"] != "" && !strings.EqualFold(p["charset"], "utf-8")) {
|
||||
return nil, invalid(errors.New("invalid multipart field charset"))
|
||||
}
|
||||
}
|
||||
text[f.name] = append(text[f.name], strings.ToValidUTF8(string(data), "<22>"))
|
||||
}
|
||||
if err = part.Close(); err != nil {
|
||||
return nil, multipartFailure(err)
|
||||
}
|
||||
}
|
||||
// Consume epilogue as well; it counts against the total request cap.
|
||||
if err = body.Complete(); err != nil {
|
||||
return nil, multipartFailure(err)
|
||||
}
|
||||
for _, f := range b.root.fields {
|
||||
if !multipartSource(f.source) {
|
||||
continue
|
||||
}
|
||||
present := seen[f.name] > 0
|
||||
dest := fresh.Elem().Field(f.index)
|
||||
if f.source == "file" {
|
||||
if !present {
|
||||
if !f.optional {
|
||||
return nil, invalid(fmt.Errorf("%s: file required", f.name))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if f.value.typ.Kind() == reflect.Slice {
|
||||
dest.Set(reflect.ValueOf(uploaded[f.name]))
|
||||
} else {
|
||||
dest.Set(reflect.ValueOf(uploaded[f.name][0]))
|
||||
}
|
||||
continue
|
||||
}
|
||||
var value any
|
||||
if present {
|
||||
if f.source == "multipartJSON" {
|
||||
d := json.NewDecoder(strings.NewReader(text[f.name][0]))
|
||||
d.UseNumber()
|
||||
value, err = decodeValue(d, 0)
|
||||
if err == nil {
|
||||
_, err = d.Token()
|
||||
if err == io.EOF {
|
||||
err = nil
|
||||
} else if err == nil {
|
||||
err = errors.New("multiple JSON values")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
value, err = fromStrings(f.value, text[f.name])
|
||||
}
|
||||
if err != nil {
|
||||
return nil, invalid(fmt.Errorf("%s: %w", f.name, err))
|
||||
}
|
||||
}
|
||||
if err = assignField(f, dest, value, present); err != nil {
|
||||
return nil, invalid(fmt.Errorf("%s: %w", f.name, err))
|
||||
}
|
||||
}
|
||||
dst.Elem().Set(fresh.Elem())
|
||||
success = true
|
||||
return release, nil
|
||||
}
|
||||
func multipartFailure(err error) error {
|
||||
var max *http.MaxBytesError
|
||||
if errors.As(err, &max) {
|
||||
return tooLarge()
|
||||
}
|
||||
var api *apierrors.APIError
|
||||
if errors.As(err, &api) {
|
||||
return err
|
||||
}
|
||||
return invalid(err)
|
||||
}
|
||||
func fieldLimit(raw string) (int64, error) {
|
||||
v, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || v < 1 || v > 1<<40 {
|
||||
return 0, errors.New("maxBytes must be 1..1099511627776")
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
152
contract/multipart_test.go
Normal file
152
contract/multipart_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type uploadInput struct {
|
||||
Query string `form:"query,optional"`
|
||||
Name string `multipart:"name" binding:"required,max=10"`
|
||||
Meta struct {
|
||||
Count int `json:"count"`
|
||||
} `multipartJSON:"meta"`
|
||||
Attachment File `file:"attachment,maxBytes=12"`
|
||||
Extra []File `file:"extra,optional,maxBytes=8,maxFiles=2"`
|
||||
}
|
||||
type uploadPart struct{ name, filename, value string }
|
||||
|
||||
func uploadRequest(t *testing.T, parts []uploadPart) (*http.Request, []byte) {
|
||||
t.Helper()
|
||||
var data bytes.Buffer
|
||||
w := multipart.NewWriter(&data)
|
||||
for _, part := range parts {
|
||||
if part.filename != "" {
|
||||
p, e := w.CreateFormFile(part.name, part.filename)
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
_, _ = io.WriteString(p, part.value)
|
||||
} else {
|
||||
_ = w.WriteField(part.name, part.value)
|
||||
}
|
||||
}
|
||||
_ = w.Close()
|
||||
r := httptest.NewRequest("POST", "/upload?query=yes", bytes.NewReader(data.Bytes()))
|
||||
r.Header.Set("Content-Type", w.FormDataContentType())
|
||||
return r, data.Bytes()
|
||||
}
|
||||
func goodParts() []uploadPart {
|
||||
return []uploadPart{{"name", "", "合同"}, {"meta", "", `{"count":2}`}, {"attachment", "../contract.txt", "hello"}}
|
||||
}
|
||||
func assertUploadEmpty(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
files, err := os.ReadDir(dir)
|
||||
if err != nil || len(files) != 0 {
|
||||
t.Fatalf("temp files remain: %v %v", files, err)
|
||||
}
|
||||
}
|
||||
func TestMultipartBindingAndLifetime(t *testing.T) {
|
||||
b := MustNew[uploadInput]()
|
||||
dir := t.TempDir()
|
||||
r, _ := uploadRequest(t, append(goodParts(), uploadPart{"extra", "one", "first"}, uploadPart{"extra", "two", "second"}))
|
||||
var input uploadInput
|
||||
release, err := b.BindMultipart(r, nil, &input, MultipartOptions{TempDir: dir, Policy: RequestPolicy{UnknownQuery: "reject", UTF8: "reject"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if input.Name != "合同" || input.Query != "yes" || input.Meta.Count != 2 || input.Attachment.Size != 5 || input.Attachment.Name != "contract.txt" || len(input.Extra) != 2 {
|
||||
t.Fatalf("%+v", input)
|
||||
}
|
||||
reader, err := input.Attachment.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil || string(data) != "hello" {
|
||||
t.Fatal(string(data), err)
|
||||
}
|
||||
// An unclosed reader is owned by the upload cleanup as well.
|
||||
if err = release(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = reader.Read(make([]byte, 1)); err == nil {
|
||||
t.Fatal("reader remains open")
|
||||
}
|
||||
if _, err = input.Attachment.Open(); err == nil {
|
||||
t.Fatal("expired upload reopened")
|
||||
}
|
||||
if err = release(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertUploadEmpty(t, dir)
|
||||
if err = b.Bind(r, nil, &input); err == nil {
|
||||
t.Fatal("ordinary binding accepted multipart fields")
|
||||
}
|
||||
}
|
||||
func TestMultipartFailuresAreTransactional(t *testing.T) {
|
||||
b := MustNew[uploadInput]()
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
parts []uploadPart
|
||||
options MultipartOptions
|
||||
status int
|
||||
}{
|
||||
{"missing", goodParts()[1:], MultipartOptions{}, 400},
|
||||
{"unknown", append(goodParts(), uploadPart{"other", "", "x"}), MultipartOptions{}, 400},
|
||||
{"duplicate", append(goodParts(), uploadPart{"name", "", "x"}), MultipartOptions{}, 400},
|
||||
{"jsonDuplicate", []uploadPart{{"name", "", "n"}, {"attachment", "f", "x"}, {"meta", "", `{"count":1,"count":2}`}}, MultipartOptions{}, 400},
|
||||
{"badType", []uploadPart{{"name", "", "n"}, {"attachment", "f", "x"}, {"meta", "", `{"count":"x"}`}}, MultipartOptions{}, 400},
|
||||
{"badUTF8", append(goodParts()[:1], uploadPart{"meta", "", "{\"count\":\"\xff\"}"}, goodParts()[2]), MultipartOptions{Policy: RequestPolicy{UTF8: "reject"}}, 400},
|
||||
{"fileSize", []uploadPart{{"attachment", "x", strings.Repeat("x", 13)}}, MultipartOptions{}, 413},
|
||||
{"fieldSize", goodParts(), MultipartOptions{MaxFieldBytes: 2}, 413},
|
||||
{"partCount", goodParts(), MultipartOptions{MaxParts: 2}, 413},
|
||||
{"total", goodParts(), MultipartOptions{MaxBodyBytes: 32}, 413},
|
||||
{"fileKind", []uploadPart{{"attachment", "", "text"}}, MultipartOptions{}, 400},
|
||||
{"extraCount", append(goodParts(), uploadPart{"extra", "a", "a"}, uploadPart{"extra", "b", "b"}, uploadPart{"extra", "c", "c"}), MultipartOptions{}, 400},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
tt.options.TempDir = dir
|
||||
r, _ := uploadRequest(t, tt.parts)
|
||||
r.ContentLength = -1
|
||||
input := uploadInput{Name: "untouched"}
|
||||
release, err := b.BindMultipart(r, nil, &input, tt.options)
|
||||
var api *apierrors.APIError
|
||||
if !errors.As(err, &api) || api.Status != tt.status {
|
||||
t.Fatalf("error %v", err)
|
||||
}
|
||||
if release != nil || input.Name != "untouched" {
|
||||
t.Fatal("partial binding published")
|
||||
}
|
||||
assertUploadEmpty(t, dir)
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestMultipartTruncationAndEpilogueLimit(t *testing.T) {
|
||||
b := MustNew[uploadInput]()
|
||||
for _, epilogue := range []bool{false, true} {
|
||||
dir := t.TempDir()
|
||||
r, original := uploadRequest(t, goodParts())
|
||||
data := original[:len(original)-8]
|
||||
limit := int64(len(original) + 16)
|
||||
if epilogue {
|
||||
data = append(original, bytes.Repeat([]byte("x"), 64)...)
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(data))
|
||||
r.ContentLength = -1
|
||||
var input uploadInput
|
||||
if _, err := b.BindMultipart(r, nil, &input, MultipartOptions{TempDir: dir, MaxBodyBytes: limit}); err == nil {
|
||||
t.Fatal("truncation or epilogue bypass accepted")
|
||||
}
|
||||
assertUploadEmpty(t, dir)
|
||||
}
|
||||
}
|
||||
105
contract/policy.go
Normal file
105
contract/policy.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// RequestPolicy describes protocol strictness, independently of business
|
||||
// fields. Empty values inherit App defaults, then the historical binder policy.
|
||||
type RequestPolicy struct {
|
||||
UnknownQuery string `json:"unknownQuery"` // ignore or reject
|
||||
UTF8 string `json:"utf8"` // replace or reject
|
||||
Charset string `json:"charset"` // any or utf8
|
||||
Body string `json:"body"` // auto, required, optional, none, emptyObject
|
||||
}
|
||||
|
||||
func (p RequestPolicy) Validate() error {
|
||||
for _, item := range []struct {
|
||||
name, value string
|
||||
allowed []string
|
||||
}{
|
||||
{"unknownQuery", p.UnknownQuery, []string{"ignore", "reject"}},
|
||||
{"utf8", p.UTF8, []string{"replace", "reject"}},
|
||||
{"charset", p.Charset, []string{"any", "utf8"}},
|
||||
{"body", p.Body, []string{"auto", "required", "optional", "none", "emptyObject"}},
|
||||
} {
|
||||
if item.value == "" {
|
||||
continue
|
||||
}
|
||||
ok := false
|
||||
for _, value := range item.allowed {
|
||||
ok = ok || value == item.value
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("request policy: invalid %s", item.name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func MergePolicy(base, override RequestPolicy) RequestPolicy {
|
||||
if override.UnknownQuery != "" {
|
||||
base.UnknownQuery = override.UnknownQuery
|
||||
}
|
||||
if override.UTF8 != "" {
|
||||
base.UTF8 = override.UTF8
|
||||
}
|
||||
if override.Charset != "" {
|
||||
base.Charset = override.Charset
|
||||
}
|
||||
if override.Body != "" {
|
||||
base.Body = override.Body
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
type policyKey struct{}
|
||||
|
||||
func WithRequestPolicy(ctx context.Context, p RequestPolicy) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithValue(ctx, policyKey{}, p)
|
||||
}
|
||||
func RequestPolicyFromContext(ctx context.Context) RequestPolicy {
|
||||
if ctx == nil {
|
||||
return RequestPolicy{}
|
||||
}
|
||||
p, _ := ctx.Value(policyKey{}).(RequestPolicy)
|
||||
return p
|
||||
}
|
||||
|
||||
// utf8Reader checks original bytes in one pass, including runes crossing Read
|
||||
// boundaries. It never replaces invalid bytes or buffers the complete body.
|
||||
type utf8Reader struct {
|
||||
r io.Reader
|
||||
pending [4]byte
|
||||
n int
|
||||
}
|
||||
|
||||
var errUTF8 = errors.New("invalid UTF-8 in JSON body")
|
||||
|
||||
func (r *utf8Reader) Read(p []byte) (int, error) {
|
||||
n, err := r.r.Read(p)
|
||||
for _, b := range p[:n] {
|
||||
if r.n == 0 && b < utf8.RuneSelf {
|
||||
continue
|
||||
}
|
||||
r.pending[r.n] = b
|
||||
r.n++
|
||||
if utf8.FullRune(r.pending[:r.n]) {
|
||||
ch, size := utf8.DecodeRune(r.pending[:r.n])
|
||||
if ch == utf8.RuneError && size == 1 {
|
||||
return n, errUTF8
|
||||
}
|
||||
r.n = 0
|
||||
}
|
||||
}
|
||||
if err == io.EOF && r.n != 0 {
|
||||
return n, errUTF8
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
104
contract/policy_test.go
Normal file
104
contract/policy_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type byteReader struct{ r io.Reader }
|
||||
|
||||
func (r byteReader) Read(p []byte) (int, error) {
|
||||
if len(p) > 1 {
|
||||
p = p[:1]
|
||||
}
|
||||
return r.r.Read(p)
|
||||
}
|
||||
|
||||
func TestBodyPolicyPropagatesOuterLimit(t *testing.T) {
|
||||
for _, limit := range []int64{0, 1, 4} {
|
||||
r := httptest.NewRequest("POST", "/", byteReader{strings.NewReader("{} ")})
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r.Body = http.MaxBytesReader(httptest.NewRecorder(), r.Body, limit)
|
||||
err := MustNew[struct{}]().BindWithPolicy(r, nil, &struct{}{}, RequestPolicy{Body: "required", UTF8: "reject"})
|
||||
var e *apierrors.APIError
|
||||
if !errors.As(err, &e) || e.Status != 413 {
|
||||
t.Fatalf("limit %d: expected 413, got %v", limit, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrictRequestPolicy(t *testing.T) {
|
||||
type request struct {
|
||||
Name string `json:"name"`
|
||||
Tags []string `form:"tag,optional"`
|
||||
}
|
||||
b := MustNew[request]()
|
||||
strict := RequestPolicy{UnknownQuery: "reject", UTF8: "reject", Charset: "utf8"}
|
||||
for _, tc := range []struct {
|
||||
name, url, ct, body string
|
||||
valid bool
|
||||
}{
|
||||
{"valid", "/?tag=a&tag=b", "application/json; charset=UTF-8", `{"name":"企业😀"}`, true},
|
||||
{"query", "/?typo=1", "application/json", `{"name":"ok"}`, false},
|
||||
{"charset", "/", "application/json; charset=gbk", `{"name":"ok"}`, false},
|
||||
{"utf8", "/", "application/json", "{\"name\":\"\xff\"}", false},
|
||||
{"truncatedRune", "/", "application/json", "{\"name\":\"\xe4\"}", false},
|
||||
{"tail", "/", "application/json", `{"name":"ok"} {}`, false},
|
||||
{"tailInvalidByte", "/", "application/json", "{\"name\":\"ok\"} \xff", false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := httptest.NewRequest("POST", tc.url, byteReader{strings.NewReader(tc.body)})
|
||||
r.Header.Set("Content-Type", tc.ct)
|
||||
var dst request
|
||||
err := b.BindWithPolicy(r, nil, &dst, strict)
|
||||
if (err == nil) != tc.valid {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.valid && len(dst.Tags) != 2 {
|
||||
t.Fatal("query slice rejected")
|
||||
}
|
||||
})
|
||||
}
|
||||
// Explicit route overrides inherit the rest of the App policy.
|
||||
r := httptest.NewRequest("POST", "/?typo=1", strings.NewReader(`{"name":"ok"}`))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r = r.WithContext(WithRequestPolicy(context.Background(), strict))
|
||||
if err := b.BindWithPolicy(r, nil, &request{}, RequestPolicy{UnknownQuery: "ignore"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodyPresencePolicies(t *testing.T) {
|
||||
b := MustNew[struct{}]()
|
||||
for _, mode := range []string{"none", "emptyObject", "required", "optional"} {
|
||||
for _, body := range []string{"", "{}", "null", `{"unexpected":1}`, "{} {}", " "} {
|
||||
r := httptest.NewRequest("POST", "/", byteReader{strings.NewReader(body)})
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
err := b.BindWithPolicy(r, nil, &struct{}{}, RequestPolicy{Body: mode, UTF8: "reject"})
|
||||
valid := (mode == "none" && body == "") || (mode != "none" && body == "{}") || (mode == "optional" && body == "")
|
||||
if (err == nil) != valid {
|
||||
t.Fatalf("mode=%s body=%q: %v", mode, body, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
type object struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
r := httptest.NewRequest("POST", "/", bytes.NewReader(nil))
|
||||
if err := MustNew[object]().BindWithPolicy(r, nil, &object{}, RequestPolicy{Body: "optional"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Chunked/unknown size is still bounded under strict UTF-8 checking.
|
||||
r = httptest.NewRequest("POST", "/", byteReader{strings.NewReader(`{"value":"1234567890"}`)})
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if err := MustNew[object]().BindWithPolicy(r, nil, &object{}, RequestPolicy{UTF8: "reject"}, 8); err == nil {
|
||||
t.Fatal("chunked over-limit accepted")
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,10 @@ func responseSchema(n *node) *openapi.Schema {
|
||||
b := base(n)
|
||||
if b.scalar != nil {
|
||||
s := wireSchema(b.scalar.JSONOutput)
|
||||
s.Description = n.schema.Description
|
||||
if b.scalar.JSONInput == b.scalar.JSONOutput {
|
||||
s.Example = n.schema.Example
|
||||
}
|
||||
if n.typ.Kind() == reflect.Pointer {
|
||||
s = nullableSchema(s)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# database
|
||||
|
||||
结构化 MySQL 配置、独立 TLS/mTLS、驱动日志和生成项目接入见 [官方 MySQL 适配器](mysql/README.md)。已有 Open/OpenConnector 接口继续支持。
|
||||
|
||||
使用 GORM 的项目可显式接入 [gormx 可选适配器](gormx/README.md),复用本包连接池,并将 SQL 日志接入 logx;普通 database 包不导入 GORM。
|
||||
|
||||
`database` 包提供基于标准库 `database/sql` 的连接池生命周期,不绑定 ORM 或驱动:
|
||||
|
||||
- `Config` / `ConfigFromEnv`:连接池参数和 `GO_OMNI_DB_*` 环境变量。
|
||||
|
||||
104
database/gormx/README.md
Normal file
104
database/gormx/README.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# 可选 GORM 适配器
|
||||
|
||||
从 `v0.14.1-preview` 提供。`gormx` 把 GORM 接到 go-omni 已初始化的 `*sql.DB`,保留一份连接池和一处资源所有权。使用 GORM v2 API(本版验收版本为 `gorm.io/gorm v1.31.2`、MySQL dialect `v1.6.0`)。
|
||||
|
||||
核心 `omni`、`database`、`database/mysql` 不导入此包;普通项目的编译依赖链不包含 GORM。仓库同一 Go module 的 go.mod 会记录可选适配器依赖,使用方显式导入 `database/gormx` 才编译它。本版没有新增默认 ORM 或 `--orm` 脚手架开关。
|
||||
|
||||
## 生成项目接入
|
||||
|
||||
```sh
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.14.1-preview
|
||||
go-omni new --database mysql --module example.com/billing ./billing
|
||||
```
|
||||
|
||||
按 [MySQL 配置说明](../mysql/README.md) 设置连接信息、TLS 和池大小。在开发者维护的 `internal/svc/serviceContext.go` 中增加 `ORM *gorm.DB` 字段,并填写现有 Init:
|
||||
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"git.yuntuo.hk/k2safe/go-omni/database/gormx"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 向现有 ServiceContext 添加 ORM *gorm.DB 字段。
|
||||
// 保留 Runtime/Config/Logger/DB 等现有字段。
|
||||
func (c *ServiceContext) Init(ctx context.Context) error {
|
||||
orm, err := gormx.MySQL(ctx, c.DB, gormx.WithLogger(c.Logger))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.ORM = orm
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
执行 `go mod tidy`。main、runtimeGen、Handler 和路由无需修改;重新生成保留 svc 的字段与 Init。已有项目先 `go get git.yuntuo.hk/k2safe/go-omni@v0.14.1-preview`,同样接入;v0.14.0 生成项目的目录和生命周期契约不变。
|
||||
|
||||
Repository 接收 `*gorm.DB`,查询使用请求 context:
|
||||
|
||||
```go
|
||||
err := repo.db.WithContext(ctx).Where("id = ?", id).First(&item).Error
|
||||
|
||||
err = repo.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 同一事务内始终使用 tx。
|
||||
return tx.Create(&item).Error
|
||||
})
|
||||
```
|
||||
|
||||
现有模型、Hooks、Scopes、事务、命名策略和错误处理继续由项目维护。共享连接池不会把分别发起的 SQL/GORM 事务自动合并;不要在一个事务内切回根 ORM 或 c.DB 执行本应属于该事务的写入。
|
||||
|
||||
## 连接池与失败处理
|
||||
|
||||
- MySQL 配置、TLS、字符集、会话参数、池大小及健康检查由底层 `database/mysql` 管理,适配器不再设置另一套 DSN 或池参数。
|
||||
- GORM 方言的版本协商继续执行,自动 Ping 关闭以复用已完成的池初始化。初始化查询使用传入 context,并受池的 PingTimeout 限制;可用 `WithInitTimeout(3*time.Second)` 单独指定。
|
||||
- 返回成功后不保留启动 context,每次查询用 `WithContext(ctx)`。查询取消、错误、事务提交/回滚保持 GORM 原生语义。
|
||||
- 对方言初始化失败时 GORM 自动关闭连接池的路径提供保护;错误仍保留 errors.Is/As,原池继续归调用方管理。生成项目的 Init 返回错误时,再由 Runtime 统一回滚该项目资源。
|
||||
- 服务排空后由 go-omni 关闭原池;`orm.DB()` 返回的仍是那一个池。不要给它重复登记 Close,也不要让 Repository 关闭它。
|
||||
|
||||
适配器自身不创建后台 worker、不自动建表、不执行 AutoMigrate、不改写默认事务或全表更新保护,也不替业务重试写入。
|
||||
|
||||
## SQL 日志
|
||||
|
||||
默认借用当前 context 的 logx,也可显式 `WithLogger(c.Logger)`。每个 ORM 可以使用独立日志实例;不会改动进程全局 Logger。
|
||||
|
||||
```go
|
||||
sqlLog := gormx.DefaultLogConfig()
|
||||
sqlLog.Level = "info" // silent / error / warn / info
|
||||
sqlLog.SlowThreshold = 300*time.Millisecond
|
||||
|
||||
orm, err := gormx.MySQL(ctx, c.DB,
|
||||
gormx.WithLogger(c.Logger),
|
||||
gormx.WithLogConfig(sqlLog),
|
||||
gormx.WithConfig(gorm.Config{
|
||||
TranslateError: true,
|
||||
// NamingStrategy 等原生选项可按项目添加。
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
默认记录错误和超过 200 ms 的慢查询,忽略 ErrRecordNotFound 的错误事件;`SlowThreshold: 0` 关闭慢查询事件。事件为 `gorm.query`、`gorm.slowQuery`、`gorm.queryFailed`,携带 durationMs、rowsAffected、sql 和 context 中的 requestId/traceId。GORM 的 `Debug()`/`LogMode` 使用副本,不改变其他请求的日志级别;最终输出还受 logx 自身级别控制。
|
||||
|
||||
默认不插入绑定参数、不记录驱动完整错误文案,只记录错误类别;SQL 正文最多保留 4096 字节,截断后附加省略号。明确开启 `IncludeParameters` / `IncludeErrorDetails` 才输出这些内容。日志不会解析和脱敏直接写在原始 SQL 里的字面量,查询仍应使用占位符。仅需要日志适配的既有 GORM 实例,也可使用 `NewLogger(logger, config)` 返回的原生 `logger.Interface`。
|
||||
|
||||
`WithConfig` 中若显式设置 `gorm.Config.Logger`,则使用项目自己的 Logger,该 Logger 自行负责脱敏和上下文。配置和 Plugins/ClauseBuilders 容器会复制;用户自定义插件、NamingStrategy 等对象仍由用户保证并发安全。
|
||||
|
||||
## 其他方言与高级配置
|
||||
|
||||
MySQL 便捷入口封装了下面的通用入口:
|
||||
|
||||
```go
|
||||
orm, err := gormx.Open(ctx, pool,
|
||||
func(conn gorm.ConnPool) gorm.Dialector {
|
||||
return mysql.New(mysql.Config{Conn: conn})
|
||||
},
|
||||
gormx.WithLogger(logger),
|
||||
)
|
||||
```
|
||||
|
||||
其他方言可由项目提供相同形式的工厂,必须使用传入的 Conn,不能创建或替换连接池。本版真实数据库验收覆盖 MySQL 8.4,不代表其他数据库已经联调。
|
||||
|
||||
不能通过 WithConfig 的 ConnPool/Dialector 覆盖此约定。初始化期间不暴露裸 sql.DB;需要它的自定义插件应在 Open 成功后显式注册。创建额外池或替换 ConnPool 的路由插件不属于本适配器的一池所有权范围。
|
||||
|
||||
PrepareStmt 默认关闭;若显式开启,保留 GORM 的缓存行为,容量、TTL 和缓存清理由项目按 GORM 接口管理。当前固定的 GORM 版本,PreparedStmtDB.Close 只清理语句,不提供停止其缓存清理 goroutine 的公开接口;因此应在服务初始化时创建一次 ORM,避免按请求反复创建。适配器不修改第三方缓存实现,也不宣称解决其生命周期限制。
|
||||
|
||||
参考:[GORM 现有连接池](https://gorm.io/docs/connecting_to_the_database.html#Existing-database-connection)、[GORM Logger](https://gorm.io/docs/logger.html)。本版验收见 [GORM 验收报告](../../reports/gormAcceptance.md)。
|
||||
121
database/gormx/gorm.go
Normal file
121
database/gormx/gorm.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// Package gormx optionally attaches GORM to a pool owned by GoOmni. It never
|
||||
// creates a pool, changes pool limits, migrates tables or closes the owner.
|
||||
package gormx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/database"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
gormmysql "gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DialectorFactory must use the supplied connection pool. It must not create a
|
||||
// pool or retain/use the ORM before Open succeeds. For example, return
|
||||
// mysql.New(mysql.Config{Conn: conn}). Other dialects can use the same contract.
|
||||
type DialectorFactory func(conn gorm.ConnPool) gorm.Dialector
|
||||
|
||||
type options struct {
|
||||
config gorm.Config
|
||||
logger *logx.Logger
|
||||
logConfig LogConfig
|
||||
timeout time.Duration
|
||||
}
|
||||
type Option func(*options)
|
||||
|
||||
// WithConfig preserves native GORM behavior, including naming, hooks, prepared
|
||||
// statements and transactions. ConnPool/Dialector are supplied by this adapter;
|
||||
// automatic Ping is disabled because the owning pool already passed startup Ping.
|
||||
// A custom Config.Logger takes precedence over the logx adapter.
|
||||
func WithConfig(config gorm.Config) Option { return func(o *options) { o.config = config } }
|
||||
func WithLogger(logger *logx.Logger) Option { return func(o *options) { o.logger = logger } }
|
||||
func WithLogConfig(config LogConfig) Option { return func(o *options) { o.logConfig = config } }
|
||||
func WithInitTimeout(timeout time.Duration) Option { return func(o *options) { o.timeout = timeout } }
|
||||
|
||||
// Error exposes a safe initialization summary while preserving errors.Is/As.
|
||||
type Error struct{ cause error }
|
||||
|
||||
func (e *Error) Error() string { return "gormx: initialization failed" }
|
||||
func (e *Error) Unwrap() error { return e.cause }
|
||||
|
||||
// MySQL attaches GORM's MySQL dialect, including version negotiation, to the
|
||||
// existing pool. Configure TLS, timeouts and connection limits on that pool.
|
||||
func MySQL(ctx context.Context, pool *database.DB, opts ...Option) (*gorm.DB, error) {
|
||||
return Open(ctx, pool, func(conn gorm.ConnPool) gorm.Dialector {
|
||||
return gormmysql.New(gormmysql.Config{Conn: conn})
|
||||
}, opts...)
|
||||
}
|
||||
|
||||
// Open borrows pool. On failure (including dialect initialization failure),
|
||||
// the original pool remains owned by the caller. No ORM Close is required.
|
||||
// The returned ORM does not retain the startup context: each request should use
|
||||
// WithContext(ctx). Never close the sql.DB returned by orm.DB() independently.
|
||||
func Open(ctx context.Context, pool *database.DB, factory DialectorFactory, opts ...Option) (*gorm.DB, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, &Error{cause: err}
|
||||
}
|
||||
if pool == nil || pool.DB == nil || factory == nil {
|
||||
return nil, errors.New("gormx: pool and dialector factory are required")
|
||||
}
|
||||
timeout := pool.PoolConfig().PingTimeout
|
||||
if timeout == 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
o := options{timeout: timeout, logConfig: DefaultLogConfig(), logger: logx.FromContext(ctx)}
|
||||
for _, option := range opts {
|
||||
if option != nil {
|
||||
option(&o)
|
||||
}
|
||||
}
|
||||
if o.timeout <= 0 {
|
||||
return nil, errors.New("gormx: initialization timeout must be positive")
|
||||
}
|
||||
if o.config.ConnPool != nil || o.config.Dialector != nil {
|
||||
return nil, errors.New("gormx: configure the existing pool through the dialector factory")
|
||||
}
|
||||
logger, err := NewLogger(o.logger, o.logConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config := o.config
|
||||
config.Plugins = maps.Clone(config.Plugins)
|
||||
config.ClauseBuilders = maps.Clone(config.ClauseBuilders)
|
||||
if config.Logger == nil {
|
||||
config.Logger = logger
|
||||
}
|
||||
config.DisableAutomaticPing = true
|
||||
startup, cancel := context.WithTimeout(ctx, o.timeout)
|
||||
defer cancel()
|
||||
borrowed := &borrowedPool{db: pool.DB}
|
||||
borrowed.startup.Store(&startupContext{startup})
|
||||
config.ConnPool = borrowed
|
||||
dialect := factory(borrowed)
|
||||
if dialect == nil || (reflect.ValueOf(dialect).Kind() == reflect.Pointer && reflect.ValueOf(dialect).IsNil()) {
|
||||
return nil, errors.New("gormx: dialector factory returned nil")
|
||||
}
|
||||
orm, err := gorm.Open(dialect, &config)
|
||||
if err != nil {
|
||||
return nil, &Error{cause: err}
|
||||
}
|
||||
if err = startup.Err(); err != nil {
|
||||
return nil, &Error{cause: err}
|
||||
}
|
||||
// Prepared statements are GORM's own wrapper over the very same pool.
|
||||
conn := orm.ConnPool
|
||||
if prepared, ok := conn.(*gorm.PreparedStmtDB); ok {
|
||||
conn = prepared.ConnPool
|
||||
}
|
||||
if actual, ok := conn.(*borrowedPool); !ok || actual != borrowed {
|
||||
return nil, errors.New("gormx: dialector or plugin replaced the supplied pool")
|
||||
}
|
||||
borrowed.startup.Store(nil)
|
||||
return orm, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user