Compare commits

..

4 Commits

Author SHA1 Message Date
7670b222ac feat: release configurable API response contracts
Define reusable response bodies in .api with typed code mappings, per-route overrides and matching OpenAPI schemas. Add application and callback encoders, early response policy selection, and isolated remote-consumer release verification.

Release v0.8.0-preview after full race, vet, build and local MySQL workflow acceptance.
2026-09-23 20:42:38 +08:00
bef7ea2641 docs: record remote API developer experience acceptance 2026-09-23 18:19:12 +08:00
2913e94a0a feat: declare API errors and authentication with developer tooling 2026-09-23 18:17:43 +08:00
ab8eac5fc4 docs: record remote preview acceptance and verify example error logging 2026-09-23 17:37:53 +08:00
72 changed files with 5280 additions and 192 deletions

114
API.md
View File

@@ -89,21 +89,126 @@ service user-api {
}
```
类型和 Go 字段使用导出命名;JSON、query、path 名称使用 lowerCamelCase;Header 使用 HTTP 标准名称。响应类型只定义 `data`,统一响应的 `code`、`message`、`requestId` 由框架添加。
类型和 Go 字段使用导出命名;JSON、query、path 名称使用 lowerCamelCase;Header 使用 HTTP 标准名称。响应类型定义业务返回值。未配置时使用默认公共体;当前源码支持在 `.api` 中声明公共响应模板,选择字段和结构,并按分组复用或由单接口覆盖,详见 [公共响应体](RESPONSE.md)。
支持:
- 可省略的 `syntax = "v1"`;`info` 中的 title、version 和描述性元数据。
- 单独的 `type X { ... }` 与 `type (...)`;基础标量、指针、嵌套命名类型、切片、字符串键 map。嵌套对象只使用 JSON 字段。
- 相对 `.api` 导入及 `import (...)`,重复导入去重;拒绝循环和越过入口目录的导入。单文件 1 MiB、总计 8 MiB、最多 64 个文件,限制导入/类型深度及 schema 展开规模。
- 同一 service 名称的多个块;`@server` 的 group、prefix、middleware,group 可以写 `admin/user`。
- 每个路由必须有 `@handler`;可选 `@doc "..."`;GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS。
- 同一 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 使用标量。
- 来源标签中的 `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 生成。认证请用命名中间件接入。`@doc` 块等未列出的语法也会明确报错。
暂不支持匿名嵌入、别名、递归类型、外部 Go 类型、自定义 JSON 编解码、`[]byte`、泛型、`json:",string"`、任意 validator 标签、go-zero 的 JWT/签名/SSE 声明,以及客户端 SDK 生成。认证验证通过声明关联的命名中间件实现。未列出的语法会明确报错。
业务错误、具名鉴权、多行文档及下述开发命令从 CLI `v0.7.0-preview.1` 提供。公共响应模板从 `v0.8.0-preview` 提供,CLI 与业务项目中的框架依赖需一起升级。
## 业务错误:声明、代码和文档共用一份定义
```go
error (
Unauthenticated 401 "UNAUTHENTICATED" "authentication required"
Forbidden 403 "FORBIDDEN" "permission denied"
UserNotFound 404 "USER_NOT_FOUND" "user not found"
EmailTaken 409 "EMAIL_TAKEN" "email already in use"
)
```
也可写单条 `error EmailTaken 409 "EMAIL_TAKEN" "email already in use"`,或把错误目录放进相对导入的 `errors.api`。
名称用 UpperCamelCase,HTTP 状态范围 400–599,错误码用大写字母/数字/下划线且全局唯一,message 是可公开返回的稳定提示。
`@server (errors: Unauthenticated)` 声明组级错误;路由 `@errors UserNotFound, EmailTaken` 追加到组级列表并去重。
`@errors none` 清空继承的业务错误。未声明、重复声明或生成符号冲突会报告源码位置。
生成 `internal/apierrors/errorsGen.go`:
```go
return nil, apierrors.EmailTaken()
// 可用 apierrors.CodeEmailTaken 引用同一个错误码。
```
每次调用返回新错误,避免并发修改共享错误对象。生成器不会自动把任意数据库错误转成业务错误;由 Service/适配层调用这些函数。
内部 Cause 可能进入访问日志,敏感驱动报错应像 userService 示例一样先归类再转换。
OpenAPI 对每个状态按选定的响应模板生成结构和命名 examples,同一 409 下多个错误不会相互覆盖;平铺模式的纯业务状态还列出 code enum。
默认框架错误状态与自动添加的鉴权 401 保留开放的 code 字段,容纳校验器/中间件实际错误。
`x-omni-errors` 提供错误目录与路由关联。文档声明不拦截、改写或穷举运行时的所有错误。
## 鉴权:声明与路由注册一起变化
```go
auth UserAuth (
type: bearer
middleware: Auth
description: "Application access token"
)
@server (
group: user
prefix: /api/v1
auth: UserAuth
errors: Unauthenticated
)
service user-api {
@errors Forbidden, EmailTaken
@handler createUser
@status 201
post /users (CreateUserReq) returns (UserResp)
@auth none
@errors none
@handler publicStatus
get /status
}
```
`auth` 是具名契约,`middleware` 指向实际 hook;生成器把它放在普通 middleware 前面执行,并在注册任何路由前检查 hook 非空。
新 hook 未实现时返回 501,已有手写 hook 保留。需要在 hook 中验证凭证、失效时间、签发者、权限等;声明不会自动实现登录或 JWT 校验。
- `type: bearer`:对应 HTTP Bearer,可选 `bearerFormat: JWT`,这只是文档提示。
- `type: apiKey`:必须同时声明 `in: header` 或 `in: cookie` 和 `name`,例如 `X-API-Key` 或 `sessionId`。
- `auth: UserAuth` 可在组级使用;路由 `@auth OtherAuth` 替换,`@auth none` 撤销继承的声明鉴权。普通 middleware 继续保留。
- 不要再把该 auth 的 hook 重复写进普通 middleware。不支持 query token、多个方案 AND/OR、OAuth2 scopes 或隐含认证策略。
- 被保护路由自动描述 401;403 和具体错误码由 `@errors` 明确声明。未声明 auth 的旧项目仍按其原有 middleware 执行。
文档生成 `components.securitySchemes` 与逐接口 `security`;显式公共接口使用空数组。
结构遵循 [OpenAPI 3.0.3 Security Requirement](https://spec.openapis.org/oas/v3.0.3.html#security-requirement-object)。
## 文档和日常开发命令
保留单行 `@doc "摘要"`,也可以:
```go
@doc (
summary: "Create a user"
description: "Writes user data and audit in the same transaction"
tags: users, administration
deprecated: false
)
```
未设置 tags 时使用 group 分组。`info` 的 description/desc 也会进入 OpenAPI;两者不能同时声明。
在生成项目目录内,不必重复输入 API 路径:
```sh
go-omni api validate # 发现 manifest 中的入口并校验
go-omni api inspect # 方法、路径、Handler、鉴权与业务错误总览
go-omni api generate --dry-run # 预览新增/更新/移除,完全不写文件
go-omni api generate # 实际生成,保留手写业务
go-omni api generate --check # 只读检查,可用于 CI
go-omni api export > openapi.json # 独立导出纯 JSON,无需启动服务
go test ./...
```
`--dry-run` 和 `--check` 不能组合。新项目预览也不创建目录;预览不是审批或锁定,实际生成会重新检查文件状态。
在项目外可显式传 `--api`;生成时传 `--dir` 可定位其他项目。不会猜测多个 .api 中哪个是入口。
路由总览的 `(undeclared)` 仅表示没有声明 auth;旧式中间件仍可能做认证。
外发文档可用 export;项目内受生成器管理的 `docs/openapi.json` 请通过 generate 更新,以同步文件哈希。
## 参数语义
@@ -124,6 +229,7 @@ etc/config.yaml
internal/settings/
internal/servicecontext/ # 依赖初始化和释放
internal/types/typesGen.go
internal/apierrors/errorsGen.go # 声明错误后生成
internal/handler/routesGen.go
internal/handler/openapiGen.go
internal/handler/<group>/handlersGen.go

View File

@@ -1,12 +1,30 @@
# Changelog
## v0.8.0-preview
- 公共响应 `codeType` 支持 `int32` / `int64` / `string`,校验默认码及映射范围,OpenAPI 标注整数位宽;JSON/YAML 和生成代码保留 int64 精度,旧 `integer` 兼容。
- 公共 JSON 响应体改为可配置模板:支持嵌套结构、独立错误体、错误码类型/映射、提示与空值行为;旧 DTO 仅保留源代码兼容。
- `.api` 支持 `response Name (...)`、分组引用和单路由覆盖;`@response raw` 输出原始业务 JSON。生成的模板、路由和 OpenAPI 使用同一份配置。
- 提供应用级/接口级编码器,以及 Text / XML / Bytes 直接输出,允许三方回调独立控制成功和失败的状态、Header、内容。
- 接口响应策略可在全局中间件之前挂载,覆盖提前鉴权/限流失败和 Context 克隆;内置路由 404/405 使用应用配置。
- 详见 [公共响应体](RESPONSE.md)。CLI 与项目框架依赖需要一起升级到本版。
## v0.7.0-preview.1
- `.api` 支持集中业务错误目录、组级与路由级错误声明,生成状态/错误码/提示一致的 `internal/apierrors` 函数,以及 OpenAPI 命名响应示例。
- 具名鉴权声明支持 Bearer、header/cookie API key,关联真实中间件;支持组级继承、单路由覆盖与公共接口,认证 hook 优先执行并在启动时校验。
- 新增多行 `@doc` 的摘要、描述、标签与弃用标记,`info.description` 同步文档。
- 新增 `api inspect`、`api export`、`api generate --dry-run`;项目内自动识别 manifest 的 API 入口,减少重复参数。
- 用户示例用真实 401/403/409 验证声明与行为,业务直接使用生成的错误函数;生成不会改写现有手写 hook 和 Service。
- 纠正旧文档误将事务 Outbox、分布式锁列为已实现的描述;这两个模块当前没有实现。
## v0.7.0-preview
- 新增 `.api` 契约驱动生成器:DTO、严格多来源绑定与校验、路由、分组 Handler / Service / Repository、命名中间件及 OpenAPI;生成文件哈希管理、业务文件保护和只读 `--check`。
- `new --api` / `api new` / `api generate` 可以组合 MySQL、日志以及 Local / S3 / R2 / OSS。保存初始基础设施选项,重生成自动沿用;显式切换已有项目选项会拒绝,避免静默丢失初始化代码。
- 新增 `examples/userService` 独立业务示例与 `verifyAPIWorkflow.py`:用户 CRUD / 分页、管理员 Token 鉴权、MySQL 事务审计、故障回滚、文件导出、requestId 日志、数据库 readiness 和优雅退出;支持从远程版本验收。
- 日志新增文件轮转、结构化敏感字段脱敏、异常堆栈及慢请求配置;存储新增统一上传策略、直传签名和云端分片能力。
- 新增缓存、HTTP 客户端、限流 / 幂等 / 超时、后台任务、Redis 队列、事务 outbox、分布式锁、审计、迁移与 telemetry 等可选模块;业务身份与 Orbit 保持由应用自行组合。
- 新增缓存、HTTP 客户端、限流 / 幂等 / 超时、后台任务、Redis 队列、审计、迁移与 telemetry 等可选模块;业务身份与 Orbit 保持由应用自行组合。
- `database.ApplyEnv` 支持文件/默认配置叠加环境变量;数据库配置使用 lowerCamelCase,DSN 不参与 JSON/YAML 序列化。
- 修复 native 在业务层提前拒绝请求体为 413 时等待继续读取 body 的问题,拒绝后关闭未读连接。

View File

@@ -5,7 +5,7 @@ GoOmni 是一套面向业务复用的 Go 后端基础框架。当前默认使用
远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本:
```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.7.0-preview
go get git.yuntuo.hk/k2safe/go-omni@v0.8.0-preview
```
`v0.4.0` 移除了 Gin 和 Gin 专属的 `WithMode` 配置;`omni.New()` 直接使用标准库路由,已有业务 Handler / Service 无需修改。
@@ -13,7 +13,7 @@ go get git.yuntuo.hk/k2safe/go-omni@v0.7.0-preview
创建新业务项目:
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.7.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.8.0-preview
go-omni new --module example.com/billing ./billing
cd billing
go mod tidy
@@ -44,7 +44,7 @@ API 生成支持 `--database mysql --storage local --log-output file` 组合初
go-omni/
├── app/ # 应用启动和生命周期
├── httpx/ # 与网络框架无关的 HTTP 抽象
├── response/ # 统一响应
├── response/ # 可配置公共体和响应编码器
├── errors/ # 统一错误
├── health/ # 存活和就绪检查
├── middleware/ # 请求 ID 等基础中间件
@@ -309,6 +309,8 @@ go run ./examples/reference
## 当前边界
缓存、消息队列和定时任务暂时作为后续可选模块。认证层只定义 `Authenticator`、`Principal` 和路由保护中间件,具体 JWT、Session 或 OAuth 由项目实现。核心数据库层只依赖 `database/sql`,具体驱动、迁移和表结构由项目选择。
缓存、Redis 消息队列和进程内定时任务已作为可选模块提供。认证层提供 `Authenticator`、JWT/Session 适配器和 Policy 接口,登录流程与业务授权规则由项目实现。核心数据库层只依赖 `database/sql`,具体驱动、迁移和表结构由项目选择。
当前成熟度与后续验收标准见 [ROADMAP.md](ROADMAP.md),版本变更见 [CHANGELOG.md](CHANGELOG.md)。
`v0.8.0-preview` 新增 [公共响应模板](RESPONSE.md):在 `.api` 中定义一次返回公共体,分组与接口引用,运行时和 OpenAPI 共用。支持数字错误码、嵌套结构、原始 JSON,以及独立的文本/XML回调编码器。源码开发可通过 `--framework-dir` 显式引用本地框架。

View File

@@ -1,63 +1,55 @@
# v0.7.0-preview
# v0.8.0-preview
这一版把 API 定义、项目生成、基础模块初始化和完整业务示例接通。
预览版用于固定版本试用;API 方言有明确范围,HTTP/2 仍是独立实验适配器。
这一版让业务自行定义公共返回体:在 `.api` 中配置字段、结构、成功/失败提示和错误码类型,按分组复用或由单接口覆盖,运行时与 OpenAPI 共用定义。需要特殊协议的回调可独立控制状态、Header 和内容。
## 安装与开始
本版继续作为预览版供固定版本接入;HTTP/2 仍是独立实验适配器。
## 安装与升级
```sh
GOPRIVATE=git.yuntuo.hk go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.7.0-preview
GOPRIVATE=git.yuntuo.hk go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.8.0-preview
go-omni api new --module example.com/demo ./demo
cd demo
GOPRIVATE=git.yuntuo.hk go mod tidy
go run ./cmd/server
# 已有业务项目中执行:
GOPRIVATE=git.yuntuo.hk go get git.yuntuo.hk/k2safe/go-omni@v0.8.0-preview
go-omni api generate
go test ./...
```
需要该 Git 仓库的读取权限时,通过自己的 Git 凭据管理器配置;不要将凭据写入 module path 或 go.mod。
CLI 会将自身下载版本固定到生成项目的 go.mod。业务项目正常使用远程模块,不需要本地 replace。
新项目可以直接 `go-omni api new --module example.com/demo ./demo`,或用 `go-omni new --api service.api --module example.com/demo ./demo`。远程安装的 CLI 会固定生成项目的框架版本,无需本地 replace。需要仓库权限时使用自己的 Git 凭据管理器,不把凭据写入 module path 或 go.mod。
使用已有定义,并一次选择基础模块:
## 本版功能
```sh
go-omni new --api user.api --module example.com/userService \
--database mysql --storage local --log-output file ./userService
```
- `response Name (...)` 定义公共 JSON 体,支持嵌套结构、独立 errorBody、常量及类型保持的占位符;通过 import 共享配置。
- `@server (response: Name)` 为整个 service 块选择模板,`@response Name` 单接口覆盖;`@response raw` 成功返回业务 JSON,错误保留组级格式。
- `codeType: int32 / int64 / string` 使用与业务字段一致的名称;默认码和映射都校验,int64 配置及生成代码保留整数精度。旧 `integer` 写法仍兼容。
- `successMessage` / `errorMessage` 配置默认提示,`codes` / `messages` 按逻辑错误码覆盖。业务只返回数据或错误,公共体由框架组装。
- `omni.WithResponse` 设置手写路由的应用默认格式,`WithResponseEncoder` 与 `HandleWithResponse` 支持特殊协议;Text / XML / Bytes / JSON / NoContent 可直接输出。
- 生成路由在全局中间件之前选定策略,提前鉴权拒绝、限流、panic 和 Context 克隆沿用正确格式。OpenAPI 同步模板结构与业务错误示例。
MySQL 与存储需要按生成 README 设置环境变量。任意契约生成的业务方法先返回 501,
由业务方实现;可直接参考 [完整用户示例](examples/userService/README.md)。
详见 [公共响应体](RESPONSE.md)、[完整定义示例](examples/contracts/responses.api) 和 [更新记录](CHANGELOG.md)。
## 主要变化
## 升级行为
- `.api` 生成项目、绑定/校验、路由、业务骨架和 OpenAPI;支持分组、导入、命名中间件。
- 同一入口初始化 MySQL、日志、Local / S3 / R2 / OSS;标准 context 与 httpx 保持业务/网络分离。
- 生成器保护手写业务文件,校验自有文件哈希,支持只读 --check;基础设施选项持久化到 manifest。
- 完整用户示例包含数据库迁移、CRUD / 分页、事务审计、鉴权、文件导出与 requestId 日志。
- 日志文件轮转/结构化脱敏、上传策略/直传/分片,以及此前 main 分支的通用业务模块一并提供。
所有变化及网络层验收记录见 [CHANGELOG](CHANGELOG.md)。
## 升级注意
1. 将 CLI 和项目框架依赖固定为相同版本。旧版 CRUD 项目继续使用现有目录;不能直接用 API 生成器接管已有 Go 目录。
2. Gen.go 由生成器维护,业务实现/配置/迁移由项目维护。新增 API 后执行 generate 和 go test;修改或删除类型需要同步处理自己的业务代码。
3. `.goOmni/apiManifest.json` 需随代码提交。切换数据库或存储选项时,在新目录生成对照后迁移;重新生成不会改写现有基础设施文件。
4. 配置使用 lowerCamelCase。DSN、Token、存储密钥由环境变量提供;`database.Config` 的 DSN 不会被 JSON/YAML 序列化。
5. API 的 none/memory 数据库选项仅表示不打开连接,不会从 HTTP DTO 自动推导内存仓库或数据表。
1. CLI 与框架依赖一起升级到本版。新生成路由使用新的响应策略入口,旧运行时不能编译。旧 `.api` 可继续使用;生成器不会覆盖已有 Service、Repository、middleware、配置和 main。
2. 生成接口以 `.api` 为响应契约来源,即使没有声明模板也固定使用默认格式;应用 YAML 的 response 用于手写路由和应用兜底。需要变更生成接口的格式,请修改 `.api` 后重新生成。
3. `response.Response` DTO 保留为兼容类型,运行时不再依赖它固定结构。未配置的正常业务响应继续使用默认 code/message/data/requestId。
4. 内置 App 的 404/405 现在经过应用响应策略和中间件,默认输出 JSON;原先依赖纯文本兜底的客户端需调整。直接使用 nethttp.Router 的兜底行为不变。
5. `int32` / `int64` 按有符号整数范围校验并输出 JSON 数值;消费端需支持相应精度。需要文本码时使用 string。旧 integer 继续保留原有 ±(2^53−1) 范围。
6. `.api` 的模板和 raw 当前描述 JSON。文本/XML回调用 Go 编码器并维护对应文档;TLS/HTTP解析错误、连接级过载等发生在进入应用前的错误仍按网络协议处理。
## 验证入口
```sh
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=2 -count=1 -timeout=300s ./...
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...
go vet ./...
go build ./...
python3 scripts/verifyAPIWorkflow.py
python3 scripts/verifyAPIWorkflow.py --version v0.7.0-preview \
--report performance/apiWorkflowRemote.json
python3 scripts/verifyAPIWorkflow.py --report performance/responsePoliciesWorkflowLocal.json
python3 scripts/verifyResponseRelease.py --version v0.8.0-preview \
--report performance/responsePoliciesRemote.json
python3 scripts/verifyAPIWorkflow.py --version v0.8.0-preview \
--report performance/responsePoliciesWorkflowRemote.json
```
最后一项在全新 module cache 安装远程 CLI,并验证无本地 replace 的项目生成、
真实 MySQL、nethttp/native 启动与业务流程。容器只使用本机临时资源,结束时清理。
本轮验收见 [API 业务闭环报告](performance/apiWorkflowAcceptance.md)。
远程验收分别从全新 module cache 安装 CLI,验证生成项目固定依赖且没有本地 replace,以及完整用户示例的真实 MySQL、nethttp/native、事务、日志和生命周期。数据库容器只使用本机临时资源(1 CPU / 768 MiB),结束清理,不连接业务服务器。
OSS/R2 云端仍需项目自己的账号做上线环境验收;当前云端适配器检查不等同于这些账号的实网测试。
验收记录见 [响应模板验收](performance/responsePoliciesAcceptance.md)。OSS/R2 云账号实网验收与长时间压力测试不属于本次发布范围。

140
RESPONSE.md Normal file
View File

@@ -0,0 +1,140 @@
# 公共响应体与独立接口协议
这些功能从 `v0.8.0-preview` 提供。CLI 与项目框架依赖请固定到同一版本;源码开发可显式使用 `--framework-dir`。`v0.7.0-preview.1` 不包含响应模板声明。
go-omni 的响应编码由应用或接口配置。没有配置时保留旧版默认值;业务可以定义自己的字段、嵌套结构、错误码类型、提示和空值行为。`response.Response` 仅保留为兼容 DTO,运行时与生成器不再通过它固定响应结构。
## 在 .api 中定义一次、到处引用
```go
response CommonResponse (
body: {
"code": "${code}",
"msg": "${message}",
"data": "${data}"
}
codeType: int32
successCode: 200
errorCode: 9999
successMessage: "操作成功"
dataMode: always
codes: {"USER_NOT_FOUND":404001}
messages: {"USER_NOT_FOUND":"用户不存在"}
)
@server (
group: user
response: CommonResponse
)
service user-api {
@handler getUser
get /users/:userId (GetUserReq) returns (UserResp)
@response raw
@handler rawUser
get /rawUsers/:userId (GetUserReq) returns (UserResp)
}
```
请求/业务返回类型照常在 `type` 中定义。上例 `UserResp` 是业务数据,由模板中的 `${data}` 放入公共体。请求失败时,同一模板输出映射后的业务错误。错误本身的 HTTP 状态保持不变,业务 `code` 与 HTTP 状态相互独立。
- 公共声明可以放进 `common.api`,其他契约通过 `import "common.api"` 复用。
- `@server (response: CommonResponse)` 指定整个 service 块的公共体;不同块可以引用同一个模板。
- `@response PartnerResponse` 为单接口替换模板,成功和错误一起生效。
- `@response raw` 让成功 JSON 直接返回业务对象,错误沿用组级模板;组本身为 raw 时,错误使用默认模板。
- 模板名使用 UpperCamelCase;未知模板、未知配置、错误的占位符和冲突设置在生成前报错。
**生成接口以 `.api` 为响应契约来源。** 生成器把选定模板编译进 `internal/handler/responseGen.go`,在全局鉴权、限流等应用中间件之前选定,然后再执行路由中间件和业务。OpenAPI 与运行时共用配置和映射规则。即使 `.api` 没有声明模板,生成接口也明确使用默认契约,不会被应用的 YAML 设置静默改变。
业务只需返回业务数据或业务错误,无需每个 Handler 手工组装公共体。生成器维护模板、路由及文档文件,重复生成不覆盖手写 Service、Repository、middleware 和配置。
## 自定义结构与占位符
公共体没有规定必须出现的字段,也可以把成功和失败分开:
```go
response PartnerResponse (
body: {
"accepted": "${ok}",
"payload": "${data}",
"meta": {"trace":"${requestId}","httpStatus":"${status}"},
"version": 1
}
errorBody: {
"accepted": "${ok}",
"error": {"reason":"${message}","id":"${code}"}
}
)
```
`body` 是 JSON 对象,可嵌套对象、数组和常量;`errorBody` 可选,需要同时定义 `body`。空对象 `{}` 也是有效模板。
| 整值占位符 | 输出值 |
| --- | --- |
| `${data}` | 当前接口的业务返回值,保留对象/数组/数值等原类型 |
| `${code}` | 映射后的 string / int32 / int64 业务错误码 |
| `${message}` | 公开提示,经 message 配置映射 |
| `${requestId}` | 请求标识;为空时省略所在对象字段 |
| `${ok}` | 成功为 true;有错误或 HTTP 状态 ≥400 时为 false |
| `${status}` | HTTP 状态整数 |
占位符只替换整个值,不执行表达式或字符串拼接。字面量 `${data}` 写成 `$${data}`。数据为 nil 时,`dataMode: omitNil`(默认)省略所在对象字段;`always` 输出 null。数组中的占位符保留位置。模板最多嵌套 24 层。
`codeType` 使用与业务字段一致的类型名,默认 `string`:
| 类型 | 含义 | OpenAPI |
| --- | --- | --- |
| `string` | 字符串业务码,例如 `"OK"`、`"USER_NOT_FOUND"` | string |
| `int32` | 32 位有符号整数,−2147483648 到 2147483647 | integer / int32 |
| `int64` | 64 位有符号整数,−9223372036854775808 到 9223372036854775807 | integer / int64 |
整数类型必须提供 `successCode` 和 `errorCode`,支持 0 和负数;默认码和 `codes` 映射都会校验范围,越界或小数会报错。`int64` 的配置读取和生成代码保留完整整数精度,并按 JSON 数值输出;消费端也需要支持对应的整数精度。需要文本形式时可用 `string` 并配置字符串码。
旧写法 `integer` 保留兼容,沿用 ±(2^53−1) 的数值范围;新配置优先写 `int32` 或 `int64`。
`codes` 按逻辑错误码覆盖默认码;`messages` 优先于 `successMessage` / `errorMessage`。未配置的公开提示沿用原错误消息;内部 cause 不会被默认编码器序列化。
简单的平铺响应也可使用 `codeField` / `messageField` / `dataField` / `requestIdField` 快捷配置,`"-"` 禁用字段。空名称沿用旧默认值。这些字段名快捷配置不能和 `body` 混用,以免两个定义冲突。
## 手写路由、配置文件与三方回调
手写路由的应用默认配置可以放在 JSON/YAML 的 `response` 字段,也可用 Go 选项:
```go
app := omni.New(omni.WithResponse(response.Config{
Body: map[string]any{"code":"${code}", "msg":"${message}", "data":"${data}"},
CodeType: "int32", SuccessCode: 200, ErrorCode: 9999, DataMode: "always",
}))
```
应用之间的配置独立,模板在创建时验证并复制;没有全局可变的响应格式。应用配置快照也会复制模板和映射。响应配置属于静态配置,变更后需重启。
单次显式输出可使用 `c.JSON(status, value)`、`c.Text(status, text)`、`c.XML(status, value)`、`c.Bytes(status, contentType, bytes)` 或 `c.NoContent()`,都绕过公共体。已经提交的响应不会再追加错误 JSON。
三方回调可能要求成功与失败都返回 HTTP 200,并分别输出 `success` / `fail`。通过接口编码器统一处理成功、错误和 panic:
```go
ack := response.EncoderFunc(func(r response.Result) (response.Payload, error) {
if r.Err != nil || r.Status >= 400 {
return response.Text(200, "fail"), nil
}
return response.Text(200, "success"), nil
})
app.HandleWithResponse("POST", "/hooks/payment", ack, paymentHandler)
```
编码器也可以设置 HTTP 状态、Header、XML 或任意字节。`omni.WithResponseEncoder` 设置应用默认编码器;`httpx.WithResponse` 是普通路由中间件,按中间件顺序生效。需要覆盖全局中间件提前拒绝时,应使用 `HandleWithResponse`。自定义 Registrar 可以实现 `httpx.ResponseRegistrar` 提供相同的早期挂载能力;未实现时辅助函数退化为普通路由中间件。
`.api` 当前声明的是 JSON 协议,`@response raw` 也是原始 JSON,不代表任意文本/XML协议。自定义文本/XML回调使用上述 Go 入口,并由应用维护相应文档;生成器不会猜测自定义 Go 编码器的输出。如果在命名中间件里替换编码器,业务也必须同步维护文档。
框架内置路由的 404/405 使用应用默认格式;它们不属于某个 `.api` 分组。TLS 握手、畸形 HTTP、连接级过载等发生在进入应用前,仍按网络层协议处理。自定义编码器失败、panic 或返回非法状态/204 body 时,最终返回空 500,避免递归编码或泄露另一种响应体。
## 运行示例与验证
```sh
go run ./cmd/go-omni api validate --api examples/contracts/responses.api
go run ./cmd/go-omni api generate --api examples/contracts/responses.api \
--module example.com/responseDemo --framework-dir . --dir ../responseDemo
```
示例业务方法起初为 501,完成 Service 后就会套用声明的模板。参考 [完整 .api](examples/contracts/responses.api)。验收测试会在临时目录生成真实项目,写入业务方法,执行已生成路由,检查数字错误码、原始 JSON、鉴权错误、路由覆盖与重复生成保护。

View File

@@ -88,4 +88,18 @@
- 已完成本地模块测试及 race 检查,Linux 全仓库 race/vet/build、生成项目 CRUD、三个 Runner 的 TLS 示例和独立 Redis/MinIO/MySQL 均通过。共享状态、重复提交、失败消息、未确认消息接管、旧确认拒绝、对象签名和迁移 dirty 阻断有对应证据,见 [扩展模块验收](performance/advancedAcceptance.md)。OSS/R2 尚无云账号实网验收。
- 契约、使用示例与限制见 [扩展模块](ADVANCED.md);验证入口为 `scripts/verifyModules.py --advanced`,继续限制单个测试容器 2 CPU / 2 GiB / 256 pids。
## 8. API 驱动项目与真实业务示例(v0.7.0-preview)
- `.api` → DTO / 绑定校验 / 路由 / Handler / Service / Repository / OpenAPI。
- MySQL、日志与五种存储选择可组合生成;manifest 管理生成文件,保留业务文件。
- 独立用户示例完成事务审计、鉴权、文件导出、数据库故障恢复与真实远程依赖验收。
- 证据:[API 业务闭环](performance/apiWorkflowAcceptance.md)。
## 9. 接口开发体验(v0.7.0-preview.1)
- 错误目录与调用函数、401/403/409 文档、具名鉴权和运行时 hook 关联。
- 组级继承/路由覆盖、多行接口描述、标签和弃用标记。
- 路由总览、独立 OpenAPI 导出、生成预览与项目入口自动识别。
- 证据:[接口开发体验验收](performance/apiDeveloperExperienceAcceptance.md)。
以上阶段以验收结果判断完成。当前仍是基础框架试用阶段;MySQL、适配层契约和一次业务模块试用已完成,自研网络层已有可运行预览,稳定性与生产规模验证尚未完成。

View File

@@ -15,6 +15,7 @@ import (
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/middleware"
"git.yuntuo.hk/k2safe/go-omni/response"
netrouter "git.yuntuo.hk/k2safe/go-omni/transport/nethttp"
)
@@ -28,6 +29,7 @@ type App struct {
closeOnce sync.Once
closeErr error
clientIPResolver *httpx.ClientIPResolver
responseEncoder response.Encoder
}
// Runner is the small lifecycle boundary used by an HTTP server
@@ -64,6 +66,15 @@ func configure(options ...config.Option) config.Config {
}
func newApp(router httpx.Router, settings config.Config) *App {
settings.Response = settings.Response.Clone()
encoder, responseErr := response.New(settings.Response)
if responseErr != nil {
panic(responseErr)
}
var responseEncoder response.Encoder = encoder
if settings.ResponseEncoder != nil {
responseEncoder = settings.ResponseEncoder
}
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...)
resolver, err := httpx.NewClientIPResolver(settings.TrustedProxies...)
@@ -75,7 +86,16 @@ func newApp(router httpx.Router, settings config.Config) *App {
middlewares = append(middlewares, middleware.AccessLog(middleware.WithSlowRequestThreshold(settings.SlowRequestThreshold)))
}
middlewares = append(middlewares, middleware.Recovery())
return &App{router: router, config: settings, middlewares: middlewares, clientIPResolver: resolver}
application := &App{router: router, config: settings, middlewares: middlewares, clientIPResolver: resolver, responseEncoder: responseEncoder}
if fallback, ok := router.(httpx.FallbackRouter); ok {
adaptError := func(status int, code, message string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
application.adapt(func(*httpx.Context) error { return apierrors.New(status, code, message) }).ServeHTTP(w, r)
})
}
fallback.SetFallbacks(adaptError(404, "NOT_FOUND", "route not found"), adaptError(405, "METHOD_NOT_ALLOWED", "method not allowed"))
}
return application
}
// Router returns the transport contract used by this application.
@@ -93,6 +113,7 @@ func (a *App) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
// Config returns the effective application configuration.
func (a *App) Config() config.Config {
settings := a.config
settings.Response = settings.Response.Clone()
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...)
return settings
@@ -235,5 +256,5 @@ func (a *App) writeError(ctx *httpx.Context, err error) {
if err == nil || ctx.Responded() {
return
}
ctx.WriteError(apierrors.From(err))
ctx.WriteError(err)
}

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func (a *App) handle(routes httpx.RouterGroup, method, path string, handler httpx.Handler, middleware []httpx.Middleware) {
@@ -11,6 +12,10 @@ func (a *App) handle(routes httpx.RouterGroup, method, path string, handler http
}
func (a *App) adapt(handler httpx.Handler, middleware ...httpx.Middleware) http.Handler {
return a.adaptResponse(a.responseEncoder, handler, middleware...)
}
func (a *App) adaptResponse(encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) http.Handler {
wrapped := handler
all := make([]httpx.Middleware, 0, len(a.middlewares)+len(middleware))
all = append(all, a.middlewares...)
@@ -25,6 +30,7 @@ func (a *App) adapt(handler httpx.Handler, middleware ...httpx.Middleware) http.
request = request.WithContext(httpx.WithClientIPResolver(request.Context(), a.clientIPResolver))
}
requestContext := httpx.NewContext(request, writer, httpx.RouteParams(request.Context()))
requestContext.SetResponseEncoder(encoder)
if err := wrapped(requestContext); err != nil {
a.writeError(requestContext, err)
}
@@ -37,6 +43,14 @@ func (a *App) Handle(method, path string, handler httpx.Handler, middleware ...h
a.handle(a.router, method, path, handler, middleware)
}
// HandleWithResponse selects the route policy before all application middleware.
func (a *App) HandleWithResponse(method, path string, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
if encoder == nil {
encoder = a.responseEncoder
}
a.router.Handle(method, path, a.adaptResponse(encoder, handler, middleware...))
}
func (a *App) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
a.handle(a.router, http.MethodGet, path, handler, middleware)
}
@@ -94,6 +108,15 @@ func (g *RouterGroup) Handle(method, path string, handler httpx.Handler, middlew
g.route(method, path, handler, middleware)
}
func (g *RouterGroup) HandleWithResponse(method, path string, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
if encoder == nil {
encoder = g.app.responseEncoder
}
combined := append([]httpx.Middleware{}, g.middlewares...)
combined = append(combined, middleware...)
g.group.Handle(method, path, g.app.adaptResponse(encoder, handler, combined...))
}
func (g *RouterGroup) GET(path string, handler httpx.Handler, middleware ...httpx.Middleware) {
g.route(http.MethodGet, path, handler, middleware)
}

View File

@@ -17,7 +17,7 @@ go run ./cmd/go-omni api validate --api examples/contracts/user.api
`new --api <file>` 是契约项目的另一个入口。可以组合 `--database mysql --storage local --log-output file`,生成配置、依赖初始化与资源关闭。API 默认无数据库、无对象存储;`--database memory` 等同于不打开连接。`--force` 不允许覆盖 API 项目。初始配置选择写入 manifest,重新生成自动沿用。
后续在生成项目内执行 `go-omni api generate --api api/user.api --dir .`。
`--check` 只读检查生成产物是否过期。生成器记录所有权,保留 Service / Repository / 配置 / main 的手写内容,遇到生成文件被手动修改则报冲突。新 Service 与中间件占位实现返回 501。
项目内可省略 `--api` / `--dir`。`api inspect` 展示路由和鉴权/业务错误,`api export` 向 stdout 导出 OpenAPI,`api generate --dry-run` 预览文件变化。`--check` 只读检查生成产物是否过期。生成器记录所有权,保留 Service / Repository / 配置 / main 的手写内容,遇到生成文件被手动修改则报冲突。新 Service 与中间件占位实现返回 501。支持 `error (...)`、具名 `auth`、路由 `@errors` / `@auth` 和多行 `@doc`;错误目录同步生成 `internal/apierrors` 调用函数。
支持范围、参数语义、目录及安全更新策略见 [API 开发指南](../../API.md)。本版借鉴 go-zero 的工作流,不宣称兼容全部 goctl 语法;仍通过 Omni/httpx 保留网络层替换能力。
@@ -73,7 +73,7 @@ Handler / Service / Repository / Model 与模块注册入口。模块名为 lowe
从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.7.0-preview
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.7.0-preview.1
go-omni new --module example.com/billing ./billing
cd billing
go mod tidy

