feat: declare API errors and authentication with developer tooling

This commit is contained in:
2026-09-23 18:17:43 +08:00
parent ab8eac5fc4
commit 2913e94a0a
35 changed files with 2246 additions and 107 deletions

112
API.md
View File

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

View File

@@ -1,12 +1,21 @@
# Changelog # Changelog
## 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 ## v0.7.0-preview
- 新增 `.api` 契约驱动生成器:DTO、严格多来源绑定与校验、路由、分组 Handler / Service / Repository、命名中间件及 OpenAPI;生成文件哈希管理、业务文件保护和只读 `--check`。 - 新增 `.api` 契约驱动生成器:DTO、严格多来源绑定与校验、路由、分组 Handler / Service / Repository、命名中间件及 OpenAPI;生成文件哈希管理、业务文件保护和只读 `--check`。
- `new --api` / `api new` / `api generate` 可以组合 MySQL、日志以及 Local / S3 / R2 / OSS。保存初始基础设施选项,重生成自动沿用;显式切换已有项目选项会拒绝,避免静默丢失初始化代码。 - `new --api` / `api new` / `api generate` 可以组合 MySQL、日志以及 Local / S3 / R2 / OSS。保存初始基础设施选项,重生成自动沿用;显式切换已有项目选项会拒绝,避免静默丢失初始化代码。
- 新增 `examples/userService` 独立业务示例与 `verifyAPIWorkflow.py`:用户 CRUD / 分页、管理员 Token 鉴权、MySQL 事务审计、故障回滚、文件导出、requestId 日志、数据库 readiness 和优雅退出;支持从远程版本验收。 - 新增 `examples/userService` 独立业务示例与 `verifyAPIWorkflow.py`:用户 CRUD / 分页、管理员 Token 鉴权、MySQL 事务审计、故障回滚、文件导出、requestId 日志、数据库 readiness 和优雅退出;支持从远程版本验收。
- 日志新增文件轮转、结构化敏感字段脱敏、异常堆栈及慢请求配置;存储新增统一上传策略、直传签名和云端分片能力。 - 日志新增文件轮转、结构化敏感字段脱敏、异常堆栈及慢请求配置;存储新增统一上传策略、直传签名和云端分片能力。
- 新增缓存、HTTP 客户端、限流 / 幂等 / 超时、后台任务、Redis 队列、事务 outbox、分布式锁、审计、迁移与 telemetry 等可选模块;业务身份与 Orbit 保持由应用自行组合。 - 新增缓存、HTTP 客户端、限流 / 幂等 / 超时、后台任务、Redis 队列、审计、迁移与 telemetry 等可选模块;业务身份与 Orbit 保持由应用自行组合。
- `database.ApplyEnv` 支持文件/默认配置叠加环境变量;数据库配置使用 lowerCamelCase,DSN 不参与 JSON/YAML 序列化。 - `database.ApplyEnv` 支持文件/默认配置叠加环境变量;数据库配置使用 lowerCamelCase,DSN 不参与 JSON/YAML 序列化。
- 修复 native 在业务层提前拒绝请求体为 413 时等待继续读取 body 的问题,拒绝后关闭未读连接。 - 修复 native 在业务层提前拒绝请求体为 413 时等待继续读取 body 的问题,拒绝后关闭未读连接。

View File

@@ -5,7 +5,7 @@ GoOmni 是一套面向业务复用的 Go 后端基础框架。当前默认使用
远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本: 远程 module 地址是 `git.yuntuo.hk/k2safe/go-omni`。业务项目可以直接引用指定版本:
```sh ```sh
go get git.yuntuo.hk/k2safe/go-omni@v0.7.0-preview go get git.yuntuo.hk/k2safe/go-omni@v0.7.0-preview.1
``` ```
`v0.4.0` 移除了 Gin 和 Gin 专属的 `WithMode` 配置;`omni.New()` 直接使用标准库路由,已有业务 Handler / Service 无需修改。 `v0.4.0` 移除了 Gin 和 Gin 专属的 `WithMode` 配置;`omni.New()` 直接使用标准库路由,已有业务 Handler / Service 无需修改。
@@ -13,7 +13,7 @@ go get git.yuntuo.hk/k2safe/go-omni@v0.7.0-preview
创建新业务项目: 创建新业务项目:
```sh ```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.7.0-preview go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.7.0-preview.1
go-omni new --module example.com/billing ./billing go-omni new --module example.com/billing ./billing
cd billing cd billing
go mod tidy go mod tidy
@@ -309,6 +309,6 @@ go run ./examples/reference
## 当前边界 ## 当前边界
缓存、消息队列和定时任务暂时作为后续可选模块。认证层只定义 `Authenticator`、`Principal` 和路由保护中间件,具体 JWT、Session 或 OAuth 由项目实现。核心数据库层只依赖 `database/sql`,具体驱动、迁移和表结构由项目选择。 缓存、Redis 消息队列和进程内定时任务已作为可选模块提供。认证层提供 `Authenticator`、JWT/Session 适配器和 Policy 接口,登录流程与业务授权规则由项目实现。核心数据库层只依赖 `database/sql`,具体驱动、迁移和表结构由项目选择。
当前成熟度与后续验收标准见 [ROADMAP.md](ROADMAP.md),版本变更见 [CHANGELOG.md](CHANGELOG.md)。 当前成熟度与后续验收标准见 [ROADMAP.md](ROADMAP.md),版本变更见 [CHANGELOG.md](CHANGELOG.md)。

