feat: add project-owned error catalogs and HTTP 200 failure contracts

This commit is contained in:
2026-09-24 12:40:05 +08:00
parent bb2caefbb4
commit ebfd279f9a
42 changed files with 1897 additions and 120 deletions

View File

@@ -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

View File

@@ -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 == "" {

View File

@@ -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

View 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)
}

View 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)
}
}
}

View File

@@ -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)

View File

@@ -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`},

View File

@@ -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

View 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")
}
}
}
}

View File

@@ -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.