Commit 7bbe85bc by CaIon

refactor(json): route JSON helpers through a host-injectable codec

`common/json.go` and `relaykit/relayconvert/kitutil/json.go` were two
hard-wired copies of the same encoding/json wrapper, so swapping the JSON
engine required editing both modules.

- kitutil defines a `Codec` interface with a standard-library default and
  a `SetCodec` hook, mirroring the existing SetLogging host hook; every
  kitutil JSON helper and relaykit DTO (un)marshal method goes through it
- `common/json.go` forwards to kitutil and injects `hostJSONCodec` from
  init() so tests run on the same engine as production; swapping the
  engine now touches only this type in the root module
- route the remaining direct encoding/json calls inside relaykit
  (dto/values.go, responses stream validation) through kitutil
- add a codec routing test and a host codec conformance test locking the
  encoding semantics the DTOs depend on

Direct encoding/json call sites in the root module are left for a
separate cleanup.
parent 6e10f9bc
......@@ -5,19 +5,48 @@ import (
"encoding/json"
"io"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/gin-gonic/gin/binding"
)
func Unmarshal(data []byte, v any) error {
// hostJSONCodec is the single place where the host chooses its JSON engine.
// Swap the implementation here (for example to sonic.ConfigStd) and every
// common.* and kitutil.* JSON helper, including relaykit DTO (un)marshalling,
// follows. Injected from init() rather than main() so tests run on the same
// engine as production: common is imported by virtually every root package
// and test binary, while main() never executes under `go test`.
type hostJSONCodec struct{}
func (hostJSONCodec) Marshal(v any) ([]byte, error) {
return json.Marshal(v)
}
func (hostJSONCodec) Unmarshal(data []byte, v any) error {
return json.Unmarshal(data, v)
}
func (hostJSONCodec) Decode(r io.Reader, v any) error {
return json.NewDecoder(r).Decode(v)
}
func (hostJSONCodec) Valid(data []byte) bool {
return json.Valid(data)
}
func init() {
kitutil.SetCodec(hostJSONCodec{})
}
func Unmarshal(data []byte, v any) error {
return kitutil.Unmarshal(data, v)
}
func UnmarshalJsonStr(data string, v any) error {
return json.Unmarshal(StringToByteSlice(data), v)
return kitutil.UnmarshalJsonStr(data, v)
}
func DecodeJson(reader io.Reader, v any) error {
return json.NewDecoder(reader).Decode(v)
return kitutil.DecodeJson(reader, v)
}
// DecodeJsonWithValidation decodes JSON and applies Gin's configured binding-tag
......@@ -33,7 +62,7 @@ func DecodeJsonWithValidation(reader io.Reader, v any) error {
}
func Marshal(v any) ([]byte, error) {
return json.Marshal(v)
return kitutil.Marshal(v)
}
func IndentJson(data []byte) ([]byte, error) {
......@@ -45,39 +74,10 @@ func IndentJson(data []byte) ([]byte, error) {
}
func GetJsonType(data json.RawMessage) string {
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 {
return "unknown"
}
firstChar := trimmed[0]
switch firstChar {
case '{':
return "object"
case '[':
return "array"
case '"':
return "string"
case 't', 'f':
return "boolean"
case 'n':
return "null"
default:
return "number"
}
return kitutil.GetJsonType(data)
}
// JsonRawMessageToString returns JSON strings as their decoded value and other JSON values as raw text.
func JsonRawMessageToString(data json.RawMessage) string {
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
return ""
}
if trimmed[0] != '"' {
return string(trimmed)
}
var value string
if err := Unmarshal(trimmed, &value); err != nil {
return string(trimmed)
}
return value
return kitutil.JsonRawMessageToString(data)
}
......@@ -5,9 +5,11 @@ import (
"strings"
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/go-playground/validator/v10"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestJsonRawMessageToString(t *testing.T) {
......@@ -83,3 +85,152 @@ func TestDecodeJsonWithValidation(t *testing.T) {
var unvalidated request
require.NoError(t, DecodeJson(strings.NewReader(`{}`), &unvalidated))
}
// TestHostJSONCodecConformance runs through the codec injected by common's
// init() and locks the encoding semantics the relay DTOs depend on. A future
// engine swap in hostJSONCodec must keep every case here green.
func TestHostJSONCodecConformance(t *testing.T) {
type embedded struct {
Content any `json:"content"`
}
type shadowed struct {
embedded
Content any `json:"content,omitempty"`
}
type anyFields struct {
Nil any `json:"nil,omitempty"`
Str any `json:"str,omitempty"`
Int any `json:"int,omitempty"`
Bool any `json:"bool,omitempty"`
}
type rawFields struct {
Obj json.RawMessage `json:"obj"`
Arr json.RawMessage `json:"arr"`
Nested json.RawMessage `json:"nested"`
Str json.RawMessage `json:"str"`
}
type numberField struct {
N json.Number `json:"n"`
}
type pointerZeros struct {
Count *int `json:"count,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
}
zero := 0
off := false
t.Run("marshal", func(t *testing.T) {
for _, tt := range []struct {
name string
in any
want string
}{
{
name: "shallowest field shadows embedded content and nil is omitted",
in: shadowed{embedded: embedded{Content: "inner"}},
want: `{}`,
},
{
name: "shallowest field keeps empty string content",
in: shadowed{embedded: embedded{Content: "inner"}, Content: ""},
want: `{"content":""}`,
},
{
name: "omitempty on any drops nil but keeps zero values",
in: anyFields{Str: "", Int: 0, Bool: false},
want: `{"str":"","int":0,"bool":false}`,
},
{
name: "map keys are sorted",
in: map[string]any{"z": 1, "a": 2, "m": 3},
want: `{"a":2,"m":3,"z":1}`,
},
{
name: "html characters are escaped",
in: map[string]string{"s": `<a href="x">&</a>`},
want: `{"s":"\u003ca href=\"x\"\u003e\u0026\u003c/a\u003e"}`,
},
{
name: "explicit pointer zeros are kept",
in: pointerZeros{Count: &zero, Enabled: &off},
want: `{"count":0,"enabled":false}`,
},
{
name: "nil pointers are omitted",
in: pointerZeros{},
want: `{}`,
},
} {
t.Run(tt.name, func(t *testing.T) {
encoded, err := Marshal(tt.in)
require.NoError(t, err)
assert.Equal(t, tt.want, string(encoded))
})
}
})
t.Run("raw message passthrough", func(t *testing.T) {
input := `{"obj":{},"arr":[],"nested":{"k":[1,2]},"str":"x"}`
var value rawFields
require.NoError(t, UnmarshalJsonStr(input, &value))
assert.Equal(t, `{}`, string(value.Obj))
assert.Equal(t, `[]`, string(value.Arr))
assert.Equal(t, `{"k":[1,2]}`, string(value.Nested))
assert.Equal(t, `"x"`, string(value.Str))
encoded, err := Marshal(value)
require.NoError(t, err)
assert.Equal(t, input, string(encoded))
})
t.Run("json.Number keeps large integers exact", func(t *testing.T) {
input := `{"n":18446744073686646784}`
var value numberField
require.NoError(t, Unmarshal([]byte(input), &value))
assert.Equal(t, json.Number("18446744073686646784"), value.N)
encoded, err := Marshal(value)
require.NoError(t, err)
assert.Equal(t, input, string(encoded))
})
t.Run("explicit zeros survive unmarshal into pointers", func(t *testing.T) {
var value pointerZeros
require.NoError(t, Unmarshal([]byte(`{"count":0,"enabled":false}`), &value))
require.NotNil(t, value.Count)
require.NotNil(t, value.Enabled)
assert.Equal(t, 0, *value.Count)
assert.False(t, *value.Enabled)
var absent pointerZeros
require.NoError(t, Unmarshal([]byte(`{}`), &absent))
assert.Nil(t, absent.Count)
assert.Nil(t, absent.Enabled)
})
t.Run("relaykit DTO round trip", func(t *testing.T) {
raw := []byte(`{
"model":"kimi-k3",
"messages":[
{"role":"system","tools":[{"type":"function","function":{"name":"get_current_time","description":"Get the current time of a city","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}}]},
{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_current_time","arguments":"{\"city\":\"Beijing\"}"}}]}
]
}`)
var req dto.GeneralOpenAIRequest
require.NoError(t, Unmarshal(raw, &req))
encoded, err := Marshal(req)
require.NoError(t, err)
messages := gjson.GetBytes(encoded, "messages").Array()
require.Len(t, messages, 2)
// Kimi K3 dynamic tool loading: tools survive and no content key is emitted.
assert.Equal(t, "system", messages[0].Get("role").String())
assert.JSONEq(t, gjson.GetBytes(raw, "messages.0.tools").Raw, messages[0].Get("tools").Raw)
assert.False(t, messages[0].Get("content").Exists())
// Assistant tool-call replay still carries an explicit "content": null.
assistantContent := messages[1].Get("content")
assert.True(t, assistantContent.Exists())
assert.Equal(t, gjson.Null, assistantContent.Type)
assert.JSONEq(t, gjson.GetBytes(raw, "messages.1.tool_calls").Raw, messages[1].Get("tool_calls").Raw)
})
}
......@@ -3,40 +3,42 @@ package dto
import (
"encoding/json"
"strconv"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
type StringValue string
func (s *StringValue) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err == nil {
if err := kitutil.Unmarshal(data, &str); err == nil {
*s = StringValue(str)
return nil
}
var raw json.Number
if err := json.Unmarshal(data, &raw); err == nil {
if err := kitutil.Unmarshal(data, &raw); err == nil {
*s = StringValue(raw.String())
return nil
}
return json.Unmarshal(data, &str)
return kitutil.Unmarshal(data, &str)
}
func (s StringValue) MarshalJSON() ([]byte, error) {
return json.Marshal(string(s))
return kitutil.Marshal(string(s))
}
type IntValue int
func (i *IntValue) UnmarshalJSON(b []byte) error {
var n int
if err := json.Unmarshal(b, &n); err == nil {
if err := kitutil.Unmarshal(b, &n); err == nil {
*i = IntValue(n)
return nil
}
var s string
if err := json.Unmarshal(b, &s); err != nil {
if err := kitutil.Unmarshal(b, &s); err != nil {
return err
}
v, err := strconv.Atoi(s)
......@@ -48,19 +50,19 @@ func (i *IntValue) UnmarshalJSON(b []byte) error {
}
func (i IntValue) MarshalJSON() ([]byte, error) {
return json.Marshal(int(i))
return kitutil.Marshal(int(i))
}
type BoolValue bool
func (b *BoolValue) UnmarshalJSON(data []byte) error {
var boolean bool
if err := json.Unmarshal(data, &boolean); err == nil {
if err := kitutil.Unmarshal(data, &boolean); err == nil {
*b = BoolValue(boolean)
return nil
}
var str string
if err := json.Unmarshal(data, &str); err != nil {
if err := kitutil.Unmarshal(data, &str); err != nil {
return err
}
if str == "true" {
......@@ -68,10 +70,10 @@ func (b *BoolValue) UnmarshalJSON(data []byte) error {
} else if str == "false" {
*b = BoolValue(false)
} else {
return json.Unmarshal(data, &boolean)
return kitutil.Unmarshal(data, &boolean)
}
return nil
}
func (b BoolValue) MarshalJSON() ([]byte, error) {
return json.Marshal(bool(b))
return kitutil.Marshal(bool(b))
}
......@@ -813,7 +813,7 @@ func hostedJSONString(value []byte) (json.RawMessage, error) {
if len(value) == 0 {
return json.RawMessage(`""`), nil
}
if !json.Valid(value) {
if !kitutil.Valid(value) {
return nil, fmt.Errorf("invalid JSON payload")
}
encoded, err := kitutil.Marshal(string(value))
......@@ -827,7 +827,7 @@ func hostedResultString(value []byte) (json.RawMessage, error) {
if len(value) == 0 {
return json.RawMessage(`""`), nil
}
if !json.Valid(value) {
if !kitutil.Valid(value) {
return nil, fmt.Errorf("invalid JSON payload")
}
if kitutil.GetJsonType(value) == "string" {
......
......@@ -11,20 +11,69 @@ import (
"unsafe"
)
func Unmarshal(data []byte, v any) error {
// Codec is the JSON engine used by every kitutil JSON helper. The host may
// replace it once at startup via SetCodec; relaykit itself never depends on a
// third-party JSON library.
type Codec interface {
Marshal(v any) ([]byte, error)
Unmarshal(data []byte, v any) error
Decode(r io.Reader, v any) error
Valid(data []byte) bool
}
// stdCodec is the default Codec, backed by encoding/json. It is the only place
// in relaykit that calls the standard library's JSON functions directly.
type stdCodec struct{}
func (stdCodec) Marshal(v any) ([]byte, error) {
return json.Marshal(v)
}
func (stdCodec) Unmarshal(data []byte, v any) error {
return json.Unmarshal(data, v)
}
func (stdCodec) Decode(r io.Reader, v any) error {
return json.NewDecoder(r).Decode(v)
}
func (stdCodec) Valid(data []byte) bool {
return json.Valid(data)
}
var codec Codec = stdCodec{}
// SetCodec installs the JSON engine behind every kitutil JSON helper, which
// also covers the custom (Un)MarshalJSON methods on relaykit DTOs. Like
// SetLogging it is meant to be called once during host startup before any
// request is served; it is not synchronized against concurrent helper calls.
// A nil codec is ignored so the standard-library default stays in place.
func SetCodec(c Codec) {
if c == nil {
return
}
codec = c
}
func Unmarshal(data []byte, v any) error {
return codec.Unmarshal(data, v)
}
func UnmarshalJsonStr(data string, v any) error {
return json.Unmarshal(StringToByteSlice(data), v)
return codec.Unmarshal(StringToByteSlice(data), v)
}
func DecodeJson(reader io.Reader, v any) error {
return json.NewDecoder(reader).Decode(v)
return codec.Decode(reader, v)
}
func Marshal(v any) ([]byte, error) {
return json.Marshal(v)
return codec.Marshal(v)
}
// Valid reports whether data is a syntactically valid JSON document.
func Valid(data []byte) bool {
return codec.Valid(data)
}
func GetJsonType(data json.RawMessage) string {
......@@ -73,12 +122,12 @@ func StringToByteSlice(s string) []byte {
func Any2Type[T any](data any) (T, error) {
var zero T
bytes, err := json.Marshal(data)
encoded, err := Marshal(data)
if err != nil {
return zero, err
}
var res T
err = json.Unmarshal(bytes, &res)
err = Unmarshal(encoded, &res)
if err != nil {
return zero, err
}
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment