Files
go-omni/response/config.go

266 lines
7.9 KiB
Go

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.IsError()
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
}
if isError && r.BusinessCode != nil {
code, message = r.BusinessCode, r.Message
}
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) {
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" {
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
}