]> Cypherpunks repositories - gostls13.git/commitdiff
encoding/json: decompose legacy options
authorJoe Tsai <joetsai@digital-static.net>
Tue, 1 Jul 2025 22:39:49 +0000 (15:39 -0700)
committerGopher Robot <gobot@golang.org>
Mon, 14 Jul 2025 15:32:08 +0000 (08:32 -0700)
WARNING: This commit contains breaking changes
for those already using GOEXPERIMENT=jsonv2.

This decomposes FormatBytesWithLegacySemantics as:
* FormatBytesWithLegacySemantics
* FormatByteArrayAsArray
* ParseBytesWithLooseRFC4648

This decomposes FormatTimeWithLegacySemantics as:
* FormatDurationAsNano
* ParseTimeWithLooseRFC3339

In particular, it splits out specific behaviors from the option
that may need to be specified on a finer-grain level.

FormatByteArrayAsArray and FormatDurationAsNano are targeted
to just the default representation of a [N]byte or time.Duration type.
Both of these are not necessary if the `format` tag is explicitly specified.
However, we want to isolate their behavior from other behaviors that used to
be part of FormatBytesWithLegacySemantics and FormatTimeWithLegacySemantics.

ParseBytesWithLooseRFC4648 and ParseTimeWithLooseRFC3339 are targeted
to just historically buggy parsing according to the relevant RFCs,
which may need to be enabled by some services for backwards compatibility.

While FormatTimeWithLegacySemantics is deleted, we still need
FormatBytesWithLegacySemantics to configure highly esoteric
aspects of how v1 used to handle byte slices.

We rename OmitEmptyWithLegacyDefinition as OmitEmptyWithLegacySemantics
to be consistent with other options with the WithLegacySemantics suffix.

Updates #71497

Change-Id: Ic660515fb086fe3af237135f195736de99c2bd33
Reviewed-on: https://go-review.googlesource.com/c/go/+/685395
Auto-Submit: Joseph Tsai <joetsai@digital-static.net>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Cherry Mui <cherryyz@google.com>
Reviewed-by: Damien Neil <dneil@google.com>
Reviewed-by: Johan Brandhorst-Satzkorn <johan.brandhorst@gmail.com>
src/encoding/json/internal/jsonflags/flags.go
src/encoding/json/v2/arshal_default.go
src/encoding/json/v2/arshal_test.go
src/encoding/json/v2/arshal_time.go
src/encoding/json/v2_options.go

