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

82
response/catalog_test.go Normal file
View File

@@ -0,0 +1,82 @@
package response_test
import (
"encoding/json"
"strings"
"testing"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
"git.yuntuo.hk/k2safe/go-omni/response"
)
func TestCatalogHTTP200AndRawJSON(t *testing.T) {
c := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{"INVALID": {Code: 400002, Message: "invalid"}}}
cfg := response.Config{CodeType: "int32", SuccessCode: 0, ErrorCode: 9999, ErrorMessage: "fallback", Body: map[string]any{"ok": "${ok}", "code": "${code}", "data": "${data}"}, ErrorBody: map[string]any{"failed": "${ok}", "code": "${code}", "msg": "${message}"}}
enc, err := response.WithErrors(c, response.MustNew(cfg))
if err != nil {
t.Fatal(err)
}
for _, encoder := range []response.Encoder{enc, response.RawJSON(enc)} {
p, err := encoder.Encode(response.Result{Status: 500, Err: apierrors.ByCode("INVALID")})
if err != nil {
t.Fatal(err)
}
var body map[string]any
if err = json.Unmarshal(p.Body, &body); err != nil {
t.Fatal(err)
}
if p.Status != 200 || body["failed"] != false || body["code"] != float64(400002) || body["msg"] != "invalid" || body["data"] != nil {
t.Fatal(string(p.Body))
}
}
p, err := response.RawJSON(enc).Encode(response.Result{Status: 200, Data: map[string]bool{"value": true}})
if err != nil || strings.Contains(string(p.Body), "code") {
t.Fatal(string(p.Body), err)
}
for _, change := range []response.Config{
{CodeType: "string"},
{CodeType: "int32", SuccessCode: 0, ErrorCode: 9, Codes: map[string]any{"INVALID": 1}},
{CodeType: "int32", SuccessCode: 0, ErrorCode: 9, Messages: map[string]string{"INVALID": "different"}},
} {
if _, err := response.WithErrors(c, response.MustNew(change)); err == nil {
t.Fatal("accepted conflicting catalog mapping")
}
}
enc, err = response.WithErrors(c, response.EncoderFunc(func(r response.Result) (response.Payload, error) {
if !r.IsError() || r.Status != 200 || r.BusinessCode != int64(400002) {
t.Fatal(r)
}
return response.Text(202, "ACK"), nil
}))
if err != nil {
t.Fatal(err)
}
p, err = enc.Encode(response.Result{Err: apierrors.ByCode("INVALID")})
if err != nil || p.Status != 202 || string(p.Body) != "ACK" {
t.Fatal(p, err)
}
}
func TestCatalogWrapperPreservesScope(t *testing.T) {
catalog := apierrors.Catalog{DefaultStatus: 200, Definitions: map[string]apierrors.Definition{
"invalid": {Code: "invalid", Message: "project message"},
}}
inner := response.MustWithErrors(catalog, response.Default())
for _, encoder := range []response.Encoder{inner, response.RawJSON(inner)} {
for _, outer := range []apierrors.Catalog{{}, catalog} {
bound, err := response.WithErrors(outer, encoder)
if err != nil {
t.Fatal(err)
}
payload, err := bound.Encode(response.Result{Err: apierrors.ByCode("invalid")})
if err != nil || payload.Status != 200 || !strings.Contains(string(payload.Body), "project message") {
t.Fatal(payload, err)
}
}
other := catalog.Clone()
other.Definitions["invalid"] = apierrors.Definition{Status: 409, Code: "other", Message: "other"}
if _, err := response.WithErrors(other, encoder); err == nil {
t.Fatal("stacked conflicting policies")
}
}
}

View File

