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:
2026-09-23 20:42:38 +08:00
parent bef7ea2641
commit 7670b222ac
47 changed files with 2689 additions and 120 deletions

123
response/codeTypes_test.go Normal file
View 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
View 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
View 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
View 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)
})
}

View File

@@ -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
View 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
View 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, &copy); 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
}