init project

This commit is contained in:
王性驊 2026-05-19 19:00:28 +08:00
commit ea4f45f949
78 changed files with 7803 additions and 0 deletions

72
.gitignore vendored Normal file
View File

@ -0,0 +1,72 @@
# =========================
# Go
# =========================
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
coverage.txt
coverage.html
/vendor/
# 專案編譯產物(根目錄 binary 名稱與 module 相同時)
/gateway
# =========================
# go-doc / 工具 binary
# =========================
generate/doc-generate/bin/
# =========================
# OpenAPI 產物make gen-doc
# 若要把 gateway.yaml 提交給前端,請註解下面這段
# =========================
docs/openapi/*.yaml
docs/openapi/*.json
!docs/openapi/.gitkeep
# =========================
# 設定與機密(勿提交真實密鑰)
# =========================
.env
.env.*
!.env.example
etc/*-local.yaml
etc/*.local.yaml
# =========================
# IDE / 編輯器
# =========================
.idea/
.vscode/
*.swp
*.swo
*~
.cursor/
# =========================
# 作業系統
# =========================
.DS_Store
.AppleDouble
.LSOverride
Thumbs.db
ehthumbs.db
Desktop.ini
# =========================
# 日誌與暫存
# =========================
*.log
logs/
tmp/
temp/
# =========================
# Go workspace本機多模組開發用
# =========================
go.work
go.work.sum

27
Makefile Normal file
View File

@ -0,0 +1,27 @@
# go-zero 生成風格
GO_ZERO_STYLE=go_zero
GO ?= go
GOFMT ?= gofmt "-s"
GOFILES := $(shell find . -name "*.go")
LDFLAGS := -s -w
VERSION="v0.0.1"
DOCKER_REP="reg.wang/app-cloudep-member-service"
GIT_COMMIT ?= $(shell git rev-parse --short HEAD)
GO_DOC_DIR := generate/doc-generate
GO_DOC_BIN := $(GO_DOC_DIR)/bin/go-doc
API_ENTRY := ./generate/api/gateway.api
DOC_OUT := ./docs/openapi
.PHONY: gen-api build-go-doc gen-doc
gen-api: # 使用專案 handler 模板response.Write
goctl api go -api $(API_ENTRY) -dir . -style go_zero -home generate/goctl
build-go-doc: ## 編譯 go-docOpenAPI 文件生成器)
cd $(GO_DOC_DIR) && GOTOOLCHAIN=go1.26.1 go build -o bin/go-doc ./cmd/go-doc
gen-doc: build-go-doc ## 從 .api 生成 OpenAPI 3.0 YAML
@mkdir -p $(DOC_OUT)
$(GO_DOC_BIN) -a $(API_ENTRY) -d $(DOC_OUT) -f gateway -s openapi3.0 -y
@echo "Generated: $(DOC_OUT)/gateway.yaml"

0
docs/openapi/.gitkeep Normal file
View File

3
etc/gateway.yaml Normal file
View File

@ -0,0 +1,3 @@
Name: gateway
Host: 0.0.0.0
Port: 8888

34
gateway.go Normal file
View File

@ -0,0 +1,34 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package main
import (
"flag"
"fmt"
"gateway/internal/config"
"gateway/internal/handler"
"gateway/internal/svc"
"github.com/zeromicro/go-zero/core/conf"
"github.com/zeromicro/go-zero/rest"
)
var configFile = flag.String("f", "etc/gateway.yaml", "the config file")
func main() {
flag.Parse()
var c config.Config
conf.MustLoad(*configFile, &c)
server := rest.MustNewServer(c.RestConf)
defer server.Stop()
ctx := svc.NewServiceContext(c)
handler.RegisterHandlers(server, ctx)
fmt.Printf("Starting server at %s:%d...\n", c.Host, c.Port)
server.Start()
}

33
generate/api/README.md Normal file
View File

@ -0,0 +1,33 @@
# API 定義goctl + go-doc 共用)
## 檔案
| 檔案 | 用途 |
|------|------|
| `gateway.api` | 入口:`info()` + `import` |
| `common.api` | 共用文件型別(`APIErrorStatus`、`ErrorDetail` |
| `normal.api` | 路由與業務 `data` 型別 |
## 指令
```bash
make gen-api # 生成 handler / logic / types
make gen-doc # 生成 docs/openapi/gateway.yamlOpenAPI 3.0
```
## 註解約定
- **Logic `returns`**:只寫業務 data`PingData`
- **文件 `@respdoc`**:寫實際 HTTP JSON`PingOKStatus`、`APIErrorStatus`
- **`@doc`**:單一 API 的 summary / description
- 多狀態碼用 `/* @respdoc-200 ... */` 區塊,放在 `@handler`
## 與 runtime 對齊
Handler 使用 `response.Write` 輸出:
```json
{ "code": 0, "message": "SUCCESS", "data": { ... } }
```
失敗時含 `error.biz_code` 等欄位,與 `common.api` 定義一致。

19
generate/api/common.api Normal file
View File

@ -0,0 +1,19 @@
syntax = "v1"
// 文件與實際 HTTP 回應共用結構handler 透過 response.Write 輸出)
type (
// ErrorDetail 失敗時 error 欄位
ErrorDetail {
BizCode string `json:"biz_code"`
Scope uint32 `json:"scope,omitempty"`
Category uint32 `json:"category,omitempty"`
Detail uint32 `json:"detail,omitempty"`
}
// APIErrorStatus 失敗回應 envelopeHTTP 4xx/5xx
APIErrorStatus {
Code int64 `json:"code"`
Message string `json:"message"`
Error ErrorDetail `json:"error"`
}
)

20
generate/api/gateway.api Normal file
View File

@ -0,0 +1,20 @@
syntax = "v1"
info (
title: "Portal-Api-Gateway (PGW)"
desc: "Digimon web portal API gateway"
author: "daniel Wang"
email: "igs170911@gmail.com"
version: "0.0.1"
host: "127.0.0.1:8888"
schemes: "http,https"
consumes: "application/json"
produces: "application/json"
useDefinitions: true
)
import (
"common.api"
"normal.api"
)

33
generate/api/normal.api Normal file
View File

@ -0,0 +1,33 @@
syntax = "v1"
// 業務 dataLogic returns 型別HTTP 外層 envelope 見 @respdoc
type PingData {
Pong string `json:"pong"`
}
// 文件用:成功回應 envelopecode=0, message=SUCCESS, data=PingData
type PingOKStatus {
Code int64 `json:"code"`
Message string `json:"message"`
Data PingData `json:"data"`
}
@server(
group: normal
prefix: /api/v1
schemes: https
timeout: 3s
)
service gateway {
@doc(
summary: "Ping"
description: "確認伺服器狀態"
)
/*
@respdoc-200 (PingOKStatus) // 成功
@respdoc-400 (APIErrorStatus) // 參數錯誤(如 httpx.Parse / 驗證失敗)
@respdoc-500 (APIErrorStatus) // 系統內部錯誤
*/
@handler ping
get /health () returns (PingData)
}

30
generate/doc-generate/.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Binaries
bin/
# Test binary
*.test
# Output coverage files
*.out
coverage.txt
coverage.html
# Go workspace file
go.work
go.work.sum
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Output files
example/test_output/
test_output_verification/
.idea/

View File

@ -0,0 +1,114 @@
.PHONY: build clean test install run fmt lint help build-all example gen-gateway deps
# Variables
BINARY_NAME=go-doc
BUILD_DIR=bin
MAIN_PATH=./cmd/go-doc
VERSION?=1.0.0
COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "dev")
BUILD_DATE=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(BUILD_DATE)"
help: ## Display this help screen
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
build: ## Build the binary for the current platform
@echo "Building $(BINARY_NAME)..."
@mkdir -p $(BUILD_DIR)
@go build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) $(MAIN_PATH)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)"
clean: ## Remove build artifacts
@echo "Cleaning..."
@rm -rf $(BUILD_DIR)
@rm -rf example/test_output
@echo "Clean complete"
test: ## Run tests
@echo "Running tests..."
@go test -v -race -coverprofile=coverage.txt -covermode=atomic ./...
@echo "Tests complete"
install: ## Install the binary to GOPATH/bin (use directly as go-doc command)
@echo "Installing $(BINARY_NAME) to GOPATH/bin..."
@go install $(LDFLAGS) $(MAIN_PATH)
@echo "Install complete. Run: go-doc --help"
run: build ## Build and run with example API file
@echo "Running $(BINARY_NAME)..."
@$(BUILD_DIR)/$(BINARY_NAME) -a example/example_cn.api -d example/test_output -f example -s openapi3.0
@echo "Generated: example/test_output/example.json"
fmt: ## Format code
@echo "Formatting code..."
@gofmt -s -w .
@goimports -w .
@echo "Format complete"
lint: ## Run linters
@echo "Running linters..."
@golangci-lint run || (echo "golangci-lint not found, skipping..." && exit 0)
@echo "Lint complete"
deps: ## Download dependencies
@echo "Downloading dependencies..."
@go mod download
@go mod tidy
@echo "Dependencies updated"
# Build for multiple platforms
# Output layout:
# bin/darwin/amd64/go-doc
# bin/darwin/arm64/go-doc
# bin/linux/amd64/go-doc
# bin/linux/arm64/go-doc
# bin/windows/amd64/go-doc.exe
# Copy the binary for your OS/arch to a directory in your PATH and use it as: go-doc
build-all: ## Build for macOS, Linux, and Windows (amd64 + arm64)
@echo "Building for all platforms..."
@mkdir -p $(BUILD_DIR)/darwin/amd64 $(BUILD_DIR)/darwin/arm64
@mkdir -p $(BUILD_DIR)/linux/amd64 $(BUILD_DIR)/linux/arm64
@mkdir -p $(BUILD_DIR)/windows/amd64
@echo " darwin/amd64..."
@GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/darwin/amd64/$(BINARY_NAME) $(MAIN_PATH)
@echo " darwin/arm64 (Apple Silicon)..."
@GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o $(BUILD_DIR)/darwin/arm64/$(BINARY_NAME) $(MAIN_PATH)
@echo " linux/amd64..."
@GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/linux/amd64/$(BINARY_NAME) $(MAIN_PATH)
@echo " linux/arm64..."
@GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o $(BUILD_DIR)/linux/arm64/$(BINARY_NAME) $(MAIN_PATH)
@echo " windows/amd64..."
@GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/windows/amd64/$(BINARY_NAME).exe $(MAIN_PATH)
@echo ""
@echo "Build complete. Binaries:"
@echo " macOS Intel : $(BUILD_DIR)/darwin/amd64/$(BINARY_NAME)"
@echo " macOS Apple : $(BUILD_DIR)/darwin/arm64/$(BINARY_NAME)"
@echo " Linux amd64 : $(BUILD_DIR)/linux/amd64/$(BINARY_NAME)"
@echo " Linux arm64 : $(BUILD_DIR)/linux/arm64/$(BINARY_NAME)"
@echo " Windows amd64: $(BUILD_DIR)/windows/amd64/$(BINARY_NAME).exe"
@echo ""
@echo "Copy the binary for your platform to a directory in PATH to use: go-doc --help"
example: build ## Generate example swagger and openapi files
@echo "Generating examples..."
@mkdir -p example/test_output
@echo " - Swagger 2.0 (JSON)..."
@$(BUILD_DIR)/$(BINARY_NAME) -a example/example.api -d example/test_output -f example_swagger2
@echo " - Swagger 2.0 (YAML)..."
@$(BUILD_DIR)/$(BINARY_NAME) -a example/example.api -d example/test_output -f example_swagger2 -y
@echo " - OpenAPI 3.0 (JSON)..."
@$(BUILD_DIR)/$(BINARY_NAME) -a example/example.api -d example/test_output -f example_openapi3 -s openapi3.0
@echo " - OpenAPI 3.0 (YAML)..."
@$(BUILD_DIR)/$(BINARY_NAME) -a example/example.api -d example/test_output -f example_openapi3 -s openapi3.0 -y
@echo " - Chinese Swagger 2.0..."
@$(BUILD_DIR)/$(BINARY_NAME) -a example/example_cn.api -d example/test_output -f example_cn_swagger2
@echo " - Chinese OpenAPI 3.0..."
@$(BUILD_DIR)/$(BINARY_NAME) -a example/example_cn.api -d example/test_output -f example_cn_openapi3 -s openapi3.0
@echo "Examples generated in example/test_output/"
gen-gateway: build ## Generate Gateway API documentation (OpenAPI 3.0)
@echo "Generating Gateway API documentation..."
@mkdir -p example/test_output
@$(BUILD_DIR)/$(BINARY_NAME) -a example/api/gateway.api -d example/test_output -f gateway -s openapi3.0
@echo "Generated: example/test_output/gateway.json"

View File

@ -0,0 +1,863 @@
# go-doc
[![Go Version](https://img.shields.io/badge/Go-%3E%3D%201.23-blue)](https://go.dev/)
[![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
[![Version](https://img.shields.io/badge/version-1.2.0-blue)](CHANGELOG.md)
**go-doc** 是一個獨立的命令行工具,專門用於將 [go-zero](https://github.com/zeromicro/go-zero) `.api` 文件轉換為 OpenAPI 規範文檔。
原本是 go-zero 專案的一部分,現在已獨立出來,成為一個易於使用的工具,支援生成 **Swagger 2.0****OpenAPI 3.0** 兩種格式。
---
## 目錄
- [特性](#特性)
- [安裝](#安裝)
- [快速開始](#快速開始)
- [API 文件格式](#api-文件格式)
- [@respdoc 多狀態碼回應](#respdoc-多狀態碼回應)
- [進階功能](#進階功能)
- [OpenAPI 3.0 vs Swagger 2.0](#openapi-30-vs-swagger-20)
- [使用範例](#使用範例)
- [測試](#測試)
- [專案結構](#專案結構)
- [從 go-zero 遷移](#從-go-zero-遷移)
- [版本記錄](#版本記錄)
- [常見問題](#常見問題)
- [貢獻](#貢獻)
- [授權](#授權)
---
## 特性
- **獨立二進制** - 無需依賴 go-zero 運行時
- **雙規格支援** - 生成 **Swagger 2.0****OpenAPI 3.0** 格式
- **豐富的類型支援** - 處理結構體、陣列、映射、指針和嵌套類型
- **基於標籤的配置** - 支援 `json`、`form`、`path`、`header` 標籤
- **進階驗證** - 範圍、列舉、預設值、範例值
- **安全定義** - 自訂身份驗證配置
- **多種輸出格式** - JSON 或 YAML 輸出
- **定義引用** - 可選使用 definitions/schemas 使輸出更簡潔
- **自動轉換** - Swagger 2.0 到 OpenAPI 3.0 的無縫轉換
- **多狀態碼回應** - 使用 `@respdoc` 定義多個 HTTP 狀態碼
- **業務錯誤碼映射** - 將業務錯誤碼映射到不同的錯誤類型OpenAPI 3.0 使用 `oneOf`
---
## 安裝
### 從源碼編譯
```bash
git clone <your-repo>
cd go-doc
make install
```
編譯完成後,可以直接 go-doc 做使用
---
## 快速開始
### 基本使用
```bash
# 生成 Swagger 2.0(預設)
go-doc -a example/example.api -d output
# 生成 OpenAPI 3.0(推薦)
go-doc -a example/example.api -d output -s openapi3.0
# 生成 YAML 格式
go-doc -a example/example.api -d output -y
# 生成 OpenAPI 3.0 YAML
go-doc -a example/example.api -d output -s openapi3.0 -y
# 自訂檔名
go-doc -a example/example.api -d output -f my-api
```
### 命令行選項
```
Flags:
-a, --api string API 文件路徑(必要)
-d, --dir string 輸出目錄(必要)
-f, --filename string 輸出檔名(不含副檔名)
-h, --help 顯示說明
-s, --spec-version string OpenAPI 規格版本swagger2.0 或 openapi3.0預設swagger2.0
-v, --version 顯示版本
-y, --yaml 生成 YAML 格式預設JSON
```
### 使用 Makefile
```bash
# 編譯
make build
# 生成範例
make example
# 清理
make clean
# 運行測試
make test
# 查看所有命令
make help
```
---
## API 文件格式
### 基本結構
```go
syntax = "v1"
info (
title: "My API"
description: "API documentation"
version: "v1.0.0"
host: "api.example.com"
basePath: "/v1"
)
type (
UserRequest {
Id int `json:"id,range=[1:10000]"`
Name string `json:"name"`
}
UserResponse {
Id int `json:"id"`
Name string `json:"name"`
}
)
@server (
tags: "user"
)
service MyAPI {
@handler getUser
get /user/:id (UserRequest) returns (UserResponse)
}
```
### 支援的 Info 屬性
- `title` - API 標題
- `description` - API 描述
- `version` - API 版本
- `host` - API 主機(如 "api.example.com"
- `basePath` - 基礎路徑(如 "/v1"
- `schemes` - 協議(如 "http,https"
- `consumes` - 請求內容類型
- `produces` - 回應內容類型
- `contactName`, `contactURL`, `contactEmail` - 聯絡資訊
- `licenseName`, `licenseURL` - 授權資訊
- `useDefinitions` - 使用 Swagger definitionstrue/false
- `wrapCodeMsg` - 將回應包裝在 `{code, msg, data}` 結構中
- `securityDefinitionsFromJson` - JSON 格式的安全定義
### 標籤選項
#### JSON 標籤
```go
type Example {
// 範圍驗證
Age int `json:"age,range=[1:150]"`
// 預設值
Status string `json:"status,default=active"`
// 範例值
Email string `json:"email,example=user@example.com"`
// 列舉值
Role string `json:"role,options=admin|user|guest"`
// 可選欄位
Phone string `json:"phone,optional"`
}
```
#### Form 標籤
```go
type QueryRequest {
Keyword string `form:"keyword"`
Page int `form:"page,default=1"`
Size int `form:"size,range=[1:100]"`
}
```
#### Path 標籤
```go
type PathRequest {
UserId int `path:"userId,range=[1:999999]"`
}
```
#### Header 標籤
```go
type HeaderRequest {
Token string `header:"Authorization"`
}
```
---
## @respdoc 多狀態碼回應
### 概述
`@respdoc` 註解允許您為單個 API 端點定義多個 HTTP 狀態碼的回應,並支援為同一狀態碼定義多種業務錯誤格式。
**重要特性:**
- 支援多個 HTTP 狀態碼200, 201, 400, 401, 404, 500 等)
- 支援業務錯誤碼映射(如 300101, 300102 等)
- **OpenAPI 3.0 使用 `oneOf` 表示多種錯誤格式**
- Swagger 2.0 在描述中列出所有可能的錯誤
### 基本語法
#### 單一回應類型
```go
service MyAPI {
@doc (
description: "用戶查詢介面"
)
/*
@respdoc-200 (UserResponse) // 成功
@respdoc-400 (ErrorResponse) // 錯誤請求
@respdoc-401 (UnauthorizedError) // 未授權
@respdoc-500 (ServerError) // 伺服器錯誤
*/
@handler getUser
get /user/:id (UserRequest) returns (UserResponse)
}
```
#### 多業務錯誤碼
```go
service MyAPI {
@doc (
description: "創建訂單"
)
/*
@respdoc-201 (OrderResponse) // 創建成功
@respdoc-400 (
300101: (ValidationError) 參數驗證失敗
300102: (InsufficientStockError) 庫存不足
300103: (InvalidPaymentError) 支付方式無效
) // 客戶端錯誤
@respdoc-401 (UnauthorizedError) // 未授權
@respdoc-500 (ServerError) // 伺服器錯誤
*/
@handler createOrder
post /order (OrderRequest) returns (OrderResponse)
}
```
### 完整範例
```go
syntax = "v1"
info (
title: "訂單 API"
description: "訂單管理系統"
version: "v1"
host: "api.example.com"
basePath: "/v1"
useDefinitions: true
)
type (
CreateOrderReq {
ProductID string `json:"product_id"`
Quantity int `json:"quantity"`
PaymentMethod string `json:"payment_method"`
}
OrderResponse {
OrderID string `json:"order_id"`
Status string `json:"status"`
Total float64 `json:"total"`
}
ValidationError {
Code string `json:"code"`
Message string `json:"message"`
Fields []string `json:"fields"`
}
InsufficientStockError {
Code string `json:"code"`
Message string `json:"message"`
ProductID string `json:"product_id"`
Required int `json:"required"`
Available int `json:"available"`
}
InvalidPaymentError {
Code string `json:"code"`
Message string `json:"message"`
Method string `json:"method"`
}
UnauthorizedError {
Code string `json:"code"`
Message string `json:"message"`
Reason string `json:"reason"`
}
ServerError {
Code string `json:"code"`
Message string `json:"message"`
TraceID string `json:"trace_id"`
}
)
@server (
tags: "order"
)
service OrderAPI {
@doc (
description: "創建訂單"
)
/*
@respdoc-201 (OrderResponse) // 創建成功
@respdoc-400 (
300101: (ValidationError) 參數驗證失敗
300102: (InsufficientStockError) 庫存不足
300103: (InvalidPaymentError) 支付方式無效
) // 客戶端錯誤
@respdoc-401 (UnauthorizedError) // 未授權
@respdoc-500 (ServerError) // 伺服器錯誤
*/
@handler createOrder
post /order (CreateOrderReq) returns (OrderResponse)
}
```
### 生成結果對比
#### Swagger 2.0 輸出
```json
{
"paths": {
"/v1/order": {
"post": {
"responses": {
"201": {
"description": "// 創建成功",
"schema": {
"$ref": "#/definitions/OrderResponse"
}
},
"400": {
"description": "客戶端錯誤\n\nPossible errors:\n300101: ValidationError - 參數驗證失敗\n300102: InsufficientStockError - 庫存不足\n300103: InvalidPaymentError - 支付方式無效",
"schema": {
"$ref": "#/definitions/ValidationError"
}
}
}
}
}
}
}
```
#### OpenAPI 3.0 輸出(使用 oneOf
```json
{
"paths": {
"/v1/order": {
"post": {
"responses": {
"201": {
"description": "// 創建成功",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OrderResponse"
}
}
}
},
"400": {
"description": "客戶端錯誤",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"$ref": "#/components/schemas/ValidationError"
},
{
"$ref": "#/components/schemas/InsufficientStockError"
},
{
"$ref": "#/components/schemas/InvalidPaymentError"
}
]
}
}
}
}
}
}
}
}
}
```
**優勢:** OpenAPI 3.0 使用 `oneOf` 精確表示多種可能的錯誤類型!
---
## 進階功能
### 安全定義
```go
info (
securityDefinitionsFromJson: `{
"apiKey": {
"type": "apiKey",
"name": "x-api-key",
"in": "header",
"description": "API Key Authentication"
}
}`
)
@server (
authType: apiKey
)
service MyAPI {
// 此處的路由將使用 apiKey 身份驗證
}
```
### Code-Msg 包裝
```go
info (
wrapCodeMsg: true
bizCodeEnumDescription: "1001-User not found<br>1002-Permission denied"
)
```
回應將被包裝為:
```json
{
"code": 0,
"msg": "ok",
"data": { /* your actual response */ }
}
```
---
## OpenAPI 3.0 vs Swagger 2.0
### 規格版本
#### Swagger 2.0(預設)
```bash
go-doc -a example/example.api -d output
# 或明確指定
go-doc -a example/example.api -d output -s swagger2.0
```
#### OpenAPI 3.0(推薦)
```bash
go-doc -a example/example.api -d output -s openapi3.0
```
### 主要差異
| 特性 | Swagger 2.0 | OpenAPI 3.0 |
|:---|:---|:---|
| **版本宣告** | `swagger: "2.0"` | `openapi: "3.0.3"` |
| **伺服器** | `host`, `basePath`, `schemes` | `servers` 陣列 |
| **Schema 定義** | `definitions` | `components/schemas` |
| **安全定義** | `securityDefinitions` | `components/securitySchemes` |
| **請求體** | `parameters[in=body]` | `requestBody` |
| **多類型回應** | 不支援 | `oneOf`/`anyOf`/`allOf` |
### 範例對比
**Swagger 2.0:**
```json
{
"swagger": "2.0",
"host": "example.com",
"basePath": "/v1",
"definitions": {
"User": {...}
},
"securityDefinitions": {
"apiKey": {...}
}
}
```
**OpenAPI 3.0:**
```json
{
"openapi": "3.0.3",
"servers": [
{"url": "http://example.com/v1"},
{"url": "https://example.com/v1"}
],
"components": {
"schemas": {
"User": {...}
},
"securitySchemes": {
"apiKey": {...}
}
}
}
```
---
## 使用範例
### 場景 1RESTful CRUD
```go
/*
@respdoc-200 (UserResponse) // 獲取成功
@respdoc-404 (NotFoundError) // 用戶不存在
@respdoc-401 (UnauthorizedError) // 未授權
*/
@handler getUser
get /user/:id (UserIdReq) returns (UserResponse)
/*
@respdoc-201 (UserResponse) // 創建成功
@respdoc-400 (ValidationError) // 參數錯誤
@respdoc-409 (ConflictError) // 用戶已存在
*/
@handler createUser
post /user (CreateUserReq) returns (UserResponse)
/*
@respdoc-200 (UserResponse) // 更新成功
@respdoc-400 (ValidationError) // 參數錯誤
@respdoc-404 (NotFoundError) // 用戶不存在
*/
@handler updateUser
put /user/:id (UpdateUserReq) returns (UserResponse)
/*
@respdoc-204 // 刪除成功
@respdoc-404 (NotFoundError) // 用戶不存在
*/
@handler deleteUser
delete /user/:id (UserIdReq)
```
### 場景 2複雜業務流程
```go
/*
@respdoc-200 (PaymentResponse) // 支付成功
@respdoc-400 (
400001: (InsufficientBalanceError) 餘額不足
400002: (InvalidCardError) 無效的卡號
400003: (ExpiredCardError) 卡已過期
400004: (DailyLimitError) 超過每日限額
) // 支付失敗
@respdoc-401 (UnauthorizedError) // 未授權
@respdoc-422 (ProcessingError) // 處理中請勿重複提交
*/
@handler processPayment
post /payment (PaymentRequest) returns (PaymentResponse)
```
### 場景 3狀態機轉換
```go
/*
@respdoc-200 (OrderResponse) // 狀態更新成功
@respdoc-400 (
400001: (InvalidStateError) 無效的狀態轉換
400002: (OrderCancelledError) 訂單已取消
400003: (OrderCompletedError) 訂單已完成
) // 狀態轉換失敗
*/
@handler updateOrderStatus
put /order/:id/status (UpdateStatusReq) returns (OrderResponse)
```
---
## 測試
### 測試所有格式
```bash
# 測試 Swagger 2.0 和 OpenAPI 3.0
./test_all_formats.sh
# 測試 @respdoc 功能
./test_respdoc.sh
```
### 手動測試
```bash
# 生成並檢查
go-doc -a example/example_respdoc.api -d output -s openapi3.0
# 查看生成的文檔
cat output/example_respdoc.json | jq '.paths."/v1/order".post.responses'
```
---
## 專案結構
```
go-doc/
├── cmd/
│ └── go-doc/ # 主程式入口
│ └── main.go
├── internal/
│ ├── swagger/ # 核心邏輯
│ │ ├── respdoc.go # @respdoc 解析
│ │ ├── response.go # 回應生成
│ │ ├── openapi3.go # OpenAPI 3.0 轉換
│ │ ├── swagger.go # Swagger 2.0 生成
│ │ ├── path.go # 路徑處理
│ │ ├── parameter.go # 參數處理
│ │ ├── definition.go # 定義處理
│ │ └── ...
│ └── util/ # 工具函數
│ ├── util.go
│ ├── stringx.go
│ └── pathx.go
├── example/
│ ├── example.api # 範例 API
│ ├── example_cn.api # 中文範例
│ ├── example_respdoc.api # @respdoc 範例
│ └── test_output/ # 生成的文檔
├── bin/ # 編譯後的執行檔
├── go.mod
├── go.sum
├── Makefile
├── README.md
├── LICENSE
├── CHANGELOG.md
├── test_all_formats.sh # 測試腳本
└── test_respdoc.sh # @respdoc 測試
```
---
## 從 go-zero 遷移
### 主要變更
#### 1. 模組獨立
**之前go-zero plugin:**
```go
// 屬於 go-zero 內部工具
package swagger // in tools/goctl/api/plugin/swagger/
```
**現在standalone:**
```go
module go-doc
package swagger // in internal/swagger/
```
#### 2. 依賴簡化
| 依賴 | 替換為 | 原因 |
|:---|:---|:---|
| `go-zero/tools/goctl/internal/version` | 自訂版本 | 內部包不可訪問 |
| `go-zero/tools/goctl/util` | `go-doc/internal/util` | 自包含工具 |
| `go-zero/tools/goctl/util/stringx` | `go-doc/internal/util` | 自訂字串處理 |
| `go-zero/tools/goctl/util/pathx` | `go-doc/internal/util` | 自訂路徑處理 |
| `google.golang.org/grpc/metadata` | 原生 map 處理 | 移除外部依賴 |
#### 3. 保留的依賴
- `github.com/go-openapi/spec` - Swagger 2.0
- `github.com/getkin/kin-openapi` - OpenAPI 3.0
- `github.com/spf13/cobra` - CLI 框架
- `github.com/zeromicro/go-zero/tools/goctl/api/spec` - API 解析
- `gopkg.in/yaml.v2` - YAML 支援
### 遷移步驟
1. **安裝 go-doc**
```bash
go build -o bin/go-doc ./cmd/go-doc
```
2. **替換命令**
**之前:**
```bash
goctl api plugin -plugin goctl-swagger="swagger -filename api.json" -api user.api -dir .
```
**現在:**
```bash
go-doc -a user.api -d . -f api
```
3. **新功能**
使用 OpenAPI 3.0
```bash
go-doc -a user.api -d . -f api -s openapi3.0
```
使用 @respdoc
```go
/*
@respdoc-200 (Response) // 成功
@respdoc-400 (Error) // 錯誤
*/
```
---
## 版本記錄
### [1.2.0] - 2025-09-30
#### 新增
- **@respdoc 註解支援** - 為單個端點定義多個 HTTP 狀態碼
- **業務錯誤碼映射** - 將不同的業務錯誤碼映射到特定錯誤類型
- **OpenAPI 3.0 oneOf 支援** - 使用 `oneOf` 表示多種可能的錯誤回應
- 測試腳本 `test_respdoc.sh`
- 支援解析 `HandlerDoc` 註釋(在 `@doc()` 外)
#### 增強
- 回應生成現在支援多個狀態碼200, 201, 400, 401, 404, 500 等)
- OpenAPI 3.0 生成器為業務錯誤碼創建 `oneOf` schema
- Swagger 2.0 在描述中列出所有可能的錯誤
### [1.1.0] - 2025-09-30
#### 新增
- **OpenAPI 3.0 支援** - 生成 Swagger 2.0 和 OpenAPI 3.0 規格
- 新增 `--spec-version` (`-s`) 標誌
- 自動從 Swagger 2.0 轉換到 OpenAPI 3.0
- 整合 `github.com/getkin/kin-openapi` 函式庫
### [1.0.0] - 2025-09-30
#### 新增
- 初始發布為獨立工具
- 從 go-zero 專案提取並獨立
- 支援將 go-zero `.api` 文件轉換為 Swagger 2.0 規格
- JSON 和 YAML 輸出格式
- 使用 cobra 的命令行介面
---
## 常見問題
### Q: 應該使用哪個版本?
**A:**
- **Swagger 2.0**: 如果需要與舊工具或系統相容
- **OpenAPI 3.0**: 推薦使用,功能更強大且為現代標準
### Q: 為什麼 OpenAPI 3.0 使用 oneOf
**A:** `oneOf` 是 OpenAPI 3.0 的標準方式來表示"多選一"的 schema。這讓 API 文檔更精確,工具(如 Swagger UI、代碼生成器可以正確理解和處理多種可能的回應類型。
### Q: Swagger 2.0 為什麼不支援 oneOf
**A:** Swagger 2.0 規範不包含 `oneOf` 關鍵字。我們在描述中列出所有可能的錯誤,並使用第一個類型作為 schema 示例。
### Q: 可以同時生成兩種格式嗎?
**A:** 可以!執行兩次命令即可:
```bash
go-doc -a api.api -d out -f api-v2
go-doc -a api.api -d out -f api-v3 -s openapi3.0
```
### Q: 可以不定義業務錯誤碼嗎?
**A:** 可以!如果只有一種錯誤類型,直接使用單一回應格式:
```go
@respdoc-400 (ValidationError) // 參數錯誤
```
### Q: 如何驗證生成的文檔?
**A:** 可以使用以下工具:
- **Swagger Editor**: https://editor.swagger.io/
- **Swagger UI**: 本地運行或線上版本
- **OpenAPI Generator**: 生成客戶端/伺服器代碼來驗證
---
## 貢獻
歡迎貢獻!請隨時提交 Pull Request。
### 開發
```bash
# 克隆專案
git clone <your-repo>
cd go-doc
# 安裝依賴
go mod download
# 運行測試
make test
# 編譯
make build
# 運行範例
make example
```
---
## 總結
**go-doc v1.2.0** 提供了強大的 API 文檔生成功能:
- **雙規格支援** - Swagger 2.0 和 OpenAPI 3.0
- **多狀態碼回應** - 使用 @respdoc 定義完整的 HTTP 回應
- **業務錯誤碼映射** - 支援複雜的錯誤場景
- **OpenAPI 3.0 oneOf** - 專業的多類型回應表示
- **完整測試** - 自動化測試腳本
- **詳細文檔** - 完整的使用指南
開始使用:
```bash
go-doc -a your-api.api -d docs -s openapi3.0
```
查看範例:
- 基本範例:`example/example.api`
- @respdoc 範例:`example/example_respdoc.api`
- 測試腳本:`./test_respdoc.sh`

View File

@ -0,0 +1,45 @@
package main
import (
"fmt"
"os"
"go-doc/internal/swagger"
"github.com/spf13/cobra"
)
var (
version = "1.2.0"
commit = "dev"
date = "unknown"
)
func main() {
rootCmd := &cobra.Command{
Use: "go-doc",
Short: "Generate Swagger documentation from go-zero API files",
Long: `go-doc is a tool that converts go-zero .api files into OpenAPI specifications (Swagger 2.0 or OpenAPI 3.0).`,
Version: fmt.Sprintf("%s (commit: %s, built at: %s)", version, commit, date),
RunE: swagger.Command,
}
rootCmd.Flags().StringVarP(&swagger.VarStringAPI, "api", "a", "", "API file path (required)")
rootCmd.Flags().StringVarP(&swagger.VarStringDir, "dir", "d", "", "Output directory (required)")
rootCmd.Flags().StringVarP(&swagger.VarStringFilename, "filename", "f", "", "Output filename without extension (optional, defaults to API filename)")
rootCmd.Flags().BoolVarP(&swagger.VarBoolYaml, "yaml", "y", false, "Generate YAML format instead of JSON")
rootCmd.Flags().StringVarP(&swagger.VarStringSpecVersion, "spec-version", "s", "swagger2.0", "OpenAPI specification version: swagger2.0 or openapi3.0")
if err := rootCmd.MarkFlagRequired("api"); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if err := rootCmd.MarkFlagRequired("dir"); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}

View File

@ -0,0 +1,4 @@
*.json
*.yaml
bin
output

View File

@ -0,0 +1,33 @@
syntax = "v1"
// ================ 通用回應 ================
type (
// 成功回應
OKResp {
Code int `json:"code"`
Msg string `json:"msg"`
Data interface{} `json:"data,omitempty"`
}
// 分頁回應
PagerResp {
Total int64 `json:"total"`
Size int64 `json:"size"`
Index int64 `json:"index"`
}
// 錯誤回應
ErrorResp {
Code int `json:"code"`
Msg string `json:"msg"`
Details string `json:"details,omitempty"`
Error interface{} `json:"error,omitempty"` // 可選的錯誤資訊
}
BaseReq {}
VerifyHeader {
Authorization string `header:"authorization" validate:"required"`
}
)

View File

@ -0,0 +1,20 @@
syntax = "v1"
info (
title: "Digimon Platform API Gateway"
description: "This is Digimon Platform "
version: "v1"
contactName: "Daniel Wang"
contactEmail: "igs170911@gmail.com"
consumes: "application/json"
produces: "application/json"
schemes: "http,https"
host: "127.0.0.1:8888"
)
import (
"common.api"
"ping.api"
"member.api"
)

View File

@ -0,0 +1,359 @@
syntax = "v1"
// ================ 請求/回應結構 ================
type (
// --- 1. 註冊 / 登入 ---
// CredentialsRegisterPayload 傳統帳號密碼註冊的資料
CredentialsRegisterPayload {
Password string `json:"password" validate:"required,min=8,max=128"` // 密碼 (後端應使用 bcrypt 進行雜湊)
PasswordConfirm string `json:"password_confirm" validate:"eqfield=Password"` // 確認密碼
}
// PlatformRegisterPayload 第三方平台註冊的資料
PlatformRegisterPayload {
Provider string `json:"provider" validate:"required,oneof=google line apple"` // 平台名稱
Token string `json:"token" validate:"required"` // 平台提供的 Access Token 或 ID Token
}
// RegisterReq 註冊請求 (整合了兩種方式)
RegisterReq {
AuthMethod string `json:"auth_method" validate:"required,oneof=credentials platform"`
LoginID string `json:"login_id" validate:"required,min=3,max=50"` // 信箱或手機號碼
Credentials *CredentialsRegisterPayload `json:"credentials,optional"` // AuthMethod 為 'credentials' 時使用
Platform *PlatformRegisterPayload `json:"platform,optional"` // AuthMethod 為 'platform' 時使用
}
// LoginResp 登入/註冊成功後的回應 (此結構設計良好,予以保留)
LoginResp {
UID string `json:"uid"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"` // 通常固定為 "Bearer"
}
// --- 2. 密碼重設流程 ---
// RequestPasswordResetReq 請求發送「忘記密碼」的驗證碼
RequestPasswordResetReq {
Identifier string `json:"identifier" validate:"required"` // 使用者帳號 (信箱或手機)
AccountType string `json:"account_type" validate:"required,oneof=email phone"`
}
// VerifyCodeReq 驗證碼校驗 (通用)
VerifyCodeReq {
Identifier string `json:"identifier" validate:"required"`
VerifyCode string `json:"verify_code" validate:"required,len=6"`
}
// ResetPasswordReq 使用已驗證的 Code 來重設密碼
ResetPasswordReq {
Identifier string `json:"identifier" validate:"required"`
VerifyCode string `json:"verify_code" validate:"required"` // 來自上一步驗證通過的 Code作為一種「票證」
Password string `json:"password" validate:"required,min=8,max=128"` // 新密碼
PasswordConfirm string `json:"password_confirm" validate:"eqfield=Password"` // 確認新密碼
}
// --- 4. 權杖刷新 ---
// RefreshTokenReq 更新 AccessToken
RefreshTokenReq {
RefreshToken string `json:"refresh_token" validate:"required"`
}
// RefreshTokenResp 刷新權杖後的回應
RefreshTokenResp {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"` // 可選:某些策略下刷新後也會換發新的 Refresh Token
TokenType string `json:"token_type"`
}
)
// =================================================================
// Service: Member (公開 API - 無需登入)
// Group: account
// =================================================================
@server(
group: account
prefix: /api/v1/account
schemes: https
timeout: 10s
)
service gateway {
// ===================================
// 1. 註冊 (Register)
// ===================================
@doc(
summary: "註冊新帳號"
description: "使用傳統帳號密碼或第三方平台進行註冊。成功後直接返回登入後的 Token 資訊。"
)
/*
@respdoc-200 (LoginResp) // 註冊成功,並返回 Token
@respdoc-400 (
40001: (ErrorResp) "請求參數格式錯誤"
40002: (ErrorResp) "密碼與確認密碼不一致"
40003: (ErrorResp) "無效的認證方式或平台"
40004: (ErrorResp) "無效的平台 Token"
)
@respdoc-409 (ErrorResp) // 409 Conflict: 代表資源衝突,這裡表示帳號已存在
@respdoc-500 (ErrorResp) // 伺服器內部錯誤
*/
@handler register
post /register (RegisterReq) returns (LoginResp)
// ===================================
// 2. 登入 (Login / Create Session)
// ===================================
@doc(
summary: "使用者登入"
description: "使用傳統帳號密碼或第三方平台 Token 進行登入,以創建一個新的會話(Session)。"
)
/*
@respdoc-200 (LoginResp) // 登入成功
@respdoc-400 (ErrorResp) "請求參數格式錯誤"
@respdoc-401 (
40101: (ErrorResp) "帳號或密碼錯誤"
40102: (ErrorResp) "無效的平台 Token"
) // 401 Unauthorized: 代表身份驗證失敗
@respdoc-500 (ErrorResp) // 伺服器內部錯誤
*/
@handler login
post /sessions/login (RegisterReq) returns (LoginResp)
// ===================================
// 3. 權杖刷新 (Token Refresh)
// ===================================
@doc(
summary: "刷新 Access Token"
description: "使用有效的 Refresh Token 來獲取一組新的 Access Token 和 Refresh Token。"
)
/*
@respdoc-200 (RefreshTokenResp) // 刷新成功
@respdoc-400 (ErrorResp) "請求參數格式錯誤"
@respdoc-401 (ErrorResp) "無效或已過期的 Refresh Token" // 401 Unauthorized
@respdoc-500 (ErrorResp) // 伺服器內部錯誤
*/
@handler refreshToken
post /sessions/refresh (RefreshTokenReq) returns (RefreshTokenResp)
// ===================================
// 4. 密碼重設 (Password Reset)
// ===================================
@doc(
summary: "請求發送密碼重設驗證碼(忘記密碼)"
description: "為指定的 email 或 phone 發送一個一次性的密碼重設驗證碼。三分鐘內對同一帳號只能請求一次。"
)
/*
@respdoc-201 () // 請求成功 (為安全起見,即使帳號不存在也應返回成功)
@respdoc-400 (ErrorResp) "請求參數格式錯誤"
@respdoc-429 (ErrorResp) // 429 Too Many Requests: 代表請求過於頻繁
@respdoc-500 (ErrorResp) // 伺服器內部錯誤
*/
@handler requestPasswordReset
post /password-resets/request (RequestPasswordResetReq) returns ()
@doc(
summary: "校驗密碼重設驗證碼"
description: "在實際重設密碼前,先驗證使用者輸入的驗證碼是否正確。這一步可以讓前端在使用者進入下一步前就得到反饋。"
)
/*
@respdoc-200 (OKResp) // 驗證碼正確
@respdoc-400 (
40001: (ErrorResp) "請求參數格式錯誤"
40002: (ErrorResp) "驗證碼無效或已過期"
)
@respdoc-500 (ErrorResp) // 伺服器內部錯誤
*/
@handler verifyPasswordResetCode
post /password-resets/verify (VerifyCodeReq) returns (OKResp)
@doc(
summary: "執行密碼重設"
description: "使用有效的驗證碼來設定新的密碼。"
)
/*
@respdoc-201 () // 密碼重設成功
@respdoc-400 (
40001: (ErrorResp) "請求參數格式錯誤"
40002: (ErrorResp) "密碼與確認密碼不一致"
40003: (ErrorResp) "驗證碼無效或已過期"
)
@respdoc-500 (ErrorResp) // 伺服器內部錯誤
*/
@handler resetPassword
put /password-resets (ResetPasswordReq) returns ()
}
// ================ 請求/回應結構 ================
type (
// --- 會員資訊 ---
// UserInfoResp 用於獲取會員資訊的標準回應結構 (合併 GetUserInfo 和 UserInfo)
UserInfoResp {
Platform string `json:"platform"` // 註冊平台
UID string `json:"uid"` // 用戶 UID
AvatarURL string `json:"avatar_url"` // 頭像 URL
FullName string `json:"full_name"` // 用戶全名
Nickname string `json:"nickname"` // 暱稱
GenderCode string `json:"gender_code"` // 性別代碼
Birthdate string `json:"birthdate"` // 生日 (格式: 1993-04-17)
PhoneNumber string `json:"phone_number"` // 電話
IsPhoneVerified bool `json:"is_phone_verified"` // 手機是否已驗證
Email string `json:"email"` // 信箱
IsEmailVerified bool `json:"is_email_verified"` // 信箱是否已驗證
Address string `json:"address"` // 地址
AlarmCategory string `json:"alarm_category"` // 告警狀態
UserStatus string `json:"user_status"` // 用戶狀態
PreferredLanguage string `json:"preferred_language"` // 偏好語言
Currency string `json:"currency"` // 偏好幣種
National string `json:"national"` // 國家
PostCode string `json:"post_code"` // 郵遞區號
Carrier string `json:"carrier"` // 載具
Role string `json:"role"` // 角色
}
// UpdateUserInfoReq 更新會員資訊的請求結構 (原 BindingUserInfo)
UpdateUserInfoReq {
AvatarURL *string `json:"avatar_url,optional"` // 頭像 URL
FullName *string `json:"full_name,optional"` // 用戶全名
Nickname *string `json:"nickname,optional"` // 暱稱
GenderCode *string `json:"gender_code,optional" validate:"omitempty,oneof=secret male female"`
Birthdate *string `json:"birthdate,optional"` // 生日 (格式: 1993-04-17)
Address *string `json:"address,optional"` // 地址
PreferredLanguage *string `json:"preferred_language,optional" validate:"omitempty,oneof=zh-tw en-us"`
Currency *string `json:"currency,optional" validate:"omitempty,oneof=TWD USD"`
National *string `json:"national,optional"` // 國家
PostCode *string `json:"post_code,optional"` // 郵遞區號
Carrier *string `json:"carrier,optional"` // 載具
VerifyHeader
}
// --- 修改密碼 ---
// UpdatePasswordReq 修改密碼的請求 (原 ModifyPasswdReq)
UpdatePasswordReq {
CurrentPassword string `json:"current_password" validate:"required"`
NewPassword string `json:"new_password" validate:"required,min=8,max=128"`
NewPasswordConfirm string `json:"new_password_confirm" validate:"eqfield=NewPassword"`
VerifyHeader
}
// --- 通用驗證碼 ---
// RequestVerificationCodeReq 請求發送驗證碼
RequestVerificationCodeReq {
VerifyHeader
// 驗證目的:'email_verification' 或 'phone_verification'
Purpose string `json:"purpose" validate:"required,oneof=email_verification phone_verification"`
}
// SubmitVerificationCodeReq 提交驗證碼以完成驗證
SubmitVerificationCodeReq {
Purpose string `json:"purpose" validate:"required,oneof=email_verification phone_verification"`
VerifyCode string `json:"verify_code" validate:"required,len=6"`
VerifyHeader
}
)
// =================================================================
// Service: Gateway (授權 API - 需要登入)
// Group: user
// Middleware: AuthMiddleware (JWT 驗證)
// =================================================================
@server(
group: user
prefix: /api/v1/user
schemes: https
timeout: 10s
middleware: AuthMiddleware
)
service gateway {
// ===================================
// 1. 會員資訊 (User Profile)
// ===================================
@doc(
summary: "取得當前登入的會員資訊"
)
/*
@respdoc-200 (UserInfoResp) // 成功獲取
@respdoc-401 (ErrorResp) "未授權或 Token 無效"
@respdoc-404 (ErrorResp) "找不到使用者"
@respdoc-500 (ErrorResp) // 伺服器內部錯誤
*/
@handler getUserInfo
get /me (VerifyHeader) returns (UserInfoResp)
@doc(
summary: "更新當前登入的會員資訊"
description: "只更新傳入的欄位,未傳入的欄位將保持不變。"
)
/*
@respdoc-200 (UserInfoResp) // 更新成功,並返回更新後的使用者資訊
@respdoc-400 (ErrorResp) "請求參數格式錯誤"
@respdoc-401 (ErrorResp) "未授權或 Token 無效"
@respdoc-500 (ErrorResp) // 伺服器內部錯誤
*/
@handler updateUserInfo
put /me (UpdateUserInfoReq) returns (UserInfoResp)
// ===================================
// 2. 修改密碼 (Password Update)
// ===================================
@doc(
summary: "修改當前登入使用者的密碼"
description: "必須提供當前密碼以進行驗證。"
)
/*
@respdoc-200 (OKResp) // 密碼修改成功
@respdoc-400 (
40001: (ErrorResp) "請求參數格式錯誤"
40002: (ErrorResp) "新密碼與確認密碼不一致"
)
@respdoc-401 (ErrorResp) "未授權或 Token 無效"
@respdoc-403 (ErrorResp) "當前密碼不正確" // 403 Forbidden: 認證成功,但無權執行此操作
@respdoc-500 (ErrorResp) // 伺服器內部錯誤
*/
@handler updatePassword
put /me/password (UpdatePasswordReq) returns (OKResp)
// ===================================
// 3. 驗證碼流程 (Verification Flow)
// ===================================
@doc(
summary: "請求發送驗證碼"
description: "用於驗證手機或 Email。根據傳入的 `purpose` 發送對應的驗證碼。同一個目的在短時間內不能重複發送。"
)
/*
@respdoc-200 (OKResp) // 請求已受理
@respdoc-400 (ErrorResp) "請求參數格式錯誤"
@respdoc-401 (ErrorResp) "未授權或 Token 無效"
@respdoc-429 (ErrorResp) // 429 Too Many Requests: 請求過於頻繁
@respdoc-500 (ErrorResp) // 伺服器內部錯誤
*/
@handler requestVerificationCode
post /me/verifications (RequestVerificationCodeReq) returns (OKResp)
@doc(
summary: "提交驗證碼以完成驗證"
description: "提交收到的驗證碼,以完成特定目的的驗證,例如綁定手機或 Email。"
)
/*
@respdoc-200 (OKResp) // 驗證成功
@respdoc-400 (
40001: (ErrorResp) "請求參數格式錯誤"
40002: (ErrorResp) "驗證碼無效或已過期"
)
@respdoc-401 (ErrorResp) "未授權或 Token 無效"
@respdoc-500 (ErrorResp) // 伺服器內部錯誤
*/
@handler submitVerificationCode
put /me/verifications (SubmitVerificationCodeReq) returns (OKResp)
}

