Compare commits
7 Commits
v0.7.0-pre
...
v0.9.0-pre
| Author | SHA1 | Date | |
|---|---|---|---|
| a8c973d8a2 | |||
| 7b8eca1a61 | |||
| 35bd724326 | |||
| 7670b222ac | |||
| bef7ea2641 | |||
| 2913e94a0a | |||
| ab8eac5fc4 |
128
API.md
128
API.md
@@ -1,5 +1,7 @@
|
||||
# API 契约与项目生成
|
||||
|
||||
首次接入请先看 [README](README.md):包含固定远程版本安装、可运行项目、完整 `.api` 示例和已有服务接入步骤。本页用于查阅语法与生成规则;下方带 `--framework-dir` 的命令用于框架源码开发。
|
||||
|
||||
本阶段目标:从 `.api` 定义生成可运行的 Go 项目;定义、绑定、校验与 OpenAPI 保持一致;重复生成不覆盖业务实现;业务代码继续独立于网络层。实现入口是 `cmd/go-omni/apigen`,运行时契约编译器是 `contract`。
|
||||
|
||||
## 与 go-zero 的对应关系
|
||||
@@ -89,27 +91,142 @@ 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 更新,以同步文件哈希。
|
||||
|
||||
## 迁移扩展(v0.9.0-preview)
|
||||
|
||||
- 动态 JSON 字段 `any` / `interface{}`,包含切片和 map;外围契约仍严格。
|
||||
- `scalar Name (...)` 显式引用项目类型,声明 JSON 输入/输出类型及文本绑定;支持已有 JSON/Text 转换方法。
|
||||
- `@body (mode: json/raw/multipart; maxBytes: ...)`:按路由限制请求体。
|
||||
- `@binding custom` 生成保留的绑定 hook;`@handlerMode manual` 生成保留的整个 Handler,raw/multipart 使用这个入口。
|
||||
- manual 可用 `@produces "text/plain"` 等声明无 DTO 成功输出。默认状态 200,初始手写 stub 返回 501。
|
||||
|
||||
完整语法、边界和示例见 [迁移扩展指南](MIGRATION.md)。新增生成代码要求 CLI 与框架依赖一起升级到 `v0.9.0-preview`;不兼容全部 goctl 语法。
|
||||
|
||||
## 参数语义
|
||||
|
||||
无 optional/default 的字段按“是否出现”判断必填,`false`、`0`、`""` 是已提供的值。额外声明 `binding:"required"` 才会拒绝标量零值。默认值只在字段缺失时使用,不替换显式的空值。
|
||||
|
||||
业务需要区分“没传”和“传了零值”时使用 `*T` 加 optional。指针允许 JSON null;非指针拒绝 null;`binding:"required"` 拒绝指针 null。缺失与 null 都会得到 nil,本版没有三态 Nullable 类型。响应端的 `optional` 不等于 Go 编码器的 `omitempty`;需要省略零值时显式使用 `omitempty`。
|
||||
业务需要区分“没传”和“传了零值”时使用 `*T` 加 optional。指针允许 JSON null;普通非指针字段拒绝 null(动态 `any` 允许);`binding:"required"` 拒绝指针 null。缺失与 null 都会得到 nil,本版没有三态 Nullable 类型。响应端的 `optional` 不等于 Go 编码器的 `omitempty`;需要省略零值时显式使用 `omitempty`。
|
||||
|
||||
JSON 必须是一个 object,Content-Type 为 application/json。默认最多 1 MiB,包含 chunked/未知长度和尾部空白;超限返回 413。重复 JSON key、未知字段、多个 JSON 值、标量 query/header 重复值、格式和约束错误返回 400;错误 Content-Type 返回 415。失败不会部分修改目标 DTO;错误详情只保留在内部 cause,公共响应不回显原始参数。
|
||||
|
||||
@@ -124,6 +241,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
|
||||
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -1,12 +1,38 @@
|
||||
# Changelog
|
||||
|
||||
## v0.9.0-preview
|
||||
|
||||
- 标准 HTTP Handler/Middleware 桥接:跟踪 Raw 状态与提交,保留取消、流式能力和中间件跨请求状态;已提交响应不追加 JSON。
|
||||
- `.api` 支持动态 JSON `any` / `interface{}`,保持重复键、大小、外围未知字段校验和数字精度。
|
||||
- 路由级请求体限额、自定义绑定和 manual Handler;上传、原始 body、下载可保留手写实现,重生成受保护。
|
||||
- 自定义 scalar 显式对接项目 JSON/Text 方法;支持可空和集合,分别生成请求/响应 OpenAPI;不放宽其他字段。
|
||||
- 新增 [迁移扩展指南](MIGRATION.md)、可运行示例和真实生成项目回归。CLI 与框架依赖一起升级。
|
||||
|
||||
## 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 的问题,拒绝后关闭未读连接。
|
||||
|
||||
|
||||
182
MIGRATION.md
Normal file
182
MIGRATION.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# 已有 HTTP 项目逐步接入
|
||||
|
||||
从 `v0.9.0-preview` 开始,以下能力贯通运行时、`.api`、代码生成和 OpenAPI。CLI 和项目框架依赖一起升级;入口见 [README](README.md)。这些都是通用扩展点,框架不内置任何项目的 ID、支付回调、鉴权或数据库规则。
|
||||
|
||||
## 1. 保留标准 Handler 与中间件
|
||||
|
||||
```go
|
||||
app := omni.New(omni.WithLogger(true))
|
||||
app.Use(httpx.WrapMiddleware(existingMiddleware)) // func(http.Handler) http.Handler
|
||||
// go-zero 风格的 func(http.HandlerFunc) http.HandlerFunc:
|
||||
app.Use(httpx.WrapFuncMiddleware(existingFuncMiddleware))
|
||||
app.POST("/legacy", httpx.WrapHandler(http.HandlerFunc(existingHandler)))
|
||||
```
|
||||
|
||||
实际使用时先配置所有 `Use`,再注册路由。`WrapHandler` 也接受 `http.FileServer` 等标准 `http.Handler`。
|
||||
|
||||
- Handler 经过 App 的日志、requestId、恢复和应用中间件链,原来的 URL、状态码、Header、响应内容由它自己控制。
|
||||
- `c.Raw()` 的写入也会更新 `c.Status()` / `c.Responded()`;404 会记为 404。提交响应后返回错误或 panic,不会再追加错误 JSON。103 等临时响应不算最终提交。
|
||||
- 桥接器不缓存完整响应。下载继续流式复制;底层支持时保留 Flusher、Hijacker、Pusher、ReaderFrom、CloseNotifier。`http.ResponseController` 可沿 `Unwrap` 访问底层控制能力。能力仍取决于网络适配器和其他中间件。
|
||||
- 请求 context、取消信号和已有值继续传递,Omni 的路径参数可用 Go 1.22 的 `r.PathValue("id")` 读取。其他框架私有的参数读取器仍需由项目适配。标准中间件应从原 `r.Context()` 派生上下文;不能换成无关的 Background,也不能在 ServeHTTP 返回后调用 next。异步 timeout middleware 不在此桥接器的契约内。
|
||||
- 标准中间件在每条注册链构造一次,状态会跨请求保留。下游返回的错误在标准中间件退出前编码,因此压缩、审计等中间件能处理错误响应。它们自己的缓冲或改写行为仍由项目负责。
|
||||
|
||||
调用流使用 `App.Handle/GET/POST` 等入口即可,不要绕到原始 Router 注册。暂不使用 `.api` 的旧接口也能这样接入。
|
||||
|
||||
## 2. 动态 JSON:允许渐进补全 DTO
|
||||
|
||||
```go
|
||||
type LegacyReply {
|
||||
Data any `json:"data"`
|
||||
}
|
||||
```
|
||||
|
||||
`interface{}` 是 `any` 的等价写法,支持 `[]any`、`map[string]any` 和对应的 `interface{}` 写法。带方法的 interface 不支持,会提示明确的类型错误。
|
||||
|
||||
动态字段允许 object、array、string、number、bool、null;OpenAPI 字段 schema 为 `{}`,不假装知道内部结构。它放开的是**该字段的内容**,外围 DTO 未声明的字段仍被拒绝,JSON 重复键、深度、总大小限制仍生效。动态数字解析为 `json.Number`,避免大整数先转 float64 丢精度。原始 JSON 的空白、转义和字节布局不会保留;验签请走原始 body 入口。
|
||||
|
||||
这不会自动补齐缺少的业务路由,也不代表兼容全部 goctl 语法。能确定结构的新接口优先使用命名 DTO。
|
||||
|
||||
## 3. 按路由限制 body 与保留手写入口
|
||||
|
||||
普通 JSON 路由只声明需要的大小,不改变其他路由默认的 1 MiB:
|
||||
|
||||
```text
|
||||
@body (mode: json
|
||||
maxBytes: 2097152
|
||||
)
|
||||
@handler createItem
|
||||
post /items (CreateItemReq) returns (ItemResp)
|
||||
```
|
||||
|
||||
`maxBytes` 单位为字节,允许 1 至 1 TiB;不写时为 1 MiB。已知超长或读取未知长度/chunked body 超限都按 413 处理。内置 App/RouterGroup 在全局中间件读取前安装 reader 限额;同时保持接口响应策略。网络层若有更小限额,以更小值为准。手写 Handler 吞掉读取错误或已经提交响应后,框架不能重新改写 HTTP 状态,务必检查读取错误。
|
||||
|
||||
### 只替换绑定函数
|
||||
|
||||
```text
|
||||
@binding custom
|
||||
@body (mode: json
|
||||
maxBytes: 65536
|
||||
)
|
||||
@handler createItem
|
||||
post /items (CreateItemReq) returns (ItemResp)
|
||||
```
|
||||
|
||||
首次生成 `internal/handler/<group>/createItemBind.go`:
|
||||
|
||||
```go
|
||||
func (h *Handler) bindCreateItem(c *httpx.Context, req *types.CreateItemReq) error {
|
||||
// 调用项目自己的解码器,填充 req;失败返回 error。
|
||||
}
|
||||
```
|
||||
|
||||
Handler 继续调用 Service 并使用统一响应;只把绑定过程交给这个函数。初始 hook 返回 501,不能忽略实现。自定义绑定需自己实现 Content-Type、未知字段、尾部数据等校验,并遵守 DTO 文档;框架负责路由总大小限制,不隐式套第二次 JSON 绑定。
|
||||
|
||||
### 上传、原文验签、下载
|
||||
|
||||
```text
|
||||
@handlerMode manual
|
||||
@body (mode: multipart
|
||||
maxBytes: 10485760
|
||||
)
|
||||
@handler upload
|
||||
post /files
|
||||
|
||||
@handlerMode manual
|
||||
@body (mode: raw
|
||||
maxBytes: 65536
|
||||
)
|
||||
@produces "text/plain"
|
||||
@handler callback
|
||||
post /callback
|
||||
|
||||
@handlerMode manual
|
||||
@produces "application/octet-stream"
|
||||
@handler download
|
||||
get /download
|
||||
```
|
||||
|
||||
首次生成 `<operation>Manual.go`,由项目实现整个 `func(h *Handler) Callback(c *httpx.Context) error`;依赖通过 `h.deps` 访问。生成器保留路由、中间件与文档管理,不会预读、JSON 解码或重新包装手写输出,也不生成这个路由的 Service 方法。需要调用 Service 时自行组合。
|
||||
|
||||
- manual 无返回类型默认成功状态 200;也可 `returns (UploadResp)` 描述 JSON DTO,手写实现按声明调用 `c.OK` 等。`@produces` 用于无 DTO 的手写输出,描述成功响应媒体类型,不会自动设置运行时 Header。
|
||||
- raw 的文档为二进制请求体;multipart 为开放对象,具体文件字段与逐部分校验由项目实现。raw/multipart 请求 DTO 仅可含 path/form/header 字段,不能同时声明 JSON body 字段。`form` 始终指 URL query。
|
||||
- 原文验签直接读取 `c.Request().Body`,在声明的小限额内 `io.ReadAll`;大文件用 `MultipartReader` 配合 `io.Copy` 流式处理,不能整个读入内存。
|
||||
- 使用 `ParseMultipartForm` 时,其内存参数只是内存阈值,不是总上传限额。App/桥接器在请求结束(含错误和 panic)后删除临时文件;业务仍须关闭 `FormFile` 得到的文件。`MultipartReader` 不自动建临时文件,业务自行创建的文件由业务清理。
|
||||
- 手写 Handler 的成功输出由它控制;返回的 error、鉴权拒绝仍走接口的错误策略。三方若要求错误也返回指定文本,使用 [自定义 Encoder](RESPONSE.md#手写路由配置文件与三方回调) 并维护对应错误文档;`@produces` 不会改变错误协议。
|
||||
|
||||
直接注册 Go 路由:
|
||||
|
||||
```go
|
||||
app.HandleWithBodyLimit("POST", "/upload", 10<<20, nil, uploadHandler)
|
||||
// nil encoder 沿用 App 策略,也可传项目自己的 response.Encoder。
|
||||
```
|
||||
|
||||
通用注册器使用 `httpx.HandleWithBodyLimit(...)`;实现 `httpx.BodyLimitRegistrar` 可像 App 一样在全局中间件前限流量,未实现时只能在路由中间件入口限制。单独 `httpx.LimitBody` 同样受中间件顺序影响。
|
||||
|
||||
### 重新生成规则
|
||||
|
||||
`handlersGen.go`、`routesGen.go`、DTO、OpenAPI 继续由生成器管理;`*Bind.go`、`*Manual.go` 只首次创建,后续不会覆盖或删除。切换 manual 回自动模式时,先迁移实现并自行移除对应 Manual 文件,否则生成器明确拒绝,避免重复方法。修改 DTO 导致手写函数签名不匹配时,需要一起调整项目代码;`go test ./...` 是必要检查。
|
||||
|
||||
## 4. 自定义标量:项目定义行为,框架连接调用
|
||||
|
||||
例如输入 ID 兼容数字/字符串、输出统一为字符串:
|
||||
|
||||
```text
|
||||
scalar BusinessID (
|
||||
goImport: "example.com/billing/internal/ids"
|
||||
goType: ID
|
||||
jsonInput: string | int64
|
||||
jsonOutput: string
|
||||
text: true
|
||||
)
|
||||
type ItemReq {
|
||||
ID BusinessID `path:"id"`
|
||||
}
|
||||
type ItemResp {
|
||||
ID BusinessID `json:"id"`
|
||||
}
|
||||
```
|
||||
|
||||
`goImport`/`goType` 指向项目已有的具体 Go 类型,不是框架内置 ID。生成器创建类型别名和 `types.ContractScalars()`,然后把注册表显式传给每个 Binder。运行时使用标准接口:
|
||||
|
||||
| 项目类型的方法 | 用途 |
|
||||
| --- | --- |
|
||||
| 值接收者 `MarshalJSON() ([]byte, error)` | JSON 输出 |
|
||||
| 指针接收者 `UnmarshalJSON([]byte) error` | JSON 输入 |
|
||||
| 指针接收者 `UnmarshalText([]byte) error` | `text: true` 时的 path/query/header |
|
||||
|
||||
完整参考 [示例 ID 类型](examples/compatibility/ids/id.go)。CLI 校验声明并生成代码,不执行项目 Go 代码;启动时校验方法是否齐全,缺失会明确失败。
|
||||
|
||||
输入可选 `string`、`bool`、`int32`、`int64`、`float64`,用 `|` 声明多个允许的 JSON 类型;输出只声明一个。框架先校验 JSON 类型和数字范围,再调用项目转换方法。字符串是否是合法 ID、是否允许空串或负数由项目方法决定,不能把数字溢出静默截断。path/query/header 实际传输文本,OpenAPI 对这些位置描述 string;query 切片通过重复键传递。
|
||||
|
||||
普通字段不受注册影响,普通 `int64` 仍拒绝 JSON 字符串。没有全局注册表,也不会自动给所有 ID 放宽规则。标量可嵌套、放入切片/map,`*BusinessID` 允许 null;缺失默认必填,`optional`/`omitempty` 可选。标量的额外约束放在转换器中,目前不叠加 `binding`、default/options/range 等标签。
|
||||
|
||||
请求 OpenAPI 按 `string | int64` 生成 `anyOf`,响应按 `string` 生成,避免把输入兼容误描述为输出也有两种类型。输出方法必须遵守声明,框架不会每次重新验证响应;项目需为自定义类型测试边界行为。
|
||||
|
||||
不使用 `.api` 也可显式注册:
|
||||
|
||||
```go
|
||||
binder := contract.MustNew[CreateReq](
|
||||
contract.ScalarOf[ids.ID]("string|int64", "string", true),
|
||||
)
|
||||
// binder.Bind(c.Request(), httpx.RouteParams(c.Context()), &req)
|
||||
```
|
||||
|
||||
## 可运行示例与验收
|
||||
|
||||
```sh
|
||||
go run ./examples/compatibility
|
||||
curl -i http://127.0.0.1:8080/legacy
|
||||
curl -i -X POST http://127.0.0.1:8080/echo \
|
||||
-H 'Content-Type: application/json' -d '{"id":9223372036854775807,"data":{"hello":true}}'
|
||||
curl -i -F file=@README.md http://127.0.0.1:8080/upload
|
||||
curl -OJ http://127.0.0.1:8080/download
|
||||
```
|
||||
|
||||
完整契约:[compatibility.api](examples/contracts/compatibility.api)。生成项目起初的手写 hook 仍为 501;真实实现与原文 HMAC 验签回归见 [消费者 fixture](cmd/go-omni/apigen/testdata/compatibility)。
|
||||
|
||||
```sh
|
||||
GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race ./cmd/go-omni/apigen -run Compatibility
|
||||
go test -race ./httpx ./contract
|
||||
```
|
||||
|
||||
这些是框架验收,不代表任何业务项目已经迁移完毕;业务的路由覆盖、响应兼容、权限、资金事务和数据库行为需要业务自己的回归。
|
||||
537
README.md
537
README.md
@@ -1,314 +1,381 @@
|
||||
# GoOmni
|
||||
|
||||
GoOmni 是一套面向业务复用的 Go 后端基础框架。当前默认使用只依赖标准库的网络适配器,业务代码只依赖 GoOmni 的 transport-neutral API,后续可以替换为自研网络层。
|
||||
GoOmni 是面向多个业务项目复用的 Go 后端框架:用 `.api` 定义接口、请求和返回类型,生成项目目录、HTTP 接入代码及 OpenAPI,业务代码负责 Service 和 Repository。也可以在已有项目中直接使用 `omni.New()`,或单独引入日志、存储等模块。
|
||||
|
||||
远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本:
|
||||
**从这份 README 开始即可接入。** 当前版本为 **`v0.9.0-preview`**,Go **1.22+**;CLI 和项目依赖使用同一固定版本。框架不依赖 Gin,默认使用标准库网络层,提供可切换的 native HTTP/1.1 和实验性 HTTP/2 适配器。
|
||||
|
||||
响应字段、业务码、登录方式、权限规则和数据库表结构由接入项目决定。文中的 `code/msg/data`、`200/9999` 都是示例配置,可以替换。
|
||||
|
||||
## 选择接入方式
|
||||
|
||||
| 你的项目 | 从哪里开始 |
|
||||
| --- | --- |
|
||||
| 新建服务,先定义接口再开发 | [新项目:生成并运行](#新项目生成并运行) |
|
||||
| 已有服务,逐步接入 HTTP 或基础模块 | [已有项目:按模块接入](#已有项目按模块接入) |
|
||||
| 已有标准 Handler、动态 JSON、上传或自定义 ID 类型 | [迁移扩展指南](MIGRATION.md) |
|
||||
| 想统一返回体、业务错误或三方回调 | [配置公共响应与独立协议](#配置公共响应与独立协议) |
|
||||
| 要接日志、数据库、鉴权和存储 | [业务开发与依赖装配](#业务开发与依赖装配) |
|
||||
| 要切换网络层、配置 TLS 和退出清理 | [配置与运行](#配置与运行) |
|
||||
| 已接入,需要升级和检查生成代码 | [日常开发与升级](#日常开发与升级) |
|
||||
|
||||
## 准备环境与安装
|
||||
|
||||
module 地址是 `git.yuntuo.hk/k2safe/go-omni`。先确保本机 Go 和 Git 可用,并具有仓库读取权限;使用自己的 Git 凭据管理器,不把账号或 Token 写进 module 地址。
|
||||
|
||||
以下命令在同一个终端执行。已有 `GOPRIVATE` 时,把 `git.yuntuo.hk` 追加到原来的逗号分隔列表。
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.7.0-preview
|
||||
export GOPRIVATE=git.yuntuo.hk
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.9.0-preview
|
||||
export PATH="$(go env GOPATH)/bin:$PATH"
|
||||
go-omni --help
|
||||
```
|
||||
|
||||
`v0.4.0` 移除了 Gin 和 Gin 专属的 `WithMode` 配置;`omni.New()` 直接使用标准库路由,已有业务 Handler / Service 无需修改。
|
||||
如果配置了自定义 `GOBIN`,将那个目录加入 PATH。只引用框架包、不生成项目时可以跳过 CLI 安装,直接使用下文的 `go get`。
|
||||
|
||||
创建新业务项目:
|
||||
## 新项目:生成并运行
|
||||
|
||||
在准备存放业务项目的目录执行,`./hello` 必须是新目录或空目录;把 `example.com/hello` 换成自己的 Go module 名称。
|
||||
|
||||
```sh
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.7.0-preview
|
||||
go-omni new --module example.com/billing ./billing
|
||||
cd billing
|
||||
go-omni api new --framework-version v0.9.0-preview \
|
||||
--module example.com/hello ./hello
|
||||
cd hello
|
||||
go mod tidy
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
远程安装的 CLI 默认引用自身版本,生成的项目不需要本地 `replace`。
|
||||
具体版本和本地开发选项见 [CLI 文档](cmd/go-omni/README.md)。
|
||||
|
||||
需要数据库持久化时,使用 `go-omni new --database mysql --module example.com/billing ./billing`。
|
||||
生成项目包含完整用户 CRUD、数据库 Repository、ServiceContext、建表脚本和就绪探针。
|
||||
先应用生成的 `schema.sql`,再配置 `GO_OMNI_DB_DSN`;启动时会检查连接与表结构,退出时释放连接池。
|
||||
|
||||
## API 定义与项目生成
|
||||
|
||||
当前源码新增 **API 定义驱动项目生成**:写 `.api` 定义请求、响应和路由,自动生成项目目录、DTO、Handler、Service 骨架及 OpenAPI。重复生成保留业务代码,支持只读 CI 检查,完整语法和示例见 [API 开发指南](API.md)。
|
||||
另开终端验证:
|
||||
|
||||
```sh
|
||||
go run ./cmd/go-omni api new --framework-dir . \
|
||||
--module example.com/omniDemo ../omniDemo
|
||||
curl -i http://127.0.0.1:8080/api/greet/Alice
|
||||
curl -i http://127.0.0.1:8080/health/ready
|
||||
curl -sS http://127.0.0.1:8080/openapi.json
|
||||
```
|
||||
|
||||
API 生成支持 `--database mysql --storage local --log-output file` 组合初始化。上面的命令使用本地源码;远程安装见 [发布说明](RELEASE.md),完整业务案例见 [userService](examples/userService/README.md)。
|
||||
第一个接口返回 HTTP 200,业务数据为 `{"message":"Hello Alice"}`。初始模板未配置响应格式,使用默认公共体:
|
||||
|
||||
## 目录结构
|
||||
```json
|
||||
{"code":"OK","message":"success","data":{"message":"Hello Alice"},"requestId":"本次请求编号"}
|
||||
```
|
||||
|
||||
按 Ctrl+C 停止服务。项目依赖已固定为远程版本,不需要克隆框架源码或添加本地 `replace`。
|
||||
|
||||
### 生成的项目放什么
|
||||
|
||||
```text
|
||||
go-omni/
|
||||
├── app/ # 应用启动和生命周期
|
||||
├── httpx/ # 与网络框架无关的 HTTP 抽象
|
||||
├── response/ # 统一响应
|
||||
├── errors/ # 统一错误
|
||||
├── health/ # 存活和就绪检查
|
||||
├── middleware/ # 请求 ID 等基础中间件
|
||||
├── logx/ # 业务层结构化日志
|
||||
├── metrics/ # 请求指标与状态统计
|
||||
├── config/ # 配置管理
|
||||
├── validate/ # 参数校验
|
||||
├── contract/ # API 字段契约、严格多源绑定与文档约束
|
||||
├── auth/ # 认证和权限接口
|
||||
├── database/ # database/sql 生命周期和事务
|
||||
├── cache/ # 有界内存缓存、Redis 适配与合并回源
|
||||
├── httpc/ # 出站 HTTP 连接池、超时和重试
|
||||
├── task/ # 有界后台任务与间隔调度
|
||||
├── queue/ # 持久队列接口与 Redis Streams 适配
|
||||
├── storage/ # Local、S3/R2、OSS 与临时下载链接
|
||||
├── openapi/ # 路由和 OpenAPI 3.0.3 契约
|
||||
├── telemetry/ # 可注入 OpenTelemetry 观测
|
||||
├── resilience/ # 熔断与上游并发隔离
|
||||
├── audit/ # 显式业务审计与可注入 Sink
|
||||
├── pagination/ # 有界分页和统一结果
|
||||
├── transport/nethttp/ # 标准库网络适配器
|
||||
├── transport/native/ # 自研 HTTP/1.1 服务端预览
|
||||
├── transport/http2/ # opt-in HTTP/2 实验适配器
|
||||
├── examples/ # 示例项目
|
||||
└── cmd/ # go-omni 项目脚手架
|
||||
hello/
|
||||
├── api/service.api # 维护接口定义,也可 import 其他 .api
|
||||
├── cmd/server/main.go # 启动、路由注册、优雅退出
|
||||
├── etc/config.yaml # 项目配置
|
||||
├── internal/
|
||||
│ ├── settings/ # 配置读取
|
||||
│ ├── servicecontext/ # 数据库、日志、外部客户端等依赖
|
||||
│ ├── types/typesGen.go # 生成的请求与业务返回类型
|
||||
│ ├── handler/ # 生成的绑定、响应策略、路由及文档
|
||||
│ ├── service/greet/ # 在这里实现业务逻辑
|
||||
│ ├── repository/greet/ # 在这里实现数据访问
|
||||
│ └── middleware/ # 声明鉴权等中间件后生成对应 hook
|
||||
├── docs/openapi.json # 生成的 OpenAPI 3.0.3
|
||||
└── .goOmni/apiManifest.json # 生成文件所有权与校验信息,一起提交 Git
|
||||
```
|
||||
|
||||
main 分支新增缓存、出站 HTTP、限流/幂等/超时中间件、后台任务和 `go-omni add` 模块脚手架。
|
||||
使用方式、默认容量和进程内能力的适用范围见 [业务基础模块](MODULES.md),
|
||||
可以直接运行 [组合示例](examples/modules/README.md)。这些能力从 `v0.7.0-preview` 提供。
|
||||
修改 `api/service.api` 后重新生成,业务写在 `internal/service`,数据访问写在 `internal/repository`,依赖在 `internal/servicecontext` 装配。声明业务错误后还会生成 `internal/apierrors/errorsGen.go`。
|
||||
|
||||
扩展模块还包括 JWT/Session、Policy、文件配置及安全热更新、Redis 共享中间件、持久队列、多平台对象存储、OpenAPI、OpenTelemetry、熔断、迁移、审计和分页。
|
||||
使用与边界见 [扩展模块](ADVANCED.md),可运行 [文件服务组合示例](examples/platform/README.md)。go-omni 不集成 Orbit;需要流程编排的业务项目独立接入。
|
||||
`api new` 的问候接口可以直接运行;自定义接口新生成的 Service 和鉴权 hook 返回 **501**,需要实现后才可用。生成器不会根据 HTTP 字段猜数据库表或业务规则。
|
||||
|
||||
## 快速开始
|
||||
### 从自己的接口定义生成
|
||||
|
||||
保存下面的完整示例为 `catalog.api`,再执行后面的生成命令。这里的响应格式、错误码和提示都由这个业务项目定义。
|
||||
|
||||
```text
|
||||
syntax = "v1"
|
||||
|
||||
response CommonResponse (
|
||||
body: {"code":"${code}", "msg":"${message}", "data":"${data}"}
|
||||
codeType: int32
|
||||
successCode: 200
|
||||
errorCode: 9999
|
||||
successMessage: "操作成功"
|
||||
dataMode: always
|
||||
codes: {"ITEM_NOT_FOUND":404001}
|
||||
)
|
||||
|
||||
error ItemNotFound 404 "ITEM_NOT_FOUND" "记录不存在"
|
||||
|
||||
type GetItemReq {
|
||||
ItemID string `path:"itemId" binding:"required"`
|
||||
}
|
||||
type ItemResp {
|
||||
ItemID string `json:"itemId"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
@server (
|
||||
group: item
|
||||
prefix: /api/v1
|
||||
response: CommonResponse
|
||||
)
|
||||
service catalog-api {
|
||||
@doc "查询商品"
|
||||
@errors ItemNotFound
|
||||
@handler getItem
|
||||
get /items/:itemId (GetItemReq) returns (ItemResp)
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
go-omni api validate --api catalog.api
|
||||
go-omni new --api catalog.api --framework-version v0.9.0-preview \
|
||||
--module example.com/catalog ./catalog
|
||||
cd catalog
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
将生成的 `internal/service/item/getItem.go` 替换为下面的演示实现。实际业务中换成 Repository 查询,Service 返回业务数据或错误,不再手工包装公共体:
|
||||
|
||||
```go
|
||||
package item
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"example.com/catalog/internal/apierrors"
|
||||
"example.com/catalog/internal/types"
|
||||
)
|
||||
|
||||
func (s *Service) GetItem(ctx context.Context, req *types.GetItemReq) (*types.ItemResp, error) {
|
||||
if req.ItemID != "1001" {
|
||||
return nil, apierrors.ItemNotFound()
|
||||
}
|
||||
return &types.ItemResp{ItemID: req.ItemID, Name: "示例商品"}, nil
|
||||
}
|
||||
```
|
||||
|
||||
示例 import 对应上面的 `example.com/catalog`,使用其他 module 名称时同步替换。启动后,在另一个终端验证:
|
||||
|
||||
```sh
|
||||
# catalog 项目目录中启动;先停掉前面的 hello 服务,释放 8080 端口。
|
||||
go run ./cmd/server
|
||||
# 另一个终端:
|
||||
curl -i http://127.0.0.1:8080/api/v1/items/1001
|
||||
curl -i http://127.0.0.1:8080/api/v1/items/missing
|
||||
```
|
||||
|
||||
成功时得到 `{"code":200,"msg":"操作成功","data":{"itemId":"1001","name":"示例商品"}}`;不存在时为 HTTP 404、`{"code":404001,"msg":"记录不存在","data":null}`。
|
||||
|
||||
需要 MySQL、文件存储或文件日志时,**首次生成**可以使用:
|
||||
|
||||
```sh
|
||||
go-omni new --api catalog.api --framework-version v0.9.0-preview \
|
||||
--module example.com/catalogFull --database mysql --storage local \
|
||||
--log-output file --log-format json ./catalogFull
|
||||
```
|
||||
|
||||
该命令在 `catalog.api` 所在目录执行,目标仍需是新目录。数据库迁移、表结构和 Repository 由项目维护,API 模板不自动建表;MySQL DSN 通过 `GO_OMNI_DB_DSN` 提供。存储还可选 `s3`、`r2`、`oss`,按生成项目的配置说明提供桶、端点和凭据。完整装配例子见 [userService](examples/userService/README.md)。
|
||||
|
||||
不采用 `.api` 时,也可以用 `go-omni new --framework-version v0.9.0-preview --module example.com/billing ./billing` 创建手写接口的 CRUD 示例项目;两种生成方式选择一种即可。
|
||||
|
||||
## 已有项目:按模块接入
|
||||
|
||||
在业务项目的 **go.mod 所在目录**执行:
|
||||
|
||||
```sh
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.9.0-preview
|
||||
```
|
||||
|
||||
已有项目可直接复用现有目录、Service、Repository 和数据库连接,先选择一个接口接入。也可以只用 `logx`、`storage` 等包,暂时保留原来的 HTTP 框架。
|
||||
|
||||
下面是可独立运行的 `main.go` 示例;迁入现有项目时,把路由调用接到自己的业务服务即可。
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
|
||||
func main() {
|
||||
app := omni.New()
|
||||
|
||||
app.GET("/health", func(ctx *omni.Context) error {
|
||||
return ctx.OK(map[string]string{"status": "ok"})
|
||||
})
|
||||
|
||||
app.Run(":8080")
|
||||
}
|
||||
```
|
||||
|
||||
成功响应默认使用统一结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "success",
|
||||
"data": {"status": "ok"},
|
||||
"requestId": "..."
|
||||
}
|
||||
```
|
||||
|
||||
业务错误可以直接返回:
|
||||
|
||||
```go
|
||||
return omni.NotFound("USER_NOT_FOUND", "user was not found")
|
||||
```
|
||||
|
||||
默认网络适配器位于 `transport/nethttp`。业务层不需要导入具体网络库;如果后续实现自己的网络层,只需要实现 `httpx.Router` / `httpx.RouterGroup`,然后通过 `omni.NewWithRouter` 启动应用:
|
||||
|
||||
```go
|
||||
application := omni.NewWithRouter(myRouter)
|
||||
```
|
||||
|
||||
仓库还提供了使用标准库路由的 `transport/nethttp` 参考实现。两种适配器已通过同一套路由、业务处理链和取消传播测试,完整用户 CRUD 复用同一份 Handler / Service。
|
||||
|
||||
自研适配器可以直接运行 `transport/transporttest.Run` 验收。具体规则、v0.4.0 的斜杠 / Any / 405 行为调整,以及自研网络内核与当前 HTTP 接口的边界,见 [适配层契约](transport/README.md)。
|
||||
|
||||
`app.Engine()` 返回标准 `http.Handler`,`ctx.Raw()` 返回适配器提供的 `http.ResponseWriter`,业务 API 不锁定具体网络实现。
|
||||
|
||||
需要试用第一版自研网络层时,业务注册代码无需改变,只替换启动器:
|
||||
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/transport/native"
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
app := omni.New()
|
||||
app.GET("/health", func(ctx *omni.Context) error {
|
||||
return ctx.OK(map[string]string{"status": "ok"})
|
||||
})
|
||||
if err := app.RunWith(ctx, native.New(nil), ":8080"); err != nil {
|
||||
log.Fatal(err)
|
||||
func run() error {
|
||||
closeLog, err := logx.Setup(logx.Config{
|
||||
Service: "catalog", Level: "info", Format: "json", Output: "stdout",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeLog()
|
||||
|
||||
app := omni.New(
|
||||
omni.WithLogger(true),
|
||||
omni.WithResponse(response.Config{
|
||||
Body: map[string]any{
|
||||
"code": "${code}", "msg": "${message}", "data": "${data}",
|
||||
},
|
||||
CodeType: "int32", SuccessCode: 200, ErrorCode: 9999,
|
||||
SuccessMessage: "操作成功", DataMode: "always",
|
||||
Codes: map[string]any{"ITEM_NOT_FOUND": 404001},
|
||||
}),
|
||||
)
|
||||
app.GET("/api/v1/items/:itemId", func(c *omni.Context) error {
|
||||
itemID := c.Param("itemId")
|
||||
logx.InfoContext(c.Context(), "item.get", "itemId", itemID)
|
||||
if itemID != "1001" {
|
||||
return omni.NotFound("ITEM_NOT_FOUND", "记录不存在")
|
||||
}
|
||||
return c.OK(map[string]string{"itemId": itemID, "name": "示例商品"})
|
||||
})
|
||||
// 第三方协议可明确输出文本,不经过公共 JSON 包装。
|
||||
app.GET("/ping", func(c *omni.Context) error {
|
||||
return c.Text(http.StatusOK, "pong")
|
||||
})
|
||||
return app.Run("127.0.0.1:8080")
|
||||
}
|
||||
```
|
||||
|
||||
`transport/native` 当前支持 HTTP/1.1、Keep-Alive、`Content-Length` 和 chunked 请求体、`http.Flusher` 流式响应、可选 TLS、读写超时和优雅关闭;不支持的传输编码会被拒绝。它会复用连接状态和响应对象,并可通过 `MaxConnections`、`MaxConcurrentRequests` 限制内存峰值。它仍通过标准 HTTP 请求/响应接口连接业务层,适合在现有 Handler / Service 上做网络层验证。
|
||||
运行 `go run .`,访问 `/api/v1/items/1001`、`/api/v1/items/missing` 和 `/ping`,可分别验证成功、业务错误及纯文本输出。`App` 实现标准 `http.Handler`,也可挂进现有 `http.Server` 或父路由;这时监听、TLS、信号和资源关闭由现有入口负责,结束时调用 `app.Close()`。
|
||||
|
||||
native TLS 当前只支持并宣告 ALPN `http/1.1`;配置 `h2` 会在启动时快速失败。需要 HTTP/2 时,可由 Nginx/Cloudflare 终止后回源 HTTP/1.1,或使用默认的 `transport/nethttp` 适配器。
|
||||
现有 `http.HandlerFunc` 可直接通过 `app.POST("/legacy", httpx.WrapHandler(http.HandlerFunc(oldHandler)))` 接入;状态码与访问日志一致,下载和流式输出保持原协议。标准中间件、动态 JSON、上传/原文 body、手写绑定和自定义类型的完整用法见 [迁移扩展指南](MIGRATION.md)。
|
||||
|
||||
`transport/http2` 是独立的 opt-in 实验适配器,使用 `x/net/http2` 处理帧和流控,同时保留 GoOmni 的连接上限、全局请求并发闸门和生命周期边界。它不会改变 native 默认路径;实验入口、限制和验收结果见 [HTTP/2 进入条件](transport/native/HTTP2.md)。
|
||||
接入现有业务时,逐项确认:
|
||||
|
||||
自研网络层还提供 `server.Stats()`,可读取 accepted、rejected、active connections 和 active requests,与上面的业务请求 metrics 组合后能区分连接堆积、过载拒绝和 Handler 慢请求。`rejectedConnections` 表示连接闸门拒绝数,`rejectedRequests` 表示并发请求闸门返回 503 的次数。
|
||||
- **协议**:保留既有 URL、HTTP 状态、字段、分页和空值语义。已有业务错误类型需要在项目内转换为 Omni 错误,或使用 `WithResponseEncoder` 适配原错误;设置字段模板不会自动识别其他库的错误。
|
||||
- **鉴权**:把现有登录态和权限校验接入中间件,继续使用原来的授权范围和业务规则。原来依赖 `gin.Context` 等类型的 HTTP 代码需要适配;标准 `context.Context` 的业务服务可以继续复用。
|
||||
- **数据类型**:ID、金额、日期等按项目协议转换。用 `.api` 的 `scalar` 显式关联项目自己的类型与 JSON/Text 转换方法,分别声明输入和输出协议;普通字段保持严格绑定,见 [自定义标量](MIGRATION.md#4-自定义标量项目定义行为框架连接调用)。
|
||||
- **生成器**:它不会自动接管任意已有 Go 项目。希望引入 `.api` 时,先在独立新目录生成对照,再规划路由与生成目录的接入,不要用脚手架覆盖现有业务目录。
|
||||
|
||||
业务代码可以直接使用 `git.yuntuo.hk/k2safe/go-omni/logx` 打结构化日志,不需要依赖具体网络库:
|
||||
先通过一个接口的成功、参数错误、未登录、无权限及业务错误回归,再按模块迁移。框架不要求所有项目使用同一套业务目录或身份模型。
|
||||
|
||||
```go
|
||||
func (s *UserService) Create(ctx *httpx.Context) error {
|
||||
logx.InfoContext(ctx.Context(), "user.create", "email", input.Email)
|
||||
return nil
|
||||
}
|
||||
```
|
||||
## 配置公共响应与独立协议
|
||||
|
||||
需要基础运行指标时,可以注册 transport-neutral metrics middleware 和接口:
|
||||
|
||||
```go
|
||||
requestMetrics := omni.NewMetrics()
|
||||
app.Use(requestMetrics.Middleware())
|
||||
app.GET("/metrics", requestMetrics.Handler())
|
||||
```
|
||||
|
||||
接口返回请求总数、错误数、当前处理中请求、累计耗时和 HTTP 状态分布。它是进程内快照,生产环境可由业务侧定时采集到监控系统。
|
||||
|
||||
`WithLogger(true)` 会启用 GoOmni 的统一 HTTP 访问日志;日志内容和业务日志共用 `requestId`。应用启动时可以调用 `logx.Setup` 配置 JSON/文本格式、级别、服务名和输出文件。文件轮转、敏感字段脱敏、异常堆栈与慢请求配置见 [logx 文档](logx/README.md);文件直传与云端分片上传见 [storage 文档](storage/README.md)。 创建新业务项目时可通过 `go-omni new --log-output file --storage local` 按需生成配置、装配代码与可运行示例,当前源码使用方式见 [CLI 文档](cmd/go-omni/README.md)。
|
||||
|
||||
应用配置可以统一从 `GO_OMNI_*` 环境变量加载,再交给 `omni.New`:
|
||||
|
||||
```go
|
||||
settings, err := omni.LoadConfig("")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
logger, err := logx.New(settings.Log)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
application := omni.New(omni.WithConfig(settings))
|
||||
application.OnShutdown(logger.Close)
|
||||
```
|
||||
|
||||
常用变量包括 `GO_OMNI_ENABLE_LOGGER`、`GO_OMNI_LOG_LEVEL`、
|
||||
`GO_OMNI_LOG_FORMAT`、`GO_OMNI_LOG_OUTPUT`、`GO_OMNI_SERVICE`、
|
||||
`GO_OMNI_ENVIRONMENT`、`GO_OMNI_HTTP_ADDR`、`GO_OMNI_SHUTDOWN_TIMEOUT` 和
|
||||
`GO_OMNI_TRUSTED_PROXIES`。可信代理变量使用逗号分隔的 IP/CIDR,例如
|
||||
`GO_OMNI_TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8`;只有来自这些地址的连接才会解析
|
||||
`X-Forwarded-For`,默认只记录 socket 对端地址。数据库连接仍由
|
||||
`database.ConfigFromEnv` 从 `GO_OMNI_DB_*` 加载。
|
||||
|
||||
网络层也由同一份配置选择,Handler / Service / Repository 不需要导入具体适配器:
|
||||
|
||||
| 配置 | 默认值 / 用途 |
|
||||
| 接口来源或需求 | 配置位置 |
|
||||
| --- | --- |
|
||||
| `GO_OMNI_TRANSPORT` | `nethttp`;可选 `native`、`http2` |
|
||||
| `GO_OMNI_TLS_CERT_FILE` | TLS 证书 PEM 文件路径 |
|
||||
| `GO_OMNI_TLS_KEY_FILE` | TLS 私钥 PEM 文件路径,必须与证书一起配置 |
|
||||
| `GO_OMNI_SHUTDOWN_TIMEOUT` | `10s`,三种适配器共用的请求排空时间 |
|
||||
| `.api` 生成的接口 | `response Name (...)` 定义,`@server (response: Name)` 引用 |
|
||||
| 多个 `.api` 共用返回体 | 将声明放到 `common.api`,使用 `import "common.api"` |
|
||||
| 某个生成接口用另一种 JSON 公共体 | 路由声明 `@response OtherResponse` |
|
||||
| 只要业务 JSON、无成功包装 | 路由声明 `@response raw`;失败仍沿用组级格式 |
|
||||
| 手写路由和应用级 404/405 | `omni.WithResponse(response.Config{...})`,或读取配置后传入 `WithConfig` |
|
||||
| 成功和失败都要遵循特殊回调协议 | `HandleWithResponse` 绑定项目自己的 `response.Encoder` |
|
||||
| 单次直接输出 JSON、文本、XML、字节或空响应 | `c.JSON` / `c.Text` / `c.XML` / `c.Bytes` / `c.NoContent` |
|
||||
|
||||
例如运行自研 HTTP/1.1,或显式试用独立 HTTP/2 适配器:
|
||||
`${data}` 是业务返回值,`${message}` 是公开提示,`${code}` 是业务码;占位符保留原值类型,不是字符串拼接。还支持 `${requestId}`、`${ok}`、`${status}`,字段名和嵌套结构由项目决定。
|
||||
|
||||
```sh
|
||||
GO_OMNI_TRANSPORT=native go run ./examples/reference
|
||||
GO_OMNI_TRANSPORT=http2 GO_OMNI_HTTP_ADDR=127.0.0.1:8443 \
|
||||
GO_OMNI_TLS_CERT_FILE=./server.crt GO_OMNI_TLS_KEY_FILE=./server.key \
|
||||
go run ./examples/reference
|
||||
```
|
||||
- `codeType` 可以是 `string`、`int32`、`int64`;整数类型需要配置 `successCode` 和 `errorCode`。
|
||||
- `codes` 把逻辑错误码映射成对外业务码;未单独映射的失败使用 `errorCode`。JSON 业务码不会改变 HTTP 状态,例如 `404001` 仍对应 HTTP 404。
|
||||
- `successMessage` 设置成功提示;未设置 `errorMessage` 时沿用错误的公开提示。`messages` 可按逻辑错误码单独覆盖,优先级最高。全局 `errorMessage` 会替换未单独映射的所有失败提示,只有需要统一失败文案时才设置。
|
||||
- `dataMode: always` 在无数据时输出 null;默认 `omitNil` 省略模板里相应对象字段。没有 `returns` 的 `.api` 路由默认 HTTP 204、无响应体,这与 `200 + data:null` 是不同的接口契约。
|
||||
|
||||
`http2` 必须配置 TLS;native TLS 只协商 `http/1.1`,nethttp TLS 支持标准库的 HTTP/1.1 和 HTTP/2。
|
||||
未知适配器、缺少配对证书、无法加载证书时,`Run` / `RunContext` 在监听前返回错误并执行资源清理,不会退回其他协议。
|
||||
HTTP/2 仍为实验适配器,尚未合并到 native 多协议监听器。
|
||||
**生成接口的格式以 `.api` 为准**,修改后重新生成,运行时与 OpenAPI 一起更新;应用 YAML 不会覆盖它。404/405 没有匹配到某个 `.api` 接口,使用应用默认配置,需要统一时也配置应用默认值。
|
||||
|
||||
代码配置可写成 `omni.New(omni.WithTransport(omni.TransportNative), omni.WithTLS(certFile, keyFile))`。
|
||||
`ServeContext(ctx, listener)` 使用相同配置并接管已有 listener;失败或退出时也会关闭它。
|
||||
`RunWith(ctx, runner)` 保留自定义网络层入口,由 runner 自己管理 TLS、超时和网络参数;连接/请求上限等适配器专属选项仍通过这个入口设置。
|
||||
`NewServer` 仅创建标准库明文服务器,配置选择应用于 `Run`、`RunContext` 和 `ServeContext`。
|
||||
三种适配器的配置、真实 TLS 业务和关闭行为已通过 Linux 全量 race 及 10 次重复回归,见 [统一启动验收](performance/transportStartup.md)。
|
||||
文本/XML 回调用 Go 编码器或直接输出,`.api` 可用 `@handlerMode manual` 与 `@produces` 声明成功媒体类型;特殊错误协议仍需项目维护。`@response raw` 仅表示原始 JSON。前面示例的 `c.Text` 控制这次正常返回;需要连鉴权失败、业务错误和 panic 都按回调协议响应时使用 `HandleWithResponse`。完整配置和回调示例见 [响应参考](RESPONSE.md)。
|
||||
|
||||
`App` 同时实现标准 `http.Handler`。`Run` 监听 `SIGINT` / `SIGTERM`;`RunContext` 则由调用方的 context 控制退出,生成项目已使用 `signal.NotifyContext` 接好信号。
|
||||
退出时先停止接收新请求,等待活跃请求完成,再执行资源清理。超过 `ShutdownTimeout` 时强制关闭连接、取消请求 context,并返回超时错误。业务代码应响应 context 取消;框架无法强制结束忽略取消的业务 goroutine。数据库连接池由 `database.Open` 创建,在服务退出时调用 `Close`。
|
||||
## 业务开发与依赖装配
|
||||
|
||||
应用资源可以直接挂到 `omni.New` 返回的生命周期上:
|
||||
### 日志
|
||||
|
||||
业务包直接使用 `logx`,不需要 HTTP 框架对象:
|
||||
|
||||
```go
|
||||
application := omni.New()
|
||||
application.OnShutdown(db.Close)
|
||||
// ctx 是 Service 接收的标准 context.Context。
|
||||
logx.InfoContext(ctx, "order.created", "orderId", orderID)
|
||||
logx.ErrorContext(ctx, "order.failed", "error", err)
|
||||
```
|
||||
|
||||
清理函数会按注册顺序的逆序执行,`application.Close()` 和 `RunContext` 都会触发,且重复调用安全。
|
||||
入口用 `logx.Setup` 初始化,退出时关闭;生成的 API 项目已经完成日志初始化和退出清理。Service、Repository 和外部请求持续传递请求的 `context.Context`,业务日志可关联 `requestId`,同时保留取消信号。框架不规定或自动注入 `userId`、`tenantId`。
|
||||
|
||||
服务可以把数据库、缓存等依赖注册为就绪探针:
|
||||
`WithLogger(true)` 开启 HTTP 访问日志。格式、级别、文件轮转、脱敏键和慢请求阈值均可配置,见 [日志](logx/README.md)。
|
||||
|
||||
```go
|
||||
checker, err := health.New(health.Probe{
|
||||
Name: "database",
|
||||
Check: db.PingContext,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
application.GET("/health/live", checker.LivenessHandler())
|
||||
application.GET("/health/ready", checker.ReadinessHandler())
|
||||
### 鉴权与业务错误
|
||||
|
||||
`.api` 可声明认证方式和关联中间件,例如把下面的声明放入契约,并在相应 `@server` 块中加上 `auth: UserAuth`:
|
||||
|
||||
```text
|
||||
auth UserAuth (
|
||||
type: bearer
|
||||
middleware: Auth
|
||||
)
|
||||
```
|
||||
|
||||
存活检查只确认进程在线;就绪检查会执行全部探针,任意依赖失败时返回 HTTP 503。
|
||||
生成器会创建 `internal/middleware/auth.go`,由项目实现凭据校验和身份注入。声明会生成路由绑定与 OpenAPI 的认证描述,**不会自动实现登录或授权规则**;未实现的 hook 返回 501。手写路由可使用 `auth.RequireAuth(authenticator)` 或自己的中间件。JWT、Session 和 Policy 按需选择。
|
||||
|
||||
新项目脚手架会生成项目自己的 `internal/servicecontext`。它集中保存 Logger、数据库、缓存和外部客户端,Service 只依赖这个项目上下文,不把基础设施初始化散落到各个 Handler:
|
||||
业务错误在 `.api` 中用 `error` 声明,通过 `@errors` 关联路由后,生成可调用的错误函数和 OpenAPI 中对应的 401、403、409 等响应。手写路由可以直接返回 `omni.NotFound`、`omni.Forbidden` 等错误。已有项目在自己的适配层转换原错误,内部原因和公开提示分开处理。
|
||||
|
||||
```go
|
||||
services, err := servicecontext.New(ctx, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer services.Close()
|
||||
userService := service.NewUsers(services)
|
||||
```
|
||||
### 数据库、存储及其他模块
|
||||
|
||||
## 完整业务参考项目
|
||||
依赖在启动入口或项目自己的 `internal/servicecontext` 中创建,通过构造函数传给 Service/Repository,避免每个请求都新建连接池。只注册自己拥有的资源,例如 `app.OnShutdown(db.Close)`;关闭按注册的逆序执行。
|
||||
|
||||
[`examples/reference`](examples/reference/README.md) 提供按 Handler / Service / Repository / Model 分层的用户 CRUD 示例。默认使用并发安全的内存存储,支持分页、邮箱唯一校验、结构化请求日志和统一错误,无需配置数据库。
|
||||
| 能力 | 包与接入说明 |
|
||||
| --- | --- |
|
||||
| 数据库连接池、事务、显式迁移 | `database`,见 [数据库](database/README.md);表结构与驱动由项目选择 |
|
||||
| 本地文件、S3、R2、OSS、直传与临时下载 | `storage`,见 [文件存储](storage/README.md);业务依赖接口,装配时选择适配器 |
|
||||
| 缓存、出站 HTTP、限流、幂等、后台任务 | `cache`、`httpc`、`middleware`、`task`,见 [基础模块](MODULES.md) |
|
||||
| JWT/Session、策略、Redis 队列、审计、分页、观测 | 见 [扩展模块](ADVANCED.md);按需注入实现 |
|
||||
| 存活与就绪检查 | `health`,见 [健康检查](health/README.md);数据库等依赖作为 readiness 探针 |
|
||||
|
||||
从远程版本生成并升级一个独立业务模块,可运行:
|
||||
API 项目的数据库迁移需要单独维护并显式执行;启动不会自动改表。完整示例 [userService](examples/userService/README.md) 展示 MySQL、事务、鉴权、日志和文件导出,可参考依赖装配方式后接入自己的业务规则。
|
||||
|
||||
## 配置与运行
|
||||
|
||||
生成项目从 `etc/config.yaml` 读取配置,优先级为 **默认值 → 配置文件 → 环境变量**。密钥通过环境变量提供。手写入口需显式调用配置加载函数并传给 `omni.New(omni.WithConfig(settings))`;单独调用 `omni.New()` 不会自动读取环境变量。
|
||||
|
||||
| 环境变量 | 用途 |
|
||||
| --- | --- |
|
||||
| `GO_OMNI_HTTP_ADDR` | 监听地址;生成项目默认 `127.0.0.1:8080` |
|
||||
| `GO_OMNI_TRANSPORT` | `nethttp`(默认)、`native`、`http2` |
|
||||
| `GO_OMNI_TLS_CERT_FILE` / `GO_OMNI_TLS_KEY_FILE` | 成对设置 TLS 证书和私钥路径 |
|
||||
| `GO_OMNI_SHUTDOWN_TIMEOUT` | 优雅退出等待时间,默认 `10s` |
|
||||
| `GO_OMNI_ENABLE_LOGGER` / `GO_OMNI_LOG_LEVEL` | 访问日志开关、日志级别 |
|
||||
| `GO_OMNI_TRUSTED_PROXIES` | 可解析转发地址头的可信代理 IP/CIDR,逗号分隔 |
|
||||
| `GO_OMNI_DB_DSN` | 选择 MySQL 初始化时的数据库连接配置 |
|
||||
|
||||
例如在生成项目中切换 native:
|
||||
|
||||
```sh
|
||||
python3 scripts/verifyBusiness.py --version v0.6.0-preview
|
||||
GO_OMNI_TRANSPORT=native go run ./cmd/server
|
||||
```
|
||||
|
||||
该验收会在临时目录中先固定 `v0.2.0`,再升级到 `v0.6.0-preview`,检查无本地 replace、配置加载、结构化日志、requestId、CRUD、校验错误、业务错误和优雅退出。
|
||||
手写入口可用 `omni.WithTransport(omni.TransportNative)`;TLS 配置为 `omni.WithTLS(certFile, keyFile)`。native 提供 HTTP/1.1、Keep-Alive、TLS、超时、连接/请求并发上限和对象复用;HTTP/2 是单独的实验适配器,必须配置 TLS。默认 nethttp 的 TLS 支持 HTTP/1.1 与 HTTP/2。
|
||||
|
||||
标准库与 native 的并发、延迟和长连接内存对照见 [PERFORMANCE.md](PERFORMANCE.md)。`python3 scripts/verifyPerformance.py` 运行检查与 benchmark;增加 `--load --output /tmp/goOmniPerf.json` 可测量独立服务进程并保存报告。
|
||||
自研网络层通过 `RunWith(ctx, runner)` 接入监听与连接生命周期,通过 `NewWithRouter(router)` 替换路由;业务 Handler/Service 不需要导入具体适配器。细节与验收契约见 [网络适配层](transport/README.md)。
|
||||
|
||||
HTTP/2 仍是显式 opt-in 实验适配器,不会改变 native 的 HTTP/1.1 默认路径。Linux TLS 长测使用 `python3 scripts/verifyHTTP2.py --duration 30m`,默认使用普通 Go 运行模式;`--race` 默认改为 60 秒,用于并发检查,其额外内存开销不能当成生产运行基线。
|
||||
`Run` 处理 SIGINT/SIGTERM;`RunContext` 接收调用方控制的退出信号。服务先停止接收新请求,等待活跃请求完成,再清理资源;超时会取消请求并关闭连接,业务代码仍需响应 context 取消。
|
||||
|
||||
多连接混合场景使用 `python3 scripts/verifyHTTP2.py --scenario mixed --duration 30m`:8 条独立 TLS 连接,每条连接 14 个普通请求 worker、1 个慢上传 worker、1 个慢下载 worker,共 128 个 worker。上传为 256 KiB、每 4 KiB 等待 2 ms;下载为 8 MiB、每次最多读取 64 KiB 后等待 20 ms。日志分别记录三类成功数、传输字节数、每条连接的覆盖情况,以及停止并释放测试数据后两次 GC 的采样。
|
||||
## 日常开发与升级
|
||||
|
||||
该脚本默认把测试容器限制为 2 核、2 GiB 内存(禁用 swap)、256 个进程/线程,整个命令最多运行 40 分钟,中断和超时会清理本次容器。这些限制可用 `--cpus`、`--memory-mb`、`--pids-limit`、`--wall-timeout` 调整;共享服务器应先用 `--duration 60s` 短测,再逐步延长。每 15 秒输出的堆、RSS 和 FD 统计包含同一进程内的客户端与服务端。
|
||||
|
||||
Linux amd64 已完成 30 分钟、单 TLS 连接、128 个 worker 的普通模式长测:58,102,476 次请求,0 失败、0 协议错误,采样 RSS 峰值约 22.6 MiB,连接/活动请求归零且 FD 回到基线。完整采样和原始日志见 [HTTP/2 长测报告](performance/http2SoakLinux.json) 与 [原始日志](performance/http2SoakLinux.txt),此前的 race 对照见 [短测记录](performance/http2SmokeLinux.json)。这项结果覆盖单连接复用。
|
||||
|
||||
多连接慢客户端场景也已完成 Linux amd64 的 30 分钟长测:8 条 TLS 连接、128 个 worker,53,079,128 次普通请求、106,971 次慢上传、5,588 次慢下载,共 53,191,687 次,0 失败与协议错误。采样 RSS 峰值约 116.6 MiB(包含客户端、服务端和下载缓冲);退出后连接/请求归零,goroutine 回到 2、FD 回到 6;释放测试数据并两次 GC 后存活堆约 450 KiB,RSS 不要求立即归还操作系统。完整 120 份采样见 [混合长测报告](performance/http2MixedSoakLinux.json) 和 [原始日志](performance/http2MixedSoakLinux.txt)。连接级窗口耗尽、WINDOW_UPDATE 恢复及零窗口写阻塞的关闭回收也已有独立测试;Nginx 的 HTTP/2 回源验收结果见下方,适配器保持实验状态。
|
||||
|
||||
`python3 scripts/verifyHTTP2Proxy.py --count 3` 验证真实 Nginx 双段 TLS/h2、回源连接复用、证书拒绝、超时/取消恢复与新旧实例的 GOAWAY 排空;增加 `--race` 可运行并发检查。Linux amd64、Nginx 1.30.5 的普通与 race 模式各 3 轮均已通过。编译和测试容器顺序运行,均限制为 2 核、2 GiB;测试容器禁用外部网络,全部请求在容器内完成。使用方法、报告及当前边界见 [HTTP/2 代理回源验收](transport/http2/PROXY.md)。
|
||||
|
||||
在框架根目录运行:
|
||||
在已生成的 API 项目目录执行:
|
||||
|
||||
```sh
|
||||
go run ./examples/reference
|
||||
go-omni api validate
|
||||
go-omni api inspect
|
||||
go-omni api generate --dry-run
|
||||
go-omni api generate
|
||||
go-omni api generate --check
|
||||
go test ./...
|
||||
```
|
||||
|
||||
默认地址为 `http://127.0.0.1:8080`。示例文档提供完整的 curl 操作流程。数据只保存在内存中,重启后清空。
|
||||
`Gen.go`、`docs/openapi.json` 和 manifest 由生成器管理,手动修改会报告冲突。Service、Repository、中间件实现、main、配置和依赖装配属于项目,重复生成保留手写内容;契约改变后仍需自行调整业务签名并通过编译。将 `.api`、生成文件和 `.goOmni/apiManifest.json` 一起提交,CI 可运行 `generate --check` 检查是否同步。
|
||||
|
||||
框架的 `App` 也实现了标准 `http.Handler`,可以直接用于 `http.Server` 和 `httptest.NewServer`;业务路由可通过 `*omni.RouterGroup` 注册,无需导入网络库。
|
||||
升级时先安装目标版本的 CLI,再在业务 module 中 `go get` **同一版本**;API 项目随后执行上述生成与测试步骤,手写项目只需依赖升级及业务回归。`v0.9.0-preview` 的变化、迁移边界和验收记录见 [发布说明](RELEASE.md)。
|
||||
|
||||
## 当前边界
|
||||
## 按需深入
|
||||
|
||||
缓存、消息队列和定时任务暂时作为后续可选模块。认证层只定义 `Authenticator`、`Principal` 和路由保护中间件,具体 JWT、Session 或 OAuth 由项目实现。核心数据库层只依赖 `database/sql`,具体驱动、迁移和表结构由项目选择。
|
||||
| 需要了解 | 文档 |
|
||||
| --- | --- |
|
||||
| `.api` 类型、参数绑定、错误、鉴权和生成规则 | [API 开发指南](API.md) |
|
||||
| 公共响应体、提示优先级、独立回调协议 | [响应参考](RESPONSE.md) |
|
||||
| CLI 完整参数、本地框架开发和模块脚手架 | [CLI](cmd/go-omni/README.md) |
|
||||
| 完整 API 业务项目 | [userService](examples/userService/README.md) |
|
||||
| 手写接口分层示例 | [reference](examples/reference/README.md) |
|
||||
| 框架成熟度、历史变更 | [路线图](ROADMAP.md)、[更新记录](CHANGELOG.md) |
|
||||
| 网络性能、测试条件与适用范围 | [性能说明](PERFORMANCE.md)、[HTTP/2 验收](transport/native/HTTP2.md) |
|
||||
|
||||
当前成熟度与后续验收标准见 [ROADMAP.md](ROADMAP.md),版本变更见 [CHANGELOG.md](CHANGELOG.md)。
|
||||
当前版本可用于固定版本接入与业务验证,仍标记为 preview。`.api` 借鉴 go-zero 的接口开发方式,支持范围以本仓库文档为准,不是完整 goctl 语法兼容层;HTTP/2 保持实验状态,OSS/R2 云账号实网验收不属于本次发布范围。
|
||||
|
||||
64
RELEASE.md
64
RELEASE.md
@@ -1,63 +1,43 @@
|
||||
# v0.7.0-preview
|
||||
# v0.9.0-preview
|
||||
|
||||
这一版把 API 定义、项目生成、基础模块初始化和完整业务示例接通。
|
||||
预览版用于固定版本试用;API 方言有明确范围,HTTP/2 仍是独立实验适配器。
|
||||
本版补齐已有 HTTP 项目的迁移接入能力:标准 Handler/Middleware 桥接、动态 JSON、路由级请求体限额与手写绑定、自定义标量转换。业务的响应格式、身份、资金事务和数据库实现仍由项目决定。
|
||||
|
||||
## 安装与开始
|
||||
## 安装与升级
|
||||
|
||||
```sh
|
||||
GOPRIVATE=git.yuntuo.hk go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.7.0-preview
|
||||
|
||||
go-omni api new --module example.com/demo ./demo
|
||||
cd demo
|
||||
GOPRIVATE=git.yuntuo.hk go mod tidy
|
||||
go run ./cmd/server
|
||||
export GOPRIVATE=git.yuntuo.hk
|
||||
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.9.0-preview
|
||||
# 在业务 module 中:
|
||||
go get git.yuntuo.hk/k2safe/go-omni@v0.9.0-preview
|
||||
```
|
||||
|
||||
需要该 Git 仓库的读取权限时,通过自己的 Git 凭据管理器配置;不要将凭据写入 module path 或 go.mod。
|
||||
CLI 会将自身下载版本固定到生成项目的 go.mod。业务项目正常使用远程模块,不需要本地 replace。
|
||||
将私有域名追加到已有 GOPRIVATE;凭据通过本机 Git 凭据管理器配置。CLI 与项目框架依赖一起升级。进入已生成的项目,再运行 `go-omni api generate --dry-run`、`go-omni api generate`、`go test ./...`。
|
||||
|
||||
使用已有定义,并一次选择基础模块:
|
||||
## 本次变化
|
||||
|
||||
```sh
|
||||
go-omni new --api user.api --module example.com/userService \
|
||||
--database mysql --storage local --log-output file ./userService
|
||||
```
|
||||
- `httpx.WrapHandler`、`WrapMiddleware`、`WrapFuncMiddleware` 将现有标准代码接入 App 中间件链。Raw 写入会记录真实状态和提交;已经输出的响应不再追加错误 JSON。下载/流式输出不整体缓存,保留底层支持的 HTTP 可选能力和取消信号。
|
||||
- `.api` 的 `any` / `interface{}` 支持动态 JSON。数字保留为 json.Number,外围 DTO 严格校验不变。
|
||||
- `@body` 按路由声明限额;内置 App 在全局中间件读取前限制 body。`@binding custom`、`@handlerMode manual` 生成保留的项目实现文件;上传、原文验签、下载可自行处理。`@produces` 描述手写成功输出的媒体类型。
|
||||
- `scalar` 显式引用项目 Go 类型,调用标准 JSON/Text 方法;输入和输出各自生成 OpenAPI。普通基础类型不会自动放宽。
|
||||
|
||||
MySQL 与存储需要按生成 README 设置环境变量。任意契约生成的业务方法先返回 501,
|
||||
由业务方实现;可直接参考 [完整用户示例](examples/userService/README.md)。
|
||||
详见 [迁移扩展指南](MIGRATION.md)、[完整契约](examples/contracts/compatibility.api)、[运行示例](examples/compatibility/main.go) 和 [历史更新](CHANGELOG.md)。
|
||||
|
||||
## 主要变化
|
||||
## 兼容与边界
|
||||
|
||||
- `.api` 生成项目、绑定/校验、路由、业务骨架和 OpenAPI;支持分组、导入、命名中间件。
|
||||
- 同一入口初始化 MySQL、日志、Local / S3 / R2 / OSS;标准 context 与 httpx 保持业务/网络分离。
|
||||
- 生成器保护手写业务文件,校验自有文件哈希,支持只读 --check;基础设施选项持久化到 manifest。
|
||||
- 完整用户示例包含数据库迁移、CRUD / 分页、事务审计、鉴权、文件导出与 requestId 日志。
|
||||
- 日志文件轮转/结构化脱敏、上传策略/直传/分片,以及此前 main 分支的通用业务模块一并提供。
|
||||
原有 `.api`、公共响应配置和普通 Handler 可以继续使用。新生成代码最低运行时版本为本版。`Raw()` 现在返回跟踪包装器;直接断言某个具体 writer 类型的代码需改用标准接口/ResponseController。
|
||||
|
||||
所有变化及网络层验收记录见 [CHANGELOG](CHANGELOG.md)。
|
||||
标准中间件须保留 incoming request context 并同步调用 next;第三方框架私有的参数读取器仍由项目适配。手写 Handler/绑定 hook 初始返回 501;实现和文档一致性由项目负责,未实现不能上线。切换 manual 回自动模式时须显式迁移并移除对应手写文件,生成器不会删除它。
|
||||
|
||||
## 升级注意
|
||||
|
||||
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 自动推导内存仓库或数据表。
|
||||
新增能力没有引入 Gin 或 go-zero 依赖。不是完整 goctl 语法兼容层,不代表任何业务项目已完成迁移。HTTP/2 仍为实验功能,本轮没有重新做 Linux 长测、代理验收或云存储账号实网验收。
|
||||
|
||||
## 验证入口
|
||||
|
||||
```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/verifyCompatibilityRelease.py --version v0.9.0-preview \
|
||||
--report performance/compatibilityRemote.json
|
||||
```
|
||||
|
||||
最后一项在全新 module cache 安装远程 CLI,并验证无本地 replace 的项目生成、
|
||||
真实 MySQL、nethttp/native 启动与业务流程。容器只使用本机临时资源,结束时清理。
|
||||
本轮验收见 [API 业务闭环报告](performance/apiWorkflowAcceptance.md)。
|
||||
|
||||
OSS/R2 云端仍需项目自己的账号做上线环境验收;当前云端适配器检查不等同于这些账号的实网测试。
|
||||
测试覆盖旧 Handler 404 与访问日志、提交后错误/panic、标准中间件状态/取消/提前写响应、nethttp/native 实际流式下载与请求体超限、multipart 临时文件清理、自定义标量精度/越界/nullable/集合,以及真实生成项目的验签与重生成保护。远程脚本从全新 module cache 安装 CLI,并检查生成项目的固定版本、校验和以及没有本地 replace。
|
||||
|
||||
140
RESPONSE.md
Normal file
140
RESPONSE.md
Normal 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` 提供相同的早期挂载能力;未实现时辅助函数退化为普通路由中间件。
|
||||
|
||||
`@response raw` 表示原始 JSON,不代表任意文本/XML协议。从 v0.9.0-preview 开始,`.api` 可用 `@handlerMode manual` 配合 `@produces` 描述文本/文件等成功输出,详见 [迁移扩展](MIGRATION.md)。自定义回调错误协议仍使用上述 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、鉴权错误、路由覆盖与重复生成保护。
|
||||
14
ROADMAP.md
14
ROADMAP.md
@@ -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、适配层契约和一次业务模块试用已完成,自研网络层已有可运行预览,稳定性与生产规模验证尚未完成。
|
||||
|
||||
25
app/app.go
25
app/app.go
@@ -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)
|
||||
}
|
||||
|
||||
32
app/body.go
Normal file
32
app/body.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (a *App) bodyHandler(maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) http.Handler {
|
||||
checked := httpx.LimitBody(maxBytes)(handler)
|
||||
if encoder == nil {
|
||||
encoder = a.responseEncoder
|
||||
}
|
||||
next := a.adaptResponse(encoder, checked, middleware...)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Body != nil {
|
||||
r = r.WithContext(r.Context())
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// HandleWithBodyLimit caps the reader before application middleware and preserves
|
||||
// the response policy, including auth failures and parameter errors.
|
||||
func (a *App) HandleWithBodyLimit(method, path string, maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||
a.router.Handle(method, path, a.bodyHandler(maxBytes, encoder, handler, middleware...))
|
||||
}
|
||||
func (g *RouterGroup) HandleWithBodyLimit(method, path string, maxBytes int64, encoder response.Encoder, handler httpx.Handler, middleware ...httpx.Middleware) {
|
||||
combined := append(append([]httpx.Middleware{}, g.middlewares...), middleware...)
|
||||
g.group.Handle(method, path, g.app.bodyHandler(maxBytes, encoder, handler, combined...))
|
||||
}
|
||||
@@ -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,8 @@ 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)
|
||||
defer func() { httpx.CleanupMultipart(requestContext.Request()) }()
|
||||
if err := wrapped(requestContext); err != nil {
|
||||
a.writeError(requestContext, err)
|
||||
}
|
||||
@@ -37,6 +44,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 +109,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)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# go-omni CLI
|
||||
|
||||
首次接入见 [README](../../README.md),无需克隆框架源码。本页说明命令参数;下方 `go run ./cmd/go-omni` 示例在框架仓库中执行,并显式引用本地源码。
|
||||
|
||||
## API 契约驱动项目
|
||||
|
||||
```sh
|
||||
@@ -17,7 +19,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,8 +75,8 @@ 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-omni new --module example.com/billing ./billing
|
||||
GOPRIVATE=git.yuntuo.hk go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.9.0-preview
|
||||
go-omni new --framework-version v0.9.0-preview --module example.com/billing ./billing
|
||||
cd billing
|
||||
go mod tidy
|
||||
go run ./cmd/server
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
161
cmd/go-omni/apigen/compatibility_test.go
Normal file
161
cmd/go-omni/apigen/compatibility_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCompatibilityContract(t *testing.T) {
|
||||
s, err := Parse(filepath.Join(frameworkRoot(t), "examples/contracts/compatibility.api"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := Compile(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(s.Scalars) != 1 || len(s.Routes) != 6 {
|
||||
t.Fatal("incomplete contract")
|
||||
}
|
||||
if !strings.Contains(string(c.Document), `"anyOf"`) || !strings.Contains(string(c.Document), `"multipart/form-data"`) {
|
||||
t.Fatal("missing schemas")
|
||||
}
|
||||
for _, change := range [][2]string{{"jsonInput: string | int64", "jsonInput: mystery"}, {"text: true", "text: false"}, {"mode: multipart", "mode: invalid"}, {"maxBytes: 128", "maxBytes: 0"}, {"goType: ID", "goType: id"}, {"Data any", "Data interface{ Method() }"}} {
|
||||
source := readFile(t, filepath.Join(frameworkRoot(t), "examples/contracts/compatibility.api"))
|
||||
spec, err := parseText(t, strings.Replace(source, change[0], change[1], 1))
|
||||
if err == nil {
|
||||
_, err = Compile(spec)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("accepted %s", change[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestCompatibilityConsumer(t *testing.T) {
|
||||
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") == "" {
|
||||
t.Skip("consumer integration")
|
||||
}
|
||||
root := frameworkRoot(t)
|
||||
dir := filepath.Join(t.TempDir(), "consumer")
|
||||
o := Options{API: filepath.Join(root, "examples/contracts/compatibility.api"), Dir: dir, Module: "example.com/compatibility", FrameworkDir: root}
|
||||
releaseCLI := os.Getenv("GO_OMNI_RELEASE_CLI")
|
||||
generate := func(check bool) {
|
||||
t.Helper()
|
||||
if releaseCLI == "" {
|
||||
options := o
|
||||
options.Check = check
|
||||
if _, err := Generate(options); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
args := []string{"api", "generate", "--api", o.API, "--dir", dir, "--module", o.Module}
|
||||
if check {
|
||||
args = append(args, "--check")
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, releaseCLI, args...)
|
||||
cmd.Dir = filepath.Dir(dir)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("released CLI: %v\n%s", err, output)
|
||||
}
|
||||
}
|
||||
generate(false)
|
||||
files := map[string]string{
|
||||
"internal/service/demo/echo.go": "echo.go",
|
||||
"internal/handler/demo/customBind.go": "hooks.go",
|
||||
"internal/handler/files/downloadManual.go": "download.go",
|
||||
"consumer_test.go": "consumer_test.go",
|
||||
}
|
||||
// The fixture implements related methods together; remove only its generated
|
||||
// one-time stubs, then fill every stub so regeneration can preserve all files.
|
||||
for _, name := range []string{"internal/service/demo/item.go", "internal/service/demo/custom.go"} {
|
||||
writeFile(t, filepath.Join(dir, name), "package demo\n")
|
||||
}
|
||||
for _, name := range []string{"internal/handler/demo/uploadManual.go", "internal/handler/demo/callbackManual.go"} {
|
||||
writeFile(t, filepath.Join(dir, name), "package demo\n")
|
||||
}
|
||||
for target, source := range files {
|
||||
writeFile(t, filepath.Join(dir, target), readFile(t, filepath.Join("testdata/compatibility", source)))
|
||||
}
|
||||
before := map[string]string{}
|
||||
for target := range files {
|
||||
before[target] = readFile(t, filepath.Join(dir, target))
|
||||
}
|
||||
generate(false)
|
||||
for target, content := range before {
|
||||
if readFile(t, filepath.Join(dir, target)) != content {
|
||||
t.Fatalf("overwrote %s", target)
|
||||
}
|
||||
}
|
||||
generate(true)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*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")
|
||||
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")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("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("not a fixed remote dependency: %+v", module)
|
||||
}
|
||||
t.Logf("remote framework %s checksum %s; no local replace", module.Version, module.Sum)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestNestedDynamicAliases(t *testing.T) {
|
||||
source := readFile(t, filepath.Join(frameworkRoot(t), "examples/contracts/compatibility.api"))
|
||||
for _, typ := range []string{"[]interface{}", "map[string]interface{}", "map[string][]interface{}"} {
|
||||
s, err := parseText(t, strings.Replace(source, "Data any", "Data "+typ, 1))
|
||||
if err == nil {
|
||||
_, err = Compile(s)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", typ, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestSwitchFromManualRequiresExplicitMigration(t *testing.T) {
|
||||
root := frameworkRoot(t)
|
||||
o := Options{API: filepath.Join(root, "examples/contracts/compatibility.api"), Dir: filepath.Join(t.TempDir(), "consumer"), Module: "example.com/compatibility", FrameworkDir: root}
|
||||
if _, err := Generate(o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The hand-written source may contain business code. Never delete it to make
|
||||
// the newly automatic handler compile; fail before touching generated files.
|
||||
o.API = filepath.Join(o.Dir, "api/compatibility.api")
|
||||
source := readFile(t, o.API)
|
||||
source = strings.Replace(source, "@handlerMode manual\n @produces \"application/octet-stream\"", "", 1)
|
||||
writeFile(t, o.API, source)
|
||||
before := snapshot(t, o.Dir)
|
||||
if _, err := Generate(o); err == nil || !strings.Contains(err.Error(), "migrate and remove") {
|
||||
t.Fatalf("unsafe mode switch: %v", err)
|
||||
}
|
||||
if !sameFiles(before, snapshot(t, o.Dir)) {
|
||||
t.Fatal("failed mode switch mutated project")
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,16 @@ 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 t.Name == "ContractScalars" && len(spec.Scalars) > 0 {
|
||||
return nil, errors.New("ContractScalars is reserved when declaring scalars")
|
||||
}
|
||||
if !exportedName.MatchString(t.Name) {
|
||||
return nil, fmt.Errorf("%s: type names must be UpperCamelCase", t.Position)
|
||||
}
|
||||
@@ -45,17 +53,24 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
}
|
||||
definitions[t.Name] = t
|
||||
}
|
||||
scalarTypes, scalarDefs, err := compileScalars(spec, definitions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resolved := map[string]reflect.Type{}
|
||||
resolving := map[string]bool{}
|
||||
var resolve func(string, int) (reflect.Type, error)
|
||||
var expression func(ast.Expr, int) (reflect.Type, error)
|
||||
primitives := map[string]reflect.Type{"string": reflect.TypeOf(""), "bool": reflect.TypeOf(false), "int": reflect.TypeOf(int(0)), "int8": reflect.TypeOf(int8(0)), "int16": reflect.TypeOf(int16(0)), "int32": reflect.TypeOf(int32(0)), "int64": reflect.TypeOf(int64(0)), "uint": reflect.TypeOf(uint(0)), "uint8": reflect.TypeOf(uint8(0)), "uint16": reflect.TypeOf(uint16(0)), "uint32": reflect.TypeOf(uint32(0)), "uint64": reflect.TypeOf(uint64(0)), "float32": reflect.TypeOf(float32(0)), "float64": reflect.TypeOf(float64(0))}
|
||||
primitives := map[string]reflect.Type{"any": reflect.TypeOf((*any)(nil)).Elem(), "string": reflect.TypeOf(""), "bool": reflect.TypeOf(false), "int": reflect.TypeOf(int(0)), "int8": reflect.TypeOf(int8(0)), "int16": reflect.TypeOf(int16(0)), "int32": reflect.TypeOf(int32(0)), "int64": reflect.TypeOf(int64(0)), "uint": reflect.TypeOf(uint(0)), "uint8": reflect.TypeOf(uint8(0)), "uint16": reflect.TypeOf(uint16(0)), "uint32": reflect.TypeOf(uint32(0)), "uint64": reflect.TypeOf(uint64(0)), "float32": reflect.TypeOf(float32(0)), "float64": reflect.TypeOf(float64(0))}
|
||||
expression = func(e ast.Expr, depth int) (reflect.Type, error) {
|
||||
if depth > 32 {
|
||||
return nil, errors.New("type nesting exceeds 32")
|
||||
}
|
||||
switch e := e.(type) {
|
||||
case *ast.Ident:
|
||||
if t, ok := scalarTypes[e.Name]; ok {
|
||||
return t, nil
|
||||
}
|
||||
if t, ok := primitives[e.Name]; ok {
|
||||
return t, nil
|
||||
}
|
||||
@@ -135,7 +150,7 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := contract.ForType(rt)
|
||||
b, err := contract.Describe(rt, scalarDefs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", t.Position, err)
|
||||
}
|
||||
@@ -212,12 +227,15 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
if r.Status != 200 && r.Status != 201 && r.Status != 202 && r.Status != 204 {
|
||||
return nil, errors.New("@status supports 200, 201, 202 or 204")
|
||||
}
|
||||
if (r.Status == 204) != (r.Response == "") {
|
||||
if !r.Manual && (r.Status == 204) != (r.Response == "") {
|
||||
return nil, errors.New("204 requires no response; other success statuses require a response type")
|
||||
}
|
||||
if r.Method == "head" && r.Response != "" {
|
||||
return nil, errors.New("HEAD must not declare a response body")
|
||||
}
|
||||
if err := validateRouteExtensions(r); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", r.Position, err)
|
||||
}
|
||||
op := openapi.Operation{Method: r.Method, Path: r.Path, OperationID: operationID(r), Summary: r.Summary, Responses: map[int]openapi.Response{}}
|
||||
if r.Request != "" {
|
||||
b, ok := c.Bindings[r.Request]
|
||||
@@ -226,6 +244,9 @@ func Compile(spec *Spec) (*Compiled, error) {
|
||||
}
|
||||
op.Parameters = b.Parameters()
|
||||
op.Request = b.BodySchema()
|
||||
if op.Request != nil && (r.BodyMode == "raw" || r.BodyMode == "multipart") {
|
||||
return nil, errors.New("raw/multipart request DTOs may declare only path/form/header fields; body binding is manual")
|
||||
}
|
||||
if op.Request != nil && (r.Method == "get" || r.Method == "head") {
|
||||
return nil, errors.New("GET/HEAD requests cannot declare JSON bodies; use form/path/header tags")
|
||||
}
|
||||
@@ -240,11 +261,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 +290,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
|
||||
}
|
||||
|
||||
221
cmd/go-omni/apigen/contractMetadata.go
Normal file
221
cmd/go-omni/apigen/contractMetadata.go
Normal file
@@ -0,0 +1,221 @@
|
||||
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)
|
||||
decorateRequestOptions(op, r)
|
||||
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}}}
|
||||
}
|
||||
165
cmd/go-omni/apigen/declarations.go
Normal file
165
cmd/go-omni/apigen/declarations.go
Normal 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
|
||||
}
|
||||
@@ -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.9.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")
|
||||
@@ -82,6 +94,16 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
||||
return Output{}, err
|
||||
}
|
||||
o.Dir = directory
|
||||
for _, r := range c.Spec.Routes {
|
||||
if !r.Manual {
|
||||
path := filepath.Join(directory, "internal/handler", r.Group, lower(r.Handler)+"Manual.go")
|
||||
if _, err := os.Lstat(path); err == nil {
|
||||
return Output{}, fmt.Errorf("manual handler %s exists; migrate and remove it explicitly before switching to automatic generation", path)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return Output{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := resolveProfile(&o); err != nil {
|
||||
return Output{}, err
|
||||
}
|
||||
@@ -118,13 +140,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, "contract", "scalar.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 migration extensions; use %s or a current local replacement", minimumVersion)
|
||||
}
|
||||
}
|
||||
if o.FrameworkDir != "" {
|
||||
@@ -204,7 +226,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,7 +311,28 @@ 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"
|
||||
if len(c.Spec.Scalars) > 0 {
|
||||
types += "import("
|
||||
for i, d := range c.Spec.Scalars {
|
||||
types += fmt.Sprintf("scalar%d %q;", i, d.GoImport)
|
||||
}
|
||||
types += ")\n"
|
||||
registry := fmt.Sprintf("package types\nimport %q\nfunc ContractScalars()[]contract.Scalar{return []contract.Scalar{", frameworkModule+"/contract")
|
||||
for i, d := range c.Spec.Scalars {
|
||||
types += fmt.Sprintf("type %s = scalar%d.%s\n", d.Name, i, d.GoType)
|
||||
registry += fmt.Sprintf("contract.ScalarOf[%s](%q,%q,%t),", d.Name, d.JSONInput, d.JSONOutput, d.Text)
|
||||
}
|
||||
registry += "}}\n"
|
||||
put(generated, "internal/types/scalarsGen.go", generatedHeader+registry)
|
||||
}
|
||||
for _, t := range c.Spec.Types {
|
||||
types += "type " + t.Name + " struct {\n"
|
||||
for _, f := range t.Fields {
|
||||
@@ -300,8 +343,22 @@ 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"
|
||||
if len(c.Spec.Scalars) > 0 {
|
||||
imports += fmt.Sprintf("%q;%q;", frameworkModule+"/contract", o.Module+"/internal/types")
|
||||
routes += "if err:=contract.ValidateScalars(types.ContractScalars()...);err!=nil{return err}\n"
|
||||
}
|
||||
middlewareSet := map[string]bool{}
|
||||
for _, r := range c.Spec.Routes {
|
||||
for _, m := range r.Middleware {
|
||||
@@ -318,7 +375,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
}
|
||||
for i, group := range c.Groups {
|
||||
imports += fmt.Sprintf("h%d %q; s%d %q;", i, o.Module+"/internal/handler/"+group, i, o.Module+"/internal/service/"+group)
|
||||
routes += fmt.Sprintf("h%d := h%d.New(s%d.New(deps))\n", i, i, i)
|
||||
routes += fmt.Sprintf("h%d := h%d.New(s%d.New(deps),deps)\n", i, i, i)
|
||||
groupRoutes := []Route{}
|
||||
for _, r := range c.Spec.Routes {
|
||||
if r.Group == group {
|
||||
@@ -327,12 +384,16 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
}
|
||||
packageName := filepath.Base(group)
|
||||
text := "package " + packageName + "\nimport(\"context\"; \"fmt\";"
|
||||
needsTypes, needsBinder := false, false
|
||||
needsTypes, needsBinder, hasAutomatic := false, false, false
|
||||
for _, r := range groupRoutes {
|
||||
if r.Manual {
|
||||
continue
|
||||
}
|
||||
hasAutomatic = true
|
||||
needsTypes = needsTypes || r.Request != "" || r.Response != ""
|
||||
needsBinder = needsBinder || r.Request != ""
|
||||
needsBinder = needsBinder || (r.Request != "" && !r.CustomBinding)
|
||||
}
|
||||
text += fmt.Sprintf("%q;", frameworkModule+"/httpx")
|
||||
text += fmt.Sprintf("%q;%q;", frameworkModule+"/httpx", o.Module+"/internal/servicecontext")
|
||||
if needsTypes {
|
||||
text += fmt.Sprintf("%q;", o.Module+"/internal/types")
|
||||
}
|
||||
@@ -342,18 +403,47 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
text += ")\n"
|
||||
text += "type Service interface {\n"
|
||||
for _, r := range groupRoutes {
|
||||
text += signature(r) + "\n"
|
||||
if !r.Manual {
|
||||
text += signature(r) + "\n"
|
||||
}
|
||||
}
|
||||
text += "}\ntype Handler struct {service Service}\nfunc New(s Service)*Handler {return &Handler{service:s}}\n"
|
||||
text += "}\ntype Handler struct {service Service;deps *servicecontext.Context}\nfunc New(s Service,deps ...*servicecontext.Context)*Handler {h:=&Handler{service:s};if len(deps)>0{h.deps=deps[0]};return h}\n"
|
||||
for _, r := range groupRoutes {
|
||||
name := upper(r.Handler)
|
||||
if r.Request != "" {
|
||||
text += fmt.Sprintf("var %sRequest = contract.MustNew[types.%s]()\n", lower(r.Handler), r.Request)
|
||||
// The response policy and body reader cap are installed before App middleware.
|
||||
if r.MaxBodyBytes > 0 {
|
||||
routes += fmt.Sprintf("httpx.HandleWithBodyLimit(router,%q,%q,%d,responseEncoder(%q,%t),h%d.%s", strings.ToUpper(r.Method), r.Path, r.MaxBodyBytes, responseName(r), r.ResponseFormat == "raw", i, name)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
routes += ")\n"
|
||||
if r.Manual {
|
||||
put(seeds, "internal/handler/"+group+"/"+lower(r.Handler)+"Manual.go", fmt.Sprintf("package %s\nimport(%q;apierrors %q)\n// %s owns raw/multipart binding and response output. h.deps holds project dependencies.\n// This file is preserved on regeneration.\nfunc(h *Handler)%s(c *httpx.Context)error{return apierrors.New(501,\"NOT_IMPLEMENTED\",\"manual handler is not implemented\")}\n", packageName, frameworkModule+"/httpx", frameworkModule+"/errors", name, name))
|
||||
continue
|
||||
}
|
||||
if r.Request != "" && !r.CustomBinding {
|
||||
scalarArgs := ""
|
||||
if len(c.Spec.Scalars) > 0 {
|
||||
scalarArgs = "types.ContractScalars()..."
|
||||
}
|
||||
text += fmt.Sprintf("var %sRequest = contract.MustNew[types.%s](%s)\n", lower(r.Handler), r.Request, scalarArgs)
|
||||
}
|
||||
text += fmt.Sprintf("func(h *Handler) %s(c *httpx.Context)error {\n", name)
|
||||
call := "c.Context()"
|
||||
if r.Request != "" {
|
||||
text += fmt.Sprintf("var req types.%s\nif err:=%sRequest.Bind(c.Request(),httpx.RouteParams(c.Context()),&req);err!=nil{return err}\n", r.Request, lower(r.Handler))
|
||||
if r.CustomBinding {
|
||||
text += fmt.Sprintf("var req types.%s\nif err:=h.bind%s(c,&req);err!=nil{return err}\n", r.Request, name)
|
||||
put(seeds, "internal/handler/"+group+"/"+lower(r.Handler)+"Bind.go", fmt.Sprintf("package %s\nimport(%q;%q;apierrors %q)\n// bind%s is project-owned; repeated generation preserves it.\nfunc(h *Handler)bind%s(c *httpx.Context,req *types.%s)error{return apierrors.New(501,\"BINDING_NOT_IMPLEMENTED\",\"custom binding is not implemented\")}\n", packageName, frameworkModule+"/httpx", o.Module+"/internal/types", frameworkModule+"/errors", name, name, r.Request))
|
||||
} else {
|
||||
limit := ""
|
||||
if r.MaxBodyBytes > 0 {
|
||||
limit = fmt.Sprintf(",%d", r.MaxBodyBytes)
|
||||
}
|
||||
text += fmt.Sprintf("var req types.%s\nif err:=%sRequest.Bind(c.Request(),httpx.RouteParams(c.Context()),&req%s);err!=nil{return err}\n", r.Request, lower(r.Handler), limit)
|
||||
}
|
||||
call += ", &req"
|
||||
}
|
||||
if r.Response != "" {
|
||||
@@ -363,11 +453,6 @@ 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)
|
||||
for _, m := range r.Middleware {
|
||||
routes += fmt.Sprintf(",deps.Middlewares[%q]", m)
|
||||
}
|
||||
routes += ")\n"
|
||||
service := fmt.Sprintf("package %s\nimport(\"context\"; apierrors %q;", packageName, frameworkModule+"/errors")
|
||||
if r.Request != "" || r.Response != "" {
|
||||
service += fmt.Sprintf("%q;", o.Module+"/internal/types")
|
||||
@@ -388,6 +473,10 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
}
|
||||
put(seeds, "internal/service/"+group+"/"+lower(r.Handler)+".go", service)
|
||||
}
|
||||
if !hasAutomatic {
|
||||
text = strings.Replace(text, strconv.Quote(frameworkModule+"/httpx")+";", "", 1)
|
||||
text = strings.Replace(text, "\"context\";", "", 1)
|
||||
}
|
||||
if !hasResponse(groupRoutes) {
|
||||
text = strings.Replace(text, "\"fmt\";", "", 1)
|
||||
}
|
||||
@@ -395,8 +484,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 {
|
||||
@@ -437,7 +524,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
}
|
||||
func hasResponse(routes []Route) bool {
|
||||
for _, r := range routes {
|
||||
if r.Response != "" {
|
||||
if r.Response != "" && !r.Manual {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
305
cmd/go-omni/apigen/metadata_test.go
Normal file
305
cmd/go-omni/apigen/metadata_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -14,11 +14,15 @@ 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
|
||||
Scalars []ScalarDefinition
|
||||
Types []Type
|
||||
Routes []Route
|
||||
Sources map[string][]byte // relative to the entry API's directory
|
||||
Entry string
|
||||
}
|
||||
type Type struct {
|
||||
Name string
|
||||
@@ -27,14 +31,25 @@ type Type struct {
|
||||
}
|
||||
type Field struct{ Name, Type, Tag string }
|
||||
type Route struct {
|
||||
Manual, CustomBinding bool
|
||||
BodyMode, Produces string
|
||||
MaxBodyBytes int64
|
||||
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 +152,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
|
||||
}
|
||||
@@ -186,6 +203,10 @@ func (p *parser) parse() error {
|
||||
syntaxSeen := false
|
||||
for p.skip(); p.tokens[p.at].kind != scanner.EOF; p.skip() {
|
||||
switch p.peek() {
|
||||
case "scalar":
|
||||
if err := p.parseScalar(); err != nil {
|
||||
return err
|
||||
}
|
||||
case "syntax":
|
||||
if syntaxSeen {
|
||||
return p.err("duplicate syntax")
|
||||
@@ -208,13 +229,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 +266,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 +307,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 +344,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
|
||||
@@ -338,7 +401,15 @@ func (p *parser) parseType() error {
|
||||
}
|
||||
expression := ""
|
||||
for p.peek() != "\n" && p.peek() != ";" && p.peek() != "}" && p.tokens[p.at].kind != scanner.RawString && p.tokens[p.at].kind != scanner.EOF {
|
||||
expression += p.next().text
|
||||
if p.peek() == "interface" {
|
||||
p.next()
|
||||
if p.expect("{") != nil || p.expect("}") != nil {
|
||||
return p.err("only empty interface{} is supported; use any for dynamic JSON")
|
||||
}
|
||||
expression += "any"
|
||||
} else {
|
||||
expression += p.next().text
|
||||
}
|
||||
}
|
||||
if expression == "" {
|
||||
return p.err("field type required; embedded types are not supported")
|
||||
@@ -379,7 +450,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"
|
||||
}
|
||||
@@ -398,10 +469,49 @@ func (p *parser) parseService(server map[string]string) error {
|
||||
}
|
||||
seen[annotation] = true
|
||||
switch annotation {
|
||||
case "handlerMode":
|
||||
mode, err := p.ident()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mode != "manual" {
|
||||
return p.err("handlerMode must be manual")
|
||||
}
|
||||
route.Manual = true
|
||||
case "binding":
|
||||
mode, err := p.ident()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if mode != "custom" {
|
||||
return p.err("binding must be custom")
|
||||
}
|
||||
route.CustomBinding = true
|
||||
case "produces":
|
||||
route.Produces, e = p.quoted()
|
||||
case "body":
|
||||
e = p.parseBody(&route)
|
||||
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:
|
||||
@@ -440,7 +550,7 @@ func (p *parser) parseService(server map[string]string) error {
|
||||
if p.peek() != "\n" && p.peek() != ";" && p.peek() != "}" {
|
||||
return p.err("unexpected route suffix")
|
||||
}
|
||||
if route.Response == "" && !seen["status"] {
|
||||
if route.Response == "" && !seen["status"] && !route.Manual {
|
||||
route.Status = 204
|
||||
}
|
||||
p.spec.Routes = append(p.spec.Routes, route)
|
||||
|
||||
35
cmd/go-omni/apigen/projectEntry.go
Normal file
35
cmd/go-omni/apigen/projectEntry.go
Normal 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
|
||||
}
|
||||
95
cmd/go-omni/apigen/requestOptions.go
Normal file
95
cmd/go-omni/apigen/requestOptions.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||
"mime"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (p *parser) parseBody(r *Route) error {
|
||||
props, err := p.metadata()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for key, value := range props {
|
||||
switch key {
|
||||
case "mode":
|
||||
r.BodyMode = value
|
||||
case "maxBytes":
|
||||
r.MaxBodyBytes, err = strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || r.MaxBodyBytes <= 0 || r.MaxBodyBytes > 1<<40 {
|
||||
return p.err("maxBytes must be 1..1099511627776 bytes")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown body property %s", key)
|
||||
}
|
||||
}
|
||||
if r.MaxBodyBytes == 0 {
|
||||
r.MaxBodyBytes = contract.DefaultMaxBodyBytes
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func validateRouteExtensions(r Route) error {
|
||||
if r.BodyMode != "" && r.BodyMode != "json" && r.BodyMode != "raw" && r.BodyMode != "multipart" {
|
||||
return errors.New("body mode must be json, raw or multipart")
|
||||
}
|
||||
if (r.BodyMode == "raw" || r.BodyMode == "multipart") && !r.Manual {
|
||||
return errors.New("raw/multipart body requires @handlerMode manual")
|
||||
}
|
||||
if r.CustomBinding && (r.Manual || r.Request == "") {
|
||||
return errors.New("@binding custom requires an automatic handler with a request DTO")
|
||||
}
|
||||
if r.MaxBodyBytes > 0 && (r.Method == "get" || r.Method == "head") {
|
||||
return errors.New("GET/HEAD cannot declare a body limit")
|
||||
}
|
||||
if r.Produces != "" {
|
||||
if !r.Manual {
|
||||
return errors.New("@produces requires @handlerMode manual")
|
||||
}
|
||||
media, params, err := mime.ParseMediaType(r.Produces)
|
||||
if err != nil || len(params) != 0 || media != r.Produces || strings.Contains(media, "*") {
|
||||
return errors.New("produces requires a concrete media type without parameters")
|
||||
}
|
||||
if r.Response != "" || r.Status == 204 || r.Method == "head" {
|
||||
return errors.New("@produces uses a manual response without a DTO, 204 or HEAD")
|
||||
}
|
||||
}
|
||||
if r.Manual && r.Status == 204 && r.Response != "" {
|
||||
return errors.New("204 cannot declare a response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decorateRequestOptions(op map[string]any, r Route) {
|
||||
if r.Manual {
|
||||
op["x-omni-handler-mode"] = "manual"
|
||||
}
|
||||
if r.CustomBinding {
|
||||
op["x-omni-binding"] = "custom"
|
||||
}
|
||||
if r.MaxBodyBytes > 0 {
|
||||
op["x-omni-max-body-bytes"] = r.MaxBodyBytes
|
||||
}
|
||||
if r.BodyMode == "raw" || r.BodyMode == "multipart" {
|
||||
media := "application/octet-stream"
|
||||
schema := map[string]any{"type": "string", "format": "binary"}
|
||||
if r.BodyMode == "multipart" {
|
||||
media = "multipart/form-data"
|
||||
schema = map[string]any{"type": "object", "additionalProperties": true}
|
||||
}
|
||||
op["requestBody"] = map[string]any{"required": true, "content": map[string]any{media: map[string]any{"schema": schema}}}
|
||||
}
|
||||
if r.Produces != "" {
|
||||
schema := map[string]any{"type": "string"}
|
||||
if r.Produces == "application/octet-stream" {
|
||||
schema["format"] = "binary"
|
||||
}
|
||||
if r.Produces == "application/json" {
|
||||
schema = map[string]any{}
|
||||
}
|
||||
op["responses"].(map[string]any)[strconv.Itoa(r.Status)] = map[string]any{"description": "Success", "content": map[string]any{r.Produces: map[string]any{"schema": schema}}}
|
||||
}
|
||||
}
|
||||
114
cmd/go-omni/apigen/response.go
Normal file
114
cmd/go-omni/apigen/response.go
Normal 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
|
||||
}
|
||||
360
cmd/go-omni/apigen/response_test.go
Normal file
360
cmd/go-omni/apigen/response_test.go
Normal file
@@ -0,0 +1,360 @@
|
||||
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")
|
||||
// The framework checkout intentionally enables local development mode.
|
||||
// Run the installed CLI outside it to exercise remote-only consumption.
|
||||
cmd.Dir = filepath.Dir(dir)
|
||||
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())}
|
||||
}
|
||||
}
|
||||
`
|
||||
74
cmd/go-omni/apigen/scalar.go
Normal file
74
cmd/go-omni/apigen/scalar.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||
"golang.org/x/mod/module"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ScalarDefinition struct {
|
||||
Name, GoImport, GoType, JSONInput, JSONOutput, Position string
|
||||
Text bool
|
||||
}
|
||||
|
||||
func (p *parser) parseScalar() 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
|
||||
}
|
||||
d := ScalarDefinition{Name: name, Position: pos}
|
||||
for key, value := range props {
|
||||
switch key {
|
||||
case "goImport":
|
||||
d.GoImport = value
|
||||
case "goType":
|
||||
d.GoType = value
|
||||
case "jsonInput":
|
||||
d.JSONInput = value
|
||||
case "jsonOutput":
|
||||
d.JSONOutput = value
|
||||
case "text":
|
||||
if value != "true" && value != "false" {
|
||||
return p.err("text must be true or false")
|
||||
}
|
||||
d.Text = value == "true"
|
||||
default:
|
||||
return fmt.Errorf("%s: unknown scalar property %s", pos, key)
|
||||
}
|
||||
}
|
||||
p.spec.Scalars = append(p.spec.Scalars, d)
|
||||
return nil
|
||||
}
|
||||
func compileScalars(spec *Spec, types map[string]Type) (map[string]reflect.Type, []contract.Scalar, error) {
|
||||
out := map[string]reflect.Type{}
|
||||
var defs []contract.Scalar
|
||||
goTypes := map[string]bool{}
|
||||
for _, d := range spec.Scalars {
|
||||
key := d.GoImport + "." + d.GoType
|
||||
if d.Name == "ContractScalars" || !exportedName.MatchString(d.Name) || !exportedName.MatchString(d.GoType) || out[d.Name] != nil || types[d.Name].Name != "" || goTypes[key] {
|
||||
return nil, nil, fmt.Errorf("%s: scalar names and Go types must be unique; names must be UpperCamelCase", d.Position)
|
||||
}
|
||||
if err := module.CheckImportPath(d.GoImport); err != nil || strings.Contains(d.GoImport, "\\") {
|
||||
return nil, nil, fmt.Errorf("%s: invalid scalar goImport", d.Position)
|
||||
}
|
||||
// Unique stand-in types allow CLI schema checks without executing project code.
|
||||
t := reflect.StructOf([]reflect.StructField{{Name: d.Name, Type: reflect.TypeOf(false)}})
|
||||
def := contract.Scalar{Type: t, JSONInput: d.JSONInput, JSONOutput: d.JSONOutput, Text: d.Text}
|
||||
probe := reflect.StructOf([]reflect.StructField{{Name: "Value", Type: t, Tag: `json:"value"`}})
|
||||
if _, err := contract.Describe(probe, def); err != nil {
|
||||
return nil, nil, fmt.Errorf("%s: %w", d.Position, err)
|
||||
}
|
||||
out[d.Name] = t
|
||||
defs = append(defs, def)
|
||||
goTypes[key] = true
|
||||
}
|
||||
return out, defs, nil
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
117
cmd/go-omni/apigen/testdata/compatibility/consumer_test.go
vendored
Normal file
117
cmd/go-omni/apigen/testdata/compatibility/consumer_test.go
vendored
Normal file
@@ -0,0 +1,117 @@
|
||||
package compatibility_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"example.com/compatibility/internal/handler"
|
||||
"example.com/compatibility/internal/servicecontext"
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGeneratedCompatibility(t *testing.T) {
|
||||
logger, err := logx.New(logx.Config{Writer: io.Discard})
|
||||
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)
|
||||
}
|
||||
send := func(method, path, body, contentType string) *httptest.ResponseRecorder {
|
||||
r := httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", contentType)
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
for _, id := range []string{`9223372036854775807`, `"9223372036854775807"`} {
|
||||
w := send("POST", "/echo", `{"id":`+id+`,"data":{"n":9223372036854775807}}`, "application/json")
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), `"id":"9223372036854775807"`) || !strings.Contains(w.Body.String(), `"n":9223372036854775807`) {
|
||||
t.Fatalf("scalar/dynamic: %d %s", w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
for _, body := range []string{`{"id":true,"data":null}`, `{"id":9223372036854775808,"data":null}`, `{"id":1,"data":{"x":1,"x":2}}`, `{"id":1,"data":null,"extra":1}`} {
|
||||
if w := send("POST", "/echo", body, "application/json"); w.Code != 400 {
|
||||
t.Fatalf("accepted %s: %d", body, w.Code)
|
||||
}
|
||||
}
|
||||
payload := `{"id":1,"data":"` + strings.Repeat("x", (1<<20)+1) + `"}`
|
||||
if w := send("POST", "/echo", payload, "application/json"); w.Code != 200 {
|
||||
t.Fatalf("route limit not passed to Binder: %d", w.Code)
|
||||
}
|
||||
if w := send("POST", "/echo", `{"id":1,"data":"`+strings.Repeat("x", 2<<20)+`"}`, "application/json"); w.Code != 413 {
|
||||
t.Fatalf("large JSON: %d", w.Code)
|
||||
}
|
||||
if w := send("GET", "/items/9223372036854775807", "", ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"id":"9223372036854775807"`) {
|
||||
t.Fatalf("text scalar: %d %s", w.Code, w.Body)
|
||||
}
|
||||
if w := send("POST", "/custom", `{"value":"hello"}`, "application/json"); w.Code != 200 || !strings.Contains(w.Body.String(), "HELLO") {
|
||||
t.Fatalf("custom binder: %d %s", w.Code, w.Body)
|
||||
}
|
||||
raw := `{ "event": 1, "id": "original bytes" }`
|
||||
mac := hmac.New(sha256.New, []byte("fixture-only-key"))
|
||||
mac.Write([]byte(raw))
|
||||
r := httptest.NewRequest("POST", "/callback", strings.NewReader(raw))
|
||||
r.Header.Set("X-Signature", hex.EncodeToString(mac.Sum(nil)))
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, r)
|
||||
if w.Code != 200 || w.Body.String() != "accepted" {
|
||||
t.Fatalf("raw callback: %d %s", w.Code, w.Body)
|
||||
}
|
||||
r = httptest.NewRequest("POST", "/callback", strings.NewReader(strings.Repeat("x", 129)))
|
||||
r.ContentLength = -1
|
||||
w = httptest.NewRecorder()
|
||||
app.ServeHTTP(w, r)
|
||||
if w.Code != 413 {
|
||||
t.Fatalf("chunked body limit: %d %s", w.Code, w.Body)
|
||||
}
|
||||
temp := t.TempDir()
|
||||
t.Setenv("TMPDIR", temp)
|
||||
var form bytes.Buffer
|
||||
writer := multipart.NewWriter(&form)
|
||||
part, err := writer.CreateFormFile("file", "sample.bin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
part.Write(bytes.Repeat([]byte("x"), 8192))
|
||||
writer.Close()
|
||||
w = send("POST", "/upload", form.String(), writer.FormDataContentType())
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), `"bytes":8192`) {
|
||||
t.Fatalf("upload: %d %s", w.Code, w.Body)
|
||||
}
|
||||
entries, err := os.ReadDir(temp)
|
||||
if err != nil || len(entries) != 0 {
|
||||
t.Fatalf("multipart temp files leaked: %v %v", entries, err)
|
||||
}
|
||||
w = send("GET", "/download", "", "")
|
||||
if w.Code != 200 || w.Body.Len() != 128<<10 || w.Header().Get("Content-Disposition") == "" {
|
||||
t.Fatalf("download wrapped: %d %d", w.Code, w.Body.Len())
|
||||
}
|
||||
var doc map[string]any
|
||||
if err = json.Unmarshal([]byte(handler.OpenAPIDocument), &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paths := doc["paths"].(map[string]any)
|
||||
media := paths["/download"].(map[string]any)["get"].(map[string]any)["responses"].(map[string]any)["200"].(map[string]any)["content"].(map[string]any)
|
||||
if media["application/octet-stream"] == nil || media["application/json"] != nil {
|
||||
t.Fatal("download schema is JSON")
|
||||
}
|
||||
}
|
||||
16
cmd/go-omni/apigen/testdata/compatibility/download.go
vendored
Normal file
16
cmd/go-omni/apigen/testdata/compatibility/download.go
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
package files
|
||||
|
||||
import (
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (h *Handler) Download(c *httpx.Context) error {
|
||||
return httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="demo.bin"`)
|
||||
_, _ = io.Copy(w, strings.NewReader(strings.Repeat("x", 128<<10)))
|
||||
}))(c)
|
||||
}
|
||||
16
cmd/go-omni/apigen/testdata/compatibility/echo.go
vendored
Normal file
16
cmd/go-omni/apigen/testdata/compatibility/echo.go
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
package demo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"example.com/compatibility/internal/types"
|
||||
)
|
||||
|
||||
func (s *Service) Echo(ctx context.Context, req *types.EchoReq) (*types.EchoResp, error) {
|
||||
return &types.EchoResp{ID: req.ID, Data: req.Data}, nil
|
||||
}
|
||||
func (s *Service) Item(ctx context.Context, req *types.ItemReq) (*types.EchoResp, error) {
|
||||
return &types.EchoResp{ID: req.ID, Data: map[string]any{"found": true}}, nil
|
||||
}
|
||||
func (s *Service) Custom(ctx context.Context, req *types.CustomReq) (*types.CustomResp, error) {
|
||||
return &types.CustomResp{Value: req.Value}, nil
|
||||
}
|
||||
58
cmd/go-omni/apigen/testdata/compatibility/hooks.go
vendored
Normal file
58
cmd/go-omni/apigen/testdata/compatibility/hooks.go
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
package demo
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"example.com/compatibility/internal/types"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (h *Handler) bindCustom(c *httpx.Context, req *types.CustomReq) error {
|
||||
decoder := json.NewDecoder(c.Request().Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(req); err != nil {
|
||||
return apierrors.BadRequest("INVALID_REQUEST", "invalid request").WithCause(err)
|
||||
}
|
||||
var extra any
|
||||
if err := decoder.Decode(&extra); err != io.EOF {
|
||||
return apierrors.BadRequest("INVALID_REQUEST", "one JSON value required").WithCause(err)
|
||||
}
|
||||
req.Value = strings.ToUpper(req.Value)
|
||||
return nil
|
||||
}
|
||||
func (h *Handler) Upload(c *httpx.Context) error {
|
||||
// The small memory threshold deliberately exercises disk spill and cleanup.
|
||||
if err := c.Request().ParseMultipartForm(8); err != nil {
|
||||
return err
|
||||
}
|
||||
file, _, err := c.Request().FormFile("file")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
count, err := io.Copy(io.Discard, file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.OK(map[string]any{"bytes": count})
|
||||
}
|
||||
func (h *Handler) Callback(c *httpx.Context) error {
|
||||
raw, err := io.ReadAll(c.Request().Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// This fixed key belongs only to the test fixture. Applications inject secrets.
|
||||
mac := hmac.New(sha256.New, []byte("fixture-only-key"))
|
||||
mac.Write(raw)
|
||||
signature, err := hex.DecodeString(c.Header("X-Signature"))
|
||||
if err != nil || !hmac.Equal(signature, mac.Sum(nil)) {
|
||||
return c.Text(http.StatusUnauthorized, "invalid signature")
|
||||
}
|
||||
return c.Text(http.StatusOK, "accepted")
|
||||
}
|
||||
@@ -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" || name == "internal/types/scalarsGen.go" {
|
||||
return true
|
||||
}
|
||||
if name == "internal/apierrors/errorsGen.go" || name == "docs/openapi.json" || name == "internal/types/typesGen.go" || name == "internal/handler/routesGen.go" || name == "internal/handler/openapiGen.go" || name == "internal/middleware/registryGen.go" {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(name, "internal/handler/") && strings.HasSuffix(name, "/handlersGen.go")
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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.9.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.
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
// strictly decoded, and limited to 1 MiB unless maxBytes is supplied. Query
|
||||
// parameters never overwrite path/body/header values. form means URL query only.
|
||||
func (b *Binder) Bind(r *http.Request, params map[string]string, target any, maxBytes ...int64) error {
|
||||
if b == nil || r == nil {
|
||||
if b == nil || r == nil || b.schemaOnly {
|
||||
return apierrors.Internal(errors.New("contract: nil binder or request"))
|
||||
}
|
||||
v := reflect.ValueOf(target)
|
||||
@@ -65,6 +65,10 @@ func (b *Binder) Bind(r *http.Request, params map[string]string, target any, max
|
||||
return tooLarge()
|
||||
}
|
||||
if err != io.EOF {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
return tooLarge()
|
||||
}
|
||||
return invalid(errors.New("exactly one JSON value required"))
|
||||
}
|
||||
var ok bool
|
||||
@@ -126,10 +130,16 @@ func tooLarge() error { return apierrors.New(413, "BODY_TOO_LARGE", "request bod
|
||||
|
||||
func fromStrings(n *node, values []string) (any, error) {
|
||||
n = base(n)
|
||||
if n.scalar != nil {
|
||||
if len(values) != 1 {
|
||||
return nil, errors.New("duplicate scalar parameter")
|
||||
}
|
||||
return scalarText(values[0]), nil
|
||||
}
|
||||
if n.typ.Kind() == reflect.Slice {
|
||||
out := make([]any, 0, len(values))
|
||||
for _, s := range values {
|
||||
v, err := parseScalar(s, base(n.elem).typ)
|
||||
v, err := fromStrings(n.elem, []string{s})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -158,6 +168,17 @@ func assignField(f field, dst reflect.Value, value any, present bool) error {
|
||||
return checkField(f, dst)
|
||||
}
|
||||
func setValue(n *node, dst reflect.Value, value any) error {
|
||||
if n.scalar != nil {
|
||||
return n.scalar.decode(dst, value)
|
||||
}
|
||||
if n.typ.Kind() == reflect.Interface && n.typ.NumMethod() == 0 {
|
||||
if value == nil {
|
||||
dst.SetZero()
|
||||
} else {
|
||||
dst.Set(reflect.ValueOf(value))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if value == nil {
|
||||
if n.typ.Kind() == reflect.Pointer {
|
||||
dst.SetZero()
|
||||
|
||||
@@ -23,10 +23,12 @@ var valuesValidator = validator.New()
|
||||
|
||||
// Binder is immutable after compilation and safe for concurrent requests.
|
||||
type Binder struct {
|
||||
root *node
|
||||
typ reflect.Type
|
||||
root *node
|
||||
typ reflect.Type
|
||||
schemaOnly bool
|
||||
}
|
||||
type node struct {
|
||||
scalar *Scalar
|
||||
typ reflect.Type
|
||||
schema *openapi.Schema
|
||||
fields []field
|
||||
@@ -42,9 +44,11 @@ type field struct {
|
||||
binding string
|
||||
}
|
||||
|
||||
func New[T any]() (*Binder, error) { return ForType(reflect.TypeOf((*T)(nil)).Elem()) }
|
||||
func MustNew[T any]() *Binder {
|
||||
b, err := New[T]()
|
||||
func New[T any](scalars ...Scalar) (*Binder, error) {
|
||||
return ForType(reflect.TypeOf((*T)(nil)).Elem(), scalars...)
|
||||
}
|
||||
func MustNew[T any](scalars ...Scalar) *Binder {
|
||||
b, err := New[T](scalars...)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -52,20 +56,36 @@ func MustNew[T any]() *Binder {
|
||||
}
|
||||
|
||||
// ForType also lets generators check the exact runtime contract before writing files.
|
||||
// The root must be a struct; recursive and custom-marshaling types are rejected.
|
||||
func ForType(t reflect.Type) (*Binder, error) {
|
||||
// The root must be a struct; custom marshalers require explicit scalar registration.
|
||||
func ForType(t reflect.Type, scalars ...Scalar) (*Binder, error) { return build(t, false, scalars) }
|
||||
|
||||
// Describe compiles schemas without loading or executing application Go types.
|
||||
// The returned Binder cannot bind requests; it is for generators only.
|
||||
func Describe(t reflect.Type, scalars ...Scalar) (*Binder, error) { return build(t, true, scalars) }
|
||||
|
||||
func build(t reflect.Type, schemaOnly bool, scalars []Scalar) (*Binder, error) {
|
||||
if t == nil || t.Kind() != reflect.Struct {
|
||||
return nil, errors.New("contract: root must be a struct")
|
||||
}
|
||||
c := compiler{scalars: map[reflect.Type]Scalar{}}
|
||||
for _, scalar := range scalars {
|
||||
if err := scalar.validate(schemaOnly); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, exists := c.scalars[scalar.Type]; exists {
|
||||
return nil, fmt.Errorf("contract: duplicate scalar type %s", scalar.Type)
|
||||
}
|
||||
c.scalars[scalar.Type] = scalar
|
||||
}
|
||||
budget := 10000
|
||||
n, err := compile(t, true, map[reflect.Type]bool{}, 0, &budget)
|
||||
n, err := c.compile(t, true, map[reflect.Type]bool{}, 0, &budget)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Binder{root: n, typ: t}, nil
|
||||
return &Binder{root: n, typ: t, schemaOnly: schemaOnly}, nil
|
||||
}
|
||||
|
||||
func compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int, budget *int) (*node, error) {
|
||||
func (c compiler) compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int, budget *int) (*node, error) {
|
||||
*budget--
|
||||
if *budget < 0 {
|
||||
return nil, errors.New("contract: schema expansion exceeds 10000 nodes")
|
||||
@@ -79,21 +99,32 @@ func compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int,
|
||||
stack[t] = true
|
||||
defer delete(stack, t)
|
||||
n := &node{typ: t, schema: &openapi.Schema{}}
|
||||
if scalar, ok := c.scalars[t]; ok {
|
||||
if root {
|
||||
return nil, errors.New("contract: root cannot be a scalar")
|
||||
}
|
||||
n.scalar = &scalar
|
||||
n.schema = scalar.inputSchema()
|
||||
return n, nil
|
||||
}
|
||||
// Wire behavior must be described completely by these tags.
|
||||
marshaler := reflect.TypeOf((*json.Marshaler)(nil)).Elem()
|
||||
unmarshaler := reflect.TypeOf((*json.Unmarshaler)(nil)).Elem()
|
||||
if t.Implements(marshaler) || reflect.PointerTo(t).Implements(marshaler) || t.Implements(unmarshaler) || reflect.PointerTo(t).Implements(unmarshaler) {
|
||||
return nil, errors.New("contract: custom JSON marshalers are unsupported")
|
||||
if t.Kind() != reflect.Pointer && (t.Implements(marshaler) || reflect.PointerTo(t).Implements(marshaler) || t.Implements(unmarshaler) || reflect.PointerTo(t).Implements(unmarshaler)) {
|
||||
return nil, errors.New("contract: custom JSON marshalers require explicit Scalar registration")
|
||||
}
|
||||
switch t.Kind() {
|
||||
case reflect.Interface:
|
||||
if t.NumMethod() != 0 {
|
||||
return nil, errors.New("contract: only any / interface{} is supported")
|
||||
}
|
||||
case reflect.Pointer:
|
||||
e, err := compile(t.Elem(), false, stack, depth+1, budget)
|
||||
e, err := c.compile(t.Elem(), false, stack, depth+1, budget)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n.elem = e
|
||||
*n.schema = *e.schema
|
||||
n.schema.Nullable = true
|
||||
n.schema = nullableSchema(e.schema)
|
||||
case reflect.String:
|
||||
n.schema.Type = "string"
|
||||
case reflect.Bool:
|
||||
@@ -132,10 +163,10 @@ func compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int,
|
||||
if t.Kind() == reflect.Map && t.Key().Kind() != reflect.String {
|
||||
return nil, errors.New("contract: map keys must be strings")
|
||||
}
|
||||
if t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 {
|
||||
if t.Kind() == reflect.Slice && t.Elem().Kind() == reflect.Uint8 && c.scalars[t.Elem()].Type == nil {
|
||||
return nil, errors.New("contract: byte slices use base64 in Go; use string for binary data")
|
||||
}
|
||||
e, err := compile(t.Elem(), false, stack, depth+1, budget)
|
||||
e, err := c.compile(t.Elem(), false, stack, depth+1, budget)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -158,7 +189,7 @@ func compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int,
|
||||
if sf.PkgPath != "" || sf.Anonymous {
|
||||
return nil, fmt.Errorf("contract: %s must be an exported named field", sf.Name)
|
||||
}
|
||||
f, err := compileField(sf, root, stack, depth+1, budget)
|
||||
f, err := c.compileField(sf, root, stack, depth+1, budget)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("contract: %s: %w", sf.Name, err)
|
||||
}
|
||||
@@ -182,7 +213,7 @@ func compile(t reflect.Type, root bool, stack map[reflect.Type]bool, depth int,
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func compileField(sf reflect.StructField, root bool, stack map[reflect.Type]bool, depth int, budget *int) (field, error) {
|
||||
func (c compiler) compileField(sf reflect.StructField, root bool, stack map[reflect.Type]bool, depth int, budget *int) (field, error) {
|
||||
f := field{}
|
||||
// Reject typos and unsupported tags instead of silently losing validation.
|
||||
tags, err := ParseTags(string(sf.Tag))
|
||||
@@ -218,11 +249,37 @@ func compileField(sf reflect.StructField, root bool, stack map[reflect.Type]bool
|
||||
if (f.source == "header" && !headerName.MatchString(f.name)) || (f.source != "header" && !wireName.MatchString(f.name)) {
|
||||
return f, errors.New("wire names must be lowerCamelCase (headers use HTTP names)")
|
||||
}
|
||||
n, err := compile(sf.Type, false, stack, depth, budget)
|
||||
n, err := c.compile(sf.Type, false, stack, depth, budget)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
f.value = n
|
||||
opaque := base(n).scalar
|
||||
isList := base(n).typ.Kind() == reflect.Slice && opaque == nil
|
||||
if isList {
|
||||
opaque = base(base(n).elem).scalar
|
||||
}
|
||||
if opaque != nil {
|
||||
if isList && (f.source == "path" || f.source == "header") {
|
||||
return f, errors.New("path/header values must be scalars")
|
||||
}
|
||||
if f.source != "json" && !opaque.Text {
|
||||
return f, errors.New("custom scalar needs text: true and UnmarshalText for path/query/header")
|
||||
}
|
||||
if f.binding != "" {
|
||||
return f, errors.New("custom scalar constraints belong in its decoder; use optional/pointer for presence and null")
|
||||
}
|
||||
for _, option := range parts[1:] {
|
||||
if option != "optional" && option != "omitempty" {
|
||||
return f, errors.New("custom scalar supports only optional/omitempty field options")
|
||||
}
|
||||
f.optional = true
|
||||
}
|
||||
if f.source == "path" && (f.optional || sf.Type.Kind() == reflect.Pointer) {
|
||||
return f, errors.New("path fields must be required non-pointer values")
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
scalar := base(n).typ.Kind()
|
||||
if f.source != "json" && !isScalar(scalar) && !(scalar == reflect.Slice && isScalar(base(base(n).elem).typ.Kind())) {
|
||||
return f, errors.New("path/query/header values must be scalars or scalar slices")
|
||||
@@ -370,7 +427,7 @@ func (b *Binder) ResponseSchema() (*openapi.Schema, error) {
|
||||
return nil, errors.New("contract: response fields must use json tags")
|
||||
}
|
||||
}
|
||||
return copySchema(b.root.schema), nil
|
||||
return responseSchema(b.root), nil
|
||||
}
|
||||
func (b *Binder) Parameters() []openapi.Parameter {
|
||||
var out []openapi.Parameter
|
||||
@@ -382,7 +439,13 @@ func (b *Binder) Parameters() []openapi.Parameter {
|
||||
if in == "form" {
|
||||
in = "query"
|
||||
}
|
||||
out = append(out, openapi.Parameter{Name: f.name, In: in, Required: !f.optional && !f.hasDefault, Schema: copySchema(f.value.schema)})
|
||||
schema := copySchema(f.value.schema)
|
||||
if base(f.value).scalar != nil {
|
||||
schema = &openapi.Schema{Type: "string"}
|
||||
} else if base(f.value).typ.Kind() == reflect.Slice && base(base(f.value).elem).scalar != nil {
|
||||
schema.Items = &openapi.Schema{Type: "string"}
|
||||
}
|
||||
out = append(out, openapi.Parameter{Name: f.name, In: in, Required: !f.optional && !f.hasDefault, Schema: schema})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
199
contract/scalar.go
Normal file
199
contract/scalar.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/openapi"
|
||||
)
|
||||
|
||||
// Scalar explicitly opts one application Go type into custom wire conversion.
|
||||
// Input is a | separated union of scalar wire types; Output is one wire type.
|
||||
// The value must implement json.Marshaler, its pointer json.Unmarshaler, and
|
||||
// Text=true additionally requires encoding.TextUnmarshaler on the pointer.
|
||||
// No process-global registry or mutation of other binders is involved.
|
||||
type Scalar struct {
|
||||
Type reflect.Type
|
||||
JSONInput, JSONOutput string
|
||||
Text bool
|
||||
}
|
||||
|
||||
func ScalarOf[T any](input, output string, text bool) Scalar {
|
||||
return Scalar{Type: reflect.TypeOf((*T)(nil)).Elem(), JSONInput: input, JSONOutput: output, Text: text}
|
||||
}
|
||||
|
||||
type compiler struct {
|
||||
scalars map[reflect.Type]Scalar
|
||||
}
|
||||
type scalarText string
|
||||
|
||||
func (s Scalar) validate(schemaOnly bool) error {
|
||||
if s.Type == nil || s.Type.Kind() == reflect.Pointer || s.Type.Kind() == reflect.Interface {
|
||||
return errors.New("contract: scalar requires a concrete non-pointer type")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, kind := range strings.Split(s.JSONInput, "|") {
|
||||
kind = strings.TrimSpace(kind)
|
||||
if wireSchema(kind) == nil || seen[kind] {
|
||||
return fmt.Errorf("contract: invalid/duplicate scalar input %q", kind)
|
||||
}
|
||||
seen[kind] = true
|
||||
}
|
||||
if wireSchema(s.JSONOutput) == nil {
|
||||
return fmt.Errorf("contract: invalid scalar output %q", s.JSONOutput)
|
||||
}
|
||||
if !schemaOnly {
|
||||
if !s.Type.Implements(reflect.TypeOf((*json.Marshaler)(nil)).Elem()) || !reflect.PointerTo(s.Type).Implements(reflect.TypeOf((*json.Unmarshaler)(nil)).Elem()) {
|
||||
return fmt.Errorf("contract: %s needs value MarshalJSON and pointer UnmarshalJSON", s.Type)
|
||||
}
|
||||
if s.Text && !reflect.PointerTo(s.Type).Implements(reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem()) {
|
||||
return fmt.Errorf("contract: %s needs UnmarshalText", s.Type)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func wireSchema(kind string) *openapi.Schema {
|
||||
switch kind {
|
||||
case "string":
|
||||
return &openapi.Schema{Type: "string"}
|
||||
case "bool":
|
||||
return &openapi.Schema{Type: "boolean"}
|
||||
case "int32", "int64":
|
||||
return &openapi.Schema{Type: "integer", Format: kind}
|
||||
case "float64":
|
||||
return &openapi.Schema{Type: "number", Format: "double"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s Scalar) inputSchema() *openapi.Schema {
|
||||
var alternatives []*openapi.Schema
|
||||
for _, kind := range strings.Split(s.JSONInput, "|") {
|
||||
alternatives = append(alternatives, wireSchema(strings.TrimSpace(kind)))
|
||||
}
|
||||
if len(alternatives) == 1 {
|
||||
return alternatives[0]
|
||||
}
|
||||
return &openapi.Schema{AnyOf: alternatives}
|
||||
}
|
||||
func (s Scalar) decode(dst reflect.Value, value any) (err error) {
|
||||
// User decoders may fail; do not allow their panic to escape a binder.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = errors.New("custom scalar decoder panicked")
|
||||
}
|
||||
}()
|
||||
if text, ok := value.(scalarText); ok {
|
||||
decoder, ok := dst.Addr().Interface().(encoding.TextUnmarshaler)
|
||||
if !s.Text || !ok {
|
||||
return errors.New("text decoder not available")
|
||||
}
|
||||
return decoder.UnmarshalText([]byte(text))
|
||||
}
|
||||
allowed := false
|
||||
for _, kind := range strings.Split(s.JSONInput, "|") {
|
||||
if scalarAccepts(strings.TrimSpace(kind), value) {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !allowed {
|
||||
return errors.New("value does not match declared scalar input")
|
||||
}
|
||||
decoder, ok := dst.Addr().Interface().(json.Unmarshaler)
|
||||
if !ok {
|
||||
return errors.New("JSON decoder not available")
|
||||
}
|
||||
raw, err := json.Marshal(value) // json.Number preserves exact integer tokens.
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return decoder.UnmarshalJSON(raw)
|
||||
}
|
||||
func scalarAccepts(kind string, value any) bool {
|
||||
switch kind {
|
||||
case "string":
|
||||
_, ok := value.(string)
|
||||
return ok
|
||||
case "bool":
|
||||
_, ok := value.(bool)
|
||||
return ok
|
||||
case "int32", "int64":
|
||||
n, ok := value.(json.Number)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
bits := 64
|
||||
if kind == "int32" {
|
||||
bits = 32
|
||||
}
|
||||
_, err := strconv.ParseInt(string(n), 10, bits)
|
||||
return err == nil
|
||||
case "float64":
|
||||
n, ok := value.(json.Number)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
_, err := strconv.ParseFloat(string(n), 64)
|
||||
return err == nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
func responseSchema(n *node) *openapi.Schema {
|
||||
b := base(n)
|
||||
if b.scalar != nil {
|
||||
s := wireSchema(b.scalar.JSONOutput)
|
||||
if n.typ.Kind() == reflect.Pointer {
|
||||
s = nullableSchema(s)
|
||||
}
|
||||
return s
|
||||
}
|
||||
// Preserve the field's own constraints (including pointer range/enum and
|
||||
// collection length) while changing only nested custom scalar wire types.
|
||||
s := copySchema(n.schema)
|
||||
if b.typ.Kind() == reflect.Slice {
|
||||
s.Items = responseSchema(b.elem)
|
||||
}
|
||||
if b.typ.Kind() == reflect.Map {
|
||||
s.AdditionalProperties = responseSchema(b.elem)
|
||||
}
|
||||
for _, f := range b.fields {
|
||||
if f.source == "json" {
|
||||
s.Properties[f.name] = responseSchema(f.value)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ValidateScalars checks the application's explicitly registered method contracts.
|
||||
func ValidateScalars(scalars ...Scalar) error {
|
||||
seen := map[reflect.Type]bool{}
|
||||
for _, s := range scalars {
|
||||
if err := s.validate(false); err != nil {
|
||||
return err
|
||||
}
|
||||
if seen[s.Type] {
|
||||
return fmt.Errorf("contract: duplicate scalar type %s", s.Type)
|
||||
}
|
||||
seen[s.Type] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenAPI 3.0 nullable only applies alongside an explicit type. A union needs
|
||||
// an explicit null alternative; an unconstrained dynamic schema already accepts null.
|
||||
func nullableSchema(schema *openapi.Schema) *openapi.Schema {
|
||||
s := copySchema(schema)
|
||||
if s.Type != "" {
|
||||
s.Nullable = true
|
||||
return s
|
||||
}
|
||||
if len(s.AnyOf) > 0 {
|
||||
s.AnyOf = append(s.AnyOf, &openapi.Schema{Type: "string", Nullable: true, Enum: []any{nil}})
|
||||
}
|
||||
return s
|
||||
}
|
||||
222
contract/scalar_test.go
Normal file
222
contract/scalar_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type flexibleID int64
|
||||
|
||||
func (v flexibleID) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(strconv.FormatInt(int64(v), 10))
|
||||
}
|
||||
func (v *flexibleID) UnmarshalJSON(b []byte) error {
|
||||
var s string
|
||||
if len(b) > 0 && b[0] == '"' {
|
||||
if err := json.Unmarshal(b, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
s = string(b)
|
||||
}
|
||||
return v.UnmarshalText([]byte(s))
|
||||
}
|
||||
func (v *flexibleID) UnmarshalText(b []byte) error {
|
||||
n, err := strconv.ParseInt(string(b), 10, 64)
|
||||
if err == nil {
|
||||
*v = flexibleID(n)
|
||||
}
|
||||
return err
|
||||
}
|
||||
func TestDynamicJSONAndCustomScalars(t *testing.T) {
|
||||
type request struct {
|
||||
ID flexibleID `json:"id"`
|
||||
Dynamic any `json:"dynamic"`
|
||||
IDs []flexibleID `json:"ids,optional"`
|
||||
}
|
||||
scalar := ScalarOf[flexibleID]("string|int64", "string", true)
|
||||
b, err := New[request](scalar)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []string{`"9223372036854775807"`, `9223372036854775807`} {
|
||||
r := httptest.NewRequest("POST", "/", strings.NewReader(`{"id":`+id+`,"dynamic":{"n":9223372036854775807},"ids":["1",2]}`))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
var req request
|
||||
if err = b.Bind(r, nil, &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.ID != flexibleID(9223372036854775807) || req.Dynamic.(map[string]any)["n"] != json.Number("9223372036854775807") {
|
||||
t.Fatalf("precision: %+v", req)
|
||||
}
|
||||
encoded, _ := json.Marshal(req)
|
||||
if !strings.Contains(string(encoded), `"id":"9223372036854775807"`) {
|
||||
t.Fatal(string(encoded))
|
||||
}
|
||||
}
|
||||
for _, body := range []string{`{"id":true,"dynamic":null}`, `{"id":1.1,"dynamic":null}`, `{"id":9223372036854775808,"dynamic":null}`, `{"id":1,"dynamic":{"x":1,"x":2}}`, `{"id":1,"dynamic":null,"unexpected":1}`} {
|
||||
r := httptest.NewRequest("POST", "/", strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
req := request{ID: 7}
|
||||
if b.Bind(r, nil, &req) == nil || req.ID != 7 {
|
||||
t.Fatalf("accepted or mutated %s", body)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{`null`, `[1,"x"]`, `false`, `"text"`} {
|
||||
r := httptest.NewRequest("POST", "/", strings.NewReader(`{"id":1,"dynamic":`+value+`}`))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if err := b.Bind(r, nil, &request{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := New[request](); err == nil {
|
||||
t.Fatal("implicit custom type registration")
|
||||
}
|
||||
response, err := b.ResponseSchema()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Properties["id"].Type != "string" || response.Properties["ids"].Items.Type != "string" || len(b.BodySchema().Properties["id"].AnyOf) != 2 {
|
||||
t.Fatal("input/output schemas conflated")
|
||||
}
|
||||
type path struct {
|
||||
ID flexibleID `path:"id"`
|
||||
}
|
||||
p, err := New[path](scalar)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req path
|
||||
if err := p.Bind(httptest.NewRequest("GET", "/", nil), map[string]string{"id": "9223372036854775807"}, &req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Parameters()[0].Schema.Type != "string" {
|
||||
t.Fatal("text parameters need text schema")
|
||||
}
|
||||
if _, err := New[path](ScalarOf[flexibleID]("string|int64", "string", false)); err == nil {
|
||||
t.Fatal("text support not explicit")
|
||||
}
|
||||
schemaOnly, err := Describe(reflect.TypeOf(request{}), scalar)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if schemaOnly.Bind(httptest.NewRequest("POST", "/", nil), nil, &request{}) == nil {
|
||||
t.Fatal("schema-only binder executed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScalarPointersCollectionsAndRegistration(t *testing.T) {
|
||||
scalar := ScalarOf[flexibleID]("string|int64", "string", true)
|
||||
type nullable struct {
|
||||
ID *flexibleID `json:"id,optional"`
|
||||
Values map[string]*flexibleID `json:"values,optional"`
|
||||
}
|
||||
b, err := New[nullable](scalar)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, body := range []string{`{}`, `{"id":null}`, `{"id":1,"values":{"a":"2","b":null}}`} {
|
||||
r := httptest.NewRequest("POST", "/", strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if err := b.Bind(r, nil, &nullable{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
input := b.BodySchema().Properties["id"]
|
||||
if len(input.AnyOf) != 3 || !input.AnyOf[2].Nullable || len(input.AnyOf[2].Enum) != 1 || input.AnyOf[2].Enum[0] != nil {
|
||||
t.Fatal("nullable input union does not describe null")
|
||||
}
|
||||
out, err := b.ResponseSchema()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Properties["id"].Type != "string" || !out.Properties["id"].Nullable {
|
||||
t.Fatal("nullable output type")
|
||||
}
|
||||
type query struct {
|
||||
IDs []flexibleID `form:"ids"`
|
||||
}
|
||||
q, err := New[query](scalar)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var req query
|
||||
if err := q.Bind(httptest.NewRequest("GET", "/?ids=1&ids=2", nil), nil, &req); err != nil || !reflect.DeepEqual(req.IDs, []flexibleID{1, 2}) {
|
||||
t.Fatalf("query scalar collection: %+v %v", req, err)
|
||||
}
|
||||
if q.Parameters()[0].Schema.Items.Type != "string" {
|
||||
t.Fatal("text array schema")
|
||||
}
|
||||
if _, err := New[query](ScalarOf[flexibleID]("string|int64", "string", false)); err == nil {
|
||||
t.Fatal("array bypassed text declaration")
|
||||
}
|
||||
if err := ValidateScalars(ScalarOf[int64]("int64", "string", true)); err == nil {
|
||||
t.Fatal("missing methods accepted")
|
||||
}
|
||||
if err := ValidateScalars(scalar, scalar); err == nil {
|
||||
t.Fatal("duplicate registration")
|
||||
}
|
||||
type strict struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
strictBinder := MustNew[strict]()
|
||||
r := httptest.NewRequest("POST", "/", strings.NewReader(`{"id":"1"}`))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
if strictBinder.Bind(r, nil, &strict{}) == nil {
|
||||
t.Fatal("other binder globally loosened")
|
||||
}
|
||||
}
|
||||
|
||||
type panicScalar string
|
||||
|
||||
func (v panicScalar) MarshalJSON() ([]byte, error) { return json.Marshal(string(v)) }
|
||||
func (v *panicScalar) UnmarshalJSON([]byte) error { panic("decoder failure") }
|
||||
func TestScalarPanicIsBindingError(t *testing.T) {
|
||||
type req struct {
|
||||
Value panicScalar `json:"value"`
|
||||
}
|
||||
b := MustNew[req](ScalarOf[panicScalar]("string", "string", false))
|
||||
r := httptest.NewRequest("POST", "/", strings.NewReader(`{"value":"bad"}`))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
dst := req{Value: "original"}
|
||||
if err := b.Bind(r, nil, &dst); err == nil || dst.Value != "original" {
|
||||
t.Fatalf("decoder panic: %+v %v", dst, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimitedJSONTrailingWhitespace(t *testing.T) {
|
||||
type req struct {
|
||||
Value any `json:"value"`
|
||||
}
|
||||
b := MustNew[req]()
|
||||
r := httptest.NewRequest("POST", "/", strings.NewReader(`{"value":null}`+strings.Repeat(" ", 65)))
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r.ContentLength = -1
|
||||
r.Body = http.MaxBytesReader(httptest.NewRecorder(), r.Body, 64)
|
||||
err := b.Bind(r, nil, &req{}, 64)
|
||||
if apierrors.From(err).Status != 413 {
|
||||
t.Fatalf("tail limit error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseSchemaPreservesOrdinaryPointerConstraints(t *testing.T) {
|
||||
type dto struct {
|
||||
Count *int `json:"count,optional,range=[1:10]"`
|
||||
Choice *string `json:"choice,options=a|b"`
|
||||
Values *[]int `json:"values,optional" binding:"max=3"`
|
||||
}
|
||||
b := MustNew[dto]()
|
||||
response, err := b.ResponseSchema()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(response, b.BodySchema()) {
|
||||
t.Fatalf("changed ordinary response constraints: %#v", response)
|
||||
}
|
||||
}
|
||||
@@ -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`。
|
||||
|
||||
29
examples/compatibility/ids/id.go
Normal file
29
examples/compatibility/ids/id.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// Package ids demonstrates an application-owned scalar. The framework does not
|
||||
// impose this ID policy on any other type or project.
|
||||
package ids
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type ID int64
|
||||
|
||||
func (id ID) MarshalJSON() ([]byte, error) { return json.Marshal(strconv.FormatInt(int64(id), 10)) }
|
||||
func (id *ID) UnmarshalJSON(raw []byte) error {
|
||||
value := string(raw)
|
||||
if len(raw) > 0 && raw[0] == '"' {
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return id.UnmarshalText([]byte(value))
|
||||
}
|
||||
func (id *ID) UnmarshalText(raw []byte) error {
|
||||
value, err := strconv.ParseInt(string(raw), 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*id = ID(value)
|
||||
return nil
|
||||
}
|
||||
75
examples/compatibility/main.go
Normal file
75
examples/compatibility/main.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// This example only uses local HTTP and bounded streaming; it has no database.
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||
"git.yuntuo.hk/k2safe/go-omni/examples/compatibility/ids"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
)
|
||||
|
||||
type echoRequest struct {
|
||||
ID ids.ID `json:"id"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
app := omni.New(omni.WithLogger(true))
|
||||
defer app.Close()
|
||||
app.Use(httpx.WrapMiddleware(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Example", "compatibility")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}))
|
||||
app.GET("/legacy", httpx.WrapHandler(http.HandlerFunc(http.NotFound)))
|
||||
binder := contract.MustNew[echoRequest](contract.ScalarOf[ids.ID]("string|int64", "string", true))
|
||||
app.POST("/echo", func(c *httpx.Context) error {
|
||||
var req echoRequest
|
||||
if err := binder.Bind(c.Request(), nil, &req); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.OK(req)
|
||||
})
|
||||
app.HandleWithBodyLimit("POST", "/upload", 10<<20, nil, func(c *httpx.Context) error {
|
||||
reader, err := c.Request().MultipartReader()
|
||||
if err != nil {
|
||||
return omni.BadRequest("INVALID_MULTIPART", "multipart body required")
|
||||
}
|
||||
var total int64
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Replace io.Discard with a streaming storage writer. Do not trust file names
|
||||
// as filesystem paths. io.Copy never buffers the complete uploaded file.
|
||||
n, copyErr := io.Copy(io.Discard, part)
|
||||
closeErr := part.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
total += n
|
||||
}
|
||||
return c.OK(map[string]int64{"bytes": total})
|
||||
})
|
||||
app.GET("/download", httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="example.txt"`)
|
||||
_, _ = io.Copy(w, strings.NewReader("A standard Handler keeps its original download response.\n"))
|
||||
})))
|
||||
if err := app.Run("127.0.0.1:8080"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
64
examples/contracts/compatibility.api
Normal file
64
examples/contracts/compatibility.api
Normal file
@@ -0,0 +1,64 @@
|
||||
syntax = "v1"
|
||||
|
||||
scalar BusinessID (
|
||||
goImport: "git.yuntuo.hk/k2safe/go-omni/examples/compatibility/ids"
|
||||
goType: ID
|
||||
jsonInput: string | int64
|
||||
jsonOutput: string
|
||||
text: true
|
||||
)
|
||||
|
||||
type EchoReq {
|
||||
ID BusinessID `json:"id"`
|
||||
Data any `json:"data"`
|
||||
Legacy interface{} `json:"legacy,optional"`
|
||||
}
|
||||
type EchoResp {
|
||||
ID BusinessID `json:"id"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
type ItemReq {
|
||||
ID BusinessID `path:"id"`
|
||||
}
|
||||
type CustomReq {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
type CustomResp {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
@server (group: demo)
|
||||
service compatibility-api {
|
||||
@body (maxBytes: 2097152)
|
||||
@handler echo
|
||||
post /echo (EchoReq) returns (EchoResp)
|
||||
|
||||
@handler item
|
||||
get /items/:id (ItemReq) returns (EchoResp)
|
||||
|
||||
@binding custom
|
||||
@body (maxBytes: 512)
|
||||
@handler custom
|
||||
post /custom (CustomReq) returns (CustomResp)
|
||||
|
||||
@handlerMode manual
|
||||
@body (mode: multipart
|
||||
maxBytes: 65536)
|
||||
@handler upload
|
||||
post /upload
|
||||
|
||||
@handlerMode manual
|
||||
@body (mode: raw
|
||||
maxBytes: 128)
|
||||
@produces "text/plain"
|
||||
@handler callback
|
||||
post /callback
|
||||
}
|
||||
|
||||
@server (group: files)
|
||||
service compatibility-api {
|
||||
@handlerMode manual
|
||||
@produces "application/octet-stream"
|
||||
@handler download
|
||||
get /download
|
||||
}
|
||||
58
examples/contracts/responses.api
Normal file
58
examples/contracts/responses.api
Normal 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)
|
||||
}
|
||||
@@ -9,10 +9,12 @@
|
||||
"module": "example.com/userService",
|
||||
"entry": "api/user.api",
|
||||
"generated": {
|
||||
"docs/openapi.json": "0675e4eb1278b36828aa31b12587b0d33d2f384112f67a43179eb72b52c3a0ea",
|
||||
"internal/handler/openapiGen.go": "8ddc60c338630db915b9f074ade776f526eabf60fc99b33e425359fd415c4926",
|
||||
"internal/handler/routesGen.go": "7f45030e7eb9f055c4b4d2ff12ad855d7c726aad4aee63c5b3c3f97559a64257",
|
||||
"internal/handler/user/handlersGen.go": "06f3c2798ff323812ff3eb2d8b933a569af3ce1b86a67b66036374add98c0d7e",
|
||||
"docs/openapi.json": "05bce8951699350cc1ebca5a626c39349395dd09e395b41560c129e530583204",
|
||||
"internal/apierrors/errorsGen.go": "20e94bbc90ae9f4330de4c25b9a550026c4c352502719a643ede3e8912a6566e",
|
||||
"internal/handler/openapiGen.go": "e4130f5c7494f7db4830bfef203fa8154a9cd087c95954f64c29d74693ead433",
|
||||
"internal/handler/responseGen.go": "d742efc2a3d1d168fae7849e905297bd440c3d6b3802b6150f494c07e6097f4f",
|
||||
"internal/handler/routesGen.go": "4e0c6879cea40e9c0155439fa1fce2cd19b5f83ea6e304523c41f8dc9c3a28e0",
|
||||
"internal/handler/user/handlersGen.go": "1808afc8e4e8ca9785a8552eff64fad39bd5fe4e6e92971f35afc9006e23d76e",
|
||||
"internal/middleware/registryGen.go": "a6122a5453dedc6a4415860108814c50e155128cfe5412b39dd33ce2d01b4b00",
|
||||
"internal/types/typesGen.go": "3ab2ce9ffe467c3ea53ac3d1fec283ecebc87cef57fd592d08dca991fd964876"
|
||||
}
|
||||
|
||||
@@ -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),使用随机密钥,结束时删除。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
42
examples/userService/internal/apierrors/errorsGen.go
Normal file
42
examples/userService/internal/apierrors/errorsGen.go
Normal 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
30
examples/userService/internal/handler/responseGen.go
Normal file
30
examples/userService/internal/handler/responseGen.go
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -16,12 +17,12 @@ func RegisterRoutes(router openapi.Registrar, deps *servicecontext.Context) erro
|
||||
if deps.Middlewares["Auth"] == nil {
|
||||
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"])
|
||||
h0 := h0.New(s0.New(deps), deps)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"example.com/userService/internal/servicecontext"
|
||||
"example.com/userService/internal/types"
|
||||
"fmt"
|
||||
"git.yuntuo.hk/k2safe/go-omni/contract"
|
||||
@@ -17,9 +18,18 @@ type Service interface {
|
||||
DeleteUser(ctx context.Context, req *types.UserIDReq) error
|
||||
ExportUser(ctx context.Context, req *types.UserIDReq) (*types.ExportResp, error)
|
||||
}
|
||||
type Handler struct{ service Service }
|
||||
type Handler struct {
|
||||
service Service
|
||||
deps *servicecontext.Context
|
||||
}
|
||||
|
||||
func New(s Service) *Handler { return &Handler{service: s} }
|
||||
func New(s Service, deps ...*servicecontext.Context) *Handler {
|
||||
h := &Handler{service: s}
|
||||
if len(deps) > 0 {
|
||||
h.deps = deps[0]
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
var createUserRequest = contract.MustNew[types.CreateUserReq]()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
36
examples/userService/internal/service/user/errors_test.go
Normal file
36
examples/userService/internal/service/user/errors_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
60
httpx/body.go
Normal file
60
httpx/body.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"errors"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// LimitBody is useful for handwritten routes. Built-in App.HandleWithBodyLimit
|
||||
// additionally installs the reader cap before global middleware can read it.
|
||||
func LimitBody(maxBytes int64) Middleware {
|
||||
if maxBytes <= 0 || maxBytes > 1<<40 {
|
||||
panic("httpx: body limit must be 1..1 TiB")
|
||||
}
|
||||
return func(next Handler) Handler {
|
||||
return func(c *Context) error {
|
||||
r := c.Request()
|
||||
if r.ContentLength > maxBytes {
|
||||
return bodyTooLarge()
|
||||
}
|
||||
if r.Body != nil {
|
||||
r.Body = http.MaxBytesReader(c.Raw(), r.Body, maxBytes)
|
||||
}
|
||||
defer CleanupMultipart(r)
|
||||
err := next(c)
|
||||
var limitErr *http.MaxBytesError
|
||||
if errors.As(err, &limitErr) {
|
||||
return bodyTooLarge()
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
func bodyTooLarge() error { return apierrors.New(413, "BODY_TOO_LARGE", "request body exceeds limit") }
|
||||
|
||||
// CleanupMultipart removes disk spill files created by ParseMultipartForm.
|
||||
// Callers still close individual multipart files. Streaming MultipartReader
|
||||
// handlers own any files they create themselves.
|
||||
func CleanupMultipart(r *http.Request) {
|
||||
if r != nil && r.MultipartForm != nil {
|
||||
_ = r.MultipartForm.RemoveAll()
|
||||
}
|
||||
}
|
||||
|
||||
type BodyLimitRegistrar interface {
|
||||
HandleWithBodyLimit(string, string, int64, response.Encoder, Handler, ...Middleware)
|
||||
}
|
||||
|
||||
func HandleWithBodyLimit(router interface {
|
||||
Handle(string, string, Handler, ...Middleware)
|
||||
}, method, path string, maxBytes int64, encoder response.Encoder, handler Handler, middleware ...Middleware) {
|
||||
if r, ok := router.(BodyLimitRegistrar); ok {
|
||||
r.HandleWithBodyLimit(method, path, maxBytes, encoder, handler, middleware...)
|
||||
return
|
||||
}
|
||||
// Custom registrars without the early hook cap at route middleware entry.
|
||||
all := append([]Middleware{LimitBody(maxBytes)}, middleware...)
|
||||
HandleWithResponse(router, method, path, encoder, handler, all...)
|
||||
}
|
||||
80
httpx/bridge.go
Normal file
80
httpx/bridge.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type bridgeState struct{ parent *Context }
|
||||
|
||||
// WrapHandler runs an existing standard handler inside the App middleware chain.
|
||||
// It preserves the original response, including downloads and streaming output.
|
||||
func WrapHandler(handler http.Handler) Handler {
|
||||
if handler == nil {
|
||||
panic("httpx: nil HTTP handler")
|
||||
}
|
||||
return func(c *Context) error {
|
||||
r := bridgeRequest(c)
|
||||
defer CleanupMultipart(r)
|
||||
handler.ServeHTTP(c.Raw(), r)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// WrapMiddleware adapts synchronous standard HTTP middleware. The middleware is
|
||||
// constructed once per chain, preserving its shared state across requests.
|
||||
// Errors from next are encoded before unwinding so response-transforming
|
||||
// middleware sees them. Preserve the incoming request context (derive with
|
||||
// WithContext), and never call next after ServeHTTP returns, as with net/http.
|
||||
func WrapMiddleware(middleware func(http.Handler) http.Handler) Middleware {
|
||||
if middleware == nil {
|
||||
panic("httpx: nil HTTP middleware")
|
||||
}
|
||||
return func(next Handler) Handler {
|
||||
// Each nested bridge has its own key, including repeated use of one adapter.
|
||||
key := &bridgeState{}
|
||||
wrapped := middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
state, ok := r.Context().Value(key).(*bridgeState)
|
||||
if !ok {
|
||||
panic("httpx: standard middleware must preserve the incoming request context")
|
||||
}
|
||||
c := state.parent
|
||||
child := c.Clone(r.Context(), w)
|
||||
child.request = r
|
||||
if c.Responded() {
|
||||
child.writer.(interface{ inheritCommit(int) }).inheritCommit(c.Status())
|
||||
}
|
||||
defer CleanupMultipart(r)
|
||||
defer func() { c.values = child.values; c.encoder = child.encoder }()
|
||||
if err := next(child); err != nil && !c.Responded() {
|
||||
child.WriteError(err)
|
||||
}
|
||||
}))
|
||||
if wrapped == nil {
|
||||
panic("httpx: HTTP middleware returned nil handler")
|
||||
}
|
||||
return func(c *Context) error {
|
||||
r := bridgeRequest(c).WithContext(context.WithValue(c.Context(), key, &bridgeState{parent: c}))
|
||||
defer CleanupMultipart(r)
|
||||
wrapped.ServeHTTP(c.Raw(), r)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WrapFuncMiddleware supports middleware shaped as func(http.HandlerFunc) http.HandlerFunc.
|
||||
func WrapFuncMiddleware(middleware func(http.HandlerFunc) http.HandlerFunc) Middleware {
|
||||
if middleware == nil {
|
||||
panic("httpx: nil HTTP middleware")
|
||||
}
|
||||
return WrapMiddleware(func(next http.Handler) http.Handler { return middleware(next.ServeHTTP) })
|
||||
}
|
||||
|
||||
// Populate Go 1.22 path values without mutating a parent router's request maps.
|
||||
func bridgeRequest(c *Context) *http.Request {
|
||||
r := c.Request().Clone(c.Context())
|
||||
for name, value := range c.params {
|
||||
r.SetPathValue(name, value)
|
||||
}
|
||||
return r
|
||||
}
|
||||
363
httpx/bridge_test.go
Normal file
363
httpx/bridge_test.go
Normal file
@@ -0,0 +1,363 @@
|
||||
package httpx_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
func TestLegacyResponseTrackingAndMiddleware(t *testing.T) {
|
||||
var logs bytes.Buffer
|
||||
closeLog, err := logx.Setup(logx.Config{Writer: &logs, Format: "json"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer closeLog()
|
||||
app := omni.New(omni.WithLogger(true))
|
||||
app.GET("/legacy", httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })))
|
||||
app.GET("/late", func(c *httpx.Context) error {
|
||||
c.Raw().WriteHeader(404)
|
||||
_, _ = c.Raw().Write([]byte("original"))
|
||||
return errors.New("late error")
|
||||
})
|
||||
app.GET("/panic", func(c *httpx.Context) error { _, _ = c.Raw().Write([]byte("partial")); panic("late panic") })
|
||||
for _, tc := range []struct {
|
||||
path string
|
||||
status int
|
||||
body string
|
||||
}{{"/legacy", 404, "404 page not found\n"}, {"/late", 404, "original"}, {"/panic", 200, "partial"}} {
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, httptest.NewRequest("GET", tc.path, nil))
|
||||
if w.Code != tc.status || w.Body.String() != tc.body {
|
||||
t.Fatalf("%s: %d %q", tc.path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
count := 0
|
||||
for _, line := range strings.Split(logs.String(), "\n") {
|
||||
var e map[string]any
|
||||
if json.Unmarshal([]byte(line), &e) != nil || e["msg"] != "http.request" {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if e["path"] != "/panic" && e["status"] != float64(404) {
|
||||
t.Fatalf("wrong log: %s", line)
|
||||
}
|
||||
}
|
||||
if count != 3 {
|
||||
t.Fatalf("access logs: %d", count)
|
||||
}
|
||||
|
||||
type key struct{}
|
||||
app = omni.New()
|
||||
app.Use(httpx.WrapFuncMiddleware(func(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Before", "yes")
|
||||
next(w, r.WithContext(context.WithValue(r.Context(), key{}, "identity")))
|
||||
}
|
||||
}))
|
||||
app.GET("/context", func(c *httpx.Context) error {
|
||||
if c.Context().Value(key{}) != "identity" || c.RequestID() == "" {
|
||||
t.Error("lost context")
|
||||
}
|
||||
return omni.NotFound("MISSING", "missing")
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, httptest.NewRequest("GET", "/context", nil))
|
||||
if w.Code != 404 || w.Header().Get("X-Before") != "yes" || strings.Count(w.Body.String(), "MISSING") != 1 {
|
||||
t.Fatalf("middleware response: %d %s", w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
|
||||
type plainWriter struct {
|
||||
header http.Header
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *plainWriter) Header() http.Header { return w.header }
|
||||
func (w *plainWriter) WriteHeader(status int) { w.status = status }
|
||||
func (w *plainWriter) Write(p []byte) (int, error) { return len(p), nil }
|
||||
func TestWriterCapabilitiesAndInformationalStatus(t *testing.T) {
|
||||
w := &plainWriter{header: http.Header{}}
|
||||
c := httpx.NewContext(httptest.NewRequest("GET", "/", nil), w, nil)
|
||||
if _, ok := c.Raw().(http.Flusher); ok {
|
||||
t.Fatal("invented Flusher")
|
||||
}
|
||||
if _, ok := c.Raw().(http.Hijacker); ok {
|
||||
t.Fatal("invented Hijacker")
|
||||
}
|
||||
if err := http.NewResponseController(c.Raw()).Flush(); !errors.Is(err, http.ErrNotSupported) {
|
||||
t.Fatalf("Flush: %v", err)
|
||||
}
|
||||
c.Raw().WriteHeader(103)
|
||||
if c.Responded() {
|
||||
t.Fatal("103 committed final response")
|
||||
}
|
||||
c.Raw().WriteHeader(201)
|
||||
c.Raw().WriteHeader(500)
|
||||
if !c.Responded() || c.Status() != 201 || w.status != 201 {
|
||||
t.Fatal("first final status not preserved")
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
c = httpx.NewContext(httptest.NewRequest("GET", "/", nil), recorder, nil)
|
||||
if err := http.NewResponseController(c.Raw()).Flush(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !c.Responded() || c.Status() != 200 || !recorder.Flushed {
|
||||
t.Fatal("flush not tracked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyStreamingAcrossTransports(t *testing.T) {
|
||||
for _, transport := range []string{omni.TransportNetHTTP, omni.TransportNative} {
|
||||
t.Run(transport, func(t *testing.T) {
|
||||
release := make(chan struct{})
|
||||
defer close(release)
|
||||
app := omni.New(omni.WithTransport(transport))
|
||||
app.GET("/stream", httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
_, _ = io.WriteString(w, "first\n")
|
||||
if err := http.NewResponseController(w).Flush(); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-release:
|
||||
case <-r.Context().Done():
|
||||
}
|
||||
})))
|
||||
app.GET("/file", httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="data.bin"`)
|
||||
_, _ = io.Copy(w, strings.NewReader(strings.Repeat("x", 2<<20)))
|
||||
})))
|
||||
app.HandleWithBodyLimit("POST", "/bounded", 32, nil, func(c *httpx.Context) error {
|
||||
_, err := io.Copy(io.Discard, c.Request().Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.NoContent()
|
||||
})
|
||||
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 <-done:
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Error("shutdown timed out")
|
||||
}
|
||||
}()
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
defer client.CloseIdleConnections()
|
||||
for _, body := range []io.Reader{strings.NewReader(strings.Repeat("x", 33)), io.NopCloser(strings.NewReader(strings.Repeat("x", 33)))} {
|
||||
res, err := client.Post("http://"+listener.Addr().String()+"/bounded", "application/octet-stream", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, res.Body)
|
||||
res.Body.Close()
|
||||
if res.StatusCode != 413 {
|
||||
t.Fatalf("real transport body limit: %d", res.StatusCode)
|
||||
}
|
||||
}
|
||||
res, err := client.Get("http://" + listener.Addr().String() + "/stream")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := make([]byte, 6)
|
||||
_, err = io.ReadFull(res.Body, first)
|
||||
res.Body.Close()
|
||||
if err != nil || string(first) != "first\n" {
|
||||
t.Fatalf("stream was buffered: %s %v", first, err)
|
||||
}
|
||||
res, err = client.Get("http://" + listener.Addr().String() + "/file")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
size, err := io.Copy(io.Discard, res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil || size != 2<<20 || res.Header.Get("Content-Disposition") == "" {
|
||||
t.Fatalf("file: %d %v", size, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardMiddlewareStateAndShortCircuit(t *testing.T) {
|
||||
constructions, calls := 0, 0
|
||||
app := omni.New()
|
||||
defer app.Close()
|
||||
app.Use(httpx.WrapMiddleware(func(next http.Handler) http.Handler {
|
||||
constructions++
|
||||
count := 0
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
count++
|
||||
if count == 2 {
|
||||
http.Error(w, "denied", 401)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}))
|
||||
app.GET("/", func(c *httpx.Context) error { calls++; return c.NoContent() })
|
||||
for _, status := range []int{204, 401, 204} {
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
|
||||
if w.Code != status {
|
||||
t.Fatalf("got %d want %d", w.Code, status)
|
||||
}
|
||||
}
|
||||
if constructions != 1 || calls != 2 {
|
||||
t.Fatalf("middleware lifecycle: %d constructions, %d calls", constructions, calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardMiddlewareTransformsErrorAndPreservesCancellation(t *testing.T) {
|
||||
app := omni.New()
|
||||
defer app.Close()
|
||||
app.Use(httpx.WrapMiddleware(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// A legacy middleware may buffer its own small response. The bridge must
|
||||
// report its eventual physical status, and encode errors before it unwinds.
|
||||
temp := httptest.NewRecorder()
|
||||
next.ServeHTTP(temp, r)
|
||||
if temp.Code != 404 {
|
||||
t.Errorf("inner error not encoded: %d", temp.Code)
|
||||
}
|
||||
w.WriteHeader(409)
|
||||
_, _ = io.WriteString(w, strings.ToUpper(temp.Body.String()))
|
||||
})
|
||||
}))
|
||||
app.GET("/", func(c *httpx.Context) error {
|
||||
if !errors.Is(c.Context().Err(), context.Canceled) {
|
||||
t.Error("cancellation lost")
|
||||
}
|
||||
return omni.NotFound("missing", "missing")
|
||||
})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, httptest.NewRequest("GET", "/", nil).WithContext(ctx))
|
||||
if w.Code != 409 || !strings.Contains(w.Body.String(), "MISSING") {
|
||||
t.Fatalf("%d %s", w.Code, w.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteBodyLimitBeforeMiddleware(t *testing.T) {
|
||||
for _, length := range []int64{-1, 33} {
|
||||
t.Run(fmt.Sprint(length), func(t *testing.T) {
|
||||
app := omni.New()
|
||||
defer app.Close()
|
||||
read := 0
|
||||
app.Use(func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
body, err := io.ReadAll(c.Request().Body)
|
||||
read = len(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
})
|
||||
app.Group("/api").HandleWithBodyLimit("POST", "/upload", 32, nil, func(c *httpx.Context) error { t.Error("called oversized handler"); return nil })
|
||||
r := httptest.NewRequest("POST", "/api/upload", strings.NewReader(strings.Repeat("x", 33)))
|
||||
r.ContentLength = length
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, r)
|
||||
if w.Code != 413 || read > 32 {
|
||||
t.Fatalf("limit: status=%d read=%d", w.Code, read)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipartCleanupOnPanic(t *testing.T) {
|
||||
temp := t.TempDir()
|
||||
t.Setenv("TMPDIR", temp)
|
||||
var body bytes.Buffer
|
||||
form := multipart.NewWriter(&body)
|
||||
part, err := form.CreateFormFile("file", "x.bin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = part.Write(bytes.Repeat([]byte("x"), 4096))
|
||||
_ = form.Close()
|
||||
app := omni.New()
|
||||
defer app.Close()
|
||||
app.HandleWithBodyLimit("POST", "/", 8192, nil, httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(1); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
panic("after disk spill")
|
||||
})))
|
||||
r := httptest.NewRequest("POST", "/", &body)
|
||||
r.Header.Set("Content-Type", form.FormDataContentType())
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, r)
|
||||
entries, err := os.ReadDir(temp)
|
||||
if w.Code != 500 || err != nil || len(entries) != 0 {
|
||||
t.Fatalf("cleanup: %d %v %v", w.Code, entries, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStandardMiddlewareCommitsBeforeCallingNext(t *testing.T) {
|
||||
app := omni.New()
|
||||
defer app.Close()
|
||||
app.Use(httpx.WrapMiddleware(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(202)
|
||||
_, _ = io.WriteString(w, "accepted")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}))
|
||||
app.GET("/", func(c *httpx.Context) error {
|
||||
if !c.Responded() || c.Status() != 202 {
|
||||
t.Error("lost middleware commit")
|
||||
}
|
||||
return errors.New("late failure")
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, httptest.NewRequest("GET", "/", nil))
|
||||
if w.Code != 202 || w.Body.String() != "accepted" {
|
||||
t.Fatalf("late append: %d %q", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeStandardPathValues(t *testing.T) {
|
||||
app := omni.New()
|
||||
defer app.Close()
|
||||
app.Use(httpx.WrapMiddleware(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.PathValue("itemId") != "abc" {
|
||||
t.Error("middleware path value lost")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}))
|
||||
app.GET("/items/:itemId", httpx.WrapHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, r.PathValue("itemId")) })))
|
||||
w := httptest.NewRecorder()
|
||||
app.ServeHTTP(w, httptest.NewRequest("GET", "/items/abc", nil))
|
||||
if w.Body.String() != "abc" {
|
||||
t.Fatal("handler path value lost")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package httpx
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
@@ -30,6 +31,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 +94,7 @@ type Context struct {
|
||||
values map[string]any
|
||||
responded bool
|
||||
status int
|
||||
encoder response.Encoder
|
||||
}
|
||||
|
||||
// NewContext creates a transport-neutral GoOmni context.
|
||||
@@ -94,12 +102,15 @@ func NewContext(request *http.Request, writer http.ResponseWriter, params map[st
|
||||
if request == nil {
|
||||
request = &http.Request{}
|
||||
}
|
||||
return &Context{
|
||||
c := &Context{
|
||||
request: request,
|
||||
writer: writer,
|
||||
params: params,
|
||||
values: make(map[string]any),
|
||||
}
|
||||
if writer != nil {
|
||||
c.writer = trackWriter(writer, c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Context) Request() *http.Request { return c.request }
|
||||
@@ -113,14 +124,15 @@ func (c *Context) Context() context.Context {
|
||||
return c.request.Context()
|
||||
}
|
||||
|
||||
// Raw returns the response writer supplied by the active network adapter.
|
||||
// Prefer the transport-neutral methods on Context for portable handlers.
|
||||
// Raw returns a streaming response writer that tracks commits and status.
|
||||
// Optional HTTP capabilities are preserved only when the underlying writer supports them.
|
||||
func (c *Context) Raw() http.ResponseWriter { return c.writer }
|
||||
|
||||
// Clone gives a buffering middleware its own response state and values map.
|
||||
// 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
|
||||
}
|
||||
@@ -138,7 +150,6 @@ func (c *Context) WriteResponse(status int, headers http.Header, body []byte) er
|
||||
for key, values := range headers {
|
||||
c.writer.Header()[key] = append([]string(nil), values...)
|
||||
}
|
||||
c.responded, c.status = true, status
|
||||
c.writer.WriteHeader(status)
|
||||
if len(body) > 0 {
|
||||
if _, err := c.writer.Write(body); err != nil {
|
||||
@@ -208,8 +219,6 @@ func (c *Context) JSON(status int, value any) error {
|
||||
if err != nil {
|
||||
return apierrors.Internal(err)
|
||||
}
|
||||
c.responded = true
|
||||
c.status = status
|
||||
c.writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
c.writer.WriteHeader(status)
|
||||
if _, err := c.writer.Write(append(data, '\n')); err != nil {
|
||||
@@ -235,14 +244,13 @@ func (c *Context) NoContent() error {
|
||||
if c.writer == nil {
|
||||
return apierrors.Internal(nil)
|
||||
}
|
||||
c.responded = true
|
||||
c.status = http.StatusNoContent
|
||||
c.writer.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -262,16 +270,24 @@ func (c *Context) Status() int {
|
||||
func (c *Context) Responded() bool { return c.responded }
|
||||
|
||||
// WriteError writes a stable public error response. Internal causes are not
|
||||
// serialized. Responses committed through Context methods are preserved;
|
||||
// direct Raw writes are outside this response-tracking contract.
|
||||
// serialized. Responses committed through Context methods or Raw are preserved.
|
||||
func (c *Context) WriteError(err error) {
|
||||
if c.responded {
|
||||
if c.responded || err == nil {
|
||||
return
|
||||
}
|
||||
var bodyErr *http.MaxBytesError
|
||||
if errors.As(err, &bodyErr) {
|
||||
err = bodyTooLarge()
|
||||
}
|
||||
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
85
httpx/response.go
Normal 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)
|
||||
}
|
||||
315
httpx/trackingWriter.go
Normal file
315
httpx/trackingWriter.go
Normal file
@@ -0,0 +1,315 @@
|
||||
package httpx
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// trackingWriter does not buffer response bodies. Each wrapper has its own
|
||||
// commit flag, so a standard middleware may safely wrap an already tracked writer.
|
||||
type trackingWriter struct {
|
||||
http.ResponseWriter
|
||||
context *Context
|
||||
committed bool
|
||||
}
|
||||
|
||||
func (w *trackingWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
|
||||
func (w *trackingWriter) inheritCommit(status int) { w.mark(status) }
|
||||
func (w *trackingWriter) mark(status int) {
|
||||
w.committed = true
|
||||
w.context.responded = true
|
||||
w.context.status = status
|
||||
}
|
||||
func (w *trackingWriter) WriteHeader(status int) {
|
||||
if w.committed {
|
||||
return
|
||||
}
|
||||
// Let the server validate the status before updating the observation.
|
||||
w.ResponseWriter.WriteHeader(status)
|
||||
if status == 101 || status >= 200 {
|
||||
w.mark(status)
|
||||
}
|
||||
}
|
||||
func (w *trackingWriter) Write(p []byte) (int, error) {
|
||||
if !w.committed {
|
||||
w.mark(http.StatusOK)
|
||||
}
|
||||
return w.ResponseWriter.Write(p)
|
||||
}
|
||||
|
||||
type flushWriter struct{ *trackingWriter }
|
||||
|
||||
func (w flushWriter) Flush() { _ = w.FlushError() }
|
||||
func (w flushWriter) FlushError() error {
|
||||
if !w.committed {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
return http.NewResponseController(w.ResponseWriter).Flush()
|
||||
}
|
||||
|
||||
type hijackWriter struct{ *trackingWriter }
|
||||
|
||||
func (w hijackWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||
conn, rw, err := w.ResponseWriter.(http.Hijacker).Hijack()
|
||||
if err == nil {
|
||||
w.context.responded = true
|
||||
w.committed = true
|
||||
}
|
||||
return conn, rw, err
|
||||
}
|
||||
|
||||
type pushWriter struct{ *trackingWriter }
|
||||
|
||||
func (w pushWriter) Push(target string, options *http.PushOptions) error {
|
||||
return w.ResponseWriter.(http.Pusher).Push(target, options)
|
||||
}
|
||||
|
||||
type readFromWriter struct{ *trackingWriter }
|
||||
|
||||
func (w readFromWriter) ReadFrom(r io.Reader) (int64, error) {
|
||||
// A writer-only view makes every write observable, including partial failures,
|
||||
// while io.Copy retains bounded streaming memory and source WriterTo support.
|
||||
return io.Copy(struct{ io.Writer }{w.trackingWriter}, r)
|
||||
}
|
||||
|
||||
type closeNotifyWriter struct{ *trackingWriter }
|
||||
|
||||
func (w closeNotifyWriter) CloseNotify() <-chan bool {
|
||||
return w.ResponseWriter.(http.CloseNotifier).CloseNotify()
|
||||
}
|
||||
|
||||
func trackWriter(writer http.ResponseWriter, c *Context) http.ResponseWriter {
|
||||
w := &trackingWriter{ResponseWriter: writer, context: c}
|
||||
mask := 0
|
||||
if _, ok := writer.(http.Flusher); ok {
|
||||
mask |= 1
|
||||
} else if _, ok := writer.(interface{ FlushError() error }); ok {
|
||||
mask |= 1
|
||||
}
|
||||
if _, ok := writer.(http.Hijacker); ok {
|
||||
mask |= 2
|
||||
}
|
||||
if _, ok := writer.(http.Pusher); ok {
|
||||
mask |= 4
|
||||
}
|
||||
if _, ok := writer.(io.ReaderFrom); ok {
|
||||
mask |= 8
|
||||
}
|
||||
if _, ok := writer.(http.CloseNotifier); ok {
|
||||
mask |= 16
|
||||
}
|
||||
switch mask {
|
||||
case 1:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
}{w, flushWriter{w}}
|
||||
case 2:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.Hijacker
|
||||
}{w, hijackWriter{w}}
|
||||
case 3:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.Hijacker
|
||||
}{w, flushWriter{w}, hijackWriter{w}}
|
||||
case 4:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.Pusher
|
||||
}{w, pushWriter{w}}
|
||||
case 5:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.Pusher
|
||||
}{w, flushWriter{w}, pushWriter{w}}
|
||||
case 6:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.Hijacker
|
||||
http.Pusher
|
||||
}{w, hijackWriter{w}, pushWriter{w}}
|
||||
case 7:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.Hijacker
|
||||
http.Pusher
|
||||
}{w, flushWriter{w}, hijackWriter{w}, pushWriter{w}}
|
||||
case 8:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
io.ReaderFrom
|
||||
}{w, readFromWriter{w}}
|
||||
case 9:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
io.ReaderFrom
|
||||
}{w, flushWriter{w}, readFromWriter{w}}
|
||||
case 10:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.Hijacker
|
||||
io.ReaderFrom
|
||||
}{w, hijackWriter{w}, readFromWriter{w}}
|
||||
case 11:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.Hijacker
|
||||
io.ReaderFrom
|
||||
}{w, flushWriter{w}, hijackWriter{w}, readFromWriter{w}}
|
||||
case 12:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.Pusher
|
||||
io.ReaderFrom
|
||||
}{w, pushWriter{w}, readFromWriter{w}}
|
||||
case 13:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.Pusher
|
||||
io.ReaderFrom
|
||||
}{w, flushWriter{w}, pushWriter{w}, readFromWriter{w}}
|
||||
case 14:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.Hijacker
|
||||
http.Pusher
|
||||
io.ReaderFrom
|
||||
}{w, hijackWriter{w}, pushWriter{w}, readFromWriter{w}}
|
||||
case 15:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.Hijacker
|
||||
http.Pusher
|
||||
io.ReaderFrom
|
||||
}{w, flushWriter{w}, hijackWriter{w}, pushWriter{w}, readFromWriter{w}}
|
||||
case 16:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.CloseNotifier
|
||||
}{w, closeNotifyWriter{w}}
|
||||
case 17:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.CloseNotifier
|
||||
}{w, flushWriter{w}, closeNotifyWriter{w}}
|
||||
case 18:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.Hijacker
|
||||
http.CloseNotifier
|
||||
}{w, hijackWriter{w}, closeNotifyWriter{w}}
|
||||
case 19:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.Hijacker
|
||||
http.CloseNotifier
|
||||
}{w, flushWriter{w}, hijackWriter{w}, closeNotifyWriter{w}}
|
||||
case 20:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.Pusher
|
||||
http.CloseNotifier
|
||||
}{w, pushWriter{w}, closeNotifyWriter{w}}
|
||||
case 21:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.Pusher
|
||||
http.CloseNotifier
|
||||
}{w, flushWriter{w}, pushWriter{w}, closeNotifyWriter{w}}
|
||||
case 22:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.Hijacker
|
||||
http.Pusher
|
||||
http.CloseNotifier
|
||||
}{w, hijackWriter{w}, pushWriter{w}, closeNotifyWriter{w}}
|
||||
case 23:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.Hijacker
|
||||
http.Pusher
|
||||
http.CloseNotifier
|
||||
}{w, flushWriter{w}, hijackWriter{w}, pushWriter{w}, closeNotifyWriter{w}}
|
||||
case 24:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
io.ReaderFrom
|
||||
http.CloseNotifier
|
||||
}{w, readFromWriter{w}, closeNotifyWriter{w}}
|
||||
case 25:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
io.ReaderFrom
|
||||
http.CloseNotifier
|
||||
}{w, flushWriter{w}, readFromWriter{w}, closeNotifyWriter{w}}
|
||||
case 26:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.Hijacker
|
||||
io.ReaderFrom
|
||||
http.CloseNotifier
|
||||
}{w, hijackWriter{w}, readFromWriter{w}, closeNotifyWriter{w}}
|
||||
case 27:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.Hijacker
|
||||
io.ReaderFrom
|
||||
http.CloseNotifier
|
||||
}{w, flushWriter{w}, hijackWriter{w}, readFromWriter{w}, closeNotifyWriter{w}}
|
||||
case 28:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.Pusher
|
||||
io.ReaderFrom
|
||||
http.CloseNotifier
|
||||
}{w, pushWriter{w}, readFromWriter{w}, closeNotifyWriter{w}}
|
||||
case 29:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.Pusher
|
||||
io.ReaderFrom
|
||||
http.CloseNotifier
|
||||
}{w, flushWriter{w}, pushWriter{w}, readFromWriter{w}, closeNotifyWriter{w}}
|
||||
case 30:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
http.Hijacker
|
||||
http.Pusher
|
||||
io.ReaderFrom
|
||||
http.CloseNotifier
|
||||
}{w, hijackWriter{w}, pushWriter{w}, readFromWriter{w}, closeNotifyWriter{w}}
|
||||
case 31:
|
||||
return struct {
|
||||
*trackingWriter
|
||||
flushCapability
|
||||
http.Hijacker
|
||||
http.Pusher
|
||||
io.ReaderFrom
|
||||
http.CloseNotifier
|
||||
}{w, flushWriter{w}, hijackWriter{w}, pushWriter{w}, readFromWriter{w}, closeNotifyWriter{w}}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
type flushCapability interface {
|
||||
Flush()
|
||||
FlushError() error
|
||||
}
|
||||
@@ -30,7 +30,7 @@ if err != nil { return err }
|
||||
defer closeLog()
|
||||
```
|
||||
|
||||
也可用 `omni.WithLogConfig(...)` 交给应用运行生命周期管理。子日志共用输出、级别和关闭状态。独立 `logx.New` 返回的实例由创建者负责关闭。
|
||||
`omni.WithLogConfig(...)` 只保存日志配置,`omni.New()` 不会因此创建或关闭 Logger。手写入口需要像上例一样调用 `logx.Setup` 并负责关闭,或使用 `logx.New`、`logx.SetDefault` 和 `app.OnShutdown(logger.Close)` 显式装配。生成的 API 项目已在入口完成这些步骤。子日志共用输出、级别和关闭状态;独立 `logx.New` 返回的实例由创建者负责关闭。
|
||||
|
||||
轮转显式开启;三个数值为 0 时分别取 100 MiB、10 份、7 天。超过大小后轮转,旧文件按份数/年龄清理,可 gzip 压缩;保留天数不表示每天定时切割。压缩和历史清理由底层异步执行,不承诺 Close 等待压缩完成。轮转使用 [lumberjack v2](https://github.com/natefinch/lumberjack/tree/v2.2.1),**一个日志文件只能由一个进程拥有**。容器通常直接用 stdout/stderr,让运行平台管理日志文件。
|
||||
|
||||
|
||||
4
omni.go
4
omni.go
@@ -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
42
openapi/codeTypes_test.go
Normal 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
114
openapi/envelope.go
Normal 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}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
53
performance/apiDeveloperExperienceAcceptance.md
Normal file
53
performance/apiDeveloperExperienceAcceptance.md
Normal 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 云账号验收与网络层长时间压力测试不属于本轮。
|
||||
161
performance/apiDeveloperExperienceLocal.json
Normal file
161
performance/apiDeveloperExperienceLocal.json
Normal 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"
|
||||
}
|
||||
}
|
||||
161
performance/apiDeveloperExperienceRemote.json
Normal file
161
performance/apiDeveloperExperienceRemote.json
Normal 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"
|
||||
}
|
||||
}
|
||||
52
performance/apiDeveloperExperienceTests.txt
Normal file
52
performance/apiDeveloperExperienceTests.txt
Normal 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]
|
||||
@@ -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",
|
||||
|
||||
@@ -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 和各项检查结果。
|
||||
|
||||
所有验收已结束,临时服务、数据库容器和测试目录由脚本清理。
|
||||
|
||||
152
performance/apiWorkflowRemote.json
Normal file
152
performance/apiWorkflowRemote.json
Normal 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"
|
||||
}
|
||||
}
|
||||
24
performance/compatibilityAcceptance.md
Normal file
24
performance/compatibilityAcceptance.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# v0.9.0-preview 迁移扩展验收
|
||||
|
||||
本轮只使用本机临时服务与生成项目;没有运行业务服务器压测,没有修改 ePay/eTax 业务代码。
|
||||
|
||||
## 本地验收
|
||||
|
||||
- `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...`:40 个有测试的包通过;包含真实生成消费者、标准 Handler/Middleware、动态 JSON、路由限额和 scalar。
|
||||
- `go vet ./...`、`go build ./...`、gofmt、`git diff --check` 通过。
|
||||
- 独立 `examples/userService` module 的 race/vet/build 通过;生成文件 `--check` 通过,手写业务保持原样。
|
||||
- nethttp/native 本机真实 TCP 测试:首块数据立即 Flush、2 MiB 文件保持原响应、已知长度和 chunked 超限返回 413,并能继续处理正常请求。
|
||||
- 404 访问日志、提交后错误/panic 不追加 JSON、标准中间件提前写入/跨请求状态/取消/PathValue 通过。
|
||||
- multipart 落盘临时文件在成功和 panic 后清理;原始请求体 HMAC 验签、定制绑定和生成代码保护通过。
|
||||
- scalar 最大 int64、数字/字符串兼容、溢出/错误类型拒绝、指针 null、query 集合、输入/输出 schema 分离通过;普通字段仍严格。
|
||||
- ePay 原始契约只读校验:26 types、43 routes、0 errors、0 auth schemes。后两项是声明数量,不代表无需鉴权或没有业务错误;不代表 144 条注册路由已全部迁移。
|
||||
|
||||
环境:`go version go1.27.1 darwin/arm64`。记录时间:2026-09-23T17:47:26+00:00。
|
||||
|
||||
## 远程验收
|
||||
|
||||
发布后执行 `scripts/verifyCompatibilityRelease.py --version v0.9.0-preview`,结果生成到 `performance/compatibilityRemote.json`,校验从全新 module cache 安装的 CLI 与消费者固定依赖、无本地 replace、重生成保护和运行时行为。该结果在 main 单独记录,发布标签保持不可变。
|
||||
|
||||
## 范围
|
||||
|
||||
本次不是完整 ePay 迁移验收;资金事务、原权限体系、业务响应细节仍需业务自己的回归。未重跑 Linux soak、Nginx/Cloudflare 代理或云存储账号实网验收。
|
||||
11
performance/responseCodeTypesTests.txt
Normal file
11
performance/responseCodeTypesTests.txt
Normal 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
|
||||
123
performance/responsePoliciesAPI.json
Normal file
123
performance/responsePoliciesAPI.json
Normal 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"
|
||||
}
|
||||
}
|
||||
40
performance/responsePoliciesAcceptance.md
Normal file
40
performance/responsePoliciesAcceptance.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# v0.8.0-preview 公共响应模板验收
|
||||
|
||||
已发布 `v0.8.0-preview`,固定标签提交为 `7670b222acbad18d1fd19d54bbbac38d9cf5030b`。发布后只在 main 更新验收工具与记录,没有移动标签。
|
||||
|
||||
## 完成范围
|
||||
|
||||
- `.api` 具名公共体支持导入、分组引用、单接口覆盖、嵌套 JSON、独立 errorBody、常量与类型保持的占位符。业务返回类型放入 `${data}`。
|
||||
- `int32` / `int64` / `string` 业务码、默认成功/失败码与提示、按逻辑码映射和 null/省略行为可配置;OpenAPI 同步结构、整数位宽和错误示例,旧 integer 写法保持兼容。
|
||||
- `@response raw` 成功返回原始业务 JSON,错误继承组级模板。文本/XML及特殊状态回调使用 Go 编码器,由应用维护对应文档。
|
||||
- 配置在创建与快照时复制,应用间独立;空模板往返序列化有效,非法占位符、冲突、越界和过深模板拒绝。
|
||||
- 接口策略在全局中间件前生效,提前拒绝、panic、Context 克隆与错误边界使用正确格式;已提交响应不追加内容。
|
||||
- 内置 App 的 404/405 使用应用策略;TLS/HTTP解析与连接级过载发生在进入应用之前,仍由 transport 处理。
|
||||
|
||||
## 发布前验证
|
||||
|
||||
- 最终代码 `GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=1 -count=1 -timeout=300s ./...`:40 个有测试的包全部通过,见 [封版测试](responseReleaseTests.txt)。
|
||||
- `go vet ./...`、`go build ./...`、格式/差异检查和 userService `api generate --check` 通过。
|
||||
- 临时生成项目执行真实路由和业务 Service,验证数字 401、raw JSON、路由覆盖、完整 int64 输出、OpenAPI 和重复生成保护;nethttp/native 真实 TCP 检查配置响应、callback panic 和超时。
|
||||
- 本机隔离 MySQL 下,nethttp/native 共 34 项 HTTP/业务/生命周期检查通过;包含事务提交、错误/panic/取消回滚、初始化清理、迁移、日志与优雅退出,见 [本地业务报告](responsePoliciesWorkflowLocal.json)。
|
||||
- 开发阶段额外完成 54 项 API 实际 HTTP 回归与类型专项检查,见 [API 回归](responsePoliciesAPI.json)、[类型专项](responseCodeTypesTests.txt)。这些历史报告分别记录当时的源码哈希。
|
||||
- 开发阶段原有 S3 fixture 曾出现一次 10 秒 deadline,单包及后续全量复跑通过;本次封版全量也通过,未修改 S3 逻辑或放宽断言。
|
||||
|
||||
## 远程发布验证
|
||||
|
||||
两个验收分别使用全新的 module cache 安装 `v0.8.0-preview`,实际消费者都无本地 replace,模块校验和一致:
|
||||
|
||||
`h1:lB4rU4FPsoFr3IedKx/K9xvvsGBO5SO/qdjnjKJ2mvQ=`
|
||||
|
||||
1. [公共响应远程报告](responsePoliciesRemote.json):实际下载 CLI,解析 int32 示例,生成并运行公共模板消费者;完整 int64 最大值、数字鉴权错误、raw JSON、路由覆盖、业务文件保护、race 检查和固定版本依赖校验全部通过。
|
||||
2. [完整业务远程报告](responsePoliciesWorkflowRemote.json):远程 CLI 新建 MySQL + local 项目并编译;用户示例在 nethttp/native 共 34 项 HTTP/业务/生命周期检查通过,事务/初始化清理测试同时通过,重生成保留 29 份开发者文件。
|
||||
|
||||
首次公共响应远程验收从框架源码目录启动 CLI,触发原有本地开发检测并加入 replace;验收正确拒绝了这种结果。main 的测试工具已改为从独立临时目录启动,随后上述纯远程校验通过。此修复只涉及验收测试的工作目录,不改变已发布 CLI 或框架;复现远程验收请使用 main 的验收脚本和测试。
|
||||
|
||||
报告中保留各自实际测试源码哈希,已逐项核对;通过 module version、checksum 和无 Replace 三项确认消费者使用的是固定发布模块。
|
||||
|
||||
## 资源与使用
|
||||
|
||||
只使用本机回环监听、临时目录和受限 MySQL 容器(1 CPU / 768 MiB),结束均清理,没有连接业务服务器。未执行 OSS/R2 云账号实网验收或网络压力长测。
|
||||
|
||||
[发布说明](../RELEASE.md)、[公共响应说明](../RESPONSE.md) 与 [完整 .api 示例](../examples/contracts/responses.api)。CLI 与框架依赖一起固定到 `v0.8.0-preview`;源码开发可使用 `--framework-dir`。
|
||||
25
performance/responsePoliciesRemote.json
Normal file
25
performance/responsePoliciesRemote.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"passed": true,
|
||||
"frameworkVersion": "v0.8.0-preview",
|
||||
"frameworkSum": "h1:lB4rU4FPsoFr3IedKx/K9xvvsGBO5SO/qdjnjKJ2mvQ=",
|
||||
"freshModuleCache": true,
|
||||
"noLocalReplace": true,
|
||||
"durationSeconds": 13.23,
|
||||
"checks": [
|
||||
"remoteCLIInstall",
|
||||
"int32ContractValidation",
|
||||
"templateProjectGeneration",
|
||||
"int64MaxValueOnWire",
|
||||
"numericAuthErrors",
|
||||
"rawJSON",
|
||||
"routeOverride",
|
||||
"handwrittenFilesPreserved",
|
||||
"generatedConsumerRaceTests",
|
||||
"fixedRemoteModuleChecksum"
|
||||
],
|
||||
"sourceHashes": {
|
||||
"scripts/verifyResponseRelease.py": "0cd47ba265ee6a9f545e1be321742aa5807dd5040522653011ff91653fc89ac1",
|
||||
"cmd/go-omni/apigen/response_test.go": "bdaea21a94297607172ab173f20640aa5c27e2d7e417376e9fe695d4c02ba524",
|
||||
"examples/contracts/responses.api": "d0fcc8d417a2c00bbea4e8d586379b5fd2443518817960f271804b3813e96eb1"
|
||||
}
|
||||
}
|
||||
55
performance/responsePoliciesTests.txt
Normal file
55
performance/responsePoliciesTests.txt
Normal 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]
|
||||
164
performance/responsePoliciesWorkflowLocal.json
Normal file
164
performance/responsePoliciesWorkflowLocal.json
Normal 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"
|
||||
}
|
||||
}
|
||||
164
performance/responsePoliciesWorkflowRemote.json
Normal file
164
performance/responsePoliciesWorkflowRemote.json
Normal file
@@ -0,0 +1,164 @@
|
||||
{
|
||||
"passed": true,
|
||||
"frameworkVersion": "v0.8.0-preview",
|
||||
"noLocalReplace": true,
|
||||
"durationSeconds": 28.28,
|
||||
"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:lB4rU4FPsoFr3IedKx/K9xvvsGBO5SO/qdjnjKJ2mvQ=",
|
||||
"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": "bdaea21a94297607172ab173f20640aa5c27e2d7e417376e9fe695d4c02ba524",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
55
performance/responseReleaseTests.txt
Normal file
55
performance/responseReleaseTests.txt
Normal 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
123
response/codeTypes_test.go
Normal 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
257
response/config.go
Normal 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
75
response/config_test.go
Normal 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
60
response/encoder.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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
109
response/template.go
Normal 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
72
response/template_test.go
Normal 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, ©); 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
215
response_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
80
scripts/verifyCompatibilityRelease.py
Normal file
80
scripts/verifyCompatibilityRelease.py
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install the released CLI in a fresh cache, then test its generated consumer.
|
||||
|
||||
The fixture tests custom scalars, dynamic JSON, uploads, raw callback signatures,
|
||||
downloads, body limits 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/compatibilityRemote.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="goOmniCompatibilityRelease-") 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/compatibility.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, scalar binding, uploads and callback signatures", flush=True)
|
||||
output = run(["go", "test", "-race", "-count=1", "-json", "-timeout=180s",
|
||||
"./cmd/go-omni/apigen", "-run", "^TestCompatibilityConsumer$"], 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 == ["TestCompatibilityConsumer"], 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", "scalarContractValidation", "manualProjectGeneration",
|
||||
"int64MaxValueOnWire", "dynamicJSON", "rawBodySignature", "routeBodyLimits",
|
||||
"multipartTempCleanup", "unwrappedDownloads",
|
||||
"handwrittenFilesPreserved", "generatedConsumerRaceTests", "fixedRemoteModuleChecksum"],
|
||||
"sourceHashes": {name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in
|
||||
("scripts/verifyCompatibilityRelease.py", "cmd/go-omni/apigen/compatibility_test.go",
|
||||
"examples/contracts/compatibility.api",
|
||||
"cmd/go-omni/apigen/testdata/compatibility/consumer_test.go",
|
||||
"cmd/go-omni/apigen/testdata/compatibility/hooks.go",
|
||||
"cmd/go-omni/apigen/testdata/compatibility/echo.go",
|
||||
"cmd/go-omni/apigen/testdata/compatibility/download.go")},
|
||||
}
|
||||
args.report.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n")
|
||||
print(f"PASS remote compatibility contract {args.version}; temporary files removed", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
75
scripts/verifyResponseRelease.py
Normal file
75
scripts/verifyResponseRelease.py
Normal 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()
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user