@@ -162,7 +162,7 @@ func (f *Formatter) Config() Config {
func (f *Formatter) Value(r Result) map[string]any {
c := f.config
code, message := any(r.Code), r.Message
isError := r.Err != nil || r.Status >= 400
isError := r.IsError()
if isError {
if c.ErrorCode != nil {
code = c.ErrorCode
@@ -184,6 +184,9 @@ func (f *Formatter) Value(r Result) map[string]any {
if mapped, ok := c.Messages[r.Code]; ok {
message = mapped
}
if isError && r.BusinessCode != nil {
code, message = r.BusinessCode, r.Message
}
body := c.Body
if isError && c.ErrorBody != nil {
body = c.ErrorBody
@@ -206,7 +209,12 @@ func (f *Formatter) Value(r Result) map[string]any {
}
return value
}
func (f *Formatter) Encode(r Result) (Payload, error) { return JSON(r.Status, f.Value(r)) }
func (f *Formatter) Encode(r Result) (Payload, error) {
if err := f.validateError(r); err != nil {
return Payload{}, err
}
return JSON(r.Status, f.Value(r))
}
func normalizeCode(value any, kind string) (any, error) {
if kind == "string" {

View File

@@ -3,6 +3,8 @@ package response
import (
"encoding/json"
"net/http"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
)
// Result is the transport-independent input to an Encoder. Err is available for
@@ -12,8 +14,13 @@ type Result struct {
Code, Message, RequestID string
Data any
Err error
BusinessCode any
Failed bool
}
// IsError preserves failure semantics independently of the HTTP status.
func (r Result) IsError() bool { return r.Failed || r.Err != nil || r.Status >= 400 }
// Payload is a complete response. Encoders must be safe for concurrent use.
// Status and headers are intentionally part of the contract: callback protocols
// may acknowledge an error using a different HTTP status or media type.
@@ -47,14 +54,30 @@ func Default() Encoder { return defaultEncoder }
// RawJSON leaves successful data unwrapped. Errors use the selected error
// policy; callback protocols with custom acknowledgments should use EncoderFunc.
type rawJSONEncoder struct{ errors Encoder }
func (e *rawJSONEncoder) ErrorRegistry() *apierrors.Registry {
if p, ok := e.errors.(ErrorRegistryProvider); ok {
return p.ErrorRegistry()
}
return nil
}
func (e *rawJSONEncoder) Encode(r Result) (Payload, error) {
if r.IsError() {
return e.errors.Encode(r)
}
return JSON(r.Status, r.Data)
}
func RawJSON(errors Encoder) Encoder {
if errors == nil {
errors = Default()
}
return EncoderFunc(func(r Result) (Payload, error) {
if r.Err != nil || r.Status >= 400 {
return errors.Encode(r)
}
return JSON(r.Status, r.Data)
})
return &rawJSONEncoder{errors: errors}
}
func (e *rawJSONEncoder) ValidateErrors(catalog apierrors.Catalog) error {
if checker, ok := e.errors.(interface{ ValidateErrors(apierrors.Catalog) error }); ok {
return checker.ValidateErrors(catalog)
}
return nil
}

84
response/errors.go Normal file
View File

@@ -0,0 +1,84 @@
package response
import (
"fmt"
"reflect"
apierrors "git.yuntuo.hk/k2safe/go-omni/errors"
)
// ErrorRegistryProvider attaches an immutable error catalog to a route policy.
// Contexts and buffered clones use it before encoding or running middleware.
type ErrorRegistryProvider interface{ ErrorRegistry() *apierrors.Registry }
type errorEncoder struct {
next Encoder
registry *apierrors.Registry
}
func (e *errorEncoder) ErrorRegistry() *apierrors.Registry { return e.registry }
func (e *errorEncoder) Encode(r Result) (Payload, error) {
if r.Err != nil {
err := e.registry.Resolve(r.Err)
r.Status, r.Code, r.Message, r.BusinessCode, r.Failed = err.Status, err.Code, err.Message, err.BusinessCode, true
}
return e.next.Encode(r)
}
// WithErrors binds a catalog to an encoder without imposing a response shape.
// Built-in templates are checked for incompatible code types and conflicting
// legacy mappings. Custom encoders remain responsible for their wire contract.
func WithErrors(catalog apierrors.Catalog, next Encoder) (Encoder, error) {
registry, err := apierrors.NewRegistry(catalog)
if err != nil {
return nil, err
}
if next == nil {
next = Default()
}
if provider, ok := next.(ErrorRegistryProvider); ok && provider.ErrorRegistry() != nil {
if len(catalog.Definitions) == 0 {
return next, nil
}
if reflect.DeepEqual(registry.Catalog(), provider.ErrorRegistry().Catalog()) {
return next, nil
}
return nil, fmt.Errorf("response: cannot stack different error catalogs; choose one policy per scope")
}
if checker, ok := next.(interface{ ValidateErrors(apierrors.Catalog) error }); ok {
if err := checker.ValidateErrors(registry.Catalog()); err != nil {
return nil, err
}
}
return &errorEncoder{next: next, registry: registry}, nil
}
func MustWithErrors(catalog apierrors.Catalog, next Encoder) Encoder {
e, err := WithErrors(catalog, next)
if err != nil {
panic(err)
}
return e
}
func (f *Formatter) ValidateErrors(catalog apierrors.Catalog) error {
for key, d := range catalog.Definitions {
if err := f.validateError(Result{Code: key, Message: d.Message, BusinessCode: d.Code, Failed: true}); err != nil {
return err
}
}
return nil
}
func (f *Formatter) validateError(r Result) error {
if !r.IsError() || r.BusinessCode == nil {
return nil
}
normalized, err := normalizeCode(r.BusinessCode, f.config.CodeType)
if err != nil {
return fmt.Errorf("response: catalog %s code does not match codeType %s: %w", r.Code, f.config.CodeType, err)
}
if code, ok := f.config.Codes[r.Code]; ok && !reflect.DeepEqual(code, normalized) {
return fmt.Errorf("response: conflicting catalog/code mapping for %s", r.Code)
}
if msg, ok := f.config.Messages[r.Code]; ok && msg != r.Message {
return fmt.Errorf("response: conflicting catalog/message mapping for %s", r.Code)
}
return nil
}