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
|
||||
|
||||
- `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"`
|
||||
skips a field whose value is the zero value of its type (a type with an
|
||||
`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.
|
||||
mv, err := fieldByIndex(dst, schema.embedMaps[0])
|
||||
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 {
|
||||
return fmt.Errorf("%s: %w", key, err)
|
||||
return newDecodeError(key, err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
fv, err := fieldByIndex(dst, field.index)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", key, err)
|
||||
return newDecodeError(key, err)
|
||||
}
|
||||
if err := d.assign(val, fv); err != nil {
|
||||
return fmt.Errorf("%s: %w", key, err)
|
||||
return newDecodeError(key, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -147,7 +147,7 @@ func (d *decoder) assignMap(tbl map[string]any, dst reflect.Value) error {
|
||||
for key, val := range tbl {
|
||||
elem := reflect.New(elemType).Elem()
|
||||
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)
|
||||
}
|
||||
@@ -161,7 +161,7 @@ func (d *decoder) assignSlice(items []any, dst reflect.Value) error {
|
||||
out := reflect.MakeSlice(dst.Type(), len(items), len(items))
|
||||
for i, item := range items {
|
||||
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)
|
||||
@@ -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))
|
||||
for i, item := range items {
|
||||
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)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -606,3 +607,50 @@ func TestUnmarshalStrictEmbeddedMapStaysStrict(t *testing.T) {
|
||||
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`
|
||||
|
||||
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:
|
||||
|
||||
- `*SyntaxError` for a malformed document, with the 1-based line
|
||||
- a plain error for everything else: a non-pointer decode target, a type
|
||||
mismatch, an overflow, a marshal policy violation, a cancelled context
|
||||
- `*DecodeError` for a decoding failure, with the key path in `Path`
|
||||
- `*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
|
||||
`fmt.Errorf`, so `errors.Is` and `errors.AsType` see through them.
|
||||
Decode and encode failures carry the key path or element index in the typed
|
||||
wrappers above, so `errors.Is` and `errors.AsType` see through them and the
|
||||
path reads from a field instead of the message text.
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ package interpres
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"math"
|
||||
@@ -287,7 +288,7 @@ func addField(doc *tomlDoc, name string, v reflect.Value, ctx string) error {
|
||||
if m, ok := v.Interface().(Marshaler); ok {
|
||||
mv, err := m.MarshalTOML()
|
||||
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)
|
||||
}
|
||||
@@ -380,13 +381,13 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
||||
}
|
||||
ev := followPtr(v.Index(i))
|
||||
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}
|
||||
switch ev.Kind() {
|
||||
case reflect.Struct:
|
||||
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 {
|
||||
return err
|
||||
@@ -396,7 +397,7 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -415,13 +416,13 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
||||
}
|
||||
ev := followPtr(v.Index(i))
|
||||
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 m, ok := ev.Interface().(Marshaler); ok {
|
||||
mv, err := m.MarshalTOML()
|
||||
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 = followPtr(ev)
|
||||
@@ -429,7 +430,7 @@ func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error
|
||||
}
|
||||
val, err := normaliseValue(ev)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1160,3 +1160,73 @@ type Custom struct {
|
||||
}
|
||||
|
||||
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 (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"unicode/utf8"
|
||||
)
|
||||
@@ -36,6 +37,58 @@ func (e *SyntaxError) Error() string {
|
||||
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.
|
||||
//
|
||||
// Values are mapped to Go types as follows: strings to string, integers to
|
||||
|
||||
Reference in New Issue
Block a user