feat: release configurable API response contracts
Define reusable response bodies in .api with typed code mappings, per-route overrides and matching OpenAPI schemas. Add application and callback encoders, early response policy selection, and isolated remote-consumer release verification. Release v0.8.0-preview after full race, vet, build and local MySQL workflow acceptance.
This commit is contained in:
123
response/codeTypes_test.go
Normal file
123
response/codeTypes_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package response_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
func TestCodeTypeWidthsAndWireValues(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
kind string
|
||||
min, max int64
|
||||
}{
|
||||
{"int32", math.MinInt32, math.MaxInt32}, {"int64", math.MinInt64, math.MaxInt64},
|
||||
{"integer", -9007199254740991, 9007199254740991},
|
||||
} {
|
||||
t.Run(tc.kind, func(t *testing.T) {
|
||||
cfg := response.Config{CodeType: tc.kind, SuccessCode: tc.max, ErrorCode: tc.min, Codes: map[string]any{"ZERO": 0}}
|
||||
formatter, err := response.New(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, sample := range []struct {
|
||||
result response.Result
|
||||
want int64
|
||||
}{
|
||||
{response.Result{Status: 200, Code: "OK"}, tc.max},
|
||||
{response.Result{Status: 500, Code: "INTERNAL"}, tc.min},
|
||||
{response.Result{Status: 409, Code: "ZERO"}, 0},
|
||||
} {
|
||||
payload, err := formatter.Encode(sample.result)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload.Body))
|
||||
decoder.UseNumber()
|
||||
var body map[string]any
|
||||
if err = decoder.Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, ok := body["code"].(json.Number)
|
||||
if !ok || code.String() != strconv.FormatInt(sample.want, 10) {
|
||||
t.Fatalf("precision/type lost: %s", payload.Body)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
kind string
|
||||
value any
|
||||
}{
|
||||
{"int32", int64(math.MaxInt32) + 1}, {"int32", int64(math.MinInt32) - 1},
|
||||
{"int64", json.Number("9223372036854775808")}, {"int64", json.Number("-9223372036854775809")},
|
||||
{"int64", uint64(math.MaxUint64)}, {"int64", float64(math.MaxInt64)},
|
||||
{"int32", 1.5}, {"int64", json.Number("1.5")}, {"int64", "200"},
|
||||
{"string", 200},
|
||||
} {
|
||||
for _, field := range []string{"successCode", "errorCode", "codes"} {
|
||||
cfg := response.Config{CodeType: tc.kind, SuccessCode: 0, ErrorCode: 1}
|
||||
if tc.kind == "string" {
|
||||
cfg.SuccessCode = "OK"
|
||||
cfg.ErrorCode = "ERROR"
|
||||
}
|
||||
switch field {
|
||||
case "successCode":
|
||||
cfg.SuccessCode = tc.value
|
||||
case "errorCode":
|
||||
cfg.ErrorCode = tc.value
|
||||
case "codes":
|
||||
cfg.Codes = map[string]any{"MAPPED": tc.value}
|
||||
}
|
||||
if _, err := response.New(cfg); err == nil {
|
||||
t.Fatalf("%s %s accepted %#v", tc.kind, field, tc.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, kind := range []string{"int32", "int64"} {
|
||||
if _, err := response.New(response.Config{CodeType: kind}); err == nil {
|
||||
t.Fatalf("%s accepted missing numeric fallbacks", kind)
|
||||
}
|
||||
}
|
||||
f := response.MustNew(response.Config{CodeType: "string", SuccessCode: "SUCCESS", ErrorCode: "ERROR", Codes: map[string]any{"DENIED": "403"}})
|
||||
if got := f.Value(response.Result{Status: 403, Code: "DENIED"})["code"]; got != "403" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInt64CodesFromConfigurationKeepPrecision(t *testing.T) {
|
||||
for ext, body := range map[string]string{
|
||||
"json": `{"response":{"codeType":"int64","successCode":9223372036854775807,"errorCode":-9223372036854775808,"codes":{"EXACT":9007199254740993}}}`,
|
||||
"yaml": "response:\n codeType: int64\n successCode: 9223372036854775807\n errorCode: -9223372036854775808\n codes:\n EXACT: 9007199254740993\n",
|
||||
} {
|
||||
path := filepath.Join(t.TempDir(), "config."+ext)
|
||||
if err := os.WriteFile(path, []byte(body), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.LoadFile(path, "RESPONSE_TEST_")
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", ext, err)
|
||||
}
|
||||
f := response.MustNew(cfg.Response)
|
||||
if f.Config().SuccessCode != int64(math.MaxInt64) || f.Config().ErrorCode != int64(math.MinInt64) || f.Config().Codes["EXACT"] != int64(9007199254740993) {
|
||||
t.Fatalf("%s: precision lost: %#v", ext, f.Config())
|
||||
}
|
||||
}
|
||||
var cfg response.Config
|
||||
if err := json.Unmarshal([]byte(`{"codeType":"int64","successCode":9223372036854775807,"errorCode":0}`), &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.MustNew(cfg).Config().SuccessCode != int64(math.MaxInt64) {
|
||||
t.Fatal("json.Unmarshal lost precision")
|
||||
}
|
||||
if err := json.Unmarshal([]byte(`{"unknownOption":1}`), &cfg); err == nil {
|
||||
t.Fatal("custom decoder bypassed unknown-field validation")
|
||||
}
|
||||
}
|
||||
257
response/config.go
Normal file
257
response/config.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config defines a JSON response template or a convenient flat envelope.
|
||||
// Body values can contain typed slots; ErrorBody optionally overrides errors.
|
||||
// Without Body, empty field names retain defaults and "-" disables a field.
|
||||
// This is application configuration, never a process-global response policy.
|
||||
type Config struct {
|
||||
Body map[string]any `json:"body"`
|
||||
ErrorBody map[string]any `json:"errorBody"`
|
||||
CodeField string `json:"codeField,omitempty"`
|
||||
MessageField string `json:"messageField,omitempty"`
|
||||
DataField string `json:"dataField,omitempty"`
|
||||
RequestIDField string `json:"requestIdField,omitempty"`
|
||||
CodeType string `json:"codeType,omitempty"` // string, int32, int64; integer is legacy
|
||||
SuccessCode any `json:"successCode,omitempty"`
|
||||
ErrorCode any `json:"errorCode,omitempty"`
|
||||
SuccessMessage string `json:"successMessage,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
DataMode string `json:"dataMode,omitempty"`
|
||||
Codes map[string]any `json:"codes,omitempty"`
|
||||
Messages map[string]string `json:"messages,omitempty"`
|
||||
}
|
||||
|
||||
type Formatter struct{ config Config }
|
||||
|
||||
// UnmarshalJSON preserves int64 values in interface fields, including mappings
|
||||
// and body constants. DecodeFile and ordinary json.Unmarshal share this path.
|
||||
func (c *Config) UnmarshalJSON(data []byte) error {
|
||||
type plain Config
|
||||
value := plain(*c)
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.UseNumber()
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return err
|
||||
}
|
||||
*c = Config(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
// New validates and detaches all configuration before serving requests.
|
||||
func New(c Config) (*Formatter, error) {
|
||||
if c.ErrorBody != nil && c.Body == nil {
|
||||
return nil, errors.New("response: errorBody requires body")
|
||||
}
|
||||
if c.Body != nil {
|
||||
if c.CodeField != "" || c.MessageField != "" || c.DataField != "" || c.RequestIDField != "" {
|
||||
return nil, errors.New("response: body cannot be combined with field-name shortcuts")
|
||||
}
|
||||
for _, body := range []map[string]any{c.Body, c.ErrorBody} {
|
||||
if err := validateTemplate(body, 0); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
c = c.Clone()
|
||||
defaults := []struct {
|
||||
field *string
|
||||
value string
|
||||
}{{&c.CodeField, "code"}, {&c.MessageField, "message"}, {&c.DataField, "data"}, {&c.RequestIDField, "requestId"}, {&c.CodeType, "string"}, {&c.DataMode, "omitNil"}}
|
||||
for _, d := range defaults {
|
||||
if *d.field == "" {
|
||||
*d.field = d.value
|
||||
}
|
||||
}
|
||||
if c.CodeType != "string" && c.CodeType != "int32" && c.CodeType != "int64" && c.CodeType != "integer" {
|
||||
return nil, errors.New("response: codeType must be string, int32 or int64 (legacy integer is also accepted)")
|
||||
}
|
||||
if c.DataMode != "omitNil" && c.DataMode != "always" {
|
||||
return nil, errors.New("response: dataMode must be omitNil or always")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, field := range []string{c.CodeField, c.MessageField, c.DataField, c.RequestIDField} {
|
||||
if field == "-" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(field) != field || strings.ContainsAny(field, "\r\n\t") || seen[field] {
|
||||
return nil, errors.New("response: field names must be distinct nonempty names without surrounding whitespace")
|
||||
}
|
||||
seen[field] = true
|
||||
}
|
||||
if c.CodeType != "string" && (c.SuccessCode == nil || c.ErrorCode == nil) {
|
||||
return nil, errors.New("response: integer codes require successCode and errorCode")
|
||||
}
|
||||
for _, item := range []struct {
|
||||
name string
|
||||
value *any
|
||||
}{{"successCode", &c.SuccessCode}, {"errorCode", &c.ErrorCode}} {
|
||||
if *item.value == nil {
|
||||
continue
|
||||
}
|
||||
value, err := normalizeCode(*item.value, c.CodeType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("response: %s: %w", item.name, err)
|
||||
}
|
||||
*item.value = value
|
||||
}
|
||||
for name, value := range c.Codes {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return nil, errors.New("response: code mapping requires a logical code")
|
||||
}
|
||||
v, err := normalizeCode(value, c.CodeType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("response: codes.%s: %w", name, err)
|
||||
}
|
||||
c.Codes[name] = v
|
||||
}
|
||||
return &Formatter{config: c}, nil
|
||||
}
|
||||
|
||||
func MustNew(c Config) *Formatter {
|
||||
f, err := New(c)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
func (c Config) Validate() error { _, err := New(c); return err }
|
||||
func (c Config) Clone() Config {
|
||||
if c.Body != nil {
|
||||
c.Body = cloneTemplate(c.Body, 0).(map[string]any)
|
||||
}
|
||||
if c.ErrorBody != nil {
|
||||
c.ErrorBody = cloneTemplate(c.ErrorBody, 0).(map[string]any)
|
||||
}
|
||||
if c.Codes != nil {
|
||||
values := make(map[string]any, len(c.Codes))
|
||||
for k, v := range c.Codes {
|
||||
values[k] = v
|
||||
}
|
||||
c.Codes = values
|
||||
}
|
||||
if c.Messages != nil {
|
||||
values := make(map[string]string, len(c.Messages))
|
||||
for k, v := range c.Messages {
|
||||
values[k] = v
|
||||
}
|
||||
c.Messages = values
|
||||
}
|
||||
return c
|
||||
}
|
||||
func (f *Formatter) Config() Config {
|
||||
c := f.config.Clone()
|
||||
if c.Body != nil {
|
||||
c.CodeField, c.MessageField, c.DataField, c.RequestIDField = "", "", "", ""
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Value is also used by documentation generation, keeping code/message mapping
|
||||
// and field omission identical to the actual response encoder.
|
||||
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
|
||||
if isError {
|
||||
if c.ErrorCode != nil {
|
||||
code = c.ErrorCode
|
||||
}
|
||||
if c.ErrorMessage != "" {
|
||||
message = c.ErrorMessage
|
||||
}
|
||||
} else {
|
||||
if c.SuccessCode != nil {
|
||||
code = c.SuccessCode
|
||||
}
|
||||
if c.SuccessMessage != "" {
|
||||
message = c.SuccessMessage
|
||||
}
|
||||
}
|
||||
if mapped, ok := c.Codes[r.Code]; ok {
|
||||
code = mapped
|
||||
}
|
||||
if mapped, ok := c.Messages[r.Code]; ok {
|
||||
message = mapped
|
||||
}
|
||||
body := c.Body
|
||||
if isError && c.ErrorBody != nil {
|
||||
body = c.ErrorBody
|
||||
}
|
||||
if body != nil {
|
||||
return renderTemplate(body, map[string]any{"code": code, "message": message, "data": r.Data, "requestId": r.RequestID, "ok": !isError, "status": r.Status}, c.DataMode).(map[string]any)
|
||||
}
|
||||
value := map[string]any{}
|
||||
if c.CodeField != "-" {
|
||||
value[c.CodeField] = code
|
||||
}
|
||||
if c.MessageField != "-" {
|
||||
value[c.MessageField] = message
|
||||
}
|
||||
if c.DataField != "-" && (r.Data != nil || c.DataMode == "always") {
|
||||
value[c.DataField] = r.Data
|
||||
}
|
||||
if c.RequestIDField != "-" && r.RequestID != "" {
|
||||
value[c.RequestIDField] = r.RequestID
|
||||
}
|
||||
return value
|
||||
}
|
||||
func (f *Formatter) Encode(r Result) (Payload, error) { return JSON(r.Status, f.Value(r)) }
|
||||
|
||||
func normalizeCode(value any, kind string) (any, error) {
|
||||
if kind == "string" {
|
||||
if s, ok := value.(string); ok && s != "" {
|
||||
return s, nil
|
||||
}
|
||||
return nil, errors.New("expected a nonempty string")
|
||||
}
|
||||
if value == nil {
|
||||
return nil, errors.New("expected an integer")
|
||||
}
|
||||
if n, ok := value.(json.Number); ok {
|
||||
v, err := n.Int64()
|
||||
if err != nil {
|
||||
return nil, errors.New("expected a signed 64-bit integer")
|
||||
}
|
||||
value = v
|
||||
}
|
||||
v := reflect.ValueOf(value)
|
||||
var n int64
|
||||
switch v.Kind() {
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
n = v.Int()
|
||||
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
|
||||
if v.Uint() > math.MaxInt64 {
|
||||
return nil, errors.New("integer code exceeds int64 range")
|
||||
}
|
||||
n = int64(v.Uint())
|
||||
case reflect.Float32, reflect.Float64:
|
||||
x := v.Float()
|
||||
if math.IsNaN(x) || math.IsInf(x, 0) || math.Trunc(x) != x || math.Abs(x) > 9007199254740991 {
|
||||
return nil, errors.New("expected an exact integer code")
|
||||
}
|
||||
n = int64(x)
|
||||
default:
|
||||
return nil, errors.New("expected an integer")
|
||||
}
|
||||
if kind == "int32" {
|
||||
if n < math.MinInt32 || n > math.MaxInt32 {
|
||||
return nil, errors.New("integer code exceeds int32 range")
|
||||
}
|
||||
return int32(n), nil
|
||||
}
|
||||
if kind == "integer" && (n < -9007199254740991 || n > 9007199254740991) {
|
||||
return nil, errors.New("integer code exceeds exact JSON range")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
75
response/config_test.go
Normal file
75
response/config_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package response_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/config"
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
func TestConfigurableWireContract(t *testing.T) {
|
||||
cfg := response.Config{CodeType: "integer", SuccessCode: 0, ErrorCode: 9999, MessageField: "msg", RequestIDField: "-", DataMode: "always", SuccessMessage: "操作成功", Codes: map[string]any{"EMAIL_TAKEN": 409001}, Messages: map[string]string{"EMAIL_TAKEN": "邮箱已存在"}}
|
||||
f, err := response.New(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.Codes["EMAIL_TAKEN"] = 123
|
||||
cfg.Messages["EMAIL_TAKEN"] = "changed"
|
||||
f.Config().Codes["EMAIL_TAKEN"] = 456
|
||||
for _, tc := range []struct {
|
||||
result response.Result
|
||||
code int64
|
||||
message string
|
||||
data any
|
||||
}{
|
||||
{response.Result{Status: 200, Code: "OK", Message: "success"}, 0, "操作成功", nil},
|
||||
{response.Result{Status: 409, Code: "EMAIL_TAKEN", Message: "email taken", RequestID: "request", Err: errors.New("private")}, 409001, "邮箱已存在", nil},
|
||||
{response.Result{Status: 200, Code: "OK", Data: []string{}}, 0, "操作成功", []string{}},
|
||||
} {
|
||||
value := f.Value(tc.result)
|
||||
if !reflect.DeepEqual(value, map[string]any{"code": tc.code, "msg": tc.message, "data": tc.data}) {
|
||||
t.Fatalf("unexpected envelope: %#v", value)
|
||||
}
|
||||
}
|
||||
for _, bad := range []response.Config{
|
||||
{CodeType: "integer"}, {CodeType: "bool"}, {MessageField: "code"}, {DataMode: "sometimes"},
|
||||
{CodeType: "integer", SuccessCode: 200, ErrorCode: 9.5},
|
||||
{CodeType: "integer", SuccessCode: json.Number("9007199254740992"), ErrorCode: 9999},
|
||||
{Codes: map[string]any{"ERROR": 42}},
|
||||
} {
|
||||
if _, err := response.New(bad); err == nil {
|
||||
t.Fatalf("invalid config accepted: %+v", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseConfigLoadsFromJSONAndYAML(t *testing.T) {
|
||||
for filename, body := range map[string]string{
|
||||
"flat.json": `{"response":{"codeType":"integer","successCode":0,"errorCode":9999,"messageField":"msg","requestIdField":"-","dataMode":"always","codes":{"DENIED":403001}}}`,
|
||||
"flat.yaml": "response:\n codeType: integer\n successCode: 0\n errorCode: 9999\n messageField: msg\n requestIdField: '-'\n dataMode: always\n codes:\n DENIED: 403001\n",
|
||||
"body.json": `{"response":{"body":{"code":"${code}","msg":"${message}","data":"${data}"},"codeType":"integer","successCode":0,"errorCode":9999,"dataMode":"always","codes":{"DENIED":403001}}}`,
|
||||
"body.yaml": "response:\n body:\n code: '${code}'\n msg: '${message}'\n data: '${data}'\n codeType: integer\n successCode: 0\n errorCode: 9999\n dataMode: always\n codes:\n DENIED: 403001\n",
|
||||
} {
|
||||
path := filepath.Join(t.TempDir(), filename)
|
||||
if err := os.WriteFile(path, []byte(body), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.LoadFile(path, "RESPONSE_TEST_")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := response.New(cfg.Response)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value := f.Value(response.Result{Status: 403, Code: "DENIED"})
|
||||
if value["code"] != int64(403001) || len(value) != 3 {
|
||||
t.Fatalf("%s: %v", filename, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
60
response/encoder.go
Normal file
60
response/encoder.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Result is the transport-independent input to an Encoder. Err is available for
|
||||
// application error mapping; default encoders never expose it or its causes.
|
||||
type Result struct {
|
||||
Status int
|
||||
Code, Message, RequestID string
|
||||
Data any
|
||||
Err error
|
||||
}
|
||||
|
||||
// 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.
|
||||
type Payload struct {
|
||||
Status int
|
||||
Header http.Header
|
||||
Body []byte
|
||||
}
|
||||
|
||||
type Encoder interface{ Encode(Result) (Payload, error) }
|
||||
type EncoderFunc func(Result) (Payload, error)
|
||||
|
||||
func (f EncoderFunc) Encode(r Result) (Payload, error) { return f(r) }
|
||||
|
||||
func JSON(status int, value any) (Payload, error) {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return Payload{}, err
|
||||
}
|
||||
return Payload{Status: status, Header: http.Header{"Content-Type": {"application/json; charset=utf-8"}}, Body: append(data, '\n')}, nil
|
||||
}
|
||||
|
||||
func Text(status int, value string) Payload {
|
||||
return Payload{Status: status, Header: http.Header{"Content-Type": {"text/plain; charset=utf-8"}}, Body: []byte(value)}
|
||||
}
|
||||
|
||||
// Default is immutable; applications can use different encoders in one process.
|
||||
var defaultEncoder = MustNew(Config{})
|
||||
|
||||
func Default() Encoder { return defaultEncoder }
|
||||
|
||||
// RawJSON leaves successful data unwrapped. Errors use the selected error
|
||||
// policy; callback protocols with custom acknowledgments should use EncoderFunc.
|
||||
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)
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package response
|
||||
|
||||
// Response is the standard JSON envelope returned by GoOmni helpers.
|
||||
// Response describes the legacy default JSON envelope only.
|
||||
// Deprecated: configure a response.Config or Encoder for application contracts.
|
||||
// The runtime and code generator do not serialize through this fixed DTO.
|
||||
type Response struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
|
||||
109
response/template.go
Normal file
109
response/template.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package response
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Slot returns the name of a whole-value placeholder, or empty for constants.
|
||||
// Slots preserve the value's JSON type; they are not string interpolation.
|
||||
func Slot(value any) string {
|
||||
s, ok := value.(string)
|
||||
if ok && strings.HasPrefix(s, "${") && strings.HasSuffix(s, "}") {
|
||||
return s[2 : len(s)-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func validateTemplate(value any, depth int) error {
|
||||
if depth > 24 {
|
||||
return fmt.Errorf("response: body nesting exceeds 24 levels")
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
for key, item := range v {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
return fmt.Errorf("response: body keys must not be empty")
|
||||
}
|
||||
if err := validateTemplate(item, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, item := range v {
|
||||
if err := validateTemplate(item, depth+1); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
case string:
|
||||
if strings.HasPrefix(v, "${") {
|
||||
switch Slot(v) {
|
||||
case "code", "message", "data", "requestId", "ok", "status":
|
||||
default:
|
||||
return fmt.Errorf("response: unknown body slot %q", v)
|
||||
}
|
||||
}
|
||||
case nil, bool, json.Number, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
|
||||
if _, err := json.Marshal(v); err != nil {
|
||||
return fmt.Errorf("response: invalid body constant: %w", err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("response: body accepts JSON values, got %T", value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneTemplate(value any, depth int) any {
|
||||
// Invalid cyclic Go configuration must still reach validation, not overflow.
|
||||
if depth > 24 {
|
||||
return value
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
copy := make(map[string]any, len(v))
|
||||
for key, item := range v {
|
||||
copy[key] = cloneTemplate(item, depth+1)
|
||||
}
|
||||
return copy
|
||||
case []any:
|
||||
copy := make([]any, len(v))
|
||||
for i, item := range v {
|
||||
copy[i] = cloneTemplate(item, depth+1)
|
||||
}
|
||||
return copy
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func renderTemplate(value any, slots map[string]any, dataMode string) any {
|
||||
if name := Slot(value); name != "" {
|
||||
return slots[name]
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
result := make(map[string]any, len(v))
|
||||
for key, item := range v {
|
||||
slot := Slot(item)
|
||||
if slot == "requestId" && slots[slot] == "" || slot == "data" && slots[slot] == nil && dataMode != "always" {
|
||||
continue
|
||||
}
|
||||
result[key] = renderTemplate(item, slots, dataMode)
|
||||
}
|
||||
return result
|
||||
case []any:
|
||||
result := make([]any, len(v))
|
||||
for i, item := range v {
|
||||
result[i] = renderTemplate(item, slots, dataMode)
|
||||
}
|
||||
return result
|
||||
case string:
|
||||
if strings.HasPrefix(v, "$${") {
|
||||
return v[1:]
|
||||
}
|
||||
return v
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
72
response/template_test.go
Normal file
72
response/template_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package response_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yuntuo.hk/k2safe/go-omni/response"
|
||||
)
|
||||
|
||||
func TestResponseBodyTemplates(t *testing.T) {
|
||||
config := response.Config{
|
||||
Body: map[string]any{"success": "${ok}", "payload": "${data}", "meta": map[string]any{"trace": "${requestId}", "status": "${status}"}, "literal": "$${data}", "version": 1},
|
||||
ErrorBody: map[string]any{"success": "${ok}", "error": map[string]any{"id": "${code}", "reason": "${message}"}},
|
||||
CodeType: "integer", SuccessCode: 0, ErrorCode: 9999, Codes: map[string]any{"DENIED": 403001},
|
||||
}
|
||||
f := response.MustNew(config)
|
||||
config.Body["meta"].(map[string]any)["status"] = "mutated"
|
||||
f.Config().ErrorBody["error"].(map[string]any)["reason"] = "mutated"
|
||||
good := f.Value(response.Result{Status: 201, Code: "CREATED", Data: []string{"a"}})
|
||||
want := map[string]any{"success": true, "payload": []string{"a"}, "meta": map[string]any{"status": 201}, "literal": "${data}", "version": 1}
|
||||
if !reflect.DeepEqual(good, want) {
|
||||
t.Fatalf("%#v", good)
|
||||
}
|
||||
bad := f.Value(response.Result{Status: 403, Code: "DENIED", Message: "denied", Err: errors.New("PRIVATE")})
|
||||
want = map[string]any{"success": false, "error": map[string]any{"id": int64(403001), "reason": "denied"}}
|
||||
if !reflect.DeepEqual(bad, want) {
|
||||
t.Fatalf("%#v", bad)
|
||||
}
|
||||
// Returning or serializing configuration preserves reusable templates, even {}.
|
||||
for _, cfg := range []response.Config{f.Config(), {Body: map[string]any{}, ErrorBody: map[string]any{}}, {Body: map[string]any{"values": []any{"${data}", true, nil}}}} {
|
||||
before := response.MustNew(cfg).Value(response.Result{Status: 200})
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var copy response.Config
|
||||
if err = json.Unmarshal(data, ©); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, _ := json.Marshal(before)
|
||||
b, _ := json.Marshal(response.MustNew(copy).Value(response.Result{Status: 200}))
|
||||
if string(a) != string(b) {
|
||||
t.Fatalf("round trip: %s / %s", a, b)
|
||||
}
|
||||
}
|
||||
cyclic := map[string]any{}
|
||||
cyclic["cycle"] = cyclic
|
||||
for _, cfg := range []response.Config{
|
||||
{Body: map[string]any{"value": "${missing}"}}, {Body: map[string]any{"value": "${data}-suffix"}},
|
||||
{Body: cyclic}, {Body: map[string]any{"value": make(chan int)}}, {Body: map[string]any{}, DataField: "data"},
|
||||
{ErrorBody: map[string]any{}},
|
||||
} {
|
||||
if _, err := response.New(cfg.Clone()); err == nil {
|
||||
t.Fatal("invalid template accepted")
|
||||
}
|
||||
}
|
||||
if strings.Contains(string(mustJSON(t, bad)), "PRIVATE") {
|
||||
t.Fatal("private cause leaked")
|
||||
}
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, value any) []byte {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
Reference in New Issue
Block a user