feat: typed decode and encode errors with the key path
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- `DecodeError` and `EncodeError`: decode and encode failures are wrapped in
|
||||||
|
typed errors carrying the key path, read with `errors.AsType` instead of
|
||||||
|
parsing the message text. The rendered messages keep their shape; the only
|
||||||
|
visible change is that an encode failure on a top-level field no longer
|
||||||
|
gains a meaningless leading dot in its path.
|
||||||
- `omitzero` and `omitempty` tag options on encode: `toml:"name,omitzero"`
|
- `omitzero` and `omitempty` tag options on encode: `toml:"name,omitzero"`
|
||||||
skips a field whose value is the zero value of its type (a type with an
|
skips a field whose value is the zero value of its type (a type with an
|
||||||
`IsZero() bool` method decides through the method), and
|
`IsZero() bool` method decides through the method), and
|
||||||
|
|||||||
@@ -117,20 +117,20 @@ func (d *decoder) assignStruct(tbl map[string]any, dst reflect.Value) error {
|
|||||||
// of the encoder inlining that map's entries.
|
// of the encoder inlining that map's entries.
|
||||||
mv, err := fieldByIndex(dst, schema.embedMaps[0])
|
mv, err := fieldByIndex(dst, schema.embedMaps[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%s: %w", key, err)
|
return newDecodeError(key, err)
|
||||||
}
|
}
|
||||||
if err := d.assignMap(map[string]any{key: val}, mv); err != nil {
|
if err := d.assignMap(map[string]any{key: val}, mv); err != nil {
|
||||||
return fmt.Errorf("%s: %w", key, err)
|
return newDecodeError(key, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fv, err := fieldByIndex(dst, field.index)
|
fv, err := fieldByIndex(dst, field.index)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%s: %w", key, err)
|
return newDecodeError(key, err)
|
||||||
}
|
}
|
||||||
if err := d.assign(val, fv); err != nil {
|
if err := d.assign(val, fv); err != nil {
|
||||||
return fmt.Errorf("%s: %w", key, err)
|
return newDecodeError(key, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -147,7 +147,7 @@ func (d *decoder) assignMap(tbl map[string]any, dst reflect.Value) error {
|
|||||||
for key, val := range tbl {
|
for key, val := range tbl {
|
||||||
elem := reflect.New(elemType).Elem()
|
elem := reflect.New(elemType).Elem()
|
||||||
if err := d.assign(val, elem); err != nil {
|
if err := d.assign(val, elem); err != nil {
|
||||||
return fmt.Errorf("%s: %w", key, err)
|
return newDecodeError(key, err)
|
||||||
}
|
}
|
||||||
dst.SetMapIndex(reflect.ValueOf(key), elem)
|
dst.SetMapIndex(reflect.ValueOf(key), elem)
|
||||||
}
|
}
|
||||||
@@ -161,7 +161,7 @@ func (d *decoder) assignSlice(items []any, dst reflect.Value) error {
|
|||||||
out := reflect.MakeSlice(dst.Type(), len(items), len(items))
|
out := reflect.MakeSlice(dst.Type(), len(items), len(items))
|
||||||
for i, item := range items {
|
for i, item := range items {
|
||||||
if err := d.assign(item, out.Index(i)); err != nil {
|
if err := d.assign(item, out.Index(i)); err != nil {
|
||||||
return fmt.Errorf("[%d]: %w", i, err)
|
return newDecodeError(fmt.Sprintf("[%d]", i), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dst.Set(out)
|
dst.Set(out)
|
||||||
@@ -175,7 +175,7 @@ func (d *decoder) assignTableSlice(items []map[string]any, dst reflect.Value) er
|
|||||||
out := reflect.MakeSlice(dst.Type(), len(items), len(items))
|
out := reflect.MakeSlice(dst.Type(), len(items), len(items))
|
||||||
for i, item := range items {
|
for i, item := range items {
|
||||||
if err := d.assign(item, out.Index(i)); err != nil {
|
if err := d.assign(item, out.Index(i)); err != nil {
|
||||||
return fmt.Errorf("[%d]: %w", i, err)
|
return newDecodeError(fmt.Sprintf("[%d]", i), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dst.Set(out)
|
dst.Set(out)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -606,3 +607,50 @@ func TestUnmarshalStrictEmbeddedMapStaysStrict(t *testing.T) {
|
|||||||
t.Fatalf("expected unknown field error, got: %v", err)
|
t.Fatalf("expected unknown field error, got: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDecodeErrorCarriesPath(t *testing.T) {
|
||||||
|
type Item struct {
|
||||||
|
Name string `toml:"name"`
|
||||||
|
Weight uint8 `toml:"weight"`
|
||||||
|
}
|
||||||
|
type Cfg struct {
|
||||||
|
Tags []string `toml:"tags"`
|
||||||
|
Items []Item `toml:"items"`
|
||||||
|
}
|
||||||
|
var cfg Cfg
|
||||||
|
err := Unmarshal([]byte("[[items]]\nname = \"a\"\nweight = 300\n"), &cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an overflow error")
|
||||||
|
}
|
||||||
|
de, ok := errors.AsType[*DecodeError](err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected a *DecodeError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
want := []string{"items", "[0]", "weight"}
|
||||||
|
if !slices.Equal(de.Path, want) {
|
||||||
|
t.Fatalf("Path = %v, want %v", de.Path, want)
|
||||||
|
}
|
||||||
|
if de.Err == nil || !strings.Contains(de.Err.Error(), "overflows uint8") {
|
||||||
|
t.Fatalf("Err = %v", de.Err)
|
||||||
|
}
|
||||||
|
// The rendered message keeps its shape: segments joined with ": ".
|
||||||
|
wantMsg := "items: [0]: weight: interpres: integer 300 overflows uint8"
|
||||||
|
if err.Error() != wantMsg {
|
||||||
|
t.Fatalf("message = %q, want %q", err.Error(), wantMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecodeErrorOnMapDestination(t *testing.T) {
|
||||||
|
var m map[string]uint8
|
||||||
|
err := Unmarshal([]byte("count = -1\n"), &m)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error")
|
||||||
|
}
|
||||||
|
de, ok := errors.AsType[*DecodeError](err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected a *DecodeError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if !slices.Equal(de.Path, []string{"count"}) {
|
||||||
|
t.Fatalf("Path = %v", de.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+28
-4
@@ -404,6 +404,27 @@ if se, ok := errors.AsType[*interpres.SyntaxError](err); ok {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### `type DecodeError struct{ Path []string; Err error }`
|
||||||
|
|
||||||
|
Wraps a decoding failure with the key path at which it happened. `Path` lists
|
||||||
|
one segment per level from the document root, the outermost key first: a key
|
||||||
|
contributes its name, an array element its bracketed index, so the path of the
|
||||||
|
`weight` field in the first item reads `["items", "[0]", "weight"]`. The
|
||||||
|
rendered message is unchanged by the type; read the fields instead of parsing
|
||||||
|
the message:
|
||||||
|
|
||||||
|
```go
|
||||||
|
if de, ok := errors.AsType[*interpres.DecodeError](err); ok {
|
||||||
|
fmt.Println(de.Path, de.Err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `type EncodeError struct{ Path string; Err error }`
|
||||||
|
|
||||||
|
Wraps an encoding failure with the key path of the value that failed, in the
|
||||||
|
document's own notation: `server.ports[2]`. Read it with `errors.AsType` the
|
||||||
|
same way.
|
||||||
|
|
||||||
### `type Decoder`
|
### `type Decoder`
|
||||||
|
|
||||||
Configurable strictness for decoding, constructed with `NewDecoder`. Set up
|
Configurable strictness for decoding, constructed with `NewDecoder`. Set up
|
||||||
@@ -460,11 +481,14 @@ types are produced by `Parse` and accepted by `Marshal`.
|
|||||||
The entry points return:
|
The entry points return:
|
||||||
|
|
||||||
- `*SyntaxError` for a malformed document, with the 1-based line
|
- `*SyntaxError` for a malformed document, with the 1-based line
|
||||||
- a plain error for everything else: a non-pointer decode target, a type
|
- `*DecodeError` for a decoding failure, with the key path in `Path`
|
||||||
mismatch, an overflow, a marshal policy violation, a cancelled context
|
- `*EncodeError` for an encoding failure, with the key path in `Path`
|
||||||
|
- a plain error for the rest: a non-pointer decode target, a cancelled
|
||||||
|
context, a key that is not valid UTF-8
|
||||||
|
|
||||||
Decode and encode failures are wrapped with the key path or element index using
|
Decode and encode failures carry the key path or element index in the typed
|
||||||
`fmt.Errorf`, so `errors.Is` and `errors.AsType` see through them.
|
wrappers above, so `errors.Is` and `errors.AsType` see through them and the
|
||||||
|
path reads from a field instead of the message text.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ package interpres
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"maps"
|
"maps"
|
||||||
"math"
|
"math"
|
||||||
@@ -287,7 +288,7 @@ func addField(doc *tomlDoc, name string, v reflect.Value, ctx string) error {
|
|||||||
if m, ok := v.Interface().(Marshaler); ok {
|
if m, ok := v.Interface().(Marshaler); ok {
|
||||||
mv, err := m.MarshalTOML()
|
mv, err := m.MarshalTOML()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("interpres: %s.%s: %w", ctx, name, err)
|
return &EncodeError{Path: joinKey(ctx, name), Err: err}
|
||||||
}
|
}
|
||||||
v = reflect.ValueOf(mv)
|
v = reflect.ValueOf(mv)
|
||||||
}
|
}
|
||||||
@@ -380,13 +381,13 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
|||||||
}
|
}
|
||||||
ev := followPtr(v.Index(i))
|
ev := followPtr(v.Index(i))
|
||||||
if !ev.IsValid() {
|
if !ev.IsValid() {
|
||||||
return fmt.Errorf("interpres: %s.%s[%d]: nil element", ctx, name, i)
|
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errors.New("nil element")}
|
||||||
}
|
}
|
||||||
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts}
|
sub := &tomlDoc{ctx: doc.ctx, opts: doc.opts}
|
||||||
switch ev.Kind() {
|
switch ev.Kind() {
|
||||||
case reflect.Struct:
|
case reflect.Struct:
|
||||||
if isScalarStruct(ev.Type()) {
|
if isScalarStruct(ev.Type()) {
|
||||||
return fmt.Errorf("interpres: %s.%s[%d]: heterogeneous array contains scalar", ctx, name, i)
|
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errors.New("heterogeneous array contains scalar")}
|
||||||
}
|
}
|
||||||
if err := buildStructDoc(ev, sub, joinKey(ctx, fmt.Sprintf("%s[%d]", name, i))); err != nil {
|
if err := buildStructDoc(ev, sub, joinKey(ctx, fmt.Sprintf("%s[%d]", name, i))); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -396,7 +397,7 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("interpres: %s.%s: heterogeneous array, expected table", ctx, name)
|
return &EncodeError{Path: joinKey(ctx, name), Err: errors.New("heterogeneous array, expected table")}
|
||||||
}
|
}
|
||||||
subs[i] = sub
|
subs[i] = sub
|
||||||
}
|
}
|
||||||
@@ -415,13 +416,13 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
|||||||
}
|
}
|
||||||
ev := followPtr(v.Index(i))
|
ev := followPtr(v.Index(i))
|
||||||
if !ev.IsValid() {
|
if !ev.IsValid() {
|
||||||
return fmt.Errorf("interpres: %s.%s[%d]: nil element", ctx, name, i)
|
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: errors.New("nil element")}
|
||||||
}
|
}
|
||||||
if ev.CanInterface() {
|
if ev.CanInterface() {
|
||||||
if m, ok := ev.Interface().(Marshaler); ok {
|
if m, ok := ev.Interface().(Marshaler); ok {
|
||||||
mv, err := m.MarshalTOML()
|
mv, err := m.MarshalTOML()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("interpres: %s.%s[%d]: %w", ctx, name, i, err)
|
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: err}
|
||||||
}
|
}
|
||||||
ev = reflect.ValueOf(mv)
|
ev = reflect.ValueOf(mv)
|
||||||
ev = followPtr(ev)
|
ev = followPtr(ev)
|
||||||
@@ -429,7 +430,7 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
|||||||
}
|
}
|
||||||
val, err := normaliseValue(ev)
|
val, err := normaliseValue(ev)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("interpres: %s.%s[%d]: %w", ctx, name, i, err)
|
return &EncodeError{Path: fmt.Sprintf("%s[%d]", joinKey(ctx, name), i), Err: err}
|
||||||
}
|
}
|
||||||
items[i] = val
|
items[i] = val
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1160,3 +1160,73 @@ type Custom struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c Custom) MarshalTOML() (any, error) { return c.tag, nil }
|
func (c Custom) MarshalTOML() (any, error) { return c.tag, nil }
|
||||||
|
|
||||||
|
// encodeErrBad is a Marshaler whose MarshalTOML always fails.
|
||||||
|
type encodeErrBad struct {
|
||||||
|
msg string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (encodeErrBad) MarshalTOML() (any, error) { return nil, errors.New("bad timestamp") }
|
||||||
|
|
||||||
|
func TestEncodeErrorCarriesPath(t *testing.T) {
|
||||||
|
type Inner struct {
|
||||||
|
Port encodeErrBad `toml:"port"`
|
||||||
|
}
|
||||||
|
type Cfg struct {
|
||||||
|
Server Inner `toml:"server"`
|
||||||
|
}
|
||||||
|
_, err := Marshal(Cfg{Server: Inner{Port: encodeErrBad{}}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected a marshal error")
|
||||||
|
}
|
||||||
|
ee, ok := errors.AsType[*EncodeError](err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if ee.Path != "server.port" {
|
||||||
|
t.Fatalf("Path = %q, want %q", ee.Path, "server.port")
|
||||||
|
}
|
||||||
|
if ee.Err == nil || ee.Err.Error() != "bad timestamp" {
|
||||||
|
t.Fatalf("Err = %v", ee.Err)
|
||||||
|
}
|
||||||
|
if err.Error() != "interpres: server.port: bad timestamp" {
|
||||||
|
t.Fatalf("message = %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeErrorTopLevelPathHasNoLeadingDot(t *testing.T) {
|
||||||
|
type Cfg struct {
|
||||||
|
Port encodeErrBad `toml:"port"`
|
||||||
|
}
|
||||||
|
_, err := Marshal(Cfg{})
|
||||||
|
ee, ok := errors.AsType[*EncodeError](err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if ee.Path != "port" {
|
||||||
|
t.Fatalf("Path = %q, want %q", ee.Path, "port")
|
||||||
|
}
|
||||||
|
if err.Error() != "interpres: port: bad timestamp" {
|
||||||
|
t.Fatalf("message = %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEncodeErrorHeterogeneousArrayPath(t *testing.T) {
|
||||||
|
type Item struct {
|
||||||
|
N int `toml:"n"`
|
||||||
|
}
|
||||||
|
cfg := map[string]any{
|
||||||
|
"items": []any{Item{}, 3},
|
||||||
|
}
|
||||||
|
_, err := Marshal(cfg)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected a heterogeneous array error")
|
||||||
|
}
|
||||||
|
ee, ok := errors.AsType[*EncodeError](err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected an *EncodeError, got %T: %v", err, err)
|
||||||
|
}
|
||||||
|
if ee.Path != "items[0]" {
|
||||||
|
t.Fatalf("Path = %q, want %q", ee.Path, "items[0]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ package interpres
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
@@ -36,6 +37,58 @@ func (e *SyntaxError) Error() string {
|
|||||||
return fmt.Sprintf("interpres: line %d: %s", e.Line, e.Msg)
|
return fmt.Sprintf("interpres: line %d: %s", e.Line, e.Msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A DecodeError wraps a decoding failure with the key path at which it
|
||||||
|
// happened. Path lists one segment per level from the document root, the
|
||||||
|
// outermost key first: a key contributes its name and an array element its
|
||||||
|
// bracketed index, so the path of the weight field in the first item reads
|
||||||
|
// ["items", "[0]", "weight"]. The rendered message is unchanged by the type;
|
||||||
|
// read it programmatically with errors.AsType:
|
||||||
|
//
|
||||||
|
// if de, ok := errors.AsType[*interpres.DecodeError](err); ok {
|
||||||
|
// fmt.Println(de.Path, de.Err)
|
||||||
|
// }
|
||||||
|
type DecodeError struct {
|
||||||
|
// Path is the key path from the document root, outermost key first.
|
||||||
|
Path []string
|
||||||
|
// Err is the failure at that path.
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *DecodeError) Error() string { return e.Path[0] + ": " + e.Err.Error() }
|
||||||
|
|
||||||
|
// Unwrap returns the failure the path points at.
|
||||||
|
func (e *DecodeError) Unwrap() error { return e.Err }
|
||||||
|
|
||||||
|
// newDecodeError wraps err with one path segment. The rest of the path comes
|
||||||
|
// from the DecodeError err already carries, if any: the decoder wraps each
|
||||||
|
// key and index on its way down, so the innermost wrap holds the deepest
|
||||||
|
// segments and each outer wrap prepends one.
|
||||||
|
func newDecodeError(key string, err error) *DecodeError {
|
||||||
|
path := make([]string, 0, 4)
|
||||||
|
path = append(path, key)
|
||||||
|
if de, ok := errors.AsType[*DecodeError](err); ok {
|
||||||
|
path = append(path, de.Path...)
|
||||||
|
}
|
||||||
|
return &DecodeError{Path: path, Err: err}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An EncodeError wraps an encoding failure with the key path of the value
|
||||||
|
// that failed, in the notation of a TOML document: fields join with dots and
|
||||||
|
// an array element carries its bracketed index, so the path of the third
|
||||||
|
// port under server reads "server.ports[2]". The rendered message is
|
||||||
|
// unchanged by the type; read it programmatically with errors.AsType.
|
||||||
|
type EncodeError struct {
|
||||||
|
// Path is the key path of the failing value.
|
||||||
|
Path string
|
||||||
|
// Err is the failure at that path.
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *EncodeError) Error() string { return "interpres: " + e.Path + ": " + e.Err.Error() }
|
||||||
|
|
||||||
|
// Unwrap returns the failure the path points at.
|
||||||
|
func (e *EncodeError) Unwrap() error { return e.Err }
|
||||||
|
|
||||||
// Parse decodes a TOML document into a nested map[string]any.
|
// Parse decodes a TOML document into a nested map[string]any.
|
||||||
//
|
//
|
||||||
// Values are mapped to Go types as follows: strings to string, integers to
|
// Values are mapped to Go types as follows: strings to string, integers to
|
||||||
|
|||||||
Reference in New Issue
Block a user