feat: declare API errors and authentication with developer tooling
This commit is contained in:
112
API.md
112
API.md
@@ -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
|
||||||
|
|||||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -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 的问题,拒绝后关闭未读连接。
|
||||||
|
|
||||||
|
|||||||
@@ -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)。
|
||||||
|
|||||||
22
RELEASE.md
22
RELEASE.md
@@ -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 云端仍需项目自己的账号做上线环境验收;当前云端适配器检查不等同于这些账号的实网测试。
|
||||||
|
|||||||
14
ROADMAP.md
14
ROADMAP.md
@@ -88,4 +88,18 @@
|
|||||||
- 已完成本地模块测试及 race 检查,Linux 全仓库 race/vet/build、生成项目 CRUD、三个 Runner 的 TLS 示例和独立 Redis/MinIO/MySQL 均通过。共享状态、重复提交、失败消息、未确认消息接管、旧确认拒绝、对象签名和迁移 dirty 阻断有对应证据,见 [扩展模块验收](performance/advancedAcceptance.md)。OSS/R2 尚无云账号实网验收。
|
- 已完成本地模块测试及 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、适配层契约和一次业务模块试用已完成,自研网络层已有可运行预览,稳定性与生产规模验证尚未完成。
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
o.Check = true
|
||||||
|
} else {
|
||||||
|
o.DryRun = 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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
197
cmd/go-omni/apigen/contractMetadata.go
Normal file
197
cmd/go-omni/apigen/contractMetadata.go
Normal 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"}},
|
||||||
|
}}}}
|
||||||
|
}
|
||||||
165
cmd/go-omni/apigen/declarations.go
Normal file
165
cmd/go-omni/apigen/declarations.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrorDefinition declares a public, stable error. Generated constructors return
|
||||||
|
// a fresh error each time so WithCause cannot mutate a shared singleton.
|
||||||
|
type ErrorDefinition struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Position string `json:"-"`
|
||||||
|
}
|
||||||
|
type AuthDefinition struct {
|
||||||
|
Name, Type, In, KeyName, Middleware, Description, BearerFormat, Position string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *parser) parseErrors() error {
|
||||||
|
p.next()
|
||||||
|
block := p.peek() == "("
|
||||||
|
if block {
|
||||||
|
p.next()
|
||||||
|
p.skip()
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
pos := p.tokens[p.at].position
|
||||||
|
name, err := p.ident()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
status, err := strconv.Atoi(p.next().text)
|
||||||
|
if err != nil {
|
||||||
|
return p.err("error HTTP status must be an integer")
|
||||||
|
}
|
||||||
|
code, err := p.quoted()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
message, err := p.quoted()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.spec.Errors = append(p.spec.Errors, ErrorDefinition{Name: name, Status: status, Code: code, Message: message, Position: pos})
|
||||||
|
if p.peek() != "\n" && p.peek() != ";" && !(block && p.peek() == ")") && p.peek() != "" {
|
||||||
|
return p.err("expected end of error declaration")
|
||||||
|
}
|
||||||
|
if !block {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p.skip()
|
||||||
|
if p.peek() == ")" {
|
||||||
|
p.next()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (p *parser) parseAuth() error {
|
||||||
|
pos := p.next().position
|
||||||
|
name, err := p.ident()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.skip()
|
||||||
|
props, err := p.metadata()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a := AuthDefinition{Name: name, Position: pos}
|
||||||
|
for key, value := range props {
|
||||||
|
switch key {
|
||||||
|
case "type":
|
||||||
|
a.Type = value
|
||||||
|
case "in":
|
||||||
|
a.In = value
|
||||||
|
case "name":
|
||||||
|
a.KeyName = value
|
||||||
|
case "middleware":
|
||||||
|
a.Middleware = value
|
||||||
|
case "description":
|
||||||
|
a.Description = value
|
||||||
|
case "bearerFormat":
|
||||||
|
a.BearerFormat = value
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%s: unsupported auth property %q", pos, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.spec.Auth = append(p.spec.Auth, a)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (p *parser) names() ([]string, error) {
|
||||||
|
var names []string
|
||||||
|
for {
|
||||||
|
name, err := p.ident()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, prior := range names {
|
||||||
|
if prior == name {
|
||||||
|
return nil, p.err("duplicate name " + name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names = append(names, name)
|
||||||
|
if p.peek() != "," {
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
p.next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func commaList(value string) []string {
|
||||||
|
if value == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := strings.Split(value, ",")
|
||||||
|
for i := range result {
|
||||||
|
result[i] = strings.TrimSpace(result[i])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
func mergeNames(base, extra []string) []string {
|
||||||
|
result := append([]string(nil), base...)
|
||||||
|
for _, name := range extra {
|
||||||
|
found := false
|
||||||
|
for _, prior := range result {
|
||||||
|
found = found || prior == name
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
result = append(result, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
func (p *parser) parseDoc(route *Route) error {
|
||||||
|
if p.peek() != "(" {
|
||||||
|
value, err := p.quoted()
|
||||||
|
route.Summary = value
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pos := p.tokens[p.at].position
|
||||||
|
props, err := p.metadata()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for key, value := range props {
|
||||||
|
switch key {
|
||||||
|
case "summary":
|
||||||
|
route.Summary = value
|
||||||
|
case "description":
|
||||||
|
route.Description = value
|
||||||
|
case "tags":
|
||||||
|
route.Tags = commaList(value)
|
||||||
|
case "deprecated":
|
||||||
|
if value != "true" && value != "false" {
|
||||||
|
return fmt.Errorf("%s: deprecated must be true or false", pos)
|
||||||
|
}
|
||||||
|
route.Deprecated = value == "true"
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%s: unsupported @doc property %q", pos, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
305
cmd/go-omni/apigen/metadata_test.go
Normal file
305
cmd/go-omni/apigen/metadata_test.go
Normal file
@@ -0,0 +1,305 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const declaredAPI = `syntax = "v1"
|
||||||
|
info (
|
||||||
|
title: "Declared API"
|
||||||
|
description: "Errors and authentication share one contract"
|
||||||
|
)
|
||||||
|
error (
|
||||||
|
Unauthenticated 401 "UNAUTHENTICATED" "authentication required"
|
||||||
|
Forbidden 403 "FORBIDDEN" "permission denied"
|
||||||
|
EmailTaken 409 "EMAIL_TAKEN" "email already in use"
|
||||||
|
NameTaken 409 "NAME_TAKEN" "name already in use"
|
||||||
|
)
|
||||||
|
auth UserAuth (
|
||||||
|
type: bearer
|
||||||
|
middleware: Auth
|
||||||
|
description: "Verified application token"
|
||||||
|
)
|
||||||
|
auth PartnerAuth (
|
||||||
|
type: apiKey
|
||||||
|
in: header
|
||||||
|
name: X-API-Key
|
||||||
|
middleware: Partner
|
||||||
|
)
|
||||||
|
auth SessionAuth (
|
||||||
|
type: apiKey
|
||||||
|
in: cookie
|
||||||
|
name: sessionId
|
||||||
|
middleware: Session
|
||||||
|
)
|
||||||
|
@server (
|
||||||
|
group: user
|
||||||
|
auth: UserAuth
|
||||||
|
errors: Unauthenticated
|
||||||
|
middleware: Audit
|
||||||
|
)
|
||||||
|
service declared-api {
|
||||||
|
@doc (
|
||||||
|
summary: "Create a user"
|
||||||
|
description: "Uniqueness is enforced in a transaction"
|
||||||
|
tags: users, administration
|
||||||
|
deprecated: true
|
||||||
|
)
|
||||||
|
@errors Forbidden, EmailTaken, NameTaken
|
||||||
|
@handler create
|
||||||
|
post /users
|
||||||
|
@auth none
|
||||||
|
@errors none
|
||||||
|
@handler public
|
||||||
|
get /public
|
||||||
|
@auth PartnerAuth
|
||||||
|
@handler partner
|
||||||
|
get /partner
|
||||||
|
@auth SessionAuth
|
||||||
|
@handler session
|
||||||
|
get /session
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
func document(t *testing.T, c *Compiled) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var doc map[string]any
|
||||||
|
if err := json.Unmarshal(c.Document, &doc); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
func operation(t *testing.T, doc map[string]any, path, method string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
return doc["paths"].(map[string]any)[path].(map[string]any)[method].(map[string]any)
|
||||||
|
}
|
||||||
|
func TestDeclaredErrorsAndAuthentication(t *testing.T) {
|
||||||
|
spec, err := parseText(t, declaredAPI)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c, err := Compile(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
again, err := Compile(spec)
|
||||||
|
if err != nil || string(c.Document) != string(again.Document) {
|
||||||
|
t.Fatalf("compile is not repeatable: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Join(spec.Routes[0].Middleware, ",") != "Audit" {
|
||||||
|
t.Fatal("parser output mutated")
|
||||||
|
}
|
||||||
|
if strings.Join(c.Spec.Routes[0].Middleware, ",") != "Auth,Audit" {
|
||||||
|
t.Fatal("authentication must be first")
|
||||||
|
}
|
||||||
|
if strings.Join(c.Spec.Routes[1].Middleware, ",") != "Audit" {
|
||||||
|
t.Fatal("public override retained automatic auth")
|
||||||
|
}
|
||||||
|
doc := document(t, c)
|
||||||
|
op := operation(t, doc, "/users", "post")
|
||||||
|
if op["deprecated"] != true || op["description"] == nil || len(op["tags"].([]any)) != 2 {
|
||||||
|
t.Fatalf("documentation missing: %v", op)
|
||||||
|
}
|
||||||
|
security := op["security"].([]any)
|
||||||
|
if _, ok := security[0].(map[string]any)["UserAuth"]; !ok {
|
||||||
|
t.Fatal("security requirement absent")
|
||||||
|
}
|
||||||
|
schemes := doc["components"].(map[string]any)["securitySchemes"].(map[string]any)
|
||||||
|
if len(schemes) != 3 || schemes["UserAuth"].(map[string]any)["scheme"] != "bearer" || schemes["SessionAuth"].(map[string]any)["in"] != "cookie" {
|
||||||
|
t.Fatalf("wrong security schemes: %v", schemes)
|
||||||
|
}
|
||||||
|
responses := op["responses"].(map[string]any)
|
||||||
|
for _, status := range []string{"401", "403", "409"} {
|
||||||
|
if responses[status] == nil {
|
||||||
|
t.Fatal("missing status " + status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
media := responses["409"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)
|
||||||
|
if len(media["examples"].(map[string]any)) != 2 {
|
||||||
|
t.Fatal("same-status errors were overwritten")
|
||||||
|
}
|
||||||
|
codes := media["schema"].(map[string]any)["properties"].(map[string]any)["code"].(map[string]any)["enum"].([]any)
|
||||||
|
if len(codes) != 2 {
|
||||||
|
t.Fatal("business code enum absent")
|
||||||
|
}
|
||||||
|
public := operation(t, doc, "/public", "get")
|
||||||
|
if len(public["security"].([]any)) != 0 || public["responses"].(map[string]any)["401"] != nil || public["x-omni-errors"] != nil {
|
||||||
|
t.Fatal("public overrides did not clear group declarations")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestInvalidDeclarations(t *testing.T) {
|
||||||
|
replacements := [][2]string{
|
||||||
|
{`"EMAIL_TAKEN"`, `"UNAUTHENTICATED"`},
|
||||||
|
{`EmailTaken 409`, `EmailTaken 200`},
|
||||||
|
{`EmailTaken 409`, `emailTaken 409`},
|
||||||
|
{`NameTaken 409`, `CodeEmailTaken 409`},
|
||||||
|
{`auth: UserAuth`, `auth: Unknown`},
|
||||||
|
{`errors: Unauthenticated`, `errors: Unknown`},
|
||||||
|
{`middleware: Audit`, `middleware: Auth`},
|
||||||
|
{`type: bearer`, `type: oauth2`},
|
||||||
|
{`in: header`, `in: query`},
|
||||||
|
{`name: X-API-Key`, `name: Authorization`},
|
||||||
|
{`deprecated: true`, `deprecated: yes`},
|
||||||
|
{`tags: users, administration`, `tags: users, users`},
|
||||||
|
{`@errors Forbidden, EmailTaken, NameTaken`, `@errors Forbidden, Forbidden`},
|
||||||
|
{`middleware: Auth`, `middleware: Defaults`},
|
||||||
|
}
|
||||||
|
for _, change := range replacements {
|
||||||
|
spec, err := parseText(t, strings.Replace(declaredAPI, change[0], change[1], 1))
|
||||||
|
if err == nil {
|
||||||
|
_, err = Compile(spec)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("accepted invalid %q", change[1])
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "service.api:") {
|
||||||
|
t.Fatalf("source position missing: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestImportedErrorCatalog(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeFile(t, filepath.Join(root, "errors.api"), `error Conflict 409 "CONFLICT" "conflict"`)
|
||||||
|
writeFile(t, filepath.Join(root, "service.api"), "import \"errors.api\"\nservice imported-api { @errors Conflict\n @handler create\n post /users\n }")
|
||||||
|
spec, err := Parse(filepath.Join(root, "service.api"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err = Compile(spec); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestDryRunAndEntryDiscovery(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
source := filepath.Join(root, "input/service.api")
|
||||||
|
writeFile(t, source, declaredAPI)
|
||||||
|
dir := filepath.Join(root, "project")
|
||||||
|
o := Options{API: source, Dir: dir, Module: "example.com/dx", FrameworkDir: frameworkRoot(t), DryRun: true}
|
||||||
|
out, err := Generate(o)
|
||||||
|
if err != nil || len(out.Changes) == 0 {
|
||||||
|
t.Fatalf("empty plan: %v", err)
|
||||||
|
}
|
||||||
|
if _, err = os.Stat(dir); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("dry-run created directory")
|
||||||
|
}
|
||||||
|
o.DryRun = false
|
||||||
|
if _, err = Generate(o); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
entry, err := ProjectEntry(dir)
|
||||||
|
if err != nil || entry != filepath.Join(dir, "api/service.api") {
|
||||||
|
t.Fatalf("entry: %s %v", entry, err)
|
||||||
|
}
|
||||||
|
o = Options{API: entry, Dir: dir, DryRun: true}
|
||||||
|
before := snapshot(t, dir)
|
||||||
|
out, err = Generate(o)
|
||||||
|
if err != nil || len(out.Changes) != 0 {
|
||||||
|
t.Fatalf("unchanged project plan: %v %+v", err, out.Changes)
|
||||||
|
}
|
||||||
|
writeFile(t, entry, strings.Replace(declaredAPI, "email already in use", "email is already registered", 1))
|
||||||
|
before = snapshot(t, dir)
|
||||||
|
out, err = Generate(o)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !sameFiles(before, snapshot(t, dir)) {
|
||||||
|
t.Fatal("dry-run changed project")
|
||||||
|
}
|
||||||
|
actions := map[string]string{}
|
||||||
|
for _, c := range out.Changes {
|
||||||
|
actions[c.Path] = c.Action
|
||||||
|
}
|
||||||
|
if actions["internal/apierrors/errorsGen.go"] != "update" || actions["docs/openapi.json"] != "update" {
|
||||||
|
t.Fatalf("missing plan: %v", actions)
|
||||||
|
}
|
||||||
|
o.Check = true
|
||||||
|
if _, err = Generate(o); err == nil {
|
||||||
|
t.Fatal("check and dry-run combined")
|
||||||
|
}
|
||||||
|
// A malformed manifest cannot redirect the shorthand outside its project.
|
||||||
|
writeFile(t, filepath.Join(dir, manifestPath), `{"version":1,"entry":"../input/service.api"}`)
|
||||||
|
if _, err = ProjectEntry(dir); err == nil {
|
||||||
|
t.Fatal("unsafe manifest entry accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeclaredContractConsumer(t *testing.T) {
|
||||||
|
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") == "" {
|
||||||
|
t.Skip("consumer integration")
|
||||||
|
}
|
||||||
|
root := t.TempDir()
|
||||||
|
source := filepath.Join(root, "input/service.api")
|
||||||
|
writeFile(t, source, declaredAPI)
|
||||||
|
dir := filepath.Join(root, "project")
|
||||||
|
if _, err := Generate(Options{API: source, Dir: dir, Module: "example.com/dx", FrameworkDir: frameworkRoot(t)}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
writeFile(t, filepath.Join(dir, "contract_test.go"), declaredConsumerTest)
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "-race", "-count=1", "./...")
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = append(os.Environ(), "GOWORK=off", "GOFLAGS=")
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Fatalf("consumer: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const declaredConsumerTest = `package dx_test
|
||||||
|
import (
|
||||||
|
"context";"errors";"io";"net/http/httptest";"strings";"testing"
|
||||||
|
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||||
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||||
|
"example.com/dx/internal/apierrors"
|
||||||
|
"example.com/dx/internal/handler"
|
||||||
|
"example.com/dx/internal/servicecontext"
|
||||||
|
)
|
||||||
|
func TestRuntimeAuthAndFreshErrors(t *testing.T){
|
||||||
|
first:=apierrors.EmailTaken().WithCause(errors.New("private"));second:=apierrors.EmailTaken()
|
||||||
|
if first==second||second.Cause!=nil||second.Code!=apierrors.CodeEmailTaken||second.Status!=409{t.Fatal("shared error instance")}
|
||||||
|
cfg:=logx.Default();cfg.Writer=io.Discard;logger,err:=logx.New(cfg);if err!=nil{t.Fatal(err)};defer logger.Close()
|
||||||
|
deps,err:=servicecontext.New(context.Background(),logger);if err!=nil{t.Fatal(err)};defer deps.Close()
|
||||||
|
app:=omni.New();defer app.Close();if err=handler.RegisterRoutes(app,deps);err!=nil{t.Fatal(err)}
|
||||||
|
w:=httptest.NewRecorder();app.Engine().ServeHTTP(w,httptest.NewRequest("POST","/users",nil))
|
||||||
|
if w.Code!=501||!strings.Contains(w.Body.String(),"MIDDLEWARE_NOT_IMPLEMENTED"){t.Fatal("unimplemented auth did not fail closed")}
|
||||||
|
var called []string
|
||||||
|
deps.Middlewares["Auth"]=func(next httpx.Handler)httpx.Handler{return func(c *httpx.Context)error{called=append(called,"auth");if c.Header("Authorization")!="Bearer valid"{return apierrors.Unauthenticated()};return next(c)}}
|
||||||
|
deps.Middlewares["Audit"]=func(next httpx.Handler)httpx.Handler{return func(c *httpx.Context)error{called=append(called,"audit");return next(c)}}
|
||||||
|
app2:=omni.New();defer app2.Close();if err=handler.RegisterRoutes(app2,deps);err!=nil{t.Fatal(err)}
|
||||||
|
w=httptest.NewRecorder();app2.Engine().ServeHTTP(w,httptest.NewRequest("POST","/users",nil))
|
||||||
|
if w.Code!=401||strings.Join(called,",")!="auth"{t.Fatal("unauthenticated request reached other middleware")}
|
||||||
|
called=nil;req:=httptest.NewRequest("POST","/users",nil);req.Header.Set("Authorization","Bearer valid")
|
||||||
|
w=httptest.NewRecorder();app2.Engine().ServeHTTP(w,req)
|
||||||
|
if w.Code!=501||strings.Join(called,",")!="auth,audit"{t.Fatal("middleware order changed")}
|
||||||
|
called=nil;w=httptest.NewRecorder();app2.Engine().ServeHTTP(w,httptest.NewRequest("GET","/public",nil))
|
||||||
|
if w.Code!=501||strings.Join(called,",")!="audit"||strings.Contains(w.Body.String(),"MIDDLEWARE_NOT_IMPLEMENTED"){t.Fatal("public override retained auth")}
|
||||||
|
delete(deps.Middlewares,"Auth");app3:=omni.New();defer app3.Close()
|
||||||
|
if err=handler.RegisterRoutes(app3,deps);err==nil{t.Fatal("missing auth registration accepted")}
|
||||||
|
w=httptest.NewRecorder();app3.Engine().ServeHTTP(w,httptest.NewRequest("GET","/public",nil))
|
||||||
|
if w.Code!=404{t.Fatal("failed auth preflight registered partial routes")}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestAuthMetadataWithParameterNamePrefixes(t *testing.T) {
|
||||||
|
source := "type Req {\n ID int `path:\"id\"`\n IDLong int `path:\"idLong\"`\n}\nauth Gate (\n type: bearer\n middleware: Auth\n)\n@server (auth: Gate)\nservice prefix-api {\n @handler read\n get /items/:id/children/:idLong (Req)\n}"
|
||||||
|
spec, err := parseText(t, source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c, err := Compile(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
op := operation(t, document(t, c), "/items/{id}/children/{idLong}", "get")
|
||||||
|
if len(op["security"].([]any)) != 1 {
|
||||||
|
t.Fatal("auth metadata lost")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Spec struct {
|
type Spec struct {
|
||||||
Title, Version, Service string
|
Title, Version, Service, Description string
|
||||||
|
Errors []ErrorDefinition
|
||||||
|
Auth []AuthDefinition
|
||||||
Types []Type
|
Types []Type
|
||||||
Routes []Route
|
Routes []Route
|
||||||
Sources map[string][]byte // relative to the entry API's directory
|
Sources map[string][]byte // relative to the entry API's directory
|
||||||
@@ -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:
|
||||||
|
|||||||
35
cmd/go-omni/apigen/projectEntry.go
Normal file
35
cmd/go-omni/apigen/projectEntry.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package apigen
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProjectEntry discovers the API entry recorded by generation, never guessing
|
||||||
|
// which of several .api files is the service entry.
|
||||||
|
func ProjectEntry(directory string) (string, error) {
|
||||||
|
root, err := filepath.Abs(directory)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err = safePath(root, manifestPath); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(root, manifestPath))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("--api is required outside a generated project (no readable %s)", manifestPath)
|
||||||
|
}
|
||||||
|
var m manifest
|
||||||
|
if err = json.Unmarshal(data, &m); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid API project manifest: %w", err)
|
||||||
|
}
|
||||||
|
if m.Version != 1 || filepath.Ext(m.Entry) != ".api" {
|
||||||
|
return "", fmt.Errorf("invalid API entry in %s; pass --api explicitly", manifestPath)
|
||||||
|
}
|
||||||
|
if err = safePath(root, m.Entry); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return filepath.Join(root, filepath.FromSlash(m.Entry)), nil
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)")
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
42
examples/userService/internal/apierrors/errorsGen.go
Normal file
42
examples/userService/internal/apierrors/errorsGen.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
// Code generated by go-omni api. DO NOT EDIT.
|
||||||
|
package apierrors
|
||||||
|
|
||||||
|
import omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||||
|
|
||||||
|
const CodeUnauthenticated = "UNAUTHENTICATED"
|
||||||
|
|
||||||
|
// Unauthenticated returns a fresh public error declared in the API contract.
|
||||||
|
func Unauthenticated() *omnierrors.APIError {
|
||||||
|
return omnierrors.New(401, CodeUnauthenticated, "authentication required")
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodeForbidden = "FORBIDDEN"
|
||||||
|
|
||||||
|
// Forbidden returns a fresh public error declared in the API contract.
|
||||||
|
func Forbidden() *omnierrors.APIError { return omnierrors.New(403, CodeForbidden, "permission denied") }
|
||||||
|
|
||||||
|
const CodeUserNotFound = "USER_NOT_FOUND"
|
||||||
|
|
||||||
|
// UserNotFound returns a fresh public error declared in the API contract.
|
||||||
|
func UserNotFound() *omnierrors.APIError {
|
||||||
|
return omnierrors.New(404, CodeUserNotFound, "user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodeEmailTaken = "EMAIL_TAKEN"
|
||||||
|
|
||||||
|
// EmailTaken returns a fresh public error declared in the API contract.
|
||||||
|
func EmailTaken() *omnierrors.APIError {
|
||||||
|
return omnierrors.New(409, CodeEmailTaken, "email already in use")
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodeInvalidUser = "INVALID_USER"
|
||||||
|
|
||||||
|
// InvalidUser returns a fresh public error declared in the API contract.
|
||||||
|
func InvalidUser() *omnierrors.APIError { return omnierrors.New(400, CodeInvalidUser, "invalid user") }
|
||||||
|
|
||||||
|
const CodeInvalidPage = "INVALID_PAGE"
|
||||||
|
|
||||||
|
// InvalidPage returns a fresh public error declared in the API contract.
|
||||||
|
func InvalidPage() *omnierrors.APIError {
|
||||||
|
return omnierrors.New(400, CodeInvalidPage, "invalid pagination")
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
44
performance/apiDeveloperExperienceAcceptance.md
Normal file
44
performance/apiDeveloperExperienceAcceptance.md
Normal 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 云账号验收与网络层长时间压力测试不属于本轮。
|
||||||
161
performance/apiDeveloperExperienceLocal.json
Normal file
161
performance/apiDeveloperExperienceLocal.json
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
{
|
||||||
|
"passed": true,
|
||||||
|
"frameworkVersion": "local",
|
||||||
|
"noLocalReplace": false,
|
||||||
|
"durationSeconds": 20.21,
|
||||||
|
"regenerationPreservedHandwrittenFiles": 29,
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"transport": "nethttp",
|
||||||
|
"checks": [
|
||||||
|
"authBeforeService",
|
||||||
|
"readOnlyPermission403",
|
||||||
|
"emptyPageAndQueryDefaults",
|
||||||
|
"contractValidation",
|
||||||
|
"createReadNormalize",
|
||||||
|
"uniqueEmailConflict",
|
||||||
|
"updateAndPagination",
|
||||||
|
"createUpdateDeleteRollbackOnAuditFailure",
|
||||||
|
"storageUploadAndSignedDownload",
|
||||||
|
"signedDownloadTamperRejected",
|
||||||
|
"openAPIMatchesContract",
|
||||||
|
"declaredAuthAndBusinessErrorDocumentation",
|
||||||
|
"readinessOutageAndRecovery",
|
||||||
|
"persistenceAfterServiceRestart",
|
||||||
|
"deleteAndTransactionalAudit",
|
||||||
|
"gracefulStopAndListenerRelease",
|
||||||
|
"businessFileLogsAndSecretExclusion"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"transport": "native",
|
||||||
|
"checks": [
|
||||||
|
"authBeforeService",
|
||||||
|
"readOnlyPermission403",
|
||||||
|
"emptyPageAndQueryDefaults",
|
||||||
|
"contractValidation",
|
||||||
|
"createReadNormalize",
|
||||||
|
"uniqueEmailConflict",
|
||||||
|
"updateAndPagination",
|
||||||
|
"createUpdateDeleteRollbackOnAuditFailure",
|
||||||
|
"storageUploadAndSignedDownload",
|
||||||
|
"signedDownloadTamperRejected",
|
||||||
|
"openAPIMatchesContract",
|
||||||
|
"declaredAuthAndBusinessErrorDocumentation",
|
||||||
|
"readinessOutageAndRecovery",
|
||||||
|
"persistenceAfterServiceRestart",
|
||||||
|
"deleteAndTransactionalAudit",
|
||||||
|
"gracefulStopAndListenerRelease",
|
||||||
|
"businessFileLogsAndSecretExclusion"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"transactionTests": [
|
||||||
|
"commit",
|
||||||
|
"errorRollback",
|
||||||
|
"panicRollback",
|
||||||
|
"cancellationRollback"
|
||||||
|
],
|
||||||
|
"startupTests": [
|
||||||
|
"storageFailureClosesDatabase",
|
||||||
|
"idempotentClose",
|
||||||
|
"closedContextCannotReopen"
|
||||||
|
],
|
||||||
|
"migrationCLI": true,
|
||||||
|
"frameworkSum": null,
|
||||||
|
"sourceHashes": {
|
||||||
|
"cmd/go-omni/api.go": "4e2722bdf6f0973df2f91d23ba3a8ade9cf5d8ec8dba68c188382a3c98415109",
|
||||||
|
"cmd/go-omni/api_test.go": "172e4e2ba4984820ba729f88273b1ae133415081344e51c3654f297ef33216d4",
|
||||||
|
"cmd/go-omni/apigen/apigen_test.go": "7cbe654e84c60f7b7f3a9631472bb66c1999ff4a9eaacaec224d26e78d08a38c",
|
||||||
|
"cmd/go-omni/apigen/compile.go": "c1334934f4f329b20efe9bfd8903f18125355d4a21111cdcd3fa0d2c8e88bcd9",
|
||||||
|
"cmd/go-omni/apigen/contractMetadata.go": "87239fa7d1610963d1c812f79074f9ceb6a71c263d16c1b1ed257fbc553a4fac",
|
||||||
|
"cmd/go-omni/apigen/declarations.go": "3f8073fb0b0bfa775f7712ed28e670f8dc98571636656726e18a6d592692bc81",
|
||||||
|
"cmd/go-omni/apigen/generate.go": "b6c10d5e7f22a36d436a4b720da9a123e4caec82ca09b349b589ac578ccfdbb8",
|
||||||
|
"cmd/go-omni/apigen/metadata_test.go": "ab0c55e90e9b7a8bdaf260415e4c8564f6b2378de625fd9379a2b801cb4bc0e5",
|
||||||
|
"cmd/go-omni/apigen/parse.go": "82ff76277478d62b7df291eec911070da168cd3739c9006f58d367e6276b77b5",
|
||||||
|
"cmd/go-omni/apigen/profile.go": "877939398384c44316e5e052d92c4a93fb3b74a6742df2414a0054913f3fca27",
|
||||||
|
"cmd/go-omni/apigen/profile_test.go": "5f3c091da241fe0f8b99ac19e9efd2c3177aea72e63a346f99fecc0f548aad1a",
|
||||||
|
"cmd/go-omni/apigen/projectEntry.go": "817936d391ec7dc76f7b5b9f7d134537947ea238d2cd281eb3df536511cb5486",
|
||||||
|
"cmd/go-omni/apigen/templates.go": "36b4946acc636960bf7ca1d95ab364135432392829c12be523ef6b514e433f63",
|
||||||
|
"cmd/go-omni/apigen/write.go": "241c8a4d386070ffee25aa5b5ce03129fe739fe3fc78c0ba65d2f48bdb075b6d",
|
||||||
|
"cmd/go-omni/main.go": "060b064973f2316b6f01c7b9a716d57c3dbc8ad33a5d6153f6cfddaf649cd5fc",
|
||||||
|
"cmd/go-omni/main_test.go": "b8040d2b19a1c86672fe1b1be7fa1bf7f885aece79ded6ffa0cc670887b7136f",
|
||||||
|
"cmd/go-omni/migrate.go": "f1ce89fbe419205eb82172da2b90e8888905d2b85d1a9db2a72b568bcf849a70",
|
||||||
|
"cmd/go-omni/migrate_test.go": "15d9a4aaebf9f899e523e908f1fa3c2282eb1e6c8cadbefae6b68f1ae4e00e6e",
|
||||||
|
"cmd/go-omni/scaffold/addModule.go": "8f43bd68c2b3820cf1d036c4f2c5f9d06d1c7c8e5114f5081597d905f8d8cdcc",
|
||||||
|
"cmd/go-omni/scaffold/addModule_test.go": "667c45312f628f0fb5b8206ddef7ccc02a75f33b72f73fd6a17b03da0cb62e91",
|
||||||
|
"cmd/go-omni/scaffold/apiInfrastructure.go": "f493364448254cae2d8e658bf5c69e96a3264c2bf3fa50fd48a5ebdb20edabd8",
|
||||||
|
"cmd/go-omni/scaffold/moduleProfiles_test.go": "ae9790e5d3549d32fc72739a10a31ea858e5a8f8189cf1f5c1840bf80b3f8ad6",
|
||||||
|
"cmd/go-omni/scaffold/moduleTemplates/README.md.tmpl": "9d3bfc46cac5618bcff7d8a6b2cc9f123e818aeb3dee9bcdfe0f91db584994bd",
|
||||||
|
"cmd/go-omni/scaffold/moduleTemplates/handler-handler.go.tmpl": "729cd0f6faba53d5d2c1492935e12ac2adcefde4490748e25c18be7ccb3f7139",
|
||||||
|
"cmd/go-omni/scaffold/moduleTemplates/model-model.go.tmpl": "a005611f6b455d4d8469e316bc26fa571043b2da1834db2b3dd14a96d66221d7",
|
||||||
|
"cmd/go-omni/scaffold/moduleTemplates/module.go.tmpl": "fb0be7fd571ba2c0edb01d70d68cdbbfe53561caab98346bf1ac7cd8df07205a",
|
||||||
|
"cmd/go-omni/scaffold/moduleTemplates/repository-repository.go.tmpl": "902da3d9f12395fb226178c4428ff547e1c2fce3261882dba98d1400d8c90747",
|
||||||
|
"cmd/go-omni/scaffold/moduleTemplates/service-service.go.tmpl": "49d26044a69a43e983aa5ba4b5b2399b26b77898b23e230751ad1372c2af3178",
|
||||||
|
"cmd/go-omni/scaffold/scaffold.go": "71b3091b83c3e45ac1981164e8c819c997ad96607d535f1492333370f4872548",
|
||||||
|
"cmd/go-omni/scaffold/scaffold_test.go": "58aab9251b01e394bbc90aeb538ff035dd32c73c0d4815132ac6cbc086527cda",
|
||||||
|
"cmd/go-omni/scaffold/templates/README.md.tmpl": "8ec215775fcafd437480022307fffd2ffe43685bc2b5d328b0c1d58402986e0a",
|
||||||
|
"cmd/go-omni/scaffold/templates/api-context.go.tmpl": "d2d67c3d235c4b1c965364f40f49c441d92764ad7ab8fd6848f56b1028e93b1b",
|
||||||
|
"cmd/go-omni/scaffold/templates/cmd-server-main.go.tmpl": "07c4f81109ece43fcf15bb807ada5ae6dc7e732babc7293269514a21cd389955",
|
||||||
|
"cmd/go-omni/scaffold/templates/databaseIntegration_test.go.tmpl": "81c4f7fbc919d74b738d3a7338248cbd83cfa0803b2058e8ee184e3ce51e7e9b",
|
||||||
|
"cmd/go-omni/scaffold/templates/go.mod.tmpl": "7d3bb15d2292e36939cc1905258d2800aeaf0e37d7d041b2ff8cfdb6fc84c57a",
|
||||||
|
"cmd/go-omni/scaffold/templates/internal-handler-user.go.tmpl": "7f8dbcd2f9b0129a90160d6059e47d19d4758274060a1a3e5160055d0592d34f",
|
||||||
|
"cmd/go-omni/scaffold/templates/internal-model-errors.go.tmpl": "f123eace52952321b0665a92a2923514c52c8c3bf13df1065710e9675750f837",
|
||||||
|
"cmd/go-omni/scaffold/templates/internal-model-user.go.tmpl": "b391143e7386e169b53e7fed25de1146e8c52f3fa287b12728e8d22b33f386de",
|
||||||
|
"cmd/go-omni/scaffold/templates/internal-repository-memory.go.tmpl": "148abc273c1fab3f28536706792762ae41e8687dfe9986c374372f162600feac",
|
||||||
|
"cmd/go-omni/scaffold/templates/internal-repository-mysql.go.tmpl": "34bd333e29211a0025ff6ebaea18c7f142f18f1cc451d692f9d360e8c8f6f635",
|
||||||
|
"cmd/go-omni/scaffold/templates/internal-repository-users.go.tmpl": "6d7dd80f34a9c215daeb4a86bfbe11e845e7ff7e9864c8af43e1c2ec3a8b28ec",
|
||||||
|
"cmd/go-omni/scaffold/templates/internal-service-user.go.tmpl": "33fdee3a2c3d98e7ca6a323b5e888a73582f94e327b6c24a34c09bf180db7b3d",
|
||||||
|
"cmd/go-omni/scaffold/templates/internal-servicecontext-context.go.tmpl": "de70ecc4194db8a2da5d65297f452a40b453652cb814cd228ce03d465761c856",
|
||||||
|
"cmd/go-omni/scaffold/templates/modules-README.md.tmpl": "4b84fbc2310b856d86c6fcd9a47d97d2261019c74ea8e0cdbc41229086aa3527",
|
||||||
|
"cmd/go-omni/scaffold/templates/modules-config.yaml.tmpl": "b8734080ae6d533a175bcad946ce2cb55d8bac1729918291753b9ca03064910f",
|
||||||
|
"cmd/go-omni/scaffold/templates/modules-filesHandler.go.tmpl": "426867fbef5b99a85394fa4457dd38b9a194adf4bdfb293384b2fbbc799091da",
|
||||||
|
"cmd/go-omni/scaffold/templates/modules-filesHandler_test.go.tmpl": "9571e39bd0a330739eb1fd7e636cd58961b41b44772b2b49162e41deda98547f",
|
||||||
|
"cmd/go-omni/scaffold/templates/modules-filesService.go.tmpl": "6460ee9a873af45ca6cec779bf53302acfce21499610425a4704c2f179eddb8f",
|
||||||
|
"cmd/go-omni/scaffold/templates/modules-gitignore.tmpl": "2579f2a63ff2962239b92dd841b0d0d26e783d85c9d0100011936c8bcbf5d0d3",
|
||||||
|
"cmd/go-omni/scaffold/templates/modules-main.go.tmpl": "cb8daddd4e12afd238540be7022187c74a548d9e409b3a7f1eaf84a9139218bf",
|
||||||
|
"cmd/go-omni/scaffold/templates/modules-settings.go.tmpl": "91084caaa1fd994dde714f52b242d23e41adb731383b12d74399247c87ac5597",
|
||||||
|
"cmd/go-omni/scaffold/templates/modules-settings_test.go.tmpl": "76e55f8954b7606d7146eb1e965065c521a2b81234a47742ed69a93aac300406",
|
||||||
|
"cmd/go-omni/scaffold/templates/modules-storage.go.tmpl": "a775acf2d2490288a8e5482bb0a8769be6b0a8c9cfcc9bb10b62aabbd05fa0c7",
|
||||||
|
"cmd/go-omni/scaffold/templates/modules-storage_test.go.tmpl": "dacca42d228bd9352948054eb2d071829a8a61d8c6fa24d95ff00fc39a74309e",
|
||||||
|
"cmd/go-omni/scaffold/templates/schema.sql.tmpl": "7c5108f18fc7d8ff75c97afd96fcaa9f8ed026c79c71c5612f55572d73354abb",
|
||||||
|
"cmd/go-omni/scaffold/version.go": "a389f1e0651544b104481309272722ad8736d7f5a4e27484c61dc8eceba84283",
|
||||||
|
"database/configOverlay_test.go": "01c9b44fded226494ca12f7d0f41a9ff417d8135d263a5833727c1c028b6b8e3",
|
||||||
|
"database/database.go": "3d8615e1bf58ecdae21558591e745c241e499ba6e85fc07acfa2344e04ce95db",
|
||||||
|
"database/database_test.go": "18eac1e4c6dbd6a09216168839ba3511aac8a38c2ddf6fdd5267f35435b09f67",
|
||||||
|
"database/lifecycle_test.go": "e141b52edfb9867184dedac0e79feed56a5b137acbe64280ad69647653bbea21",
|
||||||
|
"database/migrate/migrate.go": "fd6cd27d2dce5b35ffe80eb5442c47c657b93ffc6545bbcc1ee4651a70a4f80b",
|
||||||
|
"database/migrate/migrate_test.go": "d7ba91eed8cfd97e4ef0741d582f493dd411b65ad3192f05b167f75d9b9dcf90",
|
||||||
|
"examples/userService/api/user.api": "ed4d45cb796d9a017af9d341076553ca576918faef155aeea528718515582c95",
|
||||||
|
"examples/userService/cmd/server/main.go": "80ba4721f677947b7f2ece21d5f3e89ee0c4d2cc653412d83aef2b3a45bd3ccb",
|
||||||
|
"examples/userService/etc/config.yaml": "f50df3cddbc3c23f233405e7a9d8895b9d83d2a0607ccf001261fcd4b8978ee9",
|
||||||
|
"examples/userService/internal/apierrors/errorsGen.go": "20e94bbc90ae9f4330de4c25b9a550026c4c352502719a643ede3e8912a6566e",
|
||||||
|
"examples/userService/internal/handler/openapiGen.go": "e4130f5c7494f7db4830bfef203fa8154a9cd087c95954f64c29d74693ead433",
|
||||||
|
"examples/userService/internal/handler/routesGen.go": "7f45030e7eb9f055c4b4d2ff12ad855d7c726aad4aee63c5b3c3f97559a64257",
|
||||||
|
"examples/userService/internal/handler/user/handlersGen.go": "06f3c2798ff323812ff3eb2d8b933a569af3ce1b86a67b66036374add98c0d7e",
|
||||||
|
"examples/userService/internal/middleware/auth.go": "fbfe8ff8b12f698218b9ef5f77fd85b612b3aed362a73dc26c3627f2d688c040",
|
||||||
|
"examples/userService/internal/middleware/auth_test.go": "63d6117dadf43b766e0aa485a63c4725a5109ab814c2b4a1fa7a59ee6d1c4cf6",
|
||||||
|
"examples/userService/internal/middleware/registryGen.go": "a6122a5453dedc6a4415860108814c50e155128cfe5412b39dd33ce2d01b4b00",
|
||||||
|
"examples/userService/internal/model/errors.go": "f123eace52952321b0665a92a2923514c52c8c3bf13df1065710e9675750f837",
|
||||||
|
"examples/userService/internal/model/user.go": "b391143e7386e169b53e7fed25de1146e8c52f3fa287b12728e8d22b33f386de",
|
||||||
|
"examples/userService/internal/repository/user/mysql.go": "924cae233a68e506e9b0b953529e0ccb13a0e71d24723309383c464e1852b833",
|
||||||
|
"examples/userService/internal/repository/user/repository.go": "2aca07788d8d59831b10ac665882ca8157b20f384115bb90742f235f8352a69d",
|
||||||
|
"examples/userService/internal/repository/user/store.go": "f776832e4f3b26fb889dad53b65742833ddb7f43b7dddc72915ad3fe47ec4d3e",
|
||||||
|
"examples/userService/internal/repository/user/transaction_test.go": "9af4cb3a03b71026b80aa5078ddda4e3c4f72a7ab14bed6c87603a4db2cc2a22",
|
||||||
|
"examples/userService/internal/service/user/createUser.go": "53d0ea7371e10a0bd3f32f57b9bff9b48b5f9a3315218f95a258fa45b81d9368",
|
||||||
|
"examples/userService/internal/service/user/deleteUser.go": "0cd0b038d5a11429de438311980ef19a8f6e28ea0d7cf5ebaaf78858daf3a124",
|
||||||
|
"examples/userService/internal/service/user/errors_test.go": "c0193bdc674cbe15c4871a91e288323a99193d19ba8fd874f9bf17c390917e5c",
|
||||||
|
"examples/userService/internal/service/user/exportUser.go": "dac2adb0c9ea43a4af15511ae488afaa6d9c9b351e68e9b8a54f4014d29de14d",
|
||||||
|
"examples/userService/internal/service/user/getUser.go": "a307fa716c70286a2a6c5d5c0a6c35ec6d258c3278e2a7fe86b74b324d7b7283",
|
||||||
|
"examples/userService/internal/service/user/listUsers.go": "f0bf8b9c3a4c4a699112e3493b7fe611e2dd4f6e8a4437106396c47d484f1383",
|
||||||
|
"examples/userService/internal/service/user/service.go": "cd6c203459fccbc030e88eccfa785d5db029e45784f8b242be68ea1c9394a736",
|
||||||
|
"examples/userService/internal/service/user/updateUser.go": "9b0e3e4296c0e0df98956961a928612d9203c0fa09aa71bb0683e430e1133ff0",
|
||||||
|
"examples/userService/internal/servicecontext/context.go": "5527ca07049f5c5f5deffa65e5759494b2e0a77804b629b3ecb1705e7557ee25",
|
||||||
|
"examples/userService/internal/servicecontext/context_test.go": "04d008b6cf5b7894752bc5222abb8f4120f1ebe060034ac55ec9663c1f7bc613",
|
||||||
|
"examples/userService/internal/servicecontext/storage.go": "9402e1ee7a258b2db4b6dbf38fcb0674ad8907fb2a948d47dd8374f78bec444d",
|
||||||
|
"examples/userService/internal/settings/config.go": "721c3744a61a2d2807edcdfa190336901368966a0fb952561d0e00da5730cd3a",
|
||||||
|
"examples/userService/internal/types/typesGen.go": "3ab2ce9ffe467c3ea53ac3d1fec283ecebc87cef57fd592d08dca991fd964876",
|
||||||
|
"examples/userService/migrations/000001_users.down.sql": "64b47d3d2644745a2ba1197b73760e63fe0f2c1d12c193888c4417b3d5abb75e",
|
||||||
|
"examples/userService/migrations/000001_users.up.sql": "c6ea5cb92ee53fe20e43373d084ff360d49921b785602145e55380e4adbee1dc"
|
||||||
|
}
|
||||||
|
}
|
||||||
52
performance/apiDeveloperExperienceTests.txt
Normal file
52
performance/apiDeveloperExperienceTests.txt
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
ok git.yuntuo.hk/k2safe/go-omni 1.484s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/app 2.365s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/audit 1.278s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/auth 1.335s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cache 1.297s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cache/redis 1.276s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni 1.848s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/apigen 28.489s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/cmd/go-omni/scaffold 37.257s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/config 1.337s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/contract 1.310s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/database 1.285s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/database/migrate 1.300s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/errors [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/basic [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/modules 1.345s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/platform 1.379s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/reference 1.469s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/reference/handler [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/reference/model [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/examples/reference/repository 1.438s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/examples/reference/service [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/health 1.517s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/httpc 1.730s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/httpx 1.292s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/internal/perfcheck 1.310s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/internal/storagefixture [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/internal/testutil [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/logx 1.440s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/metrics 1.296s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/middleware 1.319s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/middleware/redis 1.296s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/openapi 1.304s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/pagination 1.279s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/queue [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/queue/redis 1.278s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/resilience 1.321s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/response [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/storage 1.309s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/storage/local 1.314s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/storage/oss 1.423s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/storage/s3 8.243s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/storage/storagetest [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/task 1.361s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/telemetry 1.308s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport 1.327s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport/http2 12.624s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/transport/internal/routing [no test files]
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport/native 2.099s
|
||||||
|
ok git.yuntuo.hk/k2safe/go-omni/transport/nethttp 1.327s
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/transport/transporttest [no test files]
|
||||||
|
? git.yuntuo.hk/k2safe/go-omni/validate [no test files]
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"passed": true,
|
"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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user