feat: declare API errors and authentication with developer tooling
This commit is contained in:
@@ -9,8 +9,9 @@
|
||||
"module": "example.com/userService",
|
||||
"entry": "api/user.api",
|
||||
"generated": {
|
||||
"docs/openapi.json": "0675e4eb1278b36828aa31b12587b0d33d2f384112f67a43179eb72b52c3a0ea",
|
||||
"internal/handler/openapiGen.go": "8ddc60c338630db915b9f074ade776f526eabf60fc99b33e425359fd415c4926",
|
||||
"docs/openapi.json": "05bce8951699350cc1ebca5a626c39349395dd09e395b41560c129e530583204",
|
||||
"internal/apierrors/errorsGen.go": "20e94bbc90ae9f4330de4c25b9a550026c4c352502719a643ede3e8912a6566e",
|
||||
"internal/handler/openapiGen.go": "e4130f5c7494f7db4830bfef203fa8154a9cd087c95954f64c29d74693ead433",
|
||||
"internal/handler/routesGen.go": "7f45030e7eb9f055c4b4d2ff12ad855d7c726aad4aee63c5b3c3f97559a64257",
|
||||
"internal/handler/user/handlersGen.go": "06f3c2798ff323812ff3eb2d8b933a569af3ce1b86a67b66036374add98c0d7e",
|
||||
"internal/middleware/registryGen.go": "a6122a5453dedc6a4415860108814c50e155128cfe5412b39dd33ce2d01b4b00",
|
||||
|
||||
@@ -29,7 +29,7 @@ go run ./cmd/server
|
||||
## 接口
|
||||
|
||||
除健康检查、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 /openapi.json | 与 docs/openapi.json 一致 |
|
||||
|
||||
参数不合法返回 400,认证失败 401,邮箱重复 409,内部故障只返回通用 500。
|
||||
OpenAPI 的业务数据类型来自契约;401/404/409 的业务错误码见上表。
|
||||
参数不合法返回 400,认证失败 401,只读身份写入 403,邮箱重复 409,内部故障只返回通用 500。
|
||||
OpenAPI 的数据类型、鉴权和业务错误均来自契约;401/403/404/409 带具体命名示例。业务使用生成的 `apierrors.UserNotFound()`、`apierrors.EmailTaken()` 等函数。
|
||||
|
||||
创建、更新、删除都会在**同一个 MySQL 事务**写入 user_audit;审计写入失败时业务变更一起回滚。
|
||||
事务提交后才写成功业务日志,requestId 同时出现在 HTTP 响应、业务日志和审计记录。
|
||||
@@ -57,8 +57,10 @@ OpenAPI 的业务数据类型来自契约;401/404/409 的业务错误码见上
|
||||
## 开发与验证
|
||||
|
||||
```sh
|
||||
go-omni api generate --api api/user.api --dir .
|
||||
go-omni api generate --api api/user.api --dir . --check
|
||||
go-omni api inspect
|
||||
go-omni api generate --dry-run
|
||||
go-omni api generate
|
||||
go-omni api generate --check
|
||||
go test ./...
|
||||
```
|
||||
|
||||
@@ -70,7 +72,7 @@ Gen.go 和 docs/openapi.json 由生成器维护;Service、Repository、迁移
|
||||
```sh
|
||||
python3 scripts/verifyAPIWorkflow.py
|
||||
# 已发布版本:全新 module cache 安装 CLI,无本地 replace 消费框架。
|
||||
python3 scripts/verifyAPIWorkflow.py --version v0.7.0-preview \
|
||||
python3 scripts/verifyAPIWorkflow.py --version v0.7.0-preview.1 \
|
||||
--report performance/apiWorkflowRemote.json
|
||||
```
|
||||
|
||||
|
||||
@@ -3,6 +3,20 @@ info (
|
||||
title: "User service reference"
|
||||
version: "1.0.0"
|
||||
)
|
||||
// Stable public errors are shared by Go code and the API document.
|
||||
error (
|
||||
Unauthenticated 401 "UNAUTHENTICATED" "authentication required"
|
||||
Forbidden 403 "FORBIDDEN" "permission denied"
|
||||
UserNotFound 404 "USER_NOT_FOUND" "user not found"
|
||||
EmailTaken 409 "EMAIL_TAKEN" "email already in use"
|
||||
InvalidUser 400 "INVALID_USER" "invalid user"
|
||||
InvalidPage 400 "INVALID_PAGE" "invalid pagination"
|
||||
)
|
||||
auth AdminAuth (
|
||||
type: bearer
|
||||
middleware: Auth
|
||||
description: "Use an administrator token for writes, or a read-only token for GET requests"
|
||||
)
|
||||
type (
|
||||
UserResp {
|
||||
ID int64 `json:"id"`
|
||||
@@ -40,21 +54,33 @@ type (
|
||||
@server (
|
||||
group: user
|
||||
prefix: /api/v1
|
||||
middleware: Auth
|
||||
auth: AdminAuth
|
||||
errors: Unauthenticated
|
||||
)
|
||||
service user-api {
|
||||
@doc (
|
||||
summary: "Create a user"
|
||||
description: "Normalizes the email, then commits user data and audit in one transaction"
|
||||
tags: users
|
||||
)
|
||||
@errors Forbidden, InvalidUser, EmailTaken
|
||||
@handler createUser
|
||||
@status 201
|
||||
post /users (CreateUserReq) returns (UserResp)
|
||||
@errors UserNotFound
|
||||
@handler getUser
|
||||
get /users/:id (UserIDReq) returns (UserResp)
|
||||
@errors InvalidPage
|
||||
@handler listUsers
|
||||
get /users (ListUsersReq) returns (UserPageResp)
|
||||
@errors Forbidden, InvalidUser, UserNotFound, EmailTaken
|
||||
@handler updateUser
|
||||
put /users/:id (UpdateUserReq) returns (UserResp)
|
||||
@errors Forbidden, UserNotFound
|
||||
@handler deleteUser
|
||||
delete /users/:id (UserIDReq)
|
||||
@doc "Export one user as a private text object with a short-lived download link"
|
||||
@errors Forbidden, UserNotFound
|
||||
@handler exportUser
|
||||
post /users/:id/export (UserIDReq) returns (ExportResp)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"components": {
|
||||
"securitySchemes": {
|
||||
"bearerAuth": {
|
||||
"AdminAuth": {
|
||||
"description": "Use an administrator token for writes, or a read-only token for GET requests",
|
||||
"scheme": "bearer",
|
||||
"type": "http"
|
||||
}
|
||||
@@ -128,6 +129,16 @@
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"InvalidPage": {
|
||||
"summary": "invalid pagination",
|
||||
"value": {
|
||||
"code": "INVALID_PAGE",
|
||||
"message": "invalid pagination",
|
||||
"requestId": "exampleRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"code": {
|
||||
@@ -148,7 +159,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error"
|
||||
"description": "INVALID_PAGE: invalid pagination"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"Unauthenticated": {
|
||||
"summary": "authentication required",
|
||||
"value": {
|
||||
"code": "UNAUTHENTICATED",
|
||||
"message": "authentication required",
|
||||
"requestId": "exampleRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "UNAUTHENTICATED: authentication required"
|
||||
},
|
||||
"413": {
|
||||
"content": {
|
||||
@@ -251,12 +297,25 @@
|
||||
"description": "Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"AdminAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "",
|
||||
"tags": [
|
||||
"user"
|
||||
],
|
||||
"x-omni-errors": [
|
||||
"Unauthenticated",
|
||||
"InvalidPage"
|
||||
],
|
||||
"x-omni-middleware": [
|
||||
"Auth"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"description": "Normalizes the email, then commits user data and audit in one transaction",
|
||||
"operationId": "user_CreateUser",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -345,6 +404,16 @@
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"InvalidUser": {
|
||||
"summary": "invalid user",
|
||||
"value": {
|
||||
"code": "INVALID_USER",
|
||||
"message": "invalid user",
|
||||
"requestId": "exampleRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"code": {
|
||||
@@ -365,7 +434,118 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error"
|
||||
"description": "INVALID_USER: invalid user"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"Unauthenticated": {
|
||||
"summary": "authentication required",
|
||||
"value": {
|
||||
"code": "UNAUTHENTICATED",
|
||||
"message": "authentication required",
|
||||
"requestId": "exampleRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "UNAUTHENTICATED: authentication required"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"Forbidden": {
|
||||
"summary": "permission denied",
|
||||
"value": {
|
||||
"code": "FORBIDDEN",
|
||||
"message": "permission denied",
|
||||
"requestId": "exampleRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"code": {
|
||||
"enum": [
|
||||
"FORBIDDEN"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "FORBIDDEN: permission denied"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"EmailTaken": {
|
||||
"summary": "email already in use",
|
||||
"value": {
|
||||
"code": "EMAIL_TAKEN",
|
||||
"message": "email already in use",
|
||||
"requestId": "exampleRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"code": {
|
||||
"enum": [
|
||||
"EMAIL_TAKEN"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "EMAIL_TAKEN: email already in use"
|
||||
},
|
||||
"413": {
|
||||
"content": {
|
||||
@@ -468,7 +648,21 @@
|
||||
"description": "Error"
|
||||
}
|
||||
},
|
||||
"summary": "",
|
||||
"security": [
|
||||
{
|
||||
"AdminAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "Create a user",
|
||||
"tags": [
|
||||
"users"
|
||||
],
|
||||
"x-omni-errors": [
|
||||
"Unauthenticated",
|
||||
"Forbidden",
|
||||
"InvalidUser",
|
||||
"EmailTaken"
|
||||
],
|
||||
"x-omni-middleware": [
|
||||
"Auth"
|
||||
]
|
||||
@@ -519,6 +713,117 @@
|
||||
},
|
||||
"description": "Error"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"Unauthenticated": {
|
||||
"summary": "authentication required",
|
||||
"value": {
|
||||
"code": "UNAUTHENTICATED",
|
||||
"message": "authentication required",
|
||||
"requestId": "exampleRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "UNAUTHENTICATED: authentication required"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"Forbidden": {
|
||||
"summary": "permission denied",
|
||||
"value": {
|
||||
"code": "FORBIDDEN",
|
||||
"message": "permission denied",
|
||||
"requestId": "exampleRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"code": {
|
||||
"enum": [
|
||||
"FORBIDDEN"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "FORBIDDEN: permission denied"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"UserNotFound": {
|
||||
"summary": "user not found",
|
||||
"value": {
|
||||
"code": "USER_NOT_FOUND",
|
||||
"message": "user not found",
|
||||
"requestId": "exampleRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"code": {
|
||||
"enum": [
|
||||
"USER_NOT_FOUND"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "USER_NOT_FOUND: user not found"
|
||||
},
|
||||
"413": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
@@ -620,7 +925,20 @@
|
||||
"description": "Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"AdminAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "",
|
||||
"tags": [
|
||||
"user"
|
||||
],
|
||||
"x-omni-errors": [
|
||||
"Unauthenticated",
|
||||
"Forbidden",
|
||||
"UserNotFound"
|
||||
],
|
||||
"x-omni-middleware": [
|
||||
"Auth"
|
||||
]
|
||||
@@ -721,6 +1039,79 @@
|
||||
},
|
||||
"description": "Error"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"Unauthenticated": {
|
||||
"summary": "authentication required",
|
||||
"value": {
|
||||
"code": "UNAUTHENTICATED",
|
||||
"message": "authentication required",
|
||||
"requestId": "exampleRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "UNAUTHENTICATED: authentication required"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"UserNotFound": {
|
||||
"summary": "user not found",
|
||||
"value": {
|
||||
"code": "USER_NOT_FOUND",
|
||||
"message": "user not found",
|
||||
"requestId": "exampleRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"code": {
|
||||
"enum": [
|
||||
"USER_NOT_FOUND"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "USER_NOT_FOUND: user not found"
|
||||
},
|
||||
"413": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
@@ -822,7 +1213,19 @@
|
||||
"description": "Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"AdminAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "",
|
||||
"tags": [
|
||||
"user"
|
||||
],
|
||||
"x-omni-errors": [
|
||||
"Unauthenticated",
|
||||
"UserNotFound"
|
||||
],
|
||||
"x-omni-middleware": [
|
||||
"Auth"
|
||||
]
|
||||
@@ -929,6 +1332,16 @@
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"InvalidUser": {
|
||||
"summary": "invalid user",
|
||||
"value": {
|
||||
"code": "INVALID_USER",
|
||||
"message": "invalid user",
|
||||
"requestId": "exampleRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"code": {
|
||||
@@ -949,7 +1362,156 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error"
|
||||
"description": "INVALID_USER: invalid user"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"Unauthenticated": {
|
||||
"summary": "authentication required",
|
||||
"value": {
|
||||
"code": "UNAUTHENTICATED",
|
||||
"message": "authentication required",
|
||||
"requestId": "exampleRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "UNAUTHENTICATED: authentication required"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"Forbidden": {
|
||||
"summary": "permission denied",
|
||||
"value": {
|
||||
"code": "FORBIDDEN",
|
||||
"message": "permission denied",
|
||||
"requestId": "exampleRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"code": {
|
||||
"enum": [
|
||||
"FORBIDDEN"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "FORBIDDEN: permission denied"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"UserNotFound": {
|
||||
"summary": "user not found",
|
||||
"value": {
|
||||
"code": "USER_NOT_FOUND",
|
||||
"message": "user not found",
|
||||
"requestId": "exampleRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"code": {
|
||||
"enum": [
|
||||
"USER_NOT_FOUND"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "USER_NOT_FOUND: user not found"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"EmailTaken": {
|
||||
"summary": "email already in use",
|
||||
"value": {
|
||||
"code": "EMAIL_TAKEN",
|
||||
"message": "email already in use",
|
||||
"requestId": "exampleRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"code": {
|
||||
"enum": [
|
||||
"EMAIL_TAKEN"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "EMAIL_TAKEN: email already in use"
|
||||
},
|
||||
"413": {
|
||||
"content": {
|
||||
@@ -1052,7 +1614,22 @@
|
||||
"description": "Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"AdminAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "",
|
||||
"tags": [
|
||||
"user"
|
||||
],
|
||||
"x-omni-errors": [
|
||||
"Unauthenticated",
|
||||
"Forbidden",
|
||||
"InvalidUser",
|
||||
"UserNotFound",
|
||||
"EmailTaken"
|
||||
],
|
||||
"x-omni-middleware": [
|
||||
"Auth"
|
||||
]
|
||||
@@ -1138,6 +1715,117 @@
|
||||
},
|
||||
"description": "Error"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"Unauthenticated": {
|
||||
"summary": "authentication required",
|
||||
"value": {
|
||||
"code": "UNAUTHENTICATED",
|
||||
"message": "authentication required",
|
||||
"requestId": "exampleRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "UNAUTHENTICATED: authentication required"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"Forbidden": {
|
||||
"summary": "permission denied",
|
||||
"value": {
|
||||
"code": "FORBIDDEN",
|
||||
"message": "permission denied",
|
||||
"requestId": "exampleRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"code": {
|
||||
"enum": [
|
||||
"FORBIDDEN"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "FORBIDDEN: permission denied"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"UserNotFound": {
|
||||
"summary": "user not found",
|
||||
"value": {
|
||||
"code": "USER_NOT_FOUND",
|
||||
"message": "user not found",
|
||||
"requestId": "exampleRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"properties": {
|
||||
"code": {
|
||||
"enum": [
|
||||
"USER_NOT_FOUND"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"requestId": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "USER_NOT_FOUND: user not found"
|
||||
},
|
||||
"413": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
@@ -1239,11 +1927,62 @@
|
||||
"description": "Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"AdminAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "Export one user as a private text object with a short-lived download link",
|
||||
"tags": [
|
||||
"user"
|
||||
],
|
||||
"x-omni-errors": [
|
||||
"Unauthenticated",
|
||||
"Forbidden",
|
||||
"UserNotFound"
|
||||
],
|
||||
"x-omni-middleware": [
|
||||
"Auth"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-omni-errors": [
|
||||
{
|
||||
"name": "Unauthenticated",
|
||||
"status": 401,
|
||||
"code": "UNAUTHENTICATED",
|
||||
"message": "authentication required"
|
||||
},
|
||||
{
|
||||
"name": "Forbidden",
|
||||
"status": 403,
|
||||
"code": "FORBIDDEN",
|
||||
"message": "permission denied"
|
||||
},
|
||||
{
|
||||
"name": "UserNotFound",
|
||||
"status": 404,
|
||||
"code": "USER_NOT_FOUND",
|
||||
"message": "user not found"
|
||||
},
|
||||
{
|
||||
"name": "EmailTaken",
|
||||
"status": 409,
|
||||
"code": "EMAIL_TAKEN",
|
||||
"message": "email already in use"
|
||||
},
|
||||
{
|
||||
"name": "InvalidUser",
|
||||
"status": 400,
|
||||
"code": "INVALID_USER",
|
||||
"message": "invalid user"
|
||||
},
|
||||
{
|
||||
"name": "InvalidPage",
|
||||
"status": 400,
|
||||
"code": "INVALID_PAGE",
|
||||
"message": "invalid pagination"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ module example.com/userService
|
||||
go 1.22.0
|
||||
|
||||
require (
|
||||
git.yuntuo.hk/k2safe/go-omni v0.7.0-preview
|
||||
git.yuntuo.hk/k2safe/go-omni v0.7.0-preview.1
|
||||
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/subtle"
|
||||
"errors"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"example.com/userService/internal/apierrors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Auth is the fail-closed registry default. Startup replaces it with configured
|
||||
// authentication. This administrative example uses one environment-only key;
|
||||
// authentication. This example uses an administrator key and an optional read key;
|
||||
// production applications can inject JWT/OIDC through the same middleware slot.
|
||||
func Auth(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication required")
|
||||
return apierrors.Unauthenticated()
|
||||
}
|
||||
}
|
||||
func NewAuthFromEnv() (httpx.Middleware, error) {
|
||||
@@ -23,16 +23,26 @@ func NewAuthFromEnv() (httpx.Middleware, error) {
|
||||
if len(token) < 32 || strings.ContainsAny(token, " \t\r\n") {
|
||||
return nil, errors.New("USER_SERVICE_API_TOKEN requires at least 32 bytes without whitespace")
|
||||
}
|
||||
readToken := os.Getenv("USER_SERVICE_READ_TOKEN")
|
||||
if readToken != "" && (len(readToken) < 32 || strings.ContainsAny(readToken, " \t\r\n") || readToken == token) {
|
||||
return nil, errors.New("USER_SERVICE_READ_TOKEN must be a distinct token of at least 32 bytes without whitespace")
|
||||
}
|
||||
expectedRead := sha256.Sum256([]byte(readToken))
|
||||
expected := sha256.Sum256([]byte(token))
|
||||
return func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
parts := strings.Fields(c.Header("Authorization"))
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication required")
|
||||
return apierrors.Unauthenticated()
|
||||
}
|
||||
actual := sha256.Sum256([]byte(parts[1]))
|
||||
if subtle.ConstantTimeCompare(expected[:], actual[:]) != 1 {
|
||||
return apierrors.Unauthorized("UNAUTHENTICATED", "authentication required")
|
||||
admin := subtle.ConstantTimeCompare(expected[:], actual[:]) == 1
|
||||
read := subtle.ConstantTimeCompare(expectedRead[:], actual[:]) == 1 && readToken != ""
|
||||
if !admin && !read {
|
||||
return apierrors.Unauthenticated()
|
||||
}
|
||||
if !admin && c.Request().Method != "GET" && c.Request().Method != "HEAD" {
|
||||
return apierrors.Forbidden()
|
||||
}
|
||||
return next(c)
|
||||
}
|
||||
|
||||
@@ -33,3 +33,31 @@ func TestAuthFailsClosedAndDoesNotLeakKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadOnlyTokenCanReadButCannotWrite(t *testing.T) {
|
||||
admin := "admin-012345678901234567890123456789"
|
||||
read := "read-012345678901234567890123456789"
|
||||
t.Setenv("USER_SERVICE_API_TOKEN", admin)
|
||||
t.Setenv("USER_SERVICE_READ_TOKEN", read)
|
||||
middleware, err := NewAuthFromEnv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, method := range []string{"GET", "POST", "PUT", "DELETE"} {
|
||||
called := false
|
||||
r := httptest.NewRequest(method, "/api/v1/users", nil)
|
||||
r.Header.Set("Authorization", "Bearer "+read)
|
||||
err = middleware(func(*httpx.Context) error { called = true; return nil })(httpx.NewContext(r, httptest.NewRecorder(), nil))
|
||||
if method == "GET" {
|
||||
if err != nil || !called {
|
||||
t.Fatal("read token rejected on GET")
|
||||
}
|
||||
} else if err == nil || called || apierrors.From(err).Status != 403 {
|
||||
t.Fatal("read token reached a write")
|
||||
}
|
||||
}
|
||||
t.Setenv("USER_SERVICE_READ_TOKEN", admin)
|
||||
if _, err = NewAuthFromEnv(); err == nil {
|
||||
t.Fatal("identical privilege tokens accepted")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ package user
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"example.com/userService/internal/apierrors"
|
||||
"example.com/userService/internal/model"
|
||||
repo "example.com/userService/internal/repository/user"
|
||||
"example.com/userService/internal/servicecontext"
|
||||
"example.com/userService/internal/types"
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
"net/mail"
|
||||
"strings"
|
||||
@@ -34,19 +35,19 @@ func (s *Service) failure(ctx context.Context, err error) error {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, model.ErrUserNotFound):
|
||||
return apierrors.NotFound("USER_NOT_FOUND", "user not found")
|
||||
return apierrors.UserNotFound()
|
||||
case errors.Is(err, model.ErrEmailTaken):
|
||||
return apierrors.New(409, "EMAIL_TAKEN", "email already in use")
|
||||
return apierrors.EmailTaken()
|
||||
case errors.Is(err, model.ErrInvalidUser):
|
||||
return apierrors.BadRequest("INVALID_USER", "invalid user")
|
||||
return apierrors.InvalidUser()
|
||||
case errors.Is(err, model.ErrInvalidPage):
|
||||
return apierrors.BadRequest("INVALID_PAGE", "invalid pagination")
|
||||
return apierrors.InvalidPage()
|
||||
default:
|
||||
// Do not log SQL/DSN or user input; requestId correlates the failed operation.
|
||||
s.deps.Logger.ErrorContext(ctx, "user.operationFailed", "category", "persistence")
|
||||
// HTTP access logging also sees the returned error. Keep raw driver
|
||||
// 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) {
|
||||
|
||||
Reference in New Issue
Block a user