diff options
| author | Damien Neil <dneil@google.com> | 2025-04-11 14:19:51 -0700 |
|---|---|---|
| committer | Gopher Robot <gobot@golang.org> | 2025-04-18 08:24:07 -0700 |
| commit | 0e17905793cb5e0acc323a0cdf3733199d93976a (patch) | |
| tree | fec117ceb6b56866e6c51e6acd72901cf91717ce /src/encoding/json/v2_diff_test.go | |
| parent | c889004615b40535ebd5054cbcf2deebdb3a299a (diff) | |
| download | go-0e17905793cb5e0acc323a0cdf3733199d93976a.tar.xz | |
encoding/json: add json/v2 with GOEXPERIMENT=jsonv2 guard
This imports the proposed new v2 JSON API implemented in
github.com/go-json-experiment/json as of commit
d3c622f1b874954c355e60c8e6b6baa5f60d2fed.
When GOEXPERIMENT=jsonv2 is set, the encoding/json/v2 and
encoding/jsontext packages are visible, the encoding/json
package is implemented in terms of encoding/json/v2, and
the encoding/json package include various additional APIs.
(See #71497 for details.)
When GOEXPERIMENT=jsonv2 is not set, the new API is not
present and the encoding/json package is unchanged.
The experimental API is not bound by the Go compatibility
promise and is expected to evolve as updates are made to
the json/v2 proposal.
The contents of encoding/json/internal/jsontest/testdata
are compressed with zstd v1.5.7 with the -19 option.
Fixes #71845
For #71497
Change-Id: Ib8c94e5f0586b6aaa22833190b41cf6ef59f4f01
Reviewed-on: https://go-review.googlesource.com/c/go/+/665796
Auto-Submit: Damien Neil <dneil@google.com>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Michael Pratt <mpratt@google.com>
Reviewed-by: Joseph Tsai <joetsai@digital-static.net>
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
Diffstat (limited to 'src/encoding/json/v2_diff_test.go')
| -rw-r--r-- | src/encoding/json/v2_diff_test.go | 1129 |
1 files changed, 1129 insertions, 0 deletions
diff --git a/src/encoding/json/v2_diff_test.go b/src/encoding/json/v2_diff_test.go new file mode 100644 index 0000000000..871be49776 --- /dev/null +++ b/src/encoding/json/v2_diff_test.go @@ -0,0 +1,1129 @@ +// Copyright 2020 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build goexperiment.jsonv2 + +package json_test + +import ( + "errors" + "path" + "reflect" + "strings" + "testing" + "time" + + jsonv1 "encoding/json" + "encoding/json/jsontext" + jsonv2 "encoding/json/v2" +) + +// NOTE: This file serves as a list of semantic differences between v1 and v2. +// Each test explains how v1 behaves, how v2 behaves, and +// a rationale for why the behavior was changed. + +var jsonPackages = []struct { + Version string + Marshal func(any) ([]byte, error) + Unmarshal func([]byte, any) error +}{ + {"v1", jsonv1.Marshal, jsonv1.Unmarshal}, + {"v2", + func(in any) ([]byte, error) { return jsonv2.Marshal(in) }, + func(in []byte, out any) error { return jsonv2.Unmarshal(in, out) }}, +} + +// In v1, unmarshal matches struct fields using a case-insensitive match. +// In v2, unmarshal matches struct fields using a case-sensitive match. +// +// Case-insensitive matching is a surprising default and +// incurs significant performance cost when unmarshaling unknown fields. +// In v2, we can opt into v1-like behavior with the `case:ignore` tag option. +// The case-insensitive matching performed by v2 is looser than that of v1 +// where it also ignores dashes and underscores. +// This allows v2 to match fields regardless of whether the name is in +// snake_case, camelCase, or kebab-case. +// +// Related issue: +// +// https://go.dev/issue/14750 +func TestCaseSensitivity(t *testing.T) { + type Fields struct { + FieldA bool + FieldB bool `json:"fooBar"` + FieldC bool `json:"fizzBuzz,case:ignore"` // `case:ignore` is used by v2 to explicitly enable case-insensitive matching + } + + for _, json := range jsonPackages { + t.Run(path.Join("Unmarshal", json.Version), func(t *testing.T) { + // This is a mapping from Go field names to JSON member names to + // whether the JSON member name would match the Go field name. + type goName = string + type jsonName = string + onlyV1 := json.Version == "v1" + onlyV2 := json.Version == "v2" + allMatches := map[goName]map[jsonName]bool{ + "FieldA": { + "FieldA": true, // exact match + "fielda": onlyV1, // v1 is case-insensitive by default + "fieldA": onlyV1, // v1 is case-insensitive by default + "FIELDA": onlyV1, // v1 is case-insensitive by default + "FieldB": false, + "FieldC": false, + }, + "FieldB": { + "fooBar": true, // exact match for explicitly specified JSON name + "FooBar": onlyV1, // v1 is case-insensitive even if an explicit JSON name is provided + "foobar": onlyV1, // v1 is case-insensitive even if an explicit JSON name is provided + "FOOBAR": onlyV1, // v1 is case-insensitive even if an explicit JSON name is provided + "fizzBuzz": false, + "FieldA": false, + "FieldB": false, // explicit JSON name means that the Go field name is not used for matching + "FieldC": false, + }, + "FieldC": { + "fizzBuzz": true, // exact match for explicitly specified JSON name + "fizzbuzz": true, // v2 is case-insensitive due to `case:ignore` tag + "FIZZBUZZ": true, // v2 is case-insensitive due to `case:ignore` tag + "fizz_buzz": onlyV2, // case-insensitivity in v2 ignores dashes and underscores + "fizz-buzz": onlyV2, // case-insensitivity in v2 ignores dashes and underscores + "fooBar": false, + "FieldA": false, + "FieldC": false, // explicit JSON name means that the Go field name is not used for matching + "FieldB": false, + }, + } + + for goFieldName, matches := range allMatches { + for jsonMemberName, wantMatch := range matches { + in := `{"` + jsonMemberName + `":true}` + var s Fields + if err := json.Unmarshal([]byte(in), &s); err != nil { + t.Fatalf("json.Unmarshal error: %v", err) + } + gotMatch := reflect.ValueOf(s).FieldByName(goFieldName).Bool() + if gotMatch != wantMatch { + t.Fatalf("%T.%s = %v, want %v", s, goFieldName, gotMatch, wantMatch) + } + } + } + }) + } +} + +// In v1, the "omitempty" option specifies that a struct field is omitted +// when marshaling if it is an empty Go value, which is defined as +// false, 0, a nil pointer, a nil interface value, and +// any empty array, slice, map, or string. +// +// In v2, the "omitempty" option specifies that a struct field is omitted +// when marshaling if it is an empty JSON value, which is defined as +// a JSON null or empty JSON string, object, or array. +// +// In v2, we also provide the "omitzero" option which specifies that a field +// is omitted if it is the zero Go value or if it implements an "IsZero() bool" +// method that reports true. Together, "omitzero" and "omitempty" can cover +// all the prior use cases of the v1 definition of "omitempty". +// Note that "omitempty" is defined in terms of the Go type system in v1, +// but now defined in terms of the JSON type system in v2. +// +// Related issues: +// +// https://go.dev/issue/11939 +// https://go.dev/issue/22480 +// https://go.dev/issue/29310 +// https://go.dev/issue/32675 +// https://go.dev/issue/45669 +// https://go.dev/issue/45787 +// https://go.dev/issue/50480 +// https://go.dev/issue/52803 +func TestOmitEmptyOption(t *testing.T) { + type Struct struct { + Foo string `json:",omitempty"` + Bar []int `json:",omitempty"` + Baz *Struct `json:",omitempty"` + } + type Types struct { + Bool bool `json:",omitempty"` + StringA string `json:",omitempty"` + StringB string `json:",omitempty"` + BytesA []byte `json:",omitempty"` + BytesB []byte `json:",omitempty"` + BytesC []byte `json:",omitempty"` + Int int `json:",omitempty"` + MapA map[string]string `json:",omitempty"` + MapB map[string]string `json:",omitempty"` + MapC map[string]string `json:",omitempty"` + StructA Struct `json:",omitempty"` + StructB Struct `json:",omitempty"` + StructC Struct `json:",omitempty"` + SliceA []string `json:",omitempty"` + SliceB []string `json:",omitempty"` + SliceC []string `json:",omitempty"` + Array [1]string `json:",omitempty"` + PointerA *string `json:",omitempty"` + PointerB *string `json:",omitempty"` + PointerC *string `json:",omitempty"` + InterfaceA any `json:",omitempty"` + InterfaceB any `json:",omitempty"` + InterfaceC any `json:",omitempty"` + InterfaceD any `json:",omitempty"` + } + + something := "something" + for _, json := range jsonPackages { + t.Run(path.Join("Marshal", json.Version), func(t *testing.T) { + in := Types{ + Bool: false, + StringA: "", + StringB: something, + BytesA: nil, + BytesB: []byte{}, + BytesC: []byte(something), + Int: 0, + MapA: nil, + MapB: map[string]string{}, + MapC: map[string]string{something: something}, + StructA: Struct{}, + StructB: Struct{Bar: []int{}, Baz: new(Struct)}, + StructC: Struct{Foo: something}, + SliceA: nil, + SliceB: []string{}, + SliceC: []string{something}, + Array: [1]string{something}, + PointerA: nil, + PointerB: new(string), + PointerC: &something, + InterfaceA: nil, + InterfaceB: (*string)(nil), + InterfaceC: new(string), + InterfaceD: &something, + } + b, err := json.Marshal(in) + if err != nil { + t.Fatalf("json.Marshal error: %v", err) + } + var out map[string]any + if err := json.Unmarshal(b, &out); err != nil { + t.Fatalf("json.Unmarshal error: %v", err) + } + + onlyV1 := json.Version == "v1" + onlyV2 := json.Version == "v2" + wantPresent := map[string]bool{ + "Bool": onlyV2, // false is an empty Go bool, but is NOT an empty JSON value + "StringA": false, + "StringB": true, + "BytesA": false, + "BytesB": false, + "BytesC": true, + "Int": onlyV2, // 0 is an empty Go integer, but NOT an empty JSON value + "MapA": false, + "MapB": false, + "MapC": true, + "StructA": onlyV1, // Struct{} is NOT an empty Go value, but {} is an empty JSON value + "StructB": onlyV1, // Struct{...} is NOT an empty Go value, but {} is an empty JSON value + "StructC": true, + "SliceA": false, + "SliceB": false, + "SliceC": true, + "Array": true, + "PointerA": false, + "PointerB": onlyV1, // new(string) is NOT a nil Go pointer, but "" is an empty JSON value + "PointerC": true, + "InterfaceA": false, + "InterfaceB": onlyV1, // (*string)(nil) is NOT a nil Go interface, but null is an empty JSON value + "InterfaceC": onlyV1, // new(string) is NOT a nil Go interface, but "" is an empty JSON value + "InterfaceD": true, + } + for field, want := range wantPresent { + _, got := out[field] + if got != want { + t.Fatalf("%T.%s = %v, want %v", in, field, got, want) + } + } + }) + } +} + +func addr[T any](v T) *T { + return &v +} + +// In v1, the "string" option specifies that Go strings, bools, and numeric +// values are encoded within a JSON string when marshaling and +// are unmarshaled from its native representation escaped within a JSON string. +// The "string" option is not applied recursively, and so does not affect +// strings, bools, and numeric values within a Go slice or map, but +// does have special handling to affect the underlying value within a pointer. +// When unmarshaling, the "string" option permits decoding from a JSON null +// escaped within a JSON string in some inconsistent cases. +// +// In v2, the "string" option specifies that only numeric values are encoded as +// a JSON number within a JSON string when marshaling and are unmarshaled +// from either a JSON number or a JSON string containing a JSON number. +// The "string" option is applied recursively to all numeric sub-values, +// and thus affects numeric values within a Go slice or map. +// There is no support for escaped JSON nulls within a JSON string. +// +// The main utility for stringifying JSON numbers is because JSON parsers +// often represents numbers as IEEE 754 floating-point numbers. +// This results in a loss of precision representing 64-bit integer values. +// Consequently, many JSON-based APIs actually requires that such values +// be encoded within a JSON string. Since the main utility of stringification +// is for numeric values, v2 limits the effect of the "string" option +// to just numeric Go types. According to all code known by the Go module proxy, +// there are close to zero usages of the "string" option on a Go string or bool. +// +// Regarding the recursive application of the "string" option, +// there have been a number of issues filed about users being surprised that +// the "string" option does not recursively affect numeric values +// within a composite type like a Go map, slice, or interface value. +// In v1, specifying the "string" option on composite type has no effect +// and so this would be a largely backwards compatible change. +// +// The ability to decode from a JSON null wrapped within a JSON string +// is removed in v2 because this behavior was surprising and inconsistent in v1. +// +// Related issues: +// +// https://go.dev/issue/15624 +// https://go.dev/issue/20651 +// https://go.dev/issue/22177 +// https://go.dev/issue/32055 +// https://go.dev/issue/32117 +// https://go.dev/issue/50997 +func TestStringOption(t *testing.T) { + type Types struct { + String string `json:",string"` + Bool bool `json:",string"` + Int int `json:",string"` + Float float64 `json:",string"` + Map map[string]int `json:",string"` + Struct struct{ Field int } `json:",string"` + Slice []int `json:",string"` + Array [1]int `json:",string"` + PointerA *int `json:",string"` + PointerB *int `json:",string"` + PointerC **int `json:",string"` + InterfaceA any `json:",string"` + InterfaceB any `json:",string"` + } + + for _, json := range jsonPackages { + t.Run(path.Join("Marshal", json.Version), func(t *testing.T) { + in := Types{ + String: "string", + Bool: true, + Int: 1, + Float: 1, + Map: map[string]int{"Name": 1}, + Struct: struct{ Field int }{1}, + Slice: []int{1}, + Array: [1]int{1}, + PointerA: nil, + PointerB: addr(1), + PointerC: addr(addr(1)), + InterfaceA: nil, + InterfaceB: 1, + } + quote := func(s string) string { + b, _ := jsontext.AppendQuote(nil, s) + return string(b) + } + quoteOnlyV1 := func(s string) string { + if json.Version == "v1" { + s = quote(s) + } + return s + } + quoteOnlyV2 := func(s string) string { + if json.Version == "v2" { + s = quote(s) + } + return s + } + want := strings.Join([]string{ + `{`, + `"String":` + quoteOnlyV1(`"string"`) + `,`, // in v1, Go strings are also stringified + `"Bool":` + quoteOnlyV1("true") + `,`, // in v1, Go bools are also stringified + `"Int":` + quote("1") + `,`, + `"Float":` + quote("1") + `,`, + `"Map":{"Name":` + quoteOnlyV2("1") + `},`, // in v2, numbers are recursively stringified + `"Struct":{"Field":` + quoteOnlyV2("1") + `},`, // in v2, numbers are recursively stringified + `"Slice":[` + quoteOnlyV2("1") + `],`, // in v2, numbers are recursively stringified + `"Array":[` + quoteOnlyV2("1") + `],`, // in v2, numbers are recursively stringified + `"PointerA":null,`, + `"PointerB":` + quote("1") + `,`, // in v1, numbers are stringified after a single pointer indirection + `"PointerC":` + quoteOnlyV2("1") + `,`, // in v2, numbers are recursively stringified + `"InterfaceA":null,`, + `"InterfaceB":` + quoteOnlyV2("1") + ``, // in v2, numbers are recursively stringified + `}`}, "") + got, err := json.Marshal(in) + if err != nil { + t.Fatalf("json.Marshal error: %v", err) + } + if string(got) != want { + t.Fatalf("json.Marshal = %s, want %s", got, want) + } + }) + } + + for _, json := range jsonPackages { + t.Run(path.Join("Unmarshal/Null", json.Version), func(t *testing.T) { + var got Types + err := json.Unmarshal([]byte(`{ + "Bool": "null", + "Int": "null", + "PointerA": "null" + }`), &got) + switch { + case !reflect.DeepEqual(got, Types{}): + t.Fatalf("json.Unmarshal = %v, want %v", got, Types{}) + case json.Version == "v1" && err != nil: + t.Fatalf("json.Unmarshal error: %v", err) + case json.Version == "v2" && err == nil: + t.Fatal("json.Unmarshal error is nil, want non-nil") + } + }) + + t.Run(path.Join("Unmarshal/Bool", json.Version), func(t *testing.T) { + var got Types + want := map[string]Types{ + "v1": {Bool: true}, + "v2": {Bool: false}, + }[json.Version] + err := json.Unmarshal([]byte(`{"Bool": "true"}`), &got) + switch { + case !reflect.DeepEqual(got, want): + t.Fatalf("json.Unmarshal = %v, want %v", got, want) + case json.Version == "v1" && err != nil: + t.Fatalf("json.Unmarshal error: %v", err) + case json.Version == "v2" && err == nil: + t.Fatal("json.Unmarshal error is nil, want non-nil") + } + }) + + t.Run(path.Join("Unmarshal/Shallow", json.Version), func(t *testing.T) { + var got Types + want := Types{Int: 1, PointerB: addr(1)} + err := json.Unmarshal([]byte(`{ + "Int": "1", + "PointerB": "1" + }`), &got) + switch { + case !reflect.DeepEqual(got, want): + t.Fatalf("json.Unmarshal = %v, want %v", got, want) + case err != nil: + t.Fatalf("json.Unmarshal error: %v", err) + } + }) + + t.Run(path.Join("Unmarshal/Deep", json.Version), func(t *testing.T) { + var got Types + want := map[string]Types{ + "v1": { + Map: map[string]int{"Name": 0}, + Slice: []int{0}, + PointerC: addr(addr(0)), + }, + "v2": { + Map: map[string]int{"Name": 1}, + Struct: struct{ Field int }{1}, + Slice: []int{1}, + Array: [1]int{1}, + PointerC: addr(addr(1)), + }, + }[json.Version] + err := json.Unmarshal([]byte(`{ + "Map": {"Name":"1"}, + "Struct": {"Field":"1"}, + "Slice": ["1"], + "Array": ["1"], + "PointerC": "1" + }`), &got) + switch { + case !reflect.DeepEqual(got, want): + t.Fatalf("json.Unmarshal =\n%v, want\n%v", got, want) + case json.Version == "v1" && err == nil: + t.Fatal("json.Unmarshal error is nil, want non-nil") + case json.Version == "v2" && err != nil: + t.Fatalf("json.Unmarshal error: %v", err) + } + }) + } +} + +// In v1, nil slices and maps are marshaled as a JSON null. +// In v2, nil slices and maps are marshaled as an empty JSON object or array. +// +// Users of v2 can opt into the v1 behavior by setting +// the "format:emitnull" option in the `json` struct field tag: +// +// struct { +// S []string `json:",format:emitnull"` +// M map[string]string `json:",format:emitnull"` +// } +// +// JSON is a language-agnostic data interchange format. +// The fact that maps and slices are nil-able in Go is a semantic detail of the +// Go language. We should avoid leaking such details to the JSON representation. +// When JSON implementations leak language-specific details, +// it complicates transition to/from languages with different type systems. +// +// Furthermore, consider two related Go types: string and []byte. +// It's an asymmetric oddity of v1 that zero values of string and []byte marshal +// as an empty JSON string for the former, while the latter as a JSON null. +// The non-zero values of those types always marshal as JSON strings. +// +// Related issues: +// +// https://go.dev/issue/27589 +// https://go.dev/issue/37711 +func TestNilSlicesAndMaps(t *testing.T) { + type Composites struct { + B []byte // always encoded in v2 as a JSON string + S []string // always encoded in v2 as a JSON array + M map[string]string // always encoded in v2 as a JSON object + } + + for _, json := range jsonPackages { + t.Run(path.Join("Marshal", json.Version), func(t *testing.T) { + in := []Composites{ + {B: []byte(nil), S: []string(nil), M: map[string]string(nil)}, + {B: []byte{}, S: []string{}, M: map[string]string{}}, + } + want := map[string]string{ + "v1": `[{"B":null,"S":null,"M":null},{"B":"","S":[],"M":{}}]`, + "v2": `[{"B":"","S":[],"M":{}},{"B":"","S":[],"M":{}}]`, // v2 emits nil slices and maps as empty JSON objects and arrays + }[json.Version] + got, err := json.Marshal(in) + if err != nil { + t.Fatalf("json.Marshal error: %v", err) + } + if string(got) != want { + t.Fatalf("json.Marshal = %s, want %s", got, want) + } + }) + } +} + +// In v1, unmarshaling into a Go array permits JSON arrays with any length. +// In v2, unmarshaling into a Go array requires that the JSON array +// have the exact same number of elements as the Go array. +// +// Go arrays are often used because the exact length has significant meaning. +// Ignoring this detail seems like a mistake. Also, the v1 behavior leads to +// silent data loss when excess JSON array elements are discarded. +func TestArrays(t *testing.T) { + for _, json := range jsonPackages { + t.Run(path.Join("Unmarshal/TooFew", json.Version), func(t *testing.T) { + var got [2]int + err := json.Unmarshal([]byte(`[1]`), &got) + switch { + case got != [2]int{1, 0}: + t.Fatalf(`json.Unmarshal = %v, want [1 0]`, got) + case json.Version == "v1" && err != nil: + t.Fatalf("json.Unmarshal error: %v", err) + case json.Version == "v2" && err == nil: + t.Fatal("json.Unmarshal error is nil, want non-nil") + } + }) + } + + for _, json := range jsonPackages { + t.Run(path.Join("Unmarshal/TooMany", json.Version), func(t *testing.T) { + var got [2]int + err := json.Unmarshal([]byte(`[1,2,3]`), &got) + switch { + case got != [2]int{1, 2}: + t.Fatalf(`json.Unmarshal = %v, want [1 2]`, got) + case json.Version == "v1" && err != nil: + t.Fatalf("json.Unmarshal error: %v", err) + case json.Version == "v2" && err == nil: + t.Fatal("json.Unmarshal error is nil, want non-nil") + } + }) + } +} + +// In v1, byte arrays are treated as arrays of unsigned integers. +// In v2, byte arrays are treated as binary values (similar to []byte). +// This is to make the behavior of [N]byte and []byte more consistent. +// +// Users of v2 can opt into the v1 behavior by setting +// the "format:array" option in the `json` struct field tag: +// +// struct { +// B [32]byte `json:",format:array"` +// } +func TestByteArrays(t *testing.T) { + for _, json := range jsonPackages { + t.Run(path.Join("Marshal", json.Version), func(t *testing.T) { + in := [4]byte{1, 2, 3, 4} + got, err := json.Marshal(in) + if err != nil { + t.Fatalf("json.Marshal error: %v", err) + } + want := map[string]string{ + "v1": `[1,2,3,4]`, + "v2": `"AQIDBA=="`, + }[json.Version] + if string(got) != want { + t.Fatalf("json.Marshal = %s, want %s", got, want) + } + }) + } + + for _, json := range jsonPackages { + t.Run(path.Join("Unmarshal", json.Version), func(t *testing.T) { + in := map[string]string{ + "v1": `[1,2,3,4]`, + "v2": `"AQIDBA=="`, + }[json.Version] + var got [4]byte + err := json.Unmarshal([]byte(in), &got) + switch { + case err != nil: + t.Fatalf("json.Unmarshal error: %v", err) + case got != [4]byte{1, 2, 3, 4}: + t.Fatalf("json.Unmarshal = %v, want [1 2 3 4]", got) + } + }) + } +} + +// CallCheck implements json.{Marshaler,Unmarshaler} on a pointer receiver. +type CallCheck string + +// MarshalJSON always returns a JSON string with the literal "CALLED". +func (*CallCheck) MarshalJSON() ([]byte, error) { + return []byte(`"CALLED"`), nil +} + +// UnmarshalJSON always stores a string with the literal "CALLED". +func (v *CallCheck) UnmarshalJSON([]byte) error { + *v = `CALLED` + return nil +} + +// In v1, the implementation is inconsistent about whether it calls +// MarshalJSON and UnmarshalJSON methods declared on pointer receivers +// when it has an unaddressable value (per reflect.Value.CanAddr) on hand. +// When marshaling, it never boxes the value on the heap to make it addressable, +// while it sometimes boxes values (e.g., for map entries) when unmarshaling. +// +// In v2, the implementation always calls MarshalJSON and UnmarshalJSON methods +// by boxing the value on the heap if necessary. +// +// The v1 behavior is surprising at best and buggy at worst. +// Unfortunately, it cannot be changed without breaking existing usages. +// +// Related issues: +// +// https://go.dev/issue/27722 +// https://go.dev/issue/33993 +// https://go.dev/issue/42508 +func TestPointerReceiver(t *testing.T) { + type Values struct { + S []CallCheck + A [1]CallCheck + M map[string]CallCheck + V CallCheck + I any + } + + for _, json := range jsonPackages { + t.Run(path.Join("Marshal", json.Version), func(t *testing.T) { + var cc CallCheck + in := Values{ + S: []CallCheck{cc}, + A: [1]CallCheck{cc}, // MarshalJSON not called on v1 + M: map[string]CallCheck{"": cc}, // MarshalJSON not called on v1 + V: cc, // MarshalJSON not called on v1 + I: cc, // MarshalJSON not called on v1 + } + want := map[string]string{ + "v1": `{"S":["CALLED"],"A":[""],"M":{"":""},"V":"","I":""}`, + "v2": `{"S":["CALLED"],"A":["CALLED"],"M":{"":"CALLED"},"V":"CALLED","I":"CALLED"}`, + }[json.Version] + got, err := json.Marshal(in) + if err != nil { + t.Fatalf("json.Marshal error: %v", err) + } + if string(got) != want { + t.Fatalf("json.Marshal = %s, want %s", got, want) + } + }) + } + + for _, json := range jsonPackages { + t.Run(path.Join("Unmarshal", json.Version), func(t *testing.T) { + in := `{"S":[""],"A":[""],"M":{"":""},"V":"","I":""}` + called := CallCheck("CALLED") // resulting state if UnmarshalJSON is called + want := map[string]Values{ + "v1": { + S: []CallCheck{called}, + A: [1]CallCheck{called}, + M: map[string]CallCheck{"": called}, + V: called, + I: "", // UnmarshalJSON not called on v1; replaced with Go string + }, + "v2": { + S: []CallCheck{called}, + A: [1]CallCheck{called}, + M: map[string]CallCheck{"": called}, + V: called, + I: called, + }, + }[json.Version] + got := Values{ + A: [1]CallCheck{CallCheck("")}, + S: []CallCheck{CallCheck("")}, + M: map[string]CallCheck{"": CallCheck("")}, + V: CallCheck(""), + I: CallCheck(""), + } + if err := json.Unmarshal([]byte(in), &got); err != nil { + t.Fatalf("json.Unmarshal error: %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("json.Unmarshal = %v, want %v", got, want) + } + }) + } +} + +// In v1, maps are marshaled in a deterministic order. +// In v2, maps are marshaled in a non-deterministic order. +// +// The reason for the change is that v2 prioritizes performance and +// the guarantee that marshaling operates primarily in a streaming manner. +// +// The v2 API provides jsontext.Value.Canonicalize if stability is needed: +// +// (*jsontext.Value)(&b).Canonicalize() +// +// Related issue: +// +// https://go.dev/issue/7872 +// https://go.dev/issue/33714 +func TestMapDeterminism(t *testing.T) { + const iterations = 10 + in := map[int]int{0: 0, 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7, 8: 8, 9: 9} + + for _, json := range jsonPackages { + t.Run(path.Join("Marshal", json.Version), func(t *testing.T) { + outs := make(map[string]bool) + for range iterations { + b, err := json.Marshal(in) + if err != nil { + t.Fatalf("json.Marshal error: %v", err) + } + outs[string(b)] = true + } + switch { + case json.Version == "v1" && len(outs) != 1: + t.Fatalf("json.Marshal encoded to %d unique forms, expected 1", len(outs)) + case json.Version == "v2" && len(outs) == 1: + t.Logf("json.Marshal encoded to 1 unique form by chance; are you feeling lucky?") + } + }) + } +} + +// In v1, JSON string encoding escapes special characters related to HTML. +// In v2, JSON string encoding uses a normalized representation (per RFC 8785). +// +// Users of v2 can opt into the v1 behavior by setting EscapeForHTML and EscapeForJS. +// +// Escaping HTML-specific characters in a JSON library is a layering violation. +// It presumes that JSON is always used with HTML and ignores other +// similar classes of injection attacks (e.g., SQL injection). +// Users of JSON with HTML should either manually ensure that embedded JSON is +// properly escaped or be relying on a module like "github.com/google/safehtml" +// to handle safe interoperability of JSON and HTML. +func TestEscapeHTML(t *testing.T) { + for _, json := range jsonPackages { + t.Run(path.Join("Marshal", json.Version), func(t *testing.T) { + const in = `<script> console.log("Hello, world!"); </script>` + got, err := json.Marshal(in) + if err != nil { + t.Fatalf("json.Marshal error: %v", err) + } + want := map[string]string{ + "v1": `"\u003cscript\u003e console.log(\"Hello, world!\"); \u003c/script\u003e"`, + "v2": `"<script> console.log(\"Hello, world!\"); </script>"`, + }[json.Version] + if string(got) != want { + t.Fatalf("json.Marshal = %s, want %s", got, want) + } + }) + } +} + +// In v1, JSON serialization silently ignored invalid UTF-8 by +// replacing such bytes with the Unicode replacement character. +// In v2, JSON serialization reports an error if invalid UTF-8 is encountered. +// +// Users of v2 can opt into the v1 behavior by setting [AllowInvalidUTF8]. +// +// Silently allowing invalid UTF-8 causes data corruption that can be difficult +// to detect until it is too late. Once it has been discovered, strict UTF-8 +// behavior sometimes cannot be enabled since other logic may be depending +// on the current behavior due to Hyrum's Law. +// +// Tim Bray, the author of RFC 8259 recommends that implementations should +// go beyond RFC 8259 and instead target compliance with RFC 7493, +// which makes strict decisions about behavior left undefined in RFC 8259. +// In particular, RFC 7493 rejects the presence of invalid UTF-8. +// See https://www.tbray.org/ongoing/When/201x/2017/12/14/RFC-8259-STD-90 +func TestInvalidUTF8(t *testing.T) { + for _, json := range jsonPackages { + t.Run(path.Join("Marshal", json.Version), func(t *testing.T) { + got, err := json.Marshal("\xff") + switch { + case json.Version == "v1" && err != nil: + t.Fatalf("json.Marshal error: %v", err) + case json.Version == "v1" && string(got) != `"\ufffd"`: + t.Fatalf(`json.Marshal = %s, want "\ufffd"`, got) + case json.Version == "v2" && err == nil: + t.Fatal("json.Marshal error is nil, want non-nil") + } + }) + } + + for _, json := range jsonPackages { + t.Run(path.Join("Unmarshal", json.Version), func(t *testing.T) { + const in = "\"\xff\"" + var got string + err := json.Unmarshal([]byte(in), &got) + switch { + case json.Version == "v1" && err != nil: + t.Fatalf("json.Unmarshal error: %v", err) + case json.Version == "v1" && got != "\ufffd": + t.Fatalf(`json.Unmarshal = %q, want "\ufffd"`, got) + case json.Version == "v2" && err == nil: + t.Fatal("json.Unmarshal error is nil, want non-nil") + } + }) + } +} + +// In v1, duplicate JSON object names are permitted by default where +// they follow the inconsistent and difficult-to-explain merge semantics of v1. +// In v2, duplicate JSON object names are rejected by default where +// they follow the merge semantics of v2 based on RFC 7396. +// +// Users of v2 can opt into the v1 behavior by setting [AllowDuplicateNames]. +// +// Per RFC 8259, the handling of duplicate names is left as undefined behavior. +// Rejecting such inputs is within the realm of valid behavior. +// Tim Bray, the author of RFC 8259 recommends that implementations should +// go beyond RFC 8259 and instead target compliance with RFC 7493, +// which makes strict decisions about behavior left undefined in RFC 8259. +// In particular, RFC 7493 rejects the presence of duplicate object names. +// See https://www.tbray.org/ongoing/When/201x/2017/12/14/RFC-8259-STD-90 +// +// The lack of duplicate name rejection has correctness implications where +// roundtrip unmarshal/marshal do not result in semantically equivalent JSON. +// This is surprising behavior for users when they accidentally +// send JSON objects with duplicate names. +// +// The lack of duplicate name rejection may have security implications since it +// becomes difficult for a security tool to validate the semantic meaning of a +// JSON object since meaning is undefined in the presence of duplicate names. +// See https://labs.bishopfox.com/tech-blog/an-exploration-of-json-interoperability-vulnerabilities +// +// Related issue: +// +// https://go.dev/issue/48298 +func TestDuplicateNames(t *testing.T) { + for _, json := range jsonPackages { + t.Run(path.Join("Unmarshal", json.Version), func(t *testing.T) { + const in = `{"Name":1,"Name":2}` + var got struct{ Name int } + err := json.Unmarshal([]byte(in), &got) + switch { + case json.Version == "v1" && err != nil: + t.Fatalf("json.Unmarshal error: %v", err) + case json.Version == "v1" && got != struct{ Name int }{2}: + t.Fatalf(`json.Unmarshal = %v, want {2}`, got) + case json.Version == "v2" && err == nil: + t.Fatal("json.Unmarshal error is nil, want non-nil") + } + }) + } +} + +// In v1, unmarshaling a JSON null into a non-empty value was inconsistent +// in that sometimes it would be ignored and other times clear the value. +// In v2, unmarshaling a JSON null into a non-empty value would consistently +// always clear the value regardless of the value's type. +// +// The purpose of this change is to have consistent behavior with how JSON nulls +// are handled during Unmarshal. This semantic detail has no effect +// when Unmarshaling into a empty value. +// +// Related issues: +// +// https://go.dev/issue/22177 +// https://go.dev/issue/33835 +func TestMergeNull(t *testing.T) { + type Types struct { + Bool bool + String string + Bytes []byte + Int int + Map map[string]string + Struct struct{ Field string } + Slice []string + Array [1]string + Pointer *string + Interface any + } + + for _, json := range jsonPackages { + t.Run(path.Join("Unmarshal", json.Version), func(t *testing.T) { + // Start with a non-empty value where all fields are populated. + in := Types{ + Bool: true, + String: "old", + Bytes: []byte("old"), + Int: 1234, + Map: map[string]string{"old": "old"}, + Struct: struct{ Field string }{"old"}, + Slice: []string{"old"}, + Array: [1]string{"old"}, + Pointer: new(string), + Interface: "old", + } + + // Unmarshal a JSON null into every field. + if err := json.Unmarshal([]byte(`{ + "Bool": null, + "String": null, + "Bytes": null, + "Int": null, + "Map": null, + "Struct": null, + "Slice": null, + "Array": null, + "Pointer": null, + "Interface": null + }`), &in); err != nil { + t.Fatalf("json.Unmarshal error: %v", err) + } + + want := map[string]Types{ + "v1": { + Bool: true, + String: "old", + Int: 1234, + Struct: struct{ Field string }{"old"}, + Array: [1]string{"old"}, + }, + "v2": {}, // all fields are zeroed + }[json.Version] + if !reflect.DeepEqual(in, want) { + t.Fatalf("json.Unmarshal = %+v, want %+v", in, want) + } + }) + } +} + +// In v1, merge semantics are inconsistent and difficult to explain. +// In v2, merge semantics replaces the destination value for anything +// other than a JSON object, and recursively merges JSON objects. +// +// Merge semantics in v1 are inconsistent and difficult to explain +// largely because the behavior came about organically, rather than +// having a principled approach to how the semantics should operate. +// In v2, merging follows behavior based on RFC 7396. +// +// Related issues: +// +// https://go.dev/issue/21092 +// https://go.dev/issue/26946 +// https://go.dev/issue/27172 +// https://go.dev/issue/30701 +// https://go.dev/issue/31924 +// https://go.dev/issue/43664 +func TestMergeComposite(t *testing.T) { + type Tuple struct{ Old, New bool } + type Composites struct { + Slice []Tuple + Array [1]Tuple + Map map[string]Tuple + MapPointer map[string]*Tuple + Struct struct{ Tuple Tuple } + StructPointer *struct{ Tuple Tuple } + Interface any + InterfacePointer any + } + + for _, json := range jsonPackages { + t.Run(path.Join("Unmarshal", json.Version), func(t *testing.T) { + // Start with a non-empty value where all fields are populated. + in := Composites{ + Slice: []Tuple{{Old: true}, {Old: true}}[:1], + Array: [1]Tuple{{Old: true}}, + Map: map[string]Tuple{"Tuple": {Old: true}}, + MapPointer: map[string]*Tuple{"Tuple": {Old: true}}, + Struct: struct{ Tuple Tuple }{Tuple{Old: true}}, + StructPointer: &struct{ Tuple Tuple }{Tuple{Old: true}}, + Interface: Tuple{Old: true}, + InterfacePointer: &Tuple{Old: true}, + } + + // Unmarshal into every pre-populated field. + if err := json.Unmarshal([]byte(`{ + "Slice": [{"New":true}, {"New":true}], + "Array": [{"New":true}], + "Map": {"Tuple": {"New":true}}, + "MapPointer": {"Tuple": {"New":true}}, + "Struct": {"Tuple": {"New":true}}, + "StructPointer": {"Tuple": {"New":true}}, + "Interface": {"New":true}, + "InterfacePointer": {"New":true} + }`), &in); err != nil { + t.Fatalf("json.Unmarshal error: %v", err) + } + + merged := Tuple{Old: true, New: true} + replaced := Tuple{Old: false, New: true} + want := map[string]Composites{ + "v1": { + Slice: []Tuple{merged, merged}, // merged + Array: [1]Tuple{merged}, // merged + Map: map[string]Tuple{"Tuple": replaced}, // replaced + MapPointer: map[string]*Tuple{"Tuple": &replaced}, // replaced + Struct: struct{ Tuple Tuple }{merged}, // merged (same as v2) + StructPointer: &struct{ Tuple Tuple }{merged}, // merged (same as v2) + Interface: map[string]any{"New": true}, // replaced + InterfacePointer: &merged, // merged (same as v2) + }, + "v2": { + Slice: []Tuple{replaced, replaced}, // replaced + Array: [1]Tuple{replaced}, // replaced + Map: map[string]Tuple{"Tuple": merged}, // merged + MapPointer: map[string]*Tuple{"Tuple": &merged}, // merged + Struct: struct{ Tuple Tuple }{merged}, // merged (same as v1) + StructPointer: &struct{ Tuple Tuple }{merged}, // merged (same as v1) + Interface: merged, // merged + InterfacePointer: &merged, // merged (same as v1) + }, + }[json.Version] + if !reflect.DeepEqual(in, want) { + t.Fatalf("json.Unmarshal = %+v, want %+v", in, want) + } + }) + } +} + +// In v1, there was no special support for time.Duration, +// which resulted in that type simply being treated as a signed integer. +// In v2, there is now first-class support for time.Duration, where the type is +// formatted and parsed using time.Duration.String and time.ParseDuration. +// +// Users of v2 can opt into the v1 behavior by setting +// the "format:nano" option in the `json` struct field tag: +// +// struct { +// Duration time.Duration `json:",format:nano"` +// } +// +// Related issue: +// +// https://go.dev/issue/10275 +func TestTimeDurations(t *testing.T) { + for _, json := range jsonPackages { + t.Run(path.Join("Marshal", json.Version), func(t *testing.T) { + got, err := json.Marshal(time.Minute) + switch { + case err != nil: + t.Fatalf("json.Marshal error: %v", err) + case json.Version == "v1" && string(got) != "60000000000": + t.Fatalf("json.Marshal = %s, want 60000000000", got) + case json.Version == "v2" && string(got) != `"1m0s"`: + t.Fatalf(`json.Marshal = %s, want "1m0s"`, got) + } + }) + } + + for _, json := range jsonPackages { + t.Run(path.Join("Unmarshal", json.Version), func(t *testing.T) { + in := map[string]string{ + "v1": "60000000000", + "v2": `"1m0s"`, + }[json.Version] + var got time.Duration + err := json.Unmarshal([]byte(in), &got) + switch { + case err != nil: + t.Fatalf("json.Unmarshal error: %v", err) + case got != time.Minute: + t.Fatalf("json.Unmarshal = %v, want 1m0s", got) + } + }) + } +} + +// In v1, non-empty structs without any JSON serializable fields are permitted. +// In v2, non-empty structs without any JSON serializable fields are rejected. +// +// The purpose of this change is to avoid a common pitfall for new users +// where they expect JSON serialization to handle unexported fields. +// However, this does not work since Go reflection does not +// provide the package the ability to mutate such fields. +// Rejecting unserializable structs in v2 is intended to be a clear signal +// that the type is not supposed to be serialized. +func TestEmptyStructs(t *testing.T) { + never := func(string) bool { return false } + onlyV2 := func(v string) bool { return v == "v2" } + values := []struct { + in any + wantError func(string) bool + }{ + // It is okay to marshal a truly empty struct in v1 and v2. + {in: addr(struct{}{}), wantError: never}, + // In v1, a non-empty struct without exported fields + // is equivalent to an empty struct, but is rejected in v2. + // Note that errors.errorString type has only unexported fields. + {in: errors.New("error"), wantError: onlyV2}, + // A mix of exported and unexported fields is permitted. + {in: addr(struct{ Exported, unexported int }{}), wantError: never}, + } + + for _, json := range jsonPackages { + t.Run("Marshal", func(t *testing.T) { + for _, value := range values { + wantError := value.wantError(json.Version) + _, err := json.Marshal(value.in) + switch { + case (err == nil) && wantError: + t.Fatalf("json.Marshal error is nil, want non-nil") + case (err != nil) && !wantError: + t.Fatalf("json.Marshal error: %v", err) + } + } + }) + } + + for _, json := range jsonPackages { + t.Run("Unmarshal", func(t *testing.T) { + for _, value := range values { + wantError := value.wantError(json.Version) + out := reflect.New(reflect.TypeOf(value.in).Elem()).Interface() + err := json.Unmarshal([]byte("{}"), out) + switch { + case (err == nil) && wantError: + t.Fatalf("json.Unmarshal error is nil, want non-nil") + case (err != nil) && !wantError: + t.Fatalf("json.Unmarshal error: %v", err) + } + } + }) + } +} |
