feat: add project-owned error catalogs and HTTP 200 failure contracts
This commit is contained in:
@@ -21,6 +21,8 @@ go run ./cmd/go-omni api validate --api examples/contracts/user.api
|
||||
后续在生成项目内执行 `go-omni api generate --api api/user.api --dir .`。
|
||||
项目内可省略 `--api` / `--dir`。`api inspect` 展示路由和鉴权/业务错误,`api export` 向 stdout 导出 OpenAPI,`api generate --dry-run` 预览文件变化。`--check` 只读检查生成产物是否过期。生成器记录所有权,保留 Service / Repository / 配置 / main 的手写内容,遇到生成文件被手动修改则报冲突。新 Service 与中间件占位实现返回 501。支持 `error (...)`、具名 `auth`、路由 `@errors` / `@auth` 和多行 `@doc`;错误目录同步生成 `internal/apierrors` 调用函数。
|
||||
|
||||
`v0.10.0-preview` 新增 `errorCatalog`:集中配置业务码、提示和 HTTP 状态,支持默认 HTTP 200 与单条覆盖,生成函数、路由策略和 OpenAPI 共用一份定义。语法及配置方式见 [统一错误目录](../../errors/README.md)。
|
||||
|
||||
支持范围、参数语义、目录及安全更新策略见 [API 开发指南](../../API.md)。本版借鉴 go-zero 的工作流,不宣称兼容全部 goctl 语法;仍通过 Omni/httpx 保留网络层替换能力。
|
||||
|
||||
## 日志与对象存储按需装配
|
||||
@@ -75,8 +77,8 @@ Handler / Service / Repository / Model 与模块注册入口。模块名为 lowe
|
||||
从远程安装,生成一个带 Handler / Service / Repository / Model 分层的服务骨架:
|
||||
|
||||
```sh
|
||||
GOPRIVATE=git.yuntuo.hk go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.9.1-preview
|
||||
go-omni new --framework-version v0.9.1-preview --module example.com/billing ./billing
|
||||
GOPRIVATE=git.yuntuo.hk go install git.yuntuo.hk/k2safe/go-omni/cmd/go-omni@v0.10.0-preview
|
||||
go-omni new --framework-version v0.10.0-preview --module example.com/billing ./billing
|
||||
cd billing
|
||||
go mod tidy
|
||||
go run ./cmd/server
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/openapi"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
@@ -20,14 +21,18 @@ var credentialName = regexp.MustCompile("^[!#$%&'*+.^_`|~0-9A-Za-z-]+$")
|
||||
func prepareMetadata(original *Spec) (*Spec, error) {
|
||||
spec := *original
|
||||
spec.Routes = append([]Route(nil), original.Routes...)
|
||||
spec.Errors = append([]ErrorDefinition(nil), original.Errors...)
|
||||
if err := validateResponses(&spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
definitions := map[string]ErrorDefinition{}
|
||||
codes, symbols := map[string]bool{}, map[string]bool{}
|
||||
codes, symbols := map[string]bool{}, map[string]bool{"Catalog": true}
|
||||
for _, e := range spec.Errors {
|
||||
if !exportedName.MatchString(e.Name) || e.Status < 400 || e.Status > 599 || !errorCode.MatchString(e.Code) || strings.TrimSpace(e.Message) == "" {
|
||||
return nil, fmt.Errorf("%s: error requires UpperCamelCase name, status 400..599, UPPER_SNAKE_CASE code and a nonempty public message", e.Position)
|
||||
if !exportedName.MatchString(e.Name) || !apierrors.ValidErrorStatus(e.Status) || strings.TrimSpace(e.Message) == "" {
|
||||
return nil, fmt.Errorf("%s: error requires UpperCamelCase name, a final body-bearing status (200..599 except 204/205/304) and a nonempty public message", e.Position)
|
||||
}
|
||||
if e.Catalog == "" && !errorCode.MatchString(e.Code) {
|
||||
return nil, fmt.Errorf("%s: legacy error declaration requires UPPER_SNAKE_CASE code", e.Position)
|
||||
}
|
||||
if codes[e.Code] {
|
||||
return nil, fmt.Errorf("%s: duplicate error code %s", e.Position, e.Code)
|
||||
@@ -41,6 +46,17 @@ func prepareMetadata(original *Spec) (*Spec, error) {
|
||||
codes[e.Code] = true
|
||||
definitions[e.Name] = e
|
||||
}
|
||||
registry, err := apierrors.NewRegistry(errorCatalog(&spec, nil))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalized := registry.Catalog()
|
||||
for i := range spec.Errors {
|
||||
spec.Errors[i].BusinessCode = normalized.Definitions[spec.Errors[i].Code].Code
|
||||
}
|
||||
if err := validateErrorPolicies(&spec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
auth := map[string]AuthDefinition{}
|
||||
for _, a := range spec.Auth {
|
||||
if !exportedName.MatchString(a.Name) || !goName.MatchString(a.Middleware) || strings.EqualFold(a.Middleware, "defaults") || strings.EqualFold(a.Middleware, "registryGen") {
|
||||
@@ -84,7 +100,7 @@ func prepareMetadata(original *Spec) (*Spec, error) {
|
||||
seen := map[string]bool{}
|
||||
for _, name := range r.Errors {
|
||||
if _, ok := definitions[name]; !ok {
|
||||
return nil, fmt.Errorf("%s: unknown error %s; declare it in error (...)", r.Position, name)
|
||||
return nil, fmt.Errorf("%s: unknown error %s; declare it in errorCatalog or error (...)", r.Position, name)
|
||||
}
|
||||
if seen[name] {
|
||||
return nil, fmt.Errorf("%s: duplicate error %s", r.Position, name)
|
||||
@@ -182,11 +198,31 @@ func decorateDocument(doc map[string]any, spec *Spec) {
|
||||
response = genericErrorResponse(http.StatusText(status), cfg)
|
||||
responses[key] = response
|
||||
}
|
||||
media := response["content"].(map[string]any)["application/json"].(map[string]any)
|
||||
content, ok := response["content"].(map[string]any)
|
||||
if !ok {
|
||||
content = map[string]any{}
|
||||
response["content"] = content
|
||||
}
|
||||
media, hasJSON := content["application/json"].(map[string]any)
|
||||
if !hasJSON {
|
||||
media = map[string]any{}
|
||||
content["application/json"] = media
|
||||
}
|
||||
errorMedia := genericErrorResponse("", cfg)["content"].(map[string]any)["application/json"].(map[string]any)
|
||||
if exists && status < 400 && media["schema"] != nil {
|
||||
media["schema"] = map[string]any{"anyOf": []any{media["schema"], errorMedia["schema"]}}
|
||||
} else if media["schema"] == nil {
|
||||
media["schema"] = errorMedia["schema"]
|
||||
}
|
||||
examples := map[string]any{}
|
||||
codes := []any{}
|
||||
seenCodes := map[string]bool{}
|
||||
description := []string{}
|
||||
if exists {
|
||||
if text, ok := response["description"].(string); ok && text != "" {
|
||||
description = append(description, text)
|
||||
}
|
||||
}
|
||||
sort.Slice(errs, func(i, j int) bool { return errs[i].Name < errs[j].Name })
|
||||
for _, e := range errs {
|
||||
value := policy.Value(responseResult(e))
|
||||
@@ -207,7 +243,7 @@ func decorateDocument(doc map[string]any, spec *Spec) {
|
||||
}
|
||||
}
|
||||
func responseResult(e ErrorDefinition) response.Result {
|
||||
return response.Result{Status: e.Status, Code: e.Code, Message: e.Message, RequestID: "exampleRequest"}
|
||||
return response.Result{Status: e.Status, Code: e.Code, Message: e.Message, BusinessCode: e.BusinessCode, Failed: true, RequestID: "exampleRequest"}
|
||||
}
|
||||
func genericErrorResponse(description string, cfg response.Config) map[string]any {
|
||||
if description == "" {
|
||||
|
||||
@@ -9,11 +9,13 @@ import (
|
||||
// ErrorDefinition declares a public, stable error. Generated constructors return
|
||||
// a fresh error each time so WithCause cannot mutate a shared singleton.
|
||||
type ErrorDefinition struct {
|
||||
Name string `json:"name"`
|
||||
Status int `json:"status"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Position string `json:"-"`
|
||||
Name string `json:"name"`
|
||||
BusinessCode any `json:"businessCode,omitempty"`
|
||||
Catalog string `json:"catalog,omitempty"`
|
||||
Status int `json:"status"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Position string `json:"-"`
|
||||
}
|
||||
type AuthDefinition struct {
|
||||
Name, Type, In, KeyName, Middleware, Description, BearerFormat, Position string
|
||||
|
||||
118
cmd/go-omni/apigen/errorCatalog.go
Normal file
118
cmd/go-omni/apigen/errorCatalog.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
// A named catalog groups project definitions under one default HTTP status.
|
||||
// Each JSON object key supplies the generated Go function's name, while key is
|
||||
// the independent logical identifier and code is the public string/integer.
|
||||
func (p *parser) parseErrorCatalog() error {
|
||||
pos := p.next().position
|
||||
name, err := p.ident()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exportedName.MatchString(name) {
|
||||
return p.err("errorCatalog requires an UpperCamelCase name")
|
||||
}
|
||||
for _, prior := range p.spec.ErrorCatalogs {
|
||||
if prior == name {
|
||||
return p.err("duplicate errorCatalog " + name)
|
||||
}
|
||||
}
|
||||
p.spec.ErrorCatalogs = append(p.spec.ErrorCatalogs, name)
|
||||
p.skip()
|
||||
props, err := p.metadata()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defaultStatus := 400
|
||||
if value, ok := props["defaultStatus"]; ok {
|
||||
defaultStatus, err = strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return p.err("defaultStatus must be an integer")
|
||||
}
|
||||
}
|
||||
if !apierrors.ValidErrorStatus(defaultStatus) {
|
||||
return p.err("invalid errorCatalog defaultStatus")
|
||||
}
|
||||
for key := range props {
|
||||
if key != "defaultStatus" && key != "errors" {
|
||||
return p.err("unsupported errorCatalog property " + key)
|
||||
}
|
||||
}
|
||||
type entry struct {
|
||||
Key string `json:"key"`
|
||||
Code any `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
var entries map[string]entry
|
||||
if err := apierrors.DecodeCatalogJSON([]byte(props["errors"]), &entries); err != nil {
|
||||
return fmt.Errorf("%s: catalog %s: %w", pos, name, err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return p.err("errorCatalog requires errors")
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for key := range entries {
|
||||
names = append(names, key)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, fn := range names {
|
||||
e := entries[fn]
|
||||
if e.Code == nil {
|
||||
return p.err("errorCatalog error requires code")
|
||||
}
|
||||
if e.Status == 0 {
|
||||
e.Status = defaultStatus
|
||||
}
|
||||
p.spec.Errors = append(p.spec.Errors, ErrorDefinition{Name: fn, Status: e.Status, Code: e.Key, Message: e.Message, BusinessCode: e.Code, Catalog: name, Position: pos})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func errorCatalog(spec *Spec, names []string) apierrors.Catalog {
|
||||
c := apierrors.Catalog{Definitions: map[string]apierrors.Definition{}}
|
||||
selected := map[string]bool{}
|
||||
for _, n := range names {
|
||||
selected[n] = true
|
||||
}
|
||||
for _, e := range spec.Errors {
|
||||
if names != nil && !selected[e.Name] {
|
||||
continue
|
||||
}
|
||||
c.Definitions[e.Code] = apierrors.Definition{Status: e.Status, Code: e.BusinessCode, Message: e.Message}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Validate the exact catalog/template pair used by each route. Different routes
|
||||
// can use different code types without forcing a global response representation.
|
||||
func validateErrorPolicies(spec *Spec) error {
|
||||
for _, r := range spec.Routes {
|
||||
f, err := response.New(responseConfig(spec, r))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = response.WithErrors(errorCatalog(spec, append([]string{}, r.Errors...)), f); err != nil {
|
||||
return fmt.Errorf("%s: %w", r.Position, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func errorCatalogJSON(spec *Spec, names []string) string {
|
||||
data, err := json.Marshal(errorCatalog(spec, names))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
205
cmd/go-omni/apigen/errorCatalog_test.go
Normal file
205
cmd/go-omni/apigen/errorCatalog_test.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package apigen
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestErrorCatalogDocumentAndValidation(t *testing.T) {
|
||||
path := filepath.Join(frameworkRoot(t), "examples/contracts/errors.api")
|
||||
source := readFile(t, path)
|
||||
spec, err := Parse(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
compiled, err := Compile(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{"/token", "/raw"} {
|
||||
method := "post"
|
||||
if path == "/raw" {
|
||||
method = "get"
|
||||
}
|
||||
op := operation(t, document(t, compiled), path, method)
|
||||
response := op["responses"].(map[string]any)["200"].(map[string]any)
|
||||
media := response["content"].(map[string]any)["application/json"].(map[string]any)
|
||||
alternatives := media["schema"].(map[string]any)["anyOf"].([]any)
|
||||
if len(alternatives) != 2 {
|
||||
t.Fatal("lost success or failure shape")
|
||||
}
|
||||
if path == "/raw" {
|
||||
if alternatives[0].(map[string]any)["properties"].(map[string]any)["token"] == nil {
|
||||
t.Fatal("raw success wrapped")
|
||||
}
|
||||
}
|
||||
examples := media["examples"].(map[string]any)
|
||||
if len(examples) != 2 {
|
||||
t.Fatal(examples)
|
||||
}
|
||||
for _, name := range []string{"UsernameInvalid", "PasswordInvalid"} {
|
||||
value := examples[name].(map[string]any)["value"].(map[string]any)
|
||||
if value["ok"] != false || value["code"] != float64(400002) || value["msg"] == nil {
|
||||
t.Fatal(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, change := range [][2]string{
|
||||
{`defaultStatus: 200`, `defaultStatus: 204`},
|
||||
{`"status": 403`, `"status": 304`},
|
||||
{`"PASSWORD_INVALID"`, `"USERNAME_INVALID"`},
|
||||
{`"code": 400002`, `"code": 2147483648`},
|
||||
{`"code": 400002`, `"code": "400002"`},
|
||||
{`"code": 400002`, `"code": 1.5`},
|
||||
{`"code": 400002`, `"code": null`},
|
||||
{`"code": 400002`, `"code": 400002, "code": 400003`},
|
||||
{`"message": "用户名格式不正确"`, `"message": "用户名格式不正确", "typo": true`},
|
||||
{`@handler token`, `@errors Unknown\n @handler token`},
|
||||
{`successCode: 0`, `successCode: 0\n codes: {"USERNAME_INVALID": 999}`},
|
||||
} {
|
||||
text := strings.Replace(source, change[0], strings.ReplaceAll(change[1], `\n`, "\n"), 1)
|
||||
s, err := parseText(t, text)
|
||||
if err == nil {
|
||||
_, err = Compile(s)
|
||||
}
|
||||
if err == nil {
|
||||
t.Errorf("accepted %s", change[1])
|
||||
}
|
||||
}
|
||||
// The legacy declaration is also allowed to carry a HTTP 200 failure.
|
||||
old := strings.Replace(declaredAPI, "EmailTaken 409", "EmailTaken 200", 1)
|
||||
s, err := parseText(t, old)
|
||||
if err == nil {
|
||||
_, err = Compile(s)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorCatalogConsumer(t *testing.T) {
|
||||
if os.Getenv("GO_OMNI_SCAFFOLD_INTEGRATION") == "" {
|
||||
t.Skip("consumer integration")
|
||||
}
|
||||
root := frameworkRoot(t)
|
||||
dir := filepath.Join(t.TempDir(), "consumer")
|
||||
opts := Options{API: filepath.Join(root, "examples/contracts/errors.api"), Dir: dir, Module: "example.com/errorCatalog", FrameworkDir: root}
|
||||
cli := os.Getenv("GO_OMNI_RELEASE_CLI")
|
||||
generate := func(check bool) {
|
||||
t.Helper()
|
||||
if cli == "" {
|
||||
o := opts
|
||||
o.Check = check
|
||||
if _, err := Generate(o); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
args := []string{"api", "generate", "--api", opts.API, "--dir", dir, "--module", opts.Module}
|
||||
if check {
|
||||
args = append(args, "--check")
|
||||
}
|
||||
cmd := exec.Command(cli, args...)
|
||||
cmd.Dir = filepath.Dir(dir)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("release generate %v\n%s", err, output)
|
||||
}
|
||||
}
|
||||
generate(false)
|
||||
|
||||
fixture := readFile(t, "testdata/errorCatalog/consumer_test.go")
|
||||
writeFile(t, filepath.Join(dir, "consumer_test.go"), fixture)
|
||||
generate(false)
|
||||
generate(true)
|
||||
if readFile(t, filepath.Join(dir, "consumer_test.go")) != fixture {
|
||||
t.Fatal("regeneration overwrote manual code")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "go", "test", "-race", "-mod=mod", "-count=1", "./...")
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("consumer %v\n%s", err, output)
|
||||
}
|
||||
if cli != "" {
|
||||
cmd = exec.CommandContext(ctx, "go", "list", "-m", "-json", frameworkModule)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GOWORK=off")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatal(err, string(output))
|
||||
}
|
||||
var module struct {
|
||||
Version, Sum string
|
||||
Replace any
|
||||
}
|
||||
if err = json.Unmarshal(output, &module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if module.Version != os.Getenv("GO_OMNI_RELEASE_VERSION") || module.Replace != nil || module.Sum == "" {
|
||||
t.Fatal("not a fixed remote consumer")
|
||||
}
|
||||
t.Logf("remote framework %s checksum %s; no local replace", module.Version, module.Sum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorCatalogImportsAndRouteScopes(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeFile(t, filepath.Join(dir, "common.api"), `errorCatalog Numeric (
|
||||
defaultStatus: 200
|
||||
errors: {"NumberInvalid":{"key":"number.invalid","code":12,"message":"number"}}
|
||||
)
|
||||
errorCatalog Text (
|
||||
defaultStatus: 409
|
||||
errors: {"TextInvalid":{"key":"textInvalid","code":"000012","message":"text"}}
|
||||
)
|
||||
response NumericResponse (
|
||||
codeType: int32
|
||||
successCode: 0
|
||||
errorCode: 9999
|
||||
)
|
||||
`)
|
||||
writeFile(t, filepath.Join(dir, "service.api"), `import "common.api"
|
||||
@server (
|
||||
response: NumericResponse
|
||||
errors: NumberInvalid
|
||||
)
|
||||
service scopes-api {
|
||||
@handler numeric
|
||||
get /number
|
||||
}
|
||||
@server (
|
||||
errors: TextInvalid
|
||||
)
|
||||
service scopes-api {
|
||||
@handler text
|
||||
get /text
|
||||
}
|
||||
`)
|
||||
s, err := Parse(filepath.Join(dir, "service.api"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
compiled, err := Compile(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc := document(t, compiled)
|
||||
for _, tc := range []struct {
|
||||
path, status, name string
|
||||
code any
|
||||
}{{"/number", "200", "NumberInvalid", float64(12)}, {"/text", "409", "TextInvalid", "000012"}} {
|
||||
response := operation(t, doc, tc.path, "get")["responses"].(map[string]any)[tc.status].(map[string]any)
|
||||
value := response["content"].(map[string]any)["application/json"].(map[string]any)["examples"].(map[string]any)[tc.name].(map[string]any)["value"].(map[string]any)
|
||||
if value["code"] != tc.code {
|
||||
t.Fatal(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
)
|
||||
|
||||
const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
const minimumVersion = "v0.9.0-preview"
|
||||
const minimumVersion = "v0.10.0-preview"
|
||||
const generatedHeader = "// Code generated by go-omni api. DO NOT EDIT.\n"
|
||||
|
||||
type Options struct {
|
||||
@@ -140,13 +140,13 @@ func generate(o Options, c *Compiled, starter bool) (Output, error) {
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(directory, path)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(path, "contract", "scalar.go")); err == nil {
|
||||
if _, err := os.Stat(filepath.Join(path, "errors", "catalog.go")); err == nil {
|
||||
localCurrent = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !localCurrent {
|
||||
return Output{}, fmt.Errorf("existing framework dependency predates migration extensions; use %s or a current local replacement", minimumVersion)
|
||||
return Output{}, fmt.Errorf("existing framework dependency predates error catalog support; use %s or a current local replacement", minimumVersion)
|
||||
}
|
||||
}
|
||||
if o.FrameworkDir != "" {
|
||||
@@ -312,12 +312,14 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
generated, seeds := map[string][]byte{}, map[string][]byte{}
|
||||
put := func(files map[string][]byte, name, content string) { files[name] = []byte(content) }
|
||||
if len(c.Spec.Errors) > 0 {
|
||||
content := "package apierrors\nimport omnierrors " + strconv.Quote(frameworkModule+"/errors") + "\n"
|
||||
content := "package apierrors\nimport(\"encoding/json\"; omnierrors " + strconv.Quote(frameworkModule+"/errors") + ")\n"
|
||||
content += fmt.Sprintf("func Catalog()omnierrors.Catalog{var c omnierrors.Catalog;if err:=json.Unmarshal([]byte(%q),&c);err!=nil{panic(err)};return c}\nvar errorRegistry=omnierrors.MustRegistry(Catalog())\n", errorCatalogJSON(c.Spec, nil))
|
||||
for _, e := range c.Spec.Errors {
|
||||
content += fmt.Sprintf("const Code%s = %q\n// %s returns a fresh public error declared in the API contract.\nfunc %s()*omnierrors.APIError{return omnierrors.New(%d,Code%s,%q)}\n", e.Name, e.Code, e.Name, e.Name, e.Status, e.Name, e.Message)
|
||||
content += fmt.Sprintf("const Code%s=%q\nfunc %s()*omnierrors.APIError{return errorRegistry.Resolve(omnierrors.ByCode(Code%s))}\n", e.Name, e.Code, e.Name, e.Name)
|
||||
}
|
||||
put(generated, "internal/apierrors/errorsGen.go", generatedHeader+content)
|
||||
}
|
||||
|
||||
types := "package types\n\n"
|
||||
if len(c.Spec.Scalars) > 0 {
|
||||
types += "import("
|
||||
@@ -343,7 +345,7 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
put(generated, "internal/types/typesGen.go", generatedHeader+types)
|
||||
put(generated, "docs/openapi.json", string(c.Document)+"\n")
|
||||
put(generated, "internal/handler/openapiGen.go", generatedHeader+fmt.Sprintf("package handler\nimport (\"net/http\"; %q)\nconst OpenAPIDocument = %q\nfunc OpenAPI(c *httpx.Context) error { return c.WriteResponse(200,http.Header{\"Content-Type\": {\"application/json; charset=utf-8\"}},[]byte(OpenAPIDocument)) }\n", frameworkModule+"/httpx", string(c.Document)+"\n"))
|
||||
policySource := fmt.Sprintf("package handler\nimport(\"encoding/json\";\"strings\";%q)\nvar responseTemplates=map[string]response.Encoder{\"\":response.Default(),\n", frameworkModule+"/response")
|
||||
policySource := fmt.Sprintf("package handler\nimport(\"encoding/json\";\"strings\";%q; omnierrors %q)\nvar responseTemplates=map[string]response.Encoder{\"\":response.Default(),\n", frameworkModule+"/response", frameworkModule+"/errors")
|
||||
for _, definition := range c.Spec.Responses {
|
||||
data, err := json.Marshal(definition.Config)
|
||||
if err != nil {
|
||||
@@ -351,7 +353,8 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
}
|
||||
policySource += fmt.Sprintf("%q: newResponseTemplate(%q),\n", definition.Name, string(data))
|
||||
}
|
||||
policySource += "}\nfunc newResponseTemplate(raw string)response.Encoder{var cfg response.Config;decoder:=json.NewDecoder(strings.NewReader(raw));decoder.UseNumber();if err:=decoder.Decode(&cfg);err!=nil{panic(err)};return response.MustNew(cfg)}\nfunc responseEncoder(name string,raw bool)response.Encoder{encoder:=responseTemplates[name];if encoder==nil{panic(\"unknown response template\")};if raw{encoder=response.RawJSON(encoder)};return encoder}\n"
|
||||
policySource += "}\nfunc newResponseTemplate(raw string)response.Encoder{var cfg response.Config;decoder:=json.NewDecoder(strings.NewReader(raw));decoder.UseNumber();if err:=decoder.Decode(&cfg);err!=nil{panic(err)};return response.MustNew(cfg)}\n"
|
||||
policySource += "func responseEncoder(name string,raw bool,catalogJSON string)response.Encoder{encoder:=responseTemplates[name];if encoder==nil{panic(\"unknown response template\")};var catalog omnierrors.Catalog;if err:=json.Unmarshal([]byte(catalogJSON),&catalog);err!=nil{panic(err)};encoder=response.MustWithErrors(catalog,encoder);if raw{encoder=response.RawJSON(encoder)};return encoder}\n"
|
||||
put(generated, "internal/handler/responseGen.go", generatedHeader+policySource)
|
||||
imports := fmt.Sprintf("import (\"fmt\"; %q; %q; %q;", frameworkModule+"/httpx", frameworkModule+"/openapi", o.Module+"/internal/servicecontext")
|
||||
routes := "func RegisterRoutes(router openapi.Registrar, deps *servicecontext.Context) error {\n if router==nil || deps==nil {return fmt.Errorf(\"router and dependencies required\")}\n"
|
||||
@@ -412,9 +415,9 @@ func renderProject(o Options, c *Compiled, starter bool) (map[string][]byte, map
|
||||
name := upper(r.Handler)
|
||||
// The response policy and body reader cap are installed before App middleware.
|
||||
if r.MaxBodyBytes > 0 {
|
||||
routes += fmt.Sprintf("httpx.HandleWithBodyLimit(router,%q,%q,%d,responseEncoder(%q,%t),h%d.%s", strings.ToUpper(r.Method), r.Path, r.MaxBodyBytes, responseName(r), r.ResponseFormat == "raw", i, name)
|
||||
routes += fmt.Sprintf("httpx.HandleWithBodyLimit(router,%q,%q,%d,responseEncoder(%q,%t,%q),h%d.%s", strings.ToUpper(r.Method), r.Path, r.MaxBodyBytes, responseName(r), r.ResponseFormat == "raw", errorCatalogJSON(c.Spec, append([]string{}, r.Errors...)), i, name)
|
||||
} else {
|
||||
routes += fmt.Sprintf("httpx.HandleWithResponse(router,%q,%q,responseEncoder(%q,%t),h%d.%s", strings.ToUpper(r.Method), r.Path, responseName(r), r.ResponseFormat == "raw", i, name)
|
||||
routes += fmt.Sprintf("httpx.HandleWithResponse(router,%q,%q,responseEncoder(%q,%t,%q),h%d.%s", strings.ToUpper(r.Method), r.Path, responseName(r), r.ResponseFormat == "raw", errorCatalogJSON(c.Spec, append([]string{}, r.Errors...)), i, name)
|
||||
}
|
||||
for _, m := range r.Middleware {
|
||||
routes += fmt.Sprintf(",deps.Middlewares[%q]", m)
|
||||
|
||||
@@ -137,7 +137,7 @@ func TestDeclaredErrorsAndAuthentication(t *testing.T) {
|
||||
func TestInvalidDeclarations(t *testing.T) {
|
||||
replacements := [][2]string{
|
||||
{`"EMAIL_TAKEN"`, `"UNAUTHENTICATED"`},
|
||||
{`EmailTaken 409`, `EmailTaken 200`},
|
||||
{`EmailTaken 409`, `EmailTaken 204`},
|
||||
{`EmailTaken 409`, `emailTaken 409`},
|
||||
{`NameTaken 409`, `CodeEmailTaken 409`},
|
||||
{`auth: UserAuth`, `auth: Unknown`},
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
type Spec struct {
|
||||
Title, Version, Service, Description string
|
||||
Errors []ErrorDefinition
|
||||
ErrorCatalogs []string
|
||||
Auth []AuthDefinition
|
||||
Responses []ResponseDefinition
|
||||
Scalars []ScalarDefinition
|
||||
@@ -266,6 +267,10 @@ func (p *parser) parse() error {
|
||||
}
|
||||
p.imports = append(p.imports, v)
|
||||
}
|
||||
case "errorCatalog":
|
||||
if err := p.parseErrorCatalog(); err != nil {
|
||||
return err
|
||||
}
|
||||
case "error":
|
||||
if err := p.parseErrors(); err != nil {
|
||||
return err
|
||||
|
||||
92
cmd/go-omni/apigen/testdata/errorCatalog/consumer_test.go
vendored
Normal file
92
cmd/go-omni/apigen/testdata/errorCatalog/consumer_test.go
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
package consumer_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"example.com/errorCatalog/internal/apierrors"
|
||||
"example.com/errorCatalog/internal/handler"
|
||||
"example.com/errorCatalog/internal/servicecontext"
|
||||
omni "git.yuntuo.hk/k2safe/go-omni"
|
||||
omnierrors "git.yuntuo.hk/k2safe/go-omni/errors"
|
||||
"git.yuntuo.hk/k2safe/go-omni/httpx"
|
||||
"git.yuntuo.hk/k2safe/go-omni/logx"
|
||||
)
|
||||
|
||||
func TestGeneratedCatalog(t *testing.T) {
|
||||
first := apierrors.UsernameInvalid().WithCause(errors.New("private"))
|
||||
second := apierrors.UsernameInvalid()
|
||||
if first == second || second.Cause != nil || second.Status != 200 || second.BusinessCode != int64(400002) {
|
||||
t.Fatal("shared or incomplete generated error")
|
||||
}
|
||||
copy := apierrors.Catalog()
|
||||
delete(copy.Definitions, apierrors.CodeUsernameInvalid)
|
||||
if len(apierrors.Catalog().Definitions) != 3 {
|
||||
t.Fatal("shared catalog")
|
||||
}
|
||||
cfg := logx.Default()
|
||||
cfg.Writer = io.Discard
|
||||
logger, err := logx.New(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
deps, err := servicecontext.New(context.Background(), logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer deps.Close()
|
||||
app := omni.New()
|
||||
defer app.Close()
|
||||
// Global middleware exits early: generated route policy must already be active.
|
||||
app.Use(func(next httpx.Handler) httpx.Handler {
|
||||
return func(c *httpx.Context) error {
|
||||
switch c.Header("X-Fixture") {
|
||||
case "username":
|
||||
return omnierrors.ByCode(apierrors.CodeUsernameInvalid).WithCause(errors.New("private"))
|
||||
case "password":
|
||||
return apierrors.PasswordInvalid()
|
||||
case "denied":
|
||||
return apierrors.PermissionDenied()
|
||||
case "unknown":
|
||||
return omnierrors.ByCode("MISSING")
|
||||
default:
|
||||
return c.OK(map[string]string{"token": "ok"})
|
||||
}
|
||||
}
|
||||
})
|
||||
if err := handler.RegisterRoutes(app, deps); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{"/token", "/raw"} {
|
||||
for _, test := range []struct {
|
||||
kind string
|
||||
status int
|
||||
want string
|
||||
}{{"username", 200, "用户名格式不正确"}, {"password", 200, "密码格式不正确"}, {"denied", 403, "没有权限"}, {"unknown", 500, "internal server error"}, {"", 200, `"token":"ok"`}} {
|
||||
method := "POST"
|
||||
if path == "/raw" {
|
||||
method = "GET"
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r := httptest.NewRequest(method, path, nil)
|
||||
r.Header.Set("X-Fixture", test.kind)
|
||||
app.ServeHTTP(w, r)
|
||||
if w.Code != test.status || !strings.Contains(w.Body.String(), test.want) || strings.Contains(w.Body.String(), "private") {
|
||||
t.Fatal(path, test.kind, w.Code, w.Body.String())
|
||||
}
|
||||
if test.kind == "username" || test.kind == "password" {
|
||||
if !strings.Contains(w.Body.String(), `"code":400002`) || !strings.Contains(w.Body.String(), `"ok":false`) {
|
||||
t.Fatal(w.Body.String())
|
||||
}
|
||||
}
|
||||
if path == "/raw" && test.kind == "" && strings.Contains(w.Body.String(), "code") {
|
||||
t.Fatal("raw success wrapped")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const frameworkModule = "git.yuntuo.hk/k2safe/go-omni"
|
||||
// Source builds can carry an unpublished VCS version (including +dirty).
|
||||
// Keep this fallback in sync with the next release; installed CLIs use their
|
||||
// downloaded module version, which also has a module checksum.
|
||||
const defaultFrameworkVersion = "v0.9.1-preview"
|
||||
const defaultFrameworkVersion = "v0.10.0-preview"
|
||||
|
||||
// This target is not a claim that the release already exists. Source users
|
||||
// select a local framework checkout until the module templates are published.
|
||||
|
||||
Reference in New Issue
Block a user