index 1e8c2842d479cc260abed1332b62a2ea72be0121..da13adff4d6d75e310d2ca980295dc5a6a39eefd 100644 (file)
@@ -58,11 +58,14 @@ const (
                FormatNilSliceAsNull |
                MatchCaseInsensitiveNames |
                CallMethodsWithLegacySemantics |
+               FormatByteArrayAsArray |
                FormatBytesWithLegacySemantics |
-               FormatTimeWithLegacySemantics |
+               FormatDurationAsNano |
                MatchCaseSensitiveDelimiter |
                MergeWithLegacySemantics |
-               OmitEmptyWithLegacyDefinition |
+               OmitEmptyWithLegacySemantics |
+               ParseBytesWithLooseRFC4648 |
+               ParseTimeWithLooseRFC3339 |
                ReportErrorsWithLegacySemantics |
                StringifyWithLegacySemantics |
                UnmarshalArrayFromAnyLength
@@ -130,11 +133,14 @@ const (
        _ Bools = (maxArshalV2Flag >> 1) << iota
 
        CallMethodsWithLegacySemantics  // marshal or unmarshal
+       FormatByteArrayAsArray          // marshal or unmarshal
        FormatBytesWithLegacySemantics  // marshal or unmarshal
-       FormatTimeWithLegacySemantics   // marshal or unmarshal
+       FormatDurationAsNano            // marshal or unmarshal
        MatchCaseSensitiveDelimiter     // marshal or unmarshal
        MergeWithLegacySemantics        // unmarshal
-       OmitEmptyWithLegacyDefinition   // marshal
+       OmitEmptyWithLegacySemantics    // marshal
+       ParseBytesWithLooseRFC4648      // unmarshal
+       ParseTimeWithLooseRFC3339       // unmarshal
        ReportErrorsWithLegacySemantics // marshal or unmarshal
        StringifyWithLegacySemantics    // marshal or unmarshal
        StringifyBoolsAndStrings        // marshal or unmarshal; for internal use by jsonv2.makeStructArshaler
@@ -144,6 +150,12 @@ const (
        maxArshalV1Flag
 )
 
+// bitsUsed is the number of bits used in the 64-bit boolean flags
+const bitsUsed = 42
+
+// Static compile check that bitsUsed and maxArshalV1Flag are in sync.
+const _ = uint64((1<<bitsUsed)-maxArshalV1Flag) + uint64(maxArshalV1Flag-(1<<bitsUsed))
+
 // Flags is a set of boolean flags.
 // If the presence bit is zero, then the value bit must also be zero.
 // The least-significant bit of both fields is always zero.
index 5ca51c66353cbca484225e3b19784bc293fad18a..0b30ac4fb7d600ddb21863159a8c8faffa816852 100644 (file)
@@ -329,8 +329,9 @@ func makeBytesArshaler(t reflect.Type, fncs *arshaler) *arshaler {
                        default:
                                return newInvalidFormatError(enc, t, mo)
                        }
-               } else if mo.Flags.Get(jsonflags.FormatBytesWithLegacySemantics) &&
-                       (va.Kind() == reflect.Array || hasMarshaler) {
+               } else if mo.Flags.Get(jsonflags.FormatByteArrayAsArray) && va.Kind() == reflect.Array {
+                       return marshalArray(enc, va, mo)
+               } else if mo.Flags.Get(jsonflags.FormatBytesWithLegacySemantics) && hasMarshaler {
                        return marshalArray(enc, va, mo)
                }
                if mo.Flags.Get(jsonflags.FormatNilSliceAsNull) && va.Kind() == reflect.Slice && va.IsNil() {
@@ -366,8 +367,9 @@ func makeBytesArshaler(t reflect.Type, fncs *arshaler) *arshaler {
                        default:
                                return newInvalidFormatError(dec, t, uo)
                        }
-               } else if uo.Flags.Get(jsonflags.FormatBytesWithLegacySemantics) &&
-                       (va.Kind() == reflect.Array || dec.PeekKind() == '[') {
+               } else if uo.Flags.Get(jsonflags.FormatByteArrayAsArray) && va.Kind() == reflect.Array {
+                       return unmarshalArray(dec, va, uo)
+               } else if uo.Flags.Get(jsonflags.FormatBytesWithLegacySemantics) && dec.PeekKind() == '[' {
                        return unmarshalArray(dec, va, uo)
                }
                var flags jsonwire.ValueFlags
@@ -395,7 +397,7 @@ func makeBytesArshaler(t reflect.Type, fncs *arshaler) *arshaler {
                        if err != nil {
                                return newUnmarshalErrorAfter(dec, t, err)
                        }
-                       if len(val) != encodedLen(len(b)) && !uo.Flags.Get(jsonflags.FormatBytesWithLegacySemantics) {
+                       if len(val) != encodedLen(len(b)) && !uo.Flags.Get(jsonflags.ParseBytesWithLooseRFC4648) {
                                // TODO(https://go.dev/issue/53845): RFC 4648, section 3.3,
                                // specifies that non-alphabet characters must be rejected.
                                // Unfortunately, the "base32" and "base64" packages allow
@@ -1065,7 +1067,7 @@ func makeStructArshaler(t reflect.Type) *arshaler {
                        }
 
                        // Check for the legacy definition of omitempty.
-                       if f.omitempty && mo.Flags.Get(jsonflags.OmitEmptyWithLegacyDefinition) && isLegacyEmpty(v) {
+                       if f.omitempty && mo.Flags.Get(jsonflags.OmitEmptyWithLegacySemantics) && isLegacyEmpty(v) {
                                continue
                        }
 
@@ -1080,7 +1082,7 @@ func makeStructArshaler(t reflect.Type) *arshaler {
                        // OmitEmpty skips the field if the marshaled JSON value is empty,
                        // which we can know up front if there are no custom marshalers,
                        // otherwise we must marshal the value and unwrite it if empty.
-                       if f.omitempty && !mo.Flags.Get(jsonflags.OmitEmptyWithLegacyDefinition) &&
+                       if f.omitempty && !mo.Flags.Get(jsonflags.OmitEmptyWithLegacySemantics) &&
                                !nonDefault && f.isEmpty != nil && f.isEmpty(v) {
                                continue // fast path for omitempty
                        }
@@ -1145,7 +1147,7 @@ func makeStructArshaler(t reflect.Type) *arshaler {
                        }
 
                        // Try unwriting the member if empty (slow path for omitempty).
-                       if f.omitempty && !mo.Flags.Get(jsonflags.OmitEmptyWithLegacyDefinition) {
+                       if f.omitempty && !mo.Flags.Get(jsonflags.OmitEmptyWithLegacySemantics) {
                                var prevName *string
                                if prevIdx >= 0 {
                                        prevName = &fields.flattened[prevIdx].name
index 879a2f3e0d7db59ac6e48e0b623f6f0e0f6ea198..88887e1b0086a85eb43f49ef9f26ef62d28ef387 100644 (file)
@@ -1924,12 +1924,12 @@ func TestMarshal(t *testing.T) {
 }`,
        }, {
                name: jsontest.Name("Structs/OmitEmpty/Legacy/Zero"),
-               opts: []Options{jsonflags.OmitEmptyWithLegacyDefinition | 1},
+               opts: []Options{jsonflags.OmitEmptyWithLegacySemantics | 1},
                in:   structOmitEmptyAll{},
                want: `{}`,
        }, {
                name: jsontest.Name("Structs/OmitEmpty/Legacy/NonEmpty"),
-               opts: []Options{jsontext.Multiline(true), jsonflags.OmitEmptyWithLegacyDefinition | 1},
+               opts: []Options{jsontext.Multiline(true), jsonflags.OmitEmptyWithLegacySemantics | 1},
                in: structOmitEmptyAll{
                        Bool:                  true,
                        PointerBool:           addr(true),
@@ -2144,7 +2144,7 @@ func TestMarshal(t *testing.T) {
        "Default": "AQIDBA=="
 }`}, {
                name: jsontest.Name("Structs/Format/ArrayBytes/Legacy"),
-               opts: []Options{jsontext.Multiline(true), jsonflags.FormatBytesWithLegacySemantics | 1},
+               opts: []Options{jsontext.Multiline(true), jsonflags.FormatByteArrayAsArray | jsonflags.FormatBytesWithLegacySemantics | 1},
                in: structFormatArrayBytes{
                        Base16:    [4]byte{1, 2, 3, 4},
                        Base32:    [4]byte{1, 2, 3, 4},
@@ -4394,7 +4394,7 @@ func TestMarshal(t *testing.T) {
        }, {
                /* TODO(https://go.dev/issue/71631): Re-enable this test case.
                name: jsontest.Name("Duration/Format/Legacy"),
-               opts: []Options{jsonflags.FormatTimeWithLegacySemantics | 1},
+               opts: []Options{jsonflags.FormatDurationAsNano | 1},
                in: structDurationFormat{
                        D1: 12*time.Hour + 34*time.Minute + 56*time.Second + 78*time.Millisecond + 90*time.Microsecond + 12*time.Nanosecond,
                        D2: 12*time.Hour + 34*time.Minute + 56*time.Second + 78*time.Millisecond + 90*time.Microsecond + 12*time.Nanosecond,
@@ -4407,7 +4407,7 @@ func TestMarshal(t *testing.T) {
                want: `{"1s":""}`,
                }, { */
                name: jsontest.Name("Duration/MapKey/Legacy"),
-               opts: []Options{jsonflags.FormatTimeWithLegacySemantics | 1},
+               opts: []Options{jsonflags.FormatDurationAsNano | 1},
                in:   map[time.Duration]string{time.Second: ""},
                want: `{"1000000000":""}`,
        }, {
@@ -6399,7 +6399,7 @@ func TestUnmarshal(t *testing.T) {
                wantErr: EU(errors.New("illegal character '\\r' at offset 3")).withPos(`{"Base64": `, "/Base64").withType('"', T[[]byte]()),
        }, {
                name:  jsontest.Name("Structs/Format/Bytes/Base64/NonAlphabet/Ignored"),
-               opts:  []Options{jsonflags.FormatBytesWithLegacySemantics | 1},
+               opts:  []Options{jsonflags.ParseBytesWithLooseRFC4648 | 1},
                inBuf: `{"Base64": "aa=\r\n="}`,
                inVal: new(structFormatBytes),
                want:  &structFormatBytes{Base64: []byte{105}},
@@ -8885,7 +8885,7 @@ func TestUnmarshal(t *testing.T) {
                /* TODO(https://go.dev/issue/71631): Re-enable this test case.
                name:  jsontest.Name("Duration/Format/Legacy"),
                inBuf: `{"D1":45296078090012,"D2":"12h34m56.078090012s"}`,
-               opts:  []Options{jsonflags.FormatTimeWithLegacySemantics | 1},
+               opts:  []Options{jsonflags.FormatDurationAsNano | 1},
                inVal: new(structDurationFormat),
                want: addr(structDurationFormat{
                        D1: 12*time.Hour + 34*time.Minute + 56*time.Second + 78*time.Millisecond + 90*time.Microsecond + 12*time.Nanosecond,
@@ -8899,7 +8899,7 @@ func TestUnmarshal(t *testing.T) {
                want:  addr(map[time.Duration]string{time.Second: ""}),
                }, { */
                name:  jsontest.Name("Duration/MapKey/Legacy"),
-               opts:  []Options{jsonflags.FormatTimeWithLegacySemantics | 1},
+               opts:  []Options{jsonflags.FormatDurationAsNano | 1},
                inBuf: `{"1000000000":""}`,
                inVal: new(map[time.Duration]string),
                want:  addr(map[time.Duration]string{time.Second: ""}),
index fefa50ff5f0d7511c9a47ae5ed035d21bb97494a..06fed03e05fbed1cbf5588351746b0d52e8df95a 100644 (file)
@@ -50,7 +50,7 @@ func makeTimeArshaler(fncs *arshaler, t reflect.Type) *arshaler {
                                if !m.initFormat(mo.Format) {
                                        return newInvalidFormatError(enc, t, mo)
                                }
-                       } else if mo.Flags.Get(jsonflags.FormatTimeWithLegacySemantics) {
+                       } else if mo.Flags.Get(jsonflags.FormatDurationAsNano) {
                                return marshalNano(enc, va, mo)
                        } else {
                                // TODO(https://go.dev/issue/71631): Decide on default duration representation.
@@ -76,7 +76,7 @@ func makeTimeArshaler(fncs *arshaler, t reflect.Type) *arshaler {
                                if !u.initFormat(uo.Format) {
                                        return newInvalidFormatError(dec, t, uo)
                                }
-                       } else if uo.Flags.Get(jsonflags.FormatTimeWithLegacySemantics) {
+                       } else if uo.Flags.Get(jsonflags.FormatDurationAsNano) {
                                return unmarshalNano(dec, va, uo)
                        } else {
                                // TODO(https://go.dev/issue/71631): Decide on default duration representation.
@@ -150,7 +150,7 @@ func makeTimeArshaler(fncs *arshaler, t reflect.Type) *arshaler {
                                if !u.initFormat(uo.Format) {
                                        return newInvalidFormatError(dec, t, uo)
                                }
-                       } else if uo.Flags.Get(jsonflags.FormatTimeWithLegacySemantics) {
+                       } else if uo.Flags.Get(jsonflags.ParseTimeWithLooseRFC3339) {
                                u.looseRFC3339 = true
                        }
 
index 66bd01eb3c262ce8310d44a745c38159e9aecfdb..4dea88ad7edaf60bd33591d00ec4287cb0219f0c 100644 (file)
@@ -36,7 +36,7 @@
 //     any empty array, slice, map, or string. In contrast, v2 redefines
 //     `omitempty` to omit a field if it encodes as an "empty" JSON value,
 //     which is defined as a JSON null, or an empty JSON string, object, or array.
-//     The [OmitEmptyWithLegacyDefinition] option controls this behavior difference.
+//     The [OmitEmptyWithLegacySemantics] option controls this behavior difference.
 //     Note that `omitempty` behaves identically in both v1 and v2 for a
 //     Go array, slice, map, or string (assuming no user-defined MarshalJSON method
 //     overrides the default representation). Existing usages of `omitempty` on a
@@ -66,7 +66,7 @@
 //
 //   - In v1, a Go byte array is represented as a JSON array of JSON numbers.
 //     In contrast, in v2 a Go byte array is represented as a Base64-encoded JSON string.
-//     The [FormatBytesWithLegacySemantics] option controls this behavior difference.
+//     The [FormatByteArrayAsArray] option controls this behavior difference.
 //     To explicitly specify a Go struct field to use a particular representation,
 //     either the `format:array` or `format:base64` field option can be specified.
 //     Field-specified options take precedence over caller-specified options.
 //
 //   - In v1, a [time.Duration] is represented as a JSON number containing
 //     the decimal number of nanoseconds. In contrast, in v2 a [time.Duration]
-//     is represented as a JSON string containing the formatted duration
-//     (e.g., "1h2m3.456s") according to [time.Duration.String].
-//     The [FormatTimeWithLegacySemantics] option controls this behavior difference.
+//     has no default representation and results in a runtime error.
+//     The [FormatDurationAsNano] option controls this behavior difference.
 //     To explicitly specify a Go struct field to use a particular representation,
 //     either the `format:nano` or `format:units` field option can be specified.
 //     Field-specified options take precedence over caller-specified options.
 // but the v1 package will forever remain supported.
 package json
 
+// TODO(https://go.dev/issue/71631): Update the "Migrating to v2" documentation
+// with default v2 behavior for [time.Duration].
+
 import (
        "encoding"
 
@@ -204,11 +206,14 @@ type Options = jsonopts.Options
 // It is equivalent to the following boolean options being set to true:
 //
 //   - [CallMethodsWithLegacySemantics]
+//   - [FormatByteArrayAsArray]
 //   - [FormatBytesWithLegacySemantics]
-//   - [FormatTimeWithLegacySemantics]
+//   - [FormatDurationAsNano]
 //   - [MatchCaseSensitiveDelimiter]
 //   - [MergeWithLegacySemantics]
-//   - [OmitEmptyWithLegacyDefinition]
+//   - [OmitEmptyWithLegacySemantics]
+//   - [ParseBytesWithLooseRFC4648]
+//   - [ParseTimeWithLooseRFC3339]
 //   - [ReportErrorsWithLegacySemantics]
 //   - [StringifyWithLegacySemantics]
 //   - [UnmarshalArrayFromAnyLength]
@@ -278,13 +283,25 @@ func CallMethodsWithLegacySemantics(v bool) Options {
        }
 }
 
+// FormatByteArrayAsArray specifies that a Go [N]byte is
+// formatted as as a normal Go array in contrast to the v2 default of
+// formatting [N]byte as using binary data encoding (RFC 4648).
+// If a struct field has a `format` tag option,
+// then the specified formatting takes precedence.
+//
+// This affects either marshaling or unmarshaling.
+// The v1 default is true.
+func FormatByteArrayAsArray(v bool) Options {
+       if v {
+               return jsonflags.FormatByteArrayAsArray | 1
+       } else {
+               return jsonflags.FormatByteArrayAsArray | 0
+       }
+}
+
 // FormatBytesWithLegacySemantics specifies that handling of
 // []~byte and [N]~byte types follow legacy semantics:
 //
-//   - A Go [N]~byte is always treated as as a normal Go array
-//     in contrast to the v2 default of treating [N]byte as
-//     using some form of binary data encoding (RFC 4648).
-//
 //   - A Go []~byte is to be treated as using some form of
 //     binary data encoding (RFC 4648) in contrast to the v2 default
 //     of only treating []byte as such. In particular, v2 does not
@@ -299,12 +316,6 @@ func CallMethodsWithLegacySemantics(v bool) Options {
 //     In contrast, the v2 default is to report an error unmarshaling
 //     a JSON array when expecting some form of binary data encoding.
 //
-//   - When unmarshaling, '\r' and '\n' characters are ignored
-//     within the encoded "base32" and "base64" data.
-//     In contrast, the v2 default is to report an error in order to be
-//     strictly compliant with RFC 4648, section 3.3,
-//     which specifies that non-alphabet characters must be rejected.
-//
 // This affects either marshaling or unmarshaling.
 // The v1 default is true.
 func FormatBytesWithLegacySemantics(v bool) Options {
@@ -315,29 +326,20 @@ func FormatBytesWithLegacySemantics(v bool) Options {
        }
 }
 
-// FormatTimeWithLegacySemantics specifies that [time] types are formatted
-// with legacy semantics:
-//
-//   - When marshaling or unmarshaling, a [time.Duration] is formatted as
-//     a JSON number representing the number of nanoseconds.
-//     In contrast, the default v2 behavior uses a JSON string
-//     with the duration formatted with [time.Duration.String].
-//     If a duration field has a `format` tag option,
-//     then the specified formatting takes precedence.
-//
-//   - When unmarshaling, a [time.Time] follows loose adherence to RFC 3339.
-//     In particular, it permits historically incorrect representations,
-//     allowing for deviations in hour format, sub-second separator,
-//     and timezone representation. In contrast, the default v2 behavior
-//     is to strictly comply with the grammar specified in RFC 3339.
+// FormatDurationAsNano specifies that a [time.Duration] is
+// formatted as a JSON number representing the number of nanoseconds
+// in contrast to the v2 default of reporting an error.
+// If a duration field has a `format` tag option,
+// then the specified formatting takes precedence.
 //
 // This affects either marshaling or unmarshaling.
 // The v1 default is true.
-func FormatTimeWithLegacySemantics(v bool) Options {
+func FormatDurationAsNano(v bool) Options {
+       // TODO(https://go.dev/issue/71631): Update documentation with v2 behavior.
        if v {
-               return jsonflags.FormatTimeWithLegacySemantics | 1
+               return jsonflags.FormatDurationAsNano | 1
        } else {
-               return jsonflags.FormatTimeWithLegacySemantics | 0
+               return jsonflags.FormatDurationAsNano | 0
        }
 }
 
@@ -386,7 +388,7 @@ func MergeWithLegacySemantics(v bool) Options {
        }
 }
 
-// OmitEmptyWithLegacyDefinition specifies that the `omitempty` tag option
+// OmitEmptyWithLegacySemantics specifies that the `omitempty` tag option
 // follows a definition of empty where a field is omitted if the Go value is
 // false, 0, a nil pointer, a nil interface value,
 // or any empty array, slice, map, or string.
@@ -400,11 +402,45 @@ func MergeWithLegacySemantics(v bool) Options {
 //
 // This only affects marshaling and is ignored when unmarshaling.
 // The v1 default is true.
-func OmitEmptyWithLegacyDefinition(v bool) Options {
+func OmitEmptyWithLegacySemantics(v bool) Options {
+       if v {
+               return jsonflags.OmitEmptyWithLegacySemantics | 1
+       } else {
+               return jsonflags.OmitEmptyWithLegacySemantics | 0
+       }
+}
+
+// ParseBytesWithLooseRFC4648 specifies that when parsing
+// binary data encoded as "base32" or "base64",
+// to ignore the presence of '\r' and '\n' characters.
+// In contrast, the v2 default is to report an error in order to be
+// strictly compliant with RFC 4648, section 3.3,
+// which specifies that non-alphabet characters must be rejected.
+//
+// This only affects unmarshaling and is ignored when marshaling.
+// The v1 default is true.
+func ParseBytesWithLooseRFC4648(v bool) Options {
+       if v {
+               return jsonflags.ParseBytesWithLooseRFC4648 | 1
+       } else {
+               return jsonflags.ParseBytesWithLooseRFC4648 | 0
+       }
+}
+
+// ParseTimeWithLooseRFC3339 specifies that a [time.Time]
+// parses according to loose adherence to RFC 3339.
+// In particular, it permits historically incorrect representations,
+// allowing for deviations in hour format, sub-second separator,
+// and timezone representation. In contrast, the default v2 behavior
+// is to strictly comply with the grammar specified in RFC 3339.
+//
+// This only affects unmarshaling and is ignored when marshaling.
+// The v1 default is true.
+func ParseTimeWithLooseRFC3339(v bool) Options {
        if v {
-               return jsonflags.OmitEmptyWithLegacyDefinition | 1
+               return jsonflags.ParseTimeWithLooseRFC3339 | 1
        } else {
-               return jsonflags.OmitEmptyWithLegacyDefinition | 0
+               return jsonflags.ParseTimeWithLooseRFC3339 | 0
        }
 }