From cd06b0b7476c72b19b40cc1bb58cf94f834fb720 Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Wed, 15 Apr 2026 10:53:44 +0200 Subject: [PATCH] feat: the RFC 6901 `"-"` array suffix is now supported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Pointer.Set` may append elements to an array using this syntax. On `Pointer.Get` / `Pointer.Offset` it is always an error per RFC 6901 §4. * fixes #120 Signed-off-by: Frederic BIDON --- .claude/CLAUDE.md | 4 +- README.md | 21 ++-- dash_token_test.go | 243 +++++++++++++++++++++++++++++++++++++++++++++ errors.go | 24 +++++ examples_test.go | 56 +++++++++++ pointer.go | 215 ++++++++++++++++++++++++++++++++------- pointer_test.go | 7 +- 7 files changed, 522 insertions(+), 48 deletions(-) create mode 100644 dash_token_test.go diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 6dd8d33..025a60d 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -40,5 +40,7 @@ See also .claude/plans/ROADMAP.md. - Struct fields **must** have a `json` tag to be reachable; untagged fields are ignored (differs from `encoding/json` which defaults to the Go field name). - Anonymous embedded struct fields are traversed only if tagged. -- The RFC 6901 `"-"` array suffix (append) is **not** implemented. +- The RFC 6901 `"-"` array suffix is supported on `Pointer.Set` as an append + operation (RFC 6902 convention). On `Pointer.Get` / `Pointer.Offset` it is + always an error per RFC 6901 §4. diff --git a/README.md b/README.md index 08a3d96..56c8e77 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,14 @@ You may join the discord community by clicking the invite link on the discord ba Or join our Slack channel: [![Slack Channel][slack-logo]![slack-badge]][slack-url] +* **2026-04-15** : added support for trailing "-" for arrays + * this brings full support of [RFC6901][RFC6901] + * this is supported for types relying on the reflection-based implemented + * API semantics remain essentially unaltered. Exception: `Pointer.Set(document any,value any) (document any, err error)` + can only perform a best-effort to mutate the input document in place. In the case of adding elements to an array with a + trailing "-", either pass a mutable array (`*[]T`) as the input document, or use the returned updated document instead. + * types that implement the `JSONSetable` interface may not implement the mutation implied by the trailing "-" + ## Status API is stable. @@ -88,7 +96,7 @@ See -also known as [RFC6901](https://www.rfc-editor.org/rfc/rfc6901) +also known as [RFC6901][RFC6901]. ## Licensing @@ -99,12 +107,10 @@ on top of which it has been built. ## Limitations -The 4.Evaluation part of the previous reference, starting with 'If the currently referenced value is a JSON array, -the reference token MUST contain either...' is not implemented. - -That is because our implementation of the JSON pointer only supports explicit references to array elements: -the provision in the spec to resolve non-existent members as "the last element in the array", -using the special trailing character "-" is not implemented. +* [RFC6901][RFC6901] is now fully supported, including trailing "-" semantics for arrays (for `Set` operations). +* JSON name detection in go `struct`s + - Unlike go standard marshaling, untagged fields do not default to the go field name and are ignored. + - anonymous fields are not traversed if untagged ## Other documentation @@ -156,3 +162,4 @@ Maintainers can cut a new release by either: [goversion-url]: https://github.com/go-openapi/jsonpointer/blob/master/go.mod [top-badge]: https://img.shields.io/github/languages/top/go-openapi/jsonpointer [commits-badge]: https://img.shields.io/github/commits-since/go-openapi/jsonpointer/latest +[RFC6901]: https://www.rfc-editor.org/rfc/rfc6901 diff --git a/dash_token_test.go b/dash_token_test.go new file mode 100644 index 0000000..6a18101 --- /dev/null +++ b/dash_token_test.go @@ -0,0 +1,243 @@ +// SPDX-FileCopyrightText: Copyright (c) 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package jsonpointer + +import ( + "errors" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// RFC 6901 §4: the "-" token refers to the (nonexistent) element after the +// last array element. It is always an error on Get/Offset, valid only as +// the terminal token of a Set against a slice (append, per RFC 6902). + +func TestDashToken_GetAlwaysErrors(t *testing.T) { + t.Parallel() + + t.Run("terminal dash on slice in map", func(t *testing.T) { + doc := map[string]any{"arr": []any{1, 2, 3}} + p, err := New("/arr/-") + require.NoError(t, err) + + _, _, err = p.Get(doc) + require.Error(t, err) + require.ErrorIs(t, err, ErrDashToken) + require.ErrorIs(t, err, ErrPointer) + }) + + t.Run("terminal dash on top-level slice", func(t *testing.T) { + doc := []int{1, 2, 3} + p, err := New("/-") + require.NoError(t, err) + + _, _, err = p.Get(doc) + require.Error(t, err) + require.ErrorIs(t, err, ErrDashToken) + }) + + t.Run("intermediate dash during get", func(t *testing.T) { + doc := map[string]any{"arr": []any{map[string]any{"x": 1}}} + p, err := New("/arr/-/x") + require.NoError(t, err) + + _, _, err = p.Get(doc) + require.Error(t, err) + require.ErrorIs(t, err, ErrDashToken) + }) + + t.Run("GetForToken on slice with dash", func(t *testing.T) { + _, _, err := GetForToken([]int{1, 2}, "-") + require.Error(t, err) + require.ErrorIs(t, err, ErrDashToken) + }) + + t.Run("dash on map key is a regular lookup, not an error", func(t *testing.T) { + // "-" is only special for arrays. A literal "-" key in a map is fine. + doc := map[string]any{"-": 42} + p, err := New("/-") + require.NoError(t, err) + + v, _, err := p.Get(doc) + require.NoError(t, err) + assert.Equal(t, 42, v) + }) +} + +func TestDashToken_OffsetErrors(t *testing.T) { + t.Parallel() + + doc := `{"arr":[1,2,3]}` + p, err := New("/arr/-") + require.NoError(t, err) + + _, err = p.Offset(doc) + require.Error(t, err) + require.ErrorIs(t, err, ErrDashToken) +} + +func TestDashToken_SetAppend(t *testing.T) { + t.Parallel() + + t.Run("append into slice nested in a map (in place)", func(t *testing.T) { + doc := map[string]any{"arr": []any{1, 2}} + p, err := New("/arr/-") + require.NoError(t, err) + + out, err := p.Set(doc, 3) + require.NoError(t, err) + + // returned doc is the same map reference + assert.Equal(t, doc, out) + + // map's slice was rebound in place + arr, ok := doc["arr"].([]any) + require.True(t, ok) + assert.Equal(t, []any{1, 2, 3}, arr) + }) + + t.Run("append into top-level slice passed by value (return value is source of truth)", func(t *testing.T) { + doc := []int{1, 2} + p, err := New("/-") + require.NoError(t, err) + + out, err := p.Set(doc, 3) + require.NoError(t, err) + + // returned doc has the appended element + outSlice, ok := out.([]int) + require.True(t, ok) + assert.Equal(t, []int{1, 2, 3}, outSlice) + }) + + t.Run("append into top-level *[]T (in place)", func(t *testing.T) { + doc := []int{1, 2} + p, err := New("/-") + require.NoError(t, err) + + _, err = p.Set(&doc, 3) + require.NoError(t, err) + + // caller's slice variable now has the appended element + assert.Equal(t, []int{1, 2, 3}, doc) + }) + + t.Run("append into struct slice field reached via pointer (in place)", func(t *testing.T) { + type holder struct { + Arr []int `json:"arr"` + } + doc := &holder{Arr: []int{1, 2}} + p, err := New("/arr/-") + require.NoError(t, err) + + _, err = p.Set(doc, 3) + require.NoError(t, err) + + assert.Equal(t, []int{1, 2, 3}, doc.Arr) + }) + + t.Run("append into deeply nested slice", func(t *testing.T) { + doc := map[string]any{ + "outer": []any{ + map[string]any{"inner": []any{"a"}}, + }, + } + p, err := New("/outer/0/inner/-") + require.NoError(t, err) + + _, err = p.Set(doc, "b") + require.NoError(t, err) + + outer, ok := doc["outer"].([]any) + require.True(t, ok) + first, ok := outer[0].(map[string]any) + require.True(t, ok) + inner, ok := first["inner"].([]any) + require.True(t, ok) + assert.Equal(t, []any{"a", "b"}, inner) + }) + + t.Run("SetForToken with dash appends", func(t *testing.T) { + out, err := SetForToken([]int{1, 2}, "-", 3) + require.NoError(t, err) + + outSlice, ok := out.([]int) + require.True(t, ok) + assert.Equal(t, []int{1, 2, 3}, outSlice) + }) +} + +func TestDashToken_SetErrors(t *testing.T) { + t.Parallel() + + t.Run("intermediate dash is rejected", func(t *testing.T) { + doc := map[string]any{"arr": []any{1, 2}} + p, err := New("/arr/-/x") + require.NoError(t, err) + + _, err = p.Set(doc, 3) + require.Error(t, err) + require.ErrorIs(t, err, ErrDashToken) + }) + + t.Run("append with wrong element type fails", func(t *testing.T) { + doc := map[string]any{"arr": []int{1, 2}} + p, err := New("/arr/-") + require.NoError(t, err) + + _, err = p.Set(doc, "not-an-int") + require.Error(t, err) + }) +} + +// dashSetter captures whatever token JSONSet receives, including "-". +type dashSetter struct { + key string + value any +} + +func (d *dashSetter) JSONSet(key string, value any) error { + d.key = key + d.value = value + return nil +} + +func TestDashToken_JSONSetableReceivesRawDash(t *testing.T) { + t.Parallel() + + // When the terminal parent implements JSONSetable, the dash token is + // passed through verbatim. Semantics are the user type's responsibility. + ds := &dashSetter{} + p, err := New("/-") + require.NoError(t, err) + + _, err = p.Set(ds, 42) + require.NoError(t, err) + assert.Equal(t, "-", ds.key) + assert.Equal(t, 42, ds.value) +} + +func TestDashToken_RoundTrip(t *testing.T) { + t.Parallel() + + p, err := New("/a/-") + require.NoError(t, err) + assert.Equal(t, "/a/-", p.String()) + assert.Equal(t, []string{"a", "-"}, p.DecodedTokens()) +} + +func TestDashToken_WrappedErrors(t *testing.T) { + t.Parallel() + + // Ensure errors.Is works through both wraps. + p, _ := New("/arr/-") + doc := map[string]any{"arr": []any{}} + + _, _, err := p.Get(doc) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDashToken)) + assert.True(t, errors.Is(err, ErrPointer)) +} diff --git a/errors.go b/errors.go index b23fc2b..8813474 100644 --- a/errors.go +++ b/errors.go @@ -20,8 +20,20 @@ const ( // ErrUnsupportedValueType indicates that a value of the wrong type is being set. ErrUnsupportedValueType pointerError = "only structs, pointers, maps and slices are supported for setting values" + + // ErrDashToken indicates use of the RFC 6901 "-" reference token + // in a context where it cannot be resolved. + // + // Per RFC 6901 §4 the "-" token refers to the (nonexistent) element + // after the last array element. It may only be used as the terminal + // token of a [Pointer.Set] against a slice, where it means "append". + // Any other use (get, offset, intermediate traversal, non-slice target) + // is an error condition that wraps this sentinel. + ErrDashToken pointerError = `the "-" array token cannot be resolved here` //nolint:gosec // G101 false positive: this is a JSON Pointer reference token, not a credential. ) +const dashToken = "-" + func errNoKey(key string) error { return fmt.Errorf("object has no key %q: %w", key, ErrPointer) } @@ -33,3 +45,15 @@ func errOutOfBounds(length, idx int) error { func errInvalidReference(token string) error { return fmt.Errorf("invalid token reference %q: %w", token, ErrPointer) } + +func errDashOnGet() error { + return fmt.Errorf("cannot resolve %q token on get: %w: %w", dashToken, ErrDashToken, ErrPointer) +} + +func errDashIntermediate() error { + return fmt.Errorf("the %q token may only appear as the terminal token of a pointer: %w: %w", dashToken, ErrDashToken, ErrPointer) +} + +func errDashOnOffset() error { + return fmt.Errorf("cannot compute offset for %q token (nonexistent element): %w: %w", dashToken, ErrDashToken, ErrPointer) +} diff --git a/examples_test.go b/examples_test.go index e4c4222..48d1755 100644 --- a/examples_test.go +++ b/examples_test.go @@ -129,3 +129,59 @@ func ExamplePointer_Set() { // result: &jsonpointer.exampleDocument{Foo:[]string{"bar", "hey my"}} // doc: jsonpointer.exampleDocument{Foo:[]string{"bar", "hey my"}} } + +// ExamplePointer_Set_append demonstrates the RFC 6901 "-" token as an +// append operation on a slice. On nested slices reached through an +// addressable parent (map entry, pointer to struct, ...), the append is +// performed in place and the returned document is the same reference. +func ExamplePointer_Set_append() { + doc := map[string]any{"foo": []any{"bar"}} + + pointer, err := New("/foo/-") + if err != nil { + fmt.Println(err) + + return + } + + if _, err := pointer.Set(doc, "baz"); err != nil { + fmt.Println(err) + + return + } + + fmt.Printf("doc: %v\n", doc["foo"]) + + // Output: + // doc: [bar baz] +} + +// ExamplePointer_Set_appendTopLevelSlice shows the one case where the +// returned document is load-bearing: appending to a top-level slice +// passed by value. The library cannot rebind the slice header in the +// caller's variable, so callers must use the returned document (or pass +// *[]T to get in-place rebind). +func ExamplePointer_Set_appendTopLevelSlice() { + doc := []int{1, 2} + + pointer, err := New("/-") + if err != nil { + fmt.Println(err) + + return + } + + out, err := pointer.Set(doc, 3) + if err != nil { + fmt.Println(err) + + return + } + + fmt.Printf("original: %v\n", doc) + fmt.Printf("returned: %v\n", out) + + // Output: + // original: [1 2] + // returned: [1 2 3] +} diff --git a/pointer.go b/pointer.go index 7df49af..68faab6 100644 --- a/pointer.go +++ b/pointer.go @@ -29,8 +29,24 @@ type JSONPointable interface { // JSONSetable is an interface for structs to implement, // when they need to customize the json pointer process or want to avoid the use of reflection. +// +// # Handling of the RFC 6901 "-" token +// +// When a type implementing JSONSetable is the terminal parent of a [Pointer.Set] +// call, the library passes the raw reference token to JSONSet without +// interpretation. In particular, the RFC 6901 "-" token (which conventionally +// means "append" for arrays, per RFC 6902) is forwarded verbatim as the key +// argument. Implementations that model an array-like container are expected +// to give "-" the append semantics; implementations that do not should return +// an error wrapping [ErrDashToken] (or [ErrPointer]) for clarity. +// +// Implementations are responsible for any in-place mutation: the library does +// not attempt to rebind the result of JSONSet into a parent container. type JSONSetable interface { // JSONSet sets the value pointed at the (unescaped) key. + // + // The key may be the RFC 6901 "-" token when the pointer targets a + // slice-like member; see the interface documentation for details. JSONSet(key string, value any) error } @@ -78,9 +94,24 @@ func (p *Pointer) Get(document any) (any, reflect.Kind, error) { // Set uses the pointer to set a value from a data type // that represent a JSON document. // -// It returns the updated document. +// # Mutation contract +// +// Set mutates the provided document in place whenever Go's type system allows +// it: when document is a map, a pointer, or when the targeted value is reached +// through an addressable ancestor (e.g. a struct field traversed via a pointer, +// a slice element). Callers that rely on this in-place behavior may continue +// to ignore the returned document. +// +// The returned document is only load-bearing when Set cannot mutate in place. +// This happens in one specific case: appending to a top-level slice passed by +// value (e.g. document of type []T rather than *[]T) via the RFC 6901 "-" +// terminal token. reflect.Append produces a new slice header that the library +// cannot rebind into the caller's variable; the updated document is returned +// instead. Pass *[]T if you want in-place rebind for that case as well. +// +// See [ErrDashToken] for the semantics of the "-" token. func (p *Pointer) Set(document any, value any) (any, error) { - return document, p.set(document, value, jsonname.DefaultJSONNameProvider) + return p.set(document, value, jsonname.DefaultJSONNameProvider) } // DecodedTokens returns the decoded (unescaped) tokens of this JSON pointer. @@ -185,47 +216,127 @@ func (p *Pointer) get(node any, nameProvider *jsonname.NameProvider) (any, refle return node, kind, nil } -func (p *Pointer) set(node, data any, nameProvider *jsonname.NameProvider) error { +func (p *Pointer) set(node, data any, nameProvider *jsonname.NameProvider) (any, error) { knd := reflect.ValueOf(node).Kind() if knd != reflect.Pointer && knd != reflect.Struct && knd != reflect.Map && knd != reflect.Slice && knd != reflect.Array { - return errors.Join( + return node, errors.Join( fmt.Errorf("unexpected type: %T", node), //nolint:err113 // err wrapping is carried out by errors.Join, not fmt.Errorf. ErrUnsupportedValueType, ErrPointer, ) } - l := len(p.referenceTokens) - // full document when empty - if l == 0 { - return nil + if len(p.referenceTokens) == 0 { + return node, nil } if nameProvider == nil { nameProvider = jsonname.DefaultJSONNameProvider } - var decodedToken string - lastIndex := l - 1 + return p.setAt(node, p.referenceTokens, data, nameProvider) +} - if lastIndex > 0 { // skip if we only have one token in pointer - for _, token := range p.referenceTokens[:lastIndex] { - decodedToken = Unescape(token) - next, err := p.resolveNodeForToken(node, decodedToken, nameProvider) - if err != nil { - return err - } +// setAt recursively walks the token list, setting the data at the terminal +// token and rebinding any new child reference (e.g. a slice header returned +// by an "-" append) into its parent on the way back up. +// +// Returning the (possibly new) node at each level is what makes append work +// at any depth without requiring the caller to pass a pointer to the +// containing slice: the new slice header propagates up and each parent +// rebinds it via the appropriate kind-specific setter. +func (p *Pointer) setAt(node any, tokens []string, data any, nameProvider *jsonname.NameProvider) (any, error) { + decodedToken := Unescape(tokens[0]) + + if len(tokens) == 1 { + return setSingleImpl(node, data, decodedToken, nameProvider) + } - node = next - } + child, err := p.resolveNodeForToken(node, decodedToken, nameProvider) + if err != nil { + return node, err } - // last token - decodedToken = Unescape(p.referenceTokens[lastIndex]) + newChild, err := p.setAt(child, tokens[1:], data, nameProvider) + if err != nil { + return node, err + } - return setSingleImpl(node, data, decodedToken, nameProvider) + return rebindChild(node, decodedToken, newChild, nameProvider) +} + +// rebindChild writes newChild back into node at decodedToken. +// +// For cases where the child was already mutated in place (pointer aliasing, +// addressable slice elements) the rebind is a safe no-op. For cases where +// the child was returned by value (map entries holding a slice, slices +// reached through a non-addressable ancestor), the rebind propagates the +// new value into the parent. +// +// Parents implementing [JSONPointable] are left alone: they took ownership +// of the child via JSONLookup and did not opt into a JSONSet-based rebind +// on intermediate tokens. +func rebindChild(node any, decodedToken string, newChild any, nameProvider *jsonname.NameProvider) (any, error) { + if _, ok := node.(JSONPointable); ok { + return node, nil + } + + rValue := reflect.Indirect(reflect.ValueOf(node)) + + switch rValue.Kind() { + case reflect.Struct: + nm, ok := nameProvider.GetGoNameForType(rValue.Type(), decodedToken) + if !ok { + return node, fmt.Errorf("object has no field %q: %w", decodedToken, ErrPointer) + } + fld := rValue.FieldByName(nm) + if !fld.CanSet() { + return node, nil + } + assignReflectValue(fld, newChild) + return node, nil + + case reflect.Map: + rValue.SetMapIndex(reflect.ValueOf(decodedToken), reflect.ValueOf(newChild)) + return node, nil + + case reflect.Slice: + if decodedToken == dashToken { + return node, errDashIntermediate() + } + idx, err := strconv.Atoi(decodedToken) + if err != nil { + return node, errors.Join(err, ErrPointer) + } + elem := rValue.Index(idx) + if !elem.CanSet() { + return node, nil + } + assignReflectValue(elem, newChild) + return node, nil + + default: + return node, errInvalidReference(decodedToken) + } +} + +// assignReflectValue assigns src into dst, unwrapping a pointer when dst +// expects the pointee type. This tolerates the pointer-wrapping performed +// by [typeFromValue] for addressable fields. +func assignReflectValue(dst reflect.Value, src any) { + nv := reflect.ValueOf(src) + if !nv.IsValid() { + return + } + if nv.Type().AssignableTo(dst.Type()) { + dst.Set(nv) + return + } + if nv.Kind() == reflect.Pointer && nv.Elem().Type().AssignableTo(dst.Type()) { + dst.Set(nv.Elem()) + } } func (p *Pointer) resolveNodeForToken(node any, decodedToken string, nameProvider *jsonname.NameProvider) (next any, err error) { @@ -272,6 +383,9 @@ func (p *Pointer) resolveNodeForToken(node any, decodedToken string, nameProvide return typeFromValue(mv), nil case reflect.Slice: + if decodedToken == dashToken { + return nil, errDashIntermediate() + } tokenIndex, err := strconv.Atoi(decodedToken) if err != nil { return nil, errors.Join(err, ErrPointer) @@ -317,8 +431,11 @@ func GetForToken(document any, decodedToken string) (any, reflect.Kind, error) { } // SetForToken sets a value for a json pointer token 1 level deep. +// +// See [Pointer.Set] for the mutation contract, in particular the handling of +// the RFC 6901 "-" token on slices. func SetForToken(document any, decodedToken string, value any) (any, error) { - return document, setSingleImpl(document, value, decodedToken, jsonname.DefaultJSONNameProvider) + return setSingleImpl(document, value, decodedToken, jsonname.DefaultJSONNameProvider) } func getSingleImpl(node any, decodedToken string, nameProvider *jsonname.NameProvider) (any, reflect.Kind, error) { @@ -361,6 +478,9 @@ func getSingleImpl(node any, decodedToken string, nameProvider *jsonname.NamePro return nil, kind, errNoKey(decodedToken) case reflect.Slice: + if decodedToken == dashToken { + return nil, kind, errDashOnGet() + } tokenIndex, err := strconv.Atoi(decodedToken) if err != nil { return nil, kind, errors.Join(err, ErrPointer) @@ -378,14 +498,14 @@ func getSingleImpl(node any, decodedToken string, nameProvider *jsonname.NamePro } } -func setSingleImpl(node, data any, decodedToken string, nameProvider *jsonname.NameProvider) error { +func setSingleImpl(node, data any, decodedToken string, nameProvider *jsonname.NameProvider) (any, error) { // check for nil to prevent panic when calling rValue.Type() if isNil(node) { - return fmt.Errorf("cannot set field %q on nil value: %w", decodedToken, ErrPointer) + return node, fmt.Errorf("cannot set field %q on nil value: %w", decodedToken, ErrPointer) } if ns, ok := node.(JSONSetable); ok { - return ns.JSONSet(decodedToken, data) + return node, ns.JSONSet(decodedToken, data) } rValue := reflect.Indirect(reflect.ValueOf(node)) @@ -394,12 +514,12 @@ func setSingleImpl(node, data any, decodedToken string, nameProvider *jsonname.N case reflect.Struct: nm, ok := nameProvider.GetGoNameForType(rValue.Type(), decodedToken) if !ok { - return fmt.Errorf("object has no field %q: %w", decodedToken, ErrPointer) + return node, fmt.Errorf("object has no field %q: %w", decodedToken, ErrPointer) } fld := rValue.FieldByName(nm) if !fld.CanSet() { - return fmt.Errorf("can't set struct field %s to %v: %w", nm, data, ErrPointer) + return node, fmt.Errorf("can't set struct field %s to %v: %w", nm, data, ErrPointer) } value := reflect.ValueOf(data) @@ -407,33 +527,51 @@ func setSingleImpl(node, data any, decodedToken string, nameProvider *jsonname.N assignedType := fld.Type() if !valueType.AssignableTo(assignedType) { - return fmt.Errorf("can't set value with type %T to field %s with type %v: %w", data, nm, assignedType, ErrPointer) + return node, fmt.Errorf("can't set value with type %T to field %s with type %v: %w", data, nm, assignedType, ErrPointer) } fld.Set(value) - return nil + return node, nil case reflect.Map: kv := reflect.ValueOf(decodedToken) rValue.SetMapIndex(kv, reflect.ValueOf(data)) - return nil + return node, nil case reflect.Slice: + if decodedToken == dashToken { + // RFC 6901 §4 / RFC 6902 append semantics: terminal "-" appends + // the value to the slice. We rebind in place when the slice is + // reachable via an addressable ancestor; otherwise we return the + // new slice header for the parent (or the public Set) to rebind. + value := reflect.ValueOf(data) + elemType := rValue.Type().Elem() + if !value.Type().AssignableTo(elemType) { + return node, fmt.Errorf("can't append value of type %T to slice of %v: %w", data, elemType, ErrPointer) + } + newSlice := reflect.Append(rValue, value) + if rValue.CanSet() { + rValue.Set(newSlice) + return node, nil + } + return newSlice.Interface(), nil + } + tokenIndex, err := strconv.Atoi(decodedToken) if err != nil { - return errors.Join(err, ErrPointer) + return node, errors.Join(err, ErrPointer) } sLength := rValue.Len() if tokenIndex < 0 || tokenIndex >= sLength { - return errOutOfBounds(sLength, tokenIndex) + return node, errOutOfBounds(sLength, tokenIndex) } elem := rValue.Index(tokenIndex) if !elem.CanSet() { - return fmt.Errorf("can't set slice index %s to %v: %w", decodedToken, data, ErrPointer) + return node, fmt.Errorf("can't set slice index %s to %v: %w", decodedToken, data, ErrPointer) } value := reflect.ValueOf(data) @@ -441,15 +579,15 @@ func setSingleImpl(node, data any, decodedToken string, nameProvider *jsonname.N assignedType := elem.Type() if !valueType.AssignableTo(assignedType) { - return fmt.Errorf("can't set value with type %T to slice element %d with type %v: %w", data, tokenIndex, assignedType, ErrPointer) + return node, fmt.Errorf("can't set value with type %T to slice element %d with type %v: %w", data, tokenIndex, assignedType, ErrPointer) } elem.Set(value) - return nil + return node, nil default: - return errInvalidReference(decodedToken) + return node, errInvalidReference(decodedToken) } } @@ -485,6 +623,9 @@ func offsetSingleObject(dec *json.Decoder, decodedToken string) (int64, error) { } func offsetSingleArray(dec *json.Decoder, decodedToken string) (int64, error) { + if decodedToken == dashToken { + return 0, errDashOnOffset() + } idx, err := strconv.Atoi(decodedToken) if err != nil { return 0, fmt.Errorf("token reference %q is not a number: %w: %w", decodedToken, err, ErrPointer) diff --git a/pointer_test.go b/pointer_test.go index d23031b..1849b48 100644 --- a/pointer_test.go +++ b/pointer_test.go @@ -110,7 +110,8 @@ func TestFullDocument(t *testing.T) { require.NoErrorf(t, err, "New(%v) error %v", in, err) const value = "hey" - require.NoError(t, setter.set(asMap, value, nil)) + _, err = setter.set(asMap, value, nil) + require.NoError(t, err) foos, ok := asMap["foo"] require.TrueT(t, ok) @@ -1087,7 +1088,7 @@ func TestInternalEdgeCases(t *testing.T) { t.Run("setSingleImpl should error on any node not a struct, map or slice", func(t *testing.T) { var node int - err := setSingleImpl(&node, 3, "a", jsonname.DefaultJSONNameProvider) + _, err := setSingleImpl(&node, 3, "a", jsonname.DefaultJSONNameProvider) require.Error(t, err) require.ErrorContains(t, err, `invalid token reference "a"`) }) @@ -1103,7 +1104,7 @@ func TestInternalEdgeCases(t *testing.T) { t.Run("setSingleImpl should error on struct field that is not settable", func(t *testing.T) { node := doc // doesn't pass a pointer: unsettable - err := setSingleImpl(node, "new value", "a", jsonname.DefaultJSONNameProvider) + _, err := setSingleImpl(node, "new value", "a", jsonname.DefaultJSONNameProvider) require.Error(t, err) require.ErrorContains(t, err, `can't set struct field`) })