View File

@@ -1,9 +1,10 @@
package main
import (
"errors"
"fmt"
"os"
"strings"
"text/tabwriter"
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen"
)
@@ -20,9 +21,10 @@ func runAPI(args []string) error {
if command == "check" {
command = "validate"
}
if command != "new" && command != "generate" && command != "validate" {
if command != "new" && command != "generate" && command != "validate" && command != "inspect" && command != "export" {
return fmt.Errorf("unknown api command %q", command)
}
readOnly := command == "validate" || command == "inspect" || command == "export"
o := apigen.Options{}
for i := 1; i < len(args); i++ {
arg := args[i]
@@ -30,11 +32,15 @@ func runAPI(args []string) error {
usage()
return nil
}
if arg == "--check" {
if arg == "--check" || arg == "--dry-run" {
if command != "generate" {
return errors.New("--check belongs to api generate")
return fmt.Errorf("%s belongs to api generate", arg)
}
if arg == "--check" {
o.Check = true
} else {
o.DryRun = true
}
o.Check = true
continue
}
if !strings.HasPrefix(arg, "-") {
@@ -67,8 +73,8 @@ func runAPI(args []string) error {
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s requires a value", name)
}
if command == "validate" && name != "--api" {
return fmt.Errorf("api validate does not accept %s", name)
if readOnly && name != "--api" {
return fmt.Errorf("api %s does not accept %s", command, name)
}
switch name {
case "--api":
@@ -91,18 +97,56 @@ func runAPI(args []string) error {
o.LogFormat = value
}
}
if command == "validate" {
if o.API == "" {
return errors.New("--api is required")
if command != "new" && o.API == "" {
dir := o.Dir
if dir == "" {
dir = "."
}
var err error
o.API, err = apigen.ProjectEntry(dir)
if err != nil {
return err
}
}
if readOnly {
spec, err := apigen.Parse(o.API)
if err != nil {
return err
}
if _, err = apigen.Compile(spec); err != nil {
compiled, err := apigen.Compile(spec)
if err != nil {
return err
}
fmt.Printf("valid API: %d types, %d routes\n", len(spec.Types), len(spec.Routes))
switch command {
case "validate":
fmt.Printf("valid API: %d types, %d routes, %d errors, %d auth schemes\n", len(spec.Types), len(spec.Routes), len(spec.Errors), len(spec.Auth))
case "export":
fmt.Println(string(compiled.Document))
case "inspect":
writer := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(writer, "METHOD\tPATH\tHANDLER\tAUTH\tRESPONSE\tERRORS")
definitions := map[string]apigen.ErrorDefinition{}
for _, e := range spec.Errors {
definitions[e.Name] = e
}
for _, r := range compiled.Spec.Routes {
auth := r.Auth
if auth == "" {
auth = "(undeclared)"
}
var errs []string
for _, name := range r.Errors {
e := definitions[name]
errs = append(errs, fmt.Sprintf("%d:%s", e.Status, e.Code))
}
format := r.ResponseFormat
if format == "" {
format = "default"
}
fmt.Fprintf(writer, "%s\t%s\t%s/%s\t%s\t%s\t%s\n", strings.ToUpper(r.Method), r.Path, r.Group, r.Handler, auth, format, strings.Join(errs, ", "))
}
return writer.Flush()
}
return nil
}
var out apigen.Output
@@ -118,7 +162,12 @@ func runAPI(args []string) error {
if err != nil {
return err
}
if o.Check {
if o.DryRun {
for _, change := range out.Changes {
fmt.Printf("%-7s %s\n", change.Action, change.Path)
}
fmt.Printf("Preview: %d changes; %d existing developer-owned files preserved; no files written\n", len(out.Changes), len(out.Preserved))
} else if o.Check {
fmt.Println("API generated files are current")
} else {
fmt.Printf("API project ready: %d generated, %d created, %d preserved, %d removed\n", len(out.Generated), len(out.Created), len(out.Preserved), len(out.Removed))

View File

@@ -1,6 +1,8 @@
package main
import (
"encoding/json"
"io"
"os"
"path/filepath"
"strings"
@@ -42,3 +44,64 @@ func TestAPICommands(t *testing.T) {
t.Fatalf("incompatible profile: %v", err)
}
}
func TestAPIDeveloperCommands(t *testing.T) {
root, err := filepath.Abs("../..")
if err != nil {
t.Fatal(err)
}
dir := filepath.Join(t.TempDir(), "project")
if err = runAPI([]string{"new", dir, "--framework-dir", root, "--module", "example.com/commands"}); err != nil {
t.Fatal(err)
}
// Generation can resolve the entry from an explicit target directory.
if err = runAPI([]string{"generate", "--dir", dir, "--dry-run"}); err != nil {
t.Fatal(err)
}
wd, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
if err = os.Chdir(dir); err != nil {
t.Fatal(err)
}
defer os.Chdir(wd)
for _, args := range [][]string{{"validate"}, {"inspect"}, {"generate", "--check"}} {
if err = runAPI(args); err != nil {
t.Fatalf("%v: %v", args, err)
}
}
doc := captureAPIOutput(t, []string{"export"})
var parsed map[string]any
if err = json.Unmarshal([]byte(doc), &parsed); err != nil {
t.Fatalf("export must be pure JSON: %v", err)
}
if parsed["openapi"] != "3.0.3" {
t.Fatal("wrong export")
}
for _, args := range [][]string{{"generate", "--dry-run", "--check"}, {"new", "--dry-run", "other"}, {"inspect", "--dir", dir}, {"export", "--log-output", "file"}} {
if err = runAPI(args); err == nil {
t.Fatalf("accepted invalid flags: %v", args)
}
}
}
func captureAPIOutput(t *testing.T, args []string) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
previous := os.Stdout
os.Stdout = w
defer func() { os.Stdout = previous; r.Close(); w.Close() }()
result := make(chan []byte, 1)
go func() { data, _ := io.ReadAll(r); result <- data }()
callErr := runAPI(args)
w.Close()
os.Stdout = previous
data := <-result
if callErr != nil {
t.Fatal(callErr)
}
return string(data)
}

View File

@@ -135,7 +135,7 @@ func TestSharedPrefixConflict(t *testing.T) {
}
func FuzzParser(f *testing.F) {
for _, s := range []string{starterAPI, "syntax = \"v1\"", "type (", "@server (prefix: /api)"} {
for _, s := range []string{starterAPI, declaredAPI, "error Broken", "auth Auth (type: bearer)", "syntax = \"v1\"", "type (", "@server (prefix: /api)"} {
f.Add(s)
}
f.Fuzz(func(t *testing.T, source string) {

View File

@@ -35,6 +35,11 @@ func Compile(spec *Spec) (*Compiled, error) {
if spec == nil || len(spec.Routes) == 0 || !serviceName.MatchString(spec.Service) {
return nil, errors.New("api: valid service and routes required")
}
var prepareErr error
spec, prepareErr = prepareMetadata(spec)
if prepareErr != nil {
return nil, prepareErr
}
definitions := map[string]Type{}
for _, t := range spec.Types {
if !exportedName.MatchString(t.Name) {
@@ -240,11 +245,20 @@ func Compile(spec *Spec) (*Compiled, error) {
if err != nil {
return nil, err
}
response = openapi.Envelope(s)
response.Required = append(response.Required, "data")
if r.ResponseFormat == "raw" {
response = s
} else {
response, err = openapi.EnvelopeWith(s, responseConfig(spec, r))
if err != nil {
return nil, err
}
}
}
op.Responses[r.Status] = openapi.Response{Description: "Success", Schema: response}
errorSchema := &openapi.Schema{Type: "object", Required: []string{"code", "message"}, Properties: map[string]*openapi.Schema{"code": {Type: "string"}, "message": {Type: "string"}, "requestId": {Type: "string"}}}
errorSchema, err := openapi.EnvelopeWith(nil, responseConfig(spec, r))
if err != nil {
return nil, err
}
for _, code := range []int{400, 413, 415, 500, 501} {
op.Responses[code] = openapi.Response{Description: "Error", Schema: errorSchema}
}
@@ -260,25 +274,15 @@ func Compile(spec *Spec) (*Compiled, error) {
if err != nil {
return nil, err
}
// Show named hooks in documentation without claiming they implement a
// particular authentication scheme. Applications own that policy.
// Decorate the document with explicit auth/error contracts. Credential
// validation remains in the linked application middleware.
var doc map[string]any
decoder := json.NewDecoder(bytes.NewReader(c.Document))
decoder.UseNumber()
if err = decoder.Decode(&doc); err != nil {
return nil, err
}
for _, r := range spec.Routes {
if len(r.Middleware) > 0 {
path := r.Path
for _, part := range strings.Split(r.Path, "/") {
if strings.HasPrefix(part, ":") {
path = strings.ReplaceAll(path, part, "{"+part[1:]+"}")
}
}
doc["paths"].(map[string]any)[path].(map[string]any)[r.Method].(map[string]any)["x-omni-middleware"] = r.Middleware
}
}
decorateDocument(doc, spec)
c.Document, err = json.MarshalIndent(doc, "", " ")
return c, err
}

View File

@@ -0,0 +1,220 @@
package apigen
import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"sort"
"strings"
"git.yuntuo.hk/k2safe/go-omni/openapi"
"git.yuntuo.hk/k2safe/go-omni/response"
)
var errorCode = regexp.MustCompile(`^[A-Z][A-Z0-9_]*$`)
var credentialName = regexp.MustCompile("^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
// prepareMetadata returns a detached spec; repeated compilation must not prepend
// authentication middleware more than once or mutate the parser's output.
func prepareMetadata(original *Spec) (*Spec, error) {
spec := *original
spec.Routes = append([]Route(nil), original.Routes...)
if err := validateResponses(&spec); err != nil {
return nil, err
}
definitions := map[string]ErrorDefinition{}
codes, symbols := map[string]bool{}, map[string]bool{}
for _, e := range spec.Errors {
if !exportedName.MatchString(e.Name) || e.Status < 400 || e.Status > 599 || !errorCode.MatchString(e.Code) || strings.TrimSpace(e.Message) == "" {
return nil, fmt.Errorf("%s: error requires UpperCamelCase name, status 400..599, UPPER_SNAKE_CASE code and a nonempty public message", e.Position)
}
if codes[e.Code] {
return nil, fmt.Errorf("%s: duplicate error code %s", e.Position, e.Code)
}
for _, symbol := range []string{e.Name, "Code" + e.Name} {
if symbols[symbol] {
return nil, fmt.Errorf("%s: duplicate error symbol %s", e.Position, symbol)
}
symbols[symbol] = true
}
codes[e.Code] = true
definitions[e.Name] = e
}
auth := map[string]AuthDefinition{}
for _, a := range spec.Auth {
if !exportedName.MatchString(a.Name) || !goName.MatchString(a.Middleware) || strings.EqualFold(a.Middleware, "defaults") || strings.EqualFold(a.Middleware, "registryGen") {
return nil, fmt.Errorf("%s: auth requires an UpperCamelCase name and a valid named middleware", a.Position)
}
if _, ok := auth[a.Name]; ok {
return nil, fmt.Errorf("%s: duplicate auth %s", a.Position, a.Name)
}
switch a.Type {
case "bearer":
if a.In != "" || a.KeyName != "" {
return nil, fmt.Errorf("%s: bearer auth does not accept in/name", a.Position)
}
case "apiKey":
if (a.In != "header" && a.In != "cookie") || !credentialName.MatchString(a.KeyName) || a.BearerFormat != "" {
return nil, fmt.Errorf("%s: apiKey requires in header/cookie and a valid name; bearerFormat is not supported", a.Position)
}
if a.In == "header" && (strings.EqualFold(a.KeyName, "Authorization") || strings.EqualFold(a.KeyName, "Cookie")) {
return nil, fmt.Errorf("%s: use bearer for Authorization or in cookie for a session", a.Position)
}
default:
return nil, fmt.Errorf("%s: auth type must be bearer or apiKey", a.Position)
}
auth[a.Name] = a
}
for i := range spec.Routes {
r := &spec.Routes[i]
r.Middleware = append([]string(nil), r.Middleware...)
if r.Auth != "" && r.Auth != "none" {
a, ok := auth[r.Auth]
if !ok {
return nil, fmt.Errorf("%s: unknown auth %s; declare auth %s (...) first", r.Position, r.Auth, r.Auth)
}
for _, m := range r.Middleware {
if strings.EqualFold(m, a.Middleware) {
return nil, fmt.Errorf("%s: auth %s already installs %s; remove it from middleware", r.Position, r.Auth, a.Middleware)
}
}
r.Middleware = append([]string{a.Middleware}, r.Middleware...)
}
seen := map[string]bool{}
for _, name := range r.Errors {
if _, ok := definitions[name]; !ok {
return nil, fmt.Errorf("%s: unknown error %s; declare it in error (...)", r.Position, name)
}
if seen[name] {
return nil, fmt.Errorf("%s: duplicate error %s", r.Position, name)
}
seen[name] = true
}
seen = map[string]bool{}
for _, tag := range r.Tags {
if strings.TrimSpace(tag) == "" || seen[tag] {
return nil, fmt.Errorf("%s: @doc tags must be nonempty and unique", r.Position)
}
seen[tag] = true
}
}
return &spec, nil
}
func decorateDocument(doc map[string]any, spec *Spec) {
if spec.Description != "" {
doc["info"].(map[string]any)["description"] = spec.Description
}
schemes := map[string]any{}
for _, a := range spec.Auth {
scheme := map[string]any{}
if a.Type == "bearer" {
scheme["type"], scheme["scheme"] = "http", "bearer"
if a.BearerFormat != "" {
scheme["bearerFormat"] = a.BearerFormat
}
} else {
scheme["type"], scheme["in"], scheme["name"] = "apiKey", a.In, a.KeyName
}
if a.Description != "" {
scheme["description"] = a.Description
}
schemes[a.Name] = scheme
}
doc["components"].(map[string]any)["securitySchemes"] = schemes
definitions := map[string]ErrorDefinition{}
for _, e := range spec.Errors {
definitions[e.Name] = e
}
if len(spec.Errors) > 0 {
doc["x-omni-errors"] = spec.Errors
}
for _, r := range spec.Routes {
policy := response.MustNew(responseConfig(spec, r))
cfg := policy.Config()
parts := strings.Split(r.Path, "/")
for i, part := range parts {
if strings.HasPrefix(part, ":") {
parts[i] = "{" + part[1:] + "}"
}
}
op := doc["paths"].(map[string]any)[strings.Join(parts, "/")].(map[string]any)[r.Method].(map[string]any)
if r.ResponseFormat != "" {
op["x-omni-response"] = r.ResponseFormat
}
if len(r.Middleware) > 0 {
op["x-omni-middleware"] = r.Middleware
}
if r.Auth != "" && r.Auth != "none" {
op["security"] = []map[string][]string{{r.Auth: {}}}
} else {
op["security"] = []any{}
}
if r.Description != "" {
op["description"] = r.Description
}
if len(r.Tags) > 0 {
op["tags"] = r.Tags
} else {
op["tags"] = []string{r.Group}
}
if r.Deprecated {
op["deprecated"] = true
}
responses := op["responses"].(map[string]any)
if r.Auth != "" && r.Auth != "none" {
responses["401"] = genericErrorResponse("Authentication required", cfg)
}
grouped := map[int][]ErrorDefinition{}
for _, name := range r.Errors {
e := definitions[name]
grouped[e.Status] = append(grouped[e.Status], e)
}
if len(r.Errors) > 0 {
op["x-omni-errors"] = r.Errors
}
for status, errs := range grouped {
key := fmt.Sprint(status)
response, exists := responses[key].(map[string]any)
if !exists {
response = genericErrorResponse(http.StatusText(status), cfg)
responses[key] = response
}
media := response["content"].(map[string]any)["application/json"].(map[string]any)
examples := map[string]any{}
codes := []any{}
seenCodes := map[string]bool{}
description := []string{}
sort.Slice(errs, func(i, j int) bool { return errs[i].Name < errs[j].Name })
for _, e := range errs {
value := policy.Value(responseResult(e))
examples[e.Name] = map[string]any{"summary": e.Message, "value": value}
codeKey := fmt.Sprint(value[cfg.CodeField])
if !seenCodes[codeKey] {
codes = append(codes, value[cfg.CodeField])
seenCodes[codeKey] = true
}
description = append(description, e.Code+": "+e.Message)
}
media["examples"] = examples
if !exists && cfg.Body == nil && cfg.CodeField != "-" {
media["schema"].(map[string]any)["properties"].(map[string]any)[cfg.CodeField].(map[string]any)["enum"] = codes
}
response["description"] = strings.Join(description, "\n")
}
}
}
func responseResult(e ErrorDefinition) response.Result {
return response.Result{Status: e.Status, Code: e.Code, Message: e.Message, RequestID: "exampleRequest"}
}
func genericErrorResponse(description string, cfg response.Config) map[string]any {
if description == "" {
description = "Error"
}
schema, _ := openapi.EnvelopeWith(nil, cfg)
data, _ := json.Marshal(schema)
var value map[string]any
_ = json.Unmarshal(data, &value)
return map[string]any{"description": description, "content": map[string]any{"application/json": map[string]any{"schema": value}}}
}

View File

@@ -0,0 +1,165 @@
package apigen
import (
"fmt"
"strconv"
"strings"
)
// ErrorDefinition declares a public, stable error. Generated constructors return
// a fresh error each time so WithCause cannot mutate a shared singleton.
type ErrorDefinition struct {
Name string `json:"name"`
Status int `json:"status"`
Code string `json:"code"`
Message string `json:"message"`
Position string `json:"-"`
}
type AuthDefinition struct {
Name, Type, In, KeyName, Middleware, Description, BearerFormat, Position string
}
func (p *parser) parseErrors() error {
p.next()
block := p.peek() == "("
if block {
p.next()
p.skip()
}
for {
pos := p.tokens[p.at].position
name, err := p.ident()
if err != nil {
return err
}
status, err := strconv.Atoi(p.next().text)
if err != nil {
return p.err("error HTTP status must be an integer")
}
code, err := p.quoted()
if err != nil {
return err
}
message, err := p.quoted()
if err != nil {
return err
}
p.spec.Errors = append(p.spec.Errors, ErrorDefinition{Name: name, Status: status, Code: code, Message: message, Position: pos})
if p.peek() != "\n" && p.peek() != ";" && !(block && p.peek() == ")") && p.peek() != "" {
return p.err("expected end of error declaration")
}
if !block {
return nil
}
p.skip()
if p.peek() == ")" {
p.next()
return nil
}
}
}
func (p *parser) parseAuth() error {
pos := p.next().position
name, err := p.ident()
if err != nil {
return err
}
p.skip()
props, err := p.metadata()
if err != nil {
return err
}
a := AuthDefinition{Name: name, Position: pos}
for key, value := range props {
switch key {
case "type":
a.Type = value
case "in":
a.In = value
case "name":
a.KeyName = value
case "middleware":
a.Middleware = value
case "description":
a.Description = value
case "bearerFormat":
a.BearerFormat = value
default:
return fmt.Errorf("%s: unsupported auth property %q", pos, key)
}
}
p.spec.Auth = append(p.spec.Auth, a)
return nil
}
func (p *parser) names() ([]string, error) {
var names []string
for {
name, err := p.ident()
if err != nil {
return nil, err
}
for _, prior := range names {
if prior == name {
return nil, p.err("duplicate name " + name)
}
}
names = append(names, name)
if p.peek() != "," {
return names, nil
}
p.next()
}
}
func commaList(value string) []string {
if value == "" {
return nil
}
result := strings.Split(value, ",")
for i := range result {
result[i] = strings.TrimSpace(result[i])
}
return result
}
func mergeNames(base, extra []string) []string {
result := append([]string(nil), base...)
for _, name := range extra {
found := false
for _, prior := range result {
found = found || prior == name
}
if !found {
result = append(result, name)
}
}
return result
}
func (p *parser) parseDoc(route *Route) error {
if p.peek() != "(" {
value, err := p.quoted()
route.Summary = value
return err
}
pos := p.tokens[p.at].position
props, err := p.metadata()
if err != nil {
return err
}
for key, value := range props {
switch key {
case "summary":
route.Summary = value
case "description":
route.Description = value
case "tags":
route.Tags = commaList(value)
case "deprecated":
if value != "true" && value != "false" {
return fmt.Errorf("%s: deprecated must be true or false", pos)
}
route.Deprecated = value == "true"
default:
return fmt.Errorf("%s: unsupported @doc property %q", pos, key)
}
}
return nil
}

View File

@@ -1,6 +1,7 @@
package apigen
import (
"encoding/json"
"errors"
"fmt"
"go/format"
@@ -18,19 +19,30 @@ import (
)
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
const minimumVersion = "v0.7.0-preview"
const minimumVersion = "v0.8.0-preview"
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
type Options struct {
Database, Storage string
API, Dir, Module, FrameworkDir, FrameworkVersion, LogOutput, LogFormat string
Check bool
DryRun bool
}
type FileChange struct {
Path string
Action string
}
type Output struct {
Generated, Created, Preserved, Removed []string
Changes []FileChange
}
type Output struct{ Generated, Created, Preserved, Removed []string }
// Generate creates or safely updates one contract-driven project. It renders
// and validates every output before applying an ownership-checked transaction.
func Generate(o Options) (Output, error) {
if o.Check && o.DryRun {
return Output{}, errors.New("--check and --dry-run cannot be combined")
}
if o.API == "" || o.Dir == "" {
return Output{}, errors.New("--api and --dir are required")
}
@@ -49,8 +61,8 @@ const starterAPI = "syntax = \"v1\"\n\ninfo (\n title: \"Greeting API\"\n versio
// New creates a runnable greeting project, including its editable API file.
func New(o Options) (Output, error) {
if o.API != "" || o.Check {
return Output{}, errors.New("api new does not accept --api or --check")
if o.API != "" || o.Check || o.DryRun {
return Output{}, errors.New("api new does not accept --api, --check or --dry-run")
}
if o.Dir == "" {
return Output{}, errors.New("project directory required")
@@ -118,13 +130,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, "contract", "contract.go")); err == nil {
if _, err := os.Stat(filepath.Join(path, "httpx", "response.go")); err == nil {
localCurrent = true
}
}
}
if !localCurrent {
return Output{}, fmt.Errorf("existing framework dependency predates API support; use %s or a current local replacement", minimumVersion)
return Output{}, fmt.Errorf("existing framework dependency predates configurable responses; use %s or a current local replacement", minimumVersion)
}
}
if o.FrameworkDir != "" {
@@ -204,7 +216,7 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
}
}
}
return apply(directory, o.Module, entry, generated, seeds, o.Check, o.profile())
return apply(directory, o.Module, entry, generated, seeds, o.Check, o.DryRun, o.profile())
}
func moduleFile(o Options) ([]byte, error) {
@@ -289,6 +301,13 @@ func canonical(path string) string {
func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map[string][]byte, error) {
generated, seeds := map[string][]byte{}, map[string][]byte{}
put := func(files map[string][]byte, name, content string) { files[name] = []byte(content) }
if len(c.Spec.Errors) > 0 {
content := "package apierrors\nimport omnierrors " + strconv.Quote(frameworkModule+"/errors") + "\n"
for _, e := range c.Spec.Errors {
content += fmt.Sprintf("const Code%s = %q\n// %s returns a fresh public error declared in the API contract.\nfunc %s()*omnierrors.APIError{return omnierrors.New(%d,Code%s,%q)}\n", e.Name, e.Code, e.Name, e.Name, e.Status, e.Name, e.Message)
}
put(generated, "internal/apierrors/errorsGen.go", generatedHeader+content)
}
types := "package types\n\n"
for _, t := range c.Spec.Types {
types += "type " + t.Name + " struct {\n"
@@ -300,6 +319,16 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
put(generated, "internal/types/typesGen.go", generatedHeader+types)
put(generated, "docs/openapi.json", string(c.Document)+"\n")
put(generated, "internal/handler/openapiGen.go", generatedHeader+fmt.Sprintf("package handler\nimport (\"net/http\"; %q)\nconst OpenAPIDocument = %q\nfunc OpenAPI(c *httpx.Context) error { return c.WriteResponse(200,http.Header{\"Content-Type\": {\"application/json; charset=utf-8\"}},[]byte(OpenAPIDocument)) }\n", frameworkModule+"/httpx", string(c.Document)+"\n"))
policySource := fmt.Sprintf("package handler\nimport(\"encoding/json\";\"strings\";%q)\nvar responseTemplates=map[string]response.Encoder{\"\":response.Default(),\n", frameworkModule+"/response")
for _, definition := range c.Spec.Responses {
data, err := json.Marshal(definition.Config)
if err != nil {
return nil, nil, err
}
policySource += fmt.Sprintf("%q: newResponseTemplate(%q),\n", definition.Name, string(data))
}
policySource += "}\nfunc newResponseTemplate(raw string)response.Encoder{var cfg response.Config;decoder:=json.NewDecoder(strings.NewReader(raw));decoder.UseNumber();if err:=decoder.Decode(&cfg);err!=nil{panic(err)};return response.MustNew(cfg)}\nfunc responseEncoder(name string,raw bool)response.Encoder{encoder:=responseTemplates[name];if encoder==nil{panic(\"unknown response template\")};if raw{encoder=response.RawJSON(encoder)};return encoder}\n"
put(generated, "internal/handler/responseGen.go", generatedHeader+policySource)
imports := fmt.Sprintf("import (\"fmt\"; %q; %q; %q;", frameworkModule+"/httpx", frameworkModule+"/openapi", o.Module+"/internal/servicecontext")
routes := "func RegisterRoutes(router openapi.Registrar, deps *servicecontext.Context) error {\n if router==nil || deps==nil {return fmt.Errorf(\"router and dependencies required\")}\n"
middlewareSet := map[string]bool{}
@@ -363,7 +392,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
} else {
text += fmt.Sprintf("if err:=h.service.%s(%s);err!=nil{return err};return c.NoContent()\n}\n", name, call)
}
routes += fmt.Sprintf("router.Handle(%q,%q,h%d.%s", strings.ToUpper(r.Method), r.Path, i, name)
routes += fmt.Sprintf("httpx.HandleWithResponse(router,%q,%q,responseEncoder(%q,%t),h%d.%s", strings.ToUpper(r.Method), r.Path, responseName(r), r.ResponseFormat == "raw", i, name)
for _, m := range r.Middleware {
routes += fmt.Sprintf(",deps.Middlewares[%q]", m)
}
@@ -395,8 +424,6 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
put(seeds, "internal/service/"+group+"/service.go", fmt.Sprintf("package %s\nimport %q\ntype Service struct {deps *servicecontext.Context}\nfunc New(deps *servicecontext.Context)*Service{return &Service{deps:deps}}\n", packageName, o.Module+"/internal/servicecontext"))
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")
}
// Avoid unused imports when no route uses a named middleware.
imports = strings.Replace(imports, strconv.Quote(frameworkModule+"/httpx")+";", "", 1)
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"
for _, m := range middlewareNames {

View File

@@ -0,0 +1,305 @@
package apigen
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
const declaredAPI = `syntax = "v1"
info (
title: "Declared API"
description: "Errors and authentication share one contract"
)
error (
Unauthenticated 401 "UNAUTHENTICATED" "authentication required"
Forbidden 403 "FORBIDDEN" "permission denied"
EmailTaken 409 "EMAIL_TAKEN" "email already in use"
NameTaken 409 "NAME_TAKEN" "name already in use"
)
auth UserAuth (
type: bearer
middleware: Auth
description: "Verified application token"
)
auth PartnerAuth (
type: apiKey
in: header
name: X-API-Key
middleware: Partner
)
auth SessionAuth (
type: apiKey
in: cookie
name: sessionId
middleware: Session
)
@server (
group: user
auth: UserAuth
errors: Unauthenticated
middleware: Audit
)
service declared-api {
@doc (
summary: "Create a user"
description: "Uniqueness is enforced in a transaction"
tags: users, administration
deprecated: true
)
@errors Forbidden, EmailTaken, NameTaken
@handler create
post /users
@auth none
@errors none
@handler public
get /public
@auth PartnerAuth
@handler partner
get /partner
@auth SessionAuth
@handler session
get /session
}
`
func document(t *testing.T, c *Compiled) map[string]any {
t.Helper()
var doc map[string]any
if err := json.Unmarshal(c.Document, &doc); err != nil {
t.Fatal(err)
}
return doc
}
func operation(t *testing.T, doc map[string]any, path, method string) map[string]any {
t.Helper()
return doc["paths"].(map[string]any)[path].(map[string]any)[method].(map[string]any)
}
func TestDeclaredErrorsAndAuthentication(t *testing.T) {
spec, err := parseText(t, declaredAPI)
if err != nil {
t.Fatal(err)
}
c, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
again, err := Compile(spec)
if err != nil || string(c.Document) != string(again.Document) {
t.Fatalf("compile is not repeatable: %v", err)
}
if strings.Join(spec.Routes[0].Middleware, ",") != "Audit" {
t.Fatal("parser output mutated")
}
if strings.Join(c.Spec.Routes[0].Middleware, ",") != "Auth,Audit" {
t.Fatal("authentication must be first")
}
if strings.Join(c.Spec.Routes[1].Middleware, ",") != "Audit" {
t.Fatal("public override retained automatic auth")
}
doc := document(t, c)
op := operation(t, doc, "/users", "post")
if op["deprecated"] != true || op["description"] == nil || len(op["tags"].([]any)) != 2 {
t.Fatalf("documentation missing: %v", op)
}
security := op["security"].([]any)
if _, ok := security[0].(map[string]any)["UserAuth"]; !ok {
t.Fatal("security requirement absent")
}
schemes := doc["components"].(map[string]any)["securitySchemes"].(map[string]any)
if len(schemes) != 3 || schemes["UserAuth"].(map[string]any)["scheme"] != "bearer" || schemes["SessionAuth"].(map[string]any)["in"] != "cookie" {
t.Fatalf("wrong security schemes: %v", schemes)
}
responses := op["responses"].(map[string]any)
for _, status := range []string{"401", "403", "409"} {
if responses[status] == nil {
t.Fatal("missing status " + status)
}
}
media := responses["409"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)
if len(media["examples"].(map[string]any)) != 2 {
t.Fatal("same-status errors were overwritten")
}
codes := media["schema"].(map[string]any)["properties"].(map[string]any)["code"].(map[string]any)["enum"].([]any)
if len(codes) != 2 {
t.Fatal("business code enum absent")
}
public := operation(t, doc, "/public", "get")
if len(public["security"].([]any)) != 0 || public["responses"].(map[string]any)["401"] != nil || public["x-omni-errors"] != nil {
t.Fatal("public overrides did not clear group declarations")
}
}
func TestInvalidDeclarations(t *testing.T) {
replacements := [][2]string{
{`"EMAIL_TAKEN"`, `"UNAUTHENTICATED"`},
{`EmailTaken 409`, `EmailTaken 200`},
{`EmailTaken 409`, `emailTaken 409`},
{`NameTaken 409`, `CodeEmailTaken 409`},
{`auth: UserAuth`, `auth: Unknown`},
{`errors: Unauthenticated`, `errors: Unknown`},
{`middleware: Audit`, `middleware: Auth`},
{`type: bearer`, `type: oauth2`},
{`in: header`, `in: query`},
{`name: X-API-Key`, `name: Authorization`},
{`deprecated: true`, `deprecated: yes`},
{`tags: users, administration`, `tags: users, users`},
{`@errors Forbidden, EmailTaken, NameTaken`, `@errors Forbidden, Forbidden`},
{`middleware: Auth`, `middleware: Defaults`},
}
for _, change := range replacements {
spec, err := parseText(t, strings.Replace(declaredAPI, change[0], change[1], 1))
if err == nil {
_, err = Compile(spec)
}
if err == nil {
t.Fatalf("accepted invalid %q", change[1])
}
if !strings.Contains(err.Error(), "service.api:") {
t.Fatalf("source position missing: %v", err)
}
}
}
func TestImportedErrorCatalog(t *testing.T) {
root := t.TempDir()
writeFile(t, filepath.Join(root, "errors.api"), `error Conflict 409 "CONFLICT" "conflict"`)
writeFile(t, filepath.Join(root, "service.api"), "import \"errors.api\"\nservice imported-api { @errors Conflict\n @handler create\n post /users\n }")
spec, err := Parse(filepath.Join(root, "service.api"))
if err != nil {
t.Fatal(err)
}
if _, err = Compile(spec); err != nil {
t.Fatal(err)
}
}
func TestDryRunAndEntryDiscovery(t *testing.T) {
root := t.TempDir()
source := filepath.Join(root, "input/service.api")
writeFile(t, source, declaredAPI)
dir := filepath.Join(root, "project")
o := Options{API: source, Dir: dir, Module: "example.com/dx", FrameworkDir: frameworkRoot(t), DryRun: true}
out, err := Generate(o)
if err != nil || len(out.Changes) == 0 {
t.Fatalf("empty plan: %v", err)
}
if _, err = os.Stat(dir); !os.IsNotExist(err) {
t.Fatal("dry-run created directory")
}
o.DryRun = false
if _, err = Generate(o); err != nil {
t.Fatal(err)
}
entry, err := ProjectEntry(dir)
if err != nil || entry != filepath.Join(dir, "api/service.api") {
t.Fatalf("entry: %s %v", entry, err)
}
o = Options{API: entry, Dir: dir, DryRun: true}
before := snapshot(t, dir)
out, err = Generate(o)
if err != nil || len(out.Changes) != 0 {
t.Fatalf("unchanged project plan: %v %+v", err, out.Changes)
}
writeFile(t, entry, strings.Replace(declaredAPI, "email already in use", "email is already registered", 1))
before = snapshot(t, dir)
out, err = Generate(o)
if err != nil {
t.Fatal(err)
}
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("dry-run changed project")
}
actions := map[string]string{}
for _, c := range out.Changes {
actions[c.Path] = c.Action
}
if actions["internal/apierrors/errorsGen.go"] != "update" || actions["docs/openapi.json"] != "update" {
t.Fatalf("missing plan: %v", actions)
}
o.Check = true
if _, err = Generate(o); err == nil {
t.Fatal("check and dry-run combined")
}
// A malformed manifest cannot redirect the shorthand outside its project.
writeFile(t, filepath.Join(dir, manifestPath), `{"version":1,"entry":"../input/service.api"}`)
if _, err = ProjectEntry(dir); err == nil {
t.Fatal("unsafe manifest entry accepted")
}
}
func TestDeclaredContractConsumer(t *testing.T) {
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") == "" {
t.Skip("consumer integration")
}
root := t.TempDir()
source := filepath.Join(root, "input/service.api")
writeFile(t, source, declaredAPI)
dir := filepath.Join(root, "project")
if _, err := Generate(Options{API: source, Dir: dir, Module: "example.com/dx", FrameworkDir: frameworkRoot(t)}); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dir, "contract_test.go"), declaredConsumerTest)
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "-race", "-count=1", "./...")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("consumer: %v\n%s", err, out)
}
}
const declaredConsumerTest = `package dx_test
import (
"context";"errors";"io";"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"
"example.com/dx/internal/apierrors"
"example.com/dx/internal/handler"
"example.com/dx/internal/servicecontext"
)
func TestRuntimeAuthAndFreshErrors(t *testing.T){
first:=apierrors.EmailTaken().WithCause(errors.New("private"));second:=apierrors.EmailTaken()
if first==second||second.Cause!=nil||second.Code!=apierrors.CodeEmailTaken||second.Status!=409{t.Fatal("shared error instance")}
cfg:=logx.Default();cfg.Writer=io.Discard;logger,err:=logx.New(cfg);if err!=nil{t.Fatal(err)};defer logger.Close()
deps,err:=servicecontext.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close()
app:=omni.New();defer app.Close();if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)}
w:=httptest.NewRecorder();app.Engine().ServeHTTP(w,httptest.NewRequest("POST","/users",nil))
if w.Code!=501||!strings.Contains(w.Body.String(),"MIDDLEWARE_NOT_IMPLEMENTED"){t.Fatal("unimplemented auth did not fail closed")}
var called []string
deps.Middlewares["Auth"]=func(next httpx.Handler)httpx.Handler{return func(c *httpx.Context)error{called=append(called,"auth");if c.Header("Authorization")!="Bearer valid"{return apierrors.Unauthenticated()};return next(c)}}
deps.Middlewares["Audit"]=func(next httpx.Handler)httpx.Handler{return func(c *httpx.Context)error{called=append(called,"audit");return next(c)}}
app2:=omni.New();defer app2.Close();if err=handler.RegisterRoutes(app2,deps);err!=nil{t.Fatal(err)}
w=httptest.NewRecorder();app2.Engine().ServeHTTP(w,httptest.NewRequest("POST","/users",nil))
if w.Code!=401||strings.Join(called,",")!="auth"{t.Fatal("unauthenticated request reached other middleware")}
called=nil;req:=httptest.NewRequest("POST","/users",nil);req.Header.Set("Authorization","Bearer valid")
w=httptest.NewRecorder();app2.Engine().ServeHTTP(w,req)
if w.Code!=501||strings.Join(called,",")!="auth,audit"{t.Fatal("middleware order changed")}
called=nil;w=httptest.NewRecorder();app2.Engine().ServeHTTP(w,httptest.NewRequest("GET","/public",nil))
if w.Code!=501||strings.Join(called,",")!="audit"||strings.Contains(w.Body.String(),"MIDDLEWARE_NOT_IMPLEMENTED"){t.Fatal("public override retained auth")}
delete(deps.Middlewares,"Auth");app3:=omni.New();defer app3.Close()
if err=handler.RegisterRoutes(app3,deps);err==nil{t.Fatal("missing auth registration accepted")}
w=httptest.NewRecorder();app3.Engine().ServeHTTP(w,httptest.NewRequest("GET","/public",nil))
if w.Code!=404{t.Fatal("failed auth preflight registered partial routes")}
}
`
func TestAuthMetadataWithParameterNamePrefixes(t *testing.T) {
source := "type Req {\n ID int `path:\"id\"`\n IDLong int `path:\"idLong\"`\n}\nauth Gate (\n type: bearer\n middleware: Auth\n)\n@server (auth: Gate)\nservice prefix-api {\n @handler read\n get /items/:id/children/:idLong (Req)\n}"
spec, err := parseText(t, source)
if err != nil {
t.Fatal(err)
}
c, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
op := operation(t, document(t, c), "/items/{id}/children/{idLong}", "get")
if len(op["security"].([]any)) != 1 {
t.Fatal("auth metadata lost")
}
}

View File

@@ -14,11 +14,14 @@ import (
)
type Spec struct {
Title, Version, Service string
Types []Type
Routes []Route
Sources map[string][]byte // relative to the entry API's directory
Entry string
Title, Version, Service, Description string
Errors []ErrorDefinition
Auth []AuthDefinition
Responses []ResponseDefinition
Types []Type
Routes []Route
Sources map[string][]byte // relative to the entry API's directory
Entry string
}
type Type struct {
Name string
@@ -29,12 +32,20 @@ type Field struct{ Name, Type, Tag string }
type Route struct {
Method, Path, Request, Response, Handler, Group, Summary string
Middleware []string
Auth string
ResponseFormat string
ErrorResponseFormat string
Errors []string
Description string
Tags []string
Deprecated bool
Status int
Position string
}
type token struct {
text, position string
kind rune
space bool
}
type parser struct {
tokens []token
@@ -137,9 +148,11 @@ func lex(name, source string) ([]token, error) {
}
}
var out []token
end := 0
for {
kind := s.Scan()
out = append(out, token{s.TokenText(), s.Position.String(), kind})
out = append(out, token{s.TokenText(), s.Position.String(), kind, s.Position.Offset > end})
end = s.Position.Offset + len(s.TokenText())
if kind == scanner.EOF {
break
}
@@ -208,13 +221,20 @@ func (p *parser) parse() error {
if err != nil {
return err
}
if _, a := m["desc"]; a {
if _, b := m["description"]; b {
return p.err("use only one of info desc and description")
}
}
for k, v := range m {
switch k {
case "title":
p.spec.Title = v
case "version":
p.spec.Version = v
case "desc", "description", "author", "email":
case "desc", "description":
p.spec.Description = v
case "author", "email":
default:
return p.err("unsupported info property " + k)
}
@@ -238,6 +258,18 @@ func (p *parser) parse() error {
}
p.imports = append(p.imports, v)
}
case "error":
if err := p.parseErrors(); err != nil {
return err
}
case "auth":
if err := p.parseAuth(); err != nil {
return err
}
case "response":
if err := p.parseResponse(); err != nil {
return err
}
case "type":
p.next()
if p.peek() == "(" {
@@ -267,7 +299,7 @@ func (p *parser) parse() error {
}
server = m
for k := range m {
if k != "prefix" && k != "group" && k != "middleware" {
if k != "prefix" && k != "group" && k != "middleware" && k != "auth" && k != "errors" && k != "response" {
return p.err("unsupported @server option " + k + "; wire authentication through named middleware")
}
}
@@ -304,6 +336,29 @@ func (p *parser) metadata() (map[string]string, error) {
if err != nil {
return nil, err
}
} else if p.peek() == "{" {
depth := 0
var object strings.Builder
for {
t := p.next()
if t.kind == scanner.EOF {
return nil, p.err("unterminated metadata object")
}
if t.text == "{" {
depth++
}
if t.text == "}" {
depth--
}
if t.space {
object.WriteByte(' ')
}
object.WriteString(t.text)
if depth == 0 {
break
}
}
value = object.String()
} else {
for p.peek() != "\n" && p.peek() != ";" && p.peek() != ")" && p.tokens[p.at].kind != scanner.EOF {
value += p.next().text
@@ -379,7 +434,7 @@ func (p *parser) parseService(server map[string]string) error {
return err
}
for p.skip(); p.peek() != "}"; p.skip() {
route := Route{Group: server["group"], Status: 200, Position: p.tokens[p.at].position}
route := Route{ResponseFormat: server["response"], Auth: server["auth"], Errors: commaList(server["errors"]), Group: server["group"], Status: 200, Position: p.tokens[p.at].position}
if route.Group == "" {
route.Group = "api"
}
@@ -401,7 +456,24 @@ func (p *parser) parseService(server map[string]string) error {
case "handler":
route.Handler, e = p.ident()
case "doc":
route.Summary, e = p.quoted()
e = p.parseDoc(&route)
case "auth":
route.Auth, e = p.ident()
case "response":
var format string
format, e = p.ident()
if format == "raw" && route.ResponseFormat != "raw" {
route.ErrorResponseFormat = route.ResponseFormat
}
route.ResponseFormat = format
case "errors":
var names []string
names, e = p.names()
if len(names) == 1 && names[0] == "none" {
route.Errors = nil
} else {
route.Errors = mergeNames(route.Errors, names)
}
case "status":
route.Status, e = strconv.Atoi(p.next().text)
default:

View File

@@ -0,0 +1,35 @@
package apigen
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// ProjectEntry discovers the API entry recorded by generation, never guessing
// which of several .api files is the service entry.
func ProjectEntry(directory string) (string, error) {
root, err := filepath.Abs(directory)
if err != nil {
return "", err
}
if err = safePath(root, manifestPath); err != nil {
return "", err
}
data, err := os.ReadFile(filepath.Join(root, manifestPath))
if err != nil {
return "", fmt.Errorf("--api is required outside a generated project (no readable %s)", manifestPath)
}
var m manifest
if err = json.Unmarshal(data, &m); err != nil {
return "", fmt.Errorf("invalid API project manifest: %w", err)
}
if m.Version != 1 || filepath.Ext(m.Entry) != ".api" {
return "", fmt.Errorf("invalid API entry in %s; pass --api explicitly", manifestPath)
}
if err = safePath(root, m.Entry); err != nil {
return "", err
}
return filepath.Join(root, filepath.FromSlash(m.Entry)), nil
}

View File

@@ -0,0 +1,114 @@
package apigen
import (
"bytes"
"encoding/json"
"fmt"
"io"
"strings"
"git.yuntuo.hk/k2safe/go-omni/response"
)
type ResponseDefinition struct {
Name string
Config response.Config
Position string
}
func (p *parser) parseResponse() error {
pos := p.next().position
name, err := p.ident()
if err != nil {
return err
}
p.skip()
props, err := p.metadata()
if err != nil {
return err
}
values := map[string]any{}
for key, value := range props {
switch key {
case "successCode", "errorCode":
if kind := props["codeType"]; kind == "integer" || kind == "int32" || kind == "int64" {
var number json.Number
if err = json.Unmarshal([]byte(value), &number); err != nil {
return fmt.Errorf("%s: %s must be an integer", pos, key)
}
values[key] = number
} else {
values[key] = value
}
case "codes", "messages", "body", "errorBody":
decoder := json.NewDecoder(strings.NewReader(value))
decoder.UseNumber()
var object map[string]any
if err = decoder.Decode(&object); err != nil || object == nil {
return fmt.Errorf("%s: %s must be a JSON object", pos, key)
}
var extra any
if decoder.Decode(&extra) != io.EOF {
return fmt.Errorf("%s: invalid %s mapping", pos, key)
}
values[key] = object
default:
values[key] = value
}
}
data, err := json.Marshal(values)
if err != nil {
return err
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
decoder.DisallowUnknownFields()
var cfg response.Config
if err = decoder.Decode(&cfg); err != nil {
return fmt.Errorf("%s: response %s: %w", pos, name, err)
}
if err = cfg.Validate(); err != nil {
return fmt.Errorf("%s: %w", pos, err)
}
p.spec.Responses = append(p.spec.Responses, ResponseDefinition{Name: name, Config: cfg, Position: pos})
return nil
}
func responseConfig(spec *Spec, route Route) response.Config {
name := responseName(route)
for _, definition := range spec.Responses {
if definition.Name == name {
return definition.Config
}
}
return response.Config{}
}
func responseName(route Route) string {
if route.ResponseFormat == "raw" {
return route.ErrorResponseFormat
}
return route.ResponseFormat
}
func validateResponses(spec *Spec) error {
seen := map[string]bool{}
for _, definition := range spec.Responses {
if !exportedName.MatchString(definition.Name) || seen[definition.Name] {
return fmt.Errorf("%s: response template requires a unique UpperCamelCase name", definition.Position)
}
if err := definition.Config.Validate(); err != nil {
return fmt.Errorf("%s: %w", definition.Position, err)
}
seen[definition.Name] = true
}
for _, r := range spec.Routes {
if r.ResponseFormat != "" && r.ResponseFormat != "raw" && !seen[r.ResponseFormat] {
return fmt.Errorf("%s: unknown response template %s", r.Position, r.ResponseFormat)
}
if r.ErrorResponseFormat != "" && !seen[r.ErrorResponseFormat] {
return fmt.Errorf("%s: unknown error response template %s", r.Position, r.ErrorResponseFormat)
}
}
return nil
}

View File

@@ -0,0 +1,357 @@
package apigen
import (
"context"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
const configurableResponseAPI = `syntax = "v1"
response CommonResponse (
codeField: code
messageField: msg
dataField: data
requestIdField: "-"
codeType: integer
successCode: 200
errorCode: 9999
successMessage: "操作成功"
errorMessage: "系统错误"
dataMode: always
codes: {
"UNAUTHENTICATED": 401001,
"EMAIL_TAKEN": 409001
}
messages: {"UNAUTHENTICATED":"请先登录","EMAIL_TAKEN":"邮箱已存在"}
)
response PartnerResponse (
codeField: result
messageField: notice
dataField: payload
requestIdField: trace
successCode: "PASS"
)
error (
Unauthenticated 401 "UNAUTHENTICATED" "authentication required"
EmailTaken 409 "EMAIL_TAKEN" "email already taken"
)
auth Access (type: bearer
middleware: Auth
)
type ItemResponse {
Name string ` + "`json:\"name\"`" + `
}
@server (
group: items
response: CommonResponse
auth: Access
errors: Unauthenticated
)
service response-api {
@errors EmailTaken
@handler item
get /item returns (ItemResponse)
@response raw
@handler callback
post /callback returns (ItemResponse)
@response PartnerResponse
@handler partner
get /partner returns (ItemResponse)
}
`
func TestResponseTemplatesDocumentAndValidation(t *testing.T) {
spec, err := parseText(t, configurableResponseAPI)
if err != nil {
t.Fatal(err)
}
c, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
doc := document(t, c)
getSchema := func(path, status string) map[string]any {
method := "get"
if path == "/callback" {
method = "post"
}
return operation(t, doc, path, method)["responses"].(map[string]any)[status].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)
}
properties := getSchema("/item", "200")["properties"].(map[string]any)
if len(properties) != 3 || properties["code"].(map[string]any)["type"] != "integer" || properties["msg"] == nil || properties["requestId"] != nil {
t.Fatal(properties)
}
if getSchema("/callback", "200")["properties"].(map[string]any)["name"] == nil {
t.Fatal("raw JSON was wrapped")
}
if getSchema("/callback", "401")["properties"].(map[string]any)["msg"] == nil {
t.Fatal("raw override lost group error policy")
}
if getSchema("/partner", "200")["properties"].(map[string]any)["payload"] == nil {
t.Fatal("route override not applied")
}
conflict := operation(t, doc, "/item", "get")["responses"].(map[string]any)["409"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)
value := conflict["examples"].(map[string]any)["EmailTaken"].(map[string]any)["value"].(map[string]any)
if value["code"] != float64(409001) || value["msg"] != "邮箱已存在" || len(value) != 3 {
t.Fatal(value)
}
if got := conflict["schema"].(map[string]any)["properties"].(map[string]any)["code"].(map[string]any)["enum"].([]any); got[0] != float64(409001) {
t.Fatal(got)
}
for _, change := range [][2]string{{"response: CommonResponse", "response: Missing"}, {"messageField: msg", "messageField: code"}, {"codeType: integer", "codeType: boolean"}, {"successCode: 200", "successCode: 2.5"}, {"requestIdField: \"-\"", "unknownOption: value"}, {"@response raw", "@response missing"}} {
invalid, err := parseText(t, strings.Replace(configurableResponseAPI, change[0], change[1], 1))
if err == nil {
_, err = Compile(invalid)
}
if err == nil || !strings.Contains(err.Error(), "service.api:") {
t.Fatalf("bad declaration %s: %v", change[1], err)
}
}
}
func TestImportedResponseTemplate(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "common.api"), "response Shared (messageField: msg)\n")
writeFile(t, filepath.Join(dir, "service.api"), "import \"common.api\"\n@server (response: Shared)\nservice shared-api {\n@handler ping\nget /ping\n}")
spec, err := Parse(filepath.Join(dir, "service.api"))
if err != nil {
t.Fatal(err)
}
if _, err = Compile(spec); err != nil {
t.Fatal(err)
}
}
func TestResponseNestedBodyDocument(t *testing.T) {
definition := `response Nested (
body: {"success":"${ok}","result":"${data}","meta":{"status":"${status}","trace":"${requestId}"},"version":1}
errorBody: {"success":"${ok}","error":{"id":"${code}","reason":"${message}"}}
codeType: integer
successCode: 0
errorCode: 9999
codes: {"EMAIL_TAKEN":409001}
)
`
spec, err := parseText(t, definition+strings.Replace(configurableResponseAPI, "response: CommonResponse", "response: Nested", 1))
if err != nil {
t.Fatal(err)
}
compiled, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
doc := document(t, compiled)
responses := operation(t, doc, "/item", "get")["responses"].(map[string]any)
success := responses["200"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)["properties"].(map[string]any)
if success["code"] != nil || success["result"] == nil || success["success"].(map[string]any)["type"] != "boolean" {
t.Fatal(success)
}
media := responses["409"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)
schema := media["schema"].(map[string]any)["properties"].(map[string]any)
if schema["error"].(map[string]any)["properties"].(map[string]any)["id"].(map[string]any)["type"] != "integer" || schema["result"] != nil {
t.Fatal(schema)
}
example := media["examples"].(map[string]any)["EmailTaken"].(map[string]any)["value"].(map[string]any)
if example["success"] != false || example["error"].(map[string]any)["id"] != float64(409001) {
t.Fatal(example)
}
}
func TestResponseTemplateJSONTokens(t *testing.T) {
for _, tc := range []struct {
body string
valid bool
}{
{`{"scale":-1.5,"exponent":1e-3,"items":[true,null,"${data}"],"label":"a b"}`, true},
{`{"scale":1 2}`, false}, {`{"scale":- 12}`, false},
{`{"scale":tru e}`, false}, {`{"scale":1. 2}`, false},
} {
text := "response Shared (\nbody: " + tc.body + "\n)\n@server (response: Shared)\nservice test-api {\n@handler ping\nget /ping\n}\n"
spec, err := parseText(t, text)
if err == nil {
_, err = Compile(spec)
}
if (err == nil) != tc.valid {
t.Fatalf("%s: %v", tc.body, err)
}
}
}
func TestResponseCodeTypeDeclarations(t *testing.T) {
for _, kind := range []string{"int32", "int64"} {
source := strings.Replace(configurableResponseAPI, "codeType: integer", "codeType: "+kind, 1)
spec, err := parseText(t, source)
if err != nil {
t.Fatal(err)
}
compiled, err := Compile(spec)
if err != nil {
t.Fatal(err)
}
doc := document(t, compiled)
for _, status := range []string{"200", "401", "409", "500"} {
responses := operation(t, doc, "/item", "get")["responses"].(map[string]any)
schema := responses[status].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)
code := schema["properties"].(map[string]any)["code"].(map[string]any)
if code["type"] != "integer" || code["format"] != kind {
t.Fatal(code)
}
}
for _, bad := range []string{"2.5", "9223372036854775808", "-9223372036854775809"} {
invalid := strings.Replace(source, "successCode: 200", "successCode: "+bad, 1)
if _, err := parseText(t, invalid); err == nil || !strings.Contains(err.Error(), "service.api:") {
t.Fatalf("%s %s: %v", kind, bad, err)
}
}
}
invalid := strings.Replace(configurableResponseAPI, "codeType: integer", "codeType: int32", 1)
invalid = strings.Replace(invalid, `"EMAIL_TAKEN": 409001`, `"EMAIL_TAKEN": 2147483648`, 1)
if _, err := parseText(t, invalid); err == nil {
t.Fatal("overflowing int32 mapping accepted")
}
}
func TestResponseTemplateConsumer(t *testing.T) {
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") == "" {
t.Skip("consumer integration")
}
dir := filepath.Join(t.TempDir(), "project")
source := filepath.Join(t.TempDir(), "service.api")
// Use an explicitly declared public body, rather than field-name shortcuts.
sourceText := strings.Replace(configurableResponseAPI, " codeField: code\n messageField: msg\n dataField: data\n requestIdField: \"-\"", ` body: {"code":"${code}","msg":"${message}","data":"${data}"}`, 1)
sourceText = strings.Replace(sourceText, "codeType: integer", "codeType: int64", 1)
sourceText = strings.Replace(sourceText, "successCode: 200", "successCode: 9223372036854775807", 1)
writeFile(t, source, sourceText)
releaseCLI := os.Getenv("GO_OMNI_RELEASE_CLI")
generate := func(api string, initial bool) {
t.Helper()
if releaseCLI != "" {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, releaseCLI, "api", "generate", "--api", api, "--dir", dir, "--module", "example.com/responseConsumer")
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("released CLI: %v\n%s", err, output)
}
return
}
o := Options{API: api, Dir: dir}
if initial {
o.Module = "example.com/responseConsumer"
o.FrameworkDir = frameworkRoot(t)
}
if _, err := Generate(o); err != nil {
t.Fatal(err)
}
}
generate(source, true)
writeFile(t, filepath.Join(dir, "response_test.go"), responseConsumerTest)
for _, method := range []string{"Item", "Callback", "Partner"} {
body := `return &types.ItemResponse{Name:"` + strings.ToLower(method) + `"},nil`
if method == "Partner" {
body = `return nil,apierrors.EmailTaken()`
}
imports := `"context";"example.com/responseConsumer/internal/types"`
if method == "Partner" {
imports += `;"example.com/responseConsumer/internal/apierrors"`
}
writeFile(t, filepath.Join(dir, "internal/service/items", strings.ToLower(method)+".go"), "package items\nimport("+imports+")\nfunc(s *Service)"+method+"(ctx context.Context)(*types.ItemResponse,error){"+body+"}\n")
}
before := snapshot(t, dir)
generate(filepath.Join(dir, "api/service.api"), false)
if !sameFiles(before, snapshot(t, dir)) {
t.Fatal("regeneration changed hand-written files")
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "-race", "-count=1", "./...")
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=")
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("consumer: %v\n%s", err, output)
}
if releaseCLI != "" {
cmd = exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=")
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("released module: %v\n%s", err, output)
}
var module struct {
Version, Sum string
Replace any
}
if err = json.Unmarshal(output, &module); err != nil {
t.Fatal(err)
}
if module.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || module.Replace != nil || module.Sum == "" {
t.Fatalf("expected fixed remote module without replacement: %+v", module)
}
t.Logf("remote framework %s checksum %s; no local replace", module.Version, module.Sum)
}
manifestBytes, err := os.ReadFile(filepath.Join(dir, manifestPath))
if err != nil {
t.Fatal(err)
}
var manifest manifest
if err = json.Unmarshal(manifestBytes, &manifest); err != nil {
t.Fatal(err)
}
if manifest.Generated["internal/handler/responseGen.go"] == "" {
t.Fatal("response policy not ownership-managed")
}
}
const responseConsumerTest = `package responseConsumer_test
import (
"bytes";"encoding/json";"io";"net/http/httptest";"testing"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/response"
"example.com/responseConsumer/internal/apierrors"
"example.com/responseConsumer/internal/handler"
item "example.com/responseConsumer/internal/handler/items"
"example.com/responseConsumer/internal/types"
"context"
"example.com/responseConsumer/internal/servicecontext"
)
type service struct{}
func(service)Item(context.Context)(*types.ItemResponse,error){return &types.ItemResponse{Name:"item"},nil}
func(service)Callback(context.Context)(*types.ItemResponse,error){return &types.ItemResponse{Name:"callback"},nil}
func(service)Partner(context.Context)(*types.ItemResponse,error){return nil,apierrors.EmailTaken()}
func TestResponseContract(t *testing.T){
app:=omni.New();defer app.Close()
cfg:=logx.Default();cfg.Writer=io.Discard;logger,err:=logx.New(cfg);if err!=nil{t.Fatal(err)};defer logger.Close()
deps,err:=servicecontext.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close()
deps.Middlewares["Auth"]=func(next httpx.Handler)httpx.Handler{return func(c *httpx.Context)error{if c.Header("Authorization")!="Bearer valid"{return apierrors.Unauthenticated()};return next(c)}}
if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)}
// Registered routes use the compiled policy before even running authentication.
for _,path:=range []string{"/item","/callback"}{
method:="GET";if path=="/callback"{method="POST"};w:=httptest.NewRecorder();app.ServeHTTP(w,httptest.NewRequest(method,path,nil))
var value map[string]any;json.Unmarshal(w.Body.Bytes(),&value)
if w.Code!=401||value["code"]!=float64(401001)||value["msg"]!="请先登录"||len(value)!=3{t.Fatalf("%s: %s",path,w.Body.String())}
}
// Execute the real generated registration and user-owned service files.
for _,tc:=range []struct{method,path string;status int;key string}{{"GET","/item",200,"data"},{"POST","/callback",200,"name"},{"GET","/partner",409,"result"}}{
w:=httptest.NewRecorder();r:=httptest.NewRequest(tc.method,tc.path,nil);r.Header.Set("Authorization","Bearer valid");app.ServeHTTP(w,r)
var value map[string]any;decoder:=json.NewDecoder(bytes.NewReader(w.Body.Bytes()));decoder.UseNumber();if err:=decoder.Decode(&value);err!=nil{t.Fatal(err)}
if w.Code!=tc.status||value[tc.key]==nil{t.Fatalf("generated %s: %d %s",tc.path,w.Code,w.Body.String())}
if tc.path=="/item"&&value["code"]!=json.Number("9223372036854775807"){t.Fatalf("generated int64 lost precision: %s",w.Body.String())}
}
// Business handlers also honor the same runtime policy and raw override.
h:=item.New(service{});runtime:=omni.New();defer runtime.Close()
common:=response.MustNew(response.Config{CodeType:"integer",SuccessCode:200,ErrorCode:9999,MessageField:"msg",RequestIDField:"-",DataMode:"always"})
runtime.GET("/item",h.Item,httpx.WithResponse(common))
runtime.POST("/callback",h.Callback,httpx.WithResponse(response.RawJSON(common)))
for _,tc:=range []struct{method,path,key string}{{"GET","/item","data"},{"POST","/callback","name"}}{
w:=httptest.NewRecorder();runtime.ServeHTTP(w,httptest.NewRequest(tc.method,tc.path,nil));var value map[string]any;json.Unmarshal(w.Body.Bytes(),&value)
if w.Code!=200||value[tc.key]==nil{t.Fatal(w.Body.String())}
}
}
`

View File

@@ -71,12 +71,17 @@ their business logic is implemented. Authentication hooks also fail closed with
2. Run the same go-omni CLI version as the framework dependency:
`+"```sh"+`
go-omni api validate --api %s
go-omni api generate --api %s --dir .
go-omni api generate --api %s --dir . --check
go-omni api validate
go-omni api inspect
go-omni api generate --dry-run
go-omni api generate
go-omni api generate --check
go-omni api export > openapi.json
go test ./...
`+"```"+`
Commands inside a generated project discover the recorded API entry automatically.
--dry-run previews create/update/remove actions without writing files.
The --check command is read-only and suitable for CI. It fails on stale or missing
generated files and missing business scaffolds. go.mod, configuration, main,
Service, Repository, servicecontext, middleware hook bodies and API files are
@@ -99,6 +104,7 @@ etc/config.yaml # lowerCamelCase configuration
internal/settings/ # defaults -> file -> environment
internal/servicecontext/ # dependency initialization and cleanup
internal/types/typesGen.go # generated request/response types
internal/apierrors/errorsGen.go # generated fresh error constructors, when declared
internal/handler/<group>/ # generated binding and service adapters
internal/service/<group>/ # developer-owned business operations
internal/repository/<group>/ # developer-owned persistence contracts
@@ -107,9 +113,12 @@ docs/openapi.json # generated OpenAPI 3.0.3
.goOmni/apiManifest.json # ownership and content hashes
`+"```"+`
The API response describes data only. Omni adds code, message and requestId.
Do not define the envelope again in each response. Handlers reject nil successful
responses. Body DTOs use application/json and have a 1 MiB limit by default.
The API response type describes business data. Declare response Name (...) and
select it with @server(response: Name) or @response Name to share a public body;
@response raw returns the business JSON directly. Without a template, the default
envelope includes code, message, data and requestId. Generated routes and OpenAPI
use the API contract, independently of the app's default response configuration.
Handlers reject nil successful responses. Body DTOs use application/json and have a 1 MiB limit by default.
Use contract.Binder.Bind's optional limit in a custom adapter for larger payloads.
Fields require exactly one source tag: json, path, form (URL query only), header.
@@ -123,6 +132,13 @@ and binding compiler. Responses are expected to honor their declared constraints
they are not validated on every response. Do not return nil slices/maps for a
non-nullable collection (initialize empty collections instead).
Declare public business errors in error (...) and reference them with @errors.
Use internal/apierrors constructors to share status, code and message with OpenAPI.
Auth declarations connect a bearer/header/cookie scheme to a real named middleware.
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.
Named middleware is composed in declaration order (first is outermost). Implement
the generated hooks or replace entries in servicecontext; never bypass an auth
hook merely to make the server start. Middleware names do not imply a JWT policy.
@@ -131,7 +147,7 @@ This CLI supports a documented go-zero-style subset, not all goctl features.
Database tables and repositories cannot be inferred from an HTTP contract. Add
business repositories through servicecontext. Database and storage options are
composed at creation; the original profile is retained on regeneration.
`, module, entry, entry, entry, entry)
`, module, entry)
}
func infrastructureREADME(o Options) string {

View File

@@ -29,7 +29,7 @@ type change struct {
mode os.FileMode
}
func apply(root, module, entry string, generated, seeds map[string][]byte, check bool, profile projectProfile) (Output, error) {
func apply(root, module, entry string, generated, seeds map[string][]byte, check, dryRun bool, profile projectProfile) (Output, error) {
var result Output
if info, err := os.Lstat(root); err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
@@ -60,7 +60,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 {
if !check && !dryRun {
if err := os.MkdirAll(filepath.Join(root, ".goOmni"), 0755); err != nil {
return result, err
}
@@ -188,6 +188,19 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
changes = append(changes, change{name: manifestPath, before: oldData, after: data, existed: oldData != nil, mode: 0644})
}
sort.Strings(result.Removed)
for _, c := range changes {
action := "create"
if c.remove {
action = "remove"
} else if c.existed {
action = "update"
}
result.Changes = append(result.Changes, FileChange{Path: c.name, Action: action})
}
sort.Slice(result.Changes, func(i, j int) bool { return result.Changes[i].Path < result.Changes[j].Path })
if dryRun {
return result, nil
}
if check {
if len(changes) > 0 {
var stale []string
@@ -239,7 +252,10 @@ func apply(root, module, entry string, generated, seeds map[string][]byte, check
}
func digest(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }
func ownedPath(name string) bool {
if name == "docs/openapi.json" || name == "internal/types/typesGen.go" || name == "internal/handler/routesGen.go" || name == "internal/handler/openapiGen.go" || name == "internal/middleware/registryGen.go" {
if name == "internal/handler/responseGen.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" {
return true
}
return strings.HasPrefix(name, "internal/handler/") && strings.HasSuffix(name, "/handlersGen.go")

View File

@@ -173,7 +173,9 @@ func usage() {
fmt.Fprintln(os.Stderr, " go-omni new [--module <module>] [--force] <directory>")
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]")
fmt.Fprintln(os.Stderr, " go-omni api generate [--api <file.api>] [--dir <project>] [--check|--dry-run]")
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")
fmt.Fprintln(os.Stderr, " go-omni add <name> [--dir <project>] Generate an isolated business module")
fmt.Fprintln(os.Stderr, " --framework-version <version> Pin a remote framework version (defaults to CLI version)")

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.7.0-preview"
const defaultFrameworkVersion = "v0.8.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.

View File

@@ -6,6 +6,7 @@ import (
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/logx"
"git.yuntuo.hk/k2safe/go-omni/response"
)
const defaultRequestIDHeader = "X-Request-ID"
@@ -24,15 +25,17 @@ 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"`
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"`
Address string `json:"address"`
Transport string `json:"transport"`
TLS TLSConfig `json:"tls"`
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"`
Response response.Config `json:"response"`
ResponseEncoder response.Encoder `json:"-"`
}
func Default() Config {
@@ -47,6 +50,9 @@ func Default() Config {
// Validate checks configuration values before an application starts.
func (c Config) Validate() error {
if err := c.Response.Validate(); err != nil {
return err
}
if c.SlowRequestThreshold < 0 {
return &ConfigError{Field: "slowRequestThreshold", Message: "must be nonnegative"}
}
@@ -134,15 +140,29 @@ func WithTrustedProxies(proxies ...string) Option {
// WithConfig applies a complete configuration loaded from a file or
// environment. Later options can still override individual fields.
func WithConfig(settings Config) Option {
settings.Response = settings.Response.Clone()
settings.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
settings.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...)
return func(cfg *Config) {
*cfg = settings
cfg.Response = settings.Response.Clone()
cfg.TrustedProxies = append([]string(nil), settings.TrustedProxies...)
cfg.Log.RedactKeys = append([]string(nil), settings.Log.RedactKeys...)
}
}
// WithResponse configures this application's default JSON envelope.
func WithResponse(settings response.Config) Option {
settings = settings.Clone()
return func(cfg *Config) { cfg.Response = settings.Clone(); cfg.ResponseEncoder = nil }
}
// WithResponseEncoder replaces the default encoder with application code.
// OpenAPI for custom encoders must describe their actual wire contract explicitly.
func WithResponseEncoder(encoder response.Encoder) Option {
return func(cfg *Config) { cfg.ResponseEncoder = encoder }
}
// WithLogConfig configures the logger used by the application runtime.
func WithLogConfig(settings logx.Config) Option {
settings.RedactKeys = append([]string(nil), settings.RedactKeys...)

View File

@@ -181,6 +181,7 @@ func (r *Reloader) Snapshot() Config {
r.mu.Lock()
defer r.mu.Unlock()
cfg := r.current
cfg.Response = r.current.Response.Clone()
cfg.TrustedProxies = append([]string(nil), cfg.TrustedProxies...)
cfg.Log.RedactKeys = append([]string(nil), cfg.Log.RedactKeys...)
return cfg

View File

@@ -56,3 +56,11 @@ python3 scripts/verifyDatabase.py --version v0.2.0
脚本仅操作本次创建的独立容器与数据库,验证 HTTP CRUD、服务重启持久化、事务提交与
错误/panic/取消回滚、连接池关闭、缺少表结构时启动失败,以及停库后的 readiness 503 和恢复后的 200。
结束时清理容器、匿名数据卷和临时项目。常规 `go test ./...` 不依赖 Docker。
## 项目配置叠加
`database.ApplyEnv(cfg, prefix)` 在已有文件/默认配置上覆盖非空环境变量,保留未覆盖的池参数。
`Config` 的 JSON/YAML 字段使用 lowerCamelCase,`DSN` 使用 `json:"-"`,连接密钥从环境变量加载。
API 脚手架 `--database mysql` 使用此流程,并要求正的 `maxOpenConns`,提供数据库 readiness 与幂等清理。
具体表结构、迁移和 Repository 留在业务项目,参考 `examples/userService`。

View File

@@ -0,0 +1,58 @@
syntax = "v1"
info (
title: "Configurable response example"
version: "1.0.0"
)
// Define the public body once. No field names are required by the framework.
response CommonResponse (
body: {
"code": "${code}",
"msg": "${message}",
"data": "${data}"
}
codeType: int32
successCode: 200
errorCode: 9999
successMessage: "操作成功"
dataMode: always
codes: {"ITEM_NOT_FOUND":404001}
messages: {"ITEM_NOT_FOUND":"记录不存在"}
)
response PartnerResponse (
body: {"accepted":"${ok}","payload":"${data}","meta":{"trace":"${requestId}"}}
errorBody: {"accepted":"${ok}","error":{"reason":"${message}"}}
)
error ItemNotFound 404 "ITEM_NOT_FOUND" "item not found"
type ItemReq {
ItemID int64 `path:"itemId" binding:"min=1"`
}
type ItemResp {
ItemID int64 `json:"itemId"`
Name string `json:"name"`
}
@server (
group: items
prefix: /api
response: CommonResponse
)
service response-api {
@errors ItemNotFound
@handler getItem
get /items/:itemId (ItemReq) returns (ItemResp)
// Success is the ItemResp object itself; errors still use CommonResponse.
@response raw
@handler rawItem
get /rawItems/:itemId (ItemReq) returns (ItemResp)
@response PartnerResponse
@handler partnerItem
get /partnerItems/:itemId (ItemReq) returns (ItemResp)
}

View File

@@ -9,9 +9,11 @@
"module": "example.com/userService",
"entry": "api/user.api",
"generated": {
"docs/openapi.json": "0675e4eb1278b36828aa31b12587b0d33d2f384112f67a43179eb72b52c3a0ea",
"internal/handler/openapiGen.go": "8ddc60c338630db915b9f074ade776f526eabf60fc99b33e425359fd415c4926",
"internal/handler/routesGen.go": "7f45030e7eb9f055c4b4d2ff12ad855d7c726aad4aee63c5b3c3f97559a64257",
"docs/openapi.json": "05bce8951699350cc1ebca5a626c39349395dd09e395b41560c129e530583204",
"internal/apierrors/errorsGen.go": "20e94bbc90ae9f4330de4c25b9a550026c4c352502719a643ede3e8912a6566e",
"internal/handler/openapiGen.go": "e4130f5c7494f7db4830bfef203fa8154a9cd087c95954f64c29d74693ead433",
"internal/handler/responseGen.go": "d742efc2a3d1d168fae7849e905297bd440c3d6b3802b6150f494c07e6097f4f",
"internal/handler/routesGen.go": "0ff429a8aa7f8643e0e844f7c43a63a1f82cca5ce6bfe917f06edd860ebe923f",
"internal/handler/user/handlersGen.go": "06f3c2798ff323812ff3eb2d8b933a569af3ce1b86a67b66036374add98c0d7e",
"internal/middleware/registryGen.go": "a6122a5453dedc6a4415860108814c50e155128cfe5412b39dd33ce2d01b4b00",
"internal/types/typesGen.go": "3ab2ce9ffe467c3ea53ac3d1fec283ecebc87cef57fd592d08dca991fd964876"

View File

@@ -18,8 +18,9 @@ go-omni migrate --dir migrations --apply
go run ./cmd/server
```
源码示例的 go.mod 使用 `replace ... => ../..`。复制到独立仓库时,删除这条 replace,
保留已发布的固定依赖版本,再执行 go mod tidy。远程版本验证脚本会自动检查没有本地替换。
源码示例的 go.mod 使用 `replace ... => ../..`。生成代码使用 `v0.8.0-preview` 响应策略入口。
复制到独立仓库时删除 replace,保留同版本固定依赖,再执行 go mod tidy;CLI 也固定到同一版本。
远程版本验证脚本会检查没有本地替换。
`GO_OMNI_TRANSPORT=native` 可切换为 native,业务代码无需调整。
配置是默认值 → etc/config.yaml → 环境变量。日志默认写入 var/log/server.log 并轮转,
@@ -29,7 +30,7 @@ go run ./cmd/server
## 接口
除健康检查、OpenAPI 和持有效签名的对象下载外,业务接口需要 `Authorization: Bearer <Token>`。
这个 Token 代表示例的管理员;可通过同一个命名中间件槽位替换为 JWT/OIDC 和业务授权策略。
这个 Token 代表示例的管理员;可选设置不同的 `USER_SERVICE_READ_TOKEN`,允许 GET/HEAD,写请求返回 403。可通过同一个命名中间件槽位替换为 JWT/OIDC 和业务授权策略。
框架不会推断租户模型或自动给接口放行。
| 接口 | 行为 |
@@ -43,8 +44,8 @@ go run ./cmd/server
| GET /health/ready | 数据库不可用时返回 503,恢复后重新就绪 |
| GET /openapi.json | 与 docs/openapi.json 一致 |
参数不合法返回 400,认证失败 401,邮箱重复 409,内部故障只返回通用 500。
OpenAPI 的业务数据类型来自契约;401/404/409 的业务错误码见上表。
参数不合法返回 400,认证失败 401,只读身份写入 403,邮箱重复 409,内部故障只返回通用 500。
OpenAPI 的数据类型、鉴权和业务错误均来自契约;401/403/404/409 带具体命名示例。业务使用生成的 `apierrors.UserNotFound()`、`apierrors.EmailTaken()` 等函数。
创建、更新、删除都会在**同一个 MySQL 事务**写入 user_audit;审计写入失败时业务变更一起回滚。
事务提交后才写成功业务日志,requestId 同时出现在 HTTP 响应、业务日志和审计记录。
@@ -57,8 +58,10 @@ OpenAPI 的业务数据类型来自契约;401/404/409 的业务错误码见上
## 开发与验证
```sh
go-omni api generate --api api/user.api --dir .
go-omni api generate --api api/user.api --dir . --check
go-omni api inspect
go-omni api generate --dry-run
go-omni api generate
go-omni api generate --check
go test ./...
```
@@ -68,10 +71,10 @@ Gen.go 和 docs/openapi.json 由生成器维护;Service、Repository、迁移
在框架仓库运行:
```sh
python3 scripts/verifyAPIWorkflow.py
# 已发布版本:全新 module cache 安装 CLI,无本地 replace 消费框架。
python3 scripts/verifyAPIWorkflow.py --version v0.7.0-preview \
--report performance/apiWorkflowRemote.json
python3 scripts/verifyAPIWorkflow.py --report performance/responsePoliciesWorkflowLocal.json
# 使用本版远程 CLI 和模块验收,无本地替换。
python3 scripts/verifyAPIWorkflow.py --version v0.8.0-preview \
--report performance/responsePoliciesWorkflowRemote.json
```
验收创建只绑定 localhost 的临时 MySQL 容器(1 CPU / 768 MiB),使用随机密钥,结束时删除。

View File

@@ -3,6 +3,20 @@ info (
title: "User service reference"
version: "1.0.0"
)
// Stable public errors are shared by Go code and the API document.
error (
Unauthenticated 401 "UNAUTHENTICATED" "authentication required"
Forbidden 403 "FORBIDDEN" "permission denied"
UserNotFound 404 "USER_NOT_FOUND" "user not found"
EmailTaken 409 "EMAIL_TAKEN" "email already in use"
InvalidUser 400 "INVALID_USER" "invalid user"
InvalidPage 400 "INVALID_PAGE" "invalid pagination"
)
auth AdminAuth (
type: bearer
middleware: Auth
description: "Use an administrator token for writes, or a read-only token for GET requests"
)
type (
UserResp {
ID int64 `json:"id"`
@@ -40,21 +54,33 @@ type (
@server (
group: user
prefix: /api/v1
middleware: Auth
auth: AdminAuth
errors: Unauthenticated
)
service user-api {
@doc (
summary: "Create a user"
description: "Normalizes the email, then commits user data and audit in one transaction"
tags: users
)
@errors Forbidden, InvalidUser, EmailTaken
@handler createUser
@status 201
post /users (CreateUserReq) returns (UserResp)
@errors UserNotFound
@handler getUser
get /users/:id (UserIDReq) returns (UserResp)
@errors InvalidPage
@handler listUsers
get /users (ListUsersReq) returns (UserPageResp)
@errors Forbidden, InvalidUser, UserNotFound, EmailTaken
@handler updateUser
put /users/:id (UpdateUserReq) returns (UserResp)
@errors Forbidden, UserNotFound
@handler deleteUser
delete /users/:id (UserIDReq)
@doc "Export one user as a private text object with a short-lived download link"
@errors Forbidden, UserNotFound
@handler exportUser
post /users/:id/export (UserIDReq) returns (ExportResp)
}

View File

@@ -1,7 +1,8 @@
{
"components": {
"securitySchemes": {
"bearerAuth": {
"AdminAuth": {
"description": "Use an administrator token for writes, or a read-only token for GET requests",
"scheme": "bearer",
"type": "http"
}
@@ -128,6 +129,16 @@
"400": {
"content": {
"application/json": {
"examples": {
"InvalidPage": {
"summary": "invalid pagination",
"value": {
"code": "INVALID_PAGE",
"message": "invalid pagination",
"requestId": "exampleRequest"
}
}
},
"schema": {
"properties": {
"code": {
@@ -148,7 +159,42 @@
}
}
},
"description": "Error"
"description": "INVALID_PAGE: invalid pagination"
},
"401": {
"content": {
"application/json": {
"examples": {
"Unauthenticated": {
"summary": "authentication required",
"value": {
"code": "UNAUTHENTICATED",
"message": "authentication required",
"requestId": "exampleRequest"
}
}
},
"schema": {
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"requestId": {
"type": "string"
}
},
"required": [
"code",
"message"
],
"type": "object"
}
}
},
"description": "UNAUTHENTICATED: authentication required"
},
"413": {
"content": {
@@ -251,12 +297,25 @@
"description": "Error"
}
},
"security": [
{
"AdminAuth": []
}
],
"summary": "",
"tags": [
"user"
],
"x-omni-errors": [
"Unauthenticated",
"InvalidPage"
],
"x-omni-middleware": [
"Auth"
]
},
"post": {
"description": "Normalizes the email, then commits user data and audit in one transaction",
"operationId": "user_CreateUser",
"requestBody": {
"content": {
@@ -345,6 +404,16 @@
"400": {
"content": {
"application/json": {
"examples": {
"InvalidUser": {
"summary": "invalid user",
"value": {
"code": "INVALID_USER",
"message": "invalid user",
"requestId": "exampleRequest"
}
}
},
"schema": {
"properties": {
"code": {
@@ -365,7 +434,118 @@
}
}
},
"description": "Error"
"description": "INVALID_USER: invalid user"
},
"401": {
"content": {
"application/json": {
"examples": {
"Unauthenticated": {
"summary": "authentication required",
"value": {
"code": "UNAUTHENTICATED",
"message": "authentication required",
"requestId": "exampleRequest"
}
}
},
"schema": {
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"requestId": {
"type": "string"
}
},
"required": [
"code",
"message"
],
"type": "object"
}
}
},
"description": "UNAUTHENTICATED: authentication required"
},
"403": {
"content": {
"application/json": {
"examples": {
"Forbidden": {
"summary": "permission denied",
"value": {
"code": "FORBIDDEN",
"message": "permission denied",
"requestId": "exampleRequest"
}
}
},
"schema": {
"properties": {
"code": {
"enum": [
"FORBIDDEN"
],
"type": "string"
},
"message": {
"type": "string"
},
"requestId": {
"type": "string"
}
},
"required": [
"code",
"message"
],
"type": "object"
}
}
},
"description": "FORBIDDEN: permission denied"
},
"409": {
"content": {
"application/json": {
"examples": {
"EmailTaken": {
"summary": "email already in use",
"value": {
"code": "EMAIL_TAKEN",
"message": "email already in use",
"requestId": "exampleRequest"
}
}
},
"schema": {
"properties": {
"code": {
"enum": [
"EMAIL_TAKEN"
],
"type": "string"
},
"message": {
"type": "string"
},
"requestId": {
"type": "string"
}
},
"required": [
"code",
"message"
],
"type": "object"
}
}
},
"description": "EMAIL_TAKEN: email already in use"
},
"413": {
"content": {
@@ -468,7 +648,21 @@
"description": "Error"
}
},
"summary": "",
"security": [
{
"AdminAuth": []
}
],
"summary": "Create a user",
"tags": [
"users"
],
"x-omni-errors": [
"Unauthenticated",
"Forbidden",
"InvalidUser",
"EmailTaken"
],
"x-omni-middleware": [
"Auth"
]
@@ -519,6 +713,117 @@
},
"description": "Error"
},
"401": {
"content": {
"application/json": {
"examples": {
"Unauthenticated": {
"summary": "authentication required",
"value": {
"code": "UNAUTHENTICATED",
"message": "authentication required",
"requestId": "exampleRequest"
}
}
},
"schema": {
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"requestId": {
"type": "string"
}
},
"required": [
"code",
"message"
],
"type": "object"
}
}
},
"description": "UNAUTHENTICATED: authentication required"
},
"403": {
"content": {
"application/json": {
"examples": {
"Forbidden": {
"summary": "permission denied",
"value": {
"code": "FORBIDDEN",
"message": "permission denied",
"requestId": "exampleRequest"
}
}
},
"schema": {
"properties": {
"code": {
"enum": [
"FORBIDDEN"
],
"type": "string"
},
"message": {
"type": "string"
},
"requestId": {
"type": "string"
}
},
"required": [
"code",
"message"
],
"type": "object"
}
}
},
"description": "FORBIDDEN: permission denied"
},
"404": {
"content": {
"application/json": {
"examples": {
"UserNotFound": {
"summary": "user not found",
"value": {
"code": "USER_NOT_FOUND",
"message": "user not found",
"requestId": "exampleRequest"
}
}
},
"schema": {
"properties": {
"code": {
"enum": [
"USER_NOT_FOUND"
],
"type": "string"
},
"message": {
"type": "string"
},
"requestId": {
"type": "string"
}
},
"required": [
"code",
"message"
],
"type": "object"
}
}
},
"description": "USER_NOT_FOUND: user not found"
},
"413": {
"content": {
"application/json": {
@@ -620,7 +925,20 @@
"description": "Error"
}
},
"security": [
{
"AdminAuth": []
}
],
"summary": "",
"tags": [
"user"
],
"x-omni-errors": [
"Unauthenticated",
"Forbidden",
"UserNotFound"
],
"x-omni-middleware": [
"Auth"
]
@@ -721,6 +1039,79 @@
},
"description": "Error"
},
"401": {
"content": {
"application/json": {
"examples": {
"Unauthenticated": {
"summary": "authentication required",
"value": {
"code": "UNAUTHENTICATED",
"message": "authentication required",
"requestId": "exampleRequest"
}
}
},
"schema": {
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"requestId": {
"type": "string"
}
},
"required": [
"code",
"message"
],
"type": "object"
}
}
},
"description": "UNAUTHENTICATED: authentication required"
},
"404": {
"content": {
"application/json": {
"examples": {
"UserNotFound": {
"summary": "user not found",
"value": {
"code": "USER_NOT_FOUND",
"message": "user not found",
"requestId": "exampleRequest"
}
}
},
"schema": {
"properties": {
"code": {
"enum": [
"USER_NOT_FOUND"
],
"type": "string"
},
"message": {
"type": "string"
},
"requestId": {
"type": "string"
}
},
"required": [
"code",
"message"
],
"type": "object"
}
}
},
"description": "USER_NOT_FOUND: user not found"
},
"413": {
"content": {
"application/json": {
@@ -822,7 +1213,19 @@
"description": "Error"
}
},
"security": [
{
"AdminAuth": []
}
],
"summary": "",
"tags": [
"user"
],
"x-omni-errors": [
"Unauthenticated",
"UserNotFound"
],
"x-omni-middleware": [
"Auth"
]
@@ -929,6 +1332,16 @@
"400": {
"content": {
"application/json": {
"examples": {
"InvalidUser": {
"summary": "invalid user",
"value": {
"code": "INVALID_USER",
"message": "invalid user",
"requestId": "exampleRequest"
}
}
},
"schema": {
"properties": {
"code": {
@@ -949,7 +1362,156 @@
}
}
},
"description": "Error"
"description": "INVALID_USER: invalid user"
},
"401": {
"content": {
"application/json": {
"examples": {
"Unauthenticated": {
"summary": "authentication required",
"value": {
"code": "UNAUTHENTICATED",
"message": "authentication required",
"requestId": "exampleRequest"
}
}
},
"schema": {
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"requestId": {
"type": "string"
}
},
"required": [
"code",
"message"
],
"type": "object"
}
}
},
"description": "UNAUTHENTICATED: authentication required"
},
"403": {
"content": {
"application/json": {
"examples": {
"Forbidden": {
"summary": "permission denied",
"value": {
"code": "FORBIDDEN",
"message": "permission denied",
"requestId": "exampleRequest"
}
}
},
"schema": {
"properties": {
"code": {
"enum": [
"FORBIDDEN"
],
"type": "string"
},
"message": {
"type": "string"
},
"requestId": {
"type": "string"
}
},
"required": [
"code",
"message"
],
"type": "object"
}
}
},
"description": "FORBIDDEN: permission denied"
},
"404": {
"content": {
"application/json": {
"examples": {
"UserNotFound": {
"summary": "user not found",
"value": {
"code": "USER_NOT_FOUND",
"message": "user not found",
"requestId": "exampleRequest"
}
}
},
"schema": {
"properties": {
"code": {
"enum": [
"USER_NOT_FOUND"
],
"type": "string"
},
"message": {
"type": "string"
},
"requestId": {
"type": "string"
}
},
"required": [
"code",
"message"
],
"type": "object"
}
}
},
"description": "USER_NOT_FOUND: user not found"
},
"409": {
"content": {
"application/json": {
"examples": {
"EmailTaken": {
"summary": "email already in use",
"value": {
"code": "EMAIL_TAKEN",
"message": "email already in use",
"requestId": "exampleRequest"
}
}
},
"schema": {
"properties": {
"code": {
"enum": [
"EMAIL_TAKEN"
],
"type": "string"
},
"message": {
"type": "string"
},
"requestId": {
"type": "string"
}
},
"required": [
"code",
"message"
],
"type": "object"
}
}
},
"description": "EMAIL_TAKEN: email already in use"
},
"413": {
"content": {
@@ -1052,7 +1614,22 @@
"description": "Error"
}
},
"security": [
{
"AdminAuth": []
}
],
"summary": "",
"tags": [
"user"
],
"x-omni-errors": [
"Unauthenticated",
"Forbidden",
"InvalidUser",
"UserNotFound",
"EmailTaken"
],
"x-omni-middleware": [
"Auth"
]
@@ -1138,6 +1715,117 @@
},
"description": "Error"
},
"401": {
"content": {
"application/json": {
"examples": {
"Unauthenticated": {
"summary": "authentication required",
"value": {
"code": "UNAUTHENTICATED",
"message": "authentication required",
"requestId": "exampleRequest"
}
}
},
"schema": {
"properties": {
"code": {
"type": "string"
},
"message": {
"type": "string"
},
"requestId": {
"type": "string"
}
},
"required": [
"code",
"message"
],
"type": "object"
}
}
},
"description": "UNAUTHENTICATED: authentication required"
},
"403": {
"content": {
"application/json": {
"examples": {
"Forbidden": {
"summary": "permission denied",
"value": {
"code": "FORBIDDEN",
"message": "permission denied",
"requestId": "exampleRequest"
}
}
},
"schema": {
"properties": {
"code": {
"enum": [
"FORBIDDEN"
],
"type": "string"
},
"message": {
"type": "string"
},
"requestId": {
"type": "string"
}
},
"required": [
"code",
"message"
],
"type": "object"
}
}
},
"description": "FORBIDDEN: permission denied"
},
"404": {
"content": {
"application/json": {
"examples": {
"UserNotFound": {
"summary": "user not found",
"value": {
"code": "USER_NOT_FOUND",
"message": "user not found",
"requestId": "exampleRequest"
}
}
},
"schema": {
"properties": {
"code": {
"enum": [
"USER_NOT_FOUND"
],
"type": "string"
},
"message": {
"type": "string"
},
"requestId": {
"type": "string"
}
},
"required": [
"code",
"message"
],
"type": "object"
}
}
},
"description": "USER_NOT_FOUND: user not found"
},
"413": {
"content": {
"application/json": {
@@ -1239,11 +1927,62 @@
"description": "Error"
}
},
"security": [
{
"AdminAuth": []
}
],
"summary": "Export one user as a private text object with a short-lived download link",
"tags": [
"user"
],
"x-omni-errors": [
"Unauthenticated",
"Forbidden",
"UserNotFound"
],
"x-omni-middleware": [
"Auth"
]
}
}
}
},
"x-omni-errors": [
{
"name": "Unauthenticated",
"status": 401,
"code": "UNAUTHENTICATED",
"message": "authentication required"
},
{
"name": "Forbidden",
"status": 403,
"code": "FORBIDDEN",
"message": "permission denied"
},
{
"name": "UserNotFound",
"status": 404,
"code": "USER_NOT_FOUND",
"message": "user not found"
},
{
"name": "EmailTaken",
"status": 409,
"code": "EMAIL_TAKEN",
"message": "email already in use"
},
{
"name": "InvalidUser",
"status": 400,
"code": "INVALID_USER",
"message": "invalid user"
},
{
"name": "InvalidPage",
"status": 400,
"code": "INVALID_PAGE",
"message": "invalid pagination"
}
]
}

View File

@@ -3,7 +3,7 @@ module example.com/userService
go 1.22.0
require (
git.yuntuo.hk/k2safe/go-omni v0.7.0-preview
git.yuntuo.hk/k2safe/go-omni v0.8.0-preview
github.com/go-sql-driver/mysql v1.9.3
)

View File

@@ -0,0 +1,42 @@
// Code generated by go-omni api. DO NOT EDIT.
package apierrors
import omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
const CodeUnauthenticated = "UNAUTHENTICATED"
// Unauthenticated returns a fresh public error declared in the API contract.
func Unauthenticated() *omnierrors.APIError {
return omnierrors.New(401, CodeUnauthenticated, "authentication required")
}
const CodeForbidden = "FORBIDDEN"
// Forbidden returns a fresh public error declared in the API contract.
func Forbidden() *omnierrors.APIError { return omnierrors.New(403, CodeForbidden, "permission denied") }
const CodeUserNotFound = "USER_NOT_FOUND"
// UserNotFound returns a fresh public error declared in the API contract.
func UserNotFound() *omnierrors.APIError {
return omnierrors.New(404, CodeUserNotFound, "user not found")
}
const CodeEmailTaken = "EMAIL_TAKEN"
// EmailTaken returns a fresh public error declared in the API contract.
func EmailTaken() *omnierrors.APIError {
return omnierrors.New(409, CodeEmailTaken, "email already in use")
}
const CodeInvalidUser = "INVALID_USER"
// InvalidUser returns a fresh public error declared in the API contract.
func InvalidUser() *omnierrors.APIError { return omnierrors.New(400, CodeInvalidUser, "invalid user") }
const CodeInvalidPage = "INVALID_PAGE"
// InvalidPage returns a fresh public error declared in the API contract.
func InvalidPage() *omnierrors.APIError {
return omnierrors.New(400, CodeInvalidPage, "invalid pagination")
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,30 @@
// Code generated by go-omni api. DO NOT EDIT.
package handler
import (
"encoding/json"
"git.yuntuo.hk/k2safe/go-omni/response"
"strings"
)
var responseTemplates = map[string]response.Encoder{"": response.Default()}
func newResponseTemplate(raw string) response.Encoder {
var cfg response.Config
decoder := json.NewDecoder(strings.NewReader(raw))
decoder.UseNumber()
if err := decoder.Decode(&cfg); err != nil {
panic(err)
}
return response.MustNew(cfg)
}
func responseEncoder(name string, raw bool) response.Encoder {
encoder := responseTemplates[name]
if encoder == nil {
panic("unknown response template")
}
if raw {
encoder = response.RawJSON(encoder)
}
return encoder
}

View File

@@ -6,6 +6,7 @@ import (
s0 "example.com/userService/internal/service/user"
"example.com/userService/internal/servicecontext"
"fmt"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/openapi"
)
@@ -17,11 +18,11 @@ func RegisterRoutes(router openapi.Registrar, deps *servicecontext.Context) erro
return fmt.Errorf("missing middleware Auth")
}
h0 := h0.New(s0.New(deps))
router.Handle("POST", "/api/v1/users", h0.CreateUser, deps.Middlewares["Auth"])
router.Handle("GET", "/api/v1/users/:id", h0.GetUser, deps.Middlewares["Auth"])
router.Handle("GET", "/api/v1/users", h0.ListUsers, deps.Middlewares["Auth"])
router.Handle("PUT", "/api/v1/users/:id", h0.UpdateUser, deps.Middlewares["Auth"])
router.Handle("DELETE", "/api/v1/users/:id", h0.DeleteUser, deps.Middlewares["Auth"])
router.Handle("POST", "/api/v1/users/:id/export", h0.ExportUser, deps.Middlewares["Auth"])
httpx.HandleWithResponse(router, "POST", "/api/v1/users", responseEncoder("", false), h0.CreateUser, deps.Middlewares["Auth"])
httpx.HandleWithResponse(router, "GET", "/api/v1/users/:id", responseEncoder("", false), h0.GetUser, deps.Middlewares["Auth"])
httpx.HandleWithResponse(router, "GET", "/api/v1/users", responseEncoder("", false), h0.ListUsers, deps.Middlewares["Auth"])
httpx.HandleWithResponse(router, "PUT", "/api/v1/users/:id", responseEncoder("", false), h0.UpdateUser, deps.Middlewares["Auth"])
httpx.HandleWithResponse(router, "DELETE", "/api/v1/users/:id", responseEncoder("", false), h0.DeleteUser, deps.Middlewares["Auth"])
httpx.HandleWithResponse(router, "POST", "/api/v1/users/:id/export", responseEncoder("", false), h0.ExportUser, deps.Middlewares["Auth"])
return nil
}

View File

@@ -4,18 +4,18 @@ import (
"crypto/sha256"
"crypto/subtle"
"errors"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"example.com/userService/internal/apierrors"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"os"
"strings"
)
// Auth is the fail-closed registry default. Startup replaces it with configured
// authentication. This administrative example uses one environment-only key;
// authentication. This example uses an administrator key and an optional read key;
// production applications can inject JWT/OIDC through the same middleware slot.
func Auth(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication required")
return apierrors.Unauthenticated()
}
}
func NewAuthFromEnv() (httpx.Middleware, error) {
@@ -23,16 +23,26 @@ func NewAuthFromEnv() (httpx.Middleware, error) {
if len(token) < 32 || strings.ContainsAny(token, " \t\r\n") {
return nil, errors.New("USER_SERVICE_API_TOKEN requires at least 32 bytes without whitespace")
}
readToken := os.Getenv("USER_SERVICE_READ_TOKEN")
if readToken != "" && (len(readToken) < 32 || strings.ContainsAny(readToken, " \t\r\n") || readToken == token) {
return nil, errors.New("USER_SERVICE_READ_TOKEN must be a distinct token of at least 32 bytes without whitespace")
}
expectedRead := sha256.Sum256([]byte(readToken))
expected := sha256.Sum256([]byte(token))
return func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
parts := strings.Fields(c.Header("Authorization"))
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication required")
return apierrors.Unauthenticated()
}
actual := sha256.Sum256([]byte(parts[1]))
if subtle.ConstantTimeCompare(expected[:], actual[:]) != 1 {
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication required")
admin := subtle.ConstantTimeCompare(expected[:], actual[:]) == 1
read := subtle.ConstantTimeCompare(expectedRead[:], actual[:]) == 1 && readToken != ""
if !admin && !read {
return apierrors.Unauthenticated()
}
if !admin && c.Request().Method != "GET" && c.Request().Method != "HEAD" {
return apierrors.Forbidden()
}
return next(c)
}

View File

@@ -33,3 +33,31 @@ func TestAuthFailsClosedAndDoesNotLeakKey(t *testing.T) {
}
}
}
func TestReadOnlyTokenCanReadButCannotWrite(t *testing.T) {
admin := "admin-012345678901234567890123456789"
read := "read-012345678901234567890123456789"
t.Setenv("USER_SERVICE_API_TOKEN", admin)
t.Setenv("USER_SERVICE_READ_TOKEN", read)
middleware, err := NewAuthFromEnv()
if err != nil {
t.Fatal(err)
}
for _, method := range []string{"GET", "POST", "PUT", "DELETE"} {
called := false
r := httptest.NewRequest(method, "/api/v1/users", nil)
r.Header.Set("Authorization", "Bearer "+read)
err = middleware(func(*httpx.Context) error { called = true; return nil })(httpx.NewContext(r, httptest.NewRecorder(), nil))
if method == "GET" {
if err != nil || !called {
t.Fatal("read token rejected on GET")
}
} else if err == nil || called || apierrors.From(err).Status != 403 {
t.Fatal("read token reached a write")
}
}
t.Setenv("USER_SERVICE_READ_TOKEN", admin)
if _, err = NewAuthFromEnv(); err == nil {
t.Fatal("identical privilege tokens accepted")
}
}

View File

@@ -0,0 +1,36 @@
package user
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"example.com/userService/internal/servicecontext"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/logx"
)
func TestDriverMessageDoesNotReachAccessOrBusinessLogs(t *testing.T) {
var output bytes.Buffer
cfg := logx.Default()
cfg.Writer = &output
logger, err := logx.New(cfg)
if err != nil {
t.Fatal(err)
}
defer logger.Close()
service := New(&servicecontext.Context{Logger: logger})
secret := "private-user-input-in-driver-error"
got := service.failure(context.Background(), errors.New(secret))
if apierrors.From(got).Status != 500 {
t.Fatal("wrong error mapping")
}
if strings.Contains(got.Error(), secret) || strings.Contains(output.String(), secret) {
t.Fatal("driver detail leaked into logging path")
}
if !strings.Contains(output.String(), "user.operationFailed") {
t.Fatal("failure event missing")
}
}

View File

@@ -3,11 +3,12 @@ package user
import (
"context"
"errors"
"example.com/userService/internal/apierrors"
"example.com/userService/internal/model"
repo "example.com/userService/internal/repository/user"
"example.com/userService/internal/servicecontext"
"example.com/userService/internal/types"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/logx"
"net/mail"
"strings"
@@ -34,17 +35,19 @@ func (s *Service) failure(ctx context.Context, err error) error {
case err == nil:
return nil
case errors.Is(err, model.ErrUserNotFound):
return apierrors.NotFound("USER_NOT_FOUND", "user not found")
return apierrors.UserNotFound()
case errors.Is(err, model.ErrEmailTaken):
return apierrors.New(409, "EMAIL_TAKEN", "email already in use")
return apierrors.EmailTaken()
case errors.Is(err, model.ErrInvalidUser):
return apierrors.BadRequest("INVALID_USER", "invalid user")
return apierrors.InvalidUser()
case errors.Is(err, model.ErrInvalidPage):
return apierrors.BadRequest("INVALID_PAGE", "invalid pagination")
return apierrors.InvalidPage()
default:
// Do not log SQL/DSN or user input; requestId correlates the failed operation.
s.deps.Logger.ErrorContext(ctx, "user.operationFailed", "category", "persistence")
return apierrors.Internal(err)
// HTTP access logging also sees the returned error. Keep raw driver
// errors out of that path because their messages may contain input.
return omnierrors.New(500, "INTERNAL_ERROR", "internal server error")
}
}
func (s *Service) mutate(ctx context.Context, operation string, fn func(repo.UserRepository) (model.User, error)) (*types.UserResp, error) {

View File

@@ -30,6 +30,12 @@ type Router interface {
Group(path string) RouterGroup
}
// FallbackRouter optionally routes 404/405 through the application's response
// policy and middleware. Custom adapters can implement it without changing Router.
type FallbackRouter interface {
SetFallbacks(notFound, methodNotAllowed http.Handler)
}
// RouterGroup is the route-registration portion of Router.
type RouterGroup interface {
Handle(method, path string, handler http.Handler)
@@ -87,6 +93,7 @@ type Context struct {
values map[string]any
responded bool
status int
encoder response.Encoder
}
// NewContext creates a transport-neutral GoOmni context.
@@ -121,6 +128,7 @@ func (c *Context) Raw() http.ResponseWriter { return c.writer }
// Stored values themselves are shared; middleware must not mutate them concurrently.
func (c *Context) Clone(ctx context.Context, writer http.ResponseWriter) *Context {
clone := NewContext(c.request.Clone(ctx), writer, c.params)
clone.encoder = c.encoder
for key, value := range c.values {
clone.values[key] = value
}
@@ -242,7 +250,8 @@ func (c *Context) NoContent() error {
}
func (c *Context) respond(status int, code, message string, data any) error {
return c.JSON(status, response.Response{
return c.writeResult(response.Result{
Status: status,
Code: code,
Message: message,
Data: data,
@@ -265,13 +274,18 @@ func (c *Context) Responded() bool { return c.responded }
// serialized. Responses committed through Context methods are preserved;
// direct Raw writes are outside this response-tracking contract.
func (c *Context) WriteError(err error) {
if c.responded {
if c.responded || err == nil {
return
}
apiErr := apierrors.From(err)
_ = c.JSON(apiErr.Status, response.Response{
if writeErr := c.writeResult(response.Result{
Status: apiErr.Status,
Code: apiErr.Code,
Message: apiErr.Message,
RequestID: c.RequestID(),
})
Err: err,
}); writeErr != nil && !c.responded {
// An invalid custom encoder must not recurse or leak a different envelope.
_ = c.WriteResponse(http.StatusInternalServerError, nil, nil)
}
}

85
httpx/response.go Normal file
View File

@@ -0,0 +1,85 @@
package httpx
import (
"encoding/xml"
"fmt"
"net/http"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/response"
)
// SetResponseEncoder overrides this request's success AND error encoding. A
// cloned context inherits it, including buffered/idempotent requests.
func (c *Context) SetResponseEncoder(encoder response.Encoder) { c.encoder = encoder }
// WithResponse applies a policy to one route/group. It deliberately stays set
// until the outer error boundary finishes handling this request.
func WithResponse(encoder response.Encoder) Middleware {
return func(next Handler) Handler {
return func(c *Context) error { c.SetResponseEncoder(encoder); return next(c) }
}
}
// ResponseRegistrar installs the response policy before application middleware,
// so early auth/protection failures and buffered clones use the route contract.
type ResponseRegistrar interface {
HandleWithResponse(string, string, response.Encoder, Handler, ...Middleware)
}
// HandleWithResponse uses the registrar's early policy hook when available.
// Custom registrars without this hook get an ordinary leading route middleware.
func HandleWithResponse(router interface {
Handle(string, string, Handler, ...Middleware)
}, method, path string, encoder response.Encoder, handler Handler, middleware ...Middleware) {
if r, ok := router.(ResponseRegistrar); ok {
r.HandleWithResponse(method, path, encoder, handler, middleware...)
return
}
all := append([]Middleware{WithResponse(encoder)}, middleware...)
router.Handle(method, path, handler, all...)
}
func (c *Context) writeResult(result response.Result) (err error) {
if c.responded {
return nil
}
defer func() {
if recovered := recover(); recovered != nil {
err = apierrors.Internal(fmt.Errorf("response encoder panic: %v", recovered))
}
}()
encoder := c.encoder
if encoder == nil {
encoder = response.Default()
}
payload, err := encoder.Encode(result)
if err != nil {
return apierrors.Internal(err)
}
if payload.Status < 200 || payload.Status > 599 {
return apierrors.Internal(fmt.Errorf("response encoder returned invalid status %d", payload.Status))
}
if (payload.Status == 204 || payload.Status == 304) && len(payload.Body) > 0 {
return apierrors.Internal(fmt.Errorf("response encoder returned a body for status %d", payload.Status))
}
return c.WriteResponse(payload.Status, payload.Header, payload.Body)
}
// Bytes, Text and XML are explicit wire responses; they bypass any envelope.
func (c *Context) Bytes(status int, contentType string, body []byte) error {
return c.WriteResponse(status, http.Header{"Content-Type": {contentType}}, body)
}
func (c *Context) Text(status int, body string) error {
return c.Bytes(status, "text/plain; charset=utf-8", []byte(body))
}
func (c *Context) XML(status int, value any) error {
if c.responded {
return nil
}
body, err := xml.Marshal(value)
if err != nil {
return apierrors.Internal(err)
}
return c.Bytes(status, "application/xml; charset=utf-8", body)
}

View File

@@ -48,6 +48,10 @@ func WithRequestIDHeader(header string) Option { return config.WithRequestIDHead
func WithLogger(enabled bool) Option { return config.WithLogger(enabled) }
func WithResponse(settings response.Config) Option { return config.WithResponse(settings) }
func WithResponseEncoder(encoder response.Encoder) Option { return config.WithResponseEncoder(encoder) }
func WithSlowRequestThreshold(threshold time.Duration) Option {
return config.WithSlowRequestThreshold(threshold)
}

42
openapi/codeTypes_test.go Normal file
View File

@@ -0,0 +1,42 @@
package openapi_test
import (
"testing"
"git.yuntuo.hk/k2safe/go-omni/openapi"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func TestResponseCodeSchemaUsesGoWidths(t *testing.T) {
for _, kind := range []string{"string", "int32", "int64", "integer"} {
cfg := response.Config{CodeType: kind}
wantType, wantFormat := "string", ""
if kind != "string" {
cfg.SuccessCode = 200
cfg.ErrorCode = 9999
wantType = "integer"
}
if kind == "int32" || kind == "int64" {
wantFormat = kind
}
for _, nested := range []bool{false, true} {
if nested {
cfg.Body = map[string]any{"result": map[string]any{"code": "${code}"}}
cfg.ErrorBody = cfg.Body
}
for _, data := range []*openapi.Schema{nil, {Type: "object"}} {
schema, err := openapi.EnvelopeWith(data, cfg)
if err != nil {
t.Fatal(err)
}
if nested {
schema = schema.Properties["result"]
}
code := schema.Properties["code"]
if code.Type != wantType || code.Format != wantFormat {
t.Fatalf("%s: %+v", kind, code)
}
}
}
}
}

114
openapi/envelope.go Normal file
View File

@@ -0,0 +1,114 @@
package openapi
import (
"encoding/json"
"git.yuntuo.hk/k2safe/go-omni/response"
"sort"
"strings"
)
// EnvelopeWith describes the same configurable envelope used at runtime. data
// describes the business value; it is never embedded under a fixed field name.
// A nil data schema describes an error response and selects ErrorBody if set.
func EnvelopeWith(data *Schema, cfg response.Config) (*Schema, error) {
f, err := response.New(cfg)
if err != nil {
return nil, err
}
c := f.Config()
body := c.Body
if data == nil && c.ErrorBody != nil {
body = c.ErrorBody
}
if body != nil {
return templateSchema(body, data, c), nil
}
s := &Schema{Type: "object", Properties: map[string]*Schema{}}
if c.CodeField != "-" {
s.Properties[c.CodeField] = codeSchema(c.CodeType)
s.Required = append(s.Required, c.CodeField)
}
if c.MessageField != "-" {
s.Properties[c.MessageField] = &Schema{Type: "string"}
s.Required = append(s.Required, c.MessageField)
}
if c.RequestIDField != "-" {
s.Properties[c.RequestIDField] = &Schema{Type: "string"}
}
if c.DataField != "-" && (data != nil || c.DataMode == "always") {
if data == nil {
data = &Schema{Type: "object", Nullable: true, Enum: []any{nil}}
}
s.Properties[c.DataField] = data
s.Required = append(s.Required, c.DataField)
}
return s, nil
}
func templateSchema(value any, data *Schema, cfg response.Config) *Schema {
switch response.Slot(value) {
case "code":
return codeSchema(cfg.CodeType)
case "message", "requestId":
return &Schema{Type: "string"}
case "ok":
return &Schema{Type: "boolean"}
case "status":
return &Schema{Type: "integer"}
case "data":
if data != nil {
return data
}
return &Schema{Type: "object", Nullable: true, Enum: []any{nil}}
}
switch v := value.(type) {
case map[string]any:
s := &Schema{Type: "object", Properties: map[string]*Schema{}}
keys := make([]string, 0, len(v))
for key := range v {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
item := v[key]
if response.Slot(item) == "data" && data == nil && cfg.DataMode != "always" {
continue
}
s.Properties[key] = templateSchema(item, data, cfg)
if response.Slot(item) != "requestId" {
s.Required = append(s.Required, key)
}
}
return s
case []any:
items := &Schema{}
for _, item := range v {
items.AnyOf = append(items.AnyOf, templateSchema(item, data, cfg))
}
size := len(v)
return &Schema{Type: "array", Items: items, MinItems: &size, MaxItems: &size}
case string:
if strings.HasPrefix(v, "$${") {
v = v[1:]
}
return &Schema{Type: "string", Enum: []any{v}}
case bool:
return &Schema{Type: "boolean", Enum: []any{v}}
case nil:
return &Schema{Type: "object", Nullable: true, Enum: []any{nil}}
default:
typeName := "number"
encoded, _ := json.Marshal(v)
if _, err := json.Number(string(encoded)).Int64(); err == nil {
typeName = "integer"
}
return &Schema{Type: typeName, Enum: []any{v}}
}
}
func codeSchema(kind string) *Schema {
if kind == "int32" || kind == "int64" {
return &Schema{Type: "integer", Format: kind}
}
return &Schema{Type: kind}
}

View File

@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/response"
"net/http"
"reflect"
"regexp"
@@ -36,6 +37,7 @@ type Schema struct {
Enum []any `json:"enum,omitempty"`
Default any `json:"default,omitempty"`
Not *Schema `json:"not,omitempty"`
AnyOf []*Schema `json:"anyOf,omitempty"`
}
type Parameter struct {
Name string `json:"name"`
@@ -181,7 +183,8 @@ func (a *API) Handler() httpx.Handler {
}
}
func Envelope(data *Schema) *Schema {
return &Schema{Type: "object", Required: []string{"code", "message"}, Properties: map[string]*Schema{"code": {Type: "string"}, "message": {Type: "string"}, "requestId": {Type: "string"}, "data": data}}
schema, _ := EnvelopeWith(data, response.Config{})
return schema
}
func SchemaOf[T any]() (*Schema, error) {
return schema(reflect.TypeOf((*T)(nil)).Elem(), map[reflect.Type]bool{}, 0)

View File

@@ -0,0 +1,53 @@
# API 错误、鉴权声明与开发体验验收
目标版本:`v0.7.0-preview.1`。本轮修改 CLI 和示例,不改变网络适配器或业务 Service 的调用接口。
## 完成范围
- `.api` 集中声明业务错误,可通过 import 拆分;组级继承、路由追加/清空,生成错误码常量和每次返回新实例的 `apierrors` 函数。
- bearer、header API key、cookie API key 声明关联实际命名中间件;鉴权先于普通中间件执行,支持单接口覆盖和公共接口。
- OpenAPI 同步安全方案、401/403/409 等响应、同状态多个错误示例及业务错误码;支持接口描述、标签、弃用标记。
- 项目根目录自动发现契约入口;`api inspect` 查看路由,`api export` 输出文档,`api generate --dry-run` 预览文件变更。
- 用户示例使用同一份错误声明,并增加只读凭证,实际覆盖未认证 401、写入被拒绝 403、重复邮箱 409。
## 本地验证
- 完整框架 `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=2 -count=1 -timeout=300s ./...`:39 个有测试的包通过。
- 包含 10 种 API 基础设施组合、10 种既有 CRUD 模块组合,以及新增的错误/鉴权消费者项目实际编译、运行与 race 检查。
- CLI 最后补充路径参数前缀回归(`:id` 和 `:idLong`),再次执行 `go test -race ./cmd/go-omni/...` 通过。
- `go vet ./...`、`go build ./...` 和格式/差异检查通过。
- 现有 API 真实 HTTP 回归在 nethttp/native 共 54 项通过。
- 用户示例在真实临时 MySQL 下,nethttp/native 共 34 项 HTTP、业务和生命周期检查通过;事务提交/回滚、初始化失败清理等测试同时通过。
- 检查鉴权优先顺序、未实现 hook 拒绝放行、缺失 hook 时不注册任何路由、公共接口覆盖、多鉴权方案和错误实例独立性。
- 检查只读预览不创建目录/锁文件、不改变文件;重复生成保留业务实现;无效声明报告源文件位置;恶意 manifest 路径被拒绝。
证据:[完整框架测试](apiDeveloperExperienceTests.txt)、[本地业务报告](apiDeveloperExperienceLocal.json)、[API 基础回归](apiGenerationLocal.json)。
## 远程版本验证
已发布 `v0.7.0-preview.1`,标签指向 `2913e94a0a4764ca70f0d19d198bd6c4852d8149`。
执行以下命令,从全新 module cache 下载 CLI 和框架,验证固定版本且没有本地 replace 的业务项目:
```sh
python3 scripts/verifyAPIWorkflow.py --version v0.7.0-preview.1 \
--report performance/apiDeveloperExperienceRemote.json
```
远程验证通过:
- 远程安装的 CLI 在新目录生成 MySQL + local 项目,依赖固定为本版,无本地 replace,编译和测试通过。
- 用户示例从远程下载框架;module checksum 为 `h1:Eavj6gW9/v0y/knsQne5eNYIHxZZ9+e/r4Nu5PRUQfI=`。
- nethttp/native 共 34 项 HTTP、业务及生命周期检查通过,包括真实 401/403/409 与对应 OpenAPI 文档。
- 数据库迁移、事务提交/回滚、初始化失败清理测试通过;重新生成前后 29 份开发者拥有的文件逐字节保持一致。
- [远程业务报告](apiDeveloperExperienceRemote.json) 记录版本、检查列表与实际源码哈希;源码哈希已与本轮最终代码核对一致。
验收全部结束,临时服务、数据库容器及测试目录已清理。发布标签保持不变,后续验收记录维护在 main。
## 边界
鉴权声明负责文档及路由中间件装配;凭证校验、登录和权限策略仍由业务 hook 实现。新 hook 默认返回 501,不会自动放行。
错误声明不会自动映射数据库错误,也不会拦截运行时返回;Service 需要使用对应错误函数。
本版每个接口只选择一个认证方案,不含 OAuth2 scopes、组合认证或 SDK 生成;现有 `.api` 继续支持,新声明需升级 CLI。
验收只使用本机临时服务和 MySQL 容器,限制 1 CPU / 768 MiB、绑定 localhost,结束删除临时资源;未使用业务服务器。
OSS/R2 云账号验收与网络层长时间压力测试不属于本轮。

View File

@@ -0,0 +1,161 @@
{
"passed": true,
"frameworkVersion": "local",
"noLocalReplace": false,
"durationSeconds": 20.21,
"regenerationPreservedHandwrittenFiles": 29,
"groups": [
{
"transport": "nethttp",
"checks": [
"authBeforeService",
"readOnlyPermission403",
"emptyPageAndQueryDefaults",
"contractValidation",
"createReadNormalize",
"uniqueEmailConflict",
"updateAndPagination",
"createUpdateDeleteRollbackOnAuditFailure",
"storageUploadAndSignedDownload",
"signedDownloadTamperRejected",
"openAPIMatchesContract",
"declaredAuthAndBusinessErrorDocumentation",
"readinessOutageAndRecovery",
"persistenceAfterServiceRestart",
"deleteAndTransactionalAudit",
"gracefulStopAndListenerRelease",
"businessFileLogsAndSecretExclusion"
]
},
{
"transport": "native",
"checks": [
"authBeforeService",
"readOnlyPermission403",
"emptyPageAndQueryDefaults",
"contractValidation",
"createReadNormalize",
"uniqueEmailConflict",
"updateAndPagination",
"createUpdateDeleteRollbackOnAuditFailure",
"storageUploadAndSignedDownload",
"signedDownloadTamperRejected",
"openAPIMatchesContract",
"declaredAuthAndBusinessErrorDocumentation",
"readinessOutageAndRecovery",
"persistenceAfterServiceRestart",
"deleteAndTransactionalAudit",
"gracefulStopAndListenerRelease",
"businessFileLogsAndSecretExclusion"
]
}
],
"transactionTests": [
"commit",
"errorRollback",
"panicRollback",
"cancellationRollback"
],
"startupTests": [
"storageFailureClosesDatabase",
"idempotentClose",
"closedContextCannotReopen"
],
"migrationCLI": true,
"frameworkSum": null,
"sourceHashes": {
"cmd/go-omni/api.go": "4e2722bdf6f0973df2f91d23ba3a8ade9cf5d8ec8dba68c188382a3c98415109",
"cmd/go-omni/api_test.go": "172e4e2ba4984820ba729f88273b1ae133415081344e51c3654f297ef33216d4",
"cmd/go-omni/apigen/apigen_test.go": "7cbe654e84c60f7b7f3a9631472bb66c1999ff4a9eaacaec224d26e78d08a38c",
"cmd/go-omni/apigen/compile.go": "c1334934f4f329b20efe9bfd8903f18125355d4a21111cdcd3fa0d2c8e88bcd9",
"cmd/go-omni/apigen/contractMetadata.go": "87239fa7d1610963d1c812f79074f9ceb6a71c263d16c1b1ed257fbc553a4fac",
"cmd/go-omni/apigen/declarations.go": "3f8073fb0b0bfa775f7712ed28e670f8dc98571636656726e18a6d592692bc81",
"cmd/go-omni/apigen/generate.go": "b6c10d5e7f22a36d436a4b720da9a123e4caec82ca09b349b589ac578ccfdbb8",
"cmd/go-omni/apigen/metadata_test.go": "ab0c55e90e9b7a8bdaf260415e4c8564f6b2378de625fd9379a2b801cb4bc0e5",
"cmd/go-omni/apigen/parse.go": "82ff76277478d62b7df291eec911070da168cd3739c9006f58d367e6276b77b5",
"cmd/go-omni/apigen/profile.go": "877939398384c44316e5e052d92c4a93fb3b74a6742df2414a0054913f3fca27",
"cmd/go-omni/apigen/profile_test.go": "5f3c091da241fe0f8b99ac19e9efd2c3177aea72e63a346f99fecc0f548aad1a",
"cmd/go-omni/apigen/projectEntry.go": "817936d391ec7dc76f7b5b9f7d134537947ea238d2cd281eb3df536511cb5486",
"cmd/go-omni/apigen/templates.go": "36b4946acc636960bf7ca1d95ab364135432392829c12be523ef6b514e433f63",
"cmd/go-omni/apigen/write.go": "241c8a4d386070ffee25aa5b5ce03129fe739fe3fc78c0ba65d2f48bdb075b6d",
"cmd/go-omni/main.go": "060b064973f2316b6f01c7b9a716d57c3dbc8ad33a5d6153f6cfddaf649cd5fc",
"cmd/go-omni/main_test.go": "b8040d2b19a1c86672fe1b1be7fa1bf7f885aece79ded6ffa0cc670887b7136f",
"cmd/go-omni/migrate.go": "f1ce89fbe419205eb82172da2b90e8888905d2b85d1a9db2a72b568bcf849a70",
"cmd/go-omni/migrate_test.go": "15d9a4aaebf9f899e523e908f1fa3c2282eb1e6c8cadbefae6b68f1ae4e00e6e",
"cmd/go-omni/scaffold/addModule.go": "8f43bd68c2b3820cf1d036c4f2c5f9d06d1c7c8e5114f5081597d905f8d8cdcc",
"cmd/go-omni/scaffold/addModule_test.go": "667c45312f628f0fb5b8206ddef7ccc02a75f33b72f73fd6a17b03da0cb62e91",
"cmd/go-omni/scaffold/apiInfrastructure.go": "f493364448254cae2d8e658bf5c69e96a3264c2bf3fa50fd48a5ebdb20edabd8",
"cmd/go-omni/scaffold/moduleProfiles_test.go": "ae9790e5d3549d32fc72739a10a31ea858e5a8f8189cf1f5c1840bf80b3f8ad6",
"cmd/go-omni/scaffold/moduleTemplates/README.md.tmpl": "9d3bfc46cac5618bcff7d8a6b2cc9f123e818aeb3dee9bcdfe0f91db584994bd",
"cmd/go-omni/scaffold/moduleTemplates/handler-handler.go.tmpl": "729cd0f6faba53d5d2c1492935e12ac2adcefde4490748e25c18be7ccb3f7139",
"cmd/go-omni/scaffold/moduleTemplates/model-model.go.tmpl": "a005611f6b455d4d8469e316bc26fa571043b2da1834db2b3dd14a96d66221d7",
"cmd/go-omni/scaffold/moduleTemplates/module.go.tmpl": "fb0be7fd571ba2c0edb01d70d68cdbbfe53561caab98346bf1ac7cd8df07205a",
"cmd/go-omni/scaffold/moduleTemplates/repository-repository.go.tmpl": "902da3d9f12395fb226178c4428ff547e1c2fce3261882dba98d1400d8c90747",
"cmd/go-omni/scaffold/moduleTemplates/service-service.go.tmpl": "49d26044a69a43e983aa5ba4b5b2399b26b77898b23e230751ad1372c2af3178",
"cmd/go-omni/scaffold/scaffold.go": "71b3091b83c3e45ac1981164e8c819c997ad96607d535f1492333370f4872548",
"cmd/go-omni/scaffold/scaffold_test.go": "58aab9251b01e394bbc90aeb538ff035dd32c73c0d4815132ac6cbc086527cda",
"cmd/go-omni/scaffold/templates/README.md.tmpl": "8ec215775fcafd437480022307fffd2ffe43685bc2b5d328b0c1d58402986e0a",
"cmd/go-omni/scaffold/templates/api-context.go.tmpl": "d2d67c3d235c4b1c965364f40f49c441d92764ad7ab8fd6848f56b1028e93b1b",
"cmd/go-omni/scaffold/templates/cmd-server-main.go.tmpl": "07c4f81109ece43fcf15bb807ada5ae6dc7e732babc7293269514a21cd389955",
"cmd/go-omni/scaffold/templates/databaseIntegration_test.go.tmpl": "81c4f7fbc919d74b738d3a7338248cbd83cfa0803b2058e8ee184e3ce51e7e9b",
"cmd/go-omni/scaffold/templates/go.mod.tmpl": "7d3bb15d2292e36939cc1905258d2800aeaf0e37d7d041b2ff8cfdb6fc84c57a",
"cmd/go-omni/scaffold/templates/internal-handler-user.go.tmpl": "7f8dbcd2f9b0129a90160d6059e47d19d4758274060a1a3e5160055d0592d34f",
"cmd/go-omni/scaffold/templates/internal-model-errors.go.tmpl": "f123eace52952321b0665a92a2923514c52c8c3bf13df1065710e9675750f837",
"cmd/go-omni/scaffold/templates/internal-model-user.go.tmpl": "b391143e7386e169b53e7fed25de1146e8c52f3fa287b12728e8d22b33f386de",
"cmd/go-omni/scaffold/templates/internal-repository-memory.go.tmpl": "148abc273c1fab3f28536706792762ae41e8687dfe9986c374372f162600feac",
"cmd/go-omni/scaffold/templates/internal-repository-mysql.go.tmpl": "34bd333e29211a0025ff6ebaea18c7f142f18f1cc451d692f9d360e8c8f6f635",
"cmd/go-omni/scaffold/templates/internal-repository-users.go.tmpl": "6d7dd80f34a9c215daeb4a86bfbe11e845e7ff7e9864c8af43e1c2ec3a8b28ec",
"cmd/go-omni/scaffold/templates/internal-service-user.go.tmpl": "33fdee3a2c3d98e7ca6a323b5e888a73582f94e327b6c24a34c09bf180db7b3d",
"cmd/go-omni/scaffold/templates/internal-servicecontext-context.go.tmpl": "de70ecc4194db8a2da5d65297f452a40b453652cb814cd228ce03d465761c856",
"cmd/go-omni/scaffold/templates/modules-README.md.tmpl": "4b84fbc2310b856d86c6fcd9a47d97d2261019c74ea8e0cdbc41229086aa3527",
"cmd/go-omni/scaffold/templates/modules-config.yaml.tmpl": "b8734080ae6d533a175bcad946ce2cb55d8bac1729918291753b9ca03064910f",
"cmd/go-omni/scaffold/templates/modules-filesHandler.go.tmpl": "426867fbef5b99a85394fa4457dd38b9a194adf4bdfb293384b2fbbc799091da",
"cmd/go-omni/scaffold/templates/modules-filesHandler_test.go.tmpl": "9571e39bd0a330739eb1fd7e636cd58961b41b44772b2b49162e41deda98547f",
"cmd/go-omni/scaffold/templates/modules-filesService.go.tmpl": "6460ee9a873af45ca6cec779bf53302acfce21499610425a4704c2f179eddb8f",
"cmd/go-omni/scaffold/templates/modules-gitignore.tmpl": "2579f2a63ff2962239b92dd841b0d0d26e783d85c9d0100011936c8bcbf5d0d3",
"cmd/go-omni/scaffold/templates/modules-main.go.tmpl": "cb8daddd4e12afd238540be7022187c74a548d9e409b3a7f1eaf84a9139218bf",
"cmd/go-omni/scaffold/templates/modules-settings.go.tmpl": "91084caaa1fd994dde714f52b242d23e41adb731383b12d74399247c87ac5597",
"cmd/go-omni/scaffold/templates/modules-settings_test.go.tmpl": "76e55f8954b7606d7146eb1e965065c521a2b81234a47742ed69a93aac300406",
"cmd/go-omni/scaffold/templates/modules-storage.go.tmpl": "a775acf2d2490288a8e5482bb0a8769be6b0a8c9cfcc9bb10b62aabbd05fa0c7",
"cmd/go-omni/scaffold/templates/modules-storage_test.go.tmpl": "dacca42d228bd9352948054eb2d071829a8a61d8c6fa24d95ff00fc39a74309e",
"cmd/go-omni/scaffold/templates/schema.sql.tmpl": "7c5108f18fc7d8ff75c97afd96fcaa9f8ed026c79c71c5612f55572d73354abb",
"cmd/go-omni/scaffold/version.go": "a389f1e0651544b104481309272722ad8736d7f5a4e27484c61dc8eceba84283",
"database/configOverlay_test.go": "01c9b44fded226494ca12f7d0f41a9ff417d8135d263a5833727c1c028b6b8e3",
"database/database.go": "3d8615e1bf58ecdae21558591e745c241e499ba6e85fc07acfa2344e04ce95db",
"database/database_test.go": "18eac1e4c6dbd6a09216168839ba3511aac8a38c2ddf6fdd5267f35435b09f67",
"database/lifecycle_test.go": "e141b52edfb9867184dedac0e79feed56a5b137acbe64280ad69647653bbea21",
"database/migrate/migrate.go": "fd6cd27d2dce5b35ffe80eb5442c47c657b93ffc6545bbcc1ee4651a70a4f80b",
"database/migrate/migrate_test.go": "d7ba91eed8cfd97e4ef0741d582f493dd411b65ad3192f05b167f75d9b9dcf90",
"examples/userService/api/user.api": "ed4d45cb796d9a017af9d341076553ca576918faef155aeea528718515582c95",
"examples/userService/cmd/server/main.go": "80ba4721f677947b7f2ece21d5f3e89ee0c4d2cc653412d83aef2b3a45bd3ccb",
"examples/userService/etc/config.yaml": "f50df3cddbc3c23f233405e7a9d8895b9d83d2a0607ccf001261fcd4b8978ee9",
"examples/userService/internal/apierrors/errorsGen.go": "20e94bbc90ae9f4330de4c25b9a550026c4c352502719a643ede3e8912a6566e",
"examples/userService/internal/handler/openapiGen.go": "e4130f5c7494f7db4830bfef203fa8154a9cd087c95954f64c29d74693ead433",
"examples/userService/internal/handler/routesGen.go": "7f45030e7eb9f055c4b4d2ff12ad855d7c726aad4aee63c5b3c3f97559a64257",
"examples/userService/internal/handler/user/handlersGen.go": "06f3c2798ff323812ff3eb2d8b933a569af3ce1b86a67b66036374add98c0d7e",
"examples/userService/internal/middleware/auth.go": "fbfe8ff8b12f698218b9ef5f77fd85b612b3aed362a73dc26c3627f2d688c040",
"examples/userService/internal/middleware/auth_test.go": "63d6117dadf43b766e0aa485a63c4725a5109ab814c2b4a1fa7a59ee6d1c4cf6",
"examples/userService/internal/middleware/registryGen.go": "a6122a5453dedc6a4415860108814c50e155128cfe5412b39dd33ce2d01b4b00",
"examples/userService/internal/model/errors.go": "f123eace52952321b0665a92a2923514c52c8c3bf13df1065710e9675750f837",
"examples/userService/internal/model/user.go": "b391143e7386e169b53e7fed25de1146e8c52f3fa287b12728e8d22b33f386de",
"examples/userService/internal/repository/user/mysql.go": "924cae233a68e506e9b0b953529e0ccb13a0e71d24723309383c464e1852b833",
"examples/userService/internal/repository/user/repository.go": "2aca07788d8d59831b10ac665882ca8157b20f384115bb90742f235f8352a69d",
"examples/userService/internal/repository/user/store.go": "f776832e4f3b26fb889dad53b65742833ddb7f43b7dddc72915ad3fe47ec4d3e",
"examples/userService/internal/repository/user/transaction_test.go": "9af4cb3a03b71026b80aa5078ddda4e3c4f72a7ab14bed6c87603a4db2cc2a22",
"examples/userService/internal/service/user/createUser.go": "53d0ea7371e10a0bd3f32f57b9bff9b48b5f9a3315218f95a258fa45b81d9368",
"examples/userService/internal/service/user/deleteUser.go": "0cd0b038d5a11429de438311980ef19a8f6e28ea0d7cf5ebaaf78858daf3a124",
"examples/userService/internal/service/user/errors_test.go": "c0193bdc674cbe15c4871a91e288323a99193d19ba8fd874f9bf17c390917e5c",
"examples/userService/internal/service/user/exportUser.go": "dac2adb0c9ea43a4af15511ae488afaa6d9c9b351e68e9b8a54f4014d29de14d",
"examples/userService/internal/service/user/getUser.go": "a307fa716c70286a2a6c5d5c0a6c35ec6d258c3278e2a7fe86b74b324d7b7283",
"examples/userService/internal/service/user/listUsers.go": "f0bf8b9c3a4c4a699112e3493b7fe611e2dd4f6e8a4437106396c47d484f1383",
"examples/userService/internal/service/user/service.go": "cd6c203459fccbc030e88eccfa785d5db029e45784f8b242be68ea1c9394a736",
"examples/userService/internal/service/user/updateUser.go": "9b0e3e4296c0e0df98956961a928612d9203c0fa09aa71bb0683e430e1133ff0",
"examples/userService/internal/servicecontext/context.go": "5527ca07049f5c5f5deffa65e5759494b2e0a77804b629b3ecb1705e7557ee25",
"examples/userService/internal/servicecontext/context_test.go": "04d008b6cf5b7894752bc5222abb8f4120f1ebe060034ac55ec9663c1f7bc613",
"examples/userService/internal/servicecontext/storage.go": "9402e1ee7a258b2db4b6dbf38fcb0674ad8907fb2a948d47dd8374f78bec444d",
"examples/userService/internal/settings/config.go": "721c3744a61a2d2807edcdfa190336901368966a0fb952561d0e00da5730cd3a",
"examples/userService/internal/types/typesGen.go": "3ab2ce9ffe467c3ea53ac3d1fec283ecebc87cef57fd592d08dca991fd964876",
"examples/userService/migrations/000001_users.down.sql": "64b47d3d2644745a2ba1197b73760e63fe0f2c1d12c193888c4417b3d5abb75e",
"examples/userService/migrations/000001_users.up.sql": "c6ea5cb92ee53fe20e43373d084ff360d49921b785602145e55380e4adbee1dc"
}
}

View File

@@ -0,0 +1,161 @@
{
"passed": true,
"frameworkVersion": "v0.7.0-preview.1",
"noLocalReplace": true,
"durationSeconds": 27.37,
"regenerationPreservedHandwrittenFiles": 29,
"groups": [
{
"transport": "nethttp",
"checks": [
"authBeforeService",
"readOnlyPermission403",
"emptyPageAndQueryDefaults",
"contractValidation",
"createReadNormalize",
"uniqueEmailConflict",
"updateAndPagination",
"createUpdateDeleteRollbackOnAuditFailure",
"storageUploadAndSignedDownload",
"signedDownloadTamperRejected",
"openAPIMatchesContract",
"declaredAuthAndBusinessErrorDocumentation",
"readinessOutageAndRecovery",
"persistenceAfterServiceRestart",
"deleteAndTransactionalAudit",
"gracefulStopAndListenerRelease",
"businessFileLogsAndSecretExclusion"
]
},
{
"transport": "native",
"checks": [
"authBeforeService",
"readOnlyPermission403",
"emptyPageAndQueryDefaults",
"contractValidation",
"createReadNormalize",
"uniqueEmailConflict",
"updateAndPagination",
"createUpdateDeleteRollbackOnAuditFailure",
"storageUploadAndSignedDownload",
"signedDownloadTamperRejected",
"openAPIMatchesContract",
"declaredAuthAndBusinessErrorDocumentation",
"readinessOutageAndRecovery",
"persistenceAfterServiceRestart",
"deleteAndTransactionalAudit",
"gracefulStopAndListenerRelease",
"businessFileLogsAndSecretExclusion"
]
}
],
"transactionTests": [
"commit",
"errorRollback",
"panicRollback",
"cancellationRollback"
],
"startupTests": [
"storageFailureClosesDatabase",
"idempotentClose",
"closedContextCannotReopen"
],
"migrationCLI": true,
"frameworkSum": "h1:Eavj6gW9/v0y/knsQne5eNYIHxZZ9+e/r4Nu5PRUQfI=",
"sourceHashes": {
"cmd/go-omni/api.go": "4e2722bdf6f0973df2f91d23ba3a8ade9cf5d8ec8dba68c188382a3c98415109",
"cmd/go-omni/api_test.go": "172e4e2ba4984820ba729f88273b1ae133415081344e51c3654f297ef33216d4",
"cmd/go-omni/apigen/apigen_test.go": "7cbe654e84c60f7b7f3a9631472bb66c1999ff4a9eaacaec224d26e78d08a38c",
"cmd/go-omni/apigen/compile.go": "c1334934f4f329b20efe9bfd8903f18125355d4a21111cdcd3fa0d2c8e88bcd9",
"cmd/go-omni/apigen/contractMetadata.go": "87239fa7d1610963d1c812f79074f9ceb6a71c263d16c1b1ed257fbc553a4fac",
"cmd/go-omni/apigen/declarations.go": "3f8073fb0b0bfa775f7712ed28e670f8dc98571636656726e18a6d592692bc81",
"cmd/go-omni/apigen/generate.go": "b6c10d5e7f22a36d436a4b720da9a123e4caec82ca09b349b589ac578ccfdbb8",
"cmd/go-omni/apigen/metadata_test.go": "ab0c55e90e9b7a8bdaf260415e4c8564f6b2378de625fd9379a2b801cb4bc0e5",
"cmd/go-omni/apigen/parse.go": "82ff76277478d62b7df291eec911070da168cd3739c9006f58d367e6276b77b5",
"cmd/go-omni/apigen/profile.go": "877939398384c44316e5e052d92c4a93fb3b74a6742df2414a0054913f3fca27",
"cmd/go-omni/apigen/profile_test.go": "5f3c091da241fe0f8b99ac19e9efd2c3177aea72e63a346f99fecc0f548aad1a",
"cmd/go-omni/apigen/projectEntry.go": "817936d391ec7dc76f7b5b9f7d134537947ea238d2cd281eb3df536511cb5486",
"cmd/go-omni/apigen/templates.go": "36b4946acc636960bf7ca1d95ab364135432392829c12be523ef6b514e433f63",
"cmd/go-omni/apigen/write.go": "241c8a4d386070ffee25aa5b5ce03129fe739fe3fc78c0ba65d2f48bdb075b6d",
"cmd/go-omni/main.go": "060b064973f2316b6f01c7b9a716d57c3dbc8ad33a5d6153f6cfddaf649cd5fc",
"cmd/go-omni/main_test.go": "b8040d2b19a1c86672fe1b1be7fa1bf7f885aece79ded6ffa0cc670887b7136f",
"cmd/go-omni/migrate.go": "f1ce89fbe419205eb82172da2b90e8888905d2b85d1a9db2a72b568bcf849a70",
"cmd/go-omni/migrate_test.go": "15d9a4aaebf9f899e523e908f1fa3c2282eb1e6c8cadbefae6b68f1ae4e00e6e",
"cmd/go-omni/scaffold/addModule.go": "8f43bd68c2b3820cf1d036c4f2c5f9d06d1c7c8e5114f5081597d905f8d8cdcc",
"cmd/go-omni/scaffold/addModule_test.go": "667c45312f628f0fb5b8206ddef7ccc02a75f33b72f73fd6a17b03da0cb62e91",
"cmd/go-omni/scaffold/apiInfrastructure.go": "f493364448254cae2d8e658bf5c69e96a3264c2bf3fa50fd48a5ebdb20edabd8",
"cmd/go-omni/scaffold/moduleProfiles_test.go": "ae9790e5d3549d32fc72739a10a31ea858e5a8f8189cf1f5c1840bf80b3f8ad6",
"cmd/go-omni/scaffold/moduleTemplates/README.md.tmpl": "9d3bfc46cac5618bcff7d8a6b2cc9f123e818aeb3dee9bcdfe0f91db584994bd",
"cmd/go-omni/scaffold/moduleTemplates/handler-handler.go.tmpl": "729cd0f6faba53d5d2c1492935e12ac2adcefde4490748e25c18be7ccb3f7139",
"cmd/go-omni/scaffold/moduleTemplates/model-model.go.tmpl": "a005611f6b455d4d8469e316bc26fa571043b2da1834db2b3dd14a96d66221d7",
"cmd/go-omni/scaffold/moduleTemplates/module.go.tmpl": "fb0be7fd571ba2c0edb01d70d68cdbbfe53561caab98346bf1ac7cd8df07205a",
"cmd/go-omni/scaffold/moduleTemplates/repository-repository.go.tmpl": "902da3d9f12395fb226178c4428ff547e1c2fce3261882dba98d1400d8c90747",
"cmd/go-omni/scaffold/moduleTemplates/service-service.go.tmpl": "49d26044a69a43e983aa5ba4b5b2399b26b77898b23e230751ad1372c2af3178",
"cmd/go-omni/scaffold/scaffold.go": "71b3091b83c3e45ac1981164e8c819c997ad96607d535f1492333370f4872548",
"cmd/go-omni/scaffold/scaffold_test.go": "58aab9251b01e394bbc90aeb538ff035dd32c73c0d4815132ac6cbc086527cda",
"cmd/go-omni/scaffold/templates/README.md.tmpl": "8ec215775fcafd437480022307fffd2ffe43685bc2b5d328b0c1d58402986e0a",
"cmd/go-omni/scaffold/templates/api-context.go.tmpl": "d2d67c3d235c4b1c965364f40f49c441d92764ad7ab8fd6848f56b1028e93b1b",
"cmd/go-omni/scaffold/templates/cmd-server-main.go.tmpl": "07c4f81109ece43fcf15bb807ada5ae6dc7e732babc7293269514a21cd389955",
"cmd/go-omni/scaffold/templates/databaseIntegration_test.go.tmpl": "81c4f7fbc919d74b738d3a7338248cbd83cfa0803b2058e8ee184e3ce51e7e9b",
"cmd/go-omni/scaffold/templates/go.mod.tmpl": "7d3bb15d2292e36939cc1905258d2800aeaf0e37d7d041b2ff8cfdb6fc84c57a",
"cmd/go-omni/scaffold/templates/internal-handler-user.go.tmpl": "7f8dbcd2f9b0129a90160d6059e47d19d4758274060a1a3e5160055d0592d34f",
"cmd/go-omni/scaffold/templates/internal-model-errors.go.tmpl": "f123eace52952321b0665a92a2923514c52c8c3bf13df1065710e9675750f837",
"cmd/go-omni/scaffold/templates/internal-model-user.go.tmpl": "b391143e7386e169b53e7fed25de1146e8c52f3fa287b12728e8d22b33f386de",
"cmd/go-omni/scaffold/templates/internal-repository-memory.go.tmpl": "148abc273c1fab3f28536706792762ae41e8687dfe9986c374372f162600feac",
"cmd/go-omni/scaffold/templates/internal-repository-mysql.go.tmpl": "34bd333e29211a0025ff6ebaea18c7f142f18f1cc451d692f9d360e8c8f6f635",
"cmd/go-omni/scaffold/templates/internal-repository-users.go.tmpl": "6d7dd80f34a9c215daeb4a86bfbe11e845e7ff7e9864c8af43e1c2ec3a8b28ec",
"cmd/go-omni/scaffold/templates/internal-service-user.go.tmpl": "33fdee3a2c3d98e7ca6a323b5e888a73582f94e327b6c24a34c09bf180db7b3d",
"cmd/go-omni/scaffold/templates/internal-servicecontext-context.go.tmpl": "de70ecc4194db8a2da5d65297f452a40b453652cb814cd228ce03d465761c856",
"cmd/go-omni/scaffold/templates/modules-README.md.tmpl": "4b84fbc2310b856d86c6fcd9a47d97d2261019c74ea8e0cdbc41229086aa3527",
"cmd/go-omni/scaffold/templates/modules-config.yaml.tmpl": "b8734080ae6d533a175bcad946ce2cb55d8bac1729918291753b9ca03064910f",
"cmd/go-omni/scaffold/templates/modules-filesHandler.go.tmpl": "426867fbef5b99a85394fa4457dd38b9a194adf4bdfb293384b2fbbc799091da",
"cmd/go-omni/scaffold/templates/modules-filesHandler_test.go.tmpl": "9571e39bd0a330739eb1fd7e636cd58961b41b44772b2b49162e41deda98547f",
"cmd/go-omni/scaffold/templates/modules-filesService.go.tmpl": "6460ee9a873af45ca6cec779bf53302acfce21499610425a4704c2f179eddb8f",
"cmd/go-omni/scaffold/templates/modules-gitignore.tmpl": "2579f2a63ff2962239b92dd841b0d0d26e783d85c9d0100011936c8bcbf5d0d3",
"cmd/go-omni/scaffold/templates/modules-main.go.tmpl": "cb8daddd4e12afd238540be7022187c74a548d9e409b3a7f1eaf84a9139218bf",
"cmd/go-omni/scaffold/templates/modules-settings.go.tmpl": "91084caaa1fd994dde714f52b242d23e41adb731383b12d74399247c87ac5597",
"cmd/go-omni/scaffold/templates/modules-settings_test.go.tmpl": "76e55f8954b7606d7146eb1e965065c521a2b81234a47742ed69a93aac300406",
"cmd/go-omni/scaffold/templates/modules-storage.go.tmpl": "a775acf2d2490288a8e5482bb0a8769be6b0a8c9cfcc9bb10b62aabbd05fa0c7",
"cmd/go-omni/scaffold/templates/modules-storage_test.go.tmpl": "dacca42d228bd9352948054eb2d071829a8a61d8c6fa24d95ff00fc39a74309e",
"cmd/go-omni/scaffold/templates/schema.sql.tmpl": "7c5108f18fc7d8ff75c97afd96fcaa9f8ed026c79c71c5612f55572d73354abb",
"cmd/go-omni/scaffold/version.go": "a389f1e0651544b104481309272722ad8736d7f5a4e27484c61dc8eceba84283",
"database/configOverlay_test.go": "01c9b44fded226494ca12f7d0f41a9ff417d8135d263a5833727c1c028b6b8e3",
"database/database.go": "3d8615e1bf58ecdae21558591e745c241e499ba6e85fc07acfa2344e04ce95db",
"database/database_test.go": "18eac1e4c6dbd6a09216168839ba3511aac8a38c2ddf6fdd5267f35435b09f67",
"database/lifecycle_test.go": "e141b52edfb9867184dedac0e79feed56a5b137acbe64280ad69647653bbea21",
"database/migrate/migrate.go": "fd6cd27d2dce5b35ffe80eb5442c47c657b93ffc6545bbcc1ee4651a70a4f80b",
"database/migrate/migrate_test.go": "d7ba91eed8cfd97e4ef0741d582f493dd411b65ad3192f05b167f75d9b9dcf90",
"examples/userService/api/user.api": "ed4d45cb796d9a017af9d341076553ca576918faef155aeea528718515582c95",
"examples/userService/cmd/server/main.go": "80ba4721f677947b7f2ece21d5f3e89ee0c4d2cc653412d83aef2b3a45bd3ccb",
"examples/userService/etc/config.yaml": "f50df3cddbc3c23f233405e7a9d8895b9d83d2a0607ccf001261fcd4b8978ee9",
"examples/userService/internal/apierrors/errorsGen.go": "20e94bbc90ae9f4330de4c25b9a550026c4c352502719a643ede3e8912a6566e",
"examples/userService/internal/handler/openapiGen.go": "e4130f5c7494f7db4830bfef203fa8154a9cd087c95954f64c29d74693ead433",
"examples/userService/internal/handler/routesGen.go": "7f45030e7eb9f055c4b4d2ff12ad855d7c726aad4aee63c5b3c3f97559a64257",
"examples/userService/internal/handler/user/handlersGen.go": "06f3c2798ff323812ff3eb2d8b933a569af3ce1b86a67b66036374add98c0d7e",
"examples/userService/internal/middleware/auth.go": "fbfe8ff8b12f698218b9ef5f77fd85b612b3aed362a73dc26c3627f2d688c040",
"examples/userService/internal/middleware/auth_test.go": "63d6117dadf43b766e0aa485a63c4725a5109ab814c2b4a1fa7a59ee6d1c4cf6",
"examples/userService/internal/middleware/registryGen.go": "a6122a5453dedc6a4415860108814c50e155128cfe5412b39dd33ce2d01b4b00",
"examples/userService/internal/model/errors.go": "f123eace52952321b0665a92a2923514c52c8c3bf13df1065710e9675750f837",
"examples/userService/internal/model/user.go": "b391143e7386e169b53e7fed25de1146e8c52f3fa287b12728e8d22b33f386de",
"examples/userService/internal/repository/user/mysql.go": "924cae233a68e506e9b0b953529e0ccb13a0e71d24723309383c464e1852b833",
"examples/userService/internal/repository/user/repository.go": "2aca07788d8d59831b10ac665882ca8157b20f384115bb90742f235f8352a69d",
"examples/userService/internal/repository/user/store.go": "f776832e4f3b26fb889dad53b65742833ddb7f43b7dddc72915ad3fe47ec4d3e",
"examples/userService/internal/repository/user/transaction_test.go": "9af4cb3a03b71026b80aa5078ddda4e3c4f72a7ab14bed6c87603a4db2cc2a22",
"examples/userService/internal/service/user/createUser.go": "53d0ea7371e10a0bd3f32f57b9bff9b48b5f9a3315218f95a258fa45b81d9368",
"examples/userService/internal/service/user/deleteUser.go": "0cd0b038d5a11429de438311980ef19a8f6e28ea0d7cf5ebaaf78858daf3a124",
"examples/userService/internal/service/user/errors_test.go": "c0193bdc674cbe15c4871a91e288323a99193d19ba8fd874f9bf17c390917e5c",
"examples/userService/internal/service/user/exportUser.go": "dac2adb0c9ea43a4af15511ae488afaa6d9c9b351e68e9b8a54f4014d29de14d",
"examples/userService/internal/service/user/getUser.go": "a307fa716c70286a2a6c5d5c0a6c35ec6d258c3278e2a7fe86b74b324d7b7283",
"examples/userService/internal/service/user/listUsers.go": "f0bf8b9c3a4c4a699112e3493b7fe611e2dd4f6e8a4437106396c47d484f1383",
"examples/userService/internal/service/user/service.go": "cd6c203459fccbc030e88eccfa785d5db029e45784f8b242be68ea1c9394a736",
"examples/userService/internal/service/user/updateUser.go": "9b0e3e4296c0e0df98956961a928612d9203c0fa09aa71bb0683e430e1133ff0",
"examples/userService/internal/servicecontext/context.go": "5527ca07049f5c5f5deffa65e5759494b2e0a77804b629b3ecb1705e7557ee25",
"examples/userService/internal/servicecontext/context_test.go": "04d008b6cf5b7894752bc5222abb8f4120f1ebe060034ac55ec9663c1f7bc613",
"examples/userService/internal/servicecontext/storage.go": "9402e1ee7a258b2db4b6dbf38fcb0674ad8907fb2a948d47dd8374f78bec444d",
"examples/userService/internal/settings/config.go": "721c3744a61a2d2807edcdfa190336901368966a0fb952561d0e00da5730cd3a",
"examples/userService/internal/types/typesGen.go": "3ab2ce9ffe467c3ea53ac3d1fec283ecebc87cef57fd592d08dca991fd964876",
"examples/userService/migrations/000001_users.down.sql": "64b47d3d2644745a2ba1197b73760e63fe0f2c1d12c193888c4417b3d5abb75e",
"examples/userService/migrations/000001_users.up.sql": "c6ea5cb92ee53fe20e43373d084ff360d49921b785602145e55380e4adbee1dc"
}
}

View File

@@ -0,0 +1,52 @@
ok git.yuntuo.hk/k2safe/go-omni 1.484s
ok git.yuntuo.hk/k2safe/go-omni/app 2.365s
ok git.yuntuo.hk/k2safe/go-omni/audit 1.278s
ok git.yuntuo.hk/k2safe/go-omni/auth 1.335s
ok git.yuntuo.hk/k2safe/go-omni/cache 1.297s
ok git.yuntuo.hk/k2safe/go-omni/cache/redis 1.276s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 1.848s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen 28.489s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold 37.257s
ok git.yuntuo.hk/k2safe/go-omni/config 1.337s
ok git.yuntuo.hk/k2safe/go-omni/contract 1.310s
ok git.yuntuo.hk/k2safe/go-omni/database 1.285s
ok git.yuntuo.hk/k2safe/go-omni/database/migrate 1.300s
? git.yuntuo.hk/k2safe/go-omni/errors [no test files]
? git.yuntuo.hk/k2safe/go-omni/examples/basic [no test files]
ok git.yuntuo.hk/k2safe/go-omni/examples/modules 1.345s
ok git.yuntuo.hk/k2safe/go-omni/examples/platform 1.379s
ok git.yuntuo.hk/k2safe/go-omni/examples/reference 1.469s
? git.yuntuo.hk/k2safe/go-omni/examples/reference/handler [no test files]
? git.yuntuo.hk/k2safe/go-omni/examples/reference/model [no test files]
ok git.yuntuo.hk/k2safe/go-omni/examples/reference/repository 1.438s
? git.yuntuo.hk/k2safe/go-omni/examples/reference/service [no test files]
ok git.yuntuo.hk/k2safe/go-omni/health 1.517s
ok git.yuntuo.hk/k2safe/go-omni/httpc 1.730s
ok git.yuntuo.hk/k2safe/go-omni/httpx 1.292s
ok git.yuntuo.hk/k2safe/go-omni/internal/perfcheck 1.310s
? git.yuntuo.hk/k2safe/go-omni/internal/storagefixture [no test files]
? git.yuntuo.hk/k2safe/go-omni/internal/testutil [no test files]
ok git.yuntuo.hk/k2safe/go-omni/logx 1.440s
ok git.yuntuo.hk/k2safe/go-omni/metrics 1.296s
ok git.yuntuo.hk/k2safe/go-omni/middleware 1.319s
ok git.yuntuo.hk/k2safe/go-omni/middleware/redis 1.296s
ok git.yuntuo.hk/k2safe/go-omni/openapi 1.304s
ok git.yuntuo.hk/k2safe/go-omni/pagination 1.279s
? git.yuntuo.hk/k2safe/go-omni/queue [no test files]
ok git.yuntuo.hk/k2safe/go-omni/queue/redis 1.278s
ok git.yuntuo.hk/k2safe/go-omni/resilience 1.321s
? git.yuntuo.hk/k2safe/go-omni/response [no test files]
ok git.yuntuo.hk/k2safe/go-omni/storage 1.309s
ok git.yuntuo.hk/k2safe/go-omni/storage/local 1.314s
ok git.yuntuo.hk/k2safe/go-omni/storage/oss 1.423s
ok git.yuntuo.hk/k2safe/go-omni/storage/s3 8.243s
? git.yuntuo.hk/k2safe/go-omni/storage/storagetest [no test files]
ok git.yuntuo.hk/k2safe/go-omni/task 1.361s
ok git.yuntuo.hk/k2safe/go-omni/telemetry 1.308s
ok git.yuntuo.hk/k2safe/go-omni/transport 1.327s
ok git.yuntuo.hk/k2safe/go-omni/transport/http2 12.624s
? git.yuntuo.hk/k2safe/go-omni/transport/internal/routing [no test files]
ok git.yuntuo.hk/k2safe/go-omni/transport/native 2.099s
ok git.yuntuo.hk/k2safe/go-omni/transport/nethttp 1.327s
? git.yuntuo.hk/k2safe/go-omni/transport/transporttest [no test files]
? git.yuntuo.hk/k2safe/go-omni/validate [no test files]

View File

@@ -1,6 +1,6 @@
{
"passed": true,
"durationSeconds": 3.59,
"durationSeconds": 3.43,
"cases": [
{
"project": "starter",
@@ -86,16 +86,20 @@
}
],
"sourceSha256": {
"cmd/go-omni/api.go": "59f3b8b45497a9f560d649ac5e7b622362f5f20edd81251d7dc76f21605fc90a",
"cmd/go-omni/apigen/apigen_test.go": "8e7c94795ffbc3ee062756a77cfeb274f3cd1a78f1dbc3b51ec4ceb14f478518",
"cmd/go-omni/apigen/compile.go": "6709da0b69e8d10d49a874447e00c93d0a819d8367b2188e9626bd0c7b3baef2",
"cmd/go-omni/apigen/generate.go": "373ec9834aa4ed71a5311c1ef40b78dc6c303aa884d7bb16a680058ca751b2bc",
"cmd/go-omni/apigen/parse.go": "e85fe261fdec2e3487851e265babd7af8c7f6153694ad6fb73177aac33f8ef95",
"cmd/go-omni/api.go": "4e2722bdf6f0973df2f91d23ba3a8ade9cf5d8ec8dba68c188382a3c98415109",
"cmd/go-omni/apigen/apigen_test.go": "7cbe654e84c60f7b7f3a9631472bb66c1999ff4a9eaacaec224d26e78d08a38c",
"cmd/go-omni/apigen/compile.go": "c1334934f4f329b20efe9bfd8903f18125355d4a21111cdcd3fa0d2c8e88bcd9",
"cmd/go-omni/apigen/contractMetadata.go": "87239fa7d1610963d1c812f79074f9ceb6a71c263d16c1b1ed257fbc553a4fac",
"cmd/go-omni/apigen/declarations.go": "3f8073fb0b0bfa775f7712ed28e670f8dc98571636656726e18a6d592692bc81",
"cmd/go-omni/apigen/generate.go": "b6c10d5e7f22a36d436a4b720da9a123e4caec82ca09b349b589ac578ccfdbb8",
"cmd/go-omni/apigen/metadata_test.go": "ab0c55e90e9b7a8bdaf260415e4c8564f6b2378de625fd9379a2b801cb4bc0e5",
"cmd/go-omni/apigen/parse.go": "82ff76277478d62b7df291eec911070da168cd3739c9006f58d367e6276b77b5",
"cmd/go-omni/apigen/profile.go": "877939398384c44316e5e052d92c4a93fb3b74a6742df2414a0054913f3fca27",
"cmd/go-omni/apigen/profile_test.go": "5f3c091da241fe0f8b99ac19e9efd2c3177aea72e63a346f99fecc0f548aad1a",
"cmd/go-omni/apigen/templates.go": "9303a2fafe44cb7e763a2c5beb193c16fa0dc0c2dbde4cb6e14f2ba533d3e56e",
"cmd/go-omni/apigen/write.go": "1ce3871fd92f638d097c576583c169daa0ce2fa8010dbcd34a8cd7faa314a1a2",
"cmd/go-omni/main.go": "6f851d21792a9eb9fcbc5095e8cacb3dcee1c715a3980650c75381f0e46f51a9",
"cmd/go-omni/apigen/projectEntry.go": "817936d391ec7dc76f7b5b9f7d134537947ea238d2cd281eb3df536511cb5486",
"cmd/go-omni/apigen/templates.go": "36b4946acc636960bf7ca1d95ab364135432392829c12be523ef6b514e433f63",
"cmd/go-omni/apigen/write.go": "241c8a4d386070ffee25aa5b5ce03129fe739fe3fc78c0ba65d2f48bdb075b6d",
"cmd/go-omni/main.go": "060b064973f2316b6f01c7b9a716d57c3dbc8ad33a5d6153f6cfddaf649cd5fc",
"contract/README.md": "ff3b56398cf676df401d84d1169dfff19fc83ee074b07fb4c16fdf5af2e8fe17",
"contract/bind.go": "8a6be9046c9a489a4bed78bca1a017f53e89ca8209b4c0f971b032709f0a8228",
"contract/contract.go": "e496e7a57a6e97c482d25bb7ad6c9d07186328d97095f4bdca84965956bd9912",

View File

@@ -12,7 +12,7 @@
- 独立用户示例:nethttp/native 共 30 项业务、存储和生命周期检查通过。
- MySQL 真实事务:提交、错误回滚、panic 回滚、context 取消回滚;业务增/改/删在审计写入失败时整体回滚。
- 通过 CLI 显式应用数据库迁移;数据库停机 readiness=503,恢复后重新就绪;进程重启数据仍在。
- 28 份开发者拥有的文件在重生成后保持原字节,包括业务实现、配置、迁移和测试。
- 本地初轮 28 份开发者拥有的文件在重生成后保持原字节,包括业务实现、配置、迁移和测试。
- 存储初始化失败关闭此前打开的数据库连接,Close 可重复调用,关闭后不能重开存储。
数据库容器只绑定 localhost,限制 1 CPU / 768 MiB,使用随机临时密钥,退出删除容器及卷。
@@ -27,5 +27,17 @@
## 远程版本验收
发布标签后执行 `python3 scripts/verifyAPIWorkflow.py --version v0.7.0-preview --report performance/apiWorkflowRemote.json`。
结果完成后记录于同目录 remote 报告;该检查从空 module cache 安装 CLI,并要求业务依赖没有本地 replace。
已发布 `v0.7.0-preview`,标签指向 `0ce3efb6fca77951e7ae3046128f82afc28446d1`。
执行 `python3 scripts/verifyAPIWorkflow.py --version v0.7.0-preview --report performance/apiWorkflowRemote.json` 通过:
- 从空 module cache 下载并安装远程 CLI,未使用源码构建的 CLI。
- 在全新目录生成 MySQL + local 项目;go.mod 固定 `v0.7.0-preview`,无本地 replace,实际编译通过。
- 独立用户示例的框架依赖从远程下载,module checksum 为 `h1:aHdKHoegW2C7sjkq5D8ls2sxDJ+fWbiZ2UnWDers1yg=`。
- nethttp/native 共 30 项业务、存储、日志与生命周期检查通过;真实数据库迁移、事务和启动清理测试通过。
- 最终示例补充了数据库错误日志边界:原始 driver 错误可能含业务输入,因此返回到 HTTP 访问日志的 500 不携带原文;用单元测试和远程全流程验证。
- 框架发布标签保持不变;示例是独立 module,后续示例和验收报告维护在 main。报告记录了实际测试示例与生成器源码哈希。
[远程验收报告](apiWorkflowRemote.json) 包含准确的文件保护数量、sourceHashes 和各项检查结果。
所有验收已结束,临时服务、数据库容器和测试目录由脚本清理。

View File

@@ -0,0 +1,152 @@
{
"passed": true,
"frameworkVersion": "v0.7.0-preview",
"noLocalReplace": true,
"durationSeconds": 33.0,
"regenerationPreservedHandwrittenFiles": 29,
"groups": [
{
"transport": "nethttp",
"checks": [
"authBeforeService",
"emptyPageAndQueryDefaults",
"contractValidation",
"createReadNormalize",
"uniqueEmailConflict",
"updateAndPagination",
"createUpdateDeleteRollbackOnAuditFailure",
"storageUploadAndSignedDownload",
"signedDownloadTamperRejected",
"openAPIMatchesContract",
"readinessOutageAndRecovery",
"persistenceAfterServiceRestart",
"deleteAndTransactionalAudit",
"gracefulStopAndListenerRelease",
"businessFileLogsAndSecretExclusion"
]
},
{
"transport": "native",
"checks": [
"authBeforeService",
"emptyPageAndQueryDefaults",
"contractValidation",
"createReadNormalize",
"uniqueEmailConflict",
"updateAndPagination",
"createUpdateDeleteRollbackOnAuditFailure",
"storageUploadAndSignedDownload",
"signedDownloadTamperRejected",
"openAPIMatchesContract",
"readinessOutageAndRecovery",
"persistenceAfterServiceRestart",
"deleteAndTransactionalAudit",
"gracefulStopAndListenerRelease",
"businessFileLogsAndSecretExclusion"
]
}
],
"transactionTests": [
"commit",
"errorRollback",
"panicRollback",
"cancellationRollback"
],
"startupTests": [
"storageFailureClosesDatabase",
"idempotentClose",
"closedContextCannotReopen"
],
"migrationCLI": true,
"frameworkSum": "h1:aHdKHoegW2C7sjkq5D8ls2sxDJ+fWbiZ2UnWDers1yg=",
"sourceHashes": {
"cmd/go-omni/api.go": "59f3b8b45497a9f560d649ac5e7b622362f5f20edd81251d7dc76f21605fc90a",
"cmd/go-omni/api_test.go": "ee68b4b8b0155f4d123135b0706c6d17d8fc90e2bcfc1312aa4f9e7a9da2454b",
"cmd/go-omni/apigen/apigen_test.go": "8e7c94795ffbc3ee062756a77cfeb274f3cd1a78f1dbc3b51ec4ceb14f478518",
"cmd/go-omni/apigen/compile.go": "6709da0b69e8d10d49a874447e00c93d0a819d8367b2188e9626bd0c7b3baef2",
"cmd/go-omni/apigen/generate.go": "373ec9834aa4ed71a5311c1ef40b78dc6c303aa884d7bb16a680058ca751b2bc",
"cmd/go-omni/apigen/parse.go": "e85fe261fdec2e3487851e265babd7af8c7f6153694ad6fb73177aac33f8ef95",
"cmd/go-omni/apigen/profile.go": "877939398384c44316e5e052d92c4a93fb3b74a6742df2414a0054913f3fca27",
"cmd/go-omni/apigen/profile_test.go": "5f3c091da241fe0f8b99ac19e9efd2c3177aea72e63a346f99fecc0f548aad1a",
"cmd/go-omni/apigen/templates.go": "9303a2fafe44cb7e763a2c5beb193c16fa0dc0c2dbde4cb6e14f2ba533d3e56e",
"cmd/go-omni/apigen/write.go": "1ce3871fd92f638d097c576583c169daa0ce2fa8010dbcd34a8cd7faa314a1a2",
"cmd/go-omni/main.go": "6f851d21792a9eb9fcbc5095e8cacb3dcee1c715a3980650c75381f0e46f51a9",
"cmd/go-omni/main_test.go": "b8040d2b19a1c86672fe1b1be7fa1bf7f885aece79ded6ffa0cc670887b7136f",
"cmd/go-omni/migrate.go": "f1ce89fbe419205eb82172da2b90e8888905d2b85d1a9db2a72b568bcf849a70",
"cmd/go-omni/migrate_test.go": "15d9a4aaebf9f899e523e908f1fa3c2282eb1e6c8cadbefae6b68f1ae4e00e6e",
"cmd/go-omni/scaffold/addModule.go": "8f43bd68c2b3820cf1d036c4f2c5f9d06d1c7c8e5114f5081597d905f8d8cdcc",
"cmd/go-omni/scaffold/addModule_test.go": "667c45312f628f0fb5b8206ddef7ccc02a75f33b72f73fd6a17b03da0cb62e91",
"cmd/go-omni/scaffold/apiInfrastructure.go": "f493364448254cae2d8e658bf5c69e96a3264c2bf3fa50fd48a5ebdb20edabd8",
"cmd/go-omni/scaffold/moduleProfiles_test.go": "ae9790e5d3549d32fc72739a10a31ea858e5a8f8189cf1f5c1840bf80b3f8ad6",
"cmd/go-omni/scaffold/moduleTemplates/README.md.tmpl": "9d3bfc46cac5618bcff7d8a6b2cc9f123e818aeb3dee9bcdfe0f91db584994bd",
"cmd/go-omni/scaffold/moduleTemplates/handler-handler.go.tmpl": "729cd0f6faba53d5d2c1492935e12ac2adcefde4490748e25c18be7ccb3f7139",
"cmd/go-omni/scaffold/moduleTemplates/model-model.go.tmpl": "a005611f6b455d4d8469e316bc26fa571043b2da1834db2b3dd14a96d66221d7",
"cmd/go-omni/scaffold/moduleTemplates/module.go.tmpl": "fb0be7fd571ba2c0edb01d70d68cdbbfe53561caab98346bf1ac7cd8df07205a",
"cmd/go-omni/scaffold/moduleTemplates/repository-repository.go.tmpl": "902da3d9f12395fb226178c4428ff547e1c2fce3261882dba98d1400d8c90747",
"cmd/go-omni/scaffold/moduleTemplates/service-service.go.tmpl": "49d26044a69a43e983aa5ba4b5b2399b26b77898b23e230751ad1372c2af3178",
"cmd/go-omni/scaffold/scaffold.go": "71b3091b83c3e45ac1981164e8c819c997ad96607d535f1492333370f4872548",
"cmd/go-omni/scaffold/scaffold_test.go": "58aab9251b01e394bbc90aeb538ff035dd32c73c0d4815132ac6cbc086527cda",
"cmd/go-omni/scaffold/templates/README.md.tmpl": "8ec215775fcafd437480022307fffd2ffe43685bc2b5d328b0c1d58402986e0a",
"cmd/go-omni/scaffold/templates/api-context.go.tmpl": "d2d67c3d235c4b1c965364f40f49c441d92764ad7ab8fd6848f56b1028e93b1b",
"cmd/go-omni/scaffold/templates/cmd-server-main.go.tmpl": "07c4f81109ece43fcf15bb807ada5ae6dc7e732babc7293269514a21cd389955",
"cmd/go-omni/scaffold/templates/databaseIntegration_test.go.tmpl": "81c4f7fbc919d74b738d3a7338248cbd83cfa0803b2058e8ee184e3ce51e7e9b",
"cmd/go-omni/scaffold/templates/go.mod.tmpl": "7d3bb15d2292e36939cc1905258d2800aeaf0e37d7d041b2ff8cfdb6fc84c57a",
"cmd/go-omni/scaffold/templates/internal-handler-user.go.tmpl": "7f8dbcd2f9b0129a90160d6059e47d19d4758274060a1a3e5160055d0592d34f",
"cmd/go-omni/scaffold/templates/internal-model-errors.go.tmpl": "f123eace52952321b0665a92a2923514c52c8c3bf13df1065710e9675750f837",
"cmd/go-omni/scaffold/templates/internal-model-user.go.tmpl": "b391143e7386e169b53e7fed25de1146e8c52f3fa287b12728e8d22b33f386de",
"cmd/go-omni/scaffold/templates/internal-repository-memory.go.tmpl": "148abc273c1fab3f28536706792762ae41e8687dfe9986c374372f162600feac",
"cmd/go-omni/scaffold/templates/internal-repository-mysql.go.tmpl": "34bd333e29211a0025ff6ebaea18c7f142f18f1cc451d692f9d360e8c8f6f635",
"cmd/go-omni/scaffold/templates/internal-repository-users.go.tmpl": "6d7dd80f34a9c215daeb4a86bfbe11e845e7ff7e9864c8af43e1c2ec3a8b28ec",
"cmd/go-omni/scaffold/templates/internal-service-user.go.tmpl": "33fdee3a2c3d98e7ca6a323b5e888a73582f94e327b6c24a34c09bf180db7b3d",
"cmd/go-omni/scaffold/templates/internal-servicecontext-context.go.tmpl": "de70ecc4194db8a2da5d65297f452a40b453652cb814cd228ce03d465761c856",
"cmd/go-omni/scaffold/templates/modules-README.md.tmpl": "4b84fbc2310b856d86c6fcd9a47d97d2261019c74ea8e0cdbc41229086aa3527",
"cmd/go-omni/scaffold/templates/modules-config.yaml.tmpl": "b8734080ae6d533a175bcad946ce2cb55d8bac1729918291753b9ca03064910f",
"cmd/go-omni/scaffold/templates/modules-filesHandler.go.tmpl": "426867fbef5b99a85394fa4457dd38b9a194adf4bdfb293384b2fbbc799091da",
"cmd/go-omni/scaffold/templates/modules-filesHandler_test.go.tmpl": "9571e39bd0a330739eb1fd7e636cd58961b41b44772b2b49162e41deda98547f",
"cmd/go-omni/scaffold/templates/modules-filesService.go.tmpl": "6460ee9a873af45ca6cec779bf53302acfce21499610425a4704c2f179eddb8f",
"cmd/go-omni/scaffold/templates/modules-gitignore.tmpl": "2579f2a63ff2962239b92dd841b0d0d26e783d85c9d0100011936c8bcbf5d0d3",
"cmd/go-omni/scaffold/templates/modules-main.go.tmpl": "cb8daddd4e12afd238540be7022187c74a548d9e409b3a7f1eaf84a9139218bf",
"cmd/go-omni/scaffold/templates/modules-settings.go.tmpl": "91084caaa1fd994dde714f52b242d23e41adb731383b12d74399247c87ac5597",
"cmd/go-omni/scaffold/templates/modules-settings_test.go.tmpl": "76e55f8954b7606d7146eb1e965065c521a2b81234a47742ed69a93aac300406",
"cmd/go-omni/scaffold/templates/modules-storage.go.tmpl": "a775acf2d2490288a8e5482bb0a8769be6b0a8c9cfcc9bb10b62aabbd05fa0c7",
"cmd/go-omni/scaffold/templates/modules-storage_test.go.tmpl": "dacca42d228bd9352948054eb2d071829a8a61d8c6fa24d95ff00fc39a74309e",
"cmd/go-omni/scaffold/templates/schema.sql.tmpl": "7c5108f18fc7d8ff75c97afd96fcaa9f8ed026c79c71c5612f55572d73354abb",
"cmd/go-omni/scaffold/version.go": "2ae216e676082a26e5778045e20964eb4f9e99a58d4f9a9ff4b559b286837d7a",
"database/configOverlay_test.go": "01c9b44fded226494ca12f7d0f41a9ff417d8135d263a5833727c1c028b6b8e3",
"database/database.go": "3d8615e1bf58ecdae21558591e745c241e499ba6e85fc07acfa2344e04ce95db",
"database/database_test.go": "18eac1e4c6dbd6a09216168839ba3511aac8a38c2ddf6fdd5267f35435b09f67",
"database/lifecycle_test.go": "e141b52edfb9867184dedac0e79feed56a5b137acbe64280ad69647653bbea21",
"database/migrate/migrate.go": "fd6cd27d2dce5b35ffe80eb5442c47c657b93ffc6545bbcc1ee4651a70a4f80b",
"database/migrate/migrate_test.go": "d7ba91eed8cfd97e4ef0741d582f493dd411b65ad3192f05b167f75d9b9dcf90",
"examples/userService/api/user.api": "cdbc7f356f336b6265e9c3fc9974952e05cf14001c58b330a826b4bb32f02712",
"examples/userService/cmd/server/main.go": "80ba4721f677947b7f2ece21d5f3e89ee0c4d2cc653412d83aef2b3a45bd3ccb",
"examples/userService/etc/config.yaml": "f50df3cddbc3c23f233405e7a9d8895b9d83d2a0607ccf001261fcd4b8978ee9",
"examples/userService/internal/handler/openapiGen.go": "8ddc60c338630db915b9f074ade776f526eabf60fc99b33e425359fd415c4926",
"examples/userService/internal/handler/routesGen.go": "7f45030e7eb9f055c4b4d2ff12ad855d7c726aad4aee63c5b3c3f97559a64257",
"examples/userService/internal/handler/user/handlersGen.go": "06f3c2798ff323812ff3eb2d8b933a569af3ce1b86a67b66036374add98c0d7e",
"examples/userService/internal/middleware/auth.go": "f12197d01da155ad56a8a44f951c83739cbfca48c3224bd52a7bc25357f05c9f",
"examples/userService/internal/middleware/auth_test.go": "6ffdd7c7b2ede7e5791eec0eb1d9105ea45b3a52cc3140fe7a94fdc9b6fb3ec6",
"examples/userService/internal/middleware/registryGen.go": "a6122a5453dedc6a4415860108814c50e155128cfe5412b39dd33ce2d01b4b00",
"examples/userService/internal/model/errors.go": "f123eace52952321b0665a92a2923514c52c8c3bf13df1065710e9675750f837",
"examples/userService/internal/model/user.go": "b391143e7386e169b53e7fed25de1146e8c52f3fa287b12728e8d22b33f386de",
"examples/userService/internal/repository/user/mysql.go": "924cae233a68e506e9b0b953529e0ccb13a0e71d24723309383c464e1852b833",
"examples/userService/internal/repository/user/repository.go": "2aca07788d8d59831b10ac665882ca8157b20f384115bb90742f235f8352a69d",
"examples/userService/internal/repository/user/store.go": "f776832e4f3b26fb889dad53b65742833ddb7f43b7dddc72915ad3fe47ec4d3e",
"examples/userService/internal/repository/user/transaction_test.go": "9af4cb3a03b71026b80aa5078ddda4e3c4f72a7ab14bed6c87603a4db2cc2a22",
"examples/userService/internal/service/user/createUser.go": "53d0ea7371e10a0bd3f32f57b9bff9b48b5f9a3315218f95a258fa45b81d9368",
"examples/userService/internal/service/user/deleteUser.go": "0cd0b038d5a11429de438311980ef19a8f6e28ea0d7cf5ebaaf78858daf3a124",
"examples/userService/internal/service/user/errors_test.go": "c0193bdc674cbe15c4871a91e288323a99193d19ba8fd874f9bf17c390917e5c",
"examples/userService/internal/service/user/exportUser.go": "dac2adb0c9ea43a4af15511ae488afaa6d9c9b351e68e9b8a54f4014d29de14d",
"examples/userService/internal/service/user/getUser.go": "a307fa716c70286a2a6c5d5c0a6c35ec6d258c3278e2a7fe86b74b324d7b7283",
"examples/userService/internal/service/user/listUsers.go": "f0bf8b9c3a4c4a699112e3493b7fe611e2dd4f6e8a4437106396c47d484f1383",
"examples/userService/internal/service/user/service.go": "fabaac1e330364f899f5c580454051054c715c56f37983155108c185fe7f2637",
"examples/userService/internal/service/user/updateUser.go": "9b0e3e4296c0e0df98956961a928612d9203c0fa09aa71bb0683e430e1133ff0",
"examples/userService/internal/servicecontext/context.go": "5527ca07049f5c5f5deffa65e5759494b2e0a77804b629b3ecb1705e7557ee25",
"examples/userService/internal/servicecontext/context_test.go": "04d008b6cf5b7894752bc5222abb8f4120f1ebe060034ac55ec9663c1f7bc613",
"examples/userService/internal/servicecontext/storage.go": "9402e1ee7a258b2db4b6dbf38fcb0674ad8907fb2a948d47dd8374f78bec444d",
"examples/userService/internal/settings/config.go": "721c3744a61a2d2807edcdfa190336901368966a0fb952561d0e00da5730cd3a",
"examples/userService/internal/types/typesGen.go": "3ab2ce9ffe467c3ea53ac3d1fec283ecebc87cef57fd592d08dca991fd964876",
"examples/userService/migrations/000001_users.down.sql": "64b47d3d2644745a2ba1197b73760e63fe0f2c1d12c193888c4417b3d5abb75e",
"examples/userService/migrations/000001_users.up.sql": "c6ea5cb92ee53fe20e43373d084ff360d49921b785602145e55380e4adbee1dc"
}
}

View File

@@ -0,0 +1,11 @@
Command: GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=2 -count=1 . ./response ./config ./httpx ./openapi ./cmd/go-omni/...
Exit status: 0
ok git.yuntuo.hk/k2safe/go-omni 1.535s
ok git.yuntuo.hk/k2safe/go-omni/response 1.607s
ok git.yuntuo.hk/k2safe/go-omni/config 1.248s
ok git.yuntuo.hk/k2safe/go-omni/httpx 1.330s
ok git.yuntuo.hk/k2safe/go-omni/openapi 1.236s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 1.895s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen 31.378s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold 37.686s

View File

@@ -0,0 +1,123 @@
{
"passed": true,
"durationSeconds": 4.4,
"cases": [
{
"project": "starter",
"transport": "nethttp",
"checks": [
"readiness",
"servedOpenApiMatchesFile",
"greeting",
"requestId",
"gracefulShutdown",
"listenerReleased"
],
"passed": true
},
{
"project": "starter",
"transport": "native",
"checks": [
"readiness",
"servedOpenApiMatchesFile",
"greeting",
"requestId",
"gracefulShutdown",
"listenerReleased"
],
"passed": true
},
{
"project": "contract",
"transport": "nethttp",
"checks": [
"readiness",
"servedOpenApiMatchesFile",
"mixedSources",
"zeroAndFalse",
"pathRange",
"defaultAndQuery",
"requiredFields",
"unknownFields",
"duplicateScalar",
"trailingJson",
"rangeValidation",
"bodyLimit413",
"auth401",
"status201",
"status202",
"contentType415",
"middlewareComposition",
"noContent204",
"gracefulShutdown",
"listenerReleased",
"businessLogCorrelation"
],
"passed": true
},
{
"project": "contract",
"transport": "native",
"checks": [
"readiness",
"servedOpenApiMatchesFile",
"mixedSources",
"zeroAndFalse",
"pathRange",
"defaultAndQuery",
"requiredFields",
"unknownFields",
"duplicateScalar",
"trailingJson",
"rangeValidation",
"bodyLimit413",
"auth401",
"status201",
"status202",
"contentType415",
"middlewareComposition",
"noContent204",
"gracefulShutdown",
"listenerReleased",
"businessLogCorrelation"
],
"passed": true
}
],
"sourceSha256": {
"cmd/go-omni/api.go": "d313526cf9f67f6b1a36260748f2c21bf53c19a01c41f640386cad08bb5538b1",
"cmd/go-omni/apigen/apigen_test.go": "7cbe654e84c60f7b7f3a9631472bb66c1999ff4a9eaacaec224d26e78d08a38c",
"cmd/go-omni/apigen/compile.go": "cc801f744ed18ffd34a9cecc1b01d869ccb609448f7fcb705fbf5a7183637c25",
"cmd/go-omni/apigen/contractMetadata.go": "a7dcacde64d95eff32b0a93e7d87b60cdc7f9b4056587a77a1f20f5a9ef5231e",
"cmd/go-omni/apigen/declarations.go": "3f8073fb0b0bfa775f7712ed28e670f8dc98571636656726e18a6d592692bc81",
"cmd/go-omni/apigen/generate.go": "93c028e31bd59b50e57ab59826366a2adbc621cccda545bde065116a40d8d905",
"cmd/go-omni/apigen/metadata_test.go": "ab0c55e90e9b7a8bdaf260415e4c8564f6b2378de625fd9379a2b801cb4bc0e5",
"cmd/go-omni/apigen/parse.go": "73576fca857a94c21ffb3636ffb318297f10f6c41f67949ce37c0b9415bbb78d",
"cmd/go-omni/apigen/profile.go": "877939398384c44316e5e052d92c4a93fb3b74a6742df2414a0054913f3fca27",
"cmd/go-omni/apigen/profile_test.go": "5f3c091da241fe0f8b99ac19e9efd2c3177aea72e63a346f99fecc0f548aad1a",
"cmd/go-omni/apigen/projectEntry.go": "817936d391ec7dc76f7b5b9f7d134537947ea238d2cd281eb3df536511cb5486",
"cmd/go-omni/apigen/response.go": "f22f8d76663243cea4af5c0f043981fe577ad74155c7436f8a14f8cd31095cf2",
"cmd/go-omni/apigen/response_test.go": "fdd496b31bbfcebcabaf1b68f5e149a2a3b9b02e6d193b2c9260213301a63f4a",
"cmd/go-omni/apigen/templates.go": "36b4946acc636960bf7ca1d95ab364135432392829c12be523ef6b514e433f63",
"cmd/go-omni/apigen/write.go": "a995ffd412a98620dd9db5244433abb3a44a20ae7f09513e710ab6d4c52a78eb",
"cmd/go-omni/main.go": "060b064973f2316b6f01c7b9a716d57c3dbc8ad33a5d6153f6cfddaf649cd5fc",
"contract/README.md": "ff3b56398cf676df401d84d1169dfff19fc83ee074b07fb4c16fdf5af2e8fe17",
"contract/bind.go": "8a6be9046c9a489a4bed78bca1a017f53e89ca8209b4c0f971b032709f0a8228",
"contract/contract.go": "e496e7a57a6e97c482d25bb7ad6c9d07186328d97095f4bdca84965956bd9912",
"contract/contract_test.go": "0b651bb4305baed8b78ac764786af11e3538858783c6e117c7294fde50ec0873",
"contract/rules.go": "3a8df741b4c5074fe8589acaf5bb584b9b779afbe0412f4ef1b8315c222706b7",
"examples/contracts/README.md": "a7ebe6983c8d89d8a745f924f3d6fb3aba72d31dccd197112c18f50bd17456a9",
"examples/contracts/responses.api": "4018af67935963cdc9f847c5bc0c5106b0ad91d1f0de73ff893102f356909213",
"examples/contracts/shared.api": "cb0096bb1565916b13ae8d2a0bdad1e2ffe7410a417d25ac347e67c7b9d77dfc",
"examples/contracts/user.api": "59b98806a142b65f782e83a60f0723bc554833cec113f1c7fb20e09540870ae8",
"openapi/envelope.go": "298d891d8ed79321d854db87c601f7dd1c2c2f5955eb67844c5905186dd63976",
"openapi/openapi.go": "914a38458aa54da6130ea2df546a5724a7e237690a361d8dd920aec1155c6b5f",
"openapi/openapi_test.go": "c92f4a2f548f0304708ac157402fb8641273473e04c524226f2429c2b64b99ab",
"openapi/schemaJSON.go": "f400cbba429cd02dc4f5bc70810a99c5d5841fc2995e6380e5de7c0e1f79d3b0",
"openapi/schemaJSON_test.go": "e0e1c7c9380350e67d8e209ba4041e3e52fe2bd86af829ff770affa8814e9c28",
"scripts/verifyAPI.py": "8fb41860ccc4320b64e26dbb001a8bae588b95641332e84a34f2d7f00fa86c3e",
"transport/native/earlyReject_test.go": "58c7b67d7c2dbb8867dec6075b891acb2c1bbbf0ad886fa0cb061c7c5ba95b0e",
"transport/native/server.go": "3b2b33d2a4c0a25034f61c54797bd9e143d994b48e6a689bb3ebd2b8c08050ce"
}
}

View File

@@ -0,0 +1,41 @@
# 公共响应模板验收
版本 `v0.8.0-preview`。本报告记录实现验收与发布验证,旧版本标签保持不变。
## 实现范围
- `.api` 定义具名公共响应体,可跨文件导入、分组引用、接口覆盖;业务类型放入 `${data}`。支持嵌套 JSON、独立 errorBody、固定字段与类型保持的占位符。
- 数字/字符串错误码、默认码、按逻辑码映射、消息映射、null/省略行为可配置;OpenAPI 同步结构、类型和真实错误示例。
- `@response raw` 成功返回原始业务 JSON,错误继承组级模板。文本、XML及特殊状态的回调通过 Go 编码器实现,不冒充 `.api` 已支持任意媒体类型。
- 应用之间的配置独立;创建/快照深复制;空对象模板序列化后保持有效,非法占位符、配置冲突、循环和过深结构拒绝。
- 接口策略在全局中间件前设置;提前拒绝、panic、Context 克隆和错误边界使用正确策略。显式 JSON/Text/XML/Bytes/NoContent 绕过包装,已提交响应不追加内容。
- 内置路由 404/405 使用应用默认格式。网络层进入应用前的 TLS/HTTP协议错误仍由 transport 处理。
## 验证结果
1. `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...`:40 个有测试的包全部通过。
2. 首轮 `-p=2` 的原有 S3 fixture 测试遇到 10 秒 deadline;单包复跑通过,随后上述串行全量复跑通过,未修改 S3 实现或放宽断言。
3. 专项集成生成临时消费者项目,执行真实生成路由和业务 Service:具名公共体、数字 401、路由覆盖、raw JSON、错误文档,以及重复生成业务文件保护通过。
4. App 在 nethttp/native 的真实 TCP 请求中验证配置响应、callback panic 和超时;并发应用配置隔离、克隆、全局中间件提前拒绝及已提交响应保护通过。
5. 原有 API 实际 HTTP 回归 54 项通过:starter 两种 transport 各 6 项,contract 两种 transport 各 21 项;见 [回归报告](responsePoliciesAPI.json)。
6. `go vet ./...`、`go build ./...`、示例 `.api` validate/inspect、差异空白检查通过。既有用户示例重新生成及 `--check` 通过,28 份原有业务文件保持不变;go.mod 显式升级目标版本并继续使用本地 replace。
完整框架记录见 [race 测试](responsePoliciesTests.txt)。本轮仅使用本机临时目录和回环监听,没有连接业务服务器,没有执行远程云存储/数据库验收或压力长测。远程模块下载验收需发布后另行完成。
## 使用入口
[公共响应说明](../RESPONSE.md) 与 [可生成的 .api 示例](../examples/contracts/responses.api)。远程使用请安装同版本 CLI 和框架;源码开发可带 `--framework-dir`。
## 后续:类型名与业务字段统一
`codeType` 新增 `int32` / `int64`,保留 `string` 与旧 `integer`;int32/int64 使用各自有符号整数范围,默认码和映射都校验。OpenAPI 为 integer + 对应 format。配置 JSON/YAML、生成项目的 int64 数值保留完整精度。
本次增量运行根包、response、config、httpx、openapi、CLI/apigen/scaffold 共 8 个包的 race 测试及生成消费者集成,全部通过。覆盖边界/越界、小数拒绝、int64 最大值真实输出、配置精度、平铺/嵌套文档和旧 integer 兼容。vet、全模块 build、示例 validate 与差异检查通过;见 [类型测试记录](responseCodeTypesTests.txt)。上方全量及 HTTP 报告是此前公共响应体验收,此次未重复远程或数据库验收。
## v0.8.0-preview 封版前验证
- 最终代码全量 race 通过:40 个有测试的包;包括源码 CLI 的生成消费者、完整 int64 输出和模板配置回归,见 [封版测试记录](responseReleaseTests.txt)。
- `go vet ./...`、`go build ./...`、格式检查、差异检查和 userService `api generate --check` 通过。
- 本机隔离 MySQL 下,nethttp/native 共 34 项 HTTP、业务和生命周期检查通过;数据库事务提交/错误回滚/panic回滚/取消回滚、初始化清理和迁移通过。
- 重生成保留 29 份开发者文件,见 [封版本地业务报告](responsePoliciesWorkflowLocal.json)。临时容器已清理。
- 发布后将使用 `verifyResponseRelease.py` 和 `verifyAPIWorkflow.py --version v0.8.0-preview` 验证真实远程 CLI、无本地 replace 的模块、公共模板及完整业务流程。标签不随验收记录变动。

View File

@@ -0,0 +1,55 @@
Command: GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...
Exit status: 0
ok git.yuntuo.hk/k2safe/go-omni 1.501s
ok git.yuntuo.hk/k2safe/go-omni/app 1.947s
ok git.yuntuo.hk/k2safe/go-omni/audit 1.275s
ok git.yuntuo.hk/k2safe/go-omni/auth 1.313s
ok git.yuntuo.hk/k2safe/go-omni/cache 1.430s
ok git.yuntuo.hk/k2safe/go-omni/cache/redis 1.452s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 2.065s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen 30.496s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold 40.140s
ok git.yuntuo.hk/k2safe/go-omni/config 1.310s
ok git.yuntuo.hk/k2safe/go-omni/contract 1.321s
ok git.yuntuo.hk/k2safe/go-omni/database 1.371s
ok git.yuntuo.hk/k2safe/go-omni/database/migrate 1.740s
? git.yuntuo.hk/k2safe/go-omni/errors [no test files]
? git.yuntuo.hk/k2safe/go-omni/examples/basic [no test files]
ok git.yuntuo.hk/k2safe/go-omni/examples/modules 1.332s
ok git.yuntuo.hk/k2safe/go-omni/examples/platform 1.415s
ok git.yuntuo.hk/k2safe/go-omni/examples/reference 1.482s
? git.yuntuo.hk/k2safe/go-omni/examples/reference/handler [no test files]
? git.yuntuo.hk/k2safe/go-omni/examples/reference/model [no test files]
ok git.yuntuo.hk/k2safe/go-omni/examples/reference/repository 1.346s
? git.yuntuo.hk/k2safe/go-omni/examples/reference/service [no test files]
ok git.yuntuo.hk/k2safe/go-omni/health 1.320s
ok git.yuntuo.hk/k2safe/go-omni/httpc 1.466s
ok git.yuntuo.hk/k2safe/go-omni/httpx 1.312s
ok git.yuntuo.hk/k2safe/go-omni/internal/perfcheck 1.357s
? git.yuntuo.hk/k2safe/go-omni/internal/storagefixture [no test files]
? git.yuntuo.hk/k2safe/go-omni/internal/testutil [no test files]
ok git.yuntuo.hk/k2safe/go-omni/logx 1.573s
ok git.yuntuo.hk/k2safe/go-omni/metrics 1.305s
ok git.yuntuo.hk/k2safe/go-omni/middleware 1.418s
ok git.yuntuo.hk/k2safe/go-omni/middleware/redis 1.388s
ok git.yuntuo.hk/k2safe/go-omni/openapi 1.303s
ok git.yuntuo.hk/k2safe/go-omni/pagination 1.286s
? git.yuntuo.hk/k2safe/go-omni/queue [no test files]
ok git.yuntuo.hk/k2safe/go-omni/queue/redis 1.285s
ok git.yuntuo.hk/k2safe/go-omni/resilience 1.287s
ok git.yuntuo.hk/k2safe/go-omni/response 1.321s
ok git.yuntuo.hk/k2safe/go-omni/storage 1.331s
ok git.yuntuo.hk/k2safe/go-omni/storage/local 1.325s
ok git.yuntuo.hk/k2safe/go-omni/storage/oss 1.440s
ok git.yuntuo.hk/k2safe/go-omni/storage/s3 10.162s
? git.yuntuo.hk/k2safe/go-omni/storage/storagetest [no test files]
ok git.yuntuo.hk/k2safe/go-omni/task 1.364s
ok git.yuntuo.hk/k2safe/go-omni/telemetry 1.320s
ok git.yuntuo.hk/k2safe/go-omni/transport 1.314s
ok git.yuntuo.hk/k2safe/go-omni/transport/http2 12.547s
? git.yuntuo.hk/k2safe/go-omni/transport/internal/routing [no test files]
ok git.yuntuo.hk/k2safe/go-omni/transport/native 2.016s
ok git.yuntuo.hk/k2safe/go-omni/transport/nethttp 1.305s
? git.yuntuo.hk/k2safe/go-omni/transport/transporttest [no test files]
? git.yuntuo.hk/k2safe/go-omni/validate [no test files]

View File

@@ -0,0 +1,164 @@
{
"passed": true,
"frameworkVersion": "local",
"noLocalReplace": false,
"durationSeconds": 20.9,
"regenerationPreservedHandwrittenFiles": 29,
"groups": [
{
"transport": "nethttp",
"checks": [
"authBeforeService",
"readOnlyPermission403",
"emptyPageAndQueryDefaults",
"contractValidation",
"createReadNormalize",
"uniqueEmailConflict",
"updateAndPagination",
"createUpdateDeleteRollbackOnAuditFailure",
"storageUploadAndSignedDownload",
"signedDownloadTamperRejected",
"openAPIMatchesContract",
"declaredAuthAndBusinessErrorDocumentation",
"readinessOutageAndRecovery",
"persistenceAfterServiceRestart",
"deleteAndTransactionalAudit",
"gracefulStopAndListenerRelease",
"businessFileLogsAndSecretExclusion"
]
},
{
"transport": "native",
"checks": [
"authBeforeService",
"readOnlyPermission403",
"emptyPageAndQueryDefaults",
"contractValidation",
"createReadNormalize",
"uniqueEmailConflict",
"updateAndPagination",
"createUpdateDeleteRollbackOnAuditFailure",
"storageUploadAndSignedDownload",
"signedDownloadTamperRejected",
"openAPIMatchesContract",
"declaredAuthAndBusinessErrorDocumentation",
"readinessOutageAndRecovery",
"persistenceAfterServiceRestart",
"deleteAndTransactionalAudit",
"gracefulStopAndListenerRelease",
"businessFileLogsAndSecretExclusion"
]
}
],
"transactionTests": [
"commit",
"errorRollback",
"panicRollback",
"cancellationRollback"
],
"startupTests": [
"storageFailureClosesDatabase",
"idempotentClose",
"closedContextCannotReopen"
],
"migrationCLI": true,
"frameworkSum": null,
"sourceHashes": {
"cmd/go-omni/api.go": "d313526cf9f67f6b1a36260748f2c21bf53c19a01c41f640386cad08bb5538b1",
"cmd/go-omni/api_test.go": "172e4e2ba4984820ba729f88273b1ae133415081344e51c3654f297ef33216d4",
"cmd/go-omni/apigen/apigen_test.go": "7cbe654e84c60f7b7f3a9631472bb66c1999ff4a9eaacaec224d26e78d08a38c",
"cmd/go-omni/apigen/compile.go": "cc801f744ed18ffd34a9cecc1b01d869ccb609448f7fcb705fbf5a7183637c25",
"cmd/go-omni/apigen/contractMetadata.go": "a7dcacde64d95eff32b0a93e7d87b60cdc7f9b4056587a77a1f20f5a9ef5231e",
"cmd/go-omni/apigen/declarations.go": "3f8073fb0b0bfa775f7712ed28e670f8dc98571636656726e18a6d592692bc81",
"cmd/go-omni/apigen/generate.go": "93c028e31bd59b50e57ab59826366a2adbc621cccda545bde065116a40d8d905",
"cmd/go-omni/apigen/metadata_test.go": "ab0c55e90e9b7a8bdaf260415e4c8564f6b2378de625fd9379a2b801cb4bc0e5",
"cmd/go-omni/apigen/parse.go": "73576fca857a94c21ffb3636ffb318297f10f6c41f67949ce37c0b9415bbb78d",
"cmd/go-omni/apigen/profile.go": "877939398384c44316e5e052d92c4a93fb3b74a6742df2414a0054913f3fca27",
"cmd/go-omni/apigen/profile_test.go": "5f3c091da241fe0f8b99ac19e9efd2c3177aea72e63a346f99fecc0f548aad1a",
"cmd/go-omni/apigen/projectEntry.go": "817936d391ec7dc76f7b5b9f7d134537947ea238d2cd281eb3df536511cb5486",
"cmd/go-omni/apigen/response.go": "2448a09472ebe87c57c2dd48ccae1691e94f8cd029fcae72a85490a0405907e3",
"cmd/go-omni/apigen/response_test.go": "cb8daee5eacd5d158837283be41c39d4245bb2db7a4983800cdb809918b898ee",
"cmd/go-omni/apigen/templates.go": "f5ed976109c2a227329635644774c24cc8f34caede86dc0dabb7295845352803",
"cmd/go-omni/apigen/write.go": "a995ffd412a98620dd9db5244433abb3a44a20ae7f09513e710ab6d4c52a78eb",
"cmd/go-omni/main.go": "060b064973f2316b6f01c7b9a716d57c3dbc8ad33a5d6153f6cfddaf649cd5fc",
"cmd/go-omni/main_test.go": "b8040d2b19a1c86672fe1b1be7fa1bf7f885aece79ded6ffa0cc670887b7136f",
"cmd/go-omni/migrate.go": "f1ce89fbe419205eb82172da2b90e8888905d2b85d1a9db2a72b568bcf849a70",
"cmd/go-omni/migrate_test.go": "15d9a4aaebf9f899e523e908f1fa3c2282eb1e6c8cadbefae6b68f1ae4e00e6e",
"cmd/go-omni/scaffold/addModule.go": "8f43bd68c2b3820cf1d036c4f2c5f9d06d1c7c8e5114f5081597d905f8d8cdcc",
"cmd/go-omni/scaffold/addModule_test.go": "667c45312f628f0fb5b8206ddef7ccc02a75f33b72f73fd6a17b03da0cb62e91",
"cmd/go-omni/scaffold/apiInfrastructure.go": "f493364448254cae2d8e658bf5c69e96a3264c2bf3fa50fd48a5ebdb20edabd8",
"cmd/go-omni/scaffold/moduleProfiles_test.go": "ae9790e5d3549d32fc72739a10a31ea858e5a8f8189cf1f5c1840bf80b3f8ad6",
"cmd/go-omni/scaffold/moduleTemplates/README.md.tmpl": "9d3bfc46cac5618bcff7d8a6b2cc9f123e818aeb3dee9bcdfe0f91db584994bd",
"cmd/go-omni/scaffold/moduleTemplates/handler-handler.go.tmpl": "729cd0f6faba53d5d2c1492935e12ac2adcefde4490748e25c18be7ccb3f7139",
"cmd/go-omni/scaffold/moduleTemplates/model-model.go.tmpl": "a005611f6b455d4d8469e316bc26fa571043b2da1834db2b3dd14a96d66221d7",
"cmd/go-omni/scaffold/moduleTemplates/module.go.tmpl": "fb0be7fd571ba2c0edb01d70d68cdbbfe53561caab98346bf1ac7cd8df07205a",
"cmd/go-omni/scaffold/moduleTemplates/repository-repository.go.tmpl": "902da3d9f12395fb226178c4428ff547e1c2fce3261882dba98d1400d8c90747",
"cmd/go-omni/scaffold/moduleTemplates/service-service.go.tmpl": "49d26044a69a43e983aa5ba4b5b2399b26b77898b23e230751ad1372c2af3178",
"cmd/go-omni/scaffold/scaffold.go": "71b3091b83c3e45ac1981164e8c819c997ad96607d535f1492333370f4872548",
"cmd/go-omni/scaffold/scaffold_test.go": "58aab9251b01e394bbc90aeb538ff035dd32c73c0d4815132ac6cbc086527cda",
"cmd/go-omni/scaffold/templates/README.md.tmpl": "8ec215775fcafd437480022307fffd2ffe43685bc2b5d328b0c1d58402986e0a",
"cmd/go-omni/scaffold/templates/api-context.go.tmpl": "d2d67c3d235c4b1c965364f40f49c441d92764ad7ab8fd6848f56b1028e93b1b",
"cmd/go-omni/scaffold/templates/cmd-server-main.go.tmpl": "07c4f81109ece43fcf15bb807ada5ae6dc7e732babc7293269514a21cd389955",
"cmd/go-omni/scaffold/templates/databaseIntegration_test.go.tmpl": "81c4f7fbc919d74b738d3a7338248cbd83cfa0803b2058e8ee184e3ce51e7e9b",
"cmd/go-omni/scaffold/templates/go.mod.tmpl": "7d3bb15d2292e36939cc1905258d2800aeaf0e37d7d041b2ff8cfdb6fc84c57a",
"cmd/go-omni/scaffold/templates/internal-handler-user.go.tmpl": "7f8dbcd2f9b0129a90160d6059e47d19d4758274060a1a3e5160055d0592d34f",
"cmd/go-omni/scaffold/templates/internal-model-errors.go.tmpl": "f123eace52952321b0665a92a2923514c52c8c3bf13df1065710e9675750f837",
"cmd/go-omni/scaffold/templates/internal-model-user.go.tmpl": "b391143e7386e169b53e7fed25de1146e8c52f3fa287b12728e8d22b33f386de",
"cmd/go-omni/scaffold/templates/internal-repository-memory.go.tmpl": "148abc273c1fab3f28536706792762ae41e8687dfe9986c374372f162600feac",
"cmd/go-omni/scaffold/templates/internal-repository-mysql.go.tmpl": "34bd333e29211a0025ff6ebaea18c7f142f18f1cc451d692f9d360e8c8f6f635",
"cmd/go-omni/scaffold/templates/internal-repository-users.go.tmpl": "6d7dd80f34a9c215daeb4a86bfbe11e845e7ff7e9864c8af43e1c2ec3a8b28ec",
"cmd/go-omni/scaffold/templates/internal-service-user.go.tmpl": "33fdee3a2c3d98e7ca6a323b5e888a73582f94e327b6c24a34c09bf180db7b3d",
"cmd/go-omni/scaffold/templates/internal-servicecontext-context.go.tmpl": "de70ecc4194db8a2da5d65297f452a40b453652cb814cd228ce03d465761c856",
"cmd/go-omni/scaffold/templates/modules-README.md.tmpl": "4b84fbc2310b856d86c6fcd9a47d97d2261019c74ea8e0cdbc41229086aa3527",
"cmd/go-omni/scaffold/templates/modules-config.yaml.tmpl": "b8734080ae6d533a175bcad946ce2cb55d8bac1729918291753b9ca03064910f",
"cmd/go-omni/scaffold/templates/modules-filesHandler.go.tmpl": "426867fbef5b99a85394fa4457dd38b9a194adf4bdfb293384b2fbbc799091da",
"cmd/go-omni/scaffold/templates/modules-filesHandler_test.go.tmpl": "9571e39bd0a330739eb1fd7e636cd58961b41b44772b2b49162e41deda98547f",
"cmd/go-omni/scaffold/templates/modules-filesService.go.tmpl": "6460ee9a873af45ca6cec779bf53302acfce21499610425a4704c2f179eddb8f",
"cmd/go-omni/scaffold/templates/modules-gitignore.tmpl": "2579f2a63ff2962239b92dd841b0d0d26e783d85c9d0100011936c8bcbf5d0d3",
"cmd/go-omni/scaffold/templates/modules-main.go.tmpl": "cb8daddd4e12afd238540be7022187c74a548d9e409b3a7f1eaf84a9139218bf",
"cmd/go-omni/scaffold/templates/modules-settings.go.tmpl": "91084caaa1fd994dde714f52b242d23e41adb731383b12d74399247c87ac5597",
"cmd/go-omni/scaffold/templates/modules-settings_test.go.tmpl": "76e55f8954b7606d7146eb1e965065c521a2b81234a47742ed69a93aac300406",
"cmd/go-omni/scaffold/templates/modules-storage.go.tmpl": "a775acf2d2490288a8e5482bb0a8769be6b0a8c9cfcc9bb10b62aabbd05fa0c7",
"cmd/go-omni/scaffold/templates/modules-storage_test.go.tmpl": "dacca42d228bd9352948054eb2d071829a8a61d8c6fa24d95ff00fc39a74309e",
"cmd/go-omni/scaffold/templates/schema.sql.tmpl": "7c5108f18fc7d8ff75c97afd96fcaa9f8ed026c79c71c5612f55572d73354abb",
"cmd/go-omni/scaffold/version.go": "1d1bd1fc0be25d7ce479587d13a2ffc978e879eb36f1c23a6e58be831b776a2d",
"database/configOverlay_test.go": "01c9b44fded226494ca12f7d0f41a9ff417d8135d263a5833727c1c028b6b8e3",
"database/database.go": "3d8615e1bf58ecdae21558591e745c241e499ba6e85fc07acfa2344e04ce95db",
"database/database_test.go": "18eac1e4c6dbd6a09216168839ba3511aac8a38c2ddf6fdd5267f35435b09f67",
"database/lifecycle_test.go": "e141b52edfb9867184dedac0e79feed56a5b137acbe64280ad69647653bbea21",
"database/migrate/migrate.go": "fd6cd27d2dce5b35ffe80eb5442c47c657b93ffc6545bbcc1ee4651a70a4f80b",
"database/migrate/migrate_test.go": "d7ba91eed8cfd97e4ef0741d582f493dd411b65ad3192f05b167f75d9b9dcf90",
"examples/userService/api/user.api": "ed4d45cb796d9a017af9d341076553ca576918faef155aeea528718515582c95",
"examples/userService/cmd/server/main.go": "80ba4721f677947b7f2ece21d5f3e89ee0c4d2cc653412d83aef2b3a45bd3ccb",
"examples/userService/etc/config.yaml": "f50df3cddbc3c23f233405e7a9d8895b9d83d2a0607ccf001261fcd4b8978ee9",
"examples/userService/internal/apierrors/errorsGen.go": "20e94bbc90ae9f4330de4c25b9a550026c4c352502719a643ede3e8912a6566e",
"examples/userService/internal/handler/openapiGen.go": "e4130f5c7494f7db4830bfef203fa8154a9cd087c95954f64c29d74693ead433",
"examples/userService/internal/handler/responseGen.go": "d742efc2a3d1d168fae7849e905297bd440c3d6b3802b6150f494c07e6097f4f",
"examples/userService/internal/handler/routesGen.go": "0ff429a8aa7f8643e0e844f7c43a63a1f82cca5ce6bfe917f06edd860ebe923f",
"examples/userService/internal/handler/user/handlersGen.go": "06f3c2798ff323812ff3eb2d8b933a569af3ce1b86a67b66036374add98c0d7e",
"examples/userService/internal/middleware/auth.go": "fbfe8ff8b12f698218b9ef5f77fd85b612b3aed362a73dc26c3627f2d688c040",
"examples/userService/internal/middleware/auth_test.go": "63d6117dadf43b766e0aa485a63c4725a5109ab814c2b4a1fa7a59ee6d1c4cf6",
"examples/userService/internal/middleware/registryGen.go": "a6122a5453dedc6a4415860108814c50e155128cfe5412b39dd33ce2d01b4b00",
"examples/userService/internal/model/errors.go": "f123eace52952321b0665a92a2923514c52c8c3bf13df1065710e9675750f837",
"examples/userService/internal/model/user.go": "b391143e7386e169b53e7fed25de1146e8c52f3fa287b12728e8d22b33f386de",
"examples/userService/internal/repository/user/mysql.go": "924cae233a68e506e9b0b953529e0ccb13a0e71d24723309383c464e1852b833",
"examples/userService/internal/repository/user/repository.go": "2aca07788d8d59831b10ac665882ca8157b20f384115bb90742f235f8352a69d",
"examples/userService/internal/repository/user/store.go": "f776832e4f3b26fb889dad53b65742833ddb7f43b7dddc72915ad3fe47ec4d3e",
"examples/userService/internal/repository/user/transaction_test.go": "9af4cb3a03b71026b80aa5078ddda4e3c4f72a7ab14bed6c87603a4db2cc2a22",
"examples/userService/internal/service/user/createUser.go": "53d0ea7371e10a0bd3f32f57b9bff9b48b5f9a3315218f95a258fa45b81d9368",
"examples/userService/internal/service/user/deleteUser.go": "0cd0b038d5a11429de438311980ef19a8f6e28ea0d7cf5ebaaf78858daf3a124",
"examples/userService/internal/service/user/errors_test.go": "c0193bdc674cbe15c4871a91e288323a99193d19ba8fd874f9bf17c390917e5c",
"examples/userService/internal/service/user/exportUser.go": "dac2adb0c9ea43a4af15511ae488afaa6d9c9b351e68e9b8a54f4014d29de14d",
"examples/userService/internal/service/user/getUser.go": "a307fa716c70286a2a6c5d5c0a6c35ec6d258c3278e2a7fe86b74b324d7b7283",
"examples/userService/internal/service/user/listUsers.go": "f0bf8b9c3a4c4a699112e3493b7fe611e2dd4f6e8a4437106396c47d484f1383",
"examples/userService/internal/service/user/service.go": "cd6c203459fccbc030e88eccfa785d5db029e45784f8b242be68ea1c9394a736",
"examples/userService/internal/service/user/updateUser.go": "9b0e3e4296c0e0df98956961a928612d9203c0fa09aa71bb0683e430e1133ff0",
"examples/userService/internal/servicecontext/context.go": "5527ca07049f5c5f5deffa65e5759494b2e0a77804b629b3ecb1705e7557ee25",
"examples/userService/internal/servicecontext/context_test.go": "04d008b6cf5b7894752bc5222abb8f4120f1ebe060034ac55ec9663c1f7bc613",
"examples/userService/internal/servicecontext/storage.go": "9402e1ee7a258b2db4b6dbf38fcb0674ad8907fb2a948d47dd8374f78bec444d",
"examples/userService/internal/settings/config.go": "721c3744a61a2d2807edcdfa190336901368966a0fb952561d0e00da5730cd3a",
"examples/userService/internal/types/typesGen.go": "3ab2ce9ffe467c3ea53ac3d1fec283ecebc87cef57fd592d08dca991fd964876",
"examples/userService/migrations/000001_users.down.sql": "64b47d3d2644745a2ba1197b73760e63fe0f2c1d12c193888c4417b3d5abb75e",
"examples/userService/migrations/000001_users.up.sql": "c6ea5cb92ee53fe20e43373d084ff360d49921b785602145e55380e4adbee1dc"
}
}

View File

@@ -0,0 +1,55 @@
Command: GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...
Exit status: 0
ok git.yuntuo.hk/k2safe/go-omni 2.660s
ok git.yuntuo.hk/k2safe/go-omni/app 2.005s
ok git.yuntuo.hk/k2safe/go-omni/audit 1.338s
ok git.yuntuo.hk/k2safe/go-omni/auth 1.346s
ok git.yuntuo.hk/k2safe/go-omni/cache 1.399s
ok git.yuntuo.hk/k2safe/go-omni/cache/redis 1.304s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 1.960s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen 30.753s
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold 37.406s
ok git.yuntuo.hk/k2safe/go-omni/config 1.334s
ok git.yuntuo.hk/k2safe/go-omni/contract 1.341s
ok git.yuntuo.hk/k2safe/go-omni/database 1.298s
ok git.yuntuo.hk/k2safe/go-omni/database/migrate 1.336s
? git.yuntuo.hk/k2safe/go-omni/errors [no test files]
? git.yuntuo.hk/k2safe/go-omni/examples/basic [no test files]
ok git.yuntuo.hk/k2safe/go-omni/examples/modules 1.356s
ok git.yuntuo.hk/k2safe/go-omni/examples/platform 1.396s
ok git.yuntuo.hk/k2safe/go-omni/examples/reference 1.423s
? git.yuntuo.hk/k2safe/go-omni/examples/reference/handler [no test files]
? git.yuntuo.hk/k2safe/go-omni/examples/reference/model [no test files]
ok git.yuntuo.hk/k2safe/go-omni/examples/reference/repository 1.305s
? git.yuntuo.hk/k2safe/go-omni/examples/reference/service [no test files]
ok git.yuntuo.hk/k2safe/go-omni/health 1.338s
ok git.yuntuo.hk/k2safe/go-omni/httpc 1.388s
ok git.yuntuo.hk/k2safe/go-omni/httpx 1.330s
ok git.yuntuo.hk/k2safe/go-omni/internal/perfcheck 1.407s
? git.yuntuo.hk/k2safe/go-omni/internal/storagefixture [no test files]
? git.yuntuo.hk/k2safe/go-omni/internal/testutil [no test files]
ok git.yuntuo.hk/k2safe/go-omni/logx 1.473s
ok git.yuntuo.hk/k2safe/go-omni/metrics 1.324s
ok git.yuntuo.hk/k2safe/go-omni/middleware 1.392s
ok git.yuntuo.hk/k2safe/go-omni/middleware/redis 1.319s
ok git.yuntuo.hk/k2safe/go-omni/openapi 1.333s
ok git.yuntuo.hk/k2safe/go-omni/pagination 1.296s
? git.yuntuo.hk/k2safe/go-omni/queue [no test files]
ok git.yuntuo.hk/k2safe/go-omni/queue/redis 1.713s
ok git.yuntuo.hk/k2safe/go-omni/resilience 1.530s
ok git.yuntuo.hk/k2safe/go-omni/response 1.324s
ok git.yuntuo.hk/k2safe/go-omni/storage 1.336s
ok git.yuntuo.hk/k2safe/go-omni/storage/local 1.311s
ok git.yuntuo.hk/k2safe/go-omni/storage/oss 1.466s
ok git.yuntuo.hk/k2safe/go-omni/storage/s3 10.741s
? git.yuntuo.hk/k2safe/go-omni/storage/storagetest [no test files]
ok git.yuntuo.hk/k2safe/go-omni/task 1.365s
ok git.yuntuo.hk/k2safe/go-omni/telemetry 1.328s
ok git.yuntuo.hk/k2safe/go-omni/transport 1.347s
ok git.yuntuo.hk/k2safe/go-omni/transport/http2 12.542s
? git.yuntuo.hk/k2safe/go-omni/transport/internal/routing [no test files]
ok git.yuntuo.hk/k2safe/go-omni/transport/native 2.099s
ok git.yuntuo.hk/k2safe/go-omni/transport/nethttp 1.331s
? git.yuntuo.hk/k2safe/go-omni/transport/transporttest [no test files]
? git.yuntuo.hk/k2safe/go-omni/validate [no test files]

123
response/codeTypes_test.go Normal file
View File

@@ -0,0 +1,123 @@
package response_test
import (
"bytes"
"encoding/json"
"math"
"os"
"path/filepath"
"strconv"
"testing"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func TestCodeTypeWidthsAndWireValues(t *testing.T) {
for _, tc := range []struct {
kind string
min, max int64
}{
{"int32", math.MinInt32, math.MaxInt32}, {"int64", math.MinInt64, math.MaxInt64},
{"integer", -9007199254740991, 9007199254740991},
} {
t.Run(tc.kind, func(t *testing.T) {
cfg := response.Config{CodeType: tc.kind, SuccessCode: tc.max, ErrorCode: tc.min, Codes: map[string]any{"ZERO": 0}}
formatter, err := response.New(cfg)
if err != nil {
t.Fatal(err)
}
for _, sample := range []struct {
result response.Result
want int64
}{
{response.Result{Status: 200, Code: "OK"}, tc.max},
{response.Result{Status: 500, Code: "INTERNAL"}, tc.min},
{response.Result{Status: 409, Code: "ZERO"}, 0},
} {
payload, err := formatter.Encode(sample.result)
if err != nil {
t.Fatal(err)
}
decoder := json.NewDecoder(bytes.NewReader(payload.Body))
decoder.UseNumber()
var body map[string]any
if err = decoder.Decode(&body); err != nil {
t.Fatal(err)
}
code, ok := body["code"].(json.Number)
if !ok || code.String() != strconv.FormatInt(sample.want, 10) {
t.Fatalf("precision/type lost: %s", payload.Body)
}
}
})
}
for _, tc := range []struct {
kind string
value any
}{
{"int32", int64(math.MaxInt32) + 1}, {"int32", int64(math.MinInt32) - 1},
{"int64", json.Number("9223372036854775808")}, {"int64", json.Number("-9223372036854775809")},
{"int64", uint64(math.MaxUint64)}, {"int64", float64(math.MaxInt64)},
{"int32", 1.5}, {"int64", json.Number("1.5")}, {"int64", "200"},
{"string", 200},
} {
for _, field := range []string{"successCode", "errorCode", "codes"} {
cfg := response.Config{CodeType: tc.kind, SuccessCode: 0, ErrorCode: 1}
if tc.kind == "string" {
cfg.SuccessCode = "OK"
cfg.ErrorCode = "ERROR"
}
switch field {
case "successCode":
cfg.SuccessCode = tc.value
case "errorCode":
cfg.ErrorCode = tc.value
case "codes":
cfg.Codes = map[string]any{"MAPPED": tc.value}
}
if _, err := response.New(cfg); err == nil {
t.Fatalf("%s %s accepted %#v", tc.kind, field, tc.value)
}
}
}
for _, kind := range []string{"int32", "int64"} {
if _, err := response.New(response.Config{CodeType: kind}); err == nil {
t.Fatalf("%s accepted missing numeric fallbacks", kind)
}
}
f := response.MustNew(response.Config{CodeType: "string", SuccessCode: "SUCCESS", ErrorCode: "ERROR", Codes: map[string]any{"DENIED": "403"}})
if got := f.Value(response.Result{Status: 403, Code: "DENIED"})["code"]; got != "403" {
t.Fatal(got)
}
}
func TestInt64CodesFromConfigurationKeepPrecision(t *testing.T) {
for ext, body := range map[string]string{
"json": `{"response":{"codeType":"int64","successCode":9223372036854775807,"errorCode":-9223372036854775808,"codes":{"EXACT":9007199254740993}}}`,
"yaml": "response:\n codeType: int64\n successCode: 9223372036854775807\n errorCode: -9223372036854775808\n codes:\n EXACT: 9007199254740993\n",
} {
path := filepath.Join(t.TempDir(), "config."+ext)
if err := os.WriteFile(path, []byte(body), 0600); err != nil {
t.Fatal(err)
}
cfg, err := config.LoadFile(path, "RESPONSE_TEST_")
if err != nil {
t.Fatalf("%s: %v", ext, err)
}
f := response.MustNew(cfg.Response)
if f.Config().SuccessCode != int64(math.MaxInt64) || f.Config().ErrorCode != int64(math.MinInt64) || f.Config().Codes["EXACT"] != int64(9007199254740993) {
t.Fatalf("%s: precision lost: %#v", ext, f.Config())
}
}
var cfg response.Config
if err := json.Unmarshal([]byte(`{"codeType":"int64","successCode":9223372036854775807,"errorCode":0}`), &cfg); err != nil {
t.Fatal(err)
}
if response.MustNew(cfg).Config().SuccessCode != int64(math.MaxInt64) {
t.Fatal("json.Unmarshal lost precision")
}
if err := json.Unmarshal([]byte(`{"unknownOption":1}`), &cfg); err == nil {
t.Fatal("custom decoder bypassed unknown-field validation")
}
}

257
response/config.go Normal file
View File

@@ -0,0 +1,257 @@
package response
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"math"
"reflect"
"strings"
)
// Config defines a JSON response template or a convenient flat envelope.
// Body values can contain typed slots; ErrorBody optionally overrides errors.
// Without Body, empty field names retain defaults and "-" disables a field.
// This is application configuration, never a process-global response policy.
type Config struct {
Body map[string]any `json:"body"`
ErrorBody map[string]any `json:"errorBody"`
CodeField string `json:"codeField,omitempty"`
MessageField string `json:"messageField,omitempty"`
DataField string `json:"dataField,omitempty"`
RequestIDField string `json:"requestIdField,omitempty"`
CodeType string `json:"codeType,omitempty"` // string, int32, int64; integer is legacy
SuccessCode any `json:"successCode,omitempty"`
ErrorCode any `json:"errorCode,omitempty"`
SuccessMessage string `json:"successMessage,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
DataMode string `json:"dataMode,omitempty"`
Codes map[string]any `json:"codes,omitempty"`
Messages map[string]string `json:"messages,omitempty"`
}
type Formatter struct{ config Config }
// UnmarshalJSON preserves int64 values in interface fields, including mappings
// and body constants. DecodeFile and ordinary json.Unmarshal share this path.
func (c *Config) UnmarshalJSON(data []byte) error {
type plain Config
value := plain(*c)
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.UseNumber()
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
return err
}
*c = Config(value)
return nil
}
// New validates and detaches all configuration before serving requests.
func New(c Config) (*Formatter, error) {
if c.ErrorBody != nil && c.Body == nil {
return nil, errors.New("response: errorBody requires body")
}
if c.Body != nil {
if c.CodeField != "" || c.MessageField != "" || c.DataField != "" || c.RequestIDField != "" {
return nil, errors.New("response: body cannot be combined with field-name shortcuts")
}
for _, body := range []map[string]any{c.Body, c.ErrorBody} {
if err := validateTemplate(body, 0); err != nil {
return nil, err
}
}
}
c = c.Clone()
defaults := []struct {
field *string
value string
}{{&c.CodeField, "code"}, {&c.MessageField, "message"}, {&c.DataField, "data"}, {&c.RequestIDField, "requestId"}, {&c.CodeType, "string"}, {&c.DataMode, "omitNil"}}
for _, d := range defaults {
if *d.field == "" {
*d.field = d.value
}
}
if c.CodeType != "string" && c.CodeType != "int32" && c.CodeType != "int64" && c.CodeType != "integer" {
return nil, errors.New("response: codeType must be string, int32 or int64 (legacy integer is also accepted)")
}
if c.DataMode != "omitNil" && c.DataMode != "always" {
return nil, errors.New("response: dataMode must be omitNil or always")
}
seen := map[string]bool{}
for _, field := range []string{c.CodeField, c.MessageField, c.DataField, c.RequestIDField} {
if field == "-" {
continue
}
if strings.TrimSpace(field) != field || strings.ContainsAny(field, "\r\n\t") || seen[field] {
return nil, errors.New("response: field names must be distinct nonempty names without surrounding whitespace")
}
seen[field] = true
}
if c.CodeType != "string" && (c.SuccessCode == nil || c.ErrorCode == nil) {
return nil, errors.New("response: integer codes require successCode and errorCode")
}
for _, item := range []struct {
name string
value *any
}{{"successCode", &c.SuccessCode}, {"errorCode", &c.ErrorCode}} {
if *item.value == nil {
continue
}
value, err := normalizeCode(*item.value, c.CodeType)
if err != nil {
return nil, fmt.Errorf("response: %s: %w", item.name, err)
}
*item.value = value
}
for name, value := range c.Codes {
if strings.TrimSpace(name) == "" {
return nil, errors.New("response: code mapping requires a logical code")
}
v, err := normalizeCode(value, c.CodeType)
if err != nil {
return nil, fmt.Errorf("response: codes.%s: %w", name, err)
}
c.Codes[name] = v
}
return &Formatter{config: c}, nil
}
func MustNew(c Config) *Formatter {
f, err := New(c)
if err != nil {
panic(err)
}
return f
}
func (c Config) Validate() error { _, err := New(c); return err }
func (c Config) Clone() Config {
if c.Body != nil {
c.Body = cloneTemplate(c.Body, 0).(map[string]any)
}
if c.ErrorBody != nil {
c.ErrorBody = cloneTemplate(c.ErrorBody, 0).(map[string]any)
}
if c.Codes != nil {
values := make(map[string]any, len(c.Codes))
for k, v := range c.Codes {
values[k] = v
}
c.Codes = values
}
if c.Messages != nil {
values := make(map[string]string, len(c.Messages))
for k, v := range c.Messages {
values[k] = v
}
c.Messages = values
}
return c
}
func (f *Formatter) Config() Config {
c := f.config.Clone()
if c.Body != nil {
c.CodeField, c.MessageField, c.DataField, c.RequestIDField = "", "", "", ""
}
return c
}
// Value is also used by documentation generation, keeping code/message mapping
// and field omission identical to the actual response encoder.
func (f *Formatter) Value(r Result) map[string]any {
c := f.config
code, message := any(r.Code), r.Message
isError := r.Err != nil || r.Status >= 400
if isError {
if c.ErrorCode != nil {
code = c.ErrorCode
}
if c.ErrorMessage != "" {
message = c.ErrorMessage
}
} else {
if c.SuccessCode != nil {
code = c.SuccessCode
}
if c.SuccessMessage != "" {
message = c.SuccessMessage
}
}
if mapped, ok := c.Codes[r.Code]; ok {
code = mapped
}
if mapped, ok := c.Messages[r.Code]; ok {
message = mapped
}
body := c.Body
if isError && c.ErrorBody != nil {
body = c.ErrorBody
}
if body != nil {
return renderTemplate(body, map[string]any{"code": code, "message": message, "data": r.Data, "requestId": r.RequestID, "ok": !isError, "status": r.Status}, c.DataMode).(map[string]any)
}
value := map[string]any{}
if c.CodeField != "-" {
value[c.CodeField] = code
}
if c.MessageField != "-" {
value[c.MessageField] = message
}
if c.DataField != "-" && (r.Data != nil || c.DataMode == "always") {
value[c.DataField] = r.Data
}
if c.RequestIDField != "-" && r.RequestID != "" {
value[c.RequestIDField] = r.RequestID
}
return value
}
func (f *Formatter) Encode(r Result) (Payload, error) { return JSON(r.Status, f.Value(r)) }
func normalizeCode(value any, kind string) (any, error) {
if kind == "string" {
if s, ok := value.(string); ok && s != "" {
return s, nil
}
return nil, errors.New("expected a nonempty string")
}
if value == nil {
return nil, errors.New("expected an integer")
}
if n, ok := value.(json.Number); ok {
v, err := n.Int64()
if err != nil {
return nil, errors.New("expected a signed 64-bit integer")
}
value = v
}
v := reflect.ValueOf(value)
var n int64
switch v.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
n = v.Int()
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
if v.Uint() > math.MaxInt64 {
return nil, errors.New("integer code exceeds int64 range")
}
n = int64(v.Uint())
case reflect.Float32, reflect.Float64:
x := v.Float()
if math.IsNaN(x) || math.IsInf(x, 0) || math.Trunc(x) != x || math.Abs(x) > 9007199254740991 {
return nil, errors.New("expected an exact integer code")
}
n = int64(x)
default:
return nil, errors.New("expected an integer")
}
if kind == "int32" {
if n < math.MinInt32 || n > math.MaxInt32 {
return nil, errors.New("integer code exceeds int32 range")
}
return int32(n), nil
}
if kind == "integer" && (n < -9007199254740991 || n > 9007199254740991) {
return nil, errors.New("integer code exceeds exact JSON range")
}
return n, nil
}

75
response/config_test.go Normal file
View File

@@ -0,0 +1,75 @@
package response_test
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"testing"
"git.yuntuo.hk/k2safe/go-omni/config"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func TestConfigurableWireContract(t *testing.T) {
cfg := response.Config{CodeType: "integer", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", RequestIDField: "-", DataMode: "always", SuccessMessage: "操作成功", Codes: map[string]any{"EMAIL_TAKEN": 409001}, Messages: map[string]string{"EMAIL_TAKEN": "邮箱已存在"}}
f, err := response.New(cfg)
if err != nil {
t.Fatal(err)
}
cfg.Codes["EMAIL_TAKEN"] = 123
cfg.Messages["EMAIL_TAKEN"] = "changed"
f.Config().Codes["EMAIL_TAKEN"] = 456
for _, tc := range []struct {
result response.Result
code int64
message string
data any
}{
{response.Result{Status: 200, Code: "OK", Message: "success"}, 0, "操作成功", nil},
{response.Result{Status: 409, Code: "EMAIL_TAKEN", Message: "email taken", RequestID: "request", Err: errors.New("private")}, 409001, "邮箱已存在", nil},
{response.Result{Status: 200, Code: "OK", Data: []string{}}, 0, "操作成功", []string{}},
} {
value := f.Value(tc.result)
if !reflect.DeepEqual(value, map[string]any{"code": tc.code, "msg": tc.message, "data": tc.data}) {
t.Fatalf("unexpected envelope: %#v", value)
}
}
for _, bad := range []response.Config{
{CodeType: "integer"}, {CodeType: "bool"}, {MessageField: "code"}, {DataMode: "sometimes"},
{CodeType: "integer", SuccessCode: 200, ErrorCode: 9.5},
{CodeType: "integer", SuccessCode: json.Number("9007199254740992"), ErrorCode: 9999},
{Codes: map[string]any{"ERROR": 42}},
} {
if _, err := response.New(bad); err == nil {
t.Fatalf("invalid config accepted: %+v", bad)
}
}
}
func TestResponseConfigLoadsFromJSONAndYAML(t *testing.T) {
for filename, body := range map[string]string{
"flat.json": `{"response":{"codeType":"integer","successCode":0,"errorCode":9999,"messageField":"msg","requestIdField":"-","dataMode":"always","codes":{"DENIED":403001}}}`,
"flat.yaml": "response:\n codeType: integer\n successCode: 0\n errorCode: 9999\n messageField: msg\n requestIdField: '-'\n dataMode: always\n codes:\n DENIED: 403001\n",
"body.json": `{"response":{"body":{"code":"${code}","msg":"${message}","data":"${data}"},"codeType":"integer","successCode":0,"errorCode":9999,"dataMode":"always","codes":{"DENIED":403001}}}`,
"body.yaml": "response:\n body:\n code: '${code}'\n msg: '${message}'\n data: '${data}'\n codeType: integer\n successCode: 0\n errorCode: 9999\n dataMode: always\n codes:\n DENIED: 403001\n",
} {
path := filepath.Join(t.TempDir(), filename)
if err := os.WriteFile(path, []byte(body), 0600); err != nil {
t.Fatal(err)
}
cfg, err := config.LoadFile(path, "RESPONSE_TEST_")
if err != nil {
t.Fatal(err)
}
f, err := response.New(cfg.Response)
if err != nil {
t.Fatal(err)
}
value := f.Value(response.Result{Status: 403, Code: "DENIED"})
if value["code"] != int64(403001) || len(value) != 3 {
t.Fatalf("%s: %v", filename, value)
}
}
}

60
response/encoder.go Normal file
View File

@@ -0,0 +1,60 @@
package response
import (
"encoding/json"
"net/http"
)
// Result is the transport-independent input to an Encoder. Err is available for
// application error mapping; default encoders never expose it or its causes.
type Result struct {
Status int
Code, Message, RequestID string
Data any
Err error
}
// Payload is a complete response. Encoders must be safe for concurrent use.
// Status and headers are intentionally part of the contract: callback protocols
// may acknowledge an error using a different HTTP status or media type.
type Payload struct {
Status int
Header http.Header
Body []byte
}
type Encoder interface{ Encode(Result) (Payload, error) }
type EncoderFunc func(Result) (Payload, error)
func (f EncoderFunc) Encode(r Result) (Payload, error) { return f(r) }
func JSON(status int, value any) (Payload, error) {
data, err := json.Marshal(value)
if err != nil {
return Payload{}, err
}
return Payload{Status: status, Header: http.Header{"Content-Type": {"application/json; charset=utf-8"}}, Body: append(data, '\n')}, nil
}
func Text(status int, value string) Payload {
return Payload{Status: status, Header: http.Header{"Content-Type": {"text/plain; charset=utf-8"}}, Body: []byte(value)}
}
// Default is immutable; applications can use different encoders in one process.
var defaultEncoder = MustNew(Config{})
func Default() Encoder { return defaultEncoder }
// RawJSON leaves successful data unwrapped. Errors use the selected error
// policy; callback protocols with custom acknowledgments should use EncoderFunc.
func RawJSON(errors Encoder) Encoder {
if errors == nil {
errors = Default()
}
return EncoderFunc(func(r Result) (Payload, error) {
if r.Err != nil || r.Status >= 400 {
return errors.Encode(r)
}
return JSON(r.Status, r.Data)
})
}

View File

@@ -1,6 +1,8 @@
package response
// Response is the standard JSON envelope returned by GoOmni helpers.
// Response describes the legacy default JSON envelope only.
// Deprecated: configure a response.Config or Encoder for application contracts.
// The runtime and code generator do not serialize through this fixed DTO.
type Response struct {
Code string `json:"code"`
Message string `json:"message"`

109
response/template.go Normal file
View File

@@ -0,0 +1,109 @@
package response
import (
"encoding/json"
"fmt"
"strings"
)
// Slot returns the name of a whole-value placeholder, or empty for constants.
// Slots preserve the value's JSON type; they are not string interpolation.
func Slot(value any) string {
s, ok := value.(string)
if ok && strings.HasPrefix(s, "${") && strings.HasSuffix(s, "}") {
return s[2 : len(s)-1]
}
return ""
}
func validateTemplate(value any, depth int) error {
if depth > 24 {
return fmt.Errorf("response: body nesting exceeds 24 levels")
}
switch v := value.(type) {
case map[string]any:
for key, item := range v {
if strings.TrimSpace(key) == "" {
return fmt.Errorf("response: body keys must not be empty")
}
if err := validateTemplate(item, depth+1); err != nil {
return err
}
}
case []any:
for _, item := range v {
if err := validateTemplate(item, depth+1); err != nil {
return err
}
}
case string:
if strings.HasPrefix(v, "${") {
switch Slot(v) {
case "code", "message", "data", "requestId", "ok", "status":
default:
return fmt.Errorf("response: unknown body slot %q", v)
}
}
case nil, bool, json.Number, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
if _, err := json.Marshal(v); err != nil {
return fmt.Errorf("response: invalid body constant: %w", err)
}
default:
return fmt.Errorf("response: body accepts JSON values, got %T", value)
}
return nil
}
func cloneTemplate(value any, depth int) any {
// Invalid cyclic Go configuration must still reach validation, not overflow.
if depth > 24 {
return value
}
switch v := value.(type) {
case map[string]any:
copy := make(map[string]any, len(v))
for key, item := range v {
copy[key] = cloneTemplate(item, depth+1)
}
return copy
case []any:
copy := make([]any, len(v))
for i, item := range v {
copy[i] = cloneTemplate(item, depth+1)
}
return copy
default:
return value
}
}
func renderTemplate(value any, slots map[string]any, dataMode string) any {
if name := Slot(value); name != "" {
return slots[name]
}
switch v := value.(type) {
case map[string]any:
result := make(map[string]any, len(v))
for key, item := range v {
slot := Slot(item)
if slot == "requestId" && slots[slot] == "" || slot == "data" && slots[slot] == nil && dataMode != "always" {
continue
}
result[key] = renderTemplate(item, slots, dataMode)
}
return result
case []any:
result := make([]any, len(v))
for i, item := range v {
result[i] = renderTemplate(item, slots, dataMode)
}
return result
case string:
if strings.HasPrefix(v, "$${") {
return v[1:]
}
return v
default:
return value
}
}

72
response/template_test.go Normal file
View File

@@ -0,0 +1,72 @@
package response_test
import (
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func TestResponseBodyTemplates(t *testing.T) {
config := response.Config{
Body: map[string]any{"success": "${ok}", "payload": "${data}", "meta": map[string]any{"trace": "${requestId}", "status": "${status}"}, "literal": "$${data}", "version": 1},
ErrorBody: map[string]any{"success": "${ok}", "error": map[string]any{"id": "${code}", "reason": "${message}"}},
CodeType: "integer", SuccessCode: 0, ErrorCode: 9999, Codes: map[string]any{"DENIED": 403001},
}
f := response.MustNew(config)
config.Body["meta"].(map[string]any)["status"] = "mutated"
f.Config().ErrorBody["error"].(map[string]any)["reason"] = "mutated"
good := f.Value(response.Result{Status: 201, Code: "CREATED", Data: []string{"a"}})
want := map[string]any{"success": true, "payload": []string{"a"}, "meta": map[string]any{"status": 201}, "literal": "${data}", "version": 1}
if !reflect.DeepEqual(good, want) {
t.Fatalf("%#v", good)
}
bad := f.Value(response.Result{Status: 403, Code: "DENIED", Message: "denied", Err: errors.New("PRIVATE")})
want = map[string]any{"success": false, "error": map[string]any{"id": int64(403001), "reason": "denied"}}
if !reflect.DeepEqual(bad, want) {
t.Fatalf("%#v", bad)
}
// Returning or serializing configuration preserves reusable templates, even {}.
for _, cfg := range []response.Config{f.Config(), {Body: map[string]any{}, ErrorBody: map[string]any{}}, {Body: map[string]any{"values": []any{"${data}", true, nil}}}} {
before := response.MustNew(cfg).Value(response.Result{Status: 200})
data, err := json.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
var copy response.Config
if err = json.Unmarshal(data, &copy); err != nil {
t.Fatal(err)
}
a, _ := json.Marshal(before)
b, _ := json.Marshal(response.MustNew(copy).Value(response.Result{Status: 200}))
if string(a) != string(b) {
t.Fatalf("round trip: %s / %s", a, b)
}
}
cyclic := map[string]any{}
cyclic["cycle"] = cyclic
for _, cfg := range []response.Config{
{Body: map[string]any{"value": "${missing}"}}, {Body: map[string]any{"value": "${data}-suffix"}},
{Body: cyclic}, {Body: map[string]any{"value": make(chan int)}}, {Body: map[string]any{}, DataField: "data"},
{ErrorBody: map[string]any{}},
} {
if _, err := response.New(cfg.Clone()); err == nil {
t.Fatal("invalid template accepted")
}
}
if strings.Contains(string(mustJSON(t, bad)), "PRIVATE") {
t.Fatal("private cause leaked")
}
}
func mustJSON(t *testing.T, value any) []byte {
t.Helper()
data, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
return data
}

215
response_test.go Normal file
View File

@@ -0,0 +1,215 @@
package omni_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
omni "git.yuntuo.hk/k2safe/go-omni"
"git.yuntuo.hk/k2safe/go-omni/httpx"
"git.yuntuo.hk/k2safe/go-omni/middleware"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func TestResponsePoliciesAndCallbackBoundaries(t *testing.T) {
type acknowledgment struct {
Message string `xml:"message"`
}
cfg := response.Config{CodeType: "integer", SuccessCode: 200, ErrorCode: 9999, MessageField: "msg", RequestIDField: "-", DataMode: "always", SuccessMessage: "成功", ErrorMessage: "系统错误", Codes: map[string]any{"DENIED": 403001}}
app := omni.New(omni.WithResponse(cfg))
defer app.Close()
app.GET("/business", func(c *httpx.Context) error { return c.OK(nil) })
app.GET("/denied", func(c *httpx.Context) error { return omni.Forbidden("DENIED", "denied") })
app.GET("/plain", func(c *httpx.Context) error { return c.Text(200, "success") })
app.GET("/xml", func(c *httpx.Context) error { return c.XML(200, acknowledgment{Message: "accepted"}) })
app.GET("/raw", func(c *httpx.Context) error { return c.JSON(202, map[string]any{"accepted": true}) })
app.GET("/committed", func(c *httpx.Context) error {
if err := c.Text(200, "success"); err != nil {
return err
}
return errors.New("late error")
})
callback := response.EncoderFunc(func(r response.Result) (response.Payload, error) {
if r.Err != nil {
return response.Text(200, "fail"), nil
}
return response.Text(200, "success"), nil
})
app.POST("/callback", func(c *httpx.Context) error { return c.OK(map[string]any{"ignored": true}) }, httpx.WithResponse(callback))
app.POST("/callbackError", func(c *httpx.Context) error { return errors.New("private callback error") }, httpx.WithResponse(callback))
app.POST("/callbackPanic", func(c *httpx.Context) error { panic("private callback panic") }, httpx.WithResponse(callback))
broken := response.EncoderFunc(func(response.Result) (response.Payload, error) { panic("broken formatter") })
app.GET("/broken", func(c *httpx.Context) error { return c.OK(nil) }, httpx.WithResponse(broken))
for _, tc := range []struct {
method, path string
status int
body, contentType string
}{
{"GET", "/business", 200, `{"code":200,"data":null,"msg":"成功"}` + "\n", "application/json"},
{"GET", "/denied", 403, `{"code":403001,"data":null,"msg":"系统错误"}` + "\n", "application/json"},
{"GET", "/plain", 200, "success", "text/plain"},
{"GET", "/xml", 200, "<", "application/xml"},
{"GET", "/raw", 202, `{"accepted":true}` + "\n", "application/json"},
{"GET", "/committed", 200, "success", "text/plain"},
{"POST", "/callback", 200, "success", "text/plain"},
{"POST", "/callbackError", 200, "fail", "text/plain"},
{"POST", "/callbackPanic", 200, "fail", "text/plain"},
{"GET", "/broken", 500, "", ""},
{"GET", "/missing", 404, `{"code":9999,"data":null,"msg":"系统错误"}` + "\n", "application/json"},
{"PUT", "/business", 405, `{"code":9999,"data":null,"msg":"系统错误"}` + "\n", "application/json"},
} {
t.Run(tc.path, func(t *testing.T) {
w := httptest.NewRecorder()
app.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, nil))
body := w.Body.String()
if w.Code != tc.status || !strings.HasPrefix(w.Header().Get("Content-Type"), tc.contentType) {
t.Fatalf("%d %s %s", w.Code, w.Header().Get("Content-Type"), body)
}
if tc.path == "/xml" {
if !strings.Contains(body, "<message>accepted</message>") || strings.Contains(body, "code") {
t.Fatal(body)
}
} else if body != tc.body {
t.Fatalf("body %q, want %q", body, tc.body)
}
})
}
}
func TestResponsePolicyIsolationAndClones(t *testing.T) {
cfg := response.Config{CodeField: "result", MessageField: "notice", DataField: "payload", RequestIDField: "-", Codes: map[string]any{"OK": "SUCCESS"}}
app := omni.New(omni.WithResponse(cfg))
defer app.Close()
cfg.Codes["OK"] = "MUTATED"
app.Config().Response.Codes["OK"] = "CHANGED"
app.GET("/clone", func(c *httpx.Context) error {
buffer := httptest.NewRecorder()
child := c.Clone(context.Background(), buffer)
if err := child.OK("value"); err != nil {
return err
}
return c.WriteResponse(buffer.Code, buffer.Header(), buffer.Body.Bytes())
})
other := omni.New()
defer other.Close()
other.GET("/clone", func(c *httpx.Context) error { return c.OK("value") })
var wg sync.WaitGroup
for n := 0; n < 32; n++ {
wg.Add(1)
go func() {
defer wg.Done()
for _, item := range []struct {
handler http.Handler
key, value string
}{{app, "result", "SUCCESS"}, {other, "code", "OK"}} {
w := httptest.NewRecorder()
item.handler.ServeHTTP(w, httptest.NewRequest("GET", "/clone", nil))
var body map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil || body[item.key] != item.value {
t.Errorf("policy leaked: %s %v", w.Body.String(), err)
}
}
}()
}
wg.Wait()
}
func TestResponsePolicyPrecedesGlobalMiddleware(t *testing.T) {
app := omni.New()
defer app.Close()
app.Use(func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error {
switch c.Header("Case") {
case "reject":
return omni.Forbidden("DENIED", "denied")
case "panic":
panic("global panic")
case "clone":
buffer := httptest.NewRecorder()
child := c.Clone(c.Context(), buffer)
if err := next(child); err != nil {
child.WriteError(err)
}
return c.WriteResponse(buffer.Code, buffer.Header(), buffer.Body.Bytes())
}
return next(c)
}
})
encoder := response.EncoderFunc(func(r response.Result) (response.Payload, error) {
if r.Err != nil {
return response.Text(200, "fail"), nil
}
return response.Text(200, "success"), nil
})
httpx.HandleWithResponse(app.Group("/hooks"), "POST", "/payment", encoder, func(c *httpx.Context) error { return c.OK(nil) })
for _, tc := range []struct{ mode, want string }{{"", "success"}, {"reject", "fail"}, {"panic", "fail"}, {"clone", "success"}} {
r := httptest.NewRequest("POST", "/hooks/payment", nil)
r.Header.Set("Case", tc.mode)
w := httptest.NewRecorder()
app.ServeHTTP(w, r)
if w.Code != 200 || w.Body.String() != tc.want {
t.Fatalf("%s: %d %s", tc.mode, w.Code, w.Body.String())
}
}
}
func TestResponsePolicyOnRealTransports(t *testing.T) {
for _, transport := range []string{"nethttp", "native"} {
t.Run(transport, func(t *testing.T) {
app := omni.New(omni.WithTransport(transport), omni.WithResponse(response.Config{CodeType: "integer", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", RequestIDField: "-", DataMode: "always"}))
app.GET("/ok", func(c *httpx.Context) error { return c.OK(nil) })
app.GET("/panic", func(c *httpx.Context) error { panic("fault") }, httpx.WithResponse(response.EncoderFunc(func(response.Result) (response.Payload, error) { return response.Text(200, "FAIL"), nil })))
app.GET("/timeout", func(c *httpx.Context) error { <-c.Context().Done(); return c.Context().Err() }, middleware.Timeout(10*time.Millisecond))
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- app.ServeContext(ctx, listener) }()
defer func() {
cancel()
select {
case err := <-done:
if err != nil {
t.Error(err)
}
case <-time.After(5 * time.Second):
t.Error("server did not stop")
}
}()
client := &http.Client{Timeout: 3 * time.Second}
defer client.CloseIdleConnections()
for _, path := range []string{"/ok", "/panic", "/timeout"} {
r, err := client.Get("http://" + listener.Addr().String() + path)
if err != nil {
t.Fatal(err)
}
data, _ := io.ReadAll(r.Body)
r.Body.Close()
if path == "/panic" {
if string(data) != "FAIL" || r.StatusCode != 200 {
t.Fatal(string(data))
}
continue
}
var value map[string]any
if err = json.Unmarshal(data, &value); err != nil || len(value) != 3 || value["msg"] == nil {
t.Fatalf("%s: %s %v", path, data, err)
}
if path == "/timeout" && fmt.Sprint(value["code"]) != "9999" {
t.Fatal(string(data))
}
}
})
}
}

View File

@@ -59,9 +59,9 @@ class LimitedMySQL(MySQL):
def exercise(binary, project, env, db, transport):
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0)); port = sock.getsockname()[1]
token, signing = secrets.token_hex(24), secrets.token_hex(24)
token, signing, readToken = secrets.token_hex(24), secrets.token_hex(24), secrets.token_hex(24)
environment = dict(env, GO_OMNI_HTTP_ADDR=f"127.0.0.1:{port}", GO_OMNI_TRANSPORT=transport,
USER_SERVICE_API_TOKEN=token, GO_OMNI_STORAGE_SIGNING_KEY=signing,
USER_SERVICE_API_TOKEN=token, USER_SERVICE_READ_TOKEN=readToken, GO_OMNI_STORAGE_SIGNING_KEY=signing,
GO_OMNI_STORAGE_ROOT=str(project / (transport + "Files")),
GO_OMNI_STORAGE_PUBLIC_URL=f"http://127.0.0.1:{port}/storage/object")
logs = project / "var/log/server.log"
@@ -72,7 +72,7 @@ def exercise(binary, project, env, db, transport):
client = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def request(method, path, body=None, expected=200, auth=True):
headers = {"Content-Type": "application/json", "X-Request-ID": "apiWorkflow"}
if auth: headers["Authorization"] = "Bearer " + token
if auth: headers["Authorization"] = "Bearer " + (token if auth is True else auth)
req = urllib.request.Request(f"http://127.0.0.1:{port}" + path,
data=None if body is None else json.dumps(body).encode(), headers=headers, method=method)
try: response = client.open(req, timeout=5)
@@ -98,6 +98,10 @@ def exercise(binary, project, env, db, transport):
process = start()
try:
request("GET", "/api/v1/users", expected=401, auth=False); checks.append("authBeforeService")
request("GET", "/api/v1/users", auth=readToken)
denied = request("POST", "/api/v1/users", {"name": "NoWrite", "email": "readonly@example.com"}, 403, auth=readToken)
assert denied["code"] == "FORBIDDEN"
checks.append("readOnlyPermission403")
empty = request("GET", "/api/v1/users")["data"]
assert empty["items"] == [] and empty["total"] == 0
checks.append("emptyPageAndQueryDefaults")
@@ -143,6 +147,11 @@ def exercise(binary, project, env, db, transport):
checks.append("signedDownloadTamperRejected")
doc = request("GET", "/openapi.json")
assert doc == json.loads((project / "docs/openapi.json").read_text()); checks.append("openAPIMatchesContract")
createDoc = doc["paths"]["/api/v1/users"]["post"]
assert createDoc["security"] == [{"AdminAuth": []}]
for status, name, code in (("401", "Unauthenticated", "UNAUTHENTICATED"), ("403", "Forbidden", "FORBIDDEN"), ("409", "EmailTaken", "EMAIL_TAKEN")):
assert createDoc["responses"][status]["content"]["application/json"]["examples"][name]["value"]["code"] == code
checks.append("declaredAuthAndBusinessErrorDocumentation")
run(["docker", "stop", "--time=3", db.container], project, db.env)
request("GET", "/health/ready", expected=503)
run(["docker", "start", db.container], project, db.env); db.wait()
@@ -162,7 +171,7 @@ def exercise(binary, project, env, db, transport):
text = logs.read_text()
entries = [json.loads(line) for line in text.splitlines() if line.startswith("{")]
assert any(e.get("msg") == "user.created" and e.get("requestId") == "apiWorkflow" for e in entries)
assert all(secret not in text for secret in (token, signing, env["GO_OMNI_DB_DSN"], link, "updated@example.com"))
assert all(secret not in text for secret in (token, signing, readToken, env["GO_OMNI_DB_DSN"], link, "updated@example.com"))
checks.append("businessFileLogsAndSecretExclusion")
return {"transport": transport, "checks": checks}
finally:

View File

@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Install the released CLI in a fresh cache, then test its generated consumer.
The fixture tests public templates, numeric auth errors, raw JSON, route override,
full int64 precision and regeneration protection. Only local temporary resources
are used; the framework and CLI are fetched from the specified remote version.
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import tempfile
import time
ROOT = Path(__file__).resolve().parents[1]
MODULE = "git.yuntuo.hk/k2safe/go-omni"
def run(args, cwd, env, timeout=300):
result = subprocess.run([str(arg) for arg in args], cwd=cwd, env=env,
capture_output=True, text=True, timeout=timeout)
output = re.sub(r"https://[^/@\s]+:[^/@\s]+@", "https://", result.stdout + result.stderr)
if result.returncode:
raise RuntimeError(f"{args[0]} failed ({result.returncode}):\n{output}")
return output
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True)
parser.add_argument("--report", type=Path, default=ROOT / "performance/responsePoliciesRemote.json")
args = parser.parse_args()
if not re.fullmatch(r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?", args.version):
parser.error("a fixed release version is required")
env = {k: v for k, v in os.environ.items() if not k.startswith("GO_OMNI_")}
env.update(GOWORK="off", GOFLAGS="", GOPRIVATE="git.yuntuo.hk", GONOSUMDB="git.yuntuo.hk")
start = time.monotonic()
with tempfile.TemporaryDirectory(prefix="goOmniResponseRelease-") as directory:
temp = Path(directory)
(temp / "bin").mkdir()
env.update(GOBIN=str(temp / "bin"), GOMODCACHE=str(temp / "modcache"))
print(f"Installing CLI {args.version} in an empty module cache", flush=True)
run(["go", "install", MODULE + "/cmd/go-omni@" + args.version], temp, env)
cli = temp / "bin/go-omni"
run([cli, "api", "validate", "--api", ROOT / "examples/contracts/responses.api"], temp, env)
env.update(GO_OMNI_SCAFFOLD_INTEGRATION="1", GO_OMNI_RELEASE_CLI=str(cli), GO_OMNI_RELEASE_VERSION=args.version)
print("Testing remote generation, int64 precision and consumer runtime", flush=True)
output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=180s",
"./cmd/go-omni/apigen", "-run", "^TestResponseTemplateConsumer$"], ROOT, env)
events = [json.loads(line) for line in output.splitlines() if line.startswith("{")]
passed = [e["Test"] for e in events if e.get("Action") == "pass" and "Test" in e]
assert passed == ["TestResponseTemplateConsumer"], output
evidence = "".join(e.get("Output", "") for e in events)
checksum = re.search(r"checksum (h1:\S+); no local replace", evidence)
assert checksum, "consumer did not verify its remote dependency"
report = {
"passed": True, "frameworkVersion": args.version, "frameworkSum": checksum.group(1),
"freshModuleCache": True, "noLocalReplace": True,
"durationSeconds": round(time.monotonic() - start, 2),
"checks": ["remoteCLIInstall", "int32ContractValidation", "templateProjectGeneration",
"int64MaxValueOnWire", "numericAuthErrors", "rawJSON", "routeOverride",
"handwrittenFilesPreserved", "generatedConsumerRaceTests", "fixedRemoteModuleChecksum"],
"sourceHashes": {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in
("scripts/verifyResponseRelease.py", "cmd/go-omni/apigen/response_test.go",
"examples/contracts/responses.api")},
}
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
print(f"PASS remote response contract {args.version}; temporary files removed", flush=True)
if __name__ == "__main__":
main()

View File

@@ -11,7 +11,10 @@ import (
"git.yuntuo.hk/k2safe/go-omni/transport/internal/routing"
)
type Router struct{ routes []route }
type Router struct {
routes []route
notFound, methodNotAllowed http.Handler
}
type route struct {
method string
@@ -22,6 +25,12 @@ type route struct {
func New() *Router { return &Router{} }
// SetFallbacks must be called before serving requests. Nil preserves the plain
// net/http fallback, useful when using this router without an Omni application.
func (r *Router) SetFallbacks(notFound, methodNotAllowed http.Handler) {
r.notFound, r.methodNotAllowed = notFound, methodNotAllowed
}
func (r *Router) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
var selected *route
var selectedParams map[string]string
@@ -48,9 +57,17 @@ func (r *Router) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
}
sort.Strings(methods)
writer.Header().Set("Allow", strings.Join(methods, ", "))
http.Error(writer, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
if r.methodNotAllowed != nil {
r.methodNotAllowed.ServeHTTP(writer, request)
} else {
http.Error(writer, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
}
} else {
http.NotFound(writer, request)
if r.notFound != nil {
r.notFound.ServeHTTP(writer, request)
} else {
http.NotFound(writer, request)
}
}
return
}