Compare commits

..

12 Commits

193 changed files with 14516 additions and 1817 deletions

22
API.md
View File

@@ -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 运行时依赖。
@@ -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.13.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

26
BUSINESS_DEVELOPMENT.md Normal file
View 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 业务,不连接既有业务服务器。真实基础设施如需验证,只使用本轮隔离资源。验收报告明确区分单元测试、生成消费者测试与真实服务测试。

296
DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,296 @@
# 通用业务开发指南(v0.18.0-preview)
从 [README](README.md) 创建服务后,业务主要维护 `.api`、配置、`svc.Init`、Logic、Repository 和项目中间件。本页说明通用接入能力;字段、错误码、权限、表结构和事务边界都由项目定义。
本版新增的公共字段组合、可选结构映射、自动中间件工厂和 Repository 开关,见 [生成能力指南](GENERATION.md)。
## 返回数组、可空对象和 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)
return nil
}
```
每个资源初始化成功就登记清理;后续初始化失败自动逆序回滚。正常退出先排空 HTTP 请求,再执行后台组件的 Stop/Drain 阶段,确认收尾后逆序关闭依赖,最后关闭日志。关闭幂等,单个清理函数 panic/返回错误不会跳过其他清理;初始化 panic 清理后继续传播。后台组件按 [分阶段关闭](lifecycle/README.md#后台组件分阶段关闭) 登记;无法确认收尾完成时保留共享依赖并异常退出,不能把取消等同于任务已经结束。
新项目使用中间件工厂:项目维护 `middleware.Dependencies` 小接口与 `New<Name>(deps) (httpx.Middleware, error)`,svc 提供对应访问器。生成的 runtime 在 Init 成功后自动调用工厂并注册,不再按名字替换 map;工厂失败会释放已经登记的依赖。示例与现有项目迁移步骤见 [中间件工厂](GENERATION.md#中间件工厂自动注册)。
v0.16 项目及 `omni.gen.json` 中选择 `middleware: handlers` 的项目继续使用原有 `Defaults()`,可以在 Init 设置 `c.Middlewares["RequireAccess"]`。更新时不会重写这部分业务代码;只有显式切换到 factories 后才使用新流程。
生成入口为每个 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.18.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 及更早的定制结构不能被精确自动合并,使用同一预览逐项迁移并编译验证。

279
GENERATION.md Normal file
View File

@@ -0,0 +1,279 @@
# 公共字段、结构映射与生成选项
本页对应 `v0.18.0-preview`。CLI 与框架依赖固定到同一版本:
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.18.0-preview
# 已有业务项目内执行:
go get git.yuntuo.hk/k2safe/go-omni@v0.18.0-preview
```
私有仓库访问设置见 [安装说明](README.md#准备环境与安装)。升级不会覆盖业务文件;已有中间件保持 handlers 模式,切换工厂模式按本页迁移步骤完成。
## 一份项目生成配置
新项目根目录生成开发者维护的 `omni.gen.json`:
```json
{
"repository": "none",
"middleware": "factories"
}
```
该文件与 `.api` 一起提交。每次 `api generate`、`--check`、`--dry-run` 和 `workspace generate/check` 都会读取;`api validate/export/format` 只处理契约,不加载业务 Go 类型。
- `repository: none` 是默认值,不创建空 Repository 文件。`group` 按 `.api` 的 group 补齐 `internal/repository/<group>/repository.go`;文件始终属于业务。切回 none 后不再补骨架,已有文件不删除、不覆盖。实际 SQL、ORM 和仓储划分由项目决定。
- `middleware: factories` 是新项目默认值,按下文自动初始化。`handlers` 保留原有 `Defaults()` 与 `func(next Handler) Handler` 接口,供既有项目逐步迁移。
- `mappings` 可省略;只有明确列出的类型对才生成映射。未启用时不会运行 Go 类型加载命令,也不增加运行时依赖。
- 升级首次遇到没有配置的旧 manifest 时,补一份 `middleware: handlers` 的配置,保留原有手写注册;Repository 默认 none。生成配置建立后不能直接删除,否则生成失败,避免误关闭映射或改变中间件模式。
### 按模块拆分映射(v0.18 新增)
主配置可以保留少量内联 `mappings`,也可以只列出文件:
```json
{
"repository": "none",
"middleware": "factories",
"mappingFiles": ["mappings/requests.json", "mappings/responses.json"]
}
```
每个子文件只包含 `{"mappings": [...]}`,其中每条映射的写法与原来一致。先加载主配置内联定义,再按文件列表顺序加载;所有定义合并后统一校验,可以跨文件、向前引用 `mapping`。输出按映射名排序,文件拆分不改变函数名或语义。
- 路径相对项目根目录,必须是明确列出的 `.json` 文件。不展开通配符,不允许越界路径、符号链接、重复文件或多层导入;子文件不能覆盖 repository/middleware 等项目选项。
- 重名映射、指针辅助函数名冲突、未知映射与循环引用明确报错;文件之间不能用覆盖顺序隐藏冲突。重复定义会指出两个来源文件,字段生成错误也带来源文件。
- 文件均由项目维护,重新生成不改写它们。原来的单文件 `mappings` 继续有效。迁移时将完整条目移到子文件并删除原位置,不能复制后保留两份同名定义。
- 最多 64 个子文件、单文件 1 MiB、全部配置 4 MiB;合计仍最多 128 个映射。主配置和子文件一起提交 Git。
- `api generate`、`--check`、`--dry-run` 与 `workspace generate/check` 使用同一加载逻辑。缺少子文件或不完整配置立即失败,check/预览不写入;生成期间重新核对所有配置输入,发生变化时要求重试。
## 公共字段组合
```text
type EmployeeFields {
// 员工姓名。
Name string `json:"name" binding:"max=80"`
Salary Money `json:"salary"`
}
type EnterpriseRequest {
EmployeeFields
}
type AdminRequest {
EmployeeFields
CompanyID string `path:"companyId"`
}
```
`Money` 由项目按既有 scalar 语法声明。组合行只有已声明的对象类型名,不加指针或字段 tag。公共对象可以通过已有相对 import 或声明过的共享根 `@common/...` 引入。
生成器展开为普通 Go 字段。JSON 保持平铺;字段的绑定来源、校验、注释、可空性和 OpenAPI 使用同一份展开结果。不会在 JSON 中增加 `EmployeeFields` 一层。重复 Go 字段、重复来源字段名、循环组合、未知对象和过深展开均失败;即使重复字段类型相同,也不能隐式覆盖。要改变某个字段的规则,应拆出更小的公共组合。
允许多层组合,上限 32 层、单类型 4096 字段、全契约展开 65536 字段。Go 匿名指针嵌入并非本功能;生成 DTO 中不会使用匿名嵌入。
## 可选结构映射
`.api` 生成 DTO,业务独立维护输入/结果类型。项目明确选择需要自动赋值的类型对;不从数据库反向生成公开接口,不自动推断反向映射。
假设 `types.AdminRequest` 有 `Name string`、`Salary amount.Money`、`CompanyID string`,业务的 `domain.EmployeeInput` 有 `EmployeeName string` 和同类型 `Salary`:
```json
{
"repository": "none",
"middleware": "factories",
"mappings": [
{
"name": "ToEmployeeInput",
"source": {"package": "example.com/payroll/internal/types", "type": "AdminRequest"},
"target": {"package": "example.com/payroll/internal/domain", "type": "EmployeeInput"},
"fields": {
"EmployeeName": {"source": "Name"}
},
"ignoreSource": {
"CompanyID": "由 Logic 单独做企业范围授权,不属于请求体输入"
}
}
]
}
```
执行 `go-omni api generate` 后,产生独立的生成文件 `internal/mapping/mappingsGen.go`:
```go
func ToEmployeeInput(source types.AdminRequest) (domain.EmployeeInput, error) {
var result domain.EmployeeInput
result.EmployeeName = source.Name
result.Salary = source.Salary
return result, nil
}
```
包别名由生成器决定。业务调用 `mapping.ToEmployeeInput(*req)`;反向响应映射按另一条 source/target 规则声明。两个类型完全相同时通常无需映射。
### 类型与覆盖检查
自动赋值要求同名且 **Go 类型一致**(类型别名按 Go 类型系统处理),字段名使用 Go 名称,不按 JSON 标签猜对应关系。int32 → int64、`*Money` → Money、数值 ID → string 都必须显式转换。不会暗中补零、解引用或执行数字强转。
每个源字段必须被使用或出现在 `ignoreSource`;每个目标字段必须被赋值或出现在 `ignoreTarget`。忽略规则必须给出非空原因,目标忽略明确意味着保留零值;规则引用不存在的字段也会失败。新字段若无法自动匹配,会在生成阶段报错。两边同时添加的同名同类型字段会自动加入生成代码,`--check` 检出未重新生成的差异。
根类型是项目可导入的具名结构;支持值类型匿名嵌入,规则可用 `Employee.EmployeeName` 这样的实际 Go 字段路径。同名提升歧义、未导出字段和匿名指针嵌入拒绝处理。普通具名嵌套字段使用独立映射或转换函数,不猜测内部结构。泛型声明不能直接作为根;具体字段类型按 Go 类型系统检查。
配置变化、遗漏映射、类型错误会在写入前失败。映射生成与其他生成产物使用同一套文件所有权机制;手改映射生成文件会被拒绝覆盖。
### 显式转换函数
```json
"fields": {
"Salary": {
"source": "Salary",
"convert": {"package": "example.com/payroll/internal/convert", "name": "SalaryValue"}
}
}
```
支持这两种精确签名:
```go
func SalaryValue(source *amount.Money) amount.Money
func SalaryValue(source *amount.Money) (amount.Money, error)
```
项目选择一种实现。nil、默认值、金额精度、日期格式和错误码由函数负责;若两边已经是同一个 Money 类型,无需再次解析或序列化。转换函数应仅负责值转换,不执行 SQL 或改变授权范围。
错误通过 `%w` 加上映射名/目标字段路径,保留 `errors.Is/As` 和框架错误链;返回零目标值,不交付部分完成的结果。包装不主动添加源值或转换后的值。错误是否公开仍由项目错误目录和响应策略决定。
### 嵌套对象、数组与引用
`mapping` 指向同配置中的另一条映射,`elements` 明确要求逐项映射:
```json
"fields": {
"Summary": {"source": "Summary", "mapping": "ToSummary"},
"Items": {"source": "Items", "mapping": "ToItem", "elements": true}
}
```
`mapping` 与 `convert` 互斥。`elements` 可以配合其中任意一种,也可以对类型相同的元素直接赋值;支持 slice→slice、等长 array→array,禁止隐式互转。nil slice 保持 nil,非 nil 空 slice 保持非 nil,逐项失败的错误包含索引,循环映射声明被拒绝。
未选择 elements 的同类型指针、map、slice 使用普通 Go 赋值,**共享引用**,不做隐式深拷贝。elements 会分配新的目标 slice,但元素内的引用仍遵循其映射规则。需要深拷贝时显式提供转换函数。
映射不保存原 JSON 的字段存在信息。普通 `*T` 在绑定后可能已把缺失和 null 合并为 nil;无法在转换阶段恢复区分。有三态需要的项目必须先使用能携带该信息的输入类型及绑定扩展;没有声明三态时,保持当前输入类型的语义。完整表单更新和局部更新由业务决定,框架不把 nil 自动解释成“保留旧值”。
### 可空嵌套映射与显式 nil 策略(v0.18 新增)
假设已有 `ToChild` 定义 `types.Child → domain.Child`。父对象的字段规则可以直接复用它:
```json
"fields": {
"Child": {"source": "Child", "mapping": "ToChild"},
"Children": {"source": "Children", "mapping": "ToChild", "elements": true}
}
```
| 源字段 → 目标字段 | 明确选择 mapping 后的行为 |
| --- | --- |
| `S → T` | 调用原值映射 |
| `*S → *T` | 默认保留 nil;非 nil 调用值映射,并分配新的目标对象;也可配置 `nil: "error"` 拒绝 nil |
| `S → *T` | 调用值映射,成功后返回目标对象指针 |
| `*S → T` | 必须选择 `nil: "zero"` 或 `nil: "error"`,不猜默认值 |
`nil: "preserve"` 只适用于可空源和可空目标。`nil: "zero"` 只适用于可空源和非空目标:nil 直接产生目标 Go 零值,**不调用子映射或转换函数**。`nil: "error"` 在 nil 时返回带映射/字段路径的普通转换错误,HTTP 公开响应仍由项目的错误处理策略决定;需要领域错误码时使用项目 converter。
同一规则适用于显式 `elements: true` 下的 slice/等长数组元素,包括 `[]*S → []*T`。nil slice、非 nil 空 slice、nil 元素分别保留;元素失败保留索引与内部错误链,整个映射返回零目标值,不交付部分结果。只支持这里列出的单层指针,不自动处理多层指针或递归对象图。
普通同类型指针字段未指定 mapping 时仍直接赋值、共享引用;上述显式映射会创建目标子对象,但不承诺对其中所有引用深拷贝。业务不可变快照继续由项目设计。
简单的 `*T → T` 也可以单独指定 nil 策略,减少重复解引用函数:
```json
"fields": {
"Title": {"source": "Title", "nil": "zero"},
"Count": {"source": "Count", "nil": "error"}
}
```
这里必须是完全相同的 Go 元素类型,`*int32 → int64`、枚举 → string、数字编号 → string 仍需显式 converter。非 nil 的零值正常保留。`convert` 与 `nil` 不能同时设置,现有 converter 继续完整控制自己的空值、格式和错误语义。
### 可选指针返回辅助(v0.18 新增)
在映射条目上增加 `"pointerResult": true`,生成器保留 `ToResponse(source S) (T, error)`,另加:
```go
func ToResponsePtr(source S) (*T, error)
```
Logic 在业务调用成功后可直接写 `return mapping.ToResponsePtr(result)`。转换失败时返回 `nil, err`,错误链不变;成功才返回非 nil 指针。业务调用本身的错误仍由 Logic 处理,辅助函数不执行任何业务操作。
辅助函数名称固定为映射名加 `Ptr`,与其他映射/辅助函数同名会在生成前报错。输入仍是原来的值类型 S,不用 nil 输入表达业务错误;关闭该选项会移除辅助函数,使用它的业务代码需同时调整。
### 生成顺序与 CI
1. 先从 `.api` 生成项目,添加业务结构、scalar 和转换函数,执行 `go mod tidy`。
2. 在 `omni.gen.json` 列出映射,执行 `go-omni api generate`。
3. Logic 调用生成函数。后续改字段后重复生成,不修改 `*Gen.go`。
4. CI 同时运行 `go-omni api generate --check` 与 `go test ./...`。
启用映射后,CLI 通过 `go list -deps -export` 加载明确声明的包与依赖,按当前构建环境做类型检查,不执行业务代码、init 或转换函数。采用待生成 DTO 的临时 overlay,避免“新增字段还看旧 DTO”。go.mod/go.sum 的副本及 overlay 都在临时目录,check 不修改项目文件;Go 自己的构建/模块缓存可能更新。使用当前 GOOS/GOARCH/GOFLAGS、`GOWORK=off`;依赖用 go.mod 与 replace 表达,生成超时为 90 秒。
类型包/转换函数包不能依赖输出包 `internal/mapping`;把转换函数放在独立包中,避免导入环。若包本身无法编译,先运行 `go list -deps -export <相关包>` 排查;CLI 不回显可能含私有仓库凭据的 Go 下载输出。纯值字段转换只生成标准 Go 代码,不引入运行时反射搬运、JSON 往返、ORM 或队列依赖。
## 中间件工厂自动注册
`.api` 沿用 `middleware: RequireAccess` 或 auth 对应的中间件声明。新项目会生成可编辑的 `internal/middleware/requireAccessFactory.go`,只需实现一次:
```go
// internal/middleware/dependencies.go,由项目维护。
type AccessChecker interface {
CheckAccess(context.Context, string) error
}
type Dependencies interface {
AccessChecker() AccessChecker
}
// internal/middleware/requireAccessFactory.go,由项目维护。
func NewRequireAccess(deps Dependencies) (httpx.Middleware, error) {
checker := deps.AccessChecker()
if checker == nil { return nil, errors.New("access checker is required") }
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)
}
}, nil
}
// internal/svc/access.go,由项目维护;ServiceContext.Init 初始化 c.Auth。
func (c *ServiceContext) AccessChecker() middleware.AccessChecker { return c.Auth }
```
编译器检查 ServiceContext 是否实现依赖接口,middleware 不导入 svc。多个工厂可以共用这些小接口。所需 imports 和具体鉴权服务仍属于项目。
启动顺序为:框架基础依赖 → `svc.Init` → 生成的 `middleware.New(c)` 调用各具名工厂 → 创建 App/注册路由 → 监听。中间件实例创建一次,由请求并发复用;不要把单次请求信息存在实例字段里。工厂之间没有初始化顺序依赖;路由执行顺序仍以 `.api` 声明为准。
工厂返回错误或 nil 中间件均阻止监听;错误链保留,已登记资源按现有生命周期清理。工厂 panic 也先清理依赖再传播。资源初始化和清理登记放在 svc.Init,工厂借用资源;HTTP 请求排空之前不会关闭这些依赖。
factory 模式不再在 Init 写 `Middlewares["..."]`;残留非空手写注册表会明确报错,防止生成注册静默覆盖它。未实现的工厂让启动失败;未实现的 Logic 仍返回 501。
### 既有项目迁移
1. 首次更新保持 `middleware: handlers`,现有 hook 和手写 Configure 不变。
2. 改为 factories 并重新生成,会补 `dependencies.go` 和 `*Factory.go`,保留原来的手写文件。
3. 将旧实现迁入工厂,提供 Dependencies 的访问器;移除 Init 中按名字覆盖 map 的代码。
4. 旧函数不再需要时由项目删除。若新工厂符号与旧文件中的同名函数冲突,整理旧文件;生成器不覆盖业务代码。
5. `generate --check` 与 `go test ./...` 检查后再运行。工厂没有全部实现前,服务不会启动。
## 文件维护边界
| 项目维护 | 生成器持续维护 |
| --- | --- |
| `.api` 公共字段与接口 | 展开后的 DTO、Handler、OpenAPI |
| `omni.gen.json` 的映射与选项 | `internal/mapping/mappingsGen.go` |
| Go 业务类型、scalar、转换函数 | 编译期可检查的赋值与调用代码 |
| middleware Dependencies、具名工厂、svc 访问器 | 中间件 registry、启动调用 |
| Logic、按需 Repository、配置、svc 初始化/清理登记 | main、runtime、路由接入 |
回归样例位于 `cmd/go-omni/apigen/testdata/composition`,为独立三端服务,不导入任何 eTax 代码。

View File

@@ -1,6 +1,6 @@
# 生成项目与文件维护边界
v0.13.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
v0.18.0-preview 提供受管理的生成项目。安装同版远程 CLI,生成的项目直接引用该版本,无需本地 replace。根目录入口支持 `go run .` 和 `go build .`;框架本身仍然是可直接引用的库。安装和私有仓库访问见 [README](README.md#准备环境与安装)。
## 创建和日常开发
@@ -38,6 +38,8 @@ ERROR [logicSignature] internal/logic/greet/greet.go:12 (GET /api/greet/:name):
这是轻量源码分析,不运行项目 init、不执行 Go 命令、不下载依赖。项目自定义类型别名、嵌入方法或 build tags 等无法可靠判断时,会给 REVIEW 并交给 Go 编译器验证;不把这些情况强行判为签名错误。它不检查方法体是否引用已删除字段,也不证明业务逻辑正确。CI 中应同时执行 `go-omni api generate --check` 和 `go test ./...`,按项目实际构建参数测试。当前版本首次接手缺少快照的 manifest v2 时,会明确提示建立基线,不猜测旧契约;Logic 签名检查仍会执行。
本版新增生成选项、公共字段组合和映射,详见 [生成能力指南](GENERATION.md)。
## 哪些文件由用户维护
| 文件 | 首次生成后由项目维护 |
@@ -47,18 +49,19 @@ 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` | 数据访问,按需实现 |
| `internal/middleware/<name>.go` | 项目鉴权/权限等具名中间件实现 |
| `internal/repository/<group>/*.go` | 数据访问;默认不补空骨架,可选 group 模式 |
| `internal/middleware/*Factory.go`、`dependencies.go` | 中间件工厂与依赖小接口,svc 实现访问器 |
| `omni.gen.json`、映射转换函数(支持 `mappingFiles` 引用子文件) | 生成选项、显式类型对与差异规则 |
| 项目 Go 错误包 | 自行维护 Catalog 和常量,可跨服务共用 |
| 自定义标量文件、`*Bind.go`、`*Manual.go` | 只有显式选择相应扩展时才生成/使用 |
| 项目测试、迁移、部署脚本、go.mod/go.sum | 项目所有,生成器不重置其内容 |
以上骨架只补缺失文件,既有内容按字节保留。删除已废弃接口后,对应业务文件也不会被自动删除;由开发者整理编译器指出的失效类型或签名。
生成器持续管理:`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`、可选映射 `internal/mapping/mappingsGen.go`、中间件 `registryGen.go`、`docs/openapi.json`、`.goOmni/apiManifest.json`。如果在 `.api` 里选择声明错误,还会管理 `internal/apierrors/errorsGen.go`。文件标注 DO NOT EDIT;手改会被拒绝覆盖,需先移走自定义内容。
Logic 实例在注册时创建并由请求并发复用;请求数据用方法参数和局部变量传递,不存入共享 Logic/svc 字段。
@@ -66,16 +69,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 +88,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 +96,7 @@ 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 无法保证在超时前结束。
## 依赖主动申请退出
@@ -106,7 +109,7 @@ c.RequestShutdown(err)
这只是通知,立即返回,不等待 HTTP 排空,也不直接关闭数据库。生成入口停止接收新请求,等待在途请求完成;超过 `shutdownTimeout` 后取消请求并关闭连接,再执行 svc.Close,最后关闭日志。非空故障原因保留在返回错误链中,进程退出码为 1;`RequestShutdown(nil)` 或正常 SIGINT/SIGTERM 在没有其他故障时退出码为 0。
多个依赖可并发通知,关闭流程仅执行一次,首个非空故障原因保留。正常信号已开始排空时收到的故障也不会被清理动作覆盖。初始化期间的通知同样有效,不依赖 HTTP 监听已经启动。业务应在 Close/OnClose 中停止并等待自己启动的监控任务,再结束资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。
多个依赖可并发通知,关闭流程仅执行一次,首个非空故障原因保留。正常信号已开始排空时收到的故障也不会被清理动作覆盖。初始化期间的通知同样有效,不依赖 HTTP 监听已经启动。后台监控任务应通过 OnStop/OnDrain 停止并等待,再进入资源清理;关闭完成以后才报告的故障无法参与已经结束的进程结果。
`Lifecycle *lifecycle.Controller` 是 svc 持有的通用控制器,初始化时创建,生成入口使用它的 Context。该 Context 表示服务正在停止,不代表数据库已经关闭。不能把它当作在途请求的 Context,也不要在它取消时立刻关闭请求仍依赖的资源。发号器失去租约后拒绝发号等业务安全规则仍由项目实现。
@@ -140,16 +143,11 @@ server:
关闭或改名后,旧路径不再由框架保留,项目可以在 `.api` 自己声明。启用的内置 GET 路由与业务路由相同或被业务参数/通配路径覆盖时,在创建 App、开始监听之前报错;不会覆盖业务路由。内置接口仍经过全局中间件;项目在鉴权中间件中明确决定这些路径的访问规则,框架不自动绕过鉴权。
## 从 v0.12 生成项目升级
<a id="从-v012-生成项目升级"></a>
manifest v2 和现有目录保留。升级同版 CLI 与框架依赖后执行 `api generate`,生成器自动更新 main 和 Handler;`svc`、配置与业务文件仍按字节保留,所以需要在项目拥有的 svc 文件补入以下扩展:
## 从 v0.13 或更早版本升级
1. `serviceContext.go` 导入 `git.yuntuo.hk/k2safe/go-omni/lifecycle`,给 ServiceContext 增加 `Lifecycle *lifecycle.Controller`;创建 c 时用 `lifecycle.New(ctx)` 初始化,并在后续依赖初始化前设置 `ctx = c.Lifecycle.Context()`。
2. 增加 `func (c *ServiceContext) RequestShutdown(err error) { c.Lifecycle.RequestShutdown(err) }`。
3. 构造失败的 defer 在 `c.Close()` 后保留 `c.Lifecycle.Err()`;Close 的 `closeOnce.Do` 开头调用 `c.Lifecycle.RequestShutdown(nil)`,重复关闭仍由已有 once 保护。
4. 配置默认值若来自 `config.Default()`,自动具有默认端点配置;如需禁用或改路径,在 YAML 或项目 Config 中显式设置。手动拼 Config 的项目需要自行填写要启用的端点。
精确实现可对照 [示例 svc](examples/managedService/internal/svc/serviceContext.go) 和 [关闭逻辑](examples/managedService/internal/svc/close.go),也可在临时目录用新版 CLI 生成参考。不要覆盖原有数据库/客户端初始化;不需要手改 main、Handler 或引入另一套启动器。最后执行 `api generate --check`、`go test ./...`、`go build .`,并验证故障退出。
请按 [v0.14 定点迁移说明](DEVELOPMENT.md#v013-项目升级) 使用 `api upgrade`。原始模板可以自动升级;含业务修改的基础文件必须参考局部预览合并。旧 New/Close/OnClose 移到框架 Runtime,项目依赖初始化保留在 Init。不要继续向项目里复制生命周期锁和清理栈。
## App 默认响应格式
@@ -193,7 +191,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。
@@ -213,7 +211,7 @@ get /download (DownloadReq)
## 已有服务接入步骤
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.13.0-preview:
每个独立服务生成一个项目,module 名称、上层目录和共享业务包由项目选择。接入时固定 CLI 与框架版本为 v0.18.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 和提前返回的中间件一起验收。

View File

@@ -2,12 +2,24 @@
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Logic 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
**从这份 README 开始接入。** 当前版本 **`v0.13.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
**从这份 README 开始接入。** 当前版本 **`v0.18.0-preview`**,新生成项目采用根目录入口和 `config / svc / logic / handler`。可以固定版本开展业务接入;文件维护边界和已有服务迁移步骤见 [项目指南](PROJECT.md),版本变化与验收见 [发布说明](RELEASE.md)。
**v0.17 提供**:公共字段组合、可选结构映射、带依赖的中间件工厂、按需 Repository 骨架,使用与迁移见 [生成能力指南](GENERATION.md)。
**本版新增**:可空嵌套映射、映射配置按文件拆分、显式 nil 策略和可选指针返回辅助,见 [生成能力指南](GENERATION.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 +40,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.13.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.18.0-preview
export PATH="$(go env GOPATH)/bin:$PATH"
go-omni --help
```
@@ -69,21 +81,22 @@ hello/
├── api/service.api # 维护接口定义,也可 import 其他 .api
├── main.go # 生成器维护:启动、路由注册、优雅退出
├── etc/config.yaml # 项目配置
├── omni.gen.json # 项目维护的生成选项与可选映射
├── internal/
│ ├── config/ # 配置读取
│ ├── svc/ # 数据库、日志、外部客户端等依赖
│ ├── types/typesGen.go # 生成的请求与业务返回类型
│ ├── types/*Gen.go # 按 .api 文件生成 DTO
│ ├── handler/ # 生成的绑定、响应策略、路由及文档
│ ├── logic/greet/ # 在这里实现业务逻辑
│ ├── repository/greet/ # 在这里实现数据访问
│ └── middleware/ # 声明鉴权等中间件后生成对应 hook
│ ├── repository/ # 按需创建,默认不生成空骨架
│ └── middleware/ # 声明鉴权等中间件后生成工厂
├── docs/openapi.json # 生成的 OpenAPI 3.0.3
└── .goOmni/apiManifest.json # 生成文件所有权与校验信息,一起提交 Git
```
修改 `api/service.api` 后重新生成,业务写在 `internal/logic`,数据访问写在 `internal/repository`,依赖在 `internal/svc` 装配。声明业务错误后还会生成 `internal/apierrors/errorsGen.go`。
`api new` 的问候接口可以直接运行;自定义接口新生成的 Logic 和鉴权 hook 返回 **501**,需要实现后才可用。生成器不会根据 HTTP 字段猜数据库表或业务规则。
`api new` 的问候接口可以直接运行;自定义接口未实现的 Logic 返回 **501**。新项目的具名中间件工厂未实现时直接阻止启动;v0.16 的原有 handler 模式仍返回 501。生成器不会根据 HTTP 字段猜数据库表或业务规则。
### 从自己的接口定义生成
@@ -175,7 +188,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 +197,7 @@ go-omni new --api catalog.api \
在业务项目的 **go.mod 所在目录**执行:
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.13.0-preview
go get git.yuntuo.hk/k2safe/go-omni@v0.18.0-preview
```
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。希望入口和 HTTP 接入都交给生成器的项目,按 [已有服务接入步骤](PROJECT.md#已有服务接入步骤) 在新目录生成后迁入业务代码。
@@ -308,7 +321,7 @@ auth UserAuth (
)
```
生成器会创建 `internal/middleware/auth.go`,由项目实现凭据校验和身份注入。声明会生成路由绑定与 OpenAPI 的认证描述,**不会自动实现登录或授权规则**;未实现的 hook 返回 501。手写路由可使用 `auth.RequireAuth(authenticator)` 或自己的中间件。JWT、Session 和 Policy 按需选择。
新项目生成 `internal/middleware/authFactory.go`,由项目实现 `NewAuth(deps)`;生成器负责初始化和注册。v0.16 或 handler 模式继续使用 `internal/middleware/auth.go`。凭据校验和身份注入由项目实现。声明会生成路由绑定与 OpenAPI 的认证描述,**不会自动实现登录或授权规则**;未实现的工厂阻止启动,原有 hook 模式返回 501。手写路由可使用 `auth.RequireAuth(authenticator)` 或自己的中间件。JWT、Session 和 Policy 按需选择。
业务错误可以集中定义,业务只选择发生了哪个错误。**HTTP 状态由项目配置,业务失败可以返回 HTTP 200**;框架不根据 HTTP 状态单独判断成功与否。
@@ -382,7 +395,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 +422,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:
@@ -452,9 +465,9 @@ go-omni api generate --check
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#契约变更提示)。
`main.go`、`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Logic、Repository、中间件实现、配置和依赖装配属于项目,重复生成保留手写内容。契约变化会提示受影响的 Logic 文件和 DTO 字段,明确的签名错误会给出原签名与期望签名;生成后 `--check` 仍能检出未修复的问题。将 `.api`、`omni.gen.json`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 同时运行 `generate --check` 与 `go test ./...`。详见 [契约变更提示与检查边界](PROJECT.md#契约变更提示)。
CLI 与业务 module 固定使用 `v0.13.0-preview`。新布局不兼容旧 manifest;旧试验项目需在新目录生成并搬入业务文件,不能直接覆盖手写入口。新布局内部升级再执行上述生成与测试步骤,手写框架项目只需依赖升级及业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
CLI 与业务 module 固定使用 `v0.18.0-preview`。v0.14.x 项目升级依赖与 CLI 后重新生成,框架维护的 main/runtimeGen 自动更新;v0.13 及更早项目按 [升级预览](DEVELOPMENT.md#v013-项目升级) 处理,不直接覆盖手写入口或业务文件。升级后执行生成检查与业务回归。版本变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
## 按需深入

View File

@@ -1,4 +1,148 @@
# v0.13.0-preview
# v0.18.0-preview
补齐可空嵌套映射和按模块拆分配置,减少重复转换代码,业务空值规则仍由项目显式选择:
- 嵌套 mapping 支持 `*S → *T` 保留 nil、`S → *T`,以及指定 nil zero/error 后的 `*S → T`;同样适用于 slice/等长数组元素。错误保留字段路径、索引和内部错误链,失败不返回部分结果。
- `mappingFiles` 显式引用项目内的映射文件,支持跨文件向前引用;统一检查重名、循环、类型与字段覆盖。禁止多层导入、路径越界和隐式覆盖,生成期间核对全部配置输入。
- 同类型 `*T → T` 可显式配置 nil zero/error;现有 converter 保持完整控制,不默认放宽类型或混入领域规则。
- `pointerResult: true` 额外生成 `<Name>Ptr(S) (*T,error)`,原值函数保留;失败返回 nil 与原错误,辅助函数名冲突在生成前拒绝。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.18.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.18.0-preview
go-omni api generate
go-omni api generate --check
go test ./...
```
v0.17 的单文件 mappings、现有 converter 和业务文件继续保留。配置拆分时移动完整映射条目,主配置显式列出子文件;不保留重复定义。新能力均为可选生成配置,不增加运行时依赖。nil zero 表示 Go 零值并跳过子映射;业务必填、金额精度、编号格式和快照复制仍由项目负责。
使用与迁移见 [生成能力指南](GENERATION.md),回归和发布后验收见 [验收记录](reports/nullableMappingAcceptance.md)。本版仍为 preview。
---
# v0.17.0-preview(历史版本)
减少项目里的重复声明、字段赋值和中间件装配代码,业务规则继续由项目控制:
- `.api` 公共字段组合:保持 JSON 平铺,DTO、绑定、校验和 OpenAPI 同步;字段冲突、循环和超限明确报错。
- 可选结构映射:在 `omni.gen.json` 显式声明源与目标,生成编译期检查的普通 Go 赋值。不同字段名、金额、时间和指针转换由项目指定函数;遗漏字段与类型错误在写文件前失败。不使用 JSON 往返或运行时反射。
- 带依赖的中间件工厂:svc 初始化依赖后自动调用 `New<Name>(deps)` 并注册;错误、nil 或 panic 阻止启动,已登记资源按生命周期清理。
- Repository 骨架默认关闭,可按 group 启用;已有业务文件始终保留。生成选项与映射纳入重新生成和 `--check`。
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.17.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.17.0-preview
go-omni api generate
go-omni api generate --check
go test ./...
```
新项目默认使用 factories;已有项目首次升级保留 handlers 模式,需要显式迁移后再切换。映射按需开启,先准备业务类型、转换函数和依赖,再声明映射;普通引用赋值保留 Go 语义,缺失/null 语义由项目类型与转换函数定义。
使用方法、文件归属和升级步骤见 [生成能力指南](GENERATION.md),回归与发布验收见 [验收记录](reports/generationExperienceAcceptance.md)。本版仍为 preview;框架验收不等于业务项目完成迁移。
全量 44 个测试包的 race、vet、build 和实际进程验收通过;发布后的空缓存远程 CLI 与 7 组生成消费者回归也通过,同版依赖且无本地 replace。标签固定在 `8b588a24cd653dac77dd68f6777e0db367981f19`。
---
# 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 个操作展开引用后与原文档完全一致。空缓存远程 CLI / 固定版本消费者验收也已通过,没有本地 replace;标签固定在 `64e3bd6cfced225476d58e95037249cd0a4fe695`。详见 [验收记录](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 租约、数据库或后台任务等故障由项目判断,框架通过统一生命周期完成退出;不内置业务规则。
@@ -33,7 +177,7 @@ python3 scripts/verifyManagedProject.py --report performance/lifecycleEndpointsL
python3 scripts/verifyManagedRelease.py --version v0.13.0-preview --report performance/lifecycleEndpointsRemote.json
```
远程验收在发布后执行,报告单独提交,不改写发布标签。此版仍为 preview;具体业务的租约失效保护、鉴权与事务回归由接入项目验证。资源清理顺序不代表能强行结束忽略 context 取消的业务 goroutine;项目 Close 必须停止并等待自己启动的后台任务。
发布后的远程空缓存验收已通过,耗时 26.55 秒;[机器报告](performance/lifecycleEndpointsRemote.json) 确认 CLI 和生成项目使用同版远程 module,无本地 replace。报告单独提交,发布标签保持在代码提交 `f0991d3ff248e4cb32812c368f0f8ba28740a6c7`。此版仍为 preview;具体业务的租约失效保护、鉴权与事务回归由接入项目验证。资源清理顺序不代表能强行结束忽略 context 取消的业务 goroutine;项目 Close 必须停止并等待自己启动的后台任务。
---

View File

@@ -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)
- 修正脚手架固定版本引用,保留显式本地开发选项。

58
app/logIsolation_test.go Normal file
View 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")
}
}

View File

@@ -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())
}

View File

@@ -1,19 +1,27 @@
# go-omni CLI
v0.18 新增:可空嵌套映射、`mappingFiles` 配置拆分、显式 nil 策略和可选 `pointerResult`。原 v0.17 配置继续有效,见 [生成能力指南](../../GENERATION.md)。
v0.17 增加公共字段组合、可选结构映射、自动中间件工厂和按需 Repository。配置和迁移见 [生成能力指南](../../GENERATION.md)。
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.13.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.18.0-preview
go-omni new --module example.com/hello ./hello
# 从自己的契约生成;service.api 及其相对 import 文件需要在本地存在
go-omni new --api service.api --module example.com/example ./example
```
`new` 与 `api new` 统一生成契约项目;不再提供另一套 CRUD 启动目录。未实现的 Logic / 中间件骨架返回 501。进入项目执行 `go mod tidy`、`go run .`、`go build .`。
`new` 与 `api new` 统一生成契约项目;不再提供另一套 CRUD 启动目录。未实现的 Logic 返回 501;中间件工厂未实现时阻止启动,既有 handler 模式仍返回 501。进入项目执行 `go mod tidy`、`go run .`、`go build .`。
参数:`--database none|memory|mysql`(默认 none;memory 等同 none),`--storage none|local|s3|r2|oss`,`--log-output stdout|stderr|file`,`--log-format json|text`。基础设施按需生成,连接凭据由环境提供,不自动创建数据库、表或云资源。选择 MySQL 不会凭空生成业务表或 Repository 查询。
@@ -41,14 +49,14 @@ main、Gen.go、OpenAPI 和 manifest 由生成器维护;业务文件按字节
## 版本与迁移
CLI 与框架依赖固定使用 v0.13.0-preview。远程安装的 CLI 默认把自身版本写入 go.mod,不添加本地 replace;`--framework-version` 可显式指定已发布的兼容版本。仅在开发框架源码时使用本地替换:
CLI 与框架依赖固定使用 v0.18.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.13.0-preview 的依赖退出与端点配置能力,继续使用 manifest v2。v0.12 项目需按 [升级步骤](../../PROJECT.md#从-v012-生成项目升级) 补入 svc 生命周期字段和方法,生成器保留这些项目文件。不兼容旧目录,旧生成项目请在新目录重新生成后移入业务代码,不覆盖手写 main。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
当前模板与依赖以 v0.18.0-preview 为基线,继续使用 manifest v2;GORM 可选适配器从 v0.14.1-preview 提供。已有项目使用 [定点升级与预览](../../DEVELOPMENT.md#v013-项目升级):原始模板可自动替换,含业务修改的基础文件按预览合并;通用清理由 Runtime 负责。独立工具命令不会被删除。受管理项目通过 `.api` 模块导入增加业务,不使用旧 `add` 命令的手动路由装配。
## 数据库迁移命令

View File

@@ -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 {

View File

@@ -170,6 +170,7 @@ func TestRegenerationAndConflicts(t *testing.T) {
servicePath := filepath.Join(dir, "internal/logic/greet/greet.go")
service := readFile(t, servicePath) + "\n// user edit must survive\n"
writeFile(t, servicePath, service)
writeFile(t, filepath.Join(dir, "internal/repository/greet/repository.go"), "package greet\n")
for _, file := range []string{"etc/config.yaml", "internal/svc/serviceContext.go", "internal/repository/greet/repository.go"} {
body := readFile(t, filepath.Join(dir, file))
if strings.HasSuffix(file, ".go") {
@@ -301,6 +302,7 @@ func TestGeneratedProjectsBuildAndRun(t *testing.T) {
if starter {
_, err = New(o)
} else {
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"middleware":"handlers"}`)
o.API = filepath.Join(root, "examples/contracts/user.api")
_, err = Generate(o)
}

View 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)
}
}

View File

@@ -0,0 +1,92 @@
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")
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"middleware":"handlers"}`)
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)
}
}

View 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
}

View File

@@ -40,8 +40,20 @@ func Compile(spec *Spec) (*Compiled, error) {
if prepareErr != nil {
return nil, prepareErr
}
if err := expandFields(spec); err != nil {
return nil, err
}
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 +73,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 +83,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 +150,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 +174,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)
}
@@ -233,14 +245,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")
}
@@ -250,11 +274,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
}
@@ -295,7 +315,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
}

View File

@@ -0,0 +1,102 @@
package apigen
import (
"fmt"
"reflect"
"strings"
)
// expandFields copies definitions rather than embedding Go structs. Binding,
// validation, DTOs, snapshots and OpenAPI all consume the same flattened fields.
func expandFields(spec *Spec) error {
definitions := map[string]Type{}
for _, t := range spec.Types {
if _, exists := definitions[t.Name]; exists {
return fmt.Errorf("%s: duplicate type %s", t.Position, t.Name)
}
definitions[t.Name] = t
}
expanded := map[string]Type{}
heights := map[string]int{}
visiting := map[string]bool{}
total := 0
var expand func(string, int) (Type, error)
expand = func(name string, depth int) (Type, error) {
if t, ok := expanded[name]; ok {
if depth+heights[name] > 32 {
return Type{}, fmt.Errorf("field composition exceeds 32 levels at %s", name)
}
return t, nil
}
if visiting[name] {
return Type{}, fmt.Errorf("field composition cycle at %s", name)
}
if depth > 32 {
return Type{}, fmt.Errorf("field composition exceeds 32 levels at %s", name)
}
t, ok := definitions[name]
if !ok {
return Type{}, fmt.Errorf("unknown composed object type %s", name)
}
visiting[name] = true
defer delete(visiting, name)
fields := []Field{}
height := 0
seen, wire := map[string]bool{}, map[string]bool{}
for _, f := range t.Fields {
add := []Field{f}
if f.Type == "" {
child, err := expand(f.Name, depth+1)
if err != nil {
return Type{}, fmt.Errorf("%s.%s: %w", name, f.Name, err)
}
add = child.Fields
if heights[f.Name]+1 > height {
height = heights[f.Name] + 1
}
if height > 32 {
return Type{}, fmt.Errorf("field composition exceeds 32 levels at %s", name)
}
}
for _, field := range add {
if seen[field.Name] {
return Type{}, fmt.Errorf("%s: conflicting composed field %s", name, field.Name)
}
seen[field.Name] = true
for _, source := range []string{"json", "form", "path", "header", "multipart", "multipartJSON", "file"} {
tag := strings.Split(reflect.StructTag(field.Tag).Get(source), ",")[0]
if tag == "" || tag == "-" {
continue
}
if source == "header" {
tag = strings.ToLower(tag)
}
key := source + ":" + tag
if wire[key] {
return Type{}, fmt.Errorf("%s: conflicting composed field tag %s", name, key)
}
wire[key] = true
}
fields = append(fields, field)
total++
if len(fields) > 4096 || total > 65536 {
return Type{}, fmt.Errorf("field composition expansion limit exceeded")
}
}
}
t.Fields = fields
expanded[name] = t
heights[name] = height
return t, nil
}
result := make([]Type, 0, len(spec.Types))
for _, t := range spec.Types {
out, err := expand(t.Name, 0)
if err != nil {
return err
}
result = append(result, out)
}
spec.Types = result
return nil
}

View File

@@ -0,0 +1,163 @@
package apigen
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestFieldComposition(t *testing.T) {
source := readFile(t, "testdata/composition/service.api")
spec, err := parseText(t, source)
if err != nil {
t.Fatal(err)
}
first, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
second, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(first.Document, second.Document) || len(spec.Types[1].Fields) != 1 {
t.Fatal("composition mutates input")
}
for _, name := range []string{"EnterpriseRequest", "TenantRequest", "AdminRequest"} {
schema := first.Bindings[name].BodySchema()
raw, _ := json.Marshal(schema)
if !bytes.Contains(raw, []byte("Employee display name.")) || bytes.Contains(raw, []byte("SalaryFields")) {
t.Fatalf("flattened schema/comment missing: %s", raw)
}
}
cases := []struct{ name, decl, want string }{
{"unknown", "type Req {\n Missing\n}", "unknown composed"},
{"cycle", "type Req {\n Other\n}\ntype Other {\n Req\n}", "cycle"},
{"conflict", "type Base {\n Name string `json:\"name\"`\n}\ntype Req {\n Base\n Name string `json:\"other\"`\n}", "conflicting"},
{"wireConflict", "type Base {\n Name string `json:\"name\"`\n}\ntype Req {\n Base\n Other string `json:\"name\"`\n}", "conflicting"},
{"tagged", "type Req {\n Other `json:\"other\"`\n}", "cannot carry"},
{"diamond", "type Base {\n Name string `json:\"name\"`\n}\ntype Left {\n Base\n}\ntype Right {\n Base\n}\ntype Req {\n Left\n Right\n}", "conflicting"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s, e := parseText(t, tc.decl+"\nservice demo{\n@handler post\npost / (Req)\n}")
if e == nil {
_, e = Compile(s)
}
if e == nil || !strings.Contains(e.Error(), tc.want) {
t.Fatal(e)
}
})
}
// Imports can contain reusable definitions and retain the existing root policy.
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "base.api"), "type Base {\n Name string `json:\"name\"`\n}\n")
writeFile(t, filepath.Join(dir, "service.api"), "import \"base.api\"\ntype Req {\n Base\n}\nservice demo{\n@handler post\npost / (Req)\n}")
if _, err := FormatProject(filepath.Join(dir, "service.api"), false); err != nil {
t.Fatal(err)
}
if _, err := FormatProject(filepath.Join(dir, "service.api"), true); err != nil {
t.Fatal(err)
}
}
func TestRepositoryGenerationOptions(t *testing.T) {
dir := filepath.Join(t.TempDir(), "app")
if _, err := New(Options{Dir: dir, Module: "example.com/repository", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "internal/repository")); !os.IsNotExist(err) {
t.Fatal("default creates repository")
}
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api")}
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"repository":"group","middleware":"factories"}`)
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
repo := filepath.Join(dir, "internal/repository/greet/repository.go")
original := readFile(t, repo) + "\n// business SQL belongs here\n"
writeFile(t, repo, original)
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"repository":"none","middleware":"factories"}`)
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
o.Check = true
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
if readFile(t, repo) != original {
t.Fatal("repository removed or overwritten")
}
for _, raw := range []string{`{"repository":"oops"}`, `{"middleware":"oops"}`, `{"unknown":true}`, `{"repository":"none","repository":"group"}`} {
writeFile(t, filepath.Join(dir, generatorConfigFile), raw)
before := snapshot(t, dir)
if _, err := Generate(o); err == nil {
t.Fatal("accepted", raw)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("invalid config wrote files")
}
}
}
func TestCompositionExpansionBound(t *testing.T) {
var source strings.Builder
source.WriteString("type Base {\n Value string `json:\"value\"`\n}\n")
last := "Base"
for i := 0; i < 35; i++ {
name := fmt.Sprintf("Level%d", i)
fmt.Fprintf(&source, "type %s {\n %s\n}\n", name, last)
last = name
}
// Reverse order forces traversal instead of hitting pre-expanded definitions.
spec, err := parseText(t, source.String()+"service demo{\n@handler post\npost / ("+last+")\n}")
if err != nil {
t.Fatal(err)
}
for i, j := 0, len(spec.Types)-1; i < j; i, j = i+1, j-1 {
spec.Types[i], spec.Types[j] = spec.Types[j], spec.Types[i]
}
if _, err = Compile(spec); err == nil {
t.Fatal("unbounded composition accepted")
}
}
func TestGenerationConfigMissingAndLegacy(t *testing.T) {
dir := filepath.Join(t.TempDir(), "app")
if _, err := New(Options{Dir: dir, Module: "example.com/config", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
o := Options{Dir: dir, API: filepath.Join(dir, "api/service.api")}
if err := os.Remove(filepath.Join(dir, generatorConfigFile)); err != nil {
t.Fatal(err)
}
before := snapshot(t, dir)
if _, err := Generate(o); err == nil || !strings.Contains(err.Error(), "restore") {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("missing config changed project")
}
// A genuine old manifest has never tracked a generator config.
var old manifest
if err := json.Unmarshal([]byte(readFile(t, filepath.Join(dir, manifestPath))), &old); err != nil {
t.Fatal(err)
}
old.GeneratorConfig = false
raw, _ := json.Marshal(old)
writeFile(t, filepath.Join(dir, manifestPath), string(raw))
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
cfg, exists, err := readGeneratorConfig(dir)
if err != nil || !exists || cfg.Middleware != "handlers" || cfg.Repository != "none" {
t.Fatal(cfg, exists, err)
}
if !strings.Contains(readFile(t, filepath.Join(dir, "internal/svc/runtimeGen.go")), "middleware.Defaults()") {
t.Fatal("legacy registration replaced")
}
}

View File

@@ -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)
}

View File

@@ -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
}

View 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)
}

View 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")
}
}
}

View 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")
}
}
}

View 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
}

View File

@@ -19,10 +19,13 @@ import (
)
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
const minimumVersion = "v0.13.0-preview"
const minimumVersion = "v0.18.0-preview"
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
type Options struct {
generation GeneratorConfig
lockHeld bool
documentCatalogs map[string][]byte
Database, Storage string
API, Dir, Module, FrameworkDir, FrameworkVersion, LogOutput, LogFormat string
Check bool
@@ -47,7 +50,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 +98,14 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
return Output{}, err
}
o.Dir = directory
genConfig, hasGenConfig, err := readGeneratorConfig(directory)
if err != nil {
return Output{}, err
}
o.generation = genConfig
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 +120,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 +132,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 +152,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, "lifecycle", "controller.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 dependency shutdown and configurable endpoint 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 != "" {
@@ -187,11 +198,24 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
for name, data := range base {
seeds[name] = data
}
if !hasGenConfig {
raw, _ := json.MarshalIndent(genConfig, "", " ")
seeds[generatorConfigFile] = append(raw, '\n')
}
// The API source remains developer-owned. A relocated project regenerates
// 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 +224,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 +233,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 +252,28 @@ 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))
if err := renderMappings(o, generated, seeds); err != nil {
return Output{}, err
}
currentConfig, stillExists, err := readGeneratorConfig(directory)
if err != nil {
return Output{}, err
}
beforeConfig, _ := json.Marshal(genConfig)
afterConfig, _ := json.Marshal(currentConfig)
if hasGenConfig != stillExists || genConfig.inputDigest != currentConfig.inputDigest || string(beforeConfig) != string(afterConfig) {
return Output{}, errors.New("generation configuration changed during generation; retry")
}
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 +382,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 +403,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 +477,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 +511,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 +530,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 +566,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 +585,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 +606,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 "
@@ -521,7 +624,9 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
}
put(generated, "internal/handler/"+group+"/handlersGen.go", generatedHeader+text)
put(seeds, "internal/logic/"+group+"/logic.go", fmt.Sprintf("package %s\nimport %q\ntype Logic struct {deps *svc.ServiceContext}\nfunc New(deps *svc.ServiceContext)*Logic{return &Logic{deps:deps}}\n", packageName, o.Module+"/internal/svc"))
put(seeds, "internal/repository/"+group+"/repository.go", "// Package "+packageName+" owns persistence contracts for this business group.\n// Define repository interfaces and implementations here; HTTP DTOs do not\n// determine a database schema. This file is never overwritten.\npackage "+packageName+"\n")
if o.generation.Repository == "group" {
put(seeds, "internal/repository/"+group+"/repository.go", "// Package "+packageName+" owns persistence contracts for this business group.\n// Define repository interfaces and implementations here; HTTP DTOs do not\n// determine a database schema. This file is never overwritten.\npackage "+packageName+"\n")
}
}
put(generated, "internal/handler/routesGen.go", generatedHeader+"package handler\n"+imports+")\n"+routes+"return nil\n}\n")
registry := "package middleware\nimport " + strconv.Quote(frameworkModule+"/httpx") + "\nfunc Defaults()map[string]httpx.Middleware{return map[string]httpx.Middleware{\n"
@@ -530,6 +635,9 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
put(seeds, "internal/middleware/"+lower(m)+".go", fmt.Sprintf("package middleware\nimport(%q; apierrors %q)\n// %s is a developer-owned hook. Implement it before enabling this route.\nfunc %s(next httpx.Handler)httpx.Handler{return func(c *httpx.Context)error {return apierrors.New(501,\"MIDDLEWARE_NOT_IMPLEMENTED\",\"middleware is not implemented\")}}\n", frameworkModule+"/httpx", frameworkModule+"/errors", upper(m), upper(m)))
}
put(generated, "internal/middleware/registryGen.go", generatedHeader+registry+"}}\n")
if o.generation.Middleware == "factories" {
renderMiddlewareFactories(middlewareNames, generated, seeds)
}
for name, source := range baseTemplates {
content := strings.ReplaceAll(source, "MODULE", o.Module)
content = strings.ReplaceAll(content, "LOGFORMAT", o.LogFormat)
@@ -549,7 +657,21 @@ 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 o.generation.Middleware == "factories" && name == "internal/svc/runtimeGen.go" {
formatted, err := format.Source(data)
if err != nil {
return nil, nil, err
}
source := string(formatted)
source = strings.Replace(source, "Middlewares: middleware.Defaults(),", "", 1)
source = strings.Replace(source, "return c, nil", "if len(c.Middlewares) != 0 { return nil, errors.New(\"factory middleware is registered automatically; remove manual Middlewares assignments from Init\") }\nc.Middlewares, err = middleware.New(c)\nif err != nil { return nil, err }\nreturn c, nil", 1)
data = []byte(source)
}
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 +686,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 +697,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"
}

View File

@@ -0,0 +1,149 @@
package apigen
import (
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
)
const generatorConfigFile = "omni.gen.json"
// GeneratorConfig is developer-owned and checked on every generation. Mapping
// rules never change the public API or infer a persistence model.
type GeneratorConfig struct {
Repository string `json:"repository,omitempty"`
Middleware string `json:"middleware,omitempty"`
Mappings []Mapping `json:"mappings,omitempty"`
MappingFiles []string `json:"mappingFiles,omitempty"`
inputDigest string
}
func readGeneratorConfig(root string) (GeneratorConfig, bool, error) {
cfg := GeneratorConfig{Repository: "none", Middleware: "factories"}
if err := safePath(root, generatorConfigFile); err != nil {
return cfg, false, err
}
stat, err := os.Lstat(filepath.Join(root, generatorConfigFile))
if errors.Is(err, os.ErrNotExist) {
// Existing projects keep their handwritten middleware signature until they
// explicitly select factories; do not rewrite or adopt developer files.
if raw, e := os.ReadFile(filepath.Join(root, manifestPath)); e == nil {
var previous manifest
if err := json.Unmarshal(raw, &previous); err != nil {
return cfg, false, err
}
if previous.GeneratorConfig {
return cfg, false, errors.New("omni.gen.json is missing; restore the project-owned generation options and mappings before regenerating")
}
cfg.Middleware = "handlers"
} else if !errors.Is(e, os.ErrNotExist) {
return cfg, false, e
}
return cfg, false, nil
}
if err != nil {
return cfg, false, err
}
if !stat.Mode().IsRegular() || stat.Size() > 1<<20 {
return cfg, false, errors.New("omni.gen.json must be a regular file of at most 1 MiB")
}
raw, err := readMappingConfigFile(root, generatorConfigFile)
if err != nil {
return cfg, false, err
}
if err = apierrors.DecodeCatalogJSON(raw, &cfg); err != nil {
return cfg, false, fmt.Errorf("omni.gen.json: %w", err)
}
// Child files only declare mappings. No recursive imports, globs, implicit
// directory scans or access outside the project are supported.
if len(cfg.MappingFiles) > 64 {
return cfg, false, errors.New("omni.gen.json: at most 64 mappingFiles supported")
}
digest := sha256.New()
fmt.Fprintf(digest, "%s:%d:", generatorConfigFile, len(raw))
digest.Write(raw)
total := len(raw)
for i := range cfg.Mappings {
cfg.Mappings[i].origin = generatorConfigFile
}
seen := map[string]bool{generatorConfigFile: true}
for _, name := range cfg.MappingFiles {
key := strings.ToLower(name)
if seen[key] {
return cfg, false, fmt.Errorf("omni.gen.json: duplicate mapping file %q", name)
}
seen[key] = true
if !strings.HasSuffix(name, ".json") {
return cfg, false, fmt.Errorf("mapping file %q must have a .json extension", name)
}
data, err := readMappingConfigFile(root, name)
if err != nil {
return cfg, false, err
}
total += len(data)
if total > 4<<20 {
return cfg, false, errors.New("mapping configuration exceeds 4 MiB in total")
}
var file struct {
Mappings []Mapping `json:"mappings"`
}
if err = apierrors.DecodeCatalogJSON(data, &file); err != nil {
return cfg, false, fmt.Errorf("%s: %w (mapping files only support mappings; nested imports are not supported)", name, err)
}
for i := range file.Mappings {
file.Mappings[i].origin = name
}
cfg.Mappings = append(cfg.Mappings, file.Mappings...)
fmt.Fprintf(digest, "%s:%d:", name, len(data))
digest.Write(data)
}
cfg.inputDigest = fmt.Sprintf("%x", digest.Sum(nil))
if cfg.Repository != "none" && cfg.Repository != "group" {
return cfg, false, errors.New("omni.gen.json: repository must be none or group")
}
if cfg.Middleware != "factories" && cfg.Middleware != "handlers" {
return cfg, false, errors.New("omni.gen.json: middleware must be factories or handlers")
}
if len(cfg.Mappings) > 128 {
return cfg, false, errors.New("omni.gen.json: at most 128 mappings supported")
}
return cfg, true, nil
}
// Read through a bounded reader as well as checking stat: a growing file cannot
// make configuration loading unbounded. safePath also rejects symlink escapes.
func readMappingConfigFile(root, name string) ([]byte, error) {
if err := safePath(root, name); err != nil {
return nil, err
}
f, err := os.Open(filepath.Join(root, filepath.FromSlash(name)))
if err != nil {
return nil, fmt.Errorf("%s: %w", name, err)
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return nil, err
}
if !stat.Mode().IsRegular() || stat.Size() > 1<<20 {
return nil, fmt.Errorf("%s must be a regular file of at most 1 MiB", name)
}
raw, err := io.ReadAll(io.LimitReader(f, (1<<20)+1))
if err != nil {
return nil, err
}
if len(raw) > 1<<20 {
return nil, fmt.Errorf("%s exceeds 1 MiB", name)
}
if trimmed := strings.TrimSpace(string(raw)); !strings.HasPrefix(trimmed, "{") {
return nil, fmt.Errorf("%s must contain a JSON object", name)
}
return raw, nil
}

View File

@@ -0,0 +1,141 @@
package apigen
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestMappingFileConfiguration(t *testing.T) {
dir := t.TempDir()
defs := nullableMappings()
writeGeneratorConfig(t, dir, GeneratorConfig{Repository: "none", Middleware: "factories", Mappings: defs[:1], MappingFiles: []string{"mappings/a.json", "mappings/b.json"}})
writeMappingFile(t, dir, "mappings/a.json", defs[1:5])
writeMappingFile(t, dir, "mappings/b.json", defs[5:])
first, exists, err := readGeneratorConfig(dir)
if err != nil || !exists || len(first.Mappings) != len(defs) {
t.Fatal(first, err)
}
for i, m := range first.Mappings {
if m.Name != defs[i].Name {
t.Fatal("non-deterministic load order")
}
}
second, _, err := readGeneratorConfig(dir)
if err != nil || first.inputDigest != second.inputDigest {
t.Fatal("unstable config digest", err)
}
path := filepath.Join(dir, "mappings/a.json")
writeFile(t, path, readFile(t, path)+"\n")
changed, _, err := readGeneratorConfig(dir)
if err != nil || changed.inputDigest == first.inputDigest {
t.Fatal("child file change invisible to generation guard", err)
}
// Duplicate definitions name both files instead of silently overriding.
writeMappingFile(t, dir, "mappings/b.json", defs[1:2])
cfg, _, err := readGeneratorConfig(dir)
if err != nil {
t.Fatal(err)
}
_, err = validateMappings(cfg.Mappings, "example.com/nullable")
if err == nil || !strings.Contains(err.Error(), "mappings/a.json") || !strings.Contains(err.Error(), "mappings/b.json") {
t.Fatal(err)
}
}
func TestMappingFileRejection(t *testing.T) {
for _, tc := range []struct{ name, root, child, want string }{
{"missing", `{"mappingFiles":["maps.json"]}`, "", "maps.json"},
{"escape", `{"mappingFiles":["../maps.json"]}`, "", "unsafe"},
{"absolute", `{"mappingFiles":["/maps.json"]}`, "", "unsafe"},
{"nonCanonical", `{"mappingFiles":["sub/../maps.json"]}`, "", "unsafe"},
{"duplicate", `{"mappingFiles":["maps.json","maps.json"]}`, `{"mappings":[]}`, "duplicate mapping file"},
{"caseDuplicate", `{"mappingFiles":["maps.json","MAPS.json"]}`, `{"mappings":[]}`, "duplicate mapping file"},
{"self", `{"mappingFiles":["omni.gen.json"]}`, "", "duplicate mapping file"},
{"recursive", `{"mappingFiles":["maps.json"]}`, `{"mappingFiles":["omni.gen.json"]}`, "nested imports are not supported"},
{"childSettings", `{"mappingFiles":["maps.json"]}`, `{"repository":"none","mappings":[]}`, "maps.json"},
{"duplicateKeys", `{"mappingFiles":["maps.json"]}`, `{"mappings":[],"mappings":[]}`, "duplicate key"},
{"rootNull", `null`, "", "JSON object"},
{"childNull", `{"mappingFiles":["maps.json"]}`, `null`, "JSON object"},
{"unknownPolicy", `{"mappingFiles":["maps.json"]}`, `{"mappings":[{"unknown":true}]}`, "unknown field"},
{"trailingValue", `{"mappingFiles":["maps.json"]}`, `{"mappings":[]} {}`, "maps.json"},
{"tooLarge", `{"mappingFiles":["maps.json"]}`, strings.Repeat(" ", 1<<20) + `{}`, "1 MiB"},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, generatorConfigFile), tc.root)
if tc.child != "" {
writeFile(t, filepath.Join(dir, "maps.json"), tc.child)
}
before := snapshot(t, dir)
_, _, err := readGeneratorConfig(dir)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("read changed configuration")
}
})
}
for _, directory := range []bool{false, true} {
t.Run(fmt.Sprintf("symlinkDirectory%t", directory), func(t *testing.T) {
dir := t.TempDir()
outside := t.TempDir()
writeMappingFile(t, outside, "maps.json", nil)
name := "maps.json"
target := filepath.Join(outside, name)
link := filepath.Join(dir, name)
if directory {
name = "linked/maps.json"
target = outside
link = filepath.Join(dir, "linked")
}
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
writeGeneratorConfig(t, dir, GeneratorConfig{Repository: "none", Middleware: "factories", MappingFiles: []string{name}})
if _, _, err := readGeneratorConfig(dir); err == nil || !strings.Contains(err.Error(), "symlink") {
t.Fatal(err)
}
})
}
}
func TestMappingFileBounds(t *testing.T) {
dir := t.TempDir()
files := make([]string, 65)
for i := range files {
files[i] = fmt.Sprintf("maps%d.json", i)
}
writeGeneratorConfig(t, dir, GeneratorConfig{Repository: "none", Middleware: "factories", MappingFiles: files})
if _, _, err := readGeneratorConfig(dir); err == nil || !strings.Contains(err.Error(), "64") {
t.Fatal(err)
}
writeGeneratorConfig(t, dir, GeneratorConfig{Repository: "none", Middleware: "factories", MappingFiles: files[:4]})
for _, name := range files[:4] {
writeFile(t, filepath.Join(dir, name), `{"mappings":[]}`+strings.Repeat(" ", (1<<20)-len(`{"mappings":[]}`)))
}
if _, _, err := readGeneratorConfig(dir); err == nil || !strings.Contains(err.Error(), "4 MiB") {
t.Fatal(err)
}
// The definition limit applies after merging, not separately to each file.
defs := make([]Mapping, 129)
for i := range defs {
defs[i] = nullableMappings()[0]
defs[i].Name = fmt.Sprintf("Map%d", i)
}
writeGeneratorConfig(t, dir, GeneratorConfig{Repository: "none", Middleware: "factories", MappingFiles: files[:2]})
writeMappingFile(t, dir, files[0], defs[:65])
writeMappingFile(t, dir, files[1], defs[65:])
if _, _, err := readGeneratorConfig(dir); err == nil || !strings.Contains(err.Error(), "128") {
t.Fatal(err)
}
// Origins/digests are loader metadata, never serialized into user configuration.
raw, err := json.Marshal(GeneratorConfig{inputDigest: "private"})
if err != nil || strings.Contains(string(raw), "private") {
t.Fatal(string(raw), err)
}
}

View File

@@ -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
}
}

View File

@@ -71,6 +71,7 @@ func TestManagedGeneratedProject(t *testing.T) {
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)))
@@ -79,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\"`\n ShutdownInit bool `json:\"shutdownInit\"`", 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")
@@ -169,12 +170,15 @@ func generateManagedTestProject(t *testing.T, o Options) error {
_, err := Generate(o)
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
args := []string{"api", "generate", "--api", o.API, "--dir", o.Dir, "--module", o.Module}
if o.Check {
args = append(args, "--check")
}
if o.DryRun {
args = append(args, "--dry-run")
}
cmd := exec.CommandContext(ctx, cli, args...)
cmd.Dir = filepath.Dir(o.Dir)
if data, err := cmd.CombinedOutput(); err != nil {

View File

@@ -0,0 +1,453 @@
package apigen
import (
"fmt"
"go/ast"
"go/format"
goparser "go/parser"
gotoken "go/token"
"go/types"
"sort"
"strconv"
"strings"
"golang.org/x/mod/module"
)
// GoTypeRef names an existing, exported, non-generic Go struct.
type GoTypeRef struct {
Package string `json:"package"`
Type string `json:"type"`
}
type GoFunctionRef struct {
Package string `json:"package"`
Name string `json:"name"`
}
type MappingField struct {
Source string `json:"source"`
Convert *GoFunctionRef `json:"convert,omitempty"`
Mapping string `json:"mapping,omitempty"`
Elements bool `json:"elements,omitempty"`
Nil string `json:"nil,omitempty"`
}
type Mapping struct {
Name string `json:"name"`
PointerResult bool `json:"pointerResult,omitempty"`
origin string
Source GoTypeRef `json:"source"`
Target GoTypeRef `json:"target"`
Fields map[string]MappingField `json:"fields,omitempty"`
IgnoreSource map[string]string `json:"ignoreSource,omitempty"`
IgnoreTarget map[string]string `json:"ignoreTarget,omitempty"`
}
type mappingField struct {
path, name string
typ types.Type
}
type mappingTypes struct{ source, target types.Type }
type mappingRenderer struct {
importer types.Importer
imports map[string]string
definitions map[string]Mapping
types map[string]mappingTypes
}
func renderMappings(o Options, generated, seeds map[string][]byte) error {
if len(o.generation.Mappings) == 0 {
return nil
}
refs, err := validateMappings(o.generation.Mappings, o.Module)
if err != nil {
return err
}
importer, close, err := loadMappingTypes(o, refs, generated, seeds)
if err != nil {
return err
}
defer close()
r := mappingRenderer{importer: importer, imports: map[string]string{}, definitions: map[string]Mapping{}, types: map[string]mappingTypes{}}
for _, m := range o.generation.Mappings {
source, err := r.lookup(m.Source)
if err != nil {
return err
}
target, err := r.lookup(m.Target)
if err != nil {
return err
}
r.definitions[m.Name] = m
r.types[m.Name] = mappingTypes{source, target}
}
names := make([]string, 0, len(r.definitions))
for name := range r.definitions {
names = append(names, name)
}
sort.Strings(names)
var body strings.Builder
for _, name := range names {
source, err := r.render(r.definitions[name])
if err != nil {
return fmt.Errorf("%s: mapping %s: %w", mappingOrigin(r.definitions[name]), name, err)
}
body.WriteString(source)
}
imports := make([]string, 0, len(r.imports))
for path := range r.imports {
imports = append(imports, path)
}
sort.Strings(imports)
text := generatedHeader + "package mapping\nimport(\n"
for _, path := range imports {
text += r.imports[path] + " " + strconv.Quote(path) + "\n"
}
text += ")\n" + body.String()
code, err := format.Source([]byte(text))
if err != nil {
return fmt.Errorf("format mappings: %w", err)
}
// The generated package is type-checked before it is written. No application
// code (including init or converters) is executed during generation.
fset := gotoken.NewFileSet()
file, err := goparser.ParseFile(fset, "mappingsGen.go", code, 0)
if err != nil {
return err
}
check := types.Config{Importer: importer}
if _, err = check.Check(o.Module+"/internal/mapping", fset, []*ast.File{file}, nil); err != nil {
return fmt.Errorf("generated mapping type check: %w", err)
}
generated["internal/mapping/mappingsGen.go"] = code
return nil
}
func validateMappings(defs []Mapping, project string) ([]string, error) {
refs := map[string]bool{}
names := map[string]string{}
validPackage := func(path string) bool {
if module.CheckImportPath(path) != nil || path == project+"/internal/mapping" {
return false
}
// go/types does not enforce the compiler's internal import visibility.
if strings.HasPrefix(path, "internal/") {
return false
}
if i := strings.LastIndex(path, "/internal/"); i >= 0 {
return strings.HasPrefix(project+"/internal/mapping", path[:i]+"/")
}
return true
}
for _, m := range defs {
if !gotoken.IsIdentifier(m.Name) || !gotoken.IsExported(m.Name) {
return nil, fmt.Errorf("mapping name %q must be unique and exported", m.Name)
}
functions := []string{m.Name}
if m.PointerResult {
functions = append(functions, m.Name+"Ptr")
}
for _, name := range functions {
if prior, exists := names[name]; exists {
return nil, fmt.Errorf("mapping function %q must be unique: %s conflicts with %s", name, mappingOrigin(m), prior)
}
names[name] = mappingOrigin(m)
}
for _, ref := range []GoTypeRef{m.Source, m.Target} {
if !validPackage(ref.Package) || !gotoken.IsIdentifier(ref.Type) || !gotoken.IsExported(ref.Type) {
return nil, fmt.Errorf("mapping %s: invalid Go type reference", m.Name)
}
refs[ref.Package] = true
}
if len(m.Fields) > 4096 || len(m.IgnoreSource) > 4096 || len(m.IgnoreTarget) > 4096 {
return nil, fmt.Errorf("mapping %s: field rule limit exceeded", m.Name)
}
for target, f := range m.Fields {
if target == "" || f.Source == "" {
return nil, fmt.Errorf("mapping %s: explicit field rule requires target and source paths", m.Name)
}
if f.Nil != "" && f.Nil != "preserve" && f.Nil != "zero" && f.Nil != "error" {
return nil, fmt.Errorf("mapping %s.%s: nil must be preserve, zero or error", m.Name, target)
}
if f.Convert != nil && f.Nil != "" {
return nil, fmt.Errorf("mapping %s.%s: nil policy cannot be combined with a converter; the converter owns nil behavior", m.Name, target)
}
if f.Convert != nil {
if f.Mapping != "" || !validPackage(f.Convert.Package) || !gotoken.IsIdentifier(f.Convert.Name) || !gotoken.IsExported(f.Convert.Name) {
return nil, fmt.Errorf("mapping %s.%s: invalid or conflicting converter", m.Name, target)
}
refs[f.Convert.Package] = true
}
}
}
visiting, done := map[string]bool{}, map[string]bool{}
byName := map[string]Mapping{}
for _, m := range defs {
byName[m.Name] = m
}
var visit func(string) error
visit = func(name string) error {
if done[name] {
return nil
}
if visiting[name] {
return fmt.Errorf("mapping cycle at %s", name)
}
m, ok := byName[name]
if !ok {
return fmt.Errorf("unknown mapping %s", name)
}
visiting[name] = true
for _, f := range m.Fields {
if f.Mapping != "" {
if err := visit(f.Mapping); err != nil {
return err
}
}
}
delete(visiting, name)
done[name] = true
return nil
}
for _, m := range defs {
if err := visit(m.Name); err != nil {
return nil, err
}
}
out := make([]string, 0, len(refs))
for path := range refs {
out = append(out, path)
}
sort.Strings(out)
return out, nil
}
func (r *mappingRenderer) qualifier(p *types.Package) string {
if alias, ok := r.imports[p.Path()]; ok {
return alias
}
alias := fmt.Sprintf("pkg%d", len(r.imports))
r.imports[p.Path()] = alias
return alias
}
func (r *mappingRenderer) typeName(t types.Type) string { return types.TypeString(t, r.qualifier) }
func (r *mappingRenderer) lookup(ref GoTypeRef) (types.Type, error) {
pkg, err := r.importer.Import(ref.Package)
if err != nil {
return nil, fmt.Errorf("mapping type %s.%s: %w", ref.Package, ref.Type, err)
}
obj, ok := pkg.Scope().Lookup(ref.Type).(*types.TypeName)
if !ok {
return nil, fmt.Errorf("mapping type %s.%s not found", ref.Package, ref.Type)
}
typ := obj.Type()
if n, ok := typ.(*types.Named); ok && n.TypeParams() != nil && n.TypeParams().Len() > 0 {
return nil, fmt.Errorf("mapping root %s must be a non-generic struct", ref.Type)
}
if _, ok := typ.Underlying().(*types.Struct); !ok {
return nil, fmt.Errorf("mapping root %s must be a struct", ref.Type)
}
return typ, nil
}
func mappingFields(typ types.Type) (map[string]mappingField, error) {
out := map[string]mappingField{}
leaves := map[string]string{}
var walk func(types.Type, string, int) error
walk = func(typ types.Type, prefix string, depth int) error {
if depth > 32 {
return fmt.Errorf("embedded field nesting exceeds 32")
}
st, ok := typ.Underlying().(*types.Struct)
if !ok {
return fmt.Errorf("embedded field %s must be a value struct", prefix)
}
for i := 0; i < st.NumFields(); i++ {
f := st.Field(i)
path := prefix + f.Name()
if !f.Exported() {
return fmt.Errorf("unexported field %s cannot be mapped; use an explicit converter on the containing value", path)
}
if f.Embedded() {
if err := walk(f.Type(), path+".", depth+1); err != nil {
return err
}
continue
}
if previous, exists := leaves[f.Name()]; exists {
return fmt.Errorf("ambiguous embedded fields %s and %s", previous, path)
}
leaves[f.Name()] = path
out[path] = mappingField{path, f.Name(), f.Type()}
if len(out) > 4096 {
return fmt.Errorf("mapping field limit exceeded")
}
}
return nil
}
if err := walk(typ, "", 0); err != nil {
return nil, err
}
return out, nil
}
func (r *mappingRenderer) render(m Mapping) (string, error) {
mt := r.types[m.Name]
source, err := mappingFields(mt.source)
if err != nil {
return "", err
}
target, err := mappingFields(mt.target)
if err != nil {
return "", err
}
for key, reason := range m.IgnoreSource {
if _, ok := source[key]; !ok || strings.TrimSpace(reason) == "" {
return "", fmt.Errorf("ignoreSource %s requires an existing field and reason", key)
}
}
for key, reason := range m.IgnoreTarget {
if _, ok := target[key]; !ok || strings.TrimSpace(reason) == "" {
return "", fmt.Errorf("ignoreTarget %s requires an existing field and reason", key)
}
}
for key := range m.Fields {
if _, ok := target[key]; !ok {
return "", fmt.Errorf("unknown target field %s", key)
}
if _, ignored := m.IgnoreTarget[key]; ignored {
return "", fmt.Errorf("target %s is both mapped and ignored", key)
}
}
names := make([]string, 0, len(target))
for name := range target {
names = append(names, name)
}
sort.Strings(names)
used := map[string]bool{}
text := fmt.Sprintf("// %s maps one value. Pointer/map/slice assignments retain Go reference semantics.\nfunc %s(source %s)(%s,error){\nvar result %s\n", m.Name, m.Name, r.typeName(mt.source), r.typeName(mt.target), r.typeName(mt.target))
for _, path := range names {
dst := target[path]
if reason, ignored := m.IgnoreTarget[path]; ignored {
text += "// Target " + path + " is intentionally left at zero; see omni.gen.json.\n"
_ = reason
continue
}
rule, explicit := m.Fields[path]
if !explicit {
for key, f := range source {
if f.name == dst.name {
rule.Source = key
break
}
}
}
src, ok := source[rule.Source]
if !ok {
return "", fmt.Errorf("target %s has no source; add a field rule or ignoreTarget reason", path)
}
if _, ignored := m.IgnoreSource[src.path]; ignored {
return "", fmt.Errorf("source %s is both mapped and ignored", src.path)
}
used[src.path] = true
from, to := src.typ, dst.typ
srcExpr, dstExpr := "source."+src.path, "result."+path
if rule.Elements {
switch d := to.Underlying().(type) {
case *types.Slice:
s, ok := from.Underlying().(*types.Slice)
if !ok {
return "", fmt.Errorf("%s: elements requires slice to slice or equal-length array to array", path)
}
text += fmt.Sprintf("if %s!=nil {\n%s=make(%s,len(%s))\nfor index:=range %s {\n", srcExpr, dstExpr, r.typeName(to), srcExpr, srcExpr)
from, to = s.Elem(), d.Elem()
case *types.Array:
s, ok := from.Underlying().(*types.Array)
if !ok || s.Len() != d.Len() {
return "", fmt.Errorf("%s: array lengths must match", path)
}
text += fmt.Sprintf("for index:=range %s {\n", srcExpr)
from, to = s.Elem(), d.Elem()
default:
return "", fmt.Errorf("%s: elements requires a slice or array", path)
}
srcExpr += "[index]"
dstExpr += "[index]"
}
expr, fallible, err := r.expression(rule, from, to, srcExpr)
if err != nil {
return "", fmt.Errorf("%s -> %s: %w", src.path, path, err)
}
if fallible {
fmtPkg, err := r.importer.Import("fmt")
if err != nil {
return "", err
}
alias := r.qualifier(fmtPkg)
message := m.Name + "." + path + ": %w"
args := "err"
if rule.Elements {
message = m.Name + "." + path + "[%d]: %w"
args = "index,err"
}
text += fmt.Sprintf("{ value,err:=%s\nif err!=nil{var zero %s;return zero,%s.Errorf(%q,%s)}\n%s=value\n}\n", expr, r.typeName(mt.target), alias, message, args, dstExpr)
} else {
text += dstExpr + "=" + expr + "\n"
}
if rule.Elements {
text += "}\n"
if _, ok := dst.typ.Underlying().(*types.Slice); ok {
text += "}\n"
}
}
}
keys := make([]string, 0, len(source))
for key := range source {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
if !used[key] {
if _, ignored := m.IgnoreSource[key]; !ignored {
return "", fmt.Errorf("unmapped source field %s; map it or provide an ignoreSource reason", key)
}
}
}
text += "return result,nil\n}\n"
if m.PointerResult {
text += fmt.Sprintf("// %sPtr returns nil on conversion failure and otherwise a pointer to the result.\nfunc %sPtr(source %s)(*%s,error){\nvalue,err:=%s(source)\nif err!=nil{return nil,err}\nreturn &value,nil\n}\n", m.Name, m.Name, r.typeName(mt.source), r.typeName(mt.target), m.Name)
}
return text, nil
}
func (r *mappingRenderer) expression(rule MappingField, from, to types.Type, source string) (string, bool, error) {
if rule.Mapping != "" {
return r.nestedExpression(rule, from, to, source)
}
if rule.Nil != "" {
return r.dereferenceExpression(rule, from, to, source)
}
if rule.Convert != nil {
pkg, err := r.importer.Import(rule.Convert.Package)
if err != nil {
return "", false, err
}
fn, ok := pkg.Scope().Lookup(rule.Convert.Name).(*types.Func)
if !ok {
return "", false, fmt.Errorf("converter function %s not found", rule.Convert.Name)
}
sig := fn.Type().(*types.Signature)
if sig.Variadic() || (sig.TypeParams() != nil && sig.TypeParams().Len() > 0) || sig.Params().Len() != 1 || sig.Results().Len() < 1 || sig.Results().Len() > 2 {
return "", false, fmt.Errorf("converter must have signature func(Source) Target or func(Source) (Target,error)")
}
if !types.Identical(sig.Params().At(0).Type(), from) || !types.Identical(sig.Results().At(0).Type(), to) {
return "", false, fmt.Errorf("converter signature does not match field types")
}
fallible := sig.Results().Len() == 2
if fallible && !types.Identical(sig.Results().At(1).Type(), types.Universe.Lookup("error").Type()) {
return "", false, fmt.Errorf("converter second result must be error")
}
return r.qualifier(pkg) + "." + rule.Convert.Name + "(" + source + ")", fallible, nil
}
if !types.Identical(from, to) {
return "", false, fmt.Errorf("types %s and %s differ; provide an explicit converter", types.TypeString(from, nil), types.TypeString(to, nil))
}
return source, false, nil
}

View File

@@ -0,0 +1,175 @@
package apigen
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"go/importer"
gotoken "go/token"
"go/types"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"time"
"golang.org/x/mod/modfile"
)
// loadMappingTypes uses the Go compiler's export data, including the pending
// DTOs via an overlay. Module files and sum updates remain in a temporary dir.
// It compiles only explicitly selected packages and their dependencies; never
// runs project code. Build tags/GOOS follow the caller; GOWORK is disabled.
func loadMappingTypes(o Options, refs []string, generated, seeds map[string][]byte) (types.Importer, func(), error) {
// go list resolves its working directory (notably /var -> /private/var on
// macOS). Overlay keys must name that same physical path or stale DTOs win.
o.Dir = canonical(o.Dir)
temp, err := os.MkdirTemp("", "go-omni-mapping-*")
if err != nil {
return nil, nil, err
}
cleanup := func() { _ = os.RemoveAll(temp) }
fail := func(err error) (types.Importer, func(), error) { cleanup(); return nil, nil, err }
raw, err := os.ReadFile(filepath.Join(o.Dir, "go.mod"))
if err != nil {
return fail(errors.New("mapping generation requires an existing Go module; scaffold first and resolve dependencies"))
}
mf, err := modfile.Parse("go.mod", raw, nil)
if err != nil {
return fail(err)
}
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(o.Dir, r.New.Path), ""); err != nil {
return fail(err)
}
}
}
raw, err = mf.Format()
if err != nil {
return fail(err)
}
modPath := filepath.Join(temp, "go.mod")
if err = os.WriteFile(modPath, raw, 0600); err != nil {
return fail(err)
}
if sums, e := os.ReadFile(filepath.Join(o.Dir, "go.sum")); e == nil {
if err = os.WriteFile(filepath.Join(temp, "go.sum"), sums, 0600); err != nil {
return fail(err)
}
} else if !errors.Is(e, os.ErrNotExist) {
return fail(e)
}
overlay := map[string]string{}
// Hide stale owned Go files, which may otherwise redeclare a renamed DTO.
var previous manifest
if data, e := os.ReadFile(filepath.Join(o.Dir, manifestPath)); e == nil {
if err = json.Unmarshal(data, &previous); err != nil {
return fail(err)
}
for name := range previous.Generated {
if strings.HasSuffix(name, ".go") {
if !ownedPath(name) {
return fail(fmt.Errorf("invalid owned path %s", name))
}
if err = safePath(o.Dir, name); err != nil {
return fail(err)
}
if _, ok := generated[name]; !ok {
overlay[filepath.Join(o.Dir, filepath.FromSlash(name))] = ""
}
}
}
} else if !errors.Is(e, os.ErrNotExist) {
return fail(e)
}
for i, files := range []map[string][]byte{generated, seeds} {
names := make([]string, 0, len(files))
for name := range files {
if strings.HasSuffix(name, ".go") {
names = append(names, name)
}
}
sort.Strings(names)
for _, name := range names {
if err = safePath(o.Dir, name); err != nil {
return fail(err)
}
path := filepath.Join(o.Dir, filepath.FromSlash(name))
if i == 1 {
if _, e := os.Stat(path); e == nil {
continue
} else if !errors.Is(e, os.ErrNotExist) {
return fail(e)
}
}
backing := filepath.Join(temp, fmt.Sprintf("overlay%d.go", len(overlay)))
if err = os.WriteFile(backing, files[name], 0600); err != nil {
return fail(err)
}
overlay[path] = backing
}
}
raw, err = json.Marshal(map[string]any{"Replace": overlay})
if err != nil {
return fail(err)
}
overlayPath := filepath.Join(temp, "overlay.json")
if err = os.WriteFile(overlayPath, raw, 0600); err != nil {
return fail(err)
}
args := []string{"list", "-deps", "-export", "-json", "-mod=readonly", "-modfile", modPath, "-overlay", overlayPath}
// fmt is used for fallible conversions even if neither struct imports it.
args = append(args, "fmt")
args = append(args, refs...)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", args...)
cmd.Dir = o.Dir
cmd.Env = append(os.Environ(), "GOWORK=off")
cmd.WaitDelay = 2 * time.Second
stdout := &limitedCatalogBuffer{limit: 32 << 20}
stderr := &limitedCatalogBuffer{limit: 64 << 10}
cmd.Stdout = stdout
cmd.Stderr = stderr
if err = cmd.Run(); err != nil {
if ctx.Err() != nil {
return fail(fmt.Errorf("mapping type loading: %w", ctx.Err()))
}
// Go download errors may contain authenticated remote URLs. Keep arbitrary
// command output out of diagnostics; exact mapping errors follow type loading.
return fail(fmt.Errorf("mapping type loading failed (%v); run go list -deps -export for the configured source/target/converter packages; check dependencies, build tags and import cycles", err))
}
exports := map[string]string{}
dec := json.NewDecoder(bytes.NewReader(stdout.Bytes()))
for {
var p struct {
ImportPath, Export string
Error *struct{ Err string }
}
if err = dec.Decode(&p); err == io.EOF {
break
} else if err != nil {
return fail(err)
}
if p.Error != nil {
return fail(fmt.Errorf("mapping package %s could not be compiled", p.ImportPath))
}
if p.Export != "" {
exports[p.ImportPath] = p.Export
}
}
imp := importer.ForCompiler(gotoken.NewFileSet(), "gc", func(path string) (io.ReadCloser, error) {
file, ok := exports[path]
if !ok {
return nil, fmt.Errorf("no export data for mapping dependency %s", path)
}
return os.Open(file)
})
return imp, cleanup, nil
}

View File

@@ -0,0 +1,250 @@
package apigen
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func nullableMappings() []Mapping {
pkg := "example.com/nullable/internal/"
ref := func(p, name string) GoTypeRef { return GoTypeRef{pkg + p, name} }
child := Mapping{Name: "ToChild", Source: ref("types", "Child"), Target: ref("domain", "Child"), Fields: map[string]MappingField{
"Name": {Source: "Name", Convert: &GoFunctionRef{pkg + "convert", "Name"}},
}}
parent := Mapping{Name: "ToParent", PointerResult: true, Source: ref("types", "Parent"), Target: ref("domain", "Parent"), Fields: map[string]MappingField{
"Child": {Source: "Child", Mapping: "ToChild"}, "Children": {Source: "Children", Mapping: "ToChild", Elements: true},
}}
defs := []Mapping{child, parent,
{Name: "ToWireChild", Source: ref("domain", "Child"), Target: ref("types", "Child")},
{Name: "ToWire", PointerResult: true, Source: ref("domain", "Parent"), Target: ref("types", "Parent"), Fields: map[string]MappingField{
"Child": {Source: "Child", Mapping: "ToWireChild"}, "Children": {Source: "Children", Mapping: "ToWireChild", Elements: true},
}},
{Name: "ToChildDTO", Source: ref("domain", "Child"), Target: ref("domain", "ChildDTO"), Fields: map[string]MappingField{"Name": {Source: "Name", Convert: &GoFunctionRef{pkg + "convert", "Name"}}}},
{Name: "Lift", Source: ref("domain", "ValueHolder"), Target: ref("domain", "PointerHolder"), Fields: map[string]MappingField{"Child": {Source: "Child", Mapping: "ToChildDTO"}}},
{Name: "Batch", Source: ref("domain", "InputBatch"), Target: ref("domain", "OutputBatch"), Fields: map[string]MappingField{"Fixed": {Source: "Fixed", Mapping: "ToChildDTO", Elements: true}}},
{Name: "ShallowCopy", Source: ref("domain", "Parent"), Target: ref("domain", "Parent")},
{Name: "RequirePointer", Source: ref("types", "Parent"), Target: ref("domain", "Parent"), Fields: map[string]MappingField{
"Child": {Source: "Child", Mapping: "ToChild", Nil: "error"}, "Children": {Source: "Children", Mapping: "ToChild", Elements: true},
}},
}
for _, p := range []struct{ name, policy string }{{"RequiredZero", "zero"}, {"RequiredError", "error"}} {
defs = append(defs, Mapping{Name: p.name, Source: ref("types", "Parent"), Target: ref("domain", "Required"), Fields: map[string]MappingField{
"Child": {Source: "Child", Mapping: "ToChild", Nil: p.policy},
}, IgnoreSource: map[string]string{"Children": "this explicit projection only includes the child"}})
}
for _, p := range []struct{ name, policy string }{{"ScalarZero", "zero"}, {"ScalarError", "error"}} {
m := Mapping{Name: p.name, Source: ref("domain", "ScalarInput"), Target: ref("domain", "ScalarOutput"), Fields: map[string]MappingField{}}
for _, name := range []string{"Text", "Count", "Enabled"} {
m.Fields[name] = MappingField{Source: name, Nil: p.policy}
}
defs = append(defs, m)
}
return defs
}
func writeMappingFile(t *testing.T, root, name string, defs []Mapping) {
t.Helper()
raw, err := json.MarshalIndent(struct {
Mappings []Mapping `json:"mappings"`
}{defs}, "", " ")
if err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(root, name), string(raw)+"\n")
}
func TestNullableMappingConsumer(t *testing.T) {
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
}
root := t.TempDir()
dir := filepath.Join(root, "app")
o := Options{API: filepath.Join(frameworkRoot(t), "cmd/go-omni/apigen/testdata/nullable/service.api"), Dir: dir, Module: "example.com/nullable", FrameworkDir: frameworkRoot(t)}
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
for path, fixture := range map[string]string{"internal/domain/model.go": "domain.go", "internal/convert/value.go": "convert.go"} {
writeFile(t, filepath.Join(dir, path), readFile(t, "testdata/nullable/"+fixture))
}
runCompositionGo(t, dir, "mod", "tidy")
defs := nullableMappings()
// References go across files, including forward references to ToChild.
writeMappingFile(t, dir, "mappings/parents.json", defs[1:])
writeMappingFile(t, dir, "mappings/children.json", defs[:1])
cfg := GeneratorConfig{Repository: "none", Middleware: "factories", MappingFiles: []string{"mappings/parents.json", "mappings/children.json"}}
writeGeneratorConfig(t, dir, cfg)
o.API = filepath.Join(dir, "api/service.api")
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dir, "internal/logic/probe/roundTrip.go"), `package probe
import("context";"example.com/nullable/internal/types";"example.com/nullable/internal/mapping")
func(s *Logic)RoundTrip(ctx context.Context,req *types.Parent)(*types.Parent,error){
value,err:=mapping.ToParent(*req);if err!=nil{return nil,err}
return mapping.ToWirePtr(value)
}
`)
writeFile(t, filepath.Join(dir, "consumer_test.go"), readFile(t, "testdata/nullable/consumer_test.go"))
before := snapshot(t, dir)
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("regeneration changed business/config files")
}
o.Check = true
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("check wrote files")
}
runCompositionGo(t, dir, "test", "-race", "-count=1", "./...")
runCompositionGo(t, dir, "build", ".")
// Workspace preflight and check use the same split configuration loader.
workspace := filepath.Join(root, "omni.workspace.json")
writeFile(t, workspace, `{"services":[{"dir":"app"}]}`)
for _, command := range []string{"format", "generate", "check"} {
if err := nullableWorkspace(t, workspace, command); err != nil {
t.Fatal(command, err)
}
}
// All invalid configurations fail before either writing or previewing output.
for _, tc := range []struct {
name, want string
edit func([]Mapping) []Mapping
}{
{"missingNilPolicy", "requires explicit nil", func(d []Mapping) []Mapping {
d[9].Fields["Child"] = MappingField{Source: "Child", Mapping: "ToChild"}
return d
}},
{"preserveValue", "requires explicit nil", func(d []Mapping) []Mapping {
d[9].Fields["Child"] = MappingField{Source: "Child", Mapping: "ToChild", Nil: "preserve"}
return d
}},
{"zeroPointer", "pointer target", func(d []Mapping) []Mapping {
d[1].Fields["Child"] = MappingField{Source: "Child", Mapping: "ToChild", Nil: "zero"}
return d
}},
{"scalarMismatch", "identical *T to T", func(d []Mapping) []Mapping {
d[11].Fields["Text"] = MappingField{Source: "Count", Nil: "zero"}
return d
}},
{"valueNil", "requires a pointer source", func(d []Mapping) []Mapping {
d[5].Fields["Child"] = MappingField{Source: "Child", Mapping: "ToChildDTO", Nil: "zero"}
return d
}},
{"converterNil", "cannot be combined", func(d []Mapping) []Mapping {
f := d[0].Fields["Name"]
f.Nil = "error"
d[0].Fields["Name"] = f
return d
}},
{"pointerNameCollision", "must be unique", func(d []Mapping) []Mapping { m := d[0]; m.Name = "ToParentPtr"; return append(d, m) }},
{"mappingCycle", "cycle", func(d []Mapping) []Mapping {
d[0].Fields["Name"] = MappingField{Source: "Name", Mapping: "ToParent"}
return d
}},
{"unknownMapping", "unknown mapping", func(d []Mapping) []Mapping {
d[1].Fields["Child"] = MappingField{Source: "Child", Mapping: "Missing"}
return d
}},
{"unhandledField", "unmapped source", func(d []Mapping) []Mapping { d[9].IgnoreSource = nil; return d }},
} {
t.Run(tc.name, func(t *testing.T) {
changed := tc.edit(nullableMappings())
writeMappingFile(t, dir, "mappings/parents.json", changed[1:])
writeMappingFile(t, dir, "mappings/children.json", changed[:1])
defer writeMappingFile(t, dir, "mappings/parents.json", defs[1:])
defer writeMappingFile(t, dir, "mappings/children.json", defs[:1])
before := snapshot(t, dir)
for _, mode := range []string{"check", "preview", "generate"} {
current := o
current.Check = mode == "check"
current.DryRun = mode == "preview"
err := generateManagedTestProject(t, current)
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatal(mode, err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("invalid mapping wrote files")
}
}
})
}
// An edit inside a child file is picked up by --check; the config is never rewritten.
changed := nullableMappings()
changed[0].PointerResult = true
writeMappingFile(t, dir, "mappings/children.json", changed[:1])
before = snapshot(t, dir)
if err := generateManagedTestProject(t, o); err == nil {
t.Fatal("check missed child mapping drift")
}
if err := nullableWorkspace(t, workspace, "check"); err == nil {
t.Fatal("workspace missed child mapping drift")
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("drift check wrote files")
}
preview := o
preview.Check = false
preview.DryRun = true
if err := generateManagedTestProject(t, preview); err != nil {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("preview wrote files")
}
if err := nullableWorkspace(t, workspace, "generate"); err != nil {
t.Fatal(err)
}
if err := nullableWorkspace(t, workspace, "check"); err != nil {
t.Fatal(err)
}
if os.Getenv("GO_OMNI_RELEASE_CLI") != "" {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
data, err := cmd.CombinedOutput()
if err != nil {
t.Fatal(err, string(data))
}
var dependency struct {
Version, Sum string
Replace any
}
if err = json.Unmarshal(data, &dependency); err != nil {
t.Fatal(err)
}
if dependency.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dependency.Replace != nil || dependency.Sum == "" {
t.Fatal("not a fixed remote consumer")
}
t.Logf("remote framework %s checksum %s; no local replace", dependency.Version, dependency.Sum)
}
}
func nullableWorkspace(t *testing.T, path, command string) error {
t.Helper()
cli := os.Getenv("GO_OMNI_RELEASE_CLI")
if cli == "" {
return Workspace(path, command, io.Discard)
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, cli, "workspace", command, "--file", path)
cmd.Dir = filepath.Dir(path)
if data, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("released workspace CLI: %w\n%s", err, data)
}
return nil
}

View File

@@ -0,0 +1,97 @@
package apigen
import (
"fmt"
"go/types"
)
func mappingOrigin(m Mapping) string {
if m.origin != "" {
return m.origin
}
return generatorConfigFile
}
// Pointer adaptation is only selected by an explicit nested mapping. Ordinary
// same-type pointer assignments retain their existing reference semantics.
func (r *mappingRenderer) nestedExpression(rule MappingField, from, to types.Type, source string) (string, bool, error) {
m := r.types[rule.Mapping]
fromValue, toValue := from, to
src, sourcePointer := from.Underlying().(*types.Pointer)
dst, targetPointer := to.Underlying().(*types.Pointer)
if sourcePointer {
fromValue = src.Elem()
}
if targetPointer {
toValue = dst.Elem()
}
if !types.Identical(fromValue, m.source) || !types.Identical(toValue, m.target) {
return "", false, fmt.Errorf("nested mapping %s has incompatible types", rule.Mapping)
}
if !sourcePointer && rule.Nil != "" {
return "", false, fmt.Errorf("nil policy requires a pointer source")
}
if !sourcePointer && !targetPointer {
return rule.Mapping + "(" + source + ")", true, nil
}
policy := rule.Nil
if sourcePointer {
if targetPointer {
if policy == "" {
policy = "preserve"
}
if policy != "preserve" && policy != "error" {
return "", false, fmt.Errorf("pointer target requires nil preserve or error")
}
} else if policy != "zero" && policy != "error" {
return "", false, fmt.Errorf("pointer to value mapping requires explicit nil policy zero or error")
}
}
// Keep a typed local input so the source is evaluated once. The surrounding
// renderer adds the mapping/field/index error path and returns a zero parent.
body := "var zero " + r.typeName(to) + "\n"
input := "input"
if sourcePointer {
nilResult, err := r.nilResult(policy)
if err != nil {
return "", false, err
}
body += "if input==nil {" + nilResult + "}\n"
input = "*input"
}
body += "value,err:=" + rule.Mapping + "(" + input + ")\nif err!=nil{return zero,err}\n"
if targetPointer {
body += "return &value,nil\n"
} else {
body += "return value,nil\n"
}
return "func(input " + r.typeName(from) + ")(" + r.typeName(to) + ",error){\n" + body + "}(" + source + ")", true, nil
}
// A nil policy without a converter/mapping is deliberately limited to *T -> T.
// It never casts numbers, changes named types, or invents business defaults.
func (r *mappingRenderer) dereferenceExpression(rule MappingField, from, to types.Type, source string) (string, bool, error) {
pointer, ok := from.Underlying().(*types.Pointer)
if !ok || !types.Identical(pointer.Elem(), to) {
return "", false, fmt.Errorf("nil policy without a mapping requires identical *T to T types")
}
if rule.Nil != "zero" && rule.Nil != "error" {
return "", false, fmt.Errorf("pointer to value conversion requires nil zero or error")
}
nilResult, err := r.nilResult(rule.Nil)
if err != nil {
return "", false, err
}
return "func(input " + r.typeName(from) + ")(" + r.typeName(to) + ",error){\nvar zero " + r.typeName(to) + "\nif input==nil{" + nilResult + "}\nreturn *input,nil\n}(" + source + ")", true, nil
}
func (r *mappingRenderer) nilResult(policy string) (string, error) {
if policy != "error" {
return "return zero,nil", nil
}
pkg, err := r.importer.Import("fmt")
if err != nil {
return "", err
}
return "return zero," + r.qualifier(pkg) + ".Errorf(\"nil source is not allowed\")", nil
}

View File

@@ -0,0 +1,266 @@
package apigen
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func compositionConfig() GeneratorConfig {
pkg := "example.com/composition/internal/"
cfg := GeneratorConfig{Repository: "none", Middleware: "factories"}
for _, name := range []string{"Enterprise", "Tenant", "Admin"} {
fields := map[string]MappingField{
"EmployeeName": {Source: "Name", Convert: &GoFunctionRef{pkg + "convert", "Name"}},
"Salary": {Source: "Salary", Convert: &GoFunctionRef{pkg + "convert", "Money"}},
"Enabled": {Source: "Enabled", Convert: &GoFunctionRef{pkg + "convert", "Bool"}},
}
m := Mapping{Name: "From" + name, Source: GoTypeRef{pkg + "types", name + "Request"}, Target: GoTypeRef{pkg + "domain", "Employee"}, Fields: fields}
if name != "Enterprise" {
m.IgnoreSource = map[string]string{"CompanyID": "Authorized separately by Logic; never copy path scope into body"}
}
cfg.Mappings = append(cfg.Mappings, m)
}
cfg.Mappings = append(cfg.Mappings, Mapping{Name: "ToResponse", Source: GoTypeRef{pkg + "domain", "Result"}, Target: GoTypeRef{pkg + "types", "EmployeeResponse"}, Fields: map[string]MappingField{"ID": {Source: "ID", Convert: &GoFunctionRef{pkg + "convert", "ID"}}}})
cfg.Mappings = append(cfg.Mappings, Mapping{Name: "ToRow", Source: GoTypeRef{pkg + "domain", "Row"}, Target: GoTypeRef{pkg + "domain", "RowDTO"}})
cfg.Mappings = append(cfg.Mappings, Mapping{Name: "ToBatch", Source: GoTypeRef{pkg + "domain", "Batch"}, Target: GoTypeRef{pkg + "domain", "BatchDTO"}, Fields: map[string]MappingField{
"Rows": {Source: "Rows", Mapping: "ToRow", Elements: true}, "Fixed": {Source: "Fixed", Mapping: "ToRow", Elements: true},
}})
return cfg
}
func writeGeneratorConfig(t *testing.T, dir string, cfg GeneratorConfig) {
t.Helper()
raw, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dir, generatorConfigFile), string(raw)+"\n")
}
func runCompositionGo(t *testing.T, dir string, args ...string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("go %v: %v\n%s", args, err, out)
}
}
func TestCompositionMappingConsumer(t *testing.T) {
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") != "1" {
t.Skip("set GO_OMNI_SCAFFOLD_INTEGRATION=1")
}
dir := filepath.Join(t.TempDir(), "app")
o := Options{API: filepath.Join(frameworkRoot(t), "cmd/go-omni/apigen/testdata/composition/service.api"), Dir: dir, Module: "example.com/composition", FrameworkDir: frameworkRoot(t)}
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
fixtures := map[string]string{
"internal/amount/money.go": "amount.go", "internal/domain/employee.go": "domain.go", "internal/convert/fields.go": "converters.go",
"internal/middleware/dependencies.go": "dependencies.go", "internal/middleware/accessFactory.go": "accessFactory.go", "internal/svc/client.go": "client.go", "consumer_test.go": "consumer_test.go",
}
for path, fixture := range fixtures {
writeFile(t, filepath.Join(dir, path), readFile(t, "testdata/composition/"+fixture))
}
svcFile := filepath.Join(dir, "internal/svc/serviceContext.go")
source := strings.Replace(readFile(t, svcFile), "type ServiceContext struct {", "type ServiceContext struct {\n client *Client", 1)
source = strings.Replace(source, "return nil", "return c.initClient(ctx)", 1)
writeFile(t, svcFile, source)
configFile := filepath.Join(dir, "internal/config/config.go")
writeFile(t, configFile, strings.Replace(readFile(t, configFile), "type Config struct {", "type Config struct {\n FactoryMode string `json:\"factoryMode\"`", 1))
// Resolve dependencies before opting into compile-time mapping. The first
// generation creates DTOs; the second sees current DTOs through an overlay.
// Logic does not yet import the generated mapping package.
testSource := readFile(t, filepath.Join(dir, "consumer_test.go"))
if err := os.Remove(filepath.Join(dir, "consumer_test.go")); err != nil {
t.Fatal(err)
}
runCompositionGo(t, dir, "mod", "tidy")
cfg := compositionConfig()
writeGeneratorConfig(t, dir, cfg)
o.API = filepath.Join(dir, "api/service.api")
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dir, "consumer_test.go"), testSource)
for _, group := range []string{"Enterprise", "Tenant", "Admin"} {
text := fmt.Sprintf(`package %s
import("context";"example.com/composition/internal/types";"example.com/composition/internal/mapping";"example.com/composition/internal/domain")
func(s *Logic)Save(ctx context.Context,req *types.%sRequest)(*types.EmployeeResponse,error){
input,err:=mapping.From%s(*req);if err!=nil{return nil,err}
result,err:=mapping.ToResponse(domain.Result{Employee:input,ID:9007199254740993});if err!=nil{return nil,err};return &result,nil
}
`, strings.ToLower(group), group, group)
writeFile(t, filepath.Join(dir, "internal/logic", strings.ToLower(group), "save.go"), text)
}
before := snapshot(t, dir)
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("regeneration changed project")
}
o.Check = true
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("check changed project")
}
runCompositionGo(t, dir, "test", "-race", "-count=1", "./...")
runCompositionGo(t, dir, "build", ".")
generated := readFile(t, filepath.Join(dir, "internal/mapping/mappingsGen.go"))
if strings.Contains(generated, "encoding/json") || strings.Contains(generated, "reflect") || !strings.Contains(generated, "result.EmployeeName = value") {
t.Fatal("mapping does not emit typed assignments")
}
// Adding a source or target field is detected before any files are written.
for _, tc := range []struct{ name, path, before, after, want string }{
{"source", o.API, "Name *string", "NewField *string `json:\"newField,optional\"`\n Name *string", "unmapped source field NewField"},
{"target", filepath.Join(dir, "internal/domain/employee.go"), "EmployeeName string", "Unmapped string\n EmployeeName string", "target Unmapped has no source"},
} {
t.Run(tc.name, func(t *testing.T) {
original := readFile(t, tc.path)
writeFile(t, tc.path, strings.Replace(original, tc.before, tc.after, 1))
defer writeFile(t, tc.path, original)
snap := snapshot(t, dir)
if err := generateManagedTestProject(t, o); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatal(err)
}
if !sameFiles(snap, snapshot(t, dir)) {
t.Fatal("failed check wrote files")
}
o.Check = false
defer func() { o.Check = true }()
if err := generateManagedTestProject(t, o); err == nil {
t.Fatal("generate accepted missing mapping")
}
if !sameFiles(snap, snapshot(t, dir)) {
t.Fatal("failed generate wrote files")
}
})
}
for _, tc := range []struct {
name string
edit func(*GeneratorConfig)
want string
}{
{"pointerPolicy", func(c *GeneratorConfig) { delete(c.Mappings[0].Fields, "Salary") }, "provide an explicit converter"},
{"unknownSource", func(c *GeneratorConfig) { c.Mappings[0].Fields["Salary"] = MappingField{Source: "Missing"} }, "has no source"},
{"wrongConverter", func(c *GeneratorConfig) {
f := c.Mappings[0].Fields["Salary"]
f.Convert.Name = "ID"
c.Mappings[0].Fields["Salary"] = f
}, "signature does not match"},
{"unhandledScope", func(c *GeneratorConfig) { c.Mappings[1].IgnoreSource = nil }, "unmapped source field CompanyID"},
{"badIgnore", func(c *GeneratorConfig) { c.Mappings[1].IgnoreSource["Missing"] = "stale rule" }, "ignoreSource Missing"},
{"arrayLength", func(c *GeneratorConfig) {
f := c.Mappings[5].Fields["Fixed"]
f.Source = "Rows"
c.Mappings[5].Fields["Fixed"] = f
}, "array lengths must match"},
} {
t.Run(tc.name, func(t *testing.T) {
changed := compositionConfig()
tc.edit(&changed)
writeGeneratorConfig(t, dir, changed)
defer writeGeneratorConfig(t, dir, cfg)
snap := snapshot(t, dir)
if err := generateManagedTestProject(t, o); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatal(err)
}
if !sameFiles(snap, snapshot(t, dir)) {
t.Fatal("invalid rule wrote files")
}
})
}
// All diagnostics exercise current DTOs, not stale exported generated types.
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
// Exercise the repository switch with the same actual CLI as the mapper.
if _, err := os.Stat(filepath.Join(dir, "internal/repository")); !os.IsNotExist(err) {
t.Fatalf("default repository skeleton: %v", err)
}
o.Check = false
cfg.Repository = "group"
writeGeneratorConfig(t, dir, cfg)
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
repo := filepath.Join(dir, "internal/repository/enterprise/repository.go")
writeFile(t, repo, readFile(t, repo)+"\n// Project-owned persistence.\n")
cfg.Repository = "none"
writeGeneratorConfig(t, dir, cfg)
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
if !strings.Contains(readFile(t, repo), "Project-owned persistence.") {
t.Fatal("disabling skeleton overwrote business code")
}
o.Check = true
if err := generateManagedTestProject(t, o); err != nil {
t.Fatal(err)
}
if os.Getenv("GO_OMNI_RELEASE_CLI") != "" {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off")
data, err := cmd.CombinedOutput()
if err != nil {
t.Fatal(err, string(data))
}
var dependency struct {
Version, Sum string
Replace any
}
if err = json.Unmarshal(data, &dependency); err != nil {
t.Fatal(err)
}
if dependency.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || dependency.Replace != nil || dependency.Sum == "" {
t.Fatal("not a fixed remote consumer")
}
t.Logf("remote framework %s checksum %s; no local replace", dependency.Version, dependency.Sum)
}
}
func TestMappingConfigurationRejection(t *testing.T) {
cases := []struct {
name string
edit func(*GeneratorConfig)
want string
}{
{"cycle", func(c *GeneratorConfig) {
c.Mappings[4].Fields = map[string]MappingField{"Value": {Source: "Value", Mapping: "ToRow"}}
}, "cycle"},
{"unknown", func(c *GeneratorConfig) {
c.Mappings[5].Fields["Rows"] = MappingField{Source: "Rows", Mapping: "Missing", Elements: true}
}, "unknown mapping"},
{"conflict", func(c *GeneratorConfig) {
f := c.Mappings[0].Fields["Salary"]
f.Mapping = "ToRow"
c.Mappings[0].Fields["Salary"] = f
}, "conflicting converter"},
{"internal", func(c *GeneratorConfig) { c.Mappings[0].Source.Package = "example.net/private/internal/types" }, "invalid Go type"},
{"outputCycle", func(c *GeneratorConfig) { c.Mappings[0].Source.Package = "example.com/composition/internal/mapping" }, "invalid Go type"},
{"duplicates", func(c *GeneratorConfig) { c.Mappings[1].Name = c.Mappings[0].Name }, "must be unique"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := compositionConfig()
tc.edit(&cfg)
if _, err := validateMappings(cfg.Mappings, "example.com/composition"); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatal(err)
}
})
}
}

View File

@@ -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()
@@ -238,6 +238,7 @@ func TestDeclaredContractConsumer(t *testing.T) {
source := filepath.Join(root, "input/service.api")
writeFile(t, source, declaredAPI)
dir := filepath.Join(root, "project")
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"middleware":"handlers"}`)
if _, err := Generate(Options{API: source, Dir: dir, Module: "example.com/dx", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
@@ -303,3 +304,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
}
}

View File

@@ -0,0 +1,19 @@
package apigen
import "fmt"
func renderMiddlewareFactories(names []string, generated, seeds map[string][]byte) {
// All project-specific types live behind this project-owned interface. svc
// implements it; middleware never imports svc (and cannot form that cycle).
seeds["internal/middleware/dependencies.go"] = []byte("package middleware\n// Dependencies is implemented by *svc.ServiceContext. Add typed accessors here\n// and implement them on ServiceContext. Factories borrow dependencies; register\n// their cleanup in ServiceContext.Init, not in a middleware factory.\ntype Dependencies interface {}\n")
text := fmt.Sprintf("package middleware\nimport(\"fmt\";%q)\n// New constructs declared middleware after ServiceContext.Init succeeds.\nfunc New(deps Dependencies)(map[string]httpx.Middleware,error){\nresult:=map[string]httpx.Middleware{}\n", frameworkModule+"/httpx")
if len(names) == 0 {
text = fmt.Sprintf("package middleware\nimport %q\nfunc New(deps Dependencies)(map[string]httpx.Middleware,error){\nresult:=map[string]httpx.Middleware{}\n", frameworkModule+"/httpx")
}
for i, name := range names {
delete(seeds, "internal/middleware/"+lower(name)+".go")
text += fmt.Sprintf("m%d,err:=New%s(deps)\nif err!=nil{return nil,fmt.Errorf(%q,err)}\nif m%d==nil{return nil,fmt.Errorf(%q)}\nresult[%q]=m%d\n", i, upper(name), "middleware "+name+": %w", i, "middleware "+name+": factory returned nil", name, i)
seeds["internal/middleware/"+lower(name)+"Factory.go"] = []byte(fmt.Sprintf("package middleware\nimport(\"errors\";%q)\n// New%s is project-owned. Implement this factory once; registration is generated.\n// Dependencies have already been initialized. Do not acquire owned resources here.\nfunc New%s(deps Dependencies)(httpx.Middleware,error){\nreturn nil,errors.New(%q)\n}\n", frameworkModule+"/httpx", upper(name), upper(name), "implement "+name+" middleware factory"))
}
generated["internal/middleware/registryGen.go"] = []byte(generatedHeader + text + "return result,nil\n}\n")
}

View File

@@ -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,14 @@ type Type struct {
Fields []Field
Position string
}
type Field struct{ Name, Type, Tag string }
// An empty Type denotes a named field composition, expanded before compilation.
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 +64,7 @@ type Route struct {
}
type token struct {
text, position string
comment string
kind rune
space bool
}
@@ -61,7 +75,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 +87,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 +104,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 +148,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 +193,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 +203,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 +382,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 +501,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
@@ -439,16 +519,22 @@ func (p *parser) parseType() error {
}
}
if expression == "" {
return p.err("field type required; embedded types are not supported")
if p.tokens[p.at].kind == scanner.RawString {
return p.err("composed types cannot carry field tags")
}
typ.Fields = append(typ.Fields, Field{Name: name, Description: description})
continue
}
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 +582,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 +681,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)
}

View File

@@ -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 := ""

View File

@@ -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)
}

View 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.") }

View File

@@ -251,6 +251,7 @@ func TestResponseTemplateConsumer(t *testing.T) {
t.Fatal(err)
}
}
writeFile(t, filepath.Join(dir, generatorConfigFile), `{"middleware":"handlers"}`)
generate(source, true)
writeFile(t, filepath.Join(dir, "response_test.go"), responseConsumerTest)
for _, method := range []string{"Item", "Callback", "Partner"} {

View 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
}

View 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")
}
}

View File

@@ -20,8 +20,11 @@ import (
func NewApp(deps *svc.ServiceContext)(*omni.App,error){
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", 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 {

View File

@@ -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,15 +28,13 @@ 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())
if cause:=deps.Lifecycle.Err();cause!=nil && !errors.Is(runErr,cause){runErr=errors.Join(runErr,cause)}
}()
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)
@@ -52,6 +51,16 @@ This project is generated from an editable API contract. Business code uses
standard context.Context; generated HTTP handlers use Omni's transport-neutral httpx.
main.go and HTTP composition are continuously generator-owned.
omni.gen.json selects repository: none (default) or group, and middleware:
factories for new projects. Named middleware factories run after svc.Init;
implement Dependencies accessors on ServiceContext and New<Name>(deps) once.
Unimplemented/nil factories fail startup and clean registered dependencies.
Existing projects retain middleware: handlers until explicitly migrated.
Optional mappings generate typed assignments in internal/mapping/mappingsGen.go;
configure source/target types and explicit conversion rules after resolving module
dependencies. Missing fields fail generation and --check. See GENERATION.md in
the framework repository for composition, null/reference semantics and migration.
## Run
`+"```sh"+`
@@ -111,6 +120,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"+`
@@ -119,12 +138,15 @@ 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
internal/repository/<group>/ # developer-owned persistence contracts
internal/middleware/ # named hooks and generated registry
internal/repository/<group>/ # optional; repository: group creates editable seeds
internal/middleware/ # typed dependency interface, factories and registry
omni.gen.json # developer-owned generator options and mappings
internal/mapping/*Gen.go # optional compile-checked struct mappings
docs/openapi.json # generated OpenAPI 3.0.3
.goOmni/apiManifest.json # ownership and content hashes
`+"```"+`
@@ -138,7 +160,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.
@@ -146,11 +168,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.
@@ -171,12 +192,21 @@ 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.
@@ -208,7 +238,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.

View 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)
}
}
}

View 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 }

View 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)
}
}
}

View 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
}

View 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())
}
}

View 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
}

View File

@@ -0,0 +1,30 @@
package middleware
import (
"errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
)
var ErrFactory = errors.New("factory fault")
func NewAccess(deps Dependencies) (httpx.Middleware, error) {
if deps.Access() == nil {
return nil, errors.New("dependencies not initialized")
}
switch deps.FactoryMode() {
case "fail":
return nil, ErrFactory
case "nil":
return nil, nil
case "panic":
panic(ErrFactory)
}
return func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
if err := deps.Access().Check(c.Context()); err != nil {
return err
}
return next(c)
}
}, nil
}

View File

@@ -0,0 +1,52 @@
package amount
import (
"encoding/json"
"errors"
"fmt"
"math/big"
"regexp"
)
// This fixture's business policy is exact decimal HALF_UP. The framework does
// not choose its precision, rounding mode or currency.
type Money string
var decimalPattern = regexp.MustCompile(`^-?[0-9]+(\.[0-9]+)?$`)
func (m Money) String() string {
if m == "" {
return "0.00"
}
return string(m)
}
func (m Money) MarshalJSON() ([]byte, error) { return json.Marshal(m.String()) }
func (m *Money) UnmarshalJSON(data []byte) error {
var text string
if err := json.Unmarshal(data, &text); err != nil {
return err
}
if !decimalPattern.MatchString(text) {
return errors.New("invalid decimal")
}
value, ok := new(big.Rat).SetString(text)
if !ok {
return errors.New("invalid decimal")
}
value.Mul(value, big.NewRat(100, 1))
sign := value.Sign()
value.Abs(value)
cents, rem := new(big.Int), new(big.Int)
cents.QuoRem(value.Num(), value.Denom(), rem)
if rem.Mul(rem, big.NewInt(2)).Cmp(value.Denom()) >= 0 {
cents.Add(cents, big.NewInt(1))
}
whole, fraction := new(big.Int), new(big.Int)
whole.QuoRem(cents, big.NewInt(100), fraction)
prefix := ""
if sign < 0 && cents.Sign() != 0 {
prefix = "-"
}
*m = Money(fmt.Sprintf("%s%s.%02d", prefix, whole.String(), fraction.Int64()))
return nil
}

View File

@@ -0,0 +1,36 @@
package svc
import (
"context"
"errors"
"example.com/composition/internal/middleware"
"sync/atomic"
)
var Closes atomic.Int64
var ErrInit = errors.New("init fault")
type Client struct{ closed atomic.Bool }
func (c *Client) Check(ctx context.Context) error {
if c.closed.Load() {
return errors.New("client closed before HTTP drained")
}
return ctx.Err()
}
func (c *ServiceContext) initClient(ctx context.Context) error {
c.client = &Client{}
c.OnClose(func() error {
if !c.client.closed.CompareAndSwap(false, true) {
return errors.New("double close")
}
Closes.Add(1)
return nil
})
if c.Config.FactoryMode == "initFail" {
return ErrInit
}
return nil
}
func (c *ServiceContext) Access() middleware.AccessChecker { return c.client }
func (c *ServiceContext) FactoryMode() string { return c.Config.FactoryMode }

View File

@@ -0,0 +1,218 @@
package main
import (
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"example.com/composition/internal/amount"
"example.com/composition/internal/config"
"example.com/composition/internal/convert"
"example.com/composition/internal/domain"
"example.com/composition/internal/handler"
"example.com/composition/internal/mapping"
"example.com/composition/internal/middleware"
"example.com/composition/internal/svc"
"example.com/composition/internal/types"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func logger(t *testing.T) *logx.Logger {
t.Helper()
cfg := logx.Default()
cfg.Writer = io.Discard
l, e := logx.New(cfg)
if e != nil {
t.Fatal(e)
}
t.Cleanup(func() { _ = l.Close() })
return l
}
func TestHTTPComposition(t *testing.T) {
deps, e := svc.New(context.Background(), logger(t))
if e != nil {
t.Fatal(e)
}
defer deps.Close()
app, e := handler.NewApp(deps)
if e != nil {
t.Fatal(e)
}
defer app.Close()
for _, path := range []string{"/enterprise", "/tenant/123", "/admin/123"} {
for _, tc := range []struct {
name, body string
status int
salary, nameValue string
employer any
}{
{"exact", `{"name":"employee","salary":"90071992547409.91","employer":null,"enabled":false}`, 200, "90071992547409.91", "employee", nil},
{"round", `{"salary":"1.005","employer":"0"}`, 200, "1.01", "", "0.00"},
{"missing", `{}`, 200, "0.00", "", nil},
{"null", `{"name":null,"salary":null,"employer":null,"enabled":null}`, 200, "0.00", "", nil},
{"zero", `{"salary":"0","enabled":false}`, 200, "0.00", "", nil},
{"invalid", `{"salary":"abc"}`, 400, "", "", nil},
{"empty", `{"salary":""}`, 400, "", "", nil},
{"numeric", `{"salary":100}`, 400, "", "", nil},
{"flat", `{"SalaryFields":{"salary":"1"}}`, 400, "", "", nil},
{"validation", `{"name":"` + strings.Repeat("x", 81) + `"}`, 400, "", "", nil},
} {
t.Run(path+tc.name, func(t *testing.T) {
req := httptest.NewRequest("POST", path, strings.NewReader(tc.body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
app.Engine().ServeHTTP(w, req)
if w.Code != tc.status {
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
}
if w.Code == 200 {
var out map[string]any
if e := json.Unmarshal(w.Body.Bytes(), &out); e != nil {
t.Fatal(e)
}
if out["salary"] != tc.salary || out["employeeName"] != tc.nameValue || out["employer"] != tc.employer || out["id"] != "9007199254740993" {
t.Fatal(out)
}
}
})
}
}
}
func TestFullFormAndMappingSemantics(t *testing.T) {
name := "employee"
money := amount.Money("2.00")
yes := true
req := types.EnterpriseRequest{Name: &name, Salary: &money, Enabled: &yes, Labels: []string{"one"}}
first, e := mapping.FromEnterprise(req)
if e != nil {
t.Fatal(e)
}
second, e := mapping.FromEnterprise(types.EnterpriseRequest{})
if e != nil {
t.Fatal(e)
}
if first.EmployeeName != "employee" || !first.Enabled || second.EmployeeName != "" || second.Enabled || second.Salary.String() != "0.00" || second.Employer != nil {
t.Fatal("full-form update became patch behavior")
}
// Direct assignments retain standard Go aliasing, including scalar pointers.
first.Labels[0] = "shared"
if req.Labels[0] != "shared" {
t.Fatal("unexpected implicit deep copy")
}
req.Employer = &money
first, e = mapping.FromEnterprise(req)
if e != nil || first.Employer != req.Employer {
t.Fatal("nullable pointer changed", e)
}
bad := "reject"
failed, e := mapping.FromEnterprise(types.EnterpriseRequest{Name: &bad})
if !errors.Is(e, convert.ErrName) || failed.EmployeeName != "" || !strings.Contains(e.Error(), "EmployeeName") {
t.Fatal("converter cause/zero result lost", e)
}
empty, e := mapping.ToBatch(domain.Batch{})
if e != nil || empty.Rows != nil {
t.Fatal("nil slice changed", e)
}
src := domain.Batch{Rows: []domain.Row{}, Fixed: [2]domain.Row{{Value: "a"}, {Value: "b"}}, Optional: &name}
out, e := mapping.ToBatch(src)
if e != nil || out.Rows == nil || len(out.Rows) != 0 || out.Fixed[1].Value != "b" || out.Optional != src.Optional {
t.Fatal(out, e)
}
src.Rows = []domain.Row{{Value: "one"}}
out, e = mapping.ToBatch(src)
if e != nil || out.Rows[0].Value != "one" {
t.Fatal(out, e)
}
out.Rows[0].Value = "changed"
if src.Rows[0].Value != "one" {
t.Fatal("element mapping reused input slice")
}
var wg sync.WaitGroup
for i := 0; i < 32; i++ {
wg.Add(1)
go func() {
defer wg.Done()
result, err := mapping.FromEnterprise(types.EnterpriseRequest{Name: &name, Salary: &money})
if err != nil || result.EmployeeName != name || result.Salary != money {
t.Error(result, err)
}
}()
}
wg.Wait()
}
func TestFactoryLifecycle(t *testing.T) {
for _, mode := range []string{"fail", "nil", "initFail", "panic"} {
t.Run(mode, func(t *testing.T) {
before := svc.Closes.Load()
cfg := config.Default()
cfg.FactoryMode = mode
if mode == "panic" {
func() {
defer func() {
if recover() != middleware.ErrFactory {
t.Error("panic cause changed")
}
}()
_, _ = svc.New(context.Background(), logger(t), cfg)
}()
} else {
deps, err := svc.New(context.Background(), logger(t), cfg)
if deps != nil || err == nil {
t.Fatal("factory did not fail closed")
}
if mode == "fail" && !errors.Is(err, middleware.ErrFactory) {
t.Fatal("factory cause lost", err)
}
if mode == "initFail" && !errors.Is(err, svc.ErrInit) {
t.Fatal("init cause lost", err)
}
}
if svc.Closes.Load() != before+1 {
t.Fatal("dependency not closed exactly once")
}
})
}
deps, e := svc.New(context.Background(), logger(t))
if e != nil {
t.Fatal(e)
}
if e = deps.Access().Check(context.Background()); e != nil {
t.Fatal(e)
}
before := svc.Closes.Load()
if e = deps.Close(); e != nil {
t.Fatal(e)
}
if e = deps.Close(); e != nil {
t.Fatal(e)
}
if svc.Closes.Load() != before+1 {
t.Fatal("normal close repeated")
}
}
func TestGeneratedEntryListenFailure(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
path := filepath.Join(t.TempDir(), "config.yaml")
if err = os.WriteFile(path, []byte("server:\n address: "+listener.Addr().String()+"\n"), 0600); err != nil {
t.Fatal(err)
}
before := svc.Closes.Load()
if err = run(context.Background(), path); err == nil {
t.Fatal("listen should fail")
}
if svc.Closes.Load() != before+1 {
t.Fatal("listen failure did not close dependencies")
}
}

View File

@@ -0,0 +1,29 @@
package convert
import (
"errors"
"example.com/composition/internal/amount"
"strconv"
)
var ErrName = errors.New("rejected name")
// Explicit project rules: full-form omission clears strings/bools and uses zero
// for required money. Nullable employer amounts remain nil by direct assignment.
func Name(value *string) (string, error) {
if value == nil {
return "", nil
}
if *value == "reject" {
return "", ErrName
}
return *value, nil
}
func Money(value *amount.Money) amount.Money {
if value == nil {
return ""
}
return *value
}
func Bool(value *bool) bool { return value != nil && *value }
func ID(value int64) string { return strconv.FormatInt(value, 10) }

View File

@@ -0,0 +1,9 @@
package middleware
import "context"
type AccessChecker interface{ Check(context.Context) error }
type Dependencies interface {
Access() AccessChecker
FactoryMode() string
}

View File

@@ -0,0 +1,29 @@
package domain
import "example.com/composition/internal/amount"
type Employee struct {
EmployeeName string
Salary amount.Money
Employer *amount.Money
Enabled bool
Labels []string
}
type Result struct {
Employee
ID int64
}
// Distinct container item types exercise explicitly selected element mappings.
type Row struct{ Value string }
type RowDTO struct{ Value string }
type Batch struct {
Rows []Row
Fixed [2]Row
Optional *string
}
type BatchDTO struct {
Rows []RowDTO
Fixed [2]RowDTO
Optional *string
}

View File

@@ -0,0 +1,63 @@
scalar Money (
goImport: "example.com/composition/internal/amount"
goType: Money
jsonInput: string
jsonOutput: string
)
type SalaryFields {
// Employee display name.
Name *string `json:"name,optional" binding:"max=80"`
Salary *Money `json:"salary,optional"`
Employer *Money `json:"employer,optional"`
Enabled *bool `json:"enabled,optional"`
Labels []string `json:"labels,optional"`
}
type EnterpriseRequest {
SalaryFields
}
type TenantRequest {
SalaryFields
CompanyID string `path:"companyId"`
}
type AdminRequest {
SalaryFields
CompanyID string `path:"companyId"`
}
type EmployeeResponse {
ID string `json:"id"`
EmployeeName string `json:"employeeName"`
Salary Money `json:"salary"`
Employer *Money `json:"employer"`
Enabled bool `json:"enabled"`
Labels []string `json:"labels"`
}
@server (
group: enterprise
middleware: Access
response: raw
)
service composition {
@handler save
post /enterprise (EnterpriseRequest) returns (EmployeeResponse)
}
@server (
group: tenant
middleware: Access
response: raw
)
service composition {
@handler save
post /tenant/:companyId (TenantRequest) returns (EmployeeResponse)
}
@server (
group: admin
middleware: Access
response: raw
)
service composition {
@handler save
post /admin/:companyId (AdminRequest) returns (EmployeeResponse)
}

View 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
}

View File

@@ -20,6 +20,11 @@ func initClient(ctx context.Context, c *ServiceContext) error {
Opened.Add(1)
c.Client = &Client{}
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")
}

View 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))
}
}

View File

@@ -0,0 +1,170 @@
package main
import (
"context"
"encoding/json"
"errors"
"io"
"net/http/httptest"
"strings"
"sync"
"testing"
"example.com/nullable/internal/convert"
"example.com/nullable/internal/domain"
"example.com/nullable/internal/handler"
"example.com/nullable/internal/mapping"
"example.com/nullable/internal/svc"
"example.com/nullable/internal/types"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func TestNullableMappings(t *testing.T) {
for _, src := range []types.Parent{{}, {Children: []*types.Child{}}, {Child: &types.Child{}, Children: []*types.Child{nil, {Name: "two"}}}} {
out, err := mapping.ToParent(src)
if err != nil || (src.Child == nil) != (out.Child == nil) || (src.Children == nil) != (out.Children == nil) || len(src.Children) != len(out.Children) {
t.Fatal(src, out, err)
}
if src.Child != nil && out.Child.Name != src.Child.Name {
t.Fatal("zero child changed")
}
if len(out.Children) > 0 && (out.Children[0] != nil || out.Children[1].Name != "two") {
t.Fatal(out)
}
}
src := types.Parent{Child: &types.Child{Name: "one"}, Children: []*types.Child{{Name: "two"}}}
out, err := mapping.ToParentPtr(src)
if err != nil || out == nil || out.Child.Name != "one" {
t.Fatal(out, err)
}
out.Child.Name = "changed"
out.Children[0].Name = "changed"
if src.Child.Name != "one" || src.Children[0].Name != "two" {
t.Fatal("nested mapped values alias input")
}
plain := domain.Parent{Child: &domain.Child{Name: "shared"}}
copy, err := mapping.ShallowCopy(plain)
if err != nil || copy.Child != plain.Child {
t.Fatal("ordinary assignment semantics changed", err)
}
zero, err := mapping.RequiredZero(types.Parent{})
if err != nil || zero.Child.Name != "" {
t.Fatal(zero, err)
}
for _, fn := range []func(types.Parent) (domain.Required, error){mapping.RequiredZero, mapping.RequiredError} {
out, err := fn(types.Parent{Child: &types.Child{Name: "required"}})
if err != nil || out.Child.Name != "required" {
t.Fatal(out, err)
}
}
_, err = mapping.RequiredError(types.Parent{})
if err == nil || !strings.Contains(err.Error(), "RequiredError.Child") {
t.Fatal(err)
}
_, err = mapping.RequirePointer(types.Parent{})
if err == nil || !strings.Contains(err.Error(), "RequirePointer.Child") {
t.Fatal(err)
}
lift, err := mapping.Lift(domain.ValueHolder{Child: domain.Child{Name: "lift"}})
if err != nil || lift.Child == nil || lift.Child.Name != "lift" {
t.Fatal(lift, err)
}
batch, err := mapping.Batch(domain.InputBatch{Fixed: [2]*domain.Child{nil, {Name: "array"}}})
if err != nil || batch.Fixed[0] != nil || batch.Fixed[1].Name != "array" {
t.Fatal(batch, err)
}
_, err = mapping.Batch(domain.InputBatch{Fixed: [2]*domain.Child{nil, {Name: "bad"}}})
if !errors.Is(err, convert.ErrValue) || !strings.Contains(err.Error(), "Batch.Fixed[1]") {
t.Fatal(err)
}
for _, input := range []types.Parent{{Child: &types.Child{Name: "bad"}}, {Children: []*types.Child{nil, {Name: "bad"}}}} {
failed, err := mapping.ToParentPtr(input)
if failed != nil || !errors.Is(err, convert.ErrValue) || !strings.Contains(err.Error(), "ToChild.Name") {
t.Fatal(failed, err)
}
if len(input.Children) > 0 && !strings.Contains(err.Error(), "ToParent.Children[1]") {
t.Fatal(err)
}
value, err := mapping.ToParent(input)
if value.Child != nil || value.Children != nil || !errors.Is(err, convert.ErrValue) {
t.Fatal("partial result escaped", err)
}
}
}
func TestExplicitNilAndConcurrentCalls(t *testing.T) {
zero, err := mapping.ScalarZero(domain.ScalarInput{})
if err != nil || zero != (domain.ScalarOutput{}) {
t.Fatal(zero, err)
}
_, err = mapping.ScalarError(domain.ScalarInput{})
if err == nil {
t.Fatal("nil accepted")
}
text, count, enabled := "", int64(0), false
out, err := mapping.ScalarError(domain.ScalarInput{Text: &text, Count: &count, Enabled: &enabled})
if err != nil || out != (domain.ScalarOutput{}) {
t.Fatal("non-nil zero rejected", out, err)
}
var wg sync.WaitGroup
for i := 0; i < 32; i++ {
wg.Add(1)
go func() {
defer wg.Done()
out, err := mapping.ToParent(types.Parent{Child: &types.Child{Name: "parallel"}})
if err != nil || out.Child == nil || out.Child.Name != "parallel" {
t.Error(out, err)
}
}()
}
wg.Wait()
}
func TestHTTPNullableMapping(t *testing.T) {
cfg := logx.Default()
cfg.Writer = io.Discard
logger, err := logx.New(cfg)
if err != nil {
t.Fatal(err)
}
defer logger.Close()
deps, err := svc.New(context.Background(), logger)
if err != nil {
t.Fatal(err)
}
defer deps.Close()
app, err := handler.NewApp(deps)
if err != nil {
t.Fatal(err)
}
defer app.Close()
for _, body := range []string{`{}`, `{"child":null}`, `{"child":{"name":""}}`, `{"child":{"name":"one"},"children":[null,{"name":"two"}]}`, `{"children":[]}`} {
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
app.Engine().ServeHTTP(w, req)
if w.Code != 200 {
t.Fatal(w.Code, w.Body.String())
}
var input, output types.Parent
if err := json.Unmarshal([]byte(body), &input); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(w.Body.Bytes(), &output); err != nil {
t.Fatal(err)
}
if (input.Child == nil) != (output.Child == nil) || (input.Children == nil) != (output.Children == nil) || len(input.Children) != len(output.Children) {
t.Fatal(body, w.Body.String())
}
if input.Child != nil && input.Child.Name != output.Child.Name {
t.Fatal(w.Body.String())
}
}
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/", strings.NewReader(`{"child":{"name":"bad"}}`))
req.Header.Set("Content-Type", "application/json")
app.Engine().ServeHTTP(w, req)
if w.Code != 500 || strings.Contains(w.Body.String(), convert.ErrValue.Error()) || strings.Contains(w.Body.String(), "ToChild") {
t.Fatal("private cause leaked", w.Code, w.Body.String())
}
}

View File

@@ -0,0 +1,15 @@
package convert
import (
"errors"
"fmt"
)
var ErrValue = errors.New("private conversion cause")
func Name(value string) (string, error) {
if value == "bad" {
return "", fmt.Errorf("conversion refused: %w", ErrValue)
}
return value, nil
}

View File

@@ -0,0 +1,23 @@
package domain
type Child struct{ Name string }
type ChildDTO struct{ Name string }
type Parent struct {
Child *Child
Children []*Child
}
type Required struct{ Child Child }
type ValueHolder struct{ Child Child }
type PointerHolder struct{ Child *ChildDTO }
type InputBatch struct{ Fixed [2]*Child }
type OutputBatch struct{ Fixed [2]*ChildDTO }
type ScalarInput struct {
Text *string
Count *int64
Enabled *bool
}
type ScalarOutput struct {
Text string
Count int64
Enabled bool
}

View File

@@ -0,0 +1,15 @@
type Child {
Name string `json:"name"`
}
type Parent {
Child *Child `json:"child,optional"`
Children []*Child `json:"children,optional"`
}
@server (
group: probe
response: raw
)
service probe {
@handler roundTrip
post / (Parent) returns (Parent)
}

View 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
}

View 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")
}
}
}

View 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
}

View 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)
}

View 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")
}
}

View 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")
}
}
}

View File

@@ -16,12 +16,14 @@ 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"`
GeneratorConfig bool `json:"generatorConfig,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 +32,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 +63,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 +110,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{GeneratorConfig: true, 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 +273,13 @@ 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 name == "internal/mapping/mappingsGen.go" {
return true
}
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" {

View File

@@ -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")

View File

@@ -8,7 +8,7 @@ import (
)
func TestNewWithRemoteFrameworkVersion(t *testing.T) {
for _, flag := range [][]string{{"--framework-version", "v0.13.0-preview"}, {"--framework-version=v0.13.0-preview"}} {
for _, flag := range [][]string{{"--framework-version", "v0.18.0-preview"}, {"--framework-version=v0.18.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.13.0-preview") || strings.Contains(string(content), "replace ") {
if !strings.Contains(string(content), "go-omni v0.18.0-preview") || strings.Contains(string(content), "replace ") {
t.Fatalf("CLI did not pin remote dependency: %s", content)
}
}

View File

@@ -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)

View 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}}

View File

@@ -1,26 +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 *lifecycle.Controller
*lifecycle.Runtime
Config settings.Config
cleanups []func()error
Logger *logx.Logger
Health *health.Checker
Middlewares map[string]httpx.Middleware
@@ -31,38 +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,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)}
// 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}

View File

@@ -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}

View 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
}

View 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)
}

View File

@@ -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)}

View File

@@ -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}

View File

@@ -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}}

View File

@@ -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}}

View File

@@ -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
}

View File

@@ -4,7 +4,11 @@ server:
enableLogger: true
slowRequestThreshold: 500ms
shutdownTimeout: 10s
{{if .API}} endpoints:
{{if .API}} lifecycle:
stopTimeout: 5s
drainTimeout: 10s
cancelTimeout: 5s
endpoints:
liveness:
enabled: true
path: /health/live
@@ -49,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}}

View File

@@ -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

View File

@@ -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.13.0-preview"
const defaultFrameworkVersion = "v0.18.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
View 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
View 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
View 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
View 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
}

View File

@@ -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,20 +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"`
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"`
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 {
@@ -48,6 +53,7 @@ func Default() Config {
Transport: TransportNetHTTP,
RequestIDHeader: defaultRequestIDHeader,
ShutdownTimeout: 10 * time.Second,
Lifecycle: lifecycle.DefaultShutdownConfig(),
Endpoints: DefaultEndpoints(),
Log: logx.Default(),
}
@@ -55,6 +61,15 @@ func Default() Config {
// 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
}
@@ -221,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 }
}

View File

@@ -1,135 +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 _, endpoint := range []struct {
name string
enabled *bool
path *string
mode *string
}{
{"LIVENESS", &cfg.Endpoints.Liveness.Enabled, &cfg.Endpoints.Liveness.Path, &cfg.Endpoints.Liveness.ResponseMode},
{"READINESS", &cfg.Endpoints.Readiness.Enabled, &cfg.Endpoints.Readiness.Path, &cfg.Endpoints.Readiness.ResponseMode},
{"OPENAPI", &cfg.Endpoints.OpenAPI.Enabled, &cfg.Endpoints.OpenAPI.Path, nil},
} {
key := prefix + "ENDPOINTS_" + endpoint.name
*endpoint.enabled, err = envBool(key+"_ENABLED", *endpoint.enabled)
if err != nil {
return Config{}, err
}
*endpoint.path = envString(key+"_PATH", *endpoint.path)
if endpoint.mode != nil {
*endpoint.mode = envString(key+"_RESPONSE_MODE", *endpoint.mode)
}
}
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
}

View File

@@ -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
View 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
View 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()
}

View 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")
}
}

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