Skip to content

Commit 04588aa

Browse files
authored
feat: added optional support for non-default json name provider (#122)
Signed-off-by: Frederic BIDON <fredbi@yahoo.com>
1 parent bca4023 commit 04588aa

4 files changed

Lines changed: 268 additions & 49 deletions

File tree

ifaces.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2015-2025 go-swagger maintainers
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package jsonpointer
5+
6+
import "reflect"
7+
8+
// JSONPointable is an interface for structs to implement,
9+
// when they need to customize the json pointer process or want to avoid the use of reflection.
10+
type JSONPointable interface {
11+
// JSONLookup returns a value pointed at this (unescaped) key.
12+
JSONLookup(key string) (any, error)
13+
}
14+
15+
// JSONSetable is an interface for structs to implement,
16+
// when they need to customize the json pointer process or want to avoid the use of reflection.
17+
//
18+
// # Handling of the RFC 6901 "-" token
19+
//
20+
// When a type implementing JSONSetable is the terminal parent of a [Pointer.Set]
21+
// call, the library passes the raw reference token to JSONSet without
22+
// interpretation. In particular, the RFC 6901 "-" token (which conventionally
23+
// means "append" for arrays, per RFC 6902) is forwarded verbatim as the key
24+
// argument. Implementations that model an array-like container are expected
25+
// to give "-" the append semantics; implementations that do not should return
26+
// an error wrapping [ErrDashToken] (or [ErrPointer]) for clarity.
27+
//
28+
// Implementations are responsible for any in-place mutation: the library does
29+
// not attempt to rebind the result of JSONSet into a parent container.
30+
type JSONSetable interface {
31+
// JSONSet sets the value pointed at the (unescaped) key.
32+
//
33+
// The key may be the RFC 6901 "-" token when the pointer targets a
34+
// slice-like member; see the interface documentation for details.
35+
JSONSet(key string, value any) error
36+
}
37+
38+
// NameProvider knows how to resolve go struct fields into json names.
39+
//
40+
// The default provider is brought by [github.com/go-openapi/swag/jsonname.DefaultJSONNameProvider].
41+
type NameProvider interface {
42+
// GetGoName gets the go name for a json property name
43+
GetGoName(subject any, name string) (string, bool)
44+
45+
// GetGoNameForType gets the go name for a given type for a json property name
46+
GetGoNameForType(tpe reflect.Type, name string) (string, bool)
47+
}

options.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2015-2025 go-swagger maintainers
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package jsonpointer
5+
6+
import (
7+
"sync"
8+
9+
"github.com/go-openapi/swag/jsonname"
10+
)
11+
12+
// Option to tune the behavior of a JSON [Pointer].
13+
type Option func(*options)
14+
15+
var (
16+
//nolint:gochecknoglobals // package level defaults are provided as a convenient, backward-compatible way to adopt options.
17+
defaultOptions = options{
18+
provider: jsonname.DefaultJSONNameProvider,
19+
}
20+
//nolint:gochecknoglobals // guards defaultOptions against concurrent SetDefaultNameProvider / read races (testing)
21+
defaultOptionsMu sync.RWMutex
22+
)
23+
24+
// SetDefaultNameProvider sets the [NameProvider] as a package-level default.
25+
//
26+
// It is safe to call concurrently with [Pointer.Get], [Pointer.Set],
27+
// [GetForToken] and [SetForToken]. The typical usage is to call it once
28+
// at initialization time.
29+
//
30+
// A nil provider is ignored.
31+
func SetDefaultNameProvider(provider NameProvider) {
32+
if provider == nil {
33+
return
34+
}
35+
36+
defaultOptionsMu.Lock()
37+
defer defaultOptionsMu.Unlock()
38+
39+
defaultOptions.provider = provider
40+
}
41+
42+
// DefaultNameProvider returns the current package-level [NameProvider].
43+
func DefaultNameProvider() NameProvider { //nolint:ireturn // returning the interface is the point — callers pick their own implementation.
44+
defaultOptionsMu.RLock()
45+
defer defaultOptionsMu.RUnlock()
46+
47+
return defaultOptions.provider
48+
}
49+
50+
// WithNameProvider injects a custom [NameProvider] to resolve json names from go struct types.
51+
func WithNameProvider(provider NameProvider) Option {
52+
return func(o *options) {
53+
o.provider = provider
54+
}
55+
}
56+
57+
type options struct {
58+
provider NameProvider
59+
}
60+
61+
func optionsWithDefaults(opts []Option) options {
62+
var o options
63+
o.provider = DefaultNameProvider()
64+
65+
for _, apply := range opts {
66+
apply(&o)
67+
}
68+
69+
return o
70+
}

options_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2015-2025 go-swagger maintainers
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package jsonpointer
5+
6+
import (
7+
"reflect"
8+
"sync"
9+
"testing"
10+
11+
"github.com/go-openapi/testify/v2/assert"
12+
"github.com/go-openapi/testify/v2/require"
13+
)
14+
15+
// stubNameProvider is a NameProvider that maps JSON names to Go field names
16+
// via a fixed dictionary. It lets tests observe which provider was used by
17+
// the resolver without relying on the default reflection-based behavior.
18+
type stubNameProvider struct {
19+
mu sync.Mutex
20+
mapping map[string]string
21+
lookups []string
22+
forTypes []string
23+
}
24+
25+
func (s *stubNameProvider) GetGoName(_ any, name string) (string, bool) {
26+
s.record(name, false)
27+
goName, ok := s.mapping[name]
28+
return goName, ok
29+
}
30+
31+
func (s *stubNameProvider) GetGoNameForType(_ reflect.Type, name string) (string, bool) {
32+
s.record(name, true)
33+
goName, ok := s.mapping[name]
34+
return goName, ok
35+
}
36+
37+
func (s *stubNameProvider) record(name string, forType bool) {
38+
s.mu.Lock()
39+
defer s.mu.Unlock()
40+
41+
if forType {
42+
s.forTypes = append(s.forTypes, name)
43+
return
44+
}
45+
s.lookups = append(s.lookups, name)
46+
}
47+
48+
type optionStruct struct {
49+
// intentional: the JSON name "renamed" is deliberately not a valid
50+
// struct tag so that only a custom provider can resolve it.
51+
Field string
52+
}
53+
54+
func TestWithNameProvider_overridesDefault(t *testing.T) {
55+
t.Parallel()
56+
57+
stub := &stubNameProvider{mapping: map[string]string{"renamed": "Field"}}
58+
59+
doc := optionStruct{Field: "hello"}
60+
p, err := New("/renamed")
61+
require.NoError(t, err)
62+
63+
v, _, err := p.Get(doc, WithNameProvider(stub))
64+
require.NoError(t, err)
65+
assert.Equal(t, "hello", v)
66+
67+
stub.mu.Lock()
68+
defer stub.mu.Unlock()
69+
assert.Contains(t, stub.forTypes, "renamed", "custom provider must be consulted")
70+
}
71+
72+
func TestWithNameProvider_setRoutesThroughProvider(t *testing.T) {
73+
t.Parallel()
74+
75+
stub := &stubNameProvider{mapping: map[string]string{"renamed": "Field"}}
76+
77+
doc := &optionStruct{Field: "before"}
78+
p, err := New("/renamed")
79+
require.NoError(t, err)
80+
81+
_, err = p.Set(doc, "after", WithNameProvider(stub))
82+
require.NoError(t, err)
83+
assert.Equal(t, "after", doc.Field)
84+
}
85+
86+
func TestSetDefaultNameProvider_roundTrip(t *testing.T) {
87+
// Not Parallel: mutates package state.
88+
original := DefaultNameProvider()
89+
t.Cleanup(func() { SetDefaultNameProvider(original) })
90+
91+
stub := &stubNameProvider{mapping: map[string]string{"renamed": "Field"}}
92+
SetDefaultNameProvider(stub)
93+
94+
assert.Same(t, stub, DefaultNameProvider())
95+
96+
doc := optionStruct{Field: "hello"}
97+
p, err := New("/renamed")
98+
require.NoError(t, err)
99+
100+
v, _, err := p.Get(doc)
101+
require.NoError(t, err)
102+
assert.Equal(t, "hello", v)
103+
}
104+
105+
func TestSetDefaultNameProvider_nilIgnored(t *testing.T) {
106+
// Not Parallel: mutates package state.
107+
original := DefaultNameProvider()
108+
t.Cleanup(func() { SetDefaultNameProvider(original) })
109+
110+
SetDefaultNameProvider(nil)
111+
assert.Same(t, original, DefaultNameProvider(), "nil must be a no-op")
112+
}
113+
114+
func TestDefaultNameProvider_reachesGetForToken(t *testing.T) {
115+
// Not Parallel: mutates package state.
116+
original := DefaultNameProvider()
117+
t.Cleanup(func() { SetDefaultNameProvider(original) })
118+
119+
stub := &stubNameProvider{mapping: map[string]string{"renamed": "Field"}}
120+
SetDefaultNameProvider(stub)
121+
122+
doc := optionStruct{Field: "hello"}
123+
v, _, err := GetForToken(doc, "renamed")
124+
require.NoError(t, err)
125+
assert.Equal(t, "hello", v)
126+
}

0 commit comments

Comments
 (0)