View File

@ -0,0 +1,21 @@
syntax = "v1"
// ===========================================
// 系統健康檢查 API 端點定義
// ===========================================
@server(
group: ping
prefix: /api/v1
schemes: https
timeout: 10s
)
service gateway {
@doc(
summary: "系統健康檢查"
description: "檢查系統服務狀態,用於監控和負載平衡器健康檢查。回傳系統運行狀態資訊。"
)
@handler Ping
get /health () returns ()
}

View File

@ -0,0 +1,241 @@
syntax = "v1"
info (
title: "Demo API" // title corresponding to Swagger
description: "Generating Swagger files using the API demo." // description corresponding to Swagger
version: "v1" // version corresponding to Swagger
termsOfService: "https://github.com/zeromicro/go-zero" // termsOfService corresponding to Swagger
contactName: "keson.an" // contactName corresponding to Swagger
contactURL: "https://github.com/zeromicro/go-zero" // contactURL corresponding to Swagger
contactEmail: "example@gmail.com" // contactEmail corresponding to Swagger
licenseName: "MIT" // licenseName corresponding to Swagger
licenseURL: "https://github.com/zeromicro/go-zero" // licenseURL corresponding to Swagger
consumes: "application/json" // consumes corresponding to Swagger,default value is `application/json`
produces: "application/json" // produces corresponding to Swagger,default value is `application/json`
schemes: "http,https" // schemes corresponding to Swagger,default value is `https``
host: "example.com" // host corresponding to Swagger,default value is `127.0.0.1`
basePath: "/v1" // basePath corresponding to Swagger,default value is `/`
wrapCodeMsg: true // to wrap in the universal code-msg structure, like {"code":0,"msg":"OK","data":$data}
bizCodeEnumDescription: "1001-User not login<br>1002-User permission denied" // enums of business error codes, in JSON format, with the key being the business error code and the value being the description of that error code. This only takes effect when wrapCodeMsg is set to true.
// securityDefinitionsFromJson is a custom authentication configuration, and the JSON content will be directly inserted into the securityDefinitions of Swagger.
// Format reference: https://swagger.io/specification/v2/#security-definitions-object
// You can declare authType in the @server of the API to specify the authentication type used for its routes.
securityDefinitionsFromJson: `{"apiKey":{"description":"apiKey type description","type":"apiKey","name":"x-api-key","in":"header"}}`
useDefinitions: true // if set true, the definitions will be generated in the swagger.json for response body or json request body file, and the models will be referenced in the API.
)
type (
QueryReq {
Id int `form:"id,range=[1:10000],example=10"`
Name string `form:"name,example=keson.an"`
Avatar string `form:"avatar,optional,example=https://example.com/avatar.png"`
}
QueryResp {
Id int `json:"id,example=10"`
Name string `json:"name,example=keson.an"`
}
PathQueryReq {
Id int `path:"id,range=[1:10000],example=10"`
Name string `form:"name,example=keson.an"`
}
PathQueryResp {
Id int `json:"id,example=10"`
Name string `json:"name,example=keson.an"`
}
)
@server (
tags: "query" // tags corresponding to Swagger
summary: "query API set" // summary corresponding to Swagger
prefix: v1
authType: apiKey // Specifies the authentication type used for this route, which is the name defined in securityDefinitionsFromJson.
)
service Swagger {
@doc (
description: "query demo"
)
@handler query
get /query (QueryReq) returns (QueryResp)
@doc (
description: "show path query demo"
)
@handler queryPath
get /query/:id (PathQueryReq) returns (PathQueryResp)
}
type (
FormReq {
Id int `form:"id,range=[1:10000],example=10"`
Name string `form:"name,example=keson.an"`
}
FormResp {
Id int `json:"id,example=10"`
Name string `json:"name,example=keson.an"`
}
)
@server (
tags: "form" // tags corresponding to Swagger
summary: "form API set" // summary corresponding to Swagger
)
service Swagger {
@doc (
description: "form demo"
)
@handler form
post /form (FormReq) returns (FormResp)
}
type (
JsonReq {
Id int `json:"id,range=[1:10000],example=10"`
Name string `json:"name,example=keson.an"`
Avatar string `json:"avatar,optional"`
Language string `json:"language,options=golang|java|python|typescript|rust"`
Gender string `json:"gender,default=male,options=male|female,example=male"`
}
JsonResp {
Id int `json:"id"`
Name string `json:"name"`
Avatar string `json:"avatar"`
Language string `json:"language"`
Gender string `json:"gender"`
}
ComplexJsonLevel2 {
// basic
Integer int `json:"integer,example=1"`
Number float64 `json:"number,example=1.1"`
Boolean bool `json:"boolean,options=true|false,example=true"`
String string `json:"string,example=some text"`
}
ComplexJsonLevel1 {
// basic
Integer int `json:"integer,example=1"`
Number float64 `json:"number,example=1.1"`
Boolean bool `json:"boolean,options=true|false,example=true"`
String string `json:"string,example=some text"`
// Object
Object ComplexJsonLevel2 `json:"object"`
PointerObject *ComplexJsonLevel2 `json:"pointerObject"`
}
ComplexJsonReq {
// basic
Integer int `json:"integer,example=1"`
Number float64 `json:"number,example=1.1"`
Boolean bool `json:"boolean,options=true|false,example=true"`
String string `json:"string,example=some text"`
// basic array
ArrayInteger []int `json:"arrayInteger"`
ArrayNumber []float64 `json:"arrayNumber"`
ArrayBoolean []bool `json:"arrayBoolean"`
ArrayString []string `json:"arrayString"`
// basic array array
ArrayArrayInteger [][]int `json:"arrayArrayInteger"`
ArrayArrayNumber [][]float64 `json:"arrayArrayNumber"`
ArrayArrayBoolean [][]bool `json:"arrayArrayBoolean"`
ArrayArrayString [][]string `json:"arrayArrayString"`
// basic map
MapInteger map[string]int `json:"mapInteger"`
MapNumber map[string]float64 `json:"mapNumber"`
MapBoolean map[string]bool `json:"mapBoolean"`
MapString map[string]string `json:"mapString"`
// basic map array
MapArrayInteger map[string][]int `json:"mapArrayInteger"`
MapArrayNumber map[string][]float64 `json:"mapArrayNumber"`
MapArrayBoolean map[string][]bool `json:"mapArrayBoolean"`
MapArrayString map[string][]string `json:"mapArrayString"`
// basic map map
MapMapInteger map[string]map[string]int `json:"mapMapInteger"`
MapMapNumber map[string]map[string]float64 `json:"mapMapNumber"`
MapMapBoolean map[string]map[string]bool `json:"mapMapBoolean"`
MapMapString map[string]map[string]string `json:"mapMapString"`
// Object
Object ComplexJsonLevel1 `json:"object"`
PointerObject *ComplexJsonLevel1 `json:"pointerObject"`
// Object array
ArrayObject []ComplexJsonLevel1 `json:"arrayObject"`
ArrayPointerObject []*ComplexJsonLevel1 `json:"arrayPointerObject"`
// Object map
MapObject map[string]ComplexJsonLevel1 `json:"mapObject"`
MapPointerObject map[string]*ComplexJsonLevel1 `json:"mapPointerObject"`
// Object array array
ArrayArrayObject [][]ComplexJsonLevel1 `json:"arrayArrayObject"`
ArrayArrayPointerObject [][]*ComplexJsonLevel1 `json:"arrayArrayPointerObject"`
// Object array map
ArrayMapObject []map[string]ComplexJsonLevel1 `json:"arrayMapObject"`
ArrayMapPointerObject []map[string]*ComplexJsonLevel1 `json:"arrayMapPointerObject"`
// Object map array
MapArrayObject map[string][]ComplexJsonLevel1 `json:"mapArrayObject"`
MapArrayPointerObject map[string][]*ComplexJsonLevel1 `json:"mapArrayPointerObject"`
}
ComplexJsonResp {
// basic
Integer int `json:"integer,example=1"`
Number float64 `json:"number,example=1.1"`
Boolean bool `json:"boolean,options=true|false,example=true"`
String string `json:"string,example=some text"`
// basic array
ArrayInteger []int `json:"arrayInteger"`
ArrayNumber []float64 `json:"arrayNumber"`
ArrayBoolean []bool `json:"arrayBoolean"`
ArrayString []string `json:"arrayString"`
// basic array array
ArrayArrayInteger [][]int `json:"arrayArrayInteger"`
ArrayArrayNumber [][]float64 `json:"arrayArrayNumber"`
ArrayArrayBoolean [][]bool `json:"arrayArrayBoolean"`
ArrayArrayString [][]string `json:"arrayArrayString"`
// basic map
MapInteger map[string]int `json:"mapInteger"`
MapNumber map[string]float64 `json:"mapNumber"`
MapBoolean map[string]bool `json:"mapBoolean"`
MapString map[string]string `json:"mapString"`
// basic map array
MapArrayInteger map[string][]int `json:"mapArrayInteger"`
MapArrayNumber map[string][]float64 `json:"mapArrayNumber"`
MapArrayBoolean map[string][]bool `json:"mapArrayBoolean"`
MapArrayString map[string][]string `json:"mapArrayString"`
// basic map map
MapMapInteger map[string]map[string]int `json:"mapMapInteger"`
MapMapNumber map[string]map[string]float64 `json:"mapMapNumber"`
MapMapBoolean map[string]map[string]bool `json:"mapMapBoolean"`
MapMapString map[string]map[string]string `json:"mapMapString"`
// Object
Object ComplexJsonLevel1 `json:"object"`
PointerObject *ComplexJsonLevel1 `json:"pointerObject"`
// Object array
ArrayObject []ComplexJsonLevel1 `json:"arrayObject"`
ArrayPointerObject []*ComplexJsonLevel1 `json:"arrayPointerObject"`
// Object map
MapObject map[string]ComplexJsonLevel1 `json:"mapObject"`
MapPointerObject map[string]*ComplexJsonLevel1 `json:"mapPointerObject"`
// Object array array
ArrayArrayObject [][]ComplexJsonLevel1 `json:"arrayArrayObject"`
ArrayArrayPointerObject [][]*ComplexJsonLevel1 `json:"arrayArrayPointerObject"`
// Object array map
ArrayMapObject []map[string]ComplexJsonLevel1 `json:"arrayMapObject"`
ArrayMapPointerObject []map[string]*ComplexJsonLevel1 `json:"arrayMapPointerObject"`
// Object map array
MapArrayObject map[string][]ComplexJsonLevel1 `json:"mapArrayObject"`
MapArrayPointerObject map[string][]*ComplexJsonLevel1 `json:"mapArrayPointerObject"`
}
)
@server (
tags: "postJson" // tags corresponding to Swagger
summary: "json API set" // summary corresponding to Swagger
)
service Swagger {
@doc (
description: "simple json request body API"
)
@handler jsonSimple
post /json/simple (JsonReq) returns (JsonResp)
@doc (
description: "complex json request body API"
)
@handler jsonComplex
post /json/complex (ComplexJsonReq) returns (ComplexJsonResp)
}

View File

@ -0,0 +1,247 @@
syntax = "v1"
info (
title: "演示 API" // 對應 swagger 的 title
description: "演示 api 生成 swagger 文件的 api 完整寫法" // 對應 swagger 的 description
version: "v1" // 對應 swagger 的 version
termsOfService: "https://github.com/zeromicro/go-zero" // 對應 swagger 的 termsOfService
contactName: "keson.an" // 對應 swagger 的 contactName
contactURL: "https://github.com/zeromicro/go-zero" // 對應 swagger 的 contactURL
contactEmail: "example@gmail.com" // 對應 swagger 的 contactEmail
licenseName: "MIT" // 對應 swagger 的 licenseName
licenseURL: "https://github.com/zeromicro/go-zero" // 對應 swagger 的 licenseURL
consumes: "application/json" // 對應 swagger 的 consumes不填預設為 application/json
produces: "application/json" // 對應 swagger 的 produces不填預設為 application/json
schemes: "http,https" // 對應 swagger 的 schemes不填預設為 https
host: "example.com" // 對應 swagger 的 host不填預設為 127.0.0.1
basePath: "/v1" // 對應 swagger 的 basePath不填預設為 /
wrapCodeMsg: true // 是否用 code-msg 通用回應體,如果開啟,則以格式 {"code":0,"msg":"OK","data":$data} 包括回應體
bizCodeEnumDescription: "1001-未登入<br>1002-無權限操作" // 全域業務錯誤碼列舉描述json 格式key 為業務錯誤碼value 為該錯誤碼的描述,僅當 wrapCodeMsg 為 true 時生效
// securityDefinitionsFromJson 為自訂鑑權配置json 內容將直接放入 swagger 的 securityDefinitions 中,
// 格式參考 https://swagger.io/specification/v2/#security-definitions-object
// 在 api 的 @server 中可宣告 authType 來指定其路由使用的鑑權類型
securityDefinitionsFromJson: `{"apiKey":{"description":"apiKey 類型鑑權自訂","type":"apiKey","name":"x-api-key","in":"header"}}`
useDefinitions: true// 開啟宣告將生成 models 進行關聯definitions 僅對回應體和 json 請求體生效
)
type (
QueryReq {
Id int `form:"id,range=[1:10000],example=10"`
Name string `form:"name,example=keson.an"`
Avatar string `form:"avatar,optional,example=https://example.com/avatar.png"`
}
QueryResp {
Id int `json:"id,example=10"`
Name string `json:"name,example=keson.an"`
}
PathQueryReq {
Id int `path:"id,range=[1:10000],example=10"`
Name string `form:"name,example=keson.an"`
}
PathQueryResp {
Id int `json:"id,example=10"`
Name string `json:"name,example=keson.an"`
}
)
@server (
tags: "query 演示" // 對應 swagger 的 tags可以對 swagger 中的 api 進行分組
summary: "query 類型介面集合" // 對應 swagger 的 summary
prefix: v1
authType: apiKey // 指定該路由使用的鑑權類型,值為 securityDefinitionsFromJson 中定義的名稱
group:"demo"
)
service Swagger {
@doc (
description: "query 接口"
bizCodeEnumDescription: " 1003-用不存在<br>1004-非法操作" // 介面級別業務錯誤碼列舉描述會覆蓋全域的業務錯誤碼json 格式key 為業務錯誤碼value 為該錯誤碼的描述,僅當 wrapCodeMsg 為 true 且 useDefinitions 為 false 時生效
)
@handler query
get /query (QueryReq) returns (QueryResp)
@doc (
description: "query path 中包含 id 欄位介面"
)
@handler queryPath
get /query/:id (PathQueryReq) returns (PathQueryResp)
}
type (
FormReq {
Id int `form:"id,range=[1:10000],example=10"`
Name string `form:"name,example=keson.an"`
}
FormResp {
Id int `json:"id,example=10"`
Name string `json:"name,example=keson.an"`
}
)
@server (
tags: "form 表單 api 演示" // 對應 swagger 的 tags可以對 swagger 中的 api 進行分組
summary: "form 表單類型介面集合" // 對應 swagger 的 summary
)
service Swagger {
@doc (
description: "form 接口"
)
@handler form
post /form (FormReq) returns (FormResp)
}
type (
JsonReq {
Id int `json:"id,range=[1:10000],example=10"`
Name string `json:"name,example=keson.an"`
Avatar string `json:"avatar,optional"`
Language string `json:"language,options=golang|java|python|typescript|rust"`
Gender string `json:"gender,default=male,options=male|female,example=male"`
}
JsonResp {
Id int `json:"id"`
Name string `json:"name"`
Avatar string `json:"avatar"`
Language string `json:"language"`
Gender string `json:"gender"`
}
ComplexJsonLevel2 {
// basic
Integer int `json:"integer,example=1"`
Number float64 `json:"number,example=1.1"`
Boolean bool `json:"boolean,options=true|false,example=true"`
String string `json:"string,example=some text"`
}
ComplexJsonLevel1 {
// basic
Integer int `json:"integer,example=1"`
Number float64 `json:"number,example=1.1"`
Boolean bool `json:"boolean,options=true|false,example=true"`
String string `json:"string,example=some text"`
// Object
Object ComplexJsonLevel2 `json:"object"`
PointerObject *ComplexJsonLevel2 `json:"pointerObject"`
}
ComplexJsonReq {
// basic
Integer int `json:"integer,example=1"`
Number float64 `json:"number,example=1.1"`
Boolean bool `json:"boolean,options=true|false,example=true"`
String string `json:"string,example=some text"`
// basic array
ArrayInteger []int `json:"arrayInteger"`
ArrayNumber []float64 `json:"arrayNumber"`
ArrayBoolean []bool `json:"arrayBoolean"`
ArrayString []string `json:"arrayString"`
// basic array array
ArrayArrayInteger [][]int `json:"arrayArrayInteger"`
ArrayArrayNumber [][]float64 `json:"arrayArrayNumber"`
ArrayArrayBoolean [][]bool `json:"arrayArrayBoolean"`
ArrayArrayString [][]string `json:"arrayArrayString"`
// basic map
MapInteger map[string]int `json:"mapInteger"`
MapNumber map[string]float64 `json:"mapNumber"`
MapBoolean map[string]bool `json:"mapBoolean"`
MapString map[string]string `json:"mapString"`
// basic map array
MapArrayInteger map[string][]int `json:"mapArrayInteger"`
MapArrayNumber map[string][]float64 `json:"mapArrayNumber"`
MapArrayBoolean map[string][]bool `json:"mapArrayBoolean"`
MapArrayString map[string][]string `json:"mapArrayString"`
// basic map map
MapMapInteger map[string]map[string]int `json:"mapMapInteger"`
MapMapNumber map[string]map[string]float64 `json:"mapMapNumber"`
MapMapBoolean map[string]map[string]bool `json:"mapMapBoolean"`
MapMapString map[string]map[string]string `json:"mapMapString"`
MapMapObject map[string]map[string]ComplexJsonLevel1 `json:"mapMapObject"`
MapMapPointerObject map[string]map[string]*ComplexJsonLevel1 `json:"mapMapPointerObject"`
// Object
Object ComplexJsonLevel1 `json:"object"`
PointerObject *ComplexJsonLevel1 `json:"pointerObject"`
// Object array
ArrayObject []ComplexJsonLevel1 `json:"arrayObject"`
ArrayPointerObject []*ComplexJsonLevel1 `json:"arrayPointerObject"`
// Object map
MapObject map[string]ComplexJsonLevel1 `json:"mapObject"`
MapPointerObject map[string]*ComplexJsonLevel1 `json:"mapPointerObject"`
// Object array array
ArrayArrayObject [][]ComplexJsonLevel1 `json:"arrayArrayObject"`
ArrayArrayPointerObject [][]*ComplexJsonLevel1 `json:"arrayArrayPointerObject"`
// Object array map
ArrayMapObject []map[string]ComplexJsonLevel1 `json:"arrayMapObject"`
ArrayMapPointerObject []map[string]*ComplexJsonLevel1 `json:"arrayMapPointerObject"`
// Object map array
MapArrayObject map[string][]ComplexJsonLevel1 `json:"mapArrayObject"`
MapArrayPointerObject map[string][]*ComplexJsonLevel1 `json:"mapArrayPointerObject"`
}
ComplexJsonResp {
// basic
Integer int `json:"integer,example=1"`
Number float64 `json:"number,example=1.1"`
Boolean bool `json:"boolean,options=true|false,example=true"`
String string `json:"string,example=some text"`
// basic array
ArrayInteger []int `json:"arrayInteger"`
ArrayNumber []float64 `json:"arrayNumber"`
ArrayBoolean []bool `json:"arrayBoolean"`
ArrayString []string `json:"arrayString"`
// basic array array
ArrayArrayInteger [][]int `json:"arrayArrayInteger"`
ArrayArrayNumber [][]float64 `json:"arrayArrayNumber"`
ArrayArrayBoolean [][]bool `json:"arrayArrayBoolean"`
ArrayArrayString [][]string `json:"arrayArrayString"`
// basic map
MapInteger map[string]int `json:"mapInteger"`
MapNumber map[string]float64 `json:"mapNumber"`
MapBoolean map[string]bool `json:"mapBoolean"`
MapString map[string]string `json:"mapString"`
// basic map array
MapArrayInteger map[string][]int `json:"mapArrayInteger"`
MapArrayNumber map[string][]float64 `json:"mapArrayNumber"`
MapArrayBoolean map[string][]bool `json:"mapArrayBoolean"`
MapArrayString map[string][]string `json:"mapArrayString"`
// basic map map
MapMapInteger map[string]map[string]int `json:"mapMapInteger"`
MapMapNumber map[string]map[string]float64 `json:"mapMapNumber"`
MapMapBoolean map[string]map[string]bool `json:"mapMapBoolean"`
MapMapString map[string]map[string]string `json:"mapMapString"`
MapMapObject map[string]map[string]ComplexJsonLevel1 `json:"mapMapObject"`
MapMapPointerObject map[string]map[string]*ComplexJsonLevel1 `json:"mapMapPointerObject"`
// Object
Object ComplexJsonLevel1 `json:"object"`
PointerObject *ComplexJsonLevel1 `json:"pointerObject"`
// Object array
ArrayObject []ComplexJsonLevel1 `json:"arrayObject"`
ArrayPointerObject []*ComplexJsonLevel1 `json:"arrayPointerObject"`
// Object map
MapObject map[string]ComplexJsonLevel1 `json:"mapObject"`
MapPointerObject map[string]*ComplexJsonLevel1 `json:"mapPointerObject"`
// Object array array
ArrayArrayObject [][]ComplexJsonLevel1 `json:"arrayArrayObject"`
ArrayArrayPointerObject [][]*ComplexJsonLevel1 `json:"arrayArrayPointerObject"`
// Object array map
ArrayMapObject []map[string]ComplexJsonLevel1 `json:"arrayMapObject"`
ArrayMapPointerObject []map[string]*ComplexJsonLevel1 `json:"arrayMapPointerObject"`
// Object map array
MapArrayObject map[string][]ComplexJsonLevel1 `json:"mapArrayObject"`
MapArrayPointerObject map[string][]*ComplexJsonLevel1 `json:"mapArrayPointerObject"`
}
)
@server (
tags: "post json api 演示" // 對應 swagger 的 tags可以對 swagger 中的 api 進行分組
summary: "json 請求類型介面集合" // 對應 swagger 的 summary
)
service Swagger {
@doc (
description: "簡單的 json 請求體介面"
)
@handler jsonSimple
post /json/simple (JsonReq) returns (JsonResp)
@doc (
description: "複雜的 json 請求體介面"
)
@handler jsonComplex
post /json/complex (ComplexJsonReq) returns (ComplexJsonResp)
}

View File

