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.
258 lines
7.8 KiB
Go
258 lines
7.8 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.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
|
|
}
|