View File

@@ -1,12 +1,12 @@
# v0.7.0-preview # v0.7.0-preview.1
这一版把 API 定义、项目生成、基础模块初始化和完整业务示例接通。 这一版完善接口开发体验:业务错误和鉴权可在 .api 中声明,代码调用与 OpenAPI 同步;增加路由总览、文档导出和无写入的生成预览。
预览版用于固定版本试用;API 方言有明确范围,HTTP/2 仍是独立实验适配器。 预览版用于固定版本试用;API 方言有明确范围,HTTP/2 仍是独立实验适配器。
## 安装与开始 ## 安装与开始
```sh ```sh
GOPRIVATE=git.yuntuo.hk go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.7.0-preview GOPRIVATE=git.yuntuo.hk go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.7.0-preview.1
go-omni api new --module example.com/demo ./demo go-omni api new --module example.com/demo ./demo
cd demo cd demo
@@ -29,6 +29,13 @@ MySQL 与存储需要按生成 README 设置环境变量。任意契约生成的
## 主要变化 ## 主要变化
- `error (...)` + `@errors` 声明业务错误,生成 `apierrors.EmailTaken()` 等全新实例构造函数和错误码常量。
- `auth Name (...)` + `@auth` 同步 OpenAPI security 与实际路由中间件,声明本身不替代凭证验证。
- 多行 `@doc` 支持 description/tags/deprecated;一个状态可有多个业务错误示例。
- 项目内直接 `api inspect`、`api export`、`api generate --dry-run`,无需重复指定入口文件。
此前 v0.7.0-preview 已提供:
- `.api` 生成项目、绑定/校验、路由、业务骨架和 OpenAPI;支持分组、导入、命名中间件。 - `.api` 生成项目、绑定/校验、路由、业务骨架和 OpenAPI;支持分组、导入、命名中间件。
- 同一入口初始化 MySQL、日志、Local / S3 / R2 / OSS;标准 context 与 httpx 保持业务/网络分离。 - 同一入口初始化 MySQL、日志、Local / S3 / R2 / OSS;标准 context 与 httpx 保持业务/网络分离。
- 生成器保护手写业务文件,校验自有文件哈希,支持只读 --check;基础设施选项持久化到 manifest。 - 生成器保护手写业务文件,校验自有文件哈希,支持只读 --check;基础设施选项持久化到 manifest。
@@ -43,7 +50,8 @@ MySQL 与存储需要按生成 README 设置环境变量。任意契约生成的
2. Gen.go 由生成器维护,业务实现/配置/迁移由项目维护。新增 API 后执行 generate 和 go test;修改或删除类型需要同步处理自己的业务代码。 2. Gen.go 由生成器维护,业务实现/配置/迁移由项目维护。新增 API 后执行 generate 和 go test;修改或删除类型需要同步处理自己的业务代码。
3. `.goOmni/apiManifest.json` 需随代码提交。切换数据库或存储选项时,在新目录生成对照后迁移;重新生成不会改写现有基础设施文件。 3. `.goOmni/apiManifest.json` 需随代码提交。切换数据库或存储选项时,在新目录生成对照后迁移;重新生成不会改写现有基础设施文件。
4. 配置使用 lowerCamelCase。DSN、Token、存储密钥由环境变量提供;`database.Config` 的 DSN 不会被 JSON/YAML 序列化。 4. 配置使用 lowerCamelCase。DSN、Token、存储密钥由环境变量提供;`database.Config` 的 DSN 不会被 JSON/YAML 序列化。
5. API 的 none/memory 数据库选项仅表示不打开连接,不会从 HTTP DTO 自动推导内存仓库或数据表。 5. 旧 `.api` 仍可解析。新语法需要本版 CLI;生成的错误函数沿用已有框架错误类型。旧 CLI 无法解析新增声明,请一起升级。鉴权引用的已有 hook 和业务函数仍保留,不会自动重写。
6. API 的 none/memory 数据库选项仅表示不打开连接,不会从 HTTP DTO 自动推导内存仓库或数据表。
## 验证入口 ## 验证入口
@@ -52,12 +60,12 @@ GO_OMNI_SCAFFOLD_INTEGRATION=1 go test -race -p=2 -count=1 -timeout=300s ./...
go vet ./... go vet ./...
go build ./... go build ./...
python3 scripts/verifyAPIWorkflow.py python3 scripts/verifyAPIWorkflow.py
python3 scripts/verifyAPIWorkflow.py --version v0.7.0-preview \ python3 scripts/verifyAPIWorkflow.py --version v0.7.0-preview.1 \
--report performance/apiWorkflowRemote.json --report performance/apiDeveloperExperienceRemote.json
``` ```
最后一项在全新 module cache 安装远程 CLI,并验证无本地 replace 的项目生成、 最后一项在全新 module cache 安装远程 CLI,并验证无本地 replace 的项目生成、
真实 MySQL、nethttp/native 启动与业务流程。容器只使用本机临时资源,结束时清理。 真实 MySQL、nethttp/native 启动与业务流程。容器只使用本机临时资源,结束时清理。
本轮验收见 [API 业务闭环报告](performance/apiWorkflowAcceptance.md)。 本轮验收见 [接口开发体验报告](performance/apiDeveloperExperienceAcceptance.md);此前业务闭环见 [API 业务验收](performance/apiWorkflowAcceptance.md)。
OSS/R2 云端仍需项目自己的账号做上线环境验收;当前云端适配器检查不等同于这些账号的实网测试。 OSS/R2 云端仍需项目自己的账号做上线环境验收;当前云端适配器检查不等同于这些账号的实网测试。

