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/example_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/example_test.go')
| -rw-r--r-- | src/encoding/json/v2/example_test.go | 692 |
1 files changed, 692 insertions, 0 deletions
diff --git a/src/encoding/json/v2/example_test.go b/src/encoding/json/v2/example_test.go new file mode 100644 index 0000000000..fe40bff964 --- /dev/null +++ b/src/encoding/json/v2/example_test.go @@ -0,0 +1,692 @@ +// Copyright 2022 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 ( + "bytes" + "errors" + "fmt" + "log" + "math" + "net/http" + "net/netip" + "os" + "reflect" + "strconv" + "strings" + "sync/atomic" + "time" + + "encoding/json/jsontext" + "encoding/json/v2" +) + +// If a type implements [encoding.TextMarshaler] and/or [encoding.TextUnmarshaler], +// then the MarshalText and UnmarshalText methods are used to encode/decode +// the value to/from a JSON string. +func Example_textMarshal() { + // Round-trip marshal and unmarshal a hostname map where the netip.Addr type + // implements both encoding.TextMarshaler and encoding.TextUnmarshaler. + want := map[netip.Addr]string{ + netip.MustParseAddr("192.168.0.100"): "carbonite", + netip.MustParseAddr("192.168.0.101"): "obsidian", + netip.MustParseAddr("192.168.0.102"): "diamond", + } + b, err := json.Marshal(&want, json.Deterministic(true)) + if err != nil { + log.Fatal(err) + } + var got map[netip.Addr]string + err = json.Unmarshal(b, &got) + if err != nil { + log.Fatal(err) + } + + // Sanity check. + if !reflect.DeepEqual(got, want) { + log.Fatalf("roundtrip mismatch: got %v, want %v", got, want) + } + + // Print the serialized JSON object. + (*jsontext.Value)(&b).Indent() // indent for readability + fmt.Println(string(b)) + + // Output: + // { + // "192.168.0.100": "carbonite", + // "192.168.0.101": "obsidian", + // "192.168.0.102": "diamond" + // } +} + +// By default, JSON object names for Go struct fields are derived from +// the Go field name, but may be specified in the `json` tag. +// Due to JSON's heritage in JavaScript, the most common naming convention +// used for JSON object names is camelCase. +func Example_fieldNames() { + var value struct { + // This field is explicitly ignored with the special "-" name. + Ignored any `json:"-"` + // No JSON name is not provided, so the Go field name is used. + GoName any + // A JSON name is provided without any special characters. + JSONName any `json:"jsonName"` + // No JSON name is not provided, so the Go field name is used. + Option any `json:",case:ignore"` + // An empty JSON name specified using an single-quoted string literal. + Empty any `json:"''"` + // A dash JSON name specified using an single-quoted string literal. + Dash any `json:"'-'"` + // A comma JSON name specified using an single-quoted string literal. + Comma any `json:"','"` + // JSON name with quotes specified using a single-quoted string literal. + Quote any `json:"'\"\\''"` + // An unexported field is always ignored. + unexported any + } + + b, err := json.Marshal(value) + if err != nil { + log.Fatal(err) + } + (*jsontext.Value)(&b).Indent() // indent for readability + fmt.Println(string(b)) + + // Output: + // { + // "GoName": null, + // "jsonName": null, + // "Option": null, + // "": null, + // "-": null, + // ",": null, + // "\"'": null + // } +} + +// Unmarshal matches JSON object names with Go struct fields using +// a case-sensitive match, but can be configured to use a case-insensitive +// match with the "case:ignore" option. This permits unmarshaling from inputs +// that use naming conventions such as camelCase, snake_case, or kebab-case. +func Example_caseSensitivity() { + // JSON input using various naming conventions. + const input = `[ + {"firstname": true}, + {"firstName": true}, + {"FirstName": true}, + {"FIRSTNAME": true}, + {"first_name": true}, + {"FIRST_NAME": true}, + {"first-name": true}, + {"FIRST-NAME": true}, + {"unknown": true} + ]` + + // Without "case:ignore", Unmarshal looks for an exact match. + var caseStrict []struct { + X bool `json:"firstName"` + } + if err := json.Unmarshal([]byte(input), &caseStrict); err != nil { + log.Fatal(err) + } + fmt.Println(caseStrict) // exactly 1 match found + + // With "case:ignore", Unmarshal looks first for an exact match, + // then for a case-insensitive match if none found. + var caseIgnore []struct { + X bool `json:"firstName,case:ignore"` + } + if err := json.Unmarshal([]byte(input), &caseIgnore); err != nil { + log.Fatal(err) + } + fmt.Println(caseIgnore) // 8 matches found + + // Output: + // [{false} {true} {false} {false} {false} {false} {false} {false} {false}] + // [{true} {true} {true} {true} {true} {true} {true} {true} {false}] +} + +// Go struct fields can be omitted from the output depending on either +// the input Go value or the output JSON encoding of the value. +// The "omitzero" option omits a field if it is the zero Go value or +// implements a "IsZero() bool" method that reports true. +// The "omitempty" option omits a field if it encodes as an empty JSON value, +// which we define as a JSON null or empty JSON string, object, or array. +// In many cases, the behavior of "omitzero" and "omitempty" are equivalent. +// If both provide the desired effect, then using "omitzero" is preferred. +func Example_omitFields() { + type MyStruct struct { + Foo string `json:",omitzero"` + Bar []int `json:",omitempty"` + // Both "omitzero" and "omitempty" can be specified together, + // in which case the field is omitted if either would take effect. + // This omits the Baz field either if it is a nil pointer or + // if it would have encoded as an empty JSON object. + Baz *MyStruct `json:",omitzero,omitempty"` + } + + // Demonstrate behavior of "omitzero". + b, err := json.Marshal(struct { + Bool bool `json:",omitzero"` + Int int `json:",omitzero"` + String string `json:",omitzero"` + Time time.Time `json:",omitzero"` + Addr netip.Addr `json:",omitzero"` + Struct MyStruct `json:",omitzero"` + SliceNil []int `json:",omitzero"` + Slice []int `json:",omitzero"` + MapNil map[int]int `json:",omitzero"` + Map map[int]int `json:",omitzero"` + PointerNil *string `json:",omitzero"` + Pointer *string `json:",omitzero"` + InterfaceNil any `json:",omitzero"` + Interface any `json:",omitzero"` + }{ + // Bool is omitted since false is the zero value for a Go bool. + Bool: false, + // Int is omitted since 0 is the zero value for a Go int. + Int: 0, + // String is omitted since "" is the zero value for a Go string. + String: "", + // Time is omitted since time.Time.IsZero reports true. + Time: time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC), + // Addr is omitted since netip.Addr{} is the zero value for a Go struct. + Addr: netip.Addr{}, + // Struct is NOT omitted since it is not the zero value for a Go struct. + Struct: MyStruct{Bar: []int{}, Baz: new(MyStruct)}, + // SliceNil is omitted since nil is the zero value for a Go slice. + SliceNil: nil, + // Slice is NOT omitted since []int{} is not the zero value for a Go slice. + Slice: []int{}, + // MapNil is omitted since nil is the zero value for a Go map. + MapNil: nil, + // Map is NOT omitted since map[int]int{} is not the zero value for a Go map. + Map: map[int]int{}, + // PointerNil is omitted since nil is the zero value for a Go pointer. + PointerNil: nil, + // Pointer is NOT omitted since new(string) is not the zero value for a Go pointer. + Pointer: new(string), + // InterfaceNil is omitted since nil is the zero value for a Go interface. + InterfaceNil: nil, + // Interface is NOT omitted since (*string)(nil) is not the zero value for a Go interface. + Interface: (*string)(nil), + }) + if err != nil { + log.Fatal(err) + } + (*jsontext.Value)(&b).Indent() // indent for readability + fmt.Println("OmitZero:", string(b)) // outputs "Struct", "Slice", "Map", "Pointer", and "Interface" + + // Demonstrate behavior of "omitempty". + b, err = json.Marshal(struct { + Bool bool `json:",omitempty"` + Int int `json:",omitempty"` + String string `json:",omitempty"` + Time time.Time `json:",omitempty"` + Addr netip.Addr `json:",omitempty"` + Struct MyStruct `json:",omitempty"` + Slice []int `json:",omitempty"` + Map map[int]int `json:",omitempty"` + PointerNil *string `json:",omitempty"` + Pointer *string `json:",omitempty"` + InterfaceNil any `json:",omitempty"` + Interface any `json:",omitempty"` + }{ + // Bool is NOT omitted since false is not an empty JSON value. + Bool: false, + // Int is NOT omitted since 0 is not a empty JSON value. + Int: 0, + // String is omitted since "" is an empty JSON string. + String: "", + // Time is NOT omitted since this encodes as a non-empty JSON string. + Time: time.Date(1, 1, 1, 0, 0, 0, 0, time.UTC), + // Addr is omitted since this encodes as an empty JSON string. + Addr: netip.Addr{}, + // Struct is omitted since {} is an empty JSON object. + Struct: MyStruct{Bar: []int{}, Baz: new(MyStruct)}, + // Slice is omitted since [] is an empty JSON array. + Slice: []int{}, + // Map is omitted since {} is an empty JSON object. + Map: map[int]int{}, + // PointerNil is omitted since null is an empty JSON value. + PointerNil: nil, + // Pointer is omitted since "" is an empty JSON string. + Pointer: new(string), + // InterfaceNil is omitted since null is an empty JSON value. + InterfaceNil: nil, + // Interface is omitted since null is an empty JSON value. + Interface: (*string)(nil), + }) + if err != nil { + log.Fatal(err) + } + (*jsontext.Value)(&b).Indent() // indent for readability + fmt.Println("OmitEmpty:", string(b)) // outputs "Bool", "Int", and "Time" + + // Output: + // OmitZero: { + // "Struct": {}, + // "Slice": [], + // "Map": {}, + // "Pointer": "", + // "Interface": null + // } + // OmitEmpty: { + // "Bool": false, + // "Int": 0, + // "Time": "0001-01-01T00:00:00Z" + // } +} + +// JSON objects can be inlined within a parent object similar to +// how Go structs can be embedded within a parent struct. +// The inlining rules are similar to those of Go embedding, +// but operates upon the JSON namespace. +func Example_inlinedFields() { + // Base is embedded within Container. + type Base struct { + // ID is promoted into the JSON object for Container. + ID string + // Type is ignored due to presence of Container.Type. + Type string + // Time cancels out with Container.Inlined.Time. + Time time.Time + } + // Other is embedded within Container. + type Other struct{ Cost float64 } + // Container embeds Base and Other. + type Container struct { + // Base is an embedded struct and is implicitly JSON inlined. + Base + // Type takes precedence over Base.Type. + Type int + // Inlined is a named Go field, but is explicitly JSON inlined. + Inlined struct { + // User is promoted into the JSON object for Container. + User string + // Time cancels out with Base.Time. + Time string + } `json:",inline"` + // ID does not conflict with Base.ID since the JSON name is different. + ID string `json:"uuid"` + // Other is not JSON inlined since it has an explicit JSON name. + Other `json:"other"` + } + + // Format an empty Container to show what fields are JSON serializable. + var input Container + b, err := json.Marshal(&input) + if err != nil { + log.Fatal(err) + } + (*jsontext.Value)(&b).Indent() // indent for readability + fmt.Println(string(b)) + + // Output: + // { + // "ID": "", + // "Type": 0, + // "User": "", + // "uuid": "", + // "other": { + // "Cost": 0 + // } + // } +} + +// Due to version skew, the set of JSON object members known at compile-time +// may differ from the set of members encountered at execution-time. +// As such, it may be useful to have finer grain handling of unknown members. +// This package supports preserving, rejecting, or discarding such members. +func Example_unknownMembers() { + const input = `{ + "Name": "Teal", + "Value": "#008080", + "WebSafe": false + }` + type Color struct { + Name string + Value string + + // Unknown is a Go struct field that holds unknown JSON object members. + // It is marked as having this behavior with the "unknown" tag option. + // + // The type may be a jsontext.Value or map[string]T. + Unknown jsontext.Value `json:",unknown"` + } + + // By default, unknown members are stored in a Go field marked as "unknown" + // or ignored if no such field exists. + var color Color + err := json.Unmarshal([]byte(input), &color) + if err != nil { + log.Fatal(err) + } + fmt.Println("Unknown members:", string(color.Unknown)) + + // Specifying RejectUnknownMembers causes Unmarshal + // to reject the presence of any unknown members. + err = json.Unmarshal([]byte(input), new(Color), json.RejectUnknownMembers(true)) + var serr *json.SemanticError + if errors.As(err, &serr) && serr.Err == json.ErrUnknownName { + fmt.Println("Unmarshal error:", serr.Err, strconv.Quote(serr.JSONPointer.LastToken())) + } + + // By default, Marshal preserves unknown members stored in + // a Go struct field marked as "unknown". + b, err := json.Marshal(color) + if err != nil { + log.Fatal(err) + } + fmt.Println("Output with unknown members: ", string(b)) + + // Specifying DiscardUnknownMembers causes Marshal + // to discard any unknown members. + b, err = json.Marshal(color, json.DiscardUnknownMembers(true)) + if err != nil { + log.Fatal(err) + } + fmt.Println("Output without unknown members:", string(b)) + + // Output: + // Unknown members: {"WebSafe":false} + // Unmarshal error: unknown object member name "WebSafe" + // Output with unknown members: {"Name":"Teal","Value":"#008080","WebSafe":false} + // Output without unknown members: {"Name":"Teal","Value":"#008080"} +} + +// The "format" tag option can be used to alter the formatting of certain types. +func Example_formatFlags() { + value := struct { + BytesBase64 []byte `json:",format:base64"` + BytesHex [8]byte `json:",format:hex"` + BytesArray []byte `json:",format:array"` + FloatNonFinite float64 `json:",format:nonfinite"` + MapEmitNull map[string]any `json:",format:emitnull"` + SliceEmitNull []any `json:",format:emitnull"` + TimeDateOnly time.Time `json:",format:'2006-01-02'"` + TimeUnixSec time.Time `json:",format:unix"` + DurationSecs time.Duration `json:",format:sec"` + DurationNanos time.Duration `json:",format:nano"` + }{ + BytesBase64: []byte{0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef}, + BytesHex: [8]byte{0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef}, + BytesArray: []byte{0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef}, + FloatNonFinite: math.NaN(), + MapEmitNull: nil, + SliceEmitNull: nil, + TimeDateOnly: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), + TimeUnixSec: time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC), + DurationSecs: 12*time.Hour + 34*time.Minute + 56*time.Second + 7*time.Millisecond + 8*time.Microsecond + 9*time.Nanosecond, + DurationNanos: 12*time.Hour + 34*time.Minute + 56*time.Second + 7*time.Millisecond + 8*time.Microsecond + 9*time.Nanosecond, + } + + b, err := json.Marshal(&value) + if err != nil { + log.Fatal(err) + } + (*jsontext.Value)(&b).Indent() // indent for readability + fmt.Println(string(b)) + + // Output: + // { + // "BytesBase64": "ASNFZ4mrze8=", + // "BytesHex": "0123456789abcdef", + // "BytesArray": [ + // 1, + // 35, + // 69, + // 103, + // 137, + // 171, + // 205, + // 239 + // ], + // "FloatNonFinite": "NaN", + // "MapEmitNull": null, + // "SliceEmitNull": null, + // "TimeDateOnly": "2000-01-01", + // "TimeUnixSec": 946684800, + // "DurationSecs": 45296.007008009, + // "DurationNanos": 45296007008009 + // } +} + +// When implementing HTTP endpoints, it is common to be operating with an +// [io.Reader] and an [io.Writer]. The [MarshalWrite] and [UnmarshalRead] functions +// assist in operating on such input/output types. +// [UnmarshalRead] reads the entirety of the [io.Reader] to ensure that [io.EOF] +// is encountered without any unexpected bytes after the top-level JSON value. +func Example_serveHTTP() { + // Some global state maintained by the server. + var n int64 + + // The "add" endpoint accepts a POST request with a JSON object + // containing a number to atomically add to the server's global counter. + // It returns the updated value of the counter. + http.HandleFunc("/api/add", func(w http.ResponseWriter, r *http.Request) { + // Unmarshal the request from the client. + var val struct{ N int64 } + if err := json.UnmarshalRead(r.Body, &val); err != nil { + // Inability to unmarshal the input suggests a client-side problem. + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Marshal a response from the server. + val.N = atomic.AddInt64(&n, val.N) + if err := json.MarshalWrite(w, &val); err != nil { + // Inability to marshal the output suggests a server-side problem. + // This error is not always observable by the client since + // json.MarshalWrite may have already written to the output. + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + }) +} + +// Some Go types have a custom JSON representation where the implementation +// is delegated to some external package. Consequently, the "json" package +// will not know how to use that external implementation. +// For example, the [google.golang.org/protobuf/encoding/protojson] package +// implements JSON for all [google.golang.org/protobuf/proto.Message] types. +// [WithMarshalers] and [WithUnmarshalers] can be used +// to configure "json" and "protojson" to cooperate together. +func Example_protoJSON() { + // Let protoMessage be "google.golang.org/protobuf/proto".Message. + type protoMessage interface{ ProtoReflect() } + // Let foopbMyMessage be a concrete implementation of proto.Message. + type foopbMyMessage struct{ protoMessage } + // Let protojson be an import of "google.golang.org/protobuf/encoding/protojson". + var protojson struct { + Marshal func(protoMessage) ([]byte, error) + Unmarshal func([]byte, protoMessage) error + } + + // This value mixes both non-proto.Message types and proto.Message types. + // It should use the "json" package to handle non-proto.Message types and + // should use the "protojson" package to handle proto.Message types. + var value struct { + // GoStruct does not implement proto.Message and + // should use the default behavior of the "json" package. + GoStruct struct { + Name string + Age int + } + + // ProtoMessage implements proto.Message and + // should be handled using protojson.Marshal. + ProtoMessage *foopbMyMessage + } + + // Marshal using protojson.Marshal for proto.Message types. + b, err := json.Marshal(&value, + // Use protojson.Marshal as a type-specific marshaler. + json.WithMarshalers(json.MarshalFunc(protojson.Marshal))) + if err != nil { + log.Fatal(err) + } + + // Unmarshal using protojson.Unmarshal for proto.Message types. + err = json.Unmarshal(b, &value, + // Use protojson.Unmarshal as a type-specific unmarshaler. + json.WithUnmarshalers(json.UnmarshalFunc(protojson.Unmarshal))) + if err != nil { + log.Fatal(err) + } +} + +// Many error types are not serializable since they tend to be Go structs +// without any exported fields (e.g., errors constructed with [errors.New]). +// Some applications, may desire to marshal an error as a JSON string +// even if these errors cannot be unmarshaled. +func ExampleWithMarshalers_errors() { + // Response to serialize with some Go errors encountered. + response := []struct { + Result string `json:",omitzero"` + Error error `json:",omitzero"` + }{ + {Result: "Oranges are a good source of Vitamin C."}, + {Error: &strconv.NumError{Func: "ParseUint", Num: "-1234", Err: strconv.ErrSyntax}}, + {Error: &os.PathError{Op: "ReadFile", Path: "/path/to/secret/file", Err: os.ErrPermission}}, + } + + b, err := json.Marshal(&response, + // Intercept every attempt to marshal an error type. + json.WithMarshalers(json.JoinMarshalers( + // Suppose we consider strconv.NumError to be a safe to serialize: + // this type-specific marshal function intercepts this type + // and encodes the error message as a JSON string. + json.MarshalToFunc(func(enc *jsontext.Encoder, err *strconv.NumError) error { + return enc.WriteToken(jsontext.String(err.Error())) + }), + // Error messages may contain sensitive information that may not + // be appropriate to serialize. For all errors not handled above, + // report some generic error message. + json.MarshalFunc(func(error) ([]byte, error) { + return []byte(`"internal server error"`), nil + }), + )), + jsontext.Multiline(true)) // expand for readability + if err != nil { + log.Fatal(err) + } + fmt.Println(string(b)) + + // Output: + // [ + // { + // "Result": "Oranges are a good source of Vitamin C." + // }, + // { + // "Error": "strconv.ParseUint: parsing \"-1234\": invalid syntax" + // }, + // { + // "Error": "internal server error" + // } + // ] +} + +// In some applications, the exact precision of JSON numbers needs to be +// preserved when unmarshaling. This can be accomplished using a type-specific +// unmarshal function that intercepts all any types and pre-populates the +// interface value with a [jsontext.Value], which can represent a JSON number exactly. +func ExampleWithUnmarshalers_rawNumber() { + // Input with JSON numbers beyond the representation of a float64. + const input = `[false, 1e-1000, 3.141592653589793238462643383279, 1e+1000, true]` + + var value any + err := json.Unmarshal([]byte(input), &value, + // Intercept every attempt to unmarshal into the any type. + json.WithUnmarshalers( + json.UnmarshalFromFunc(func(dec *jsontext.Decoder, val *any) error { + // If the next value to be decoded is a JSON number, + // then provide a concrete Go type to unmarshal into. + if dec.PeekKind() == '0' { + *val = jsontext.Value(nil) + } + // Return SkipFunc to fallback on default unmarshal behavior. + return json.SkipFunc + }), + )) + if err != nil { + log.Fatal(err) + } + fmt.Println(value) + + // Sanity check. + want := []any{false, jsontext.Value("1e-1000"), jsontext.Value("3.141592653589793238462643383279"), jsontext.Value("1e+1000"), true} + if !reflect.DeepEqual(value, want) { + log.Fatalf("value mismatch:\ngot %v\nwant %v", value, want) + } + + // Output: + // [false 1e-1000 3.141592653589793238462643383279 1e+1000 true] +} + +// When using JSON for parsing configuration files, +// the parsing logic often needs to report an error with a line and column +// indicating where in the input an error occurred. +func ExampleWithUnmarshalers_recordOffsets() { + // Hypothetical configuration file. + const input = `[ + {"Source": "192.168.0.100:1234", "Destination": "192.168.0.1:80"}, + {"Source": "192.168.0.251:4004"}, + {"Source": "192.168.0.165:8080", "Destination": "0.0.0.0:80"} + ]` + type Tunnel struct { + Source netip.AddrPort + Destination netip.AddrPort + + // ByteOffset is populated during unmarshal with the byte offset + // within the JSON input of the JSON object for this Go struct. + ByteOffset int64 `json:"-"` // metadata to be ignored for JSON serialization + } + + var tunnels []Tunnel + err := json.Unmarshal([]byte(input), &tunnels, + // Intercept every attempt to unmarshal into the Tunnel type. + json.WithUnmarshalers( + json.UnmarshalFromFunc(func(dec *jsontext.Decoder, tunnel *Tunnel) error { + // Decoder.InputOffset reports the offset after the last token, + // but we want to record the offset before the next token. + // + // Call Decoder.PeekKind to buffer enough to reach the next token. + // Add the number of leading whitespace, commas, and colons + // to locate the start of the next token. + dec.PeekKind() + unread := dec.UnreadBuffer() + n := len(unread) - len(bytes.TrimLeft(unread, " \n\r\t,:")) + tunnel.ByteOffset = dec.InputOffset() + int64(n) + + // Return SkipFunc to fallback on default unmarshal behavior. + return json.SkipFunc + }), + )) + if err != nil { + log.Fatal(err) + } + + // lineColumn converts a byte offset into a one-indexed line and column. + // The offset must be within the bounds of the input. + lineColumn := func(input string, offset int) (line, column int) { + line = 1 + strings.Count(input[:offset], "\n") + column = 1 + offset - (strings.LastIndex(input[:offset], "\n") + len("\n")) + return line, column + } + + // Verify that the configuration file is valid. + for _, tunnel := range tunnels { + if !tunnel.Source.IsValid() || !tunnel.Destination.IsValid() { + line, column := lineColumn(input, int(tunnel.ByteOffset)) + fmt.Printf("%d:%d: source and destination must both be specified", line, column) + } + } + + // Output: + // 3:3: source and destination must both be specified +} |