@ -0,0 +1,95 @@
syntax = "v1"
info (
title: "演示 API" // 對應 swagger 的 title
description: "演示 api 生成 swagger 文件的 api 完整寫法" // 對應 swagger 的 description
version: "v1" // 對應 swagger 的 version
termsOfService: "https://github.com/zeromicro/go-zero" // 對應 swagger 的 termsOfService
contactName: "keson.an" // 對應 swagger 的 contactName
contactURL: "https://github.com/zeromicro/go-zero" // 對應 swagger 的 contactURL
contactEmail: "example@gmail.com" // 對應 swagger 的 contactEmail
licenseName: "MIT" // 對應 swagger 的 licenseName
licenseURL: "https://github.com/zeromicro/go-zero" // 對應 swagger 的 licenseURL
consumes: "application/json" // 對應 swagger 的 consumes不填預設為 application/json
produces: "application/json" // 對應 swagger 的 produces不填預設為 application/json
schemes: "http,https" // 對應 swagger 的 schemes不填預設為 https
host: "example.com" // 對應 swagger 的 host不填預設為 127.0.0.1
basePath: "/v1" // 對應 swagger 的 basePath不填預設為 /
wrapCodeMsg: true // 是否用 code-msg 通用回應體,如果開啟,則以格式 {"code":0,"msg":"OK","data":$data} 包括回應體
bizCodeEnumDescription: "1001-未登入<br>1002-無權限操作" // 全域業務錯誤碼列舉描述json 格式key 為業務錯誤碼value 為該錯誤碼的描述,僅當 wrapCodeMsg 為 true 時生效
// securityDefinitionsFromJson 為自訂鑑權配置json 內容將直接放入 swagger 的 securityDefinitions 中,
// 格式參考 https://swagger.io/specification/v2/#security-definitions-object
// 在 api 的 @server 中可宣告 authType 來指定其路由使用的鑑權類型
securityDefinitionsFromJson: `{"apiKey":{"description":"apiKey 類型鑑權自訂","type":"apiKey","name":"x-api-key","in":"header"}}`
useDefinitions: true// 開啟宣告將生成 models 進行關聯definitions 僅對回應體和 json 請求體生效
)
type (
QueryReq {
Id int `form:"id,range=[1:10000],example=10"`
Name string `form:"name,example=keson.an"`
Avatar string `form:"avatar,optional,example=https://example.com/avatar.png"`
}
QueryResp {
Id int `json:"id,example=10"`
Name string `json:"name,example=keson.an"`
}
// 錯誤響應
ErrorResponse {
Code string `json:"code"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
}
// 具體錯誤類型
ValidationError {
Code string `json:"code"`
Message string `json:"message"`
Fields []string `json:"fields"`
}
InsufficientStockError {
Code string `json:"code"`
Message string `json:"message"`
ProductID string `json:"product_id"`
Required int `json:"required"`
Available int `json:"available"`
}
InvalidPaymentError {
Code string `json:"code"`
Message string `json:"message"`
Method string `json:"method"`
}
UnauthorizedError {
Code string `json:"code"`
Message string `json:"message"`
Reason string `json:"reason"`
}
)
@server (
tags: "query 演示" // 對應 swagger 的 tags可以對 swagger 中的 api 進行分組
summary: "query 類型介面集合" // 對應 swagger 的 summary
prefix: v1
authType: apiKey // 指定該路由使用的鑑權類型,值為 securityDefinitionsFromJson 中定義的名稱
group:"demo"
)
service Swagger {
@doc (
description: "query 接口"
)
/*
@respdoc-201 (QueryResp) // 創建成功
@respdoc-400 (
300101: (ValidationError) 參數驗證失敗
300102: (InsufficientStockError) 庫存不足
300103: (InvalidPaymentError) 支付方式無效
) // 客戶端錯誤
@respdoc-401 (UnauthorizedError) // 未授權
@respdoc-500 (ErrorResponse) // 服務器錯誤
*/
@handler query
get /query (QueryReq) returns (QueryResp)
}

View File

@ -0,0 +1,3 @@
syntax = "v1"
import "example_respdoc.api"

View File

@ -0,0 +1,51 @@
syntax = "v1"
info (
title: "Test All Parameter Types"
version: "v1"
)
type (
// 同時包含所有類型的參數
AllParamsReq {
// Header 參數
Authorization string `header:"Authorization" validate:"required"`
XRequestID string `header:"X-Request-ID,optional"`
// Path 參數
UserID string `path:"userId" validate:"required"`
ItemID string `path:"itemId" validate:"required"`
// Query 參數 (form)
Page int `form:"page,optional"`
PageSize int `form:"pageSize,optional"`
Filter string `form:"filter,optional"`
// JSON Body 參數
Name string `json:"name" validate:"required"`
Description string `json:"description,optional"`
Tags []string `json:"tags,optional"`
}
Response {
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
)
@server (
prefix: /api/v1
)
service test {
// 測試所有參數類型同時存在
@handler UpdateWithAllParams
put /users/:userId/items/:itemId (AllParamsReq) returns (Response)
// 測試只有 path + query
@handler GetWithPathQuery
get /users/:userId/items/:itemId (AllParamsReq) returns (Response)
// 測試 path + header + body (POST)
@handler CreateWithPathHeaderBody
post /users/:userId/items (AllParamsReq) returns (Response)
}

View File

@ -0,0 +1,41 @@
syntax = "v1"
info (
title: "Test Header with Body"
version: "v1"
)
type (
HeaderDef {
Token string `header:"Authorization" validate:"required"`
}
// 方式1: 嵌入 header 結構體
UpdateReqWithEmbed {
Name string `json:"name"`
Age int `json:"age"`
HeaderDef
}
// 方式2: 分開定義
UpdateReqSeparate {
Token string `header:"Authorization" validate:"required"`
Name string `json:"name"`
Age int `json:"age"`
}
Response {
Message string `json:"message"`
}
)
@server (
prefix: /api/v1
)
service test {
@handler TestWithEmbed
put /with-embed (UpdateReqWithEmbed) returns (Response)
@handler TestSeparate
put /separate (UpdateReqSeparate) returns (Response)
}

View File

@ -0,0 +1,59 @@
syntax = "v1"
info (
title: "Test Header and Path Parameters"
version: "v1"
)
type (
HeaderReq {
Token string `header:"Authorization" validate:"required"`
}
PathReq {
ID string `path:"id" validate:"required"`
}
QueryReq {
Name string `form:"name,optional"`
}
BodyReq {
Data string `json:"data"`
}
Response {
Message string `json:"message"`
}
CombinedReq {
Token string `header:"Authorization" validate:"required"`
ID string `path:"id" validate:"required"`
Data string `json:"data"`
}
)
@server (
prefix: /api/v1
)
service test {
// Test header parameter
@handler TestHeader
get /header (HeaderReq) returns (Response)
// Test path parameter
@handler TestPath
get /path/:id (PathReq) returns (Response)
// Test query parameter
@handler TestQuery
get /query (QueryReq) returns (Response)
// Test body parameter
@handler TestBody
post /body (BodyReq) returns (Response)
// Test combined
@handler TestCombined
post /combined/:id (CombinedReq) returns (Response)
}

View File

@ -0,0 +1,34 @@
module go-doc
go 1.26.1
require (
github.com/getkin/kin-openapi v0.128.0
github.com/go-openapi/spec v0.21.1-0.20250328170532-a3928469592e
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.11.1
github.com/zeromicro/go-zero/tools/goctl v1.9.0
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/go-openapi/jsonpointer v0.21.1 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/swag v0.23.1 // indirect
github.com/gookit/color v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/invopop/yaml v0.3.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/zeromicro/go-zero v1.9.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@ -0,0 +1,70 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4=
github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94=
github.com/getkin/kin-openapi v0.128.0 h1:jqq3D9vC9pPq1dGcOCv7yOp1DaEe7c/T1vzcLbITSp4=
github.com/getkin/kin-openapi v0.128.0/go.mod h1:OZrfXzUfGrNbsKj+xmFBx6E5c6yH3At/tAKSc2UszXM=
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
github.com/go-openapi/spec v0.21.1-0.20250328170532-a3928469592e h1:auobAirzhPsLHMso0NVMqK0QunuLDYCK83KnaVUM/RU=
github.com/go-openapi/spec v0.21.1-0.20250328170532-a3928469592e/go.mod h1:NAKTe9SplQBxIUlHlsuId1jk1I7bWTVV/2q/GtdRi6g=
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/gookit/assert v0.1.1 h1:lh3GcawXe/p+cU7ESTZ5Ui3Sm/x8JWpIis4/1aF0mY0=
github.com/gookit/assert v0.1.1/go.mod h1:jS5bmIVQZTIwk42uXl4lyj4iaaxx32tqH16CFj0VX2E=
github.com/gookit/color v1.6.0 h1:JjJXBTk1ETNyqyilJhkTXJYYigHG24TM9Xa2M1xAhRA=
github.com/gookit/color v1.6.0/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/zeromicro/go-zero v1.9.0 h1:hlVtQCSHPszQdcwZTawzGwTej1G2mhHybYzMRLuwCt4=
github.com/zeromicro/go-zero v1.9.0/go.mod h1:TMyCxiaOjLQ3YxyYlJrejaQZF40RlzQ3FVvFu5EbcV4=
github.com/zeromicro/go-zero/tools/goctl v1.9.0 h1:Ro5YK1iTarQc5XO0BYysRr18+1seBY36YjnxmDkyKRg=
github.com/zeromicro/go-zero/tools/goctl v1.9.0/go.mod h1:ypiu1QOOEMTHd0Ft8knzqmq4PWBI7+l3ozoi1stGVqo=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,79 @@
package swagger
import (
"strconv"
"go-doc/internal/util"
)
func getBoolFromKVOrDefault(properties map[string]string, key string, def bool) bool {
if len(properties) == 0 {
return def
}
val, ok := properties[key]
if !ok || len(val) == 0 {
return def
}
//I think this function and those below should handle error, but they didn't.
//Since a default value (def) is provided, any parsing errors will result in the default being returned.
// Try to unquote if the value is quoted, otherwise use as-is
str := val
if unquoted, err := strconv.Unquote(val); err == nil {
str = unquoted
}
if len(str) == 0 {
return def
}
res, _ := strconv.ParseBool(str)
return res
}
func getStringFromKVOrDefault(properties map[string]string, key string, def string) string {
if len(properties) == 0 {
return def
}
val, ok := properties[key]
if !ok || len(val) == 0 {
return def
}
// Try to unquote if the value is quoted, otherwise use as-is
str := val
if unquoted, err := strconv.Unquote(val); err == nil {
str = unquoted
}
return str
}
func getListFromInfoOrDefault(properties map[string]string, key string, def []string) []string {
if len(properties) == 0 {
return def
}
val, ok := properties[key]
if !ok || len(val) == 0 {
return def
}
// Try to unquote if the value is quoted, otherwise use as-is
str := val
if unquoted, err := strconv.Unquote(val); err == nil {
str = unquoted
}
resp := util.FieldsAndTrimSpace(str, commaRune)
if len(resp) == 0 {
return def
}
return resp
}
func getFirstUsableString(def ...string) string {
if len(def) == 0 {
return ""
}
for _, val := range def {
str, err := strconv.Unquote(val)
if err == nil && len(str) != 0 {
return str
}
}
return ""
}

View File

@ -0,0 +1,53 @@
package swagger
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_getBoolFromKVOrDefault(t *testing.T) {
properties := map[string]string{
"enabled": `"true"`,
"disabled": `"false"`,
"invalid": `"notabool"`,
"empty_value": `""`,
}
assert.True(t, getBoolFromKVOrDefault(properties, "enabled", false))
assert.False(t, getBoolFromKVOrDefault(properties, "disabled", true))
assert.False(t, getBoolFromKVOrDefault(properties, "invalid", false))
assert.True(t, getBoolFromKVOrDefault(properties, "missing", true))
assert.False(t, getBoolFromKVOrDefault(properties, "empty_value", false))
assert.False(t, getBoolFromKVOrDefault(nil, "nil", false))
assert.False(t, getBoolFromKVOrDefault(map[string]string{}, "empty", false))
}
func Test_getStringFromKVOrDefault(t *testing.T) {
properties := map[string]string{
"name": `"example"`,
"empty": `""`,
}
assert.Equal(t, "example", getStringFromKVOrDefault(properties, "name", "default"))
assert.Equal(t, "default", getStringFromKVOrDefault(properties, "empty", "default"))
assert.Equal(t, "default", getStringFromKVOrDefault(properties, "missing", "default"))
assert.Equal(t, "default", getStringFromKVOrDefault(nil, "nil", "default"))
assert.Equal(t, "default", getStringFromKVOrDefault(map[string]string{}, "empty", "default"))
}
func Test_getListFromInfoOrDefault(t *testing.T) {
properties := map[string]string{
"list": `"a, b, c"`,
"empty": `""`,
}
assert.Equal(t, []string{"a", " b", " c"}, getListFromInfoOrDefault(properties, "list", []string{"default"}))
assert.Equal(t, []string{"default"}, getListFromInfoOrDefault(properties, "empty", []string{"default"}))
assert.Equal(t, []string{"default"}, getListFromInfoOrDefault(properties, "missing", []string{"default"}))
assert.Equal(t, []string{"default"}, getListFromInfoOrDefault(nil, "nil", []string{"default"}))
assert.Equal(t, []string{"default"}, getListFromInfoOrDefault(map[string]string{}, "empty", []string{"default"}))
assert.Equal(t, []string{"default"}, getListFromInfoOrDefault(map[string]string{
"foo": ",,",
}, "foo", []string{"default"}))
}

View File

@ -0,0 +1,138 @@
package swagger
import "github.com/zeromicro/go-zero/tools/goctl/api/spec"
func fillAllStructs(api *spec.ApiSpec) {
var (
tps []spec.Type
structTypes = make(map[string]spec.DefineStruct)
groups []spec.Group
)
for _, tp := range api.Types {
structTypes[tp.Name()] = tp.(spec.DefineStruct)
}
for _, tp := range api.Types {
filledTP := fillStruct("", tp, structTypes)
tps = append(tps, filledTP)
structTypes[filledTP.Name()] = filledTP.(spec.DefineStruct)
}
for _, group := range api.Service.Groups {
routes := make([]spec.Route, 0, len(group.Routes))
for _, route := range group.Routes {
route.RequestType = fillStruct("", route.RequestType, structTypes)
route.ResponseType = fillStruct("", route.ResponseType, structTypes)
routes = append(routes, route)
}
group.Routes = routes
groups = append(groups, group)
}
api.Service.Groups = groups
api.Types = tps
}
func fillStruct(parent string, tp spec.Type, allTypes map[string]spec.DefineStruct) spec.Type {
switch val := tp.(type) {
case spec.DefineStruct:
var members []spec.Member
for _, member := range val.Members {
switch memberType := member.Type.(type) {
case spec.PointerType:
member.Type = spec.PointerType{
RawName: memberType.RawName,
Type: fillStruct(val.Name(), memberType.Type, allTypes),
}
case spec.ArrayType:
member.Type = spec.ArrayType{
RawName: memberType.RawName,
Value: fillStruct(val.Name(), memberType.Value, allTypes),
}
case spec.MapType:
member.Type = spec.MapType{
RawName: memberType.RawName,
Key: memberType.Key,
Value: fillStruct(val.Name(), memberType.Value, allTypes),
}
case spec.DefineStruct:
if parent != memberType.Name() { // avoid recursive struct
if st, ok := allTypes[memberType.Name()]; ok {
member.Type = fillStruct("", st, allTypes)
}
}
case spec.NestedStruct:
member.Type = fillStruct("", member.Type, allTypes)
}
members = append(members, member)
}
if len(members) == 0 {
st, ok := allTypes[val.RawName]
if ok {
members = st.Members
}
}
val.Members = members
return val
case spec.NestedStruct:
members := make([]spec.Member, 0, len(val.Members))
for _, member := range val.Members {
switch memberType := member.Type.(type) {
case spec.PointerType:
member.Type = spec.PointerType{
RawName: memberType.RawName,
Type: fillStruct(val.Name(), memberType.Type, allTypes),
}
case spec.ArrayType:
member.Type = spec.ArrayType{
RawName: memberType.RawName,
Value: fillStruct(val.Name(), memberType.Value, allTypes),
}
case spec.MapType:
member.Type = spec.MapType{
RawName: memberType.RawName,
Key: memberType.Key,
Value: fillStruct(val.Name(), memberType.Value, allTypes),
}
case spec.DefineStruct:
if parent != memberType.Name() { // avoid recursive struct
if st, ok := allTypes[memberType.Name()]; ok {
member.Type = fillStruct("", st, allTypes)
}
}
case spec.NestedStruct:
if parent != memberType.Name() {
if st, ok := allTypes[memberType.Name()]; ok {
member.Type = fillStruct("", st, allTypes)
}
}
}
members = append(members, member)
}
if len(members) == 0 {
st, ok := allTypes[val.RawName]
if ok {
members = st.Members
}
}
val.Members = members
return val
case spec.PointerType:
return spec.PointerType{
RawName: val.RawName,
Type: fillStruct(parent, val.Type, allTypes),
}
case spec.ArrayType:
return spec.ArrayType{
RawName: val.RawName,
Value: fillStruct(parent, val.Value, allTypes),
}
case spec.MapType:
return spec.MapType{
RawName: val.RawName,
Key: val.Key,
Value: fillStruct(parent, val.Value, allTypes),
}
default:
return tp
}
}

View File

@ -0,0 +1,112 @@
package swagger
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/zeromicro/go-zero/tools/goctl/pkg/parser/api/parser"
"go-doc/internal/util"
"gopkg.in/yaml.v2"
)
var (
// VarStringAPI specifies the API filename.
VarStringAPI string
// VarStringDir specifies the directory to generate swagger file.
VarStringDir string
// VarStringFilename specifies the generated swagger file name without the extension.
VarStringFilename string
// VarBoolYaml specifies whether to generate a YAML file.
VarBoolYaml bool
// VarStringSpecVersion specifies the OpenAPI specification version (swagger2.0 or openapi3.0).
VarStringSpecVersion string
)
func Command(_ *cobra.Command, _ []string) error {
if len(VarStringAPI) == 0 {
return errors.New("missing -api")
}
if len(VarStringDir) == 0 {
return errors.New("missing -dir")
}
// Validate spec version
if VarStringSpecVersion != "swagger2.0" && VarStringSpecVersion != "openapi3.0" {
return errors.New("spec-version must be either 'swagger2.0' or 'openapi3.0'")
}
api, err := parser.Parse(VarStringAPI, "")
if err != nil {
return err
}
fillAllStructs(api)
if err := api.Validate(); err != nil {
return err
}
var data []byte
if VarStringSpecVersion == "openapi3.0" {
// Generate OpenAPI 3.0
swagger2, err := spec2Swagger(api)
if err != nil {
return err
}
openapi3Doc := convertSwagger2ToOpenAPI3(swagger2)
data, err = json.MarshalIndent(openapi3Doc, "", " ")
if err != nil {
return err
}
} else {
// Generate Swagger 2.0 (default)
swagger, err := spec2Swagger(api)
if err != nil {
return err
}
data, err = json.MarshalIndent(swagger, "", " ")
if err != nil {
return err
}
}
err = util.MkdirIfNotExist(VarStringDir)
if err != nil {
return err
}
filename := VarStringFilename
if filename == "" {
base := filepath.Base(VarStringAPI)
filename = strings.TrimSuffix(base, filepath.Ext(base))
}
if VarBoolYaml {
filePath := filepath.Join(VarStringDir, filename+".yaml")
var jsonObj interface{}
if err := yaml.Unmarshal(data, &jsonObj); err != nil {
return err
}
data, err := yaml.Marshal(jsonObj)
if err != nil {
return err
}
return os.WriteFile(filePath, data, 0644)
}
// generate json file
filePath := filepath.Join(VarStringDir, filename+".json")
return os.WriteFile(filePath, data, 0644)
}

View File

@ -0,0 +1,65 @@
package swagger
const (
tagHeader = "header"
tagPath = "path"
tagForm = "form"
tagJson = "json"
defFlag = "default="
enumFlag = "options="
rangeFlag = "range="
exampleFlag = "example="
optionalFlag = "optional"
paramsInHeader = "header"
paramsInPath = "path"
paramsInQuery = "query"
paramsInBody = "body"
paramsInForm = "formData"
swaggerTypeInteger = "integer"
swaggerTypeNumber = "number"
swaggerTypeString = "string"
swaggerTypeBoolean = "boolean"
swaggerTypeArray = "array"
swaggerTypeObject = "object"
swaggerVersion = "2.0"
applicationJson = "application/json"
applicationForm = "application/x-www-form-urlencoded"
schemeHttps = "https"
defaultBasePath = "/"
)
const (
propertyKeyUseDefinitions = "useDefinitions"
propertyKeyExternalDocsDescription = "externalDocsDescription"
propertyKeyExternalDocsURL = "externalDocsURL"
propertyKeyTitle = "title"
propertyKeyTermsOfService = "termsOfService"
propertyKeyDescription = "description"
propertyKeyVersion = "version"
propertyKeyContactName = "contactName"
propertyKeyContactURL = "contactURL"
propertyKeyContactEmail = "contactEmail"
propertyKeyLicenseName = "licenseName"
propertyKeyLicenseURL = "licenseURL"
propertyKeyProduces = "produces"
propertyKeyConsumes = "consumes"
propertyKeySchemes = "schemes"
propertyKeyTags = "tags"
propertyKeySummary = "summary"
propertyKeyGroup = "group"
// propertyKeyOperationId = "operationId"
propertyKeyDeprecated = "deprecated"
propertyKeyPrefix = "prefix"
propertyKeyAuthType = "authType"
propertyKeyHost = "host"
propertyKeyBasePath = "basePath"
propertyKeyWrapCodeMsg = "wrapCodeMsg"
propertyKeyBizCodeEnumDescription = "bizCodeEnumDescription"
)
const (
defaultValueOfPropertyUseDefinition = true
)

View File

@ -0,0 +1,25 @@
package swagger
import (
"net/http"
"strings"
"github.com/zeromicro/go-zero/tools/goctl/api/spec"
)
func consumesFromTypeOrDef(ctx Context, method string, tp spec.Type) []string {
if strings.EqualFold(method, http.MethodGet) {
return []string{}
}
if tp == nil {
return []string{}
}
structType, ok := tp.(spec.DefineStruct)
if !ok {
return []string{}
}
if typeContainsTag(ctx, structType, tagJson) {
return []string{applicationJson}
}
return []string{applicationForm}
}

View File

@ -0,0 +1,68 @@
package swagger
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/zeromicro/go-zero/tools/goctl/api/spec"
)
func TestConsumesFromTypeOrDef(t *testing.T) {
tests := []struct {
name string
method string
tp spec.Type
expected []string
}{
{
name: "GET method with nil type",
method: http.MethodGet,
tp: nil,
expected: []string{},
},
{
name: "post nil",
method: http.MethodPost,
tp: nil,
expected: []string{},
},
{
name: "json tag",
method: http.MethodPost,
tp: spec.DefineStruct{
Members: []spec.Member{
{
Tag: `json:"example"`,
},
},
},
expected: []string{applicationJson},
},
{
name: "form tag",
method: http.MethodPost,
tp: spec.DefineStruct{
Members: []spec.Member{
{
Tag: `form:"example"`,
},
},
},
expected: []string{applicationForm},
},
{
name: "Non struct type",
method: http.MethodPost,
tp: spec.ArrayType{},
expected: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := consumesFromTypeOrDef(testingContext(t), tt.method, tt.tp)
assert.Equal(t, tt.expected, result)
})
}
}

View File

@ -0,0 +1,32 @@
package swagger
import (
"testing"
"github.com/zeromicro/go-zero/tools/goctl/api/spec"
)
type Context struct {
Api *spec.ApiSpec // API 規範,用於查找類型定義
UseDefinitions bool
WrapCodeMsg bool
BizCodeEnumDescription string
}
func testingContext(_ *testing.T) Context {
return Context{}
}
func contextFromApi(api *spec.ApiSpec) Context {
if len(api.Info.Properties) == 0 {
return Context{
Api: api,
}
}
return Context{
Api: api,
UseDefinitions: getBoolFromKVOrDefault(api.Info.Properties, propertyKeyUseDefinitions, defaultValueOfPropertyUseDefinition),
WrapCodeMsg: getBoolFromKVOrDefault(api.Info.Properties, propertyKeyWrapCodeMsg, false),
BizCodeEnumDescription: getStringFromKVOrDefault(api.Info.Properties, propertyKeyBizCodeEnumDescription, "business code"),
}
}

View File

@ -0,0 +1,32 @@
package swagger
import (
"github.com/go-openapi/spec"
apiSpec "github.com/zeromicro/go-zero/tools/goctl/api/spec"
)
func definitionsFromTypes(ctx Context, types []apiSpec.Type) spec.Definitions {
if !ctx.UseDefinitions {
return nil
}
definitions := make(spec.Definitions)
for _, tp := range types {
typeName := tp.Name()
definitions[typeName] = schemaFromType(ctx, tp)
}
return definitions
}
func schemaFromType(ctx Context, tp apiSpec.Type) spec.Schema {
p, r := propertiesFromType(ctx, tp)
props := spec.SchemaProps{
Type: typeFromGoType(ctx, tp),
Properties: p,
AdditionalProperties: mapFromGoType(ctx, tp),
Items: itemsFromGoType(ctx, tp),
Required: r,
}
return spec.Schema{
SchemaProps: props,
}
}

View File

@ -0,0 +1,506 @@
package swagger
import (
"github.com/getkin/kin-openapi/openapi3"
"github.com/go-openapi/spec"
)
// convertSwagger2ToOpenAPI3 converts a Swagger 2.0 spec to OpenAPI 3.0
func convertSwagger2ToOpenAPI3(swagger2 *spec.Swagger) *openapi3.T {
openapi3Doc := &openapi3.T{
OpenAPI: "3.0.3",
Info: &openapi3.Info{
Title: swagger2.Info.Title,
Description: swagger2.Info.Description,
TermsOfService: swagger2.Info.TermsOfService,
Version: swagger2.Info.Version,
},
Servers: openapi3.Servers{},
Paths: &openapi3.Paths{},
}
// Convert extensions
if swagger2.Info.VendorExtensible.Extensions != nil {
openapi3Doc.Extensions = make(map[string]interface{})
for k, v := range swagger2.Info.VendorExtensible.Extensions {
openapi3Doc.Extensions[k] = v
}
}
// Add extensions from swagger root
if swagger2.Extensions != nil {
if openapi3Doc.Extensions == nil {
openapi3Doc.Extensions = make(map[string]interface{})
}
for k, v := range swagger2.Extensions {
openapi3Doc.Extensions[k] = v
}
}
// Convert Contact
if swagger2.Info.Contact != nil {
openapi3Doc.Info.Contact = &openapi3.Contact{
Name: swagger2.Info.Contact.Name,
URL: swagger2.Info.Contact.URL,
Email: swagger2.Info.Contact.Email,
}
}
// Convert License
if swagger2.Info.License != nil {
openapi3Doc.Info.License = &openapi3.License{
Name: swagger2.Info.License.Name,
URL: swagger2.Info.License.URL,
}
}
// Convert Servers from host, basePath, and schemes
if len(swagger2.Host) > 0 || len(swagger2.BasePath) > 0 {
schemes := swagger2.Schemes
if len(schemes) == 0 {
schemes = []string{"https"}
}
for _, scheme := range schemes {
serverURL := scheme + "://"
if len(swagger2.Host) > 0 {
serverURL += swagger2.Host
} else {
serverURL += "localhost"
}
if len(swagger2.BasePath) > 0 && swagger2.BasePath != "/" {
serverURL += swagger2.BasePath
}
openapi3Doc.Servers = append(openapi3Doc.Servers, &openapi3.Server{
URL: serverURL,
})
}
}
// Convert Paths
openapi3Doc.Paths = openapi3.NewPaths()
for path, pathItem := range swagger2.Paths.Paths {
newPathItem := &openapi3.PathItem{}
if pathItem.Get != nil {
newPathItem.Get = convertOperation(pathItem.Get)
}
if pathItem.Post != nil {
newPathItem.Post = convertOperation(pathItem.Post)
}
if pathItem.Put != nil {
newPathItem.Put = convertOperation(pathItem.Put)
}
if pathItem.Delete != nil {
newPathItem.Delete = convertOperation(pathItem.Delete)
}
if pathItem.Patch != nil {
newPathItem.Patch = convertOperation(pathItem.Patch)
}
if pathItem.Head != nil {
newPathItem.Head = convertOperation(pathItem.Head)
}
if pathItem.Options != nil {
newPathItem.Options = convertOperation(pathItem.Options)
}
openapi3Doc.Paths.Set(path, newPathItem)
}
// Convert Definitions to Components/Schemas
if len(swagger2.Definitions) > 0 {
openapi3Doc.Components = &openapi3.Components{
Schemas: make(openapi3.Schemas),
}
for name, schema := range swagger2.Definitions {
openapi3Doc.Components.Schemas[name] = convertSchemaToSchemaRef(&schema)
}
}
// Convert SecurityDefinitions to Components/SecuritySchemes
if len(swagger2.SecurityDefinitions) > 0 {
if openapi3Doc.Components == nil {
openapi3Doc.Components = &openapi3.Components{}
}
openapi3Doc.Components.SecuritySchemes = make(openapi3.SecuritySchemes)
for name, secDef := range swagger2.SecurityDefinitions {
openapi3Doc.Components.SecuritySchemes[name] = convertSecurityScheme(secDef)
}
}
return openapi3Doc
}
// convertOperation converts a Swagger 2.0 operation to OpenAPI 3.0
func convertOperation(op *spec.Operation) *openapi3.Operation {
newOp := &openapi3.Operation{
Tags: op.Tags,
Summary: op.Summary,
Description: op.Description,
OperationID: op.ID,
Parameters: openapi3.Parameters{},
Responses: &openapi3.Responses{},
Deprecated: op.Deprecated,
}
// Convert ExternalDocs
if op.ExternalDocs != nil {
newOp.ExternalDocs = &openapi3.ExternalDocs{
Description: op.ExternalDocs.Description,
URL: op.ExternalDocs.URL,
}
}
// Convert Parameters and RequestBody
var bodyParam *spec.Parameter
for _, param := range op.Parameters {
if param.In == "body" {
bodyParam = &param
} else {
newOp.Parameters = append(newOp.Parameters, convertParameter(&param))
}
}
// Convert body parameter to requestBody
if bodyParam != nil {
newOp.RequestBody = &openapi3.RequestBodyRef{
Value: convertBodyParameter(bodyParam, op.Consumes),
}
}
// Convert Responses
for code, response := range op.Responses.StatusCodeResponses {
newOp.Responses.Set(intToString(code), convertResponse(&response, op.Produces))
}
// Convert Security
if len(op.Security) > 0 {
newOp.Security = &openapi3.SecurityRequirements{}
for _, sec := range op.Security {
secReq := openapi3.SecurityRequirement{}
for key, scopes := range sec {
secReq[key] = scopes
}
*newOp.Security = append(*newOp.Security, secReq)
}
}
return newOp
}
// convertParameter converts a Swagger 2.0 parameter to OpenAPI 3.0
func convertParameter(param *spec.Parameter) *openapi3.ParameterRef {
schema := &openapi3.Schema{
Format: param.Format,
Default: param.Default,
}
if param.Type != "" {
schema.Type = &openapi3.Types{param.Type}
}
newParam := &openapi3.Parameter{
Name: param.Name,
In: param.In,
Description: param.Description,
Required: param.Required,
Schema: &openapi3.SchemaRef{Value: schema},
}
// Convert validation properties
if param.Maximum != nil {
max := *param.Maximum
newParam.Schema.Value.Max = &max
}
if param.Minimum != nil {
min := *param.Minimum
newParam.Schema.Value.Min = &min
}
if param.ExclusiveMaximum {
newParam.Schema.Value.ExclusiveMax = true
}
if param.ExclusiveMinimum {
newParam.Schema.Value.ExclusiveMin = true
}
if len(param.Enum) > 0 {
newParam.Schema.Value.Enum = param.Enum
}
// Handle array items
if param.Items != nil {
newParam.Schema.Value.Items = convertItemsToSchemaRef(param.Items)
}
return &openapi3.ParameterRef{Value: newParam}
}
// convertBodyParameter converts a body parameter to RequestBody
func convertBodyParameter(param *spec.Parameter, consumes []string) *openapi3.RequestBody {
if len(consumes) == 0 {
consumes = []string{"application/json"}
}
content := make(openapi3.Content)
for _, contentType := range consumes {
mediaType := &openapi3.MediaType{}
if param.Schema != nil {
mediaType.Schema = convertSchemaToSchemaRef(param.Schema)
}
content[contentType] = mediaType
}
return &openapi3.RequestBody{
Description: param.Description,
Required: param.Required,
Content: content,
}
}
// convertResponse converts a Swagger 2.0 response to OpenAPI 3.0
func convertResponse(response *spec.Response, produces []string) *openapi3.ResponseRef {
if len(produces) == 0 {
produces = []string{"application/json"}
}
newResp := &openapi3.Response{
Description: &response.Description,
}
if response.Schema != nil {
newResp.Content = make(openapi3.Content)
// 檢查是否有業務錯誤碼x-biz-codes
bizCodes, hasBizCodes := response.Extensions["x-biz-codes"]
if hasBizCodes {
// 使用 oneOf 來表示多個可能的回應類型
for _, contentType := range produces {
mediaType := &openapi3.MediaType{
Schema: createOneOfSchemaFromBizCodes(bizCodes),
}
newResp.Content[contentType] = mediaType
}
} else {
// 普通回應
for _, contentType := range produces {
mediaType := &openapi3.MediaType{
Schema: convertSchemaToSchemaRef(response.Schema),
}
newResp.Content[contentType] = mediaType
}
}
}
return &openapi3.ResponseRef{Value: newResp}
}
// createOneOfSchemaFromBizCodes 從業務錯誤碼創建 oneOf schema
func createOneOfSchemaFromBizCodes(bizCodesInterface interface{}) *openapi3.SchemaRef {
bizCodes, ok := bizCodesInterface.(map[string]BizCode)
if !ok {
// 嘗試轉換為 map[string]interface{}
bizCodesMap, ok := bizCodesInterface.(map[string]interface{})
if !ok {
return nil
}
// 轉換為 BizCode
bizCodes = make(map[string]BizCode)
for code, val := range bizCodesMap {
if valMap, ok := val.(map[string]interface{}); ok {
bizCode := BizCode{
Code: code,
}
if schema, ok := valMap["Schema"].(string); ok {
bizCode.Schema = schema
}
if desc, ok := valMap["Description"].(string); ok {
bizCode.Description = desc
}
bizCodes[code] = bizCode
}
}
}
if len(bizCodes) == 0 {
return nil
}
// 創建 oneOf schema
oneOfSchemas := make([]*openapi3.SchemaRef, 0, len(bizCodes))
for code, bizCode := range bizCodes {
// 創建每個業務錯誤碼的 schema 引用
ref := "#/components/schemas/" + bizCode.Schema
schemaRef := &openapi3.SchemaRef{
Ref: ref,
}
// 添加描述作為擴展
if bizCode.Description != "" {
schemaRef.Value = &openapi3.Schema{
Description: "業務錯誤碼 " + code + ": " + bizCode.Description,
}
}
oneOfSchemas = append(oneOfSchemas, schemaRef)
}
// 返回包含 oneOf 的 schema
return &openapi3.SchemaRef{
Value: &openapi3.Schema{
OneOf: oneOfSchemas,
},
}
}
// convertSchemaToSchemaRef converts a Swagger 2.0 schema to OpenAPI 3.0 SchemaRef
func convertSchemaToSchemaRef(schema *spec.Schema) *openapi3.SchemaRef {
if schema == nil {
return nil
}
// Handle $ref
if schema.Ref.String() != "" {
ref := schema.Ref.String()
// Convert #/definitions/ to #/components/schemas/
if len(ref) > 14 && ref[:14] == "#/definitions/" {
ref = "#/components/schemas/" + ref[14:]
}
return &openapi3.SchemaRef{Ref: ref}
}
newSchema := &openapi3.Schema{
Format: schema.Format,
Description: schema.Description,
Default: schema.Default,
Example: schema.Example,
Required: schema.Required,
}
// Convert Type from StringOrArray to *Types
if len(schema.Type) > 0 {
types := openapi3.Types(schema.Type)
newSchema.Type = &types
}
// Convert validation properties
if schema.Maximum != nil {
max := *schema.Maximum
newSchema.Max = &max
}
if schema.Minimum != nil {
min := *schema.Minimum
newSchema.Min = &min
}
if schema.ExclusiveMaximum {
newSchema.ExclusiveMax = true
}
if schema.ExclusiveMinimum {
newSchema.ExclusiveMin = true
}
if len(schema.Enum) > 0 {
newSchema.Enum = schema.Enum
}
// Convert properties
if len(schema.Properties) > 0 {
newSchema.Properties = make(openapi3.Schemas)
for name, prop := range schema.Properties {
newSchema.Properties[name] = convertSchemaToSchemaRef(&prop)
}
}
// Convert items
if schema.Items != nil && schema.Items.Schema != nil {
newSchema.Items = convertSchemaToSchemaRef(schema.Items.Schema)
}
// Convert additionalProperties
if schema.AdditionalProperties != nil && schema.AdditionalProperties.Schema != nil {
newSchema.AdditionalProperties = openapi3.AdditionalProperties{
Schema: convertSchemaToSchemaRef(schema.AdditionalProperties.Schema),
}
}
return &openapi3.SchemaRef{Value: newSchema}
}
// convertItemsToSchemaRef converts Swagger 2.0 items to OpenAPI 3.0 SchemaRef
func convertItemsToSchemaRef(items *spec.Items) *openapi3.SchemaRef {
if items == nil {
return nil
}
schema := &openapi3.Schema{
Format: items.Format,
}
if items.Type != "" {
schema.Type = &openapi3.Types{items.Type}
}
if items.Items != nil {
schema.Items = convertItemsToSchemaRef(items.Items)
}
return &openapi3.SchemaRef{Value: schema}
}
// convertSecurityScheme converts a Swagger 2.0 security definition to OpenAPI 3.0
func convertSecurityScheme(secDef *spec.SecurityScheme) *openapi3.SecuritySchemeRef {
newScheme := &openapi3.SecurityScheme{
Type: secDef.Type,
Description: secDef.Description,
Name: secDef.Name,
}
// Convert In to openapi3 format
switch secDef.In {
case "header":
newScheme.In = "header"
case "query":
newScheme.In = "query"
case "cookie":
newScheme.In = "cookie"
}
// Handle OAuth2
if secDef.Type == "oauth2" {
newScheme.Flows = &openapi3.OAuthFlows{}
switch secDef.Flow {
case "implicit":
newScheme.Flows.Implicit = &openapi3.OAuthFlow{
AuthorizationURL: secDef.AuthorizationURL,
Scopes: secDef.Scopes,
}
case "password":
newScheme.Flows.Password = &openapi3.OAuthFlow{
TokenURL: secDef.TokenURL,
Scopes: secDef.Scopes,
}
case "application":
newScheme.Flows.ClientCredentials = &openapi3.OAuthFlow{
TokenURL: secDef.TokenURL,
Scopes: secDef.Scopes,
}
case "accessCode":
newScheme.Flows.AuthorizationCode = &openapi3.OAuthFlow{
AuthorizationURL: secDef.AuthorizationURL,
TokenURL: secDef.TokenURL,
Scopes: secDef.Scopes,
}
}
}
return &openapi3.SecuritySchemeRef{Value: newScheme}
}
// intToString converts int to string for response codes
func intToString(code int) string {
if code < 0 || code > 999 {
return "200" // fallback to 200
}
hundreds := code / 100
tens := (code / 10) % 10
ones := code % 10
return string(rune('0'+hundreds)) + string(rune('0'+tens)) + string(rune('0'+ones))
}

View File

@ -0,0 +1,125 @@
package swagger
import (
"strconv"
"strings"
"github.com/zeromicro/go-zero/tools/goctl/api/spec"
"go-doc/internal/util"
)
func rangeValueFromOptions(options []string) (minimum *float64, maximum *float64, exclusiveMinimum bool, exclusiveMaximum bool) {
if len(options) == 0 {
return nil, nil, false, false
}
for _, option := range options {
if strings.HasPrefix(option, rangeFlag) {
val := option[6:]
start, end := val[0], val[len(val)-1]
if start != '[' && start != '(' {
return nil, nil, false, false
}
if end != ']' && end != ')' {
return nil, nil, false, false
}
exclusiveMinimum = start == '('
exclusiveMaximum = end == ')'
content := val[1 : len(val)-1]
idxColon := strings.Index(content, ":")
if idxColon < 0 {
return nil, nil, false, false
}
var (
minStr, maxStr string
minVal, maxVal *float64
)
minStr = util.TrimWhiteSpace(content[:idxColon])
if len(val) >= idxColon+1 {
maxStr = util.TrimWhiteSpace(content[idxColon+1:])
}
if len(minStr) > 0 {
min, err := strconv.ParseFloat(minStr, 64)
if err != nil {
return nil, nil, false, false
}
minVal = &min
}
if len(maxStr) > 0 {
max, err := strconv.ParseFloat(maxStr, 64)
if err != nil {
return nil, nil, false, false
}
maxVal = &max
}
return minVal, maxVal, exclusiveMinimum, exclusiveMaximum
}
}
return nil, nil, false, false
}
func enumsValueFromOptions(options []string) []any {
if len(options) == 0 {
return []any{}
}
for _, option := range options {
if strings.HasPrefix(option, enumFlag) {
val := option[8:]
fields := util.FieldsAndTrimSpace(val, func(r rune) bool {
return r == '|'
})
var resp = make([]any, 0, len(fields))
for _, field := range fields {
resp = append(resp, field)
}
return resp
}
}
return []any{}
}
func defValueFromOptions(ctx Context, options []string, apiType spec.Type) any {
tp := sampleTypeFromGoType(ctx, apiType)
return valueFromOptions(ctx, options, defFlag, tp)
}
func exampleValueFromOptions(ctx Context, options []string, apiType spec.Type) any {
tp := sampleTypeFromGoType(ctx, apiType)
val := valueFromOptions(ctx, options, exampleFlag, tp)
if val != nil {
return val
}
return defValueFromOptions(ctx, options, apiType)
}
func valueFromOptions(_ Context, options []string, key string, tp string) any {
if len(options) == 0 {
return nil
}
for _, option := range options {
if strings.HasPrefix(option, key) {
s := option[len(key):]
switch tp {
case swaggerTypeInteger:
val, _ := strconv.ParseInt(s, 10, 64)
return val
case swaggerTypeBoolean:
val, _ := strconv.ParseBool(s)
return val
case swaggerTypeNumber:
val, _ := strconv.ParseFloat(s, 64)
return val
case swaggerTypeArray:
return s
case swaggerTypeString:
return s
default:
return nil
}
}
}
return nil
}

View File

@ -0,0 +1,258 @@
package swagger
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/zeromicro/go-zero/tools/goctl/api/spec"
)
func TestRangeValueFromOptions(t *testing.T) {
tests := []struct {
name string
options []string
expectedMin *float64
expectedMax *float64
expectedExclMin bool
expectedExclMax bool
}{
{
name: "Valid range with inclusive bounds",
options: []string{"range=[1.0:10.0]"},
expectedMin: floatPtr(1.0),
expectedMax: floatPtr(10.0),
expectedExclMin: false,
expectedExclMax: false,
},
{
name: "Valid range with exclusive bounds",
options: []string{"range=(1.0:10.0)"},
expectedMin: floatPtr(1.0),
expectedMax: floatPtr(10.0),
expectedExclMin: true,
expectedExclMax: true,
},
{
name: "Invalid range format",
options: []string{"range=1.0:10.0"},
expectedMin: nil,
expectedMax: nil,
expectedExclMin: false,
expectedExclMax: false,
},
{
name: "Invalid range start",
options: []string{"range=[a:1.0)"},
expectedMin: nil,
expectedMax: nil,
expectedExclMin: false,
expectedExclMax: false,
},
{
name: "Missing range end",
options: []string{"range=[1.0:)"},
expectedMin: floatPtr(1.0),
expectedMax: nil,
expectedExclMin: false,
expectedExclMax: true,
},
{
name: "Missing range start and end",
options: []string{"range=[:)"},
expectedMin: nil,
expectedMax: nil,
expectedExclMin: false,
expectedExclMax: true,
},
{
name: "Missing range start",
options: []string{"range=[:1.0)"},
expectedMin: nil,
expectedMax: floatPtr(1.0),
expectedExclMin: false,
expectedExclMax: true,
},
{
name: "Invalid range end",
options: []string{"range=[1.0:b)"},
expectedMin: nil,
expectedMax: nil,
expectedExclMin: false,
expectedExclMax: false,
},
{
name: "Empty options",
options: []string{},
expectedMin: nil,
expectedMax: nil,
expectedExclMin: false,
expectedExclMax: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
min, max, exclMin, exclMax := rangeValueFromOptions(tt.options)
assert.Equal(t, tt.expectedMin, min)
assert.Equal(t, tt.expectedMax, max)
assert.Equal(t, tt.expectedExclMin, exclMin)
assert.Equal(t, tt.expectedExclMax, exclMax)
})
}
}
func TestEnumsValueFromOptions(t *testing.T) {
tests := []struct {
name string
options []string
expected []any
}{
{
name: "Valid enums",
options: []string{"options=a|b|c"},
expected: []any{"a", "b", "c"},
},
{
name: "Empty enums",
options: []string{"options="},
expected: []any{},
},
{
name: "No enum option",
options: []string{},
expected: []any{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := enumsValueFromOptions(tt.options)
assert.Equal(t, tt.expected, result)
})
}
}
func TestDefValueFromOptions(t *testing.T) {
tests := []struct {
name string
options []string
apiType spec.Type
expected any
}{
{
name: "Default integer value",
options: []string{"default=42"},
apiType: spec.PrimitiveType{RawName: "int"},
expected: int64(42),
},
{
name: "Default string value",
options: []string{"default=hello"},
apiType: spec.PrimitiveType{RawName: "string"},
expected: "hello",
},
{
name: "No default value",
options: []string{},
apiType: spec.PrimitiveType{RawName: "string"},
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := defValueFromOptions(testingContext(t), tt.options, tt.apiType)
assert.Equal(t, tt.expected, result)
})
}
}
func TestExampleValueFromOptions(t *testing.T) {
tests := []struct {
name string
options []string
apiType spec.Type
expected any
}{
{
name: "Example value present",
options: []string{"example=3.14"},
apiType: spec.PrimitiveType{RawName: "float"},
expected: 3.14,
},
{
name: "Fallback to default value",
options: []string{"default=42"},
apiType: spec.PrimitiveType{RawName: "int"},
expected: int64(42),
},
{
name: "Fallback to default value",
options: []string{"default="},
apiType: spec.PrimitiveType{RawName: "int"},
expected: int64(0),
},
{
name: "No example or default value",
options: []string{},
apiType: spec.PrimitiveType{RawName: "string"},
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
exampleValueFromOptions(testingContext(t), tt.options, tt.apiType)
})
}
}
func TestValueFromOptions(t *testing.T) {
tests := []struct {
name string
options []string
key string
tp string
expected any
}{
{
name: "Integer value",
options: []string{"default=42"},
key: "default=",
tp: "integer",
expected: int64(42),
},
{
name: "Boolean value",
options: []string{"default=true"},
key: "default=",
tp: "boolean",
expected: true,
},
{
name: "Number value",
options: []string{"default=1.1"},
key: "default=",
tp: "number",
expected: 1.1,
},
{
name: "No matching key",
options: []string{"example=42"},
key: "default=",
tp: "integer",
expected: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := valueFromOptions(testingContext(t), tt.options, tt.key, tt.tp)
assert.Equal(t, tt.expected, result)
})
}
}
func floatPtr(f float64) *float64 {
return &f
}

View File

@ -0,0 +1,220 @@
package swagger
import (
"net/http"
"strings"
"github.com/go-openapi/spec"
apiSpec "github.com/zeromicro/go-zero/tools/goctl/api/spec"
)
func isPostJson(ctx Context, method string, tp apiSpec.Type) (string, bool) {
// Check if this is a method that supports request body (POST, PUT, PATCH)
if !strings.EqualFold(method, http.MethodPost) &&
!strings.EqualFold(method, http.MethodPut) &&
!strings.EqualFold(method, http.MethodPatch) {
return "", false
}
structType, ok := tp.(apiSpec.DefineStruct)
if !ok {
return "", false
}
var hasJsonField bool
rangeMemberAndDo(ctx, structType, func(tag *apiSpec.Tags, required bool, member apiSpec.Member) {
jsonTag, _ := tag.Get(tagJson)
if !hasJsonField {
hasJsonField = jsonTag != nil
}
})
return structType.RawName, hasJsonField
}
func parametersFromType(ctx Context, method string, tp apiSpec.Type) []spec.Parameter {
if tp == nil {
return []spec.Parameter{}
}
structType, ok := tp.(apiSpec.DefineStruct)
if !ok {
return []spec.Parameter{}
}
var (
resp []spec.Parameter
properties = map[string]spec.Schema{}
requiredFields []string
)
rangeMemberAndDo(ctx, structType, func(tag *apiSpec.Tags, required bool, member apiSpec.Member) {
headerTag, _ := tag.Get(tagHeader)
hasHeader := headerTag != nil
pathParameterTag, _ := tag.Get(tagPath)
hasPathParameter := pathParameterTag != nil
formTag, _ := tag.Get(tagForm)
hasForm := formTag != nil
jsonTag, _ := tag.Get(tagJson)
hasJson := jsonTag != nil
if hasHeader {
minimum, maximum, exclusiveMinimum, exclusiveMaximum := rangeValueFromOptions(headerTag.Options)
resp = append(resp, spec.Parameter{
CommonValidations: spec.CommonValidations{
Maximum: maximum,
ExclusiveMaximum: exclusiveMaximum,
Minimum: minimum,
ExclusiveMinimum: exclusiveMinimum,
Enum: enumsValueFromOptions(headerTag.Options),
},
SimpleSchema: spec.SimpleSchema{
Type: sampleTypeFromGoType(ctx, member.Type),
Default: defValueFromOptions(ctx, headerTag.Options, member.Type),
Items: sampleItemsFromGoType(ctx, member.Type),
},
ParamProps: spec.ParamProps{
In: paramsInHeader,
Name: headerTag.Name,
Description: formatComment(member.Comment),
Required: required,
},
})
}
if hasPathParameter {
minimum, maximum, exclusiveMinimum, exclusiveMaximum := rangeValueFromOptions(pathParameterTag.Options)
resp = append(resp, spec.Parameter{
CommonValidations: spec.CommonValidations{
Maximum: maximum,
ExclusiveMaximum: exclusiveMaximum,
Minimum: minimum,
ExclusiveMinimum: exclusiveMinimum,
Enum: enumsValueFromOptions(pathParameterTag.Options),
},
SimpleSchema: spec.SimpleSchema{
Type: sampleTypeFromGoType(ctx, member.Type),
Default: defValueFromOptions(ctx, pathParameterTag.Options, member.Type),
Items: sampleItemsFromGoType(ctx, member.Type),
},
ParamProps: spec.ParamProps{
In: paramsInPath,
Name: pathParameterTag.Name,
Description: formatComment(member.Comment),
Required: required,
},
})
}
if hasForm {
minimum, maximum, exclusiveMinimum, exclusiveMaximum := rangeValueFromOptions(formTag.Options)
if strings.EqualFold(method, http.MethodGet) {
resp = append(resp, spec.Parameter{
CommonValidations: spec.CommonValidations{
Maximum: maximum,
ExclusiveMaximum: exclusiveMaximum,
Minimum: minimum,
ExclusiveMinimum: exclusiveMinimum,
Enum: enumsValueFromOptions(formTag.Options),
},
SimpleSchema: spec.SimpleSchema{
Type: sampleTypeFromGoType(ctx, member.Type),
Default: defValueFromOptions(ctx, formTag.Options, member.Type),
Items: sampleItemsFromGoType(ctx, member.Type),
},
ParamProps: spec.ParamProps{
In: paramsInQuery,
Name: formTag.Name,
Description: formatComment(member.Comment),
Required: required,
AllowEmptyValue: !required,
},
})
} else {
resp = append(resp, spec.Parameter{
CommonValidations: spec.CommonValidations{
Maximum: maximum,
ExclusiveMaximum: exclusiveMaximum,
Minimum: minimum,
ExclusiveMinimum: exclusiveMinimum,
Enum: enumsValueFromOptions(formTag.Options),
},
SimpleSchema: spec.SimpleSchema{
Type: sampleTypeFromGoType(ctx, member.Type),
Default: defValueFromOptions(ctx, formTag.Options, member.Type),
Items: sampleItemsFromGoType(ctx, member.Type),
},
ParamProps: spec.ParamProps{
In: paramsInForm,
Name: formTag.Name,
Description: formatComment(member.Comment),
Required: required,
AllowEmptyValue: !required,
},
})
}
}
if hasJson {
minimum, maximum, exclusiveMinimum, exclusiveMaximum := rangeValueFromOptions(jsonTag.Options)
if required {
requiredFields = append(requiredFields, jsonTag.Name)
}
var schema = spec.Schema{
SwaggerSchemaProps: spec.SwaggerSchemaProps{
Example: exampleValueFromOptions(ctx, jsonTag.Options, member.Type),
},
SchemaProps: spec.SchemaProps{
Description: formatComment(member.Comment),
Type: typeFromGoType(ctx, member.Type),
Default: defValueFromOptions(ctx, jsonTag.Options, member.Type),
Maximum: maximum,
ExclusiveMaximum: exclusiveMaximum,
Minimum: minimum,
ExclusiveMinimum: exclusiveMinimum,
Enum: enumsValueFromOptions(jsonTag.Options),
AdditionalProperties: mapFromGoType(ctx, member.Type),
},
}
switch sampleTypeFromGoType(ctx, member.Type) {
case swaggerTypeArray:
schema.Items = itemsFromGoType(ctx, member.Type)
case swaggerTypeObject:
p, r := propertiesFromType(ctx, member.Type)
schema.Properties = p
schema.Required = r
}
properties[jsonTag.Name] = schema
}
})
if len(properties) > 0 {
if ctx.UseDefinitions {
structName, ok := isPostJson(ctx, method, tp)
if ok {
resp = append(resp, spec.Parameter{
ParamProps: spec.ParamProps{
In: paramsInBody,
Name: paramsInBody,
Required: true,
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Ref: spec.MustCreateRef(getRefName(structName)),
},
},
},
})
}
} else {
resp = append(resp, spec.Parameter{
ParamProps: spec.ParamProps{
In: paramsInBody,
Name: paramsInBody,
Required: true,
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: typeFromGoType(ctx, structType),
Properties: properties,
Required: requiredFields,
},
},
},
})
}
}
return resp
}

View File

@ -0,0 +1,91 @@
package swagger
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
apiSpec "github.com/zeromicro/go-zero/tools/goctl/api/spec"
)
func TestIsPostJson(t *testing.T) {
tests := []struct {
name string
method string
hasJson bool
expected bool
}{
{"POST with JSON", http.MethodPost, true, true},
{"POST without JSON", http.MethodPost, false, false},
{"GET with JSON", http.MethodGet, true, false},
{"PUT with JSON", http.MethodPut, true, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testStruct := createTestStruct("TestStruct", tt.hasJson)
_, result := isPostJson(testingContext(t), tt.method, testStruct)
assert.Equal(t, tt.expected, result)
})
}
}
func TestParametersFromType(t *testing.T) {
tests := []struct {
name string
method string
useDefinitions bool
hasJson bool
expectedCount int
expectedBody bool
}{
{"POST JSON with definitions", http.MethodPost, true, true, 1, true},
{"POST JSON without definitions", http.MethodPost, false, true, 1, true},
{"GET with form", http.MethodGet, false, false, 1, false},
{"POST with form", http.MethodPost, false, false, 1, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := Context{UseDefinitions: tt.useDefinitions}
testStruct := createTestStruct("TestStruct", tt.hasJson)
params := parametersFromType(ctx, tt.method, testStruct)
assert.Equal(t, tt.expectedCount, len(params))
if tt.expectedBody {
assert.Equal(t, paramsInBody, params[0].In)
} else if len(params) > 0 {
assert.NotEqual(t, paramsInBody, params[0].In)
}
})
}
}
func TestParametersFromType_EdgeCases(t *testing.T) {
ctx := testingContext(t)
params := parametersFromType(ctx, http.MethodPost, nil)
assert.Empty(t, params)
primitiveType := apiSpec.PrimitiveType{RawName: "string"}
params = parametersFromType(ctx, http.MethodPost, primitiveType)
assert.Empty(t, params)
}
func createTestStruct(name string, hasJson bool) apiSpec.DefineStruct {
tag := `form:"username"`
if hasJson {
tag = `json:"username"`
}
return apiSpec.DefineStruct{
RawName: name,
Members: []apiSpec.Member{
{
Name: "Username",
Type: apiSpec.PrimitiveType{RawName: "string"},
Tag: tag,
},
},
}
}

View File

@ -0,0 +1,123 @@
package swagger
import (
"net/http"
"path"
"strings"
"github.com/go-openapi/spec"
apiSpec "github.com/zeromicro/go-zero/tools/goctl/api/spec"
"go-doc/internal/util"
)
func spec2Paths(ctx Context, srv apiSpec.Service) *spec.Paths {
paths := &spec.Paths{
Paths: make(map[string]spec.PathItem),
}
for _, group := range srv.Groups {
prefix := path.Clean(strings.TrimPrefix(group.GetAnnotation(propertyKeyPrefix), "/"))
for _, route := range group.Routes {
routPath := pathVariable2SwaggerVariable(ctx, route.Path)
if len(prefix) > 0 && prefix != "." {
routPath = "/" + path.Clean(prefix) + routPath
}
pathItem := spec2Path(ctx, group, route)
existPathItem, ok := paths.Paths[routPath]
if !ok {
paths.Paths[routPath] = pathItem
} else {
paths.Paths[routPath] = mergePathItem(existPathItem, pathItem)
}
}
}
return paths
}
func mergePathItem(old, new spec.PathItem) spec.PathItem {
if new.Get != nil {
old.Get = new.Get
}
if new.Put != nil {
old.Put = new.Put
}
if new.Post != nil {
old.Post = new.Post
}
if new.Delete != nil {
old.Delete = new.Delete
}
if new.Options != nil {
old.Options = new.Options
}
if new.Head != nil {
old.Head = new.Head
}
if new.Patch != nil {
old.Patch = new.Patch
}
if new.Parameters != nil {
old.Parameters = new.Parameters
}
return old
}
func spec2Path(ctx Context, group apiSpec.Group, route apiSpec.Route) spec.PathItem {
authType := getStringFromKVOrDefault(group.Annotation.Properties, propertyKeyAuthType, "")
var security []map[string][]string
if len(authType) > 0 {
security = []map[string][]string{
{
authType: []string{},
},
}
}
groupName := getStringFromKVOrDefault(group.Annotation.Properties, propertyKeyGroup, "")
operationId := route.Handler
if len(groupName) > 0 {
operationId = util.From(groupName + "_" + route.Handler).ToCamel()
}
operationId = util.From(operationId).Untitle()
op := &spec.Operation{
OperationProps: spec.OperationProps{
Description: getStringFromKVOrDefault(route.AtDoc.Properties, propertyKeyDescription, ""),
Consumes: consumesFromTypeOrDef(ctx, route.Method, route.RequestType),
Produces: getListFromInfoOrDefault(route.AtDoc.Properties, propertyKeyProduces, []string{applicationJson}),
Schemes: getListFromInfoOrDefault(route.AtDoc.Properties, propertyKeySchemes, []string{schemeHttps}),
Tags: getListFromInfoOrDefault(group.Annotation.Properties, propertyKeyTags, getListFromInfoOrDefault(group.Annotation.Properties, propertyKeySummary, []string{})),
Summary: getStringFromKVOrDefault(route.AtDoc.Properties, propertyKeySummary, getFirstUsableString(route.AtDoc.Text, route.Handler)),
ID: operationId,
Deprecated: getBoolFromKVOrDefault(route.AtDoc.Properties, propertyKeyDeprecated, false),
Parameters: parametersFromType(ctx, route.Method, route.RequestType),
Security: security,
Responses: jsonResponseFromTypeWithDocs(ctx, route.AtDoc, route.ResponseType, route.HandlerDoc),
},
}
externalDocsDescription := getStringFromKVOrDefault(route.AtDoc.Properties, propertyKeyExternalDocsDescription, "")
externalDocsURL := getStringFromKVOrDefault(route.AtDoc.Properties, propertyKeyExternalDocsURL, "")
if len(externalDocsDescription) > 0 || len(externalDocsURL) > 0 {
op.ExternalDocs = &spec.ExternalDocumentation{
Description: externalDocsDescription,
URL: externalDocsURL,
}
}
item := spec.PathItem{}
switch strings.ToUpper(route.Method) {
case http.MethodGet:
item.Get = op
case http.MethodHead:
item.Head = op
case http.MethodPost:
item.Post = op
case http.MethodPut:
item.Put = op
case http.MethodPatch:
item.Patch = op
case http.MethodDelete:
item.Delete = op
case http.MethodOptions:
item.Options = op
default: // [http.MethodConnect,http.MethodTrace] not supported
}
return item
}

View File

@ -0,0 +1,109 @@
package swagger
import (
"github.com/go-openapi/spec"
apiSpec "github.com/zeromicro/go-zero/tools/goctl/api/spec"
)
func propertiesFromType(ctx Context, tp apiSpec.Type) (spec.SchemaProperties, []string) {
var (
properties = map[string]spec.Schema{}
requiredFields []string
)
switch val := tp.(type) {
case apiSpec.PointerType:
return propertiesFromType(ctx, val.Type)
case apiSpec.ArrayType:
return propertiesFromType(ctx, val.Value)
case apiSpec.DefineStruct, apiSpec.NestedStruct:
rangeMemberAndDo(ctx, val, func(tag *apiSpec.Tags, required bool, member apiSpec.Member) {
var (
jsonTagString = member.Name
minimum, maximum *float64
exclusiveMinimum, exclusiveMaximum bool
example, defaultValue any
enum []any
)
pathTag, _ := tag.Get(tagPath)
if pathTag != nil {
return
}
formTag, _ := tag.Get(tagForm)
if formTag != nil {
return
}
headerTag, _ := tag.Get(tagHeader)
if headerTag != nil {
return
}
jsonTag, _ := tag.Get(tagJson)
if jsonTag != nil {
jsonTagString = jsonTag.Name
minimum, maximum, exclusiveMinimum, exclusiveMaximum = rangeValueFromOptions(jsonTag.Options)
example = exampleValueFromOptions(ctx, jsonTag.Options, member.Type)
defaultValue = defValueFromOptions(ctx, jsonTag.Options, member.Type)
enum = enumsValueFromOptions(jsonTag.Options)
}
if required {
requiredFields = append(requiredFields, jsonTagString)
}
schema := spec.Schema{
SwaggerSchemaProps: spec.SwaggerSchemaProps{
Example: example,
},
SchemaProps: spec.SchemaProps{
Description: formatComment(member.Comment),
Type: typeFromGoType(ctx, member.Type),
Default: defaultValue,
Maximum: maximum,
ExclusiveMaximum: exclusiveMaximum,
Minimum: minimum,
ExclusiveMinimum: exclusiveMinimum,
Enum: enum,
AdditionalProperties: mapFromGoType(ctx, member.Type),
},
}
switch sampleTypeFromGoType(ctx, member.Type) {
case swaggerTypeArray:
schema.Items = itemsFromGoType(ctx, member.Type)
case swaggerTypeObject:
p, r := propertiesFromType(ctx, member.Type)
schema.Properties = p
schema.Required = r
}
if ctx.UseDefinitions {
structName, containsStruct := containsStruct(member.Type)
if containsStruct {
schema.SchemaProps.Ref = spec.MustCreateRef(getRefName(structName))
}
}
properties[jsonTagString] = schema
})
}
return properties, requiredFields
}
func containsStruct(tp apiSpec.Type) (string, bool) {
switch val := tp.(type) {
case apiSpec.PointerType:
return containsStruct(val.Type)
case apiSpec.ArrayType:
return containsStruct(val.Value)
case apiSpec.DefineStruct:
return val.RawName, true
case apiSpec.MapType:
return containsStruct(val.Value)
default:
return "", false
}
}
func getRefName(typeName string) string {
return "#/definitions/" + typeName
}

View File

@ -0,0 +1,249 @@
package swagger
import (
"fmt"
"regexp"
"strconv"
"strings"
apiSpec "github.com/zeromicro/go-zero/tools/goctl/api/spec"
)
// RespDoc 表示一個回應文檔定義
type RespDoc struct {
StatusCode int // HTTP 狀態碼
Schema string // 回應類型名稱(單一回應)
BizCodes map[string]BizCode // 業務錯誤碼映射(多個回應)
Description string // 描述
}
// BizCode 表示業務錯誤碼定義
type BizCode struct {
Code string // 業務錯誤碼(如 300101
Schema string // 對應的類型名稱
Description string // 描述
}
var (
// @respdoc-200 (TypeName) description
respdocSimpleRegex = regexp.MustCompile(`@respdoc-(\d+)\s+\((\w+)\)(?:\s+(.+))?`)
// 業務錯誤碼格式: 300101: (ValidationError) description
bizCodeRegex = regexp.MustCompile(`(\d+):\s+\((\w+)\)(?:\s+(.+))?`)
)
// parseRespDocsFromDoc 從 Doc 數組中解析所有 @respdoc 定義
func parseRespDocsFromDoc(doc apiSpec.Doc) []RespDoc {
// Doc 是字符串數組,合併為一個文本
var text string
for _, line := range doc {
text += line + "\n"
}
return parseRespDocsFromText(text)
}
// parseRespDocs 從 AtDoc 註解中解析所有 @respdoc 定義
func parseRespDocs(atDoc apiSpec.AtDoc) []RespDoc {
return parseRespDocsFromText(atDoc.Text)
}
// parseRespDocsFromText 從文本中解析所有 @respdoc 定義
func parseRespDocsFromText(text string) []RespDoc {
var respDocs []RespDoc
if text == "" {
return respDocs
}
lines := strings.Split(text, "\n")
var currentRespDoc *RespDoc
var inMultiLine bool
for _, line := range lines {
line = strings.TrimSpace(line)
// 跳過空行和純註釋符號
if line == "" || line == "/*" || line == "*/" || strings.HasPrefix(line, "//") {
continue
}
// 移除行首的註釋符號
line = strings.TrimPrefix(line, "*")
line = strings.TrimSpace(line)
// 檢查是否為 @respdoc 開頭
if strings.HasPrefix(line, "@respdoc-") {
// 保存上一個 respDoc
if currentRespDoc != nil {
respDocs = append(respDocs, *currentRespDoc)
}
// 解析新的 respdoc
currentRespDoc = parseRespDocLine(line)
// 檢查是否為多行格式(含有左括號但不含右括號,或右括號後還有註釋符號)
if strings.Contains(line, "(") && !strings.Contains(line, ")") {
inMultiLine = true
} else {
inMultiLine = false
}
// 如果是單行格式,直接添加
if !inMultiLine && currentRespDoc != nil {
respDocs = append(respDocs, *currentRespDoc)
currentRespDoc = nil
}
} else if inMultiLine && currentRespDoc != nil {
// 處理多行格式中的業務錯誤碼
if strings.Contains(line, ":") && strings.Contains(line, "(") {
bizCode := parseBizCodeLine(line)
if bizCode != nil {
if currentRespDoc.BizCodes == nil {
currentRespDoc.BizCodes = make(map[string]BizCode)
}
currentRespDoc.BizCodes[bizCode.Code] = *bizCode
}
}
// 檢查是否為多行結束(含有右括號和註釋)
if strings.Contains(line, ")") && strings.Contains(line, "//") {
// 提取描述
parts := strings.SplitN(line, "//", 2)
if len(parts) == 2 {
currentRespDoc.Description = strings.TrimSpace(parts[1])
}
respDocs = append(respDocs, *currentRespDoc)
currentRespDoc = nil
inMultiLine = false
}
}
}
// 添加最後一個 respDoc
if currentRespDoc != nil {
respDocs = append(respDocs, *currentRespDoc)
}
return respDocs
}
// parseRespDocLine 解析單行 @respdoc 定義
func parseRespDocLine(line string) *RespDoc {
// 移除 @respdoc- 前綴
line = strings.TrimPrefix(line, "@respdoc-")
// 提取狀態碼
parts := strings.SplitN(line, " ", 2)
if len(parts) == 0 {
return nil
}
statusCode, err := strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil {
return nil
}
respDoc := &RespDoc{
StatusCode: statusCode,
}
if len(parts) < 2 {
return respDoc
}
rest := strings.TrimSpace(parts[1])
// 檢查是否為簡單格式: (TypeName) description
matches := respdocSimpleRegex.FindStringSubmatch("@respdoc-" + line)
if len(matches) >= 3 {
respDoc.Schema = matches[2]
if len(matches) >= 4 {
respDoc.Description = strings.TrimSpace(matches[3])
}
return respDoc
}
// 檢查是否為多行格式開始: (
if strings.HasPrefix(rest, "(") && !strings.Contains(rest, ")") {
// 多行格式,等待後續行處理
return respDoc
}
return respDoc
}
// parseBizCodeLine 解析業務錯誤碼行
func parseBizCodeLine(line string) *BizCode {
matches := bizCodeRegex.FindStringSubmatch(line)
if len(matches) < 3 {
return nil
}
bizCode := &BizCode{
Code: matches[1],
Schema: matches[2],
}
if len(matches) >= 4 {
bizCode.Description = strings.TrimSpace(matches[3])
}
return bizCode
}
// findTypeByName 在 API 規範中查找類型定義
func findTypeByName(api *apiSpec.ApiSpec, typeName string) apiSpec.Type {
if typeName == "" {
return nil
}
for _, tp := range api.Types {
if defStruct, ok := tp.(apiSpec.DefineStruct); ok {
if defStruct.Name() == typeName {
return tp
}
}
}
return nil
}
// getHTTPStatusText 獲取 HTTP 狀態碼的標準文本
func getHTTPStatusText(code int) string {
switch code {
case 200:
return "OK"
case 201:
return "Created"
case 202:
return "Accepted"
case 204:
return "No Content"
case 400:
return "Bad Request"
case 401:
return "Unauthorized"
case 403:
return "Forbidden"
case 404:
return "Not Found"
case 405:
return "Method Not Allowed"
case 409:
return "Conflict"
case 422:
return "Unprocessable Entity"
case 429:
return "Too Many Requests"
case 500:
return "Internal Server Error"
case 502:
return "Bad Gateway"
case 503:
return "Service Unavailable"
case 504:
return "Gateway Timeout"
default:
return fmt.Sprintf("HTTP %d", code)
}
}

View File

@ -0,0 +1,201 @@
package swagger
import (
"net/http"
"strings"
"github.com/go-openapi/spec"
apiSpec "github.com/zeromicro/go-zero/tools/goctl/api/spec"
)
func jsonResponseFromType(ctx Context, atDoc apiSpec.AtDoc, tp apiSpec.Type) *spec.Responses {
return jsonResponseFromTypeWithDocs(ctx, atDoc, tp, nil)
}
func jsonResponseFromTypeWithDocs(ctx Context, atDoc apiSpec.AtDoc, tp apiSpec.Type, handlerDoc apiSpec.Doc) *spec.Responses {
// 首先檢查是否有 @respdoc 註解(從 handlerDoc 或 atDoc
var respDocs []RespDoc
if len(handlerDoc) > 0 {
respDocs = parseRespDocsFromDoc(handlerDoc)
}
if len(respDocs) == 0 {
respDocs = parseRespDocs(atDoc)
}
if len(respDocs) > 0 {
return jsonResponseFromRespDocs(ctx, atDoc, respDocs, tp)
}
// 原有邏輯:使用默認的 200 回應
if tp == nil {
return &spec.Responses{
ResponsesProps: spec.ResponsesProps{
StatusCodeResponses: map[int]spec.Response{
http.StatusOK: {
ResponseProps: spec.ResponseProps{
Description: "",
Schema: &spec.Schema{},
},
},
},
},
}
}
props := spec.SchemaProps{
AdditionalProperties: mapFromGoType(ctx, tp),
Items: itemsFromGoType(ctx, tp),
}
if ctx.UseDefinitions {
structName, ok := containsStruct(tp)
if ok {
props.Ref = spec.MustCreateRef(getRefName(structName))
return &spec.Responses{
ResponsesProps: spec.ResponsesProps{
StatusCodeResponses: map[int]spec.Response{
http.StatusOK: {
ResponseProps: spec.ResponseProps{
Schema: &spec.Schema{
SchemaProps: wrapCodeMsgProps(ctx, props, atDoc),
},
},
},
},
},
}
}
}
p, _ := propertiesFromType(ctx, tp)
props.Type = typeFromGoType(ctx, tp)
props.Properties = p
return &spec.Responses{
ResponsesProps: spec.ResponsesProps{
StatusCodeResponses: map[int]spec.Response{
http.StatusOK: {
ResponseProps: spec.ResponseProps{
Schema: &spec.Schema{
SchemaProps: wrapCodeMsgProps(ctx, props, atDoc),
},
},
},
},
},
}
}
// jsonResponseFromRespDocs 從 @respdoc 註解生成多狀態碼回應
func jsonResponseFromRespDocs(ctx Context, atDoc apiSpec.AtDoc, respDocs []RespDoc, defaultType apiSpec.Type) *spec.Responses {
responses := &spec.Responses{
ResponsesProps: spec.ResponsesProps{
StatusCodeResponses: make(map[int]spec.Response),
},
}
for _, respDoc := range respDocs {
// 如果有單一 Schema直接使用
if respDoc.Schema != "" {
tp := findTypeByName(ctx.Api, respDoc.Schema)
if tp == nil && respDoc.StatusCode == http.StatusOK && defaultType != nil {
// 如果找不到類型且是 200使用默認類型
tp = defaultType
}
response := createResponseFromType(ctx, atDoc, tp, respDoc.Description)
responses.StatusCodeResponses[respDoc.StatusCode] = response
continue
}
// 如果有多個業務錯誤碼BizCodes為 Swagger 2.0 創建一個通用回應
// OpenAPI 3.0 會在 convertSwagger2ToOpenAPI3 中特殊處理
if len(respDoc.BizCodes) > 0 {
// 對於 Swagger 2.0,我們使用 oneOf/anyOf 概念的註釋
// 但 Swagger 2.0 不支持 oneOf所以使用第一個類型作為示例
// 並在描述中列出所有可能的類型
var firstType apiSpec.Type
descriptions := []string{}
for code, bizCode := range respDoc.BizCodes {
tp := findTypeByName(ctx.Api, bizCode.Schema)
if firstType == nil && tp != nil {
firstType = tp
}
desc := code + ": " + bizCode.Schema
if bizCode.Description != "" {
desc += " - " + bizCode.Description
}
descriptions = append(descriptions, desc)
}
description := respDoc.Description
if len(descriptions) > 0 {
if description != "" {
description += "\n\n"
}
description += "Possible errors:\n" + strings.Join(descriptions, "\n")
}
response := createResponseFromType(ctx, atDoc, firstType, description)
// 在 VendorExtensible 中存儲業務錯誤碼信息,供 OpenAPI 3.0 使用
if response.Extensions == nil {
response.Extensions = make(spec.Extensions)
}
response.Extensions["x-biz-codes"] = respDoc.BizCodes
responses.StatusCodeResponses[respDoc.StatusCode] = response
}
}
// 如果沒有定義 200 回應,添加默認的
if _, ok := responses.StatusCodeResponses[http.StatusOK]; !ok && defaultType != nil {
responses.StatusCodeResponses[http.StatusOK] = createResponseFromType(ctx, atDoc, defaultType, "")
}
return responses
}
// createResponseFromType 從類型創建回應對象
func createResponseFromType(ctx Context, atDoc apiSpec.AtDoc, tp apiSpec.Type, description string) spec.Response {
if tp == nil {
return spec.Response{
ResponseProps: spec.ResponseProps{
Description: description,
Schema: &spec.Schema{},
},
}
}
props := spec.SchemaProps{
AdditionalProperties: mapFromGoType(ctx, tp),
Items: itemsFromGoType(ctx, tp),
}
if ctx.UseDefinitions {
structName, ok := containsStruct(tp)
if ok {
props.Ref = spec.MustCreateRef(getRefName(structName))
return spec.Response{
ResponseProps: spec.ResponseProps{
Description: description,
Schema: &spec.Schema{
SchemaProps: wrapCodeMsgProps(ctx, props, atDoc),
},
},
}
}
}
p, _ := propertiesFromType(ctx, tp)
props.Type = typeFromGoType(ctx, tp)
props.Properties = p
return spec.Response{
ResponseProps: spec.ResponseProps{
Description: description,
Schema: &spec.Schema{
SchemaProps: wrapCodeMsgProps(ctx, props, atDoc),
},
},
}
}

View File

@ -0,0 +1,325 @@
package swagger
import (
"encoding/json"
"strings"
"time"
"github.com/go-openapi/spec"
apiSpec "github.com/zeromicro/go-zero/tools/goctl/api/spec"
)
func spec2Swagger(api *apiSpec.ApiSpec) (*spec.Swagger, error) {
ctx := contextFromApi(api)
extensions, info := specExtensions(api.Info)
var securityDefinitions spec.SecurityDefinitions
securityDefinitionsFromJson := getStringFromKVOrDefault(api.Info.Properties, "securityDefinitionsFromJson", `{}`)
_ = json.Unmarshal([]byte(securityDefinitionsFromJson), &securityDefinitions)
swagger := &spec.Swagger{
VendorExtensible: spec.VendorExtensible{
Extensions: extensions,
},
SwaggerProps: spec.SwaggerProps{
Definitions: definitionsFromTypes(ctx, api.Types),
Consumes: getListFromInfoOrDefault(api.Info.Properties, propertyKeyConsumes, []string{applicationJson}),
Produces: getListFromInfoOrDefault(api.Info.Properties, propertyKeyProduces, []string{applicationJson}),
Schemes: getListFromInfoOrDefault(api.Info.Properties, propertyKeySchemes, []string{schemeHttps}),
Swagger: swaggerVersion,
Info: info,
Host: getStringFromKVOrDefault(api.Info.Properties, propertyKeyHost, ""),
BasePath: getStringFromKVOrDefault(api.Info.Properties, propertyKeyBasePath, defaultBasePath),
Paths: spec2Paths(ctx, api.Service),
SecurityDefinitions: securityDefinitions,
},
}
return swagger, nil
}
func formatComment(comment string) string {
s := strings.TrimPrefix(comment, "//")
return strings.TrimSpace(s)
}
func sampleItemsFromGoType(ctx Context, tp apiSpec.Type) *spec.Items {
val, ok := tp.(apiSpec.ArrayType)
if !ok {
return nil
}
item := val.Value
switch item.(type) {
case apiSpec.PrimitiveType:
return &spec.Items{
SimpleSchema: spec.SimpleSchema{
Type: sampleTypeFromGoType(ctx, item),
},
}
case apiSpec.ArrayType:
return &spec.Items{
SimpleSchema: spec.SimpleSchema{
Type: sampleTypeFromGoType(ctx, item),
Items: sampleItemsFromGoType(ctx, item),
},
}
default: // unsupported type
}
return nil
}
// itemsFromGoType returns the schema or array of the type, just for non json body parameters.
func itemsFromGoType(ctx Context, tp apiSpec.Type) *spec.SchemaOrArray {
array, ok := tp.(apiSpec.ArrayType)
if !ok {
return nil
}
return itemFromGoType(ctx, array.Value)
}
func mapFromGoType(ctx Context, tp apiSpec.Type) *spec.SchemaOrBool {
mapType, ok := tp.(apiSpec.MapType)
if !ok {
return nil
}
var schema = &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: typeFromGoType(ctx, mapType.Value),
AdditionalProperties: mapFromGoType(ctx, mapType.Value),
},
}
switch sampleTypeFromGoType(ctx, mapType.Value) {
case swaggerTypeArray:
schema.Items = itemsFromGoType(ctx, mapType.Value)
case swaggerTypeObject:
p, r := propertiesFromType(ctx, mapType.Value)
schema.Properties = p
schema.Required = r
}
return &spec.SchemaOrBool{
Allows: true,
Schema: schema,
}
}
// itemFromGoType returns the schema or array of the type, just for non json body parameters.
func itemFromGoType(ctx Context, tp apiSpec.Type) *spec.SchemaOrArray {
switch itemType := tp.(type) {
case apiSpec.PrimitiveType:
return &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: typeFromGoType(ctx, tp),
},
},
}
case apiSpec.DefineStruct, apiSpec.NestedStruct, apiSpec.MapType:
properties, requiredFields := propertiesFromType(ctx, itemType)
return &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: typeFromGoType(ctx, itemType),
Items: itemsFromGoType(ctx, itemType),
Properties: properties,
Required: requiredFields,
AdditionalProperties: mapFromGoType(ctx, itemType),
},
},
}
case apiSpec.PointerType:
return itemFromGoType(ctx, itemType.Type)
case apiSpec.ArrayType:
return &spec.SchemaOrArray{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: typeFromGoType(ctx, itemType),
Items: itemsFromGoType(ctx, itemType),
},
},
}
}
return nil
}
func typeFromGoType(ctx Context, tp apiSpec.Type) []string {
switch val := tp.(type) {
case apiSpec.PrimitiveType:
res, ok := tpMapper[val.RawName]
if ok {
return []string{res}
}
case apiSpec.ArrayType:
return []string{swaggerTypeArray}
case apiSpec.DefineStruct, apiSpec.MapType:
return []string{swaggerTypeObject}
case apiSpec.PointerType:
return typeFromGoType(ctx, val.Type)
}
return nil
}
func sampleTypeFromGoType(ctx Context, tp apiSpec.Type) string {
switch val := tp.(type) {
case apiSpec.PrimitiveType:
return tpMapper[val.RawName]
case apiSpec.ArrayType:
return swaggerTypeArray
case apiSpec.DefineStruct, apiSpec.MapType, apiSpec.NestedStruct:
return swaggerTypeObject
case apiSpec.PointerType:
return sampleTypeFromGoType(ctx, val.Type)
default:
return ""
}
}
func typeContainsTag(ctx Context, structType apiSpec.DefineStruct, tag string) bool {
members := expandMembers(ctx, structType)
for _, member := range members {
tags, _ := apiSpec.Parse(member.Tag)
if _, err := tags.Get(tag); err == nil {
return true
}
}
return false
}
func expandMembers(ctx Context, tp apiSpec.Type) []apiSpec.Member {
var members []apiSpec.Member
switch val := tp.(type) {
case apiSpec.DefineStruct:
for _, v := range val.Members {
if v.IsInline {
members = append(members, expandMembers(ctx, v.Type)...)
continue
}
members = append(members, v)
}
case apiSpec.NestedStruct:
for _, v := range val.Members {
if v.IsInline {
members = append(members, expandMembers(ctx, v.Type)...)
continue
}
members = append(members, v)
}
}
return members
}
func rangeMemberAndDo(ctx Context, structType apiSpec.Type, do func(tag *apiSpec.Tags, required bool, member apiSpec.Member)) {
var members = expandMembers(ctx, structType)
for _, field := range members {
tags, _ := apiSpec.Parse(field.Tag)
required := isRequired(ctx, tags, field)
do(tags, required, field)
}
}
func isRequired(ctx Context, tags *apiSpec.Tags, member apiSpec.Member) bool {
// Check if the field type is a pointer - pointer types are optional
if _, isPointer := member.Type.(apiSpec.PointerType); isPointer {
return false
}
tag, err := tags.Get(tagJson)
if err == nil {
return !isOptional(ctx, tag.Options)
}
tag, err = tags.Get(tagForm)
if err == nil {
return !isOptional(ctx, tag.Options)
}
tag, err = tags.Get(tagPath)
if err == nil {
return !isOptional(ctx, tag.Options)
}
return false
}
func isOptional(_ Context, options []string) bool {
for _, option := range options {
if option == optionalFlag || option == "omitempty" {
return true
}
}
return false
}
func pathVariable2SwaggerVariable(_ Context, path string) string {
pathItems := strings.FieldsFunc(path, slashRune)
resp := make([]string, 0, len(pathItems))
for _, v := range pathItems {
if strings.HasPrefix(v, ":") {
resp = append(resp, "{"+v[1:]+"}")
} else {
resp = append(resp, v)
}
}
return "/" + strings.Join(resp, "/")
}
func wrapCodeMsgProps(ctx Context, properties spec.SchemaProps, atDoc apiSpec.AtDoc) spec.SchemaProps {
if !ctx.WrapCodeMsg {
return properties
}
globalCodeDesc := ctx.BizCodeEnumDescription
methodCodeDesc := getStringFromKVOrDefault(atDoc.Properties, propertyKeyBizCodeEnumDescription, globalCodeDesc)
return spec.SchemaProps{
Type: []string{swaggerTypeObject},
Properties: spec.SchemaProperties{
"code": {
SwaggerSchemaProps: spec.SwaggerSchemaProps{
Example: 0,
},
SchemaProps: spec.SchemaProps{
Type: []string{swaggerTypeInteger},
Description: methodCodeDesc,
},
},
"msg": {
SwaggerSchemaProps: spec.SwaggerSchemaProps{
Example: "ok",
},
SchemaProps: spec.SchemaProps{
Type: []string{swaggerTypeString},
Description: "business message",
},
},
"data": {
SchemaProps: properties,
},
},
}
}
func specExtensions(api apiSpec.Info) (spec.Extensions, *spec.Info) {
ext := spec.Extensions{}
ext.Add("x-generator", "go-doc")
ext.Add("x-description", "This is a go-doc generated swagger file.")
ext.Add("x-date", time.Now().Format(time.DateTime))
ext.Add("x-github", "https://github.com/daniel-25/go-doc")
ext.Add("x-source", "go-zero API specification")
info := &spec.Info{}
info.Title = getStringFromKVOrDefault(api.Properties, propertyKeyTitle, "")
info.Description = getStringFromKVOrDefault(api.Properties, propertyKeyDescription, "")
info.TermsOfService = getStringFromKVOrDefault(api.Properties, propertyKeyTermsOfService, "")
info.Version = getStringFromKVOrDefault(api.Properties, propertyKeyVersion, "1.0")
contactInfo := spec.ContactInfo{}
contactInfo.Name = getStringFromKVOrDefault(api.Properties, propertyKeyContactName, "")
contactInfo.URL = getStringFromKVOrDefault(api.Properties, propertyKeyContactURL, "")
contactInfo.Email = getStringFromKVOrDefault(api.Properties, propertyKeyContactEmail, "")
if len(contactInfo.Name) > 0 || len(contactInfo.URL) > 0 || len(contactInfo.Email) > 0 {
info.Contact = &contactInfo
}
license := &spec.License{}
license.Name = getStringFromKVOrDefault(api.Properties, propertyKeyLicenseName, "")
license.URL = getStringFromKVOrDefault(api.Properties, propertyKeyLicenseURL, "")
if len(license.Name) > 0 || len(license.URL) > 0 {
info.License = license
}
return ext, info
}

View File

@ -0,0 +1,25 @@
package swagger
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_pathVariable2SwaggerVariable(t *testing.T) {
testCases := []struct {
input string
expected string
}{
{input: "/api/:id", expected: "/api/{id}"},
{input: "/api/:id/details", expected: "/api/{id}/details"},
{input: "/:version/api/:id", expected: "/{version}/api/{id}"},
{input: "/api/v1", expected: "/api/v1"},
{input: "/api/:id/:action", expected: "/api/{id}/{action}"},
}
for _, tc := range testCases {
result := pathVariable2SwaggerVariable(testingContext(t), tc.input)
assert.Equal(t, tc.expected, result)
}
}

View File

@ -0,0 +1,27 @@
package swagger
var (
tpMapper = map[string]string{
"uint8": swaggerTypeInteger,
"uint16": swaggerTypeInteger,
"uint32": swaggerTypeInteger,
"uint64": swaggerTypeInteger,
"int8": swaggerTypeInteger,
"int16": swaggerTypeInteger,
"int32": swaggerTypeInteger,
"int64": swaggerTypeInteger,
"int": swaggerTypeInteger,
"uint": swaggerTypeInteger,
"byte": swaggerTypeInteger,
"float32": swaggerTypeNumber,
"float64": swaggerTypeNumber,
"string": swaggerTypeString,
"bool": swaggerTypeBoolean,
}
commaRune = func(r rune) bool {
return r == ','
}
slashRune = func(r rune) bool {
return r == '/'
}
)

View File

@ -0,0 +1,13 @@
package util
import (
"os"
)
// MkdirIfNotExist creates a directory if it doesn't exist
func MkdirIfNotExist(dir string) error {
if _, err := os.Stat(dir); os.IsNotExist(err) {
return os.MkdirAll(dir, 0755)
}
return nil
}

View File

@ -0,0 +1,89 @@
package util
import (
"strings"
"unicode"
)
// String wraps a string for manipulation
type String struct {
source string
}
// From creates a String instance
func From(s string) String {
return String{source: s}
}
// ToCamel converts string to camelCase
func (s String) ToCamel() string {
if s.source == "" {
return ""
}
words := splitWords(s.source)
if len(words) == 0 {
return s.source
}
result := strings.Builder{}
for i, word := range words {
if i == 0 {
result.WriteString(strings.ToLower(word))
} else {
result.WriteString(title(word))
}
}
return result.String()
}
// Untitle converts first character to lowercase
func (s String) Untitle() string {
if s.source == "" {
return ""
}
runes := []rune(s.source)
runes[0] = unicode.ToLower(runes[0])
return string(runes)
}
// splitWords splits a string into words by common separators
func splitWords(s string) []string {
var words []string
var current strings.Builder
for i, r := range s {
if r == '_' || r == '-' || r == ' ' || r == '.' {
if current.Len() > 0 {
words = append(words, current.String())
current.Reset()
}
continue
}
if i > 0 && unicode.IsUpper(r) && !unicode.IsUpper(rune(s[i-1])) {
if current.Len() > 0 {
words = append(words, current.String())
current.Reset()
}
}
current.WriteRune(r)
}
if current.Len() > 0 {
words = append(words, current.String())
}
return words
}
// title capitalizes the first character of a string
func title(s string) string {
if s == "" {
return ""
}
runes := []rune(s)
runes[0] = unicode.ToUpper(runes[0])
return string(runes)
}

View File

@ -0,0 +1,29 @@
package util
import (
"strings"
"unicode"
)
// TrimWhiteSpace removes all whitespace characters from the string
func TrimWhiteSpace(s string) string {
return strings.Map(func(r rune) rune {
if unicode.IsSpace(r) {
return -1
}
return r
}, s)
}
// FieldsAndTrimSpace splits string by the given separator function and trims space for each field
func FieldsAndTrimSpace(s string, fn func(rune) bool) []string {
fields := strings.FieldsFunc(s, fn)
result := make([]string, 0, len(fields))
for _, field := range fields {
trimmed := strings.TrimSpace(field)
if len(trimmed) > 0 {
result = append(result, trimmed)
}
}
return result
}

View File

@ -0,0 +1,103 @@
#!/bin/bash
# 測試所有格式生成
set -e
echo "測試 go-doc 所有格式生成"
echo "================================"
# 建立測試目錄
TEST_DIR="test_output_verification"
mkdir -p "$TEST_DIR"
echo ""
echo "測試 1: Swagger 2.0 (JSON)"
./bin/go-doc -a example/example.api -d "$TEST_DIR" -f test1_swagger2
if [ -f "$TEST_DIR/test1_swagger2.json" ]; then
VERSION=$(jq -r '.swagger' "$TEST_DIR/test1_swagger2.json")
echo "OK: Swagger $VERSION"
else
echo "FAIL"
exit 1
fi
echo ""
echo "測試 2: Swagger 2.0 (YAML)"
./bin/go-doc -a example/example.api -d "$TEST_DIR" -f test2_swagger2 -y
if [ -f "$TEST_DIR/test2_swagger2.yaml" ]; then
echo "OK: YAML format"
else
echo "FAIL"
exit 1
fi
echo ""
echo "測試 3: OpenAPI 3.0 (JSON)"
./bin/go-doc -a example/example.api -d "$TEST_DIR" -f test3_openapi3 -s openapi3.0
if [ -f "$TEST_DIR/test3_openapi3.json" ]; then
VERSION=$(jq -r '.openapi' "$TEST_DIR/test3_openapi3.json")
echo "OK: OpenAPI $VERSION"
else
echo "FAIL"
exit 1
fi
echo ""
echo "測試 4: OpenAPI 3.0 (YAML)"
./bin/go-doc -a example/example.api -d "$TEST_DIR" -f test4_openapi3 -s openapi3.0 -y
if [ -f "$TEST_DIR/test4_openapi3.yaml" ]; then
echo "OK: OpenAPI YAML"
else
echo "FAIL"
exit 1
fi
echo ""
echo "測試 5: 中文範例 (Swagger 2.0)"
./bin/go-doc -a example/example_cn.api -d "$TEST_DIR" -f test5_cn_swagger2
if [ -f "$TEST_DIR/test5_cn_swagger2.json" ]; then
TITLE=$(jq -r '.info.title' "$TEST_DIR/test5_cn_swagger2.json")
echo "OK: $TITLE"
else
echo "FAIL"
exit 1
fi
echo ""
echo "測試 6: 中文範例 (OpenAPI 3.0)"
./bin/go-doc -a example/example_cn.api -d "$TEST_DIR" -f test6_cn_openapi3 -s openapi3.0
if [ -f "$TEST_DIR/test6_cn_openapi3.json" ]; then
TITLE=$(jq -r '.info.title' "$TEST_DIR/test6_cn_openapi3.json")
echo "OK: OpenAPI 3.0 $TITLE"
else
echo "FAIL"
exit 1
fi
echo ""
echo "測試 7: 錯誤處理(無效的 spec-version"
if ./bin/go-doc -a example/example.api -d "$TEST_DIR" -s invalid 2>&1 | grep -q "spec-version must be"; then
echo "OK: error handling"
else
echo "FAIL: error handling"
exit 1
fi
echo ""
echo "生成檔案統計"
echo "================================"
ls -lh "$TEST_DIR"
echo ""
echo "檔案大小比較"
echo "================================"
echo "Swagger 2.0 vs OpenAPI 3.0:"
S2_SIZE=$(stat -f%z "$TEST_DIR/test1_swagger2.json" 2>/dev/null || stat -c%s "$TEST_DIR/test1_swagger2.json")
O3_SIZE=$(stat -f%z "$TEST_DIR/test3_openapi3.json" 2>/dev/null || stat -c%s "$TEST_DIR/test3_openapi3.json")
echo " Swagger 2.0: $S2_SIZE bytes"
echo " OpenAPI 3.0: $O3_SIZE bytes"
echo ""
echo "所有測試通過"
echo ""
echo "生成的檔案位於: $TEST_DIR/"

View File

@ -0,0 +1,50 @@
#!/bin/bash
# 測試 respdoc 功能
set -e
echo "測試 @respdoc 多狀態碼回應功能"
echo "================================"
OUTPUT_DIR="example/test_output"
mkdir -p "$OUTPUT_DIR"
echo ""
echo "測試 1: Swagger 2.0 with @respdoc"
./bin/go-doc -a example/example_respdoc.api -d "$OUTPUT_DIR" -f respdoc_swagger2
CODES=$(jq -r '.paths."/v1/query".get.responses | keys | join(", ")' "$OUTPUT_DIR/respdoc_swagger2.json")
echo "OK: $CODES"
echo ""
echo "測試 2: OpenAPI 3.0 with @respdoc"
./bin/go-doc -a example/example_respdoc.api -d "$OUTPUT_DIR" -f respdoc_openapi3 -s openapi3.0
CODES=$(jq -r '.paths."/v1/query".get.responses | keys | join(", ")' "$OUTPUT_DIR/respdoc_openapi3.json")
echo "OK: $CODES"
echo ""
echo "測試 3: 檢查 400 回應的 oneOfOpenAPI 3.0"
HAS_ONEOF=$(jq '.paths."/v1/query".get.responses."400".content."application/json".schema | has("oneOf")' "$OUTPUT_DIR/respdoc_openapi3.json")
if [ "$HAS_ONEOF" = "true" ]; then
TYPES=$(jq -r '.paths."/v1/query".get.responses."400".content."application/json".schema.oneOf | map(."$ref" | split("/") | last) | join(", ")' "$OUTPUT_DIR/respdoc_openapi3.json")
echo "OK: oneOf types: $TYPES"
else
echo "FAIL: oneOf not found"
exit 1
fi
echo ""
echo "測試 4: 檢查所有錯誤類型的 schema"
SCHEMAS=$(jq -r '.components.schemas | keys | map(select(. | contains("Error"))) | join(", ")' "$OUTPUT_DIR/respdoc_openapi3.json")
echo "OK: $SCHEMAS"
echo ""
echo "測試 5: Swagger 2.0 檢查業務錯誤碼描述"
DESC=$(jq -r '.paths."/v1/query".get.responses."400".description' "$OUTPUT_DIR/respdoc_swagger2.json" | head -3)
echo "400 描述:"
echo "$DESC"
echo ""
echo "所有 @respdoc 測試通過"
echo ""
echo "生成的檔案:"
ls -lh "$OUTPUT_DIR"/respdoc_*

View File

@ -0,0 +1,27 @@
// Code scaffolded by goctl. Safe to edit.
// goctl {{.version}}
package {{.PkgName}}
import (
"net/http"
"gateway/internal/response"
"github.com/zeromicro/go-zero/rest/httpx"
{{.ImportPackages}}
)
{{if .HasDoc}}{{.Doc}}{{end}}
func {{.HandlerName}}(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
{{if .HasRequest}}var req types.{{.RequestType}}
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err))
return
}
{{end}}l := {{.LogicName}}.New{{.LogicType}}(r.Context(), svcCtx)
{{if .HasResp}}data, {{end}}err := l.{{.Call}}({{if .HasRequest}}&req{{end}})
{{if .HasResp}}response.Write(r.Context(), w, data, err){{else}}response.Write(r.Context(), w, nil, err){{end}}
}
}

52
go.mod Normal file
View File

@ -0,0 +1,52 @@
module gateway
go 1.26.1
require github.com/zeromicro/go-zero v1.10.1
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grafana/pyroscope-go v1.2.8 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/openzipkin/zipkin-go v0.4.3 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/titanous/json5 v1.0.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/zipkin v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)

137
go.sum Normal file
View File

@ -0,0 +1,137 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grafana/pyroscope-go v1.2.8 h1:UvCwIhlx9DeV7F6TW/z8q1Mi4PIm3vuUJ2ZlCEvmA4M=
github.com/grafana/pyroscope-go v1.2.8/go.mod h1:SSi59eQ1/zmKoY/BKwa5rSFsJaq+242Bcrr4wPix1g8=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg=
github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/robertkrimen/otto v0.2.1 h1:FVP0PJ0AHIjC+N4pKCG9yCDz6LHNPCwi/GKID5pGGF0=
github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//farB5FlRY=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/titanous/json5 v1.0.0 h1:hJf8Su1d9NuI/ffpxgxQfxh/UiBFZX7bMPid0rIL/7s=
github.com/titanous/json5 v1.0.0/go.mod h1:7JH1M8/LHKc6cyP5o5g3CSaRj+mBrIimTxzpvmckH8c=
github.com/zeromicro/go-zero v1.10.1 h1:1nM3ilvYx97GUqyaNH2IQPtfNyK7tp5JvN63c7m6QKU=
github.com/zeromicro/go-zero v1.10.1/go.mod h1:z41DXmO6gx/Se7Ow5UIwPxcUmpVj3ebhoNCcZ1gfp5k=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8=
go.opentelemetry.io/otel/exporters/zipkin v1.40.0 h1:zu+I4j+FdO6xIxBVPeuncQVbjxUM4LiMgv6GwGe9REE=
go.opentelemetry.io/otel/exporters/zipkin v1.40.0/go.mod h1:zS6cC4nFBYXbu18e7aLfMzubBjOiN7ZcROu477qtMf8=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI=
gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM=
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=

10
internal/config/config.go Normal file
View File

@ -0,0 +1,10 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package config
import "github.com/zeromicro/go-zero/rest"
type Config struct {
rest.RestConf
}

View File

@ -0,0 +1,20 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package normal
import (
"net/http"
"gateway/internal/logic/normal"
"gateway/internal/response"
"gateway/internal/svc"
)
func PingHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := normal.NewPingLogic(r.Context(), svcCtx)
data, err := l.Ping()
response.Write(r.Context(), w, data, err)
}
}

View File

@ -0,0 +1,29 @@
// Code generated by goctl. DO NOT EDIT.
// goctl 1.10.1
package handler
import (
"net/http"
"time"
normal "gateway/internal/handler/normal"
"gateway/internal/svc"
"github.com/zeromicro/go-zero/rest"
)
func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
server.AddRoutes(
[]rest.Route{
{
// Ping
Method: http.MethodGet,
Path: "/health",
Handler: normal.PingHandler(serverCtx),
},
},
rest.WithPrefix("/api/v1"),
rest.WithTimeout(3000*time.Millisecond),
)
}

View File

@ -0,0 +1,51 @@
// Package errlog provides optional logging helpers for gateway/internal/library/errors.
// Keep logging out of the core errors package so callers can use slog, zap, or any logger.
package errlog
import (
"context"
"log/slog"
errs "gateway/internal/library/errors"
)
// Attrs builds structured fields for any logger from an *errs.Error.
func Attrs(e *errs.Error) []any {
if e == nil {
return nil
}
attrs := []any{
"code", e.DisplayCode(),
"scope", e.Scope(),
"category", e.Category(),
"detail", e.Detail(),
"http_status", e.HTTPStatus(),
"grpc_code", e.GRPCCode().String(),
}
if cause := e.Unwrap(); cause != nil {
attrs = append(attrs, "cause", cause)
}
return attrs
}
// Log writes a structured log line for e using slog.
func Log(ctx context.Context, log *slog.Logger, level slog.Level, e *errs.Error, msg string, attrs ...any) {
if log == nil || e == nil {
return
}
args := append(Attrs(e), attrs...)
log.Log(ctx, level, msg, args...)
}
// Error logs at slog.LevelError.
func Error(ctx context.Context, log *slog.Logger, e *errs.Error, msg string, attrs ...any) {
Log(ctx, log, slog.LevelError, e, msg, attrs...)
}
// Warn logs at slog.LevelWarn.
func Warn(ctx context.Context, log *slog.Logger, e *errs.Error, msg string, attrs ...any) {
Log(ctx, log, slog.LevelWarn, e, msg, attrs...)
}

View File

@ -0,0 +1,303 @@
# 結構化錯誤碼
套件路徑:`gateway/internal/library/errors`import 建議別名 `errs`
## 錯誤碼格式
8 碼 **SSCCCDDD**(十進位,左側補零顯示):
| 段 | 名稱 | 範圍 | 說明 |
|----|------|------|------|
| SS | Scope | 0099 | 服務 / 模組(見 `code/types.go` 常數) |
| CCC | Category | 000999 | 錯誤類別,**決定 HTTP / gRPC 映射** |
| DDD | Detail | 000999 | 業務細節,不影響 HTTP 狀態(`CatGRPC` 除外) |
範例:`10101000` → Scope=`10`Facade、Category=`101`InputInvalidFormat、Detail=`000`。
```
10101000
^^ Scope = 10
^^^ Category = 101
^^^ Detail = 000
```
---
## 快速開始
```go
package myhandler
import (
"net/http"
errs "gateway/internal/library/errors"
"gateway/internal/library/errors/code"
)
// 模組頂層綁定 scope每個 binary / 套件一次)
var errb = errs.For(code.Facade)
func GetUser(w http.ResponseWriter, id string) error {
if id == "" {
return errb.InputInvalidFormat("缺少參數: id")
}
u, err := repo.Find(id)
if err != nil {
return errb.DBError("查詢失敗").WithCause(err)
}
if u == nil {
return errb.ResNotFound("user", id)
}
return nil
}
func writeHTTP(w http.ResponseWriter, e *errs.Error) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("X-Error-Code", e.DisplayCode())
w.WriteHeader(e.HTTPStatus())
// body: {"code":"10101000","message":"..."}
}
```
### 常用 API
| 需求 | 用法 |
|------|------|
| 綁定 scope | `errs.For(code.Facade)` |
| 語意化建構 | `errb.ResNotFound("user", id)` |
| 自訂 category + detail | `errb.Code(code.SysTimeout, 42, "下游逾時")` |
| 附加底層錯誤 | `e.WithCause(err)`(不可變) |
| 更換 scope | `e.WithScope(code.LocalAPI)` |
| 嚴格驗證 | `errs.New(scope, cat, det, msg)``(*Error, error)` |
| 從 8 碼還原 | `errs.FromCode(10101000)` |
| 日誌(可選) | `gateway/internal/library/errlog` + `slog` |
---
## Category → HTTP 完整對照表
`HTTPStatus()` 實作一致。未列出的 category 會 fallback 為 **500**
### A. Input1xx
| Category | 常數 | HTTP | 說明 |
|:--------:|------|:----:|------|
| 101 | `InputInvalidFormat` | **400** Bad Request | 格式錯、型別錯 |
| 102 | `InputNotValidImplementation` | **422** Unprocessable Entity | 語意正確但無法依目前實作處理 |
| 103 | `InputInvalidRange` | **422** | 數值 / 範圍不合法 |
| 104 | `InputMissingRequired` | **400** | 必填欄位缺失 |
| 105 | `InputUnsupportedMedia` | **415** Unsupported Media Type | `Content-Type` 不支援 |
| 106 | `InputPayloadTooLarge` | **413** Payload Too Large | HTTP body 過大 |
### B. DB2xx
| Category | 常數 | HTTP | 說明 |
|:--------:|------|:----:|------|
| 201 | `DBError` | **500** Internal Server Error | 非預期 DB 故障 |
| 202 | `DBDataConvert` | **422** | 可修正的資料轉換問題 |
| 203 | `DBDuplicate` | **409** Conflict | 唯一鍵 / 重複寫入 |
| 204 | `DBUnavailable` | **503** Service Unavailable | DB 暫時不可用(可重試) |
### C. Resource3xx
| Category | 常數 | HTTP | 說明 |
|:--------:|------|:----:|------|
| 301 | `ResNotFound` | **404** Not Found | 資源不存在 |
| 302 | `ResInvalidFormat` | **422** | 資源表示 / Schema 不符 |
| 303 | `ResAlreadyExist` | **409** | 已存在 |
| 304 | `ResInsufficient` | **400** | 數量 / 容量不足(客戶端可調參數) |
| 305 | `ResInsufficientPerm` | **403** Forbidden | 對該資源無權限 |
| 306 | `ResInvalidMeasureID` | **400** | ID 格式不合法 |
| 307 | `ResExpired` | **410** Gone | 已過期 |
| 308 | `ResMigrated` | **410** | 已遷移(可在 Gateway 加 `Location` |
| 309 | `ResInvalidState` | **409** | 狀態機不允許此操作 |
| 310 | `ResInsufficientQuota` | **429** Too Many Requests | 配額 / 額度不足 |
| 311 | `ResMultiOwner` | **409** | 所有權衝突 |
| 312 | `ResPreconditionFailed` | **412** Precondition Failed | ETag / 版本前置條件失敗 |
| 313 | `ResLocked` | **423** Locked | 資源被鎖定 |
### D. gRPC 轉換4xx
| Category | 常數 | HTTP | 說明 |
|:--------:|------|:----:|------|
| 400 | `CatGRPC` | **依 Detail** | Detail 存標準 `codes.Code`016見下方子表 |
`CatGRPC` 子映射(與 [Google API 設計指南](https://cloud.google.com/apis/design/errors) 對齊):
| gRPC Code | HTTP |
|-----------|:----:|
| `InvalidArgument`, `OutOfRange`, `FailedPrecondition` | 400 |
| `NotFound` | 404 |
| `AlreadyExists`, `Aborted` | 409 |
| `PermissionDenied` | 403 |
| `Unauthenticated` | 401 |
| `ResourceExhausted` | 429 |
| `DeadlineExceeded` | 504 |
| `Unavailable` | 503 |
| `Unimplemented` | 501 |
| `Canceled` | 408 |
| `Internal`, `Unknown`, `DataLoss` | 500 |
### E. Auth5xx
| Category | 常數 | HTTP | 說明 |
|:--------:|------|:----:|------|
| 501 | `AuthUnauthorized` | **401** Unauthorized | 未提供或無效憑證 |
| 502 | `AuthExpired` | **401** | Token / 會話過期 |
| 503 | `AuthInvalidPosixTime` | **401** | 時戳異常導致驗簽失敗 |
| 504 | `AuthSigPayloadMismatch` | **401** | 簽名與 payload 不符 |
| 505 | `AuthForbidden` | **403** | 已驗證但無操作權限 |
| 506 | `AuthMethodNotAllowed` | **405** Method Not Allowed | HTTP method 不允許 |
### F. System6xx
| Category | 常數 | HTTP | 說明 |
|:--------:|------|:----:|------|
| 601 | `SysInternal` | **500** | 系統內部錯誤 |
| 602 | `SysMaintain` | **503** Service Unavailable | 維護 / 停機 |
| 603 | `SysTimeout` | **504** Gateway Timeout | 處理或下游逾時 |
| 604 | `SysTooManyRequest` | **429** | 全局限流 |
| 605 | `SysNotImplemented` | **501** Not Implemented | 功能未上線 / 開關關閉 |
| 606 | `SysClientTimeout` | **408** Request Timeout | 客戶端未完成請求(與 603/504 區分) |
### G. PubSub7xx
| Category | 常數 | HTTP | 說明 |
|:--------:|------|:----:|------|
| 701 | `PSuPublish` | **502** Bad Gateway | 發佈到匯流排失敗 |
| 702 | `PSuConsume` | **502** | 消費失敗 |
| 703 | `PSuTooLarge` | **413** Payload Too Large | 訊息過大 |
### H. Service8xx
| Category | 常數 | HTTP | 說明 |
|:--------:|------|:----:|------|
| 801 | `SvcInternal` | **500** | 服務邏輯內錯 |
| 802 | `SvcThirdParty` | **502** | 呼叫外部 API 失敗 |
| 803 | `SvcHTTP400` | **400** | 明確要回 400 的業務情境 |
| 804 | `SvcMaintenance` | **503** | 模組級維護 |
| 805 | `SvcRateLimited` | **429** | 特定下游 / 供應商限流 |
### HTTP 狀態碼使用一覽
本套件目前會回傳的 HTTP 狀態:
`200` `400` `401` `403` `404` `405` `408` `409` `410` `412` `413` `415` `422` `423` `429` `500` `501` `502` `503` `504`
---
## HTTP 映射設計評估
### 整體結論:**合理,可作為 API Gateway 的預設策略**
多數映射符合 REST 慣例與 Google / Microsoft API 設計指南。Category 負責「協定層語意」Detail 負責「業務細節」,分工清楚。
### 映射得當(建議維持)
| 映射 | 理由 |
|------|------|
| Input 格式 → 400語意 → 422 | 區分「語法錯」與「語意錯」,利於客戶端處理 |
| DB 重複 → 409 | 標準 REST 做法 |
| 資源不存在 → 404 | 標準 |
| 過期 / 遷移 → 410 | 比 404 更精確表達「曾存在但不可用」 |
| 狀態衝突 / 多所有者 → 409 | 符合狀態機與併發場景 |
| 配額 / 限流 → 429 | 與 `Retry-After` 搭配良好 |
| 未驗證 → 401已驗證無權 → 403 | 符合 RFC 9110 |
| 下游 / 第三方 / MQ → 502 | Gateway 視角正確 |
| 維護 → 503 | 可搭配 `Retry-After` |
| 處理逾時 → 504 | 適合 Gateway區分於客戶端 408 |
### 可商榷(依產品政策調整,非必須改)
| 現狀 | 替代方案 | 何時考慮 |
|------|----------|----------|
| `DBError` → 500 | 使用 **`DBUnavailable` → 503** | 暫時性連線問題應改用 `errb.DBUnavailable()` |
| `ResInsufficient` → 400 | **409****422** | 若語意是「庫存不足導致無法完成訂單」而非「參數錯」 |
| `PSuPublish/Consume` → 502 | **503** | 訊息中介明確處於不可用(非單次請求失敗) |
| `SysTimeout` → 504 | **500** | 若逾時發生在服務內部、前面沒有 Gateway 代理 |
| `AuthExpired` → 401 | 少數團隊用 403 | 401 較符合 OAuth2 / OIDC 慣例,建議維持 401 |
| `FailedPrecondition`gRPC→ 400 | **412** | 若錯誤來自 `If-Match` / ETag 版本衝突 |
### 先前缺口(已補)
| 問題 | 處理 |
|------|------|
| `CatGRPC`400未映射一律 500 | 已依 Detail 中的 gRPC code 映射 HTTP見上表 |
| 未知 category | 仍 fallback **500**(保守、安全) |
---
### 號段保留建議(擴充用)
| 號段 | 用途 |
|------|------|
| 104199 | Input 擴充 |
| 204299 | DB 擴充 |
| 312399 | Resource 擴充400 保留給 `CatGRPC` |
| 506599 | Auth 擴充 |
| 605699 | System 擴充 |
| 704799 | PubSub 擴充 |
| 805899 | Service 擴充 |
| 900999 | 平台 / 保留 |
---
## gRPC
```go
// 服務端:回傳 status
return e.GRPCStatus().Err()
// 客戶端:還原為 *Error內建 gRPC 碼請傳入本服務 scope
e, err := errs.FromGRPCError(grpcErr, code.Facade)
```
- 業務 8 碼寫在 message`[10101007] email invalid`
- `GRPCCode()`**Category** 映射標準 gRPC code不會把 8 碼當成 gRPC code
- Category → gRPC 對照見 `grpc.go``grpcCodeForCategory`
---
## 日誌(可選,`errlog`
```go
import (
"log/slog"
"gateway/internal/library/errlog"
)
errlog.Error(ctx, slog.Default(), e, "request failed", "req_id", reqID)
// 或只取欄位,餵給 go-zero / zap
attrs := errlog.Attrs(e)
```
---
## Scope 常數
定義於 `code/types.go`,例如:`Facade(10)`、`LocalAPI(11)`、`GearAuditLog(12)` … `GearAssetMgr(27)`
新增服務時在該檔登記,避免號段衝突。
---
## 遷移(舊版 API
| 舊 | 新 |
|----|-----|
| `errs.Scope = code.Facade` | `var errb = errs.For(code.Facade)` |
| `errs.ResNotFoundError("x")` | `errb.ResNotFound("x")` |
| `e.Wrap(err)` | `e.WithCause(err)` |
| `ResNotFoundErrorL` / `WithLog` | 先建構 error`errlog.Error` |
---
## 測試
```bash
go test ./internal/library/errors/...
```

View File

@ -0,0 +1,220 @@
package errs
import (
"strings"
"gateway/internal/library/errors/code"
)
// Builder constructs *Error values for a fixed scope.
type Builder struct {
scope code.Scope
}
// Scope returns the builder's scope.
func (b Builder) Scope() code.Scope {
return b.scope
}
// Code builds an error from category, detail, and an optional message.
func (b Builder) Code(category code.Category, detail code.Detail, msg ...string) *Error {
return b.must(category, detail, joinMsg(msg))
}
func (b Builder) must(category code.Category, detail code.Detail, msg string) *Error {
return MustNew(b.scope, category, detail, msg)
}
func joinMsg(parts []string) string {
if len(parts) == 0 {
return ""
}
return strings.Join(parts, " ")
}
/* ----- Input ----- */
func (b Builder) InputInvalidFormat(msg ...string) *Error {
return b.must(code.InputInvalidFormat, 0, joinMsg(msg))
}
func (b Builder) InputNotValidImplementation(msg ...string) *Error {
return b.must(code.InputNotValidImplementation, 0, joinMsg(msg))
}
func (b Builder) InputInvalidRange(msg ...string) *Error {
return b.must(code.InputInvalidRange, 0, joinMsg(msg))
}
func (b Builder) InputMissingRequired(msg ...string) *Error {
return b.must(code.InputMissingRequired, 0, joinMsg(msg))
}
func (b Builder) InputUnsupportedMedia(msg ...string) *Error {
return b.must(code.InputUnsupportedMedia, 0, joinMsg(msg))
}
func (b Builder) InputPayloadTooLarge(msg ...string) *Error {
return b.must(code.InputPayloadTooLarge, 0, joinMsg(msg))
}
/* ----- DB ----- */
func (b Builder) DBError(msg ...string) *Error {
return b.must(code.DBError, 0, joinMsg(msg))
}
func (b Builder) DBDataConvert(msg ...string) *Error {
return b.must(code.DBDataConvert, 0, joinMsg(msg))
}
func (b Builder) DBDuplicate(msg ...string) *Error {
return b.must(code.DBDuplicate, 0, joinMsg(msg))
}
func (b Builder) DBUnavailable(msg ...string) *Error {
return b.must(code.DBUnavailable, 0, joinMsg(msg))
}
/* ----- Resource ----- */
func (b Builder) ResNotFound(msg ...string) *Error {
return b.must(code.ResNotFound, 0, joinMsg(msg))
}
func (b Builder) ResInvalidFormat(msg ...string) *Error {
return b.must(code.ResInvalidFormat, 0, joinMsg(msg))
}
func (b Builder) ResAlreadyExist(msg ...string) *Error {
return b.must(code.ResAlreadyExist, 0, joinMsg(msg))
}
func (b Builder) ResInsufficient(msg ...string) *Error {
return b.must(code.ResInsufficient, 0, joinMsg(msg))
}
func (b Builder) ResInsufficientPerm(msg ...string) *Error {
return b.must(code.ResInsufficientPerm, 0, joinMsg(msg))
}
func (b Builder) ResInvalidMeasureID(msg ...string) *Error {
return b.must(code.ResInvalidMeasureID, 0, joinMsg(msg))
}
func (b Builder) ResExpired(msg ...string) *Error {
return b.must(code.ResExpired, 0, joinMsg(msg))
}
func (b Builder) ResMigrated(msg ...string) *Error {
return b.must(code.ResMigrated, 0, joinMsg(msg))
}
func (b Builder) ResInvalidState(msg ...string) *Error {
return b.must(code.ResInvalidState, 0, joinMsg(msg))
}
func (b Builder) ResInsufficientQuota(msg ...string) *Error {
return b.must(code.ResInsufficientQuota, 0, joinMsg(msg))
}
func (b Builder) ResMultiOwner(msg ...string) *Error {
return b.must(code.ResMultiOwner, 0, joinMsg(msg))
}
func (b Builder) ResPreconditionFailed(msg ...string) *Error {
return b.must(code.ResPreconditionFailed, 0, joinMsg(msg))
}
func (b Builder) ResLocked(msg ...string) *Error {
return b.must(code.ResLocked, 0, joinMsg(msg))
}
/* ----- Auth ----- */
func (b Builder) AuthUnauthorized(msg ...string) *Error {
return b.must(code.AuthUnauthorized, 0, joinMsg(msg))
}
func (b Builder) AuthExpired(msg ...string) *Error {
return b.must(code.AuthExpired, 0, joinMsg(msg))
}
func (b Builder) AuthInvalidPosixTime(msg ...string) *Error {
return b.must(code.AuthInvalidPosixTime, 0, joinMsg(msg))
}
func (b Builder) AuthSigPayloadMismatch(msg ...string) *Error {
return b.must(code.AuthSigPayloadMismatch, 0, joinMsg(msg))
}
func (b Builder) AuthForbidden(msg ...string) *Error {
return b.must(code.AuthForbidden, 0, joinMsg(msg))
}
func (b Builder) AuthMethodNotAllowed(msg ...string) *Error {
return b.must(code.AuthMethodNotAllowed, 0, joinMsg(msg))
}
/* ----- System ----- */
func (b Builder) SysInternal(msg ...string) *Error {
return b.must(code.SysInternal, 0, joinMsg(msg))
}
func (b Builder) SysMaintain(msg ...string) *Error {
return b.must(code.SysMaintain, 0, joinMsg(msg))
}
func (b Builder) SysTimeout(msg ...string) *Error {
return b.must(code.SysTimeout, 0, joinMsg(msg))
}
func (b Builder) SysTooManyRequest(msg ...string) *Error {
return b.must(code.SysTooManyRequest, 0, joinMsg(msg))
}
func (b Builder) SysNotImplemented(msg ...string) *Error {
return b.must(code.SysNotImplemented, 0, joinMsg(msg))
}
func (b Builder) SysClientTimeout(msg ...string) *Error {
return b.must(code.SysClientTimeout, 0, joinMsg(msg))
}
/* ----- PubSub ----- */
func (b Builder) PSuPublish(msg ...string) *Error {
return b.must(code.PSuPublish, 0, joinMsg(msg))
}
func (b Builder) PSuConsume(msg ...string) *Error {
return b.must(code.PSuConsume, 0, joinMsg(msg))
}
func (b Builder) PSuTooLarge(msg ...string) *Error {
return b.must(code.PSuTooLarge, 0, joinMsg(msg))
}
/* ----- Service ----- */
func (b Builder) SvcInternal(msg ...string) *Error {
return b.must(code.SvcInternal, 0, joinMsg(msg))
}
func (b Builder) SvcThirdParty(msg ...string) *Error {
return b.must(code.SvcThirdParty, 0, joinMsg(msg))
}
func (b Builder) SvcHTTP400(msg ...string) *Error {
return b.must(code.SvcHTTP400, 0, joinMsg(msg))
}
func (b Builder) SvcMaintenance(msg ...string) *Error {
return b.must(code.SvcMaintenance, 0, joinMsg(msg))
}
func (b Builder) SvcRateLimited(msg ...string) *Error {
return b.must(code.SvcRateLimited, 0, joinMsg(msg))
}

View File

@ -0,0 +1,138 @@
package code
type Scope uint32 // SS (00..99)
type Category uint32 // CCC (000..999)
type Detail uint32 // DDD (000..999)
const (
Unset Scope = 0
MaxScope Scope = 99
CategoryMultiplier = 1000
ScopeMultiplier = 1000000
NonCode uint32 = 0
OK uint32 = 0
DefaultSuccessFullCode = "102000"
DefaultSuccessMessage = "SUCCESS"
DefaultSuccessFullCodeInt = 102000
)
const (
MaxCategory Category = 999
MaxDetail Detail = 999
DefaultCategory Category = 0
DefaultDetail Detail = 0
)
// Valid reports whether s is within the allowed scope range.
func (s Scope) Valid() bool {
return s <= MaxScope
}
// Valid reports whether c is within the allowed category range.
func (c Category) Valid() bool {
return c <= MaxCategory
}
// Valid reports whether d is within the allowed detail range.
func (d Detail) Valid() bool {
return d <= MaxDetail
}
// Input errors (101-106)
const (
InputInvalidFormat Category = 101
InputNotValidImplementation Category = 102
InputInvalidRange Category = 103
InputMissingRequired Category = 104
InputUnsupportedMedia Category = 105
InputPayloadTooLarge Category = 106
)
// DB errors (201-204)
const (
DBError Category = 201
DBDataConvert Category = 202
DBDuplicate Category = 203
DBUnavailable Category = 204
)
// Resource errors (301-311)
const (
ResNotFound Category = 301
ResInvalidFormat Category = 302
ResAlreadyExist Category = 303
ResInsufficient Category = 304
ResInsufficientPerm Category = 305
ResInvalidMeasureID Category = 306
ResExpired Category = 307
ResMigrated Category = 308
ResInvalidState Category = 309
ResInsufficientQuota Category = 310
ResMultiOwner Category = 311
ResPreconditionFailed Category = 312
ResLocked Category = 313
)
// GRPC mapping category (400)
const (
CatGRPC Category = 400
)
// Auth errors (501-505)
const (
AuthUnauthorized Category = 501
AuthExpired Category = 502
AuthInvalidPosixTime Category = 503
AuthSigPayloadMismatch Category = 504
AuthForbidden Category = 505
AuthMethodNotAllowed Category = 506
)
// System errors (601-604)
const (
SysInternal Category = 601
SysMaintain Category = 602
SysTimeout Category = 603
SysTooManyRequest Category = 604
SysNotImplemented Category = 605
SysClientTimeout Category = 606
)
// PubSub errors (701-703)
const (
PSuPublish Category = 701
PSuConsume Category = 702
PSuTooLarge Category = 703
)
// Service errors (801-804)
const (
SvcInternal Category = 801
SvcThirdParty Category = 802
SvcHTTP400 Category = 803
SvcMaintenance Category = 804
SvcRateLimited Category = 805
)
// Service/module scopes (SS).
const (
Facade Scope = 10
LocalAPI Scope = 11
GearAuditLog Scope = 12
GearConsistMGR Scope = 13
GearDataReport Scope = 14
GearEventReactor Scope = 15
GearImporterScope Scope = 16
MonitorDataProcessorScope Scope = 17
OrbitDistributedScheduler Scope = 18
OrbitEventMGR Scope = 19
OrbitIgniter Scope = 20
OrbitLauncher Scope = 21
OrbitMonitor Scope = 22
OrbitPowerMGR Scope = 23
OrbitTasker Scope = 24
PluginVcenterHSM Scope = 25
PluginMGR Scope = 26
GearAssetMgr Scope = 27
)

View File

@ -0,0 +1,104 @@
package errs
import (
"errors"
"fmt"
"regexp"
"strconv"
"gateway/internal/library/errors/code"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
var grpcDisplayCodeRE = regexp.MustCompile(`^\[(\d{8})\]\s*(.*)$`)
// FromError unwraps err and returns the first *Error in the chain.
func FromError(err error) *Error {
if err == nil {
return nil
}
var e *Error
if errors.As(err, &e) {
return e
}
return nil
}
// FromCode parses an 8-digit code (SSCCCDDD) into an Error without a message.
func FromCode(raw uint32) (*Error, error) {
scope := code.Scope(raw / code.ScopeMultiplier)
sub := raw % code.ScopeMultiplier
category := code.Category(sub / code.CategoryMultiplier)
detail := code.Detail(sub % code.CategoryMultiplier)
return New(scope, category, detail, "")
}
// FromGRPCError converts a gRPC status error into *Error.
// When scope is omitted, code.Unset is used for built-in gRPC status codes.
func FromGRPCError(err error, scope ...code.Scope) (*Error, error) {
if err == nil {
return nil, nil
}
sc := code.Unset
if len(scope) > 0 {
sc = scope[0]
}
s, ok := status.FromError(err)
if !ok {
return nil, fmt.Errorf("not a gRPC status error: %w", err)
}
if parsed := parseGRPCStatusMessage(s); parsed != nil {
if parsed.Scope() == code.Unset && sc != code.Unset {
out, scopeErr := parsed.WithScope(sc)
if scopeErr != nil {
return nil, scopeErr
}
return out.WithCause(err), nil
}
return parsed.WithCause(err), nil
}
// Standard gRPC codes (0-16): map into CatGRPC with detail = grpc code number.
if isBuiltinGRPCCode(s.Code()) {
return MustNew(sc, code.CatGRPC, code.Detail(s.Code()), s.Message()).WithCause(err), nil
}
// Custom status code carrying full business code (legacy clients).
if e, convErr := FromCode(uint32(s.Code())); convErr == nil {
return e.WithMessage(s.Message()).WithCause(err), nil
}
return nil, fmt.Errorf("unable to convert gRPC error: code=%s msg=%q", s.Code(), s.Message())
}
func parseGRPCStatusMessage(s *status.Status) *Error {
matches := grpcDisplayCodeRE.FindStringSubmatch(s.Message())
if len(matches) != 3 {
return nil
}
raw, err := strconv.ParseUint(matches[1], 10, 32)
if err != nil {
return nil
}
e, err := FromCode(uint32(raw))
if err != nil {
return nil
}
return e.WithMessage(matches[2])
}
func isBuiltinGRPCCode(c codes.Code) bool {
return uint32(c) <= uint32(codes.Unauthenticated)
}

View File

@ -0,0 +1,293 @@
package errs
import (
"errors"
"fmt"
"net/http"
"gateway/internal/library/errors/code"
)
// Error is a structured application error with an 8-digit code: SSCCCDDD.
type Error struct {
scope code.Scope
category code.Category
detail code.Detail
msg string
internalErr error
}
// New constructs an Error after validating scope, category, and detail.
func New(scope code.Scope, category code.Category, detail code.Detail, displayMsg string) (*Error, error) {
if err := validateComponents(scope, category, detail); err != nil {
return nil, err
}
return &Error{
scope: scope,
category: category,
detail: detail,
msg: displayMsg,
}, nil
}
// MustNew is like New but panics when components are invalid.
// Intended for compile-time constants (Builder helpers).
func MustNew(scope code.Scope, category code.Category, detail code.Detail, displayMsg string) *Error {
e, err := New(scope, category, detail, displayMsg)
if err != nil {
panic(err)
}
return e
}
// For returns a scope-bound builder. Prefer this over package-level globals.
//
// var appErr = errs.For(code.Facade)
// return appErr.ResNotFound("user", id).WithCause(err)
func For(scope code.Scope) Builder {
return Builder{scope: scope}
}
// Error returns the client-facing message.
func (e *Error) Error() string {
if e == nil {
return ""
}
return e.msg
}
func (e *Error) Scope() code.Scope {
if e == nil {
return code.Unset
}
return e.scope
}
func (e *Error) Category() code.Category {
if e == nil {
return code.DefaultCategory
}
return e.category
}
func (e *Error) Detail() code.Detail {
if e == nil {
return code.DefaultDetail
}
return e.detail
}
// SubCode returns the 6-digit CCCDDD portion.
func (e *Error) SubCode() uint32 {
if e == nil {
return code.OK
}
return uint32(e.category)*code.CategoryMultiplier + uint32(e.detail)
}
// Code returns the full 8-digit SSCCCDDD code.
func (e *Error) Code() uint32 {
if e == nil {
return code.NonCode
}
return uint32(e.scope)*code.ScopeMultiplier + e.SubCode()
}
// DisplayCode returns the 8-digit code as a zero-padded string.
func (e *Error) DisplayCode() string {
if e == nil {
return "00000000"
}
return fmt.Sprintf("%08d", e.Code())
}
// Is implements errors.Is semantics using the full 8-digit code.
func (e *Error) Is(target error) bool {
var t *Error
if !errors.As(target, &t) {
return false
}
return e.Code() == t.Code()
}
// Unwrap returns the wrapped cause, if any.
func (e *Error) Unwrap() error {
if e == nil {
return nil
}
return e.internalErr
}
func (e *Error) clone() *Error {
if e == nil {
return nil
}
cp := *e
return &cp
}
// WithCause returns a copy of e with the given cause attached.
func (e *Error) WithCause(cause error) *Error {
if e == nil {
return nil
}
cp := e.clone()
cp.internalErr = cause
return cp
}
// WithScope returns a copy of e using a different scope.
func (e *Error) WithScope(scope code.Scope) (*Error, error) {
if e == nil {
return nil, nil
}
if !scope.Valid() {
return nil, fmt.Errorf("%w: scope %d exceeds max %d", ErrInvalidCode, scope, code.MaxScope)
}
cp := e.clone()
cp.scope = scope
return cp, nil
}
// MustWithScope is like WithScope but panics on invalid scope.
func (e *Error) MustWithScope(scope code.Scope) *Error {
out, err := e.WithScope(scope)
if err != nil {
panic(err)
}
return out
}
// WithDetail returns a copy of e with a different detail code.
func (e *Error) WithDetail(detail code.Detail) (*Error, error) {
if e == nil {
return nil, nil
}
if !detail.Valid() {
return nil, fmt.Errorf("%w: detail %d exceeds max %d", ErrInvalidCode, detail, code.MaxDetail)
}
cp := e.clone()
cp.detail = detail
return cp, nil
}
// WithMessage returns a copy of e with a different client-facing message.
func (e *Error) WithMessage(msg string) *Error {
if e == nil {
return nil
}
cp := e.clone()
cp.msg = msg
return cp
}
// HTTPStatus maps the error category to an HTTP status code.
func (e *Error) HTTPStatus() int {
if e == nil || e.SubCode() == code.OK {
return http.StatusOK
}
switch e.Category() {
case code.InputInvalidFormat, code.InputMissingRequired:
return http.StatusBadRequest
case code.InputNotValidImplementation, code.InputInvalidRange:
return http.StatusUnprocessableEntity
case code.InputUnsupportedMedia:
return http.StatusUnsupportedMediaType
case code.InputPayloadTooLarge:
return http.StatusRequestEntityTooLarge
case code.DBError:
return http.StatusInternalServerError
case code.DBDataConvert:
return http.StatusUnprocessableEntity
case code.DBDuplicate:
return http.StatusConflict
case code.DBUnavailable:
return http.StatusServiceUnavailable
case code.ResNotFound:
return http.StatusNotFound
case code.ResInvalidFormat:
return http.StatusUnprocessableEntity
case code.ResAlreadyExist:
return http.StatusConflict
case code.ResInsufficient, code.ResInvalidMeasureID:
return http.StatusBadRequest
case code.ResInsufficientPerm:
return http.StatusForbidden
case code.ResExpired, code.ResMigrated:
return http.StatusGone
case code.ResInvalidState, code.ResMultiOwner:
return http.StatusConflict
case code.ResInsufficientQuota:
return http.StatusTooManyRequests
case code.ResPreconditionFailed:
return http.StatusPreconditionFailed
case code.ResLocked:
return http.StatusLocked
case code.AuthUnauthorized, code.AuthExpired, code.AuthInvalidPosixTime, code.AuthSigPayloadMismatch:
return http.StatusUnauthorized
case code.AuthForbidden:
return http.StatusForbidden
case code.AuthMethodNotAllowed:
return http.StatusMethodNotAllowed
case code.SysTooManyRequest:
return http.StatusTooManyRequests
case code.SysInternal:
return http.StatusInternalServerError
case code.SysMaintain:
return http.StatusServiceUnavailable
case code.SysTimeout:
return http.StatusGatewayTimeout
case code.SysNotImplemented:
return http.StatusNotImplemented
case code.SysClientTimeout:
return http.StatusRequestTimeout
case code.PSuPublish, code.PSuConsume:
return http.StatusBadGateway
case code.PSuTooLarge:
return http.StatusRequestEntityTooLarge
case code.SvcMaintenance:
return http.StatusServiceUnavailable
case code.SvcInternal:
return http.StatusInternalServerError
case code.SvcThirdParty:
return http.StatusBadGateway
case code.SvcHTTP400:
return http.StatusBadRequest
case code.SvcRateLimited:
return http.StatusTooManyRequests
case code.CatGRPC:
return httpStatusFromGRPCDetail(e.Detail())
}
return http.StatusInternalServerError
}

View File

@ -0,0 +1,188 @@
package errs_test
import (
"errors"
"net/http"
"testing"
errs "gateway/internal/library/errors"
"gateway/internal/library/errors/code"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
var app = errs.For(code.Facade)
func TestNewValidation(t *testing.T) {
t.Parallel()
_, err := errs.New(code.Scope(100), code.InputInvalidFormat, 0, "bad scope")
if !errors.Is(err, errs.ErrInvalidCode) {
t.Fatalf("expected ErrInvalidCode, got %v", err)
}
_, err = errs.New(code.Facade, code.Category(1000), 0, "bad category")
if !errors.Is(err, errs.ErrInvalidCode) {
t.Fatalf("expected ErrInvalidCode, got %v", err)
}
}
func TestBuilderCode(t *testing.T) {
t.Parallel()
e := app.ResNotFound("user", "42")
if e.DisplayCode() != "10301000" {
t.Fatalf("DisplayCode = %s, want 10301000", e.DisplayCode())
}
if e.HTTPStatus() != http.StatusNotFound {
t.Fatalf("HTTPStatus = %d, want 404", e.HTTPStatus())
}
}
func TestWithCauseImmutable(t *testing.T) {
t.Parallel()
base := app.DBError("db fail")
wrapped := base.WithCause(errors.New("pq: connection refused"))
if base.Unwrap() != nil {
t.Fatal("WithCause must not mutate original error")
}
if wrapped.Unwrap() == nil {
t.Fatal("wrapped error should have cause")
}
}
func TestWithScope(t *testing.T) {
t.Parallel()
e := app.ResNotFound("x")
moved, err := e.WithScope(code.LocalAPI)
if err != nil {
t.Fatal(err)
}
if moved.DisplayCode() != "11301000" {
t.Fatalf("DisplayCode = %s, want 11301000", moved.DisplayCode())
}
if e.DisplayCode() != "10301000" {
t.Fatal("WithScope must not mutate original error")
}
}
func TestIsUsesFullCode(t *testing.T) {
t.Parallel()
a := app.ResNotFound("a")
b := errs.For(code.LocalAPI).ResNotFound("b")
if errors.Is(a, b) {
t.Fatal("same category/detail but different scope must not match")
}
if !errors.Is(a, app.ResNotFound("c")) {
t.Fatal("same full code should match via errors.Is")
}
}
func TestFromCodeRoundTrip(t *testing.T) {
t.Parallel()
raw := uint32(10301123)
e, err := errs.FromCode(raw)
if err != nil {
t.Fatal(err)
}
if e.Code() != raw {
t.Fatalf("Code = %d, want %d", e.Code(), raw)
}
}
func TestGRPCStatusBuiltin(t *testing.T) {
t.Parallel()
st := status.New(codes.NotFound, "missing")
e, err := errs.FromGRPCError(st.Err(), code.Facade)
if err != nil {
t.Fatal(err)
}
if e.Category() != code.CatGRPC {
t.Fatalf("Category = %d, want %d", e.Category(), code.CatGRPC)
}
if e.Detail() != code.Detail(codes.NotFound) {
t.Fatalf("Detail = %d, want %d", e.Detail(), codes.NotFound)
}
if e.Scope() != code.Facade {
t.Fatalf("Scope = %d, want %d", e.Scope(), code.Facade)
}
}
func TestGRPCRoundTrip(t *testing.T) {
t.Parallel()
orig, err := app.InputInvalidFormat("email invalid").WithDetail(7)
if err != nil {
t.Fatal(err)
}
st := orig.GRPCStatus()
back, err := errs.FromGRPCError(st.Err())
if err != nil {
t.Fatal(err)
}
if back.Code() != orig.Code() {
t.Fatalf("Code = %d, want %d", back.Code(), orig.Code())
}
if back.Error() != orig.Error() {
t.Fatalf("msg = %q, want %q", back.Error(), orig.Error())
}
if back.GRPCCode() != codes.InvalidArgument {
t.Fatalf("GRPCCode = %s, want InvalidArgument", back.GRPCCode())
}
}
func TestCatGRPCHTTPStatus(t *testing.T) {
t.Parallel()
e := errs.MustNew(code.Facade, code.CatGRPC, code.Detail(codes.NotFound), "missing")
if e.HTTPStatus() != http.StatusNotFound {
t.Fatalf("HTTPStatus = %d, want 404", e.HTTPStatus())
}
}
func TestExtendedCategoriesHTTPStatus(t *testing.T) {
t.Parallel()
cases := []struct {
name string
got *errs.Error
status int
}{
{"InputMissingRequired", app.InputMissingRequired("name"), http.StatusBadRequest},
{"InputUnsupportedMedia", app.InputUnsupportedMedia("application/xml"), http.StatusUnsupportedMediaType},
{"InputPayloadTooLarge", app.InputPayloadTooLarge(), http.StatusRequestEntityTooLarge},
{"DBUnavailable", app.DBUnavailable(), http.StatusServiceUnavailable},
{"ResPreconditionFailed", app.ResPreconditionFailed("etag mismatch"), http.StatusPreconditionFailed},
{"ResLocked", app.ResLocked(), http.StatusLocked},
{"AuthMethodNotAllowed", app.AuthMethodNotAllowed("POST"), http.StatusMethodNotAllowed},
{"SysNotImplemented", app.SysNotImplemented(), http.StatusNotImplemented},
{"SysClientTimeout", app.SysClientTimeout(), http.StatusRequestTimeout},
{"SvcRateLimited", app.SvcRateLimited("payment provider"), http.StatusTooManyRequests},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if tc.got.HTTPStatus() != tc.status {
t.Fatalf("HTTPStatus() = %d, want %d (code %s)", tc.got.HTTPStatus(), tc.status, tc.got.DisplayCode())
}
})
}
}
func TestBuilderCustomDetail(t *testing.T) {
t.Parallel()
e := app.Code(code.SysTimeout, 42, "downstream timeout")
if e.Detail() != 42 {
t.Fatalf("Detail = %d, want 42", e.Detail())
}
}

View File

@ -0,0 +1,91 @@
package errs
import (
"fmt"
"gateway/internal/library/errors/code"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// GRPCCode maps the business category to a standard gRPC status code.
func (e *Error) GRPCCode() codes.Code {
if e == nil || e.SubCode() == code.OK {
return codes.OK
}
return grpcCodeForCategory(e.Category())
}
// GRPCStatus converts the error to a gRPC status.
// The business code is embedded in the message as [SSCCCDDD].
func (e *Error) GRPCStatus() *status.Status {
if e == nil {
return status.New(codes.OK, "")
}
msg := fmt.Sprintf("[%s] %s", e.DisplayCode(), e.Error())
if cause := e.Unwrap(); cause != nil {
msg = fmt.Sprintf("%s: %v", msg, cause)
}
return status.New(e.GRPCCode(), msg)
}
func grpcCodeForCategory(cat code.Category) codes.Code {
switch cat {
case code.InputInvalidFormat, code.InputNotValidImplementation, code.InputInvalidRange,
code.InputMissingRequired, code.InputUnsupportedMedia,
code.ResInsufficient, code.ResInvalidMeasureID, code.ResInvalidFormat,
code.DBDataConvert, code.SvcHTTP400:
return codes.InvalidArgument
case code.InputPayloadTooLarge, code.PSuTooLarge:
return codes.InvalidArgument
case code.ResNotFound:
return codes.NotFound
case code.DBDuplicate, code.ResAlreadyExist, code.ResInvalidState, code.ResMultiOwner:
return codes.AlreadyExists
case code.ResPreconditionFailed, code.ResLocked:
return codes.FailedPrecondition
case code.AuthUnauthorized, code.AuthExpired, code.AuthInvalidPosixTime, code.AuthSigPayloadMismatch:
return codes.Unauthenticated
case code.AuthForbidden, code.ResInsufficientPerm:
return codes.PermissionDenied
case code.ResExpired, code.ResMigrated:
return codes.FailedPrecondition
case code.SysTooManyRequest, code.ResInsufficientQuota, code.SvcRateLimited:
return codes.ResourceExhausted
case code.SysTimeout:
return codes.DeadlineExceeded
case code.SysClientTimeout:
return codes.Canceled
case code.SysMaintain, code.SvcMaintenance, code.DBUnavailable:
return codes.Unavailable
case code.SvcThirdParty, code.PSuPublish, code.PSuConsume:
return codes.Unavailable
case code.SysNotImplemented, code.AuthMethodNotAllowed:
return codes.Unimplemented
case code.DBError, code.SysInternal, code.SvcInternal:
return codes.Internal
case code.CatGRPC:
return codes.Unknown
default:
return codes.Unknown
}
}

View File

@ -0,0 +1,41 @@
package errs
import (
"net/http"
"gateway/internal/library/errors/code"
"google.golang.org/grpc/codes"
)
// httpStatusFromGRPCDetail maps a stored gRPC codes.Code (in Detail) to HTTP.
// Used for CatGRPC errors produced by FromGRPCError.
func httpStatusFromGRPCDetail(d code.Detail) int {
switch codes.Code(d) {
case codes.OK:
return http.StatusOK
case codes.InvalidArgument, codes.OutOfRange, codes.FailedPrecondition:
return http.StatusBadRequest
case codes.NotFound:
return http.StatusNotFound
case codes.AlreadyExists, codes.Aborted:
return http.StatusConflict
case codes.PermissionDenied:
return http.StatusForbidden
case codes.Unauthenticated:
return http.StatusUnauthorized
case codes.ResourceExhausted:
return http.StatusTooManyRequests
case codes.DeadlineExceeded:
return http.StatusGatewayTimeout
case codes.Unavailable:
return http.StatusServiceUnavailable
case codes.Unimplemented:
return http.StatusNotImplemented
case codes.Canceled:
return http.StatusRequestTimeout
case codes.Unknown, codes.Internal, codes.DataLoss:
return http.StatusInternalServerError
default:
return http.StatusInternalServerError
}
}

View File

@ -0,0 +1,24 @@
package errs
import (
"fmt"
"gateway/internal/library/errors/code"
)
// ErrInvalidCode is returned when scope, category, or detail is out of range.
var ErrInvalidCode = fmt.Errorf("invalid error code components")
func validateComponents(scope code.Scope, category code.Category, detail code.Detail) error {
if !scope.Valid() {
return fmt.Errorf("%w: scope %d exceeds max %d", ErrInvalidCode, scope, code.MaxScope)
}
if !category.Valid() {
return fmt.Errorf("%w: category %d exceeds max %d", ErrInvalidCode, category, code.MaxCategory)
}
if !detail.Valid() {
return fmt.Errorf("%w: detail %d exceeds max %d", ErrInvalidCode, detail, code.MaxDetail)
}
return nil
}

View File

@ -0,0 +1,33 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package normal
import (
"context"
"gateway/internal/svc"
"gateway/internal/types"
"github.com/zeromicro/go-zero/core/logx"
)
type PingLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// Ping
func NewPingLogic(ctx context.Context, svcCtx *svc.ServiceContext) *PingLogic {
return &PingLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *PingLogic) Ping() (*types.PingData, error) {
resp := &types.PingData{Pong: "ok"}
return resp, nil
}

View File

@ -0,0 +1,73 @@
# HTTP 統一回應
## 分工
| 層 | 職責 |
|----|------|
| **Logic** | `return data, err``err` 為 `*errs.Error` 或標準 `error` |
| **Handler** | `response.Write(ctx, w, data, err)` |
| **`.api`** | 只描述 `data` 的型別(如 `PingData`),不描述外層 `Status` |
## Handler 範例
有 request 的 API模板會自動生成 **參數綁定 + 驗證**`httpx.Parse`
```go
var req types.GetUserReq
if err := httpx.Parse(r, &req); err != nil {
response.Write(r.Context(), w, nil, response.WrapRequestError(err)) // → 400 InputInvalidFormat
return
}
data, err := l.GetUser(&req)
response.Write(r.Context(), w, data, err)
```
無 request 的 API`GET /health`)不會生成 Parse 區塊,屬正常行為。
`main` 可設定驗證錯誤使用的 scope
```go
response.RequestErrScope = code.Facade
```
`.api` 的 request struct 可加 `validate:` tag 或實作 `Validate() error`,由 go-zero `httpx.Parse` 觸發。
## Logic 範例
```go
var errb = errs.For(code.Facade)
func (l *XLogic) GetUser(req *types.GetUserReq) (*types.UserVO, error) {
if req.Id == "" {
return nil, errb.InputMissingRequired("id")
}
// ...
return vo, nil
}
```
## 回應 JSON
成功HTTP 200
```json
{ "code": 0, "message": "SUCCESS", "data": { ... } }
```
失敗HTTP 依 category如 404
```json
{
"code": 10301000,
"message": "user not found",
"error": { "biz_code": "10301000", "scope": 10, "category": 301, "detail": 0 }
}
```
## goctl 模板(可選)
專案模板路徑:`generate/goctl/api/handler.tpl`**不是** `api/gogen/`)。
`make gen-api` 已帶 `-home generate/goctl`**新建的 handler** 會自動使用 `response.Write`
注意:已存在的 handler 檔案 goctl **不會覆寫**(會顯示 `exists, ignored`)。新增 API 沒問題;若要重生成舊 handler需先刪除該檔再 `make gen-api`

View File

@ -0,0 +1,23 @@
package response
import (
errs "gateway/internal/library/errors"
"gateway/internal/library/errors/code"
)
// RequestErrScope is used when mapping httpx.Parse / validation errors to business codes.
// Set once in main (e.g. response.RequestErrScope = code.Facade).
var RequestErrScope = code.Facade
// WrapRequestError maps binding / validation failures to InputInvalidFormat (HTTP 400).
// Business *errs.Error values are returned unchanged.
func WrapRequestError(err error) error {
if err == nil {
return nil
}
if errs.FromError(err) != nil {
return err
}
return errs.For(RequestErrScope).InputInvalidFormat(err.Error())
}

View File

@ -0,0 +1,38 @@
package response_test
import (
"errors"
"net/http"
"testing"
errs "gateway/internal/library/errors"
"gateway/internal/library/errors/code"
"gateway/internal/response"
)
func TestWrapRequestError(t *testing.T) {
t.Parallel()
response.RequestErrScope = code.Facade
e := response.WrapRequestError(errors.New("field id is required"))
be := errs.FromError(e)
if be == nil {
t.Fatal("expected business error")
}
if be.Category() != code.InputInvalidFormat {
t.Fatalf("category = %d, want %d", be.Category(), code.InputInvalidFormat)
}
if be.HTTPStatus() != http.StatusBadRequest {
t.Fatalf("http = %d, want 400", be.HTTPStatus())
}
}
func TestWrapRequestErrorPreservesBusinessError(t *testing.T) {
t.Parallel()
orig := errs.For(code.Facade).ResNotFound("x")
if response.WrapRequestError(orig) != orig {
t.Fatal("business error should not be wrapped")
}
}

View File

@ -0,0 +1,66 @@
// Package response wraps handler output into the unified types.Status envelope.
// Logic layers should return (data, error) only; handlers call Write.
package response
import (
"context"
"net/http"
errs "gateway/internal/library/errors"
"gateway/internal/library/errors/code"
"gateway/internal/types"
"github.com/zeromicro/go-zero/rest/httpx"
)
// Write serializes data or err into types.Status and writes the HTTP response.
// - Success: HTTP 200, code=0, message=SUCCESS, data=<payload>
// - Failure: HTTP from errs.Error.HTTPStatus(), code/message/error from business error
func Write(ctx context.Context, w http.ResponseWriter, data any, err error) {
if err != nil {
status, body := buildError(err)
httpx.WriteJsonCtx(ctx, w, status, body)
return
}
httpx.WriteJsonCtx(ctx, w, http.StatusOK, buildOK(data))
}
func buildOK(data any) types.Status {
st := types.Status{
Code: code.DefaultSuccessFullCodeInt,
Message: code.DefaultSuccessMessage,
}
if data != nil {
st.Data = data
}
return st
}
func buildError(err error) (httpStatus int, body types.Status) {
if e := errs.FromError(err); e != nil {
return e.HTTPStatus(), types.Status{
Code: int64(e.Code()),
Message: e.Error(),
Error: types.ErrorDetail{
BizCode: e.DisplayCode(),
Scope: uint32(e.Scope()),
Category: uint32(e.Category()),
Detail: uint32(e.Detail()),
},
}
}
// Non-business errors: generic 500, do not leak internal error text to clients.
fallback := errs.MustNew(code.Unset, code.SysInternal, 0, "internal server error")
return fallback.HTTPStatus(), types.Status{
Code: int64(fallback.Code()),
Message: fallback.Error(),
Error: types.ErrorDetail{
BizCode: fallback.DisplayCode(),
},
}
}

View File

@ -0,0 +1,61 @@
package response_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
errs "gateway/internal/library/errors"
"gateway/internal/library/errors/code"
"gateway/internal/response"
"gateway/internal/types"
)
var errb = errs.For(code.Facade)
func TestWriteSuccess(t *testing.T) {
t.Parallel()
w := httptest.NewRecorder()
response.Write(context.Background(), w, map[string]string{"pong": "ok"}, nil)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var body types.Status
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Code != 0 || body.Message != code.DefaultSuccessMessage {
t.Fatalf("envelope = %+v", body)
}
if body.Error != nil {
t.Fatal("success response must not include error")
}
}
func TestWriteBusinessError(t *testing.T) {
t.Parallel()
w := httptest.NewRecorder()
e := errb.ResNotFound("user", "1")
response.Write(context.Background(), w, nil, e)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
var body types.Status
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Code != int64(e.Code()) {
t.Fatalf("code = %d, want %d", body.Code, e.Code())
}
if body.Data != nil {
t.Fatal("error response should not include data")
}
}

View File

@ -0,0 +1,18 @@
// Code scaffolded by goctl. Safe to edit.
// goctl 1.10.1
package svc
import (
"gateway/internal/config"
)
type ServiceContext struct {
Config config.Config
}
func NewServiceContext(c config.Config) *ServiceContext {
return &ServiceContext{
Config: c,
}
}

View File

@ -0,0 +1,18 @@
package types
// Status is the unified HTTP JSON envelope for every API (success and failure).
// Hand-maintained — do not generate with goctl into this file.
type Status struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
Error any `json:"error,omitempty"`
}
// ErrorDetail is the structured payload for Status.Error on failure.
type ErrorDetail struct {
BizCode string `json:"biz_code"`
Scope uint32 `json:"scope,omitempty"`
Category uint32 `json:"category,omitempty"`
Detail uint32 `json:"detail,omitempty"`
}

27
internal/types/types.go Normal file
View File

@ -0,0 +1,27 @@
// Code generated by goctl. DO NOT EDIT.
// goctl 1.10.1
package types
type APIErrorStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Error ErrorDetail `json:"error"`
}
type ErrorDetail struct {
BizCode string `json:"biz_code"`
Scope uint32 `json:"scope,omitempty"`
Category uint32 `json:"category,omitempty"`
Detail uint32 `json:"detail,omitempty"`
}
type PingData struct {
Pong string `json:"pong"`
}
type PingOKStatus struct {
Code int64 `json:"code"`
Message string `json:"message"`
Data PingData `json:"data"`
}