Skip to content

Commit ec802e2

Browse files
kevwanCopilot
andauthored
feat: add JSON5 configuration support (#5433)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 8a2e09d commit ec802e2

File tree

6 files changed

+369
-6
lines changed

6 files changed

+369
-6
lines changed

core/conf/config.go

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,11 @@ const (
2121
var (
2222
fillDefaultUnmarshaler = mapping.NewUnmarshaler(jsonTagKey, mapping.WithDefault())
2323
loaders = map[string]func([]byte, any) error{
24-
".json": LoadFromJsonBytes,
25-
".toml": LoadFromTomlBytes,
26-
".yaml": LoadFromYamlBytes,
27-
".yml": LoadFromYamlBytes,
24+
".json": LoadFromJsonBytes,
25+
".json5": LoadFromJson5Bytes,
26+
".toml": LoadFromTomlBytes,
27+
".yaml": LoadFromYamlBytes,
28+
".yml": LoadFromYamlBytes,
2829
}
2930
)
3031

@@ -41,7 +42,7 @@ func FillDefault(v any) error {
4142
return fillDefaultUnmarshaler.Unmarshal(map[string]any{}, v)
4243
}
4344

44-
// Load loads config into v from file, .json, .yaml and .yml are acceptable.
45+
// Load loads config into v from file, .json, .json5, .toml, .yaml and .yml are acceptable.
4546
func Load(file string, v any, opts ...Option) error {
4647
content, err := os.ReadFile(file)
4748
if err != nil {
@@ -65,7 +66,7 @@ func Load(file string, v any, opts ...Option) error {
6566
return loader(content, v)
6667
}
6768

68-
// LoadConfig loads config into v from file, .json, .yaml and .yml are acceptable.
69+
// LoadConfig loads config into v from file, .json, .json5, .toml, .yaml and .yml are acceptable.
6970
// Deprecated: use Load instead.
7071
func LoadConfig(file string, v any, opts ...Option) error {
7172
return Load(file, v, opts...)
@@ -119,6 +120,16 @@ func LoadFromYamlBytes(content []byte, v any) error {
119120
return LoadFromJsonBytes(b, v)
120121
}
121122

123+
// LoadFromJson5Bytes loads config into v from content json5 bytes.
124+
func LoadFromJson5Bytes(content []byte, v any) error {
125+
b, err := encoding.Json5ToJson(content)
126+
if err != nil {
127+
return err
128+
}
129+
130+
return LoadFromJsonBytes(b, v)
131+
}
132+
122133
// LoadConfigFromYamlBytes loads config into v from content yaml bytes.
123134
// Deprecated: use LoadFromYamlBytes instead.
124135
func LoadConfigFromYamlBytes(content []byte, v any) error {

core/conf/config_test.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,160 @@ func TestLoadFromJsonBytesArray(t *testing.T) {
7575
assert.EqualValues(t, []string{"foo", "bar"}, expect)
7676
}
7777

78+
func TestConfigJson5(t *testing.T) {
79+
// JSON5 with comments, trailing commas, and unquoted keys
80+
text := `{
81+
// This is a comment
82+
a: 'foo', // single quotes
83+
b: 1,
84+
c: "${FOO}",
85+
d: "abcd!@#$112", // trailing comma
86+
}`
87+
t.Setenv("FOO", "2")
88+
89+
tmpfile, err := createTempFile(t, ".json5", text)
90+
assert.Nil(t, err)
91+
92+
var val struct {
93+
A string `json:"a"`
94+
B int `json:"b"`
95+
C string `json:"c"`
96+
D string `json:"d"`
97+
}
98+
MustLoad(tmpfile, &val)
99+
assert.Equal(t, "foo", val.A)
100+
assert.Equal(t, 1, val.B)
101+
assert.Equal(t, "${FOO}", val.C)
102+
assert.Equal(t, "abcd!@#$112", val.D)
103+
}
104+
105+
func TestConfigJsonStandardParser(t *testing.T) {
106+
// Standard JSON uses standard JSON parser (not JSON5) for backward compatibility
107+
text := `{
108+
"a": "foo",
109+
"b": 1,
110+
"c": "${FOO}",
111+
"d": "abcd!@#$112"
112+
}`
113+
t.Setenv("FOO", "2")
114+
115+
tmpfile, err := createTempFile(t, ".json", text)
116+
assert.Nil(t, err)
117+
118+
var val struct {
119+
A string `json:"a"`
120+
B int `json:"b"`
121+
C string `json:"c"`
122+
D string `json:"d"`
123+
}
124+
MustLoad(tmpfile, &val)
125+
assert.Equal(t, "foo", val.A)
126+
assert.Equal(t, 1, val.B)
127+
assert.Equal(t, "${FOO}", val.C)
128+
assert.Equal(t, "abcd!@#$112", val.D)
129+
}
130+
131+
func TestConfigJsonLargeIntegers(t *testing.T) {
132+
// Test that .json files preserve large integer precision (backward compatibility)
133+
text := `{
134+
"id": 1234567890123456789,
135+
"timestamp": 9223372036854775807
136+
}`
137+
138+
tmpfile, err := createTempFile(t, ".json", text)
139+
assert.Nil(t, err)
140+
141+
var val struct {
142+
ID int64 `json:"id"`
143+
Timestamp int64 `json:"timestamp"`
144+
}
145+
MustLoad(tmpfile, &val)
146+
assert.Equal(t, int64(1234567890123456789), val.ID)
147+
assert.Equal(t, int64(9223372036854775807), val.Timestamp)
148+
}
149+
150+
func TestConfigJson5Env(t *testing.T) {
151+
text := `{
152+
// Comment with env variable
153+
a: "foo",
154+
b: 1,
155+
c: "${FOO}",
156+
d: "abcd!@#$a12 3",
157+
}`
158+
t.Setenv("FOO", "2")
159+
160+
tmpfile, err := createTempFile(t, ".json5", text)
161+
assert.Nil(t, err)
162+
163+
var val struct {
164+
A string `json:"a"`
165+
B int `json:"b"`
166+
C string `json:"c"`
167+
D string `json:"d"`
168+
}
169+
MustLoad(tmpfile, &val, UseEnv())
170+
assert.Equal(t, "foo", val.A)
171+
assert.Equal(t, 1, val.B)
172+
assert.Equal(t, "2", val.C)
173+
assert.Equal(t, "abcd!@# 3", val.D)
174+
}
175+
176+
func TestLoadFromJson5Bytes(t *testing.T) {
177+
// Test JSON5 features: comments, trailing commas, single quotes, unquoted keys
178+
input := []byte(`{
179+
// This is a comment
180+
users: [
181+
{name: 'foo'}, // trailing comma
182+
{Name: "bar"},
183+
],
184+
}`)
185+
var val struct {
186+
Users []struct {
187+
Name string
188+
}
189+
}
190+
191+
assert.NoError(t, LoadFromJson5Bytes(input, &val))
192+
var expect []string
193+
for _, user := range val.Users {
194+
expect = append(expect, user.Name)
195+
}
196+
assert.EqualValues(t, []string{"foo", "bar"}, expect)
197+
}
198+
199+
func TestLoadFromJson5BytesError(t *testing.T) {
200+
// Invalid JSON5 syntax
201+
input := []byte(`{a: foo}`) // unquoted string value (invalid)
202+
var val struct {
203+
A string
204+
}
205+
206+
assert.Error(t, LoadFromJson5Bytes(input, &val))
207+
}
208+
209+
func TestConfigJson5LargeIntegersLimitation(t *testing.T) {
210+
// Document that JSON5 has precision limitations for large integers (>2^53)
211+
// due to JavaScript number semantics. Users should use .json for configs with large IDs.
212+
text := `{
213+
// JSON5 converts numbers to float64, which loses precision for large integers
214+
id: 1234567890123456789
215+
}`
216+
217+
tmpfile, err := createTempFile(t, ".json5", text)
218+
assert.Nil(t, err)
219+
220+
var val struct {
221+
ID int64 `json:"id"`
222+
}
223+
224+
// This will load; depending on the JSON5 implementation, large integers may lose precision.
225+
// This test documents that behavior without requiring loss of precision as an invariant.
226+
err = Load(tmpfile, &val)
227+
assert.NoError(t, err)
228+
229+
t.Logf("loaded JSON5 large integer id=%d (original 1234567890123456789)", val.ID)
230+
}
231+
78232
func TestConfigToml(t *testing.T) {
79233
text := `a = "foo"
80234
b = 1

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ require (
2020
github.com/redis/go-redis/v9 v9.18.0
2121
github.com/spaolacci/murmur3 v1.1.0
2222
github.com/stretchr/testify v1.11.1
23+
github.com/titanous/json5 v1.0.0
2324
go.etcd.io/etcd/api/v3 v3.5.15
2425
go.etcd.io/etcd/client/v3 v3.5.15
2526
go.mongodb.org/mongo-driver/v2 v2.5.0

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfS
164164
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
165165
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
166166
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
167+
github.com/robertkrimen/otto v0.2.1 h1:FVP0PJ0AHIjC+N4pKCG9yCDz6LHNPCwi/GKID5pGGF0=
168+
github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//farB5FlRY=
167169
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
168170
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
169171
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
@@ -186,6 +188,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
186188
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
187189
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
188190
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
191+
github.com/titanous/json5 v1.0.0 h1:hJf8Su1d9NuI/ffpxgxQfxh/UiBFZX7bMPid0rIL/7s=
192+
github.com/titanous/json5 v1.0.0/go.mod h1:7JH1M8/LHKc6cyP5o5g3CSaRj+mBrIimTxzpvmckH8c=
189193
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
190194
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
191195
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
@@ -321,6 +325,8 @@ gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
321325
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
322326
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
323327
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
328+
gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI=
329+
gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78=
324330
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
325331
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
326332
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

internal/encoding/encoding.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,63 @@ package encoding
33
import (
44
"bytes"
55
"encoding/json"
6+
"fmt"
7+
"math"
68

79
"github.com/pelletier/go-toml/v2"
10+
"github.com/titanous/json5"
811
"github.com/zeromicro/go-zero/core/lang"
912
"gopkg.in/yaml.v2"
1013
)
1114

15+
// Json5ToJson converts JSON5 data into its JSON representation.
16+
func Json5ToJson(data []byte) ([]byte, error) {
17+
var val any
18+
if err := json5.Unmarshal(data, &val); err != nil {
19+
return nil, err
20+
}
21+
22+
// Validate that there are no unsupported values like Infinity or NaN
23+
if err := validateJSONCompatible(val); err != nil {
24+
return nil, err
25+
}
26+
27+
return encodeToJSON(val)
28+
}
29+
30+
// validateJSONCompatible checks if the value can be represented in standard JSON.
31+
// JSON5 allows Infinity and NaN, but standard JSON does not support these values.
32+
func validateJSONCompatible(val any) error {
33+
switch v := val.(type) {
34+
case float64:
35+
if math.IsInf(v, 0) {
36+
return fmt.Errorf("JSON5 value Infinity cannot be represented in standard JSON")
37+
}
38+
if math.IsNaN(v) {
39+
return fmt.Errorf("JSON5 value NaN cannot be represented in standard JSON")
40+
}
41+
case []any:
42+
for _, item := range v {
43+
if err := validateJSONCompatible(item); err != nil {
44+
return err
45+
}
46+
}
47+
case map[string]any:
48+
for _, value := range v {
49+
if err := validateJSONCompatible(value); err != nil {
50+
return err
51+
}
52+
}
53+
case map[any]any:
54+
for _, value := range v {
55+
if err := validateJSONCompatible(value); err != nil {
56+
return err
57+
}
58+
}
59+
}
60+
return nil
61+
}
62+
1263
// TomlToJson converts TOML data into its JSON representation.
1364
func TomlToJson(data []byte) ([]byte, error) {
1465
var val any

0 commit comments

Comments
 (0)