From ea4f45f94927fc121c00c1635211b71bcb83ae45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E6=80=A7=E9=A9=8A?= Date: Tue, 19 May 2026 19:00:28 +0800 Subject: [PATCH] init project --- .gitignore | 72 ++ Makefile | 27 + docs/openapi/.gitkeep | 0 etc/gateway.yaml | 3 + gateway.go | 34 + generate/api/README.md | 33 + generate/api/common.api | 19 + generate/api/gateway.api | 20 + generate/api/normal.api | 33 + generate/doc-generate/.gitignore | 30 + generate/doc-generate/Makefile | 114 +++ generate/doc-generate/README.md | 863 ++++++++++++++++++ generate/doc-generate/cmd/go-doc/main.go | 45 + generate/doc-generate/example/.gitignore | 4 + generate/doc-generate/example/api/common.api | 33 + generate/doc-generate/example/api/gateway.api | 20 + generate/doc-generate/example/api/member.api | 359 ++++++++ generate/doc-generate/example/api/ping.api | 21 + generate/doc-generate/example/example.api | 241 +++++ generate/doc-generate/example/example_cn.api | 247 +++++ .../doc-generate/example/example_respdoc.api | 95 ++ generate/doc-generate/example/multiple.api | 3 + .../doc-generate/example/test_all_params.api | 51 ++ .../doc-generate/example/test_header_body.api | 41 + .../doc-generate/example/test_header_path.api | 59 ++ generate/doc-generate/go.mod | 34 + generate/doc-generate/go.sum | 70 ++ .../internal/swagger/annotation.go | 79 ++ .../internal/swagger/annotation_test.go | 53 ++ generate/doc-generate/internal/swagger/api.go | 138 +++ .../doc-generate/internal/swagger/command.go | 112 +++ .../doc-generate/internal/swagger/const.go | 65 ++ .../internal/swagger/contenttype.go | 25 + .../internal/swagger/contenttype_test.go | 68 ++ .../doc-generate/internal/swagger/context.go | 32 + .../internal/swagger/definition.go | 32 + .../doc-generate/internal/swagger/openapi3.go | 506 ++++++++++ .../doc-generate/internal/swagger/options.go | 125 +++ .../internal/swagger/options_test.go | 258 ++++++ .../internal/swagger/parameter.go | 220 +++++ .../internal/swagger/parameter_test.go | 91 ++ .../doc-generate/internal/swagger/path.go | 123 +++ .../internal/swagger/properties.go | 109 +++ .../doc-generate/internal/swagger/respdoc.go | 249 +++++ .../doc-generate/internal/swagger/response.go | 201 ++++ .../doc-generate/internal/swagger/swagger.go | 325 +++++++ .../internal/swagger/swagger_test.go | 25 + .../doc-generate/internal/swagger/vars.go | 27 + generate/doc-generate/internal/util/pathx.go | 13 + .../doc-generate/internal/util/stringx.go | 89 ++ generate/doc-generate/internal/util/util.go | 29 + generate/doc-generate/test_all_formats.sh | 103 +++ generate/doc-generate/test_respdoc.sh | 50 + generate/goctl/api/handler.tpl | 27 + go.mod | 52 ++ go.sum | 137 +++ internal/config/config.go | 10 + internal/handler/normal/ping_handler.go | 20 + internal/handler/routes.go | 29 + internal/library/errlog/log.go | 51 ++ internal/library/errors/README.md | 303 ++++++ internal/library/errors/builder.go | 220 +++++ internal/library/errors/code/types.go | 138 +++ internal/library/errors/convert.go | 104 +++ internal/library/errors/errors.go | 293 ++++++ internal/library/errors/errors_test.go | 188 ++++ internal/library/errors/grpc.go | 91 ++ internal/library/errors/http_grpc.go | 41 + internal/library/errors/validate.go | 24 + internal/logic/normal/ping_logic.go | 33 + internal/response/README.md | 73 ++ internal/response/request.go | 23 + internal/response/request_test.go | 38 + internal/response/response.go | 66 ++ internal/response/response_test.go | 61 ++ internal/svc/service_context.go | 18 + internal/types/response.go | 18 + internal/types/types.go | 27 + 78 files changed, 7803 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 docs/openapi/.gitkeep create mode 100644 etc/gateway.yaml create mode 100644 gateway.go create mode 100644 generate/api/README.md create mode 100644 generate/api/common.api create mode 100644 generate/api/gateway.api create mode 100644 generate/api/normal.api create mode 100644 generate/doc-generate/.gitignore create mode 100644 generate/doc-generate/Makefile create mode 100644 generate/doc-generate/README.md create mode 100644 generate/doc-generate/cmd/go-doc/main.go create mode 100644 generate/doc-generate/example/.gitignore create mode 100644 generate/doc-generate/example/api/common.api create mode 100644 generate/doc-generate/example/api/gateway.api create mode 100644 generate/doc-generate/example/api/member.api create mode 100644 generate/doc-generate/example/api/ping.api create mode 100644 generate/doc-generate/example/example.api create mode 100644 generate/doc-generate/example/example_cn.api create mode 100644 generate/doc-generate/example/example_respdoc.api create mode 100644 generate/doc-generate/example/multiple.api create mode 100644 generate/doc-generate/example/test_all_params.api create mode 100644 generate/doc-generate/example/test_header_body.api create mode 100644 generate/doc-generate/example/test_header_path.api create mode 100644 generate/doc-generate/go.mod create mode 100644 generate/doc-generate/go.sum create mode 100644 generate/doc-generate/internal/swagger/annotation.go create mode 100644 generate/doc-generate/internal/swagger/annotation_test.go create mode 100644 generate/doc-generate/internal/swagger/api.go create mode 100644 generate/doc-generate/internal/swagger/command.go create mode 100644 generate/doc-generate/internal/swagger/const.go create mode 100644 generate/doc-generate/internal/swagger/contenttype.go create mode 100644 generate/doc-generate/internal/swagger/contenttype_test.go create mode 100644 generate/doc-generate/internal/swagger/context.go create mode 100644 generate/doc-generate/internal/swagger/definition.go create mode 100644 generate/doc-generate/internal/swagger/openapi3.go create mode 100644 generate/doc-generate/internal/swagger/options.go create mode 100644 generate/doc-generate/internal/swagger/options_test.go create mode 100644 generate/doc-generate/internal/swagger/parameter.go create mode 100644 generate/doc-generate/internal/swagger/parameter_test.go create mode 100644 generate/doc-generate/internal/swagger/path.go create mode 100644 generate/doc-generate/internal/swagger/properties.go create mode 100644 generate/doc-generate/internal/swagger/respdoc.go create mode 100644 generate/doc-generate/internal/swagger/response.go create mode 100644 generate/doc-generate/internal/swagger/swagger.go create mode 100644 generate/doc-generate/internal/swagger/swagger_test.go create mode 100644 generate/doc-generate/internal/swagger/vars.go create mode 100644 generate/doc-generate/internal/util/pathx.go create mode 100644 generate/doc-generate/internal/util/stringx.go create mode 100644 generate/doc-generate/internal/util/util.go create mode 100755 generate/doc-generate/test_all_formats.sh create mode 100755 generate/doc-generate/test_respdoc.sh create mode 100644 generate/goctl/api/handler.tpl create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/handler/normal/ping_handler.go create mode 100644 internal/handler/routes.go create mode 100644 internal/library/errlog/log.go create mode 100644 internal/library/errors/README.md create mode 100644 internal/library/errors/builder.go create mode 100644 internal/library/errors/code/types.go create mode 100644 internal/library/errors/convert.go create mode 100644 internal/library/errors/errors.go create mode 100644 internal/library/errors/errors_test.go create mode 100644 internal/library/errors/grpc.go create mode 100644 internal/library/errors/http_grpc.go create mode 100644 internal/library/errors/validate.go create mode 100644 internal/logic/normal/ping_logic.go create mode 100644 internal/response/README.md create mode 100644 internal/response/request.go create mode 100644 internal/response/request_test.go create mode 100644 internal/response/response.go create mode 100644 internal/response/response_test.go create mode 100644 internal/svc/service_context.go create mode 100644 internal/types/response.go create mode 100644 internal/types/types.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9476944 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7667bad --- /dev/null +++ b/Makefile @@ -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-doc(OpenAPI 文件生成器) + 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" \ No newline at end of file diff --git a/docs/openapi/.gitkeep b/docs/openapi/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/etc/gateway.yaml b/etc/gateway.yaml new file mode 100644 index 0000000..70d230c --- /dev/null +++ b/etc/gateway.yaml @@ -0,0 +1,3 @@ +Name: gateway +Host: 0.0.0.0 +Port: 8888 diff --git a/gateway.go b/gateway.go new file mode 100644 index 0000000..54a375c --- /dev/null +++ b/gateway.go @@ -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() +} diff --git a/generate/api/README.md b/generate/api/README.md new file mode 100644 index 0000000..c090432 --- /dev/null +++ b/generate/api/README.md @@ -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.yaml(OpenAPI 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` 定義一致。 diff --git a/generate/api/common.api b/generate/api/common.api new file mode 100644 index 0000000..f4881cf --- /dev/null +++ b/generate/api/common.api @@ -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 失敗回應 envelope(HTTP 4xx/5xx) + APIErrorStatus { + Code int64 `json:"code"` + Message string `json:"message"` + Error ErrorDetail `json:"error"` + } +) diff --git a/generate/api/gateway.api b/generate/api/gateway.api new file mode 100644 index 0000000..3a1d1d1 --- /dev/null +++ b/generate/api/gateway.api @@ -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" +) + diff --git a/generate/api/normal.api b/generate/api/normal.api new file mode 100644 index 0000000..076ea11 --- /dev/null +++ b/generate/api/normal.api @@ -0,0 +1,33 @@ +syntax = "v1" + +// 業務 data(Logic returns 型別;HTTP 外層 envelope 見 @respdoc) +type PingData { + Pong string `json:"pong"` +} + +// 文件用:成功回應 envelope(code=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) +} diff --git a/generate/doc-generate/.gitignore b/generate/doc-generate/.gitignore new file mode 100644 index 0000000..9815fb4 --- /dev/null +++ b/generate/doc-generate/.gitignore @@ -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/ \ No newline at end of file diff --git a/generate/doc-generate/Makefile b/generate/doc-generate/Makefile new file mode 100644 index 0000000..092e58a --- /dev/null +++ b/generate/doc-generate/Makefile @@ -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" + diff --git a/generate/doc-generate/README.md b/generate/doc-generate/README.md new file mode 100644 index 0000000..c39e1db --- /dev/null +++ b/generate/doc-generate/README.md @@ -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 +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 definitions(true/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
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": {...} + } + } +} +``` + +--- + +## 使用範例 + +### 場景 1:RESTful 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 +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` \ No newline at end of file diff --git a/generate/doc-generate/cmd/go-doc/main.go b/generate/doc-generate/cmd/go-doc/main.go new file mode 100644 index 0000000..a858038 --- /dev/null +++ b/generate/doc-generate/cmd/go-doc/main.go @@ -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) + } +} diff --git a/generate/doc-generate/example/.gitignore b/generate/doc-generate/example/.gitignore new file mode 100644 index 0000000..4997e4e --- /dev/null +++ b/generate/doc-generate/example/.gitignore @@ -0,0 +1,4 @@ +*.json +*.yaml +bin +output \ No newline at end of file diff --git a/generate/doc-generate/example/api/common.api b/generate/doc-generate/example/api/common.api new file mode 100644 index 0000000..1f45930 --- /dev/null +++ b/generate/doc-generate/example/api/common.api @@ -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"` + } +) + diff --git a/generate/doc-generate/example/api/gateway.api b/generate/doc-generate/example/api/gateway.api new file mode 100644 index 0000000..051d28d --- /dev/null +++ b/generate/doc-generate/example/api/gateway.api @@ -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" +) + diff --git a/generate/doc-generate/example/api/member.api b/generate/doc-generate/example/api/member.api new file mode 100644 index 0000000..5ecada2 --- /dev/null +++ b/generate/doc-generate/example/api/member.api @@ -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) +} \ No newline at end of file diff --git a/generate/doc-generate/example/api/ping.api b/generate/doc-generate/example/api/ping.api new file mode 100644 index 0000000..528f85a --- /dev/null +++ b/generate/doc-generate/example/api/ping.api @@ -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 () +} \ No newline at end of file diff --git a/generate/doc-generate/example/example.api b/generate/doc-generate/example/example.api new file mode 100644 index 0000000..6ac4534 --- /dev/null +++ b/generate/doc-generate/example/example.api @@ -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
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) +} + diff --git a/generate/doc-generate/example/example_cn.api b/generate/doc-generate/example/example_cn.api new file mode 100644 index 0000000..ad9c338 --- /dev/null +++ b/generate/doc-generate/example/example_cn.api @@ -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-未登入
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-用不存在
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) +} + diff --git a/generate/doc-generate/example/example_respdoc.api b/generate/doc-generate/example/example_respdoc.api new file mode 100644 index 0000000..4aac029 --- /dev/null +++ b/generate/doc-generate/example/example_respdoc.api @@ -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-未登入
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) +} diff --git a/generate/doc-generate/example/multiple.api b/generate/doc-generate/example/multiple.api new file mode 100644 index 0000000..8460436 --- /dev/null +++ b/generate/doc-generate/example/multiple.api @@ -0,0 +1,3 @@ +syntax = "v1" + +import "example_respdoc.api" \ No newline at end of file diff --git a/generate/doc-generate/example/test_all_params.api b/generate/doc-generate/example/test_all_params.api new file mode 100644 index 0000000..0ba0379 --- /dev/null +++ b/generate/doc-generate/example/test_all_params.api @@ -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) +} diff --git a/generate/doc-generate/example/test_header_body.api b/generate/doc-generate/example/test_header_body.api new file mode 100644 index 0000000..fe7336c --- /dev/null +++ b/generate/doc-generate/example/test_header_body.api @@ -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) +} diff --git a/generate/doc-generate/example/test_header_path.api b/generate/doc-generate/example/test_header_path.api new file mode 100644 index 0000000..12f42dc --- /dev/null +++ b/generate/doc-generate/example/test_header_path.api @@ -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) +} diff --git a/generate/doc-generate/go.mod b/generate/doc-generate/go.mod new file mode 100644 index 0000000..0c92bef --- /dev/null +++ b/generate/doc-generate/go.mod @@ -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 +) diff --git a/generate/doc-generate/go.sum b/generate/doc-generate/go.sum new file mode 100644 index 0000000..6d19005 --- /dev/null +++ b/generate/doc-generate/go.sum @@ -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= diff --git a/generate/doc-generate/internal/swagger/annotation.go b/generate/doc-generate/internal/swagger/annotation.go new file mode 100644 index 0000000..26718b3 --- /dev/null +++ b/generate/doc-generate/internal/swagger/annotation.go @@ -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 "" +} diff --git a/generate/doc-generate/internal/swagger/annotation_test.go b/generate/doc-generate/internal/swagger/annotation_test.go new file mode 100644 index 0000000..872d656 --- /dev/null +++ b/generate/doc-generate/internal/swagger/annotation_test.go @@ -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"})) +} diff --git a/generate/doc-generate/internal/swagger/api.go b/generate/doc-generate/internal/swagger/api.go new file mode 100644 index 0000000..ed32c45 --- /dev/null +++ b/generate/doc-generate/internal/swagger/api.go @@ -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 + } +} diff --git a/generate/doc-generate/internal/swagger/command.go b/generate/doc-generate/internal/swagger/command.go new file mode 100644 index 0000000..170453a --- /dev/null +++ b/generate/doc-generate/internal/swagger/command.go @@ -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) +} diff --git a/generate/doc-generate/internal/swagger/const.go b/generate/doc-generate/internal/swagger/const.go new file mode 100644 index 0000000..f2e81a6 --- /dev/null +++ b/generate/doc-generate/internal/swagger/const.go @@ -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 +) diff --git a/generate/doc-generate/internal/swagger/contenttype.go b/generate/doc-generate/internal/swagger/contenttype.go new file mode 100644 index 0000000..f711a1a --- /dev/null +++ b/generate/doc-generate/internal/swagger/contenttype.go @@ -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} +} diff --git a/generate/doc-generate/internal/swagger/contenttype_test.go b/generate/doc-generate/internal/swagger/contenttype_test.go new file mode 100644 index 0000000..7d9586f --- /dev/null +++ b/generate/doc-generate/internal/swagger/contenttype_test.go @@ -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) + }) + } +} diff --git a/generate/doc-generate/internal/swagger/context.go b/generate/doc-generate/internal/swagger/context.go new file mode 100644 index 0000000..92c2b12 --- /dev/null +++ b/generate/doc-generate/internal/swagger/context.go @@ -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"), + } +} diff --git a/generate/doc-generate/internal/swagger/definition.go b/generate/doc-generate/internal/swagger/definition.go new file mode 100644 index 0000000..c705aba --- /dev/null +++ b/generate/doc-generate/internal/swagger/definition.go @@ -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, + } +} diff --git a/generate/doc-generate/internal/swagger/openapi3.go b/generate/doc-generate/internal/swagger/openapi3.go new file mode 100644 index 0000000..6b863eb --- /dev/null +++ b/generate/doc-generate/internal/swagger/openapi3.go @@ -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 = ¶m + } else { + newOp.Parameters = append(newOp.Parameters, convertParameter(¶m)) + } + } + + // 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)) +} diff --git a/generate/doc-generate/internal/swagger/options.go b/generate/doc-generate/internal/swagger/options.go new file mode 100644 index 0000000..0cb13cc --- /dev/null +++ b/generate/doc-generate/internal/swagger/options.go @@ -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 +} diff --git a/generate/doc-generate/internal/swagger/options_test.go b/generate/doc-generate/internal/swagger/options_test.go new file mode 100644 index 0000000..f915bdd --- /dev/null +++ b/generate/doc-generate/internal/swagger/options_test.go @@ -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 +} diff --git a/generate/doc-generate/internal/swagger/parameter.go b/generate/doc-generate/internal/swagger/parameter.go new file mode 100644 index 0000000..a186545 --- /dev/null +++ b/generate/doc-generate/internal/swagger/parameter.go @@ -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 +} diff --git a/generate/doc-generate/internal/swagger/parameter_test.go b/generate/doc-generate/internal/swagger/parameter_test.go new file mode 100644 index 0000000..6c87855 --- /dev/null +++ b/generate/doc-generate/internal/swagger/parameter_test.go @@ -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, + }, + }, + } +} diff --git a/generate/doc-generate/internal/swagger/path.go b/generate/doc-generate/internal/swagger/path.go new file mode 100644 index 0000000..506e047 --- /dev/null +++ b/generate/doc-generate/internal/swagger/path.go @@ -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 +} diff --git a/generate/doc-generate/internal/swagger/properties.go b/generate/doc-generate/internal/swagger/properties.go new file mode 100644 index 0000000..e5bf6d2 --- /dev/null +++ b/generate/doc-generate/internal/swagger/properties.go @@ -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 +} diff --git a/generate/doc-generate/internal/swagger/respdoc.go b/generate/doc-generate/internal/swagger/respdoc.go new file mode 100644 index 0000000..f48e9cc --- /dev/null +++ b/generate/doc-generate/internal/swagger/respdoc.go @@ -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) + } +} diff --git a/generate/doc-generate/internal/swagger/response.go b/generate/doc-generate/internal/swagger/response.go new file mode 100644 index 0000000..75b0a16 --- /dev/null +++ b/generate/doc-generate/internal/swagger/response.go @@ -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), + }, + }, + } +} diff --git a/generate/doc-generate/internal/swagger/swagger.go b/generate/doc-generate/internal/swagger/swagger.go new file mode 100644 index 0000000..5ba2928 --- /dev/null +++ b/generate/doc-generate/internal/swagger/swagger.go @@ -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 +} diff --git a/generate/doc-generate/internal/swagger/swagger_test.go b/generate/doc-generate/internal/swagger/swagger_test.go new file mode 100644 index 0000000..d9fa47a --- /dev/null +++ b/generate/doc-generate/internal/swagger/swagger_test.go @@ -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) + } +} diff --git a/generate/doc-generate/internal/swagger/vars.go b/generate/doc-generate/internal/swagger/vars.go new file mode 100644 index 0000000..8e7d62a --- /dev/null +++ b/generate/doc-generate/internal/swagger/vars.go @@ -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 == '/' + } +) diff --git a/generate/doc-generate/internal/util/pathx.go b/generate/doc-generate/internal/util/pathx.go new file mode 100644 index 0000000..af44f9f --- /dev/null +++ b/generate/doc-generate/internal/util/pathx.go @@ -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 +} diff --git a/generate/doc-generate/internal/util/stringx.go b/generate/doc-generate/internal/util/stringx.go new file mode 100644 index 0000000..646044e --- /dev/null +++ b/generate/doc-generate/internal/util/stringx.go @@ -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) +} diff --git a/generate/doc-generate/internal/util/util.go b/generate/doc-generate/internal/util/util.go new file mode 100644 index 0000000..cabf87c --- /dev/null +++ b/generate/doc-generate/internal/util/util.go @@ -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 +} diff --git a/generate/doc-generate/test_all_formats.sh b/generate/doc-generate/test_all_formats.sh new file mode 100755 index 0000000..77c7151 --- /dev/null +++ b/generate/doc-generate/test_all_formats.sh @@ -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/" diff --git a/generate/doc-generate/test_respdoc.sh b/generate/doc-generate/test_respdoc.sh new file mode 100755 index 0000000..74c8a06 --- /dev/null +++ b/generate/doc-generate/test_respdoc.sh @@ -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 回應的 oneOf(OpenAPI 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_* diff --git a/generate/goctl/api/handler.tpl b/generate/goctl/api/handler.tpl new file mode 100644 index 0000000..24edaf6 --- /dev/null +++ b/generate/goctl/api/handler.tpl @@ -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}} + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ad30b3b --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7423c18 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..0fb5680 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/handler/normal/ping_handler.go b/internal/handler/normal/ping_handler.go new file mode 100644 index 0000000..4b2ff77 --- /dev/null +++ b/internal/handler/normal/ping_handler.go @@ -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) + } +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go new file mode 100644 index 0000000..85e5cc2 --- /dev/null +++ b/internal/handler/routes.go @@ -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), + ) +} diff --git a/internal/library/errlog/log.go b/internal/library/errlog/log.go new file mode 100644 index 0000000..4920e87 --- /dev/null +++ b/internal/library/errlog/log.go @@ -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...) +} diff --git a/internal/library/errors/README.md b/internal/library/errors/README.md new file mode 100644 index 0000000..d9c5154 --- /dev/null +++ b/internal/library/errors/README.md @@ -0,0 +1,303 @@ +# 結構化錯誤碼 + +套件路徑:`gateway/internal/library/errors`(import 建議別名 `errs`) + +## 錯誤碼格式 + +8 碼 **SSCCCDDD**(十進位,左側補零顯示): + +| 段 | 名稱 | 範圍 | 說明 | +|----|------|------|------| +| SS | Scope | 00–99 | 服務 / 模組(見 `code/types.go` 常數) | +| CCC | Category | 000–999 | 錯誤類別,**決定 HTTP / gRPC 映射** | +| DDD | Detail | 000–999 | 業務細節,不影響 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. Input(1xx) + +| 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. DB(2xx) + +| Category | 常數 | HTTP | 說明 | +|:--------:|------|:----:|------| +| 201 | `DBError` | **500** Internal Server Error | 非預期 DB 故障 | +| 202 | `DBDataConvert` | **422** | 可修正的資料轉換問題 | +| 203 | `DBDuplicate` | **409** Conflict | 唯一鍵 / 重複寫入 | +| 204 | `DBUnavailable` | **503** Service Unavailable | DB 暫時不可用(可重試) | + +### C. Resource(3xx) + +| 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`(0–16),見下方子表 | + +`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. Auth(5xx) + +| 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. System(6xx) + +| 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. PubSub(7xx) + +| Category | 常數 | HTTP | 說明 | +|:--------:|------|:----:|------| +| 701 | `PSuPublish` | **502** Bad Gateway | 發佈到匯流排失敗 | +| 702 | `PSuConsume` | **502** | 消費失敗 | +| 703 | `PSuTooLarge` | **413** Payload Too Large | 訊息過大 | + +### H. Service(8xx) + +| 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**(保守、安全) | + +--- + +### 號段保留建議(擴充用) + +| 號段 | 用途 | +|------|------| +| 104–199 | Input 擴充 | +| 204–299 | DB 擴充 | +| 312–399 | Resource 擴充(400 保留給 `CatGRPC`) | +| 506–599 | Auth 擴充 | +| 605–699 | System 擴充 | +| 704–799 | PubSub 擴充 | +| 805–899 | Service 擴充 | +| 900–999 | 平台 / 保留 | + +--- + +## 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/... +``` diff --git a/internal/library/errors/builder.go b/internal/library/errors/builder.go new file mode 100644 index 0000000..682218b --- /dev/null +++ b/internal/library/errors/builder.go @@ -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)) +} diff --git a/internal/library/errors/code/types.go b/internal/library/errors/code/types.go new file mode 100644 index 0000000..8bf1ea6 --- /dev/null +++ b/internal/library/errors/code/types.go @@ -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 +) diff --git a/internal/library/errors/convert.go b/internal/library/errors/convert.go new file mode 100644 index 0000000..7e9731b --- /dev/null +++ b/internal/library/errors/convert.go @@ -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) +} diff --git a/internal/library/errors/errors.go b/internal/library/errors/errors.go new file mode 100644 index 0000000..86dec03 --- /dev/null +++ b/internal/library/errors/errors.go @@ -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 +} diff --git a/internal/library/errors/errors_test.go b/internal/library/errors/errors_test.go new file mode 100644 index 0000000..d3c4a94 --- /dev/null +++ b/internal/library/errors/errors_test.go @@ -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()) + } +} diff --git a/internal/library/errors/grpc.go b/internal/library/errors/grpc.go new file mode 100644 index 0000000..d142e38 --- /dev/null +++ b/internal/library/errors/grpc.go @@ -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 + } +} diff --git a/internal/library/errors/http_grpc.go b/internal/library/errors/http_grpc.go new file mode 100644 index 0000000..f646259 --- /dev/null +++ b/internal/library/errors/http_grpc.go @@ -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 + } +} diff --git a/internal/library/errors/validate.go b/internal/library/errors/validate.go new file mode 100644 index 0000000..30233c6 --- /dev/null +++ b/internal/library/errors/validate.go @@ -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 +} diff --git a/internal/logic/normal/ping_logic.go b/internal/logic/normal/ping_logic.go new file mode 100644 index 0000000..a0507ed --- /dev/null +++ b/internal/logic/normal/ping_logic.go @@ -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 +} diff --git a/internal/response/README.md b/internal/response/README.md new file mode 100644 index 0000000..eeb4e27 --- /dev/null +++ b/internal/response/README.md @@ -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`。 diff --git a/internal/response/request.go b/internal/response/request.go new file mode 100644 index 0000000..545e43d --- /dev/null +++ b/internal/response/request.go @@ -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()) +} diff --git a/internal/response/request_test.go b/internal/response/request_test.go new file mode 100644 index 0000000..a17bde0 --- /dev/null +++ b/internal/response/request_test.go @@ -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") + } +} diff --git a/internal/response/response.go b/internal/response/response.go new file mode 100644 index 0000000..049c8e5 --- /dev/null +++ b/internal/response/response.go @@ -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= +// - 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(), + }, + } +} diff --git a/internal/response/response_test.go b/internal/response/response_test.go new file mode 100644 index 0000000..2ef9d70 --- /dev/null +++ b/internal/response/response_test.go @@ -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") + } +} diff --git a/internal/svc/service_context.go b/internal/svc/service_context.go new file mode 100644 index 0000000..5aaded5 --- /dev/null +++ b/internal/svc/service_context.go @@ -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, + } +} diff --git a/internal/types/response.go b/internal/types/response.go new file mode 100644 index 0000000..9085d2d --- /dev/null +++ b/internal/types/response.go @@ -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"` +} diff --git a/internal/types/types.go b/internal/types/types.go new file mode 100644 index 0000000..d2a433f --- /dev/null +++ b/internal/types/types.go @@ -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"` +}