View File

@@ -88,4 +88,18 @@
- 已完成本地模块测试及 race 检查,Linux 全仓库 race/vet/build、生成项目 CRUD、三个 Runner 的 TLS 示例和独立 Redis/MinIO/MySQL 均通过。共享状态、重复提交、失败消息、未确认消息接管、旧确认拒绝、对象签名和迁移 dirty 阻断有对应证据,见 [扩展模块验收](performance/advancedAcceptance.md)。OSS/R2 尚无云账号实网验收。 - 已完成本地模块测试及 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。 - 契约、使用示例与限制见 [扩展模块](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、适配层契约和一次业务模块试用已完成,自研网络层已有可运行预览,稳定性与生产规模验证尚未完成。 以上阶段以验收结果判断完成。当前仍是基础框架试用阶段;MySQL、适配层契约和一次业务模块试用已完成,自研网络层已有可运行预览,稳定性与生产规模验证尚未完成。

View File

@@ -17,7 +17,7 @@ go run ./cmd/go-omni api validate --api examples/contracts/user.api
`new --api <file>` 是契约项目的另一个入口。可以组合 `--database mysql --storage local --log-output file`,生成配置、依赖初始化与资源关闭。API 默认无数据库、无对象存储;`--database memory` 等同于不打开连接。`--force` 不允许覆盖 API 项目。初始配置选择写入 manifest,重新生成自动沿用。 `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 .`。 后续在生成项目内执行 `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 保留网络层替换能力。 支持范围、参数语义、目录及安全更新策略见 [API 开发指南](../../API.md)。本版借鉴 go-zero 的工作流,不宣称兼容全部 goctl 语法;仍通过 Omni/httpx 保留网络层替换能力。
@@ -73,7 +73,7 @@ Handler / Service / Repository / Model 与模块注册入口。模块名为 lowe
从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架: 从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
```sh ```sh
go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.7.0-preview go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.7.0-preview.1
go-omni new --module example.com/billing ./billing go-omni new --module example.com/billing ./billing
cd billing cd billing
go mod tidy go mod tidy

View File

@@ -1,9 +1,10 @@
package main package main
import ( import (
"errors"
"fmt" "fmt"
"os"
"strings" "strings"
"text/tabwriter"
"git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen" "git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen"
) )
@@ -20,9 +21,10 @@ func runAPI(args []string) error {
if command == "check" { if command == "check" {
command = "validate" 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) return fmt.Errorf("unknown api command %q", command)
} }
readOnly := command == "validate" || command == "inspect" || command == "export"
o := apigen.Options{} o := apigen.Options{}
for i := 1; i < len(args); i++ { for i := 1; i < len(args); i++ {
arg := args[i] arg := args[i]
@@ -30,11 +32,15 @@ func runAPI(args []string) error {
usage() usage()
return nil return nil
} }
if arg == "--check" { if arg == "--check" || arg == "--dry-run" {
if command != "generate" { 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 continue
} }
if !strings.HasPrefix(arg, "-") { if !strings.HasPrefix(arg, "-") {
@@ -67,8 +73,8 @@ func runAPI(args []string) error {
if strings.TrimSpace(value) == "" { if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s requires a value", name) return fmt.Errorf("%s requires a value", name)
} }
if command == "validate" && name != "--api" { if readOnly && name != "--api" {
return fmt.Errorf("api validate does not accept %s", name) return fmt.Errorf("api %s does not accept %s", command, name)
} }
switch name { switch name {
case "--api": case "--api":
@@ -91,18 +97,52 @@ func runAPI(args []string) error {
o.LogFormat = value o.LogFormat = value
} }
} }
if command == "validate" { if command != "new" && o.API == "" {
if o.API == "" { dir := o.Dir
return errors.New("--api is required") 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) spec, err := apigen.Parse(o.API)
if err != nil { if err != nil {
return err return err
} }
if _, err = apigen.Compile(spec); err != nil { compiled, err := apigen.Compile(spec)
if err != nil {
return err 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\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))
}
fmt.Fprintf(writer, "%s\t%s\t%s/%s\t%s\t%s\n", strings.ToUpper(r.Method), r.Path, r.Group, r.Handler, auth, strings.Join(errs, ", "))
}
return writer.Flush()
}
return nil return nil
} }
var out apigen.Output var out apigen.Output
@@ -118,7 +158,12 @@ func runAPI(args []string) error {
if err != nil { if err != nil {
return err 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") fmt.Println("API generated files are current")
} else { } 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)) fmt.Printf("API project ready: %d generated, %d created, %d preserved, %d removed\n", len(out.Generated), len(out.Created), len(out.Preserved), len(out.Removed))

View File

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

View File

@@ -135,7 +135,7 @@ func TestSharedPrefixConflict(t *testing.T) {
} }
func FuzzParser(f *testing.F) { 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.Add(s)
} }
f.Fuzz(func(t *testing.T, source string) { f.Fuzz(func(t *testing.T, source string) {

View File

@@ -35,6 +35,11 @@ func Compile(spec *Spec) (*Compiled, error) {
if spec == nil || len(spec.Routes) == 0 || !serviceName.MatchString(spec.Service) { if spec == nil || len(spec.Routes) == 0 || !serviceName.MatchString(spec.Service) {
return nil, errors.New("api: valid service and routes required") 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{} definitions := map[string]Type{}
for _, t := range spec.Types { for _, t := range spec.Types {
if !exportedName.MatchString(t.Name) { if !exportedName.MatchString(t.Name) {
@@ -260,25 +265,15 @@ func Compile(spec *Spec) (*Compiled, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Show named hooks in documentation without claiming they implement a // Decorate the document with explicit auth/error contracts. Credential
// particular authentication scheme. Applications own that policy. // validation remains in the linked application middleware.
var doc map[string]any var doc map[string]any
decoder := json.NewDecoder(bytes.NewReader(c.Document)) decoder := json.NewDecoder(bytes.NewReader(c.Document))
decoder.UseNumber() decoder.UseNumber()
if err = decoder.Decode(&doc); err != nil { if err = decoder.Decode(&doc); err != nil {
return nil, err return nil, err
} }
for _, r := range spec.Routes { decorateDocument(doc, spec)
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
}
}
c.Document, err = json.MarshalIndent(doc, "", " ") c.Document, err = json.MarshalIndent(doc, "", " ")
return c, err return c, err
} }

View File

@@ -0,0 +1,197 @@
package apigen
import (
"fmt"
"net/http"
"regexp"
"sort"
"strings"
)
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...)
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 {
parts := strings.Split(r.Path, "/")
for i, part := range parts {
if strings.HasPrefix(part, ":") {
parts[i] = "{" + part[1:] + "}"
}
}
op := doc["paths"].(map[string]any)[strings.Join(parts, "/")].(map[string]any)[r.Method].(map[string]any)
if 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")
}
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))
responses[key] = response
}
media := response["content"].(map[string]any)["application/json"].(map[string]any)
examples := map[string]any{}
codes := []any{}
description := []string{}
sort.Slice(errs, func(i, j int) bool { return errs[i].Name < errs[j].Name })
for _, e := range errs {
examples[e.Name] = map[string]any{"summary": e.Message, "value": map[string]any{"code": e.Code, "message": e.Message, "requestId": "exampleRequest"}}
codes = append(codes, e.Code)
description = append(description, e.Code+": "+e.Message)
}
media["examples"] = examples
if !exists {
media["schema"].(map[string]any)["properties"].(map[string]any)["code"].(map[string]any)["enum"] = codes
}
response["description"] = strings.Join(description, "\n")
}
}
}
func genericErrorResponse(description string) map[string]any {
if description == "" {
description = "Error"
}
return map[string]any{"description": description, "content": map[string]any{"application/json": map[string]any{"schema": map[string]any{
"type": "object", "required": []string{"code", "message"}, "properties": map[string]any{"code": map[string]any{"type": "string"}, "message": map[string]any{"type": "string"}, "requestId": map[string]any{"type": "string"}},
}}}}
}

View File

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

View File

@@ -25,12 +25,23 @@ type Options struct {
Database, Storage string Database, Storage string
API, Dir, Module, FrameworkDir, FrameworkVersion, LogOutput, LogFormat string API, Dir, Module, FrameworkDir, FrameworkVersion, LogOutput, LogFormat string
Check bool 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 // Generate creates or safely updates one contract-driven project. It renders
// and validates every output before applying an ownership-checked transaction. // and validates every output before applying an ownership-checked transaction.
func Generate(o Options) (Output, error) { 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 == "" { if o.API == "" || o.Dir == "" {
return Output{}, errors.New("--api and --dir are required") return Output{}, errors.New("--api and --dir are required")
} }
@@ -49,8 +60,8 @@ const starterAPI = "syntax = \"v1\"\n\ninfo (\n title: \"Greeting API\"\n versio
// New creates a runnable greeting project, including its editable API file. // New creates a runnable greeting project, including its editable API file.
func New(o Options) (Output, error) { func New(o Options) (Output, error) {
if o.API != "" || o.Check { if o.API != "" || o.Check || o.DryRun {
return Output{}, errors.New("api new does not accept --api or --check") return Output{}, errors.New("api new does not accept --api, --check or --dry-run")
} }
if o.Dir == "" { if o.Dir == "" {
return Output{}, errors.New("project directory required") return Output{}, errors.New("project directory required")
@@ -204,7 +215,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) { func moduleFile(o Options) ([]byte, error) {
@@ -289,6 +300,13 @@ func canonical(path string) string {
func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map[string][]byte, error) { func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map[string][]byte, error) {
generated, seeds := map[string][]byte{}, map[string][]byte{} generated, seeds := map[string][]byte{}, map[string][]byte{}
put := func(files map[string][]byte, name, content string) { files[name] = []byte(content) } 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" types := "package types\n\n"
for _, t := range c.Spec.Types { for _, t := range c.Spec.Types {
types += "type " + t.Name + " struct {\n" types += "type " + t.Name + " struct {\n"

View File

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

View File

@@ -14,11 +14,13 @@ import (
) )
type Spec struct { type Spec struct {
Title, Version, Service string Title, Version, Service, Description string
Types []Type Errors []ErrorDefinition
Routes []Route Auth []AuthDefinition
Sources map[string][]byte // relative to the entry API's directory Types []Type
Entry string Routes []Route
Sources map[string][]byte // relative to the entry API's directory
Entry string
} }
type Type struct { type Type struct {
Name string Name string
@@ -29,6 +31,11 @@ type Field struct{ Name, Type, Tag string }
type Route struct { type Route struct {
Method, Path, Request, Response, Handler, Group, Summary string Method, Path, Request, Response, Handler, Group, Summary string
Middleware []string Middleware []string
Auth string
Errors []string
Description string
Tags []string
Deprecated bool
Status int Status int
Position string Position string
} }
@@ -208,13 +215,20 @@ func (p *parser) parse() error {
if err != nil { if err != nil {
return err 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 { for k, v := range m {
switch k { switch k {
case "title": case "title":
p.spec.Title = v p.spec.Title = v
case "version": case "version":
p.spec.Version = v p.spec.Version = v
case "desc", "description", "author", "email": case "desc", "description":
p.spec.Description = v
case "author", "email":
default: default:
return p.err("unsupported info property " + k) return p.err("unsupported info property " + k)
} }
@@ -238,6 +252,14 @@ func (p *parser) parse() error {
} }
p.imports = append(p.imports, v) 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 "type": case "type":
p.next() p.next()
if p.peek() == "(" { if p.peek() == "(" {
@@ -267,7 +289,7 @@ func (p *parser) parse() error {
} }
server = m server = m
for k := range m { for k := range m {
if k != "prefix" && k != "group" && k != "middleware" { if k != "prefix" && k != "group" && k != "middleware" && k != "auth" && k != "errors" {
return p.err("unsupported @server option " + k + "; wire authentication through named middleware") return p.err("unsupported @server option " + k + "; wire authentication through named middleware")
} }
} }
@@ -379,7 +401,7 @@ func (p *parser) parseService(server map[string]string) error {
return err return err
} }
for p.skip(); p.peek() != "}"; p.skip() { for p.skip(); p.peek() != "}"; p.skip() {
route := Route{Group: server["group"], Status: 200, Position: p.tokens[p.at].position} route := Route{Auth: server["auth"], Errors: commaList(server["errors"]), Group: server["group"], Status: 200, Position: p.tokens[p.at].position}
if route.Group == "" { if route.Group == "" {
route.Group = "api" route.Group = "api"
} }
@@ -401,7 +423,17 @@ func (p *parser) parseService(server map[string]string) error {
case "handler": case "handler":
route.Handler, e = p.ident() route.Handler, e = p.ident()
case "doc": case "doc":
route.Summary, e = p.quoted() e = p.parseDoc(&route)
case "auth":
route.Auth, e = p.ident()
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": case "status":
route.Status, e = strconv.Atoi(p.next().text) route.Status, e = strconv.Atoi(p.next().text)
default: default:

View File

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

View File

@@ -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: 2. Run the same go-omni CLI version as the framework dependency:
`+"```sh"+` `+"```sh"+`
go-omni api validate --api %s go-omni api validate
go-omni api generate --api %s --dir . go-omni api inspect
go-omni api generate --api %s --dir . --check go-omni api generate --dry-run
go-omni api generate
go-omni api generate --check
go-omni api export > openapi.json
go test ./... 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 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, generated files and missing business scaffolds. go.mod, configuration, main,
Service, Repository, servicecontext, middleware hook bodies and API files are 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/settings/ # defaults -> file -> environment
internal/servicecontext/ # dependency initialization and cleanup internal/servicecontext/ # dependency initialization and cleanup
internal/types/typesGen.go # generated request/response types 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/handler/<group>/ # generated binding and service adapters
internal/service/<group>/ # developer-owned business operations internal/service/<group>/ # developer-owned business operations
internal/repository/<group>/ # developer-owned persistence contracts internal/repository/<group>/ # developer-owned persistence contracts
@@ -123,6 +129,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 they are not validated on every response. Do not return nil slices/maps for a
non-nullable collection (initialize empty collections instead). 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 Named middleware is composed in declaration order (first is outermost). Implement
the generated hooks or replace entries in servicecontext; never bypass an auth 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. hook merely to make the server start. Middleware names do not imply a JWT policy.
@@ -131,7 +144,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 Database tables and repositories cannot be inferred from an HTTP contract. Add
business repositories through servicecontext. Database and storage options are business repositories through servicecontext. Database and storage options are
composed at creation; the original profile is retained on regeneration. composed at creation; the original profile is retained on regeneration.
`, module, entry, entry, entry, entry) `, module, entry)
} }
func infrastructureREADME(o Options) string { func infrastructureREADME(o Options) string {

View File

@@ -29,7 +29,7 @@ type change struct {
mode os.FileMode 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 var result Output
if info, err := os.Lstat(root); err == nil { if info, err := os.Lstat(root); err == nil {
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { 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 { if err := safePath(root, manifestPath); err != nil {
return result, err return result, err
} }
if !check { if !check && !dryRun {
if err := os.MkdirAll(filepath.Join(root, ".goOmni"), 0755); err != nil { if err := os.MkdirAll(filepath.Join(root, ".goOmni"), 0755); err != nil {
return result, err 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}) changes = append(changes, change{name: manifestPath, before: oldData, after: data, existed: oldData != nil, mode: 0644})
} }
sort.Strings(result.Removed) 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 check {
if len(changes) > 0 { if len(changes) > 0 {
var stale []string var stale []string
@@ -239,7 +252,7 @@ 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 digest(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) }
func ownedPath(name string) bool { 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/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 true
} }
return strings.HasPrefix(name, "internal/handler/") && strings.HasSuffix(name, "/handlersGen.go") return strings.HasPrefix(name, "internal/handler/") && strings.HasSuffix(name, "/handlersGen.go")

View File

@@ -173,7 +173,9 @@ func usage() {
fmt.Fprintln(os.Stderr, " go-omni new [--module <module>] [--force] <directory>") fmt.Fprintln(os.Stderr, " go-omni new [--module <module>] [--force] <directory>")
fmt.Fprintln(os.Stderr, " go-omni new --api <file.api> [--module <module>] <directory>") fmt.Fprintln(os.Stderr, " go-omni new --api <file.api> [--module <module>] <directory>")
fmt.Fprintln(os.Stderr, " go-omni api new [--module <module>] <directory> Create a runnable API project") 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 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, " 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)") fmt.Fprintln(os.Stderr, " --framework-version <version> Pin a remote framework version (defaults to CLI version)")

View File

@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
// Source builds can carry an unpublished VCS version (including +dirty). // Source builds can carry an unpublished VCS version (including +dirty).
// Keep this fallback in sync with the next release; installed CLIs use their // Keep this fallback in sync with the next release; installed CLIs use their
// downloaded module version, which also has a module checksum. // downloaded module version, which also has a module checksum.
const defaultFrameworkVersion = "v0.7.0-preview" const defaultFrameworkVersion = "v0.7.0-preview.1"
// This target is not a claim that the release already exists. Source users // This target is not a claim that the release already exists. Source users
// select a local framework checkout until the module templates are published. // select a local framework checkout until the module templates are published.

View File

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

View File

@@ -29,7 +29,7 @@ go run ./cmd/server
## 接口 ## 接口
除健康检查、OpenAPI 和持有效签名的对象下载外,业务接口需要 `Authorization: Bearer <Token>`。 除健康检查、OpenAPI 和持有效签名的对象下载外,业务接口需要 `Authorization: Bearer <Token>`。
这个 Token 代表示例的管理员;可通过同一个命名中间件槽位替换为 JWT/OIDC 和业务授权策略。 这个 Token 代表示例的管理员;可选设置不同的 `USER_SERVICE_READ_TOKEN`,允许 GET/HEAD,写请求返回 403。可通过同一个命名中间件槽位替换为 JWT/OIDC 和业务授权策略。
框架不会推断租户模型或自动给接口放行。 框架不会推断租户模型或自动给接口放行。
| 接口 | 行为 | | 接口 | 行为 |
@@ -43,8 +43,8 @@ go run ./cmd/server
| GET /health/ready | 数据库不可用时返回 503,恢复后重新就绪 | | GET /health/ready | 数据库不可用时返回 503,恢复后重新就绪 |
| GET /openapi.json | 与 docs/openapi.json 一致 | | GET /openapi.json | 与 docs/openapi.json 一致 |
参数不合法返回 400,认证失败 401,邮箱重复 409,内部故障只返回通用 500。 参数不合法返回 400,认证失败 401,只读身份写入 403,邮箱重复 409,内部故障只返回通用 500。
OpenAPI 的业务数据类型来自契约;401/404/409 的业务错误码见上表。 OpenAPI 的数据类型、鉴权和业务错误均来自契约;401/403/404/409 带具体命名示例。业务使用生成的 `apierrors.UserNotFound()`、`apierrors.EmailTaken()` 等函数。
创建、更新、删除都会在**同一个 MySQL 事务**写入 user_audit;审计写入失败时业务变更一起回滚。 创建、更新、删除都会在**同一个 MySQL 事务**写入 user_audit;审计写入失败时业务变更一起回滚。
事务提交后才写成功业务日志,requestId 同时出现在 HTTP 响应、业务日志和审计记录。 事务提交后才写成功业务日志,requestId 同时出现在 HTTP 响应、业务日志和审计记录。
@@ -57,8 +57,10 @@ OpenAPI 的业务数据类型来自契约;401/404/409 的业务错误码见上
## 开发与验证 ## 开发与验证
```sh ```sh
go-omni api generate --api api/user.api --dir . go-omni api inspect
go-omni api generate --api api/user.api --dir . --check go-omni api generate --dry-run
go-omni api generate
go-omni api generate --check
go test ./... go test ./...
``` ```
@@ -70,7 +72,7 @@ Gen.go 和 docs/openapi.json 由生成器维护;Service、Repository、迁移
```sh ```sh
python3 scripts/verifyAPIWorkflow.py python3 scripts/verifyAPIWorkflow.py
# 已发布版本:全新 module cache 安装 CLI,无本地 replace 消费框架。 # 已发布版本:全新 module cache 安装 CLI,无本地 replace 消费框架。
python3 scripts/verifyAPIWorkflow.py --version v0.7.0-preview \ python3 scripts/verifyAPIWorkflow.py --version v0.7.0-preview.1 \
--report performance/apiWorkflowRemote.json --report performance/apiWorkflowRemote.json
``` ```

View File

@@ -3,6 +3,20 @@ info (
title: "User service reference" title: "User service reference"
version: "1.0.0" 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 ( type (
UserResp { UserResp {
ID int64 `json:"id"` ID int64 `json:"id"`
@@ -40,21 +54,33 @@ type (
@server ( @server (
group: user group: user
prefix: /api/v1 prefix: /api/v1
middleware: Auth auth: AdminAuth
errors: Unauthenticated
) )
service user-api { 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 @handler createUser
@status 201 @status 201
post /users (CreateUserReq) returns (UserResp) post /users (CreateUserReq) returns (UserResp)
@errors UserNotFound
@handler getUser @handler getUser
get /users/:id (UserIDReq) returns (UserResp) get /users/:id (UserIDReq) returns (UserResp)
@errors InvalidPage
@handler listUsers @handler listUsers
get /users (ListUsersReq) returns (UserPageResp) get /users (ListUsersReq) returns (UserPageResp)
@errors Forbidden, InvalidUser, UserNotFound, EmailTaken
@handler updateUser @handler updateUser
put /users/:id (UpdateUserReq) returns (UserResp) put /users/:id (UpdateUserReq) returns (UserResp)
@errors Forbidden, UserNotFound
@handler deleteUser @handler deleteUser
delete /users/:id (UserIDReq) delete /users/:id (UserIDReq)
@doc "Export one user as a private text object with a short-lived download link" @doc "Export one user as a private text object with a short-lived download link"
@errors Forbidden, UserNotFound
@handler exportUser @handler exportUser
post /users/:id/export (UserIDReq) returns (ExportResp) post /users/:id/export (UserIDReq) returns (ExportResp)
} }

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -4,18 +4,18 @@ import (
"crypto/sha256" "crypto/sha256"
"crypto/subtle" "crypto/subtle"
"errors" "errors"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors" "example.com/userService/internal/apierrors"
"git.yuntuo.hk/k2safe/go-omni/httpx" "git.yuntuo.hk/k2safe/go-omni/httpx"
"os" "os"
"strings" "strings"
) )
// Auth is the fail-closed registry default. Startup replaces it with configured // 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. // production applications can inject JWT/OIDC through the same middleware slot.
func Auth(next httpx.Handler) httpx.Handler { func Auth(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error { return func(c *httpx.Context) error {
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication required") return apierrors.Unauthenticated()
} }
} }
func NewAuthFromEnv() (httpx.Middleware, error) { func NewAuthFromEnv() (httpx.Middleware, error) {
@@ -23,16 +23,26 @@ func NewAuthFromEnv() (httpx.Middleware, error) {
if len(token) < 32 || strings.ContainsAny(token, " \t\r\n") { 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") 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)) expected := sha256.Sum256([]byte(token))
return func(next httpx.Handler) httpx.Handler { return func(next httpx.Handler) httpx.Handler {
return func(c *httpx.Context) error { return func(c *httpx.Context) error {
parts := strings.Fields(c.Header("Authorization")) parts := strings.Fields(c.Header("Authorization"))
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication required") return apierrors.Unauthenticated()
} }
actual := sha256.Sum256([]byte(parts[1])) actual := sha256.Sum256([]byte(parts[1]))
if subtle.ConstantTimeCompare(expected[:], actual[:]) != 1 { admin := subtle.ConstantTimeCompare(expected[:], actual[:]) == 1
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication required") 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) return next(c)
} }

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
# 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)。
## 远程版本验证
发布后执行以下命令,从全新 module cache 下载 CLI 和框架,验证固定版本且没有本地 replace 的业务项目:
```sh
python3 scripts/verifyAPIWorkflow.py --version v0.7.0-preview.1 \
--report performance/apiDeveloperExperienceRemote.json
```
远程结果在实际完成后记录;本地通过不代替远程下载验证。
## 边界
鉴权声明负责文档及路由中间件装配;凭证校验、登录和权限策略仍由业务 hook 实现。新 hook 默认返回 501,不会自动放行。
错误声明不会自动映射数据库错误,也不会拦截运行时返回;Service 需要使用对应错误函数。
本版每个接口只选择一个认证方案,不含 OAuth2 scopes、组合认证或 SDK 生成;现有 `.api` 继续支持,新声明需升级 CLI。
验收只使用本机临时服务和 MySQL 容器,限制 1 CPU / 768 MiB、绑定 localhost,结束删除临时资源;未使用业务服务器。
OSS/R2 云账号验收与网络层长时间压力测试不属于本轮。

View File

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

View File

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

View File

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

View File

@@ -59,9 +59,9 @@ class LimitedMySQL(MySQL):
def exercise(binary, project, env, db, transport): def exercise(binary, project, env, db, transport):
with socket.socket() as sock: with socket.socket() as sock:
sock.bind(("127.0.0.1", 0)); port = sock.getsockname()[1] 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, 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_ROOT=str(project / (transport + "Files")),
GO_OMNI_STORAGE_PUBLIC_URL=f"http://127.0.0.1:{port}/storage/object") GO_OMNI_STORAGE_PUBLIC_URL=f"http://127.0.0.1:{port}/storage/object")
logs = project / "var/log/server.log" 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({})) client = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def request(method, path, body=None, expected=200, auth=True): def request(method, path, body=None, expected=200, auth=True):
headers = {"Content-Type": "application/json", "X-Request-ID": "apiWorkflow"} 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, 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) data=None if body is None else json.dumps(body).encode(), headers=headers, method=method)
try: response = client.open(req, timeout=5) try: response = client.open(req, timeout=5)
@@ -98,6 +98,10 @@ def exercise(binary, project, env, db, transport):
process = start() process = start()
try: try:
request("GET", "/api/v1/users", expected=401, auth=False); checks.append("authBeforeService") 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"] empty = request("GET", "/api/v1/users")["data"]
assert empty["items"] == [] and empty["total"] == 0 assert empty["items"] == [] and empty["total"] == 0
checks.append("emptyPageAndQueryDefaults") checks.append("emptyPageAndQueryDefaults")
@@ -143,6 +147,11 @@ def exercise(binary, project, env, db, transport):
checks.append("signedDownloadTamperRejected") checks.append("signedDownloadTamperRejected")
doc = request("GET", "/openapi.json") doc = request("GET", "/openapi.json")
assert doc == json.loads((project / "docs/openapi.json").read_text()); checks.append("openAPIMatchesContract") 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) run(["docker", "stop", "--time=3", db.container], project, db.env)
request("GET", "/health/ready", expected=503) request("GET", "/health/ready", expected=503)
run(["docker", "start", db.container], project, db.env); db.wait() 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() text = logs.read_text()
entries = [json.loads(line) for line in text.splitlines() if line.startswith("{")] 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 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") checks.append("businessFileLogsAndSecretExclusion")
return {"transport": transport, "checks": checks} return {"transport": transport, "checks": checks}
finally: finally: