Skip to content

Commit d33a654

Browse files
committed
feat: implement extension caching
Extensions objects will now cache any extensions they don't recognize (that don't correspond to a field inside their registered IMapValue) when deserializing values. This means CORIMs with extensions remain stable when deserialized and then re-serialized using structs without registered extensions. Cached extensions are only used during marshalling, and will not be returned when calling the Get* methods. However they can be accessed directly via Extensions.Cached field. When an IMapValue struct is registered, cached values are scanned, and the new struct is populated with cached values, which are then removed form the cache. When registering a new struct when there is an existing IMapValue, the non-zero-value fields of the old IMapValue are stored in the cache. Signed-off-by: Sergei Trofimov <[email protected]>
1 parent 31325d4 commit d33a654

File tree

3 files changed

+226
-4
lines changed

3 files changed

+226
-4
lines changed

extensions/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,20 @@ method, passing itself as the parameter.
127127
You do not need to define this method unless you actually want to enforce some
128128
constraints (i.e., if you just want to define additional fields).
129129

130+
### Unknown extensions caching
131+
132+
When unmarshaled data contains entries that do not correspond to fields inside
133+
a registered extensions struct, their values get cached inside the `Extensions`
134+
objects. If the containing object is later re-marshalled, cached values will be
135+
included, so that unknown extensions are not lost.
136+
137+
Cached extension values are not accessible via `Get*()` methods described
138+
above, however they are available as `Extensions.Cached` map.
139+
140+
If an extensions struct is registered after unmarshalling, it will be populated
141+
with any now-recognized cached values, which will then be removed from the
142+
cache.
143+
130144
### Example
131145

132146
The following example illustrates how to implement a map extension by extending

extensions/extensions.go

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2023-2024 Contributors to the Veraison project.
1+
// Copyright 2023-2025 Contributors to the Veraison project.
22
// SPDX-License-Identifier: Apache-2.0
33
package extensions
44

@@ -24,14 +24,24 @@ type ExtensionValue struct {
2424

2525
type Extensions struct {
2626
IMapValue `json:"extensions,omitempty"`
27+
28+
Cached map[string]any `field-cache:"" cbor:"-" json:"-"`
2729
}
2830

2931
func (o *Extensions) Register(exts IMapValue) {
3032
if reflect.TypeOf(exts).Kind() != reflect.Pointer {
3133
panic("attempting to register a non-pointer IMapValue")
3234
}
3335

36+
// Ensure that the values of any existing extensions are preserved.
37+
// The contents of the existing IMapValue (if there is one) are added
38+
// to the cache, which is then applied to the new IMapValue. If the new
39+
// IMapValue has fields corresponding to the old extensions, they will be
40+
// populated into the new IMapValue; any old extensions that are not
41+
// recognised by the new IMapValue will be cached.
42+
updateMapFromInterface(&o.Cached, o.IMapValue)
3443
o.IMapValue = exts
44+
updateInterfaceFromMap(o.IMapValue, o.Cached)
3545
}
3646

3747
func (o *Extensions) HaveExtensions() bool {
@@ -455,3 +465,89 @@ func newIMapValue(v IMapValue) IMapValue {
455465

456466
return reflect.New(valType).Interface()
457467
}
468+
469+
func updateMapFromInterface(mp *map[string]any, iface any) { // nolint: gocritic
470+
if iface == nil {
471+
return
472+
}
473+
474+
if *mp == nil {
475+
*mp = make(map[string]any)
476+
}
477+
478+
ifType := reflect.TypeOf(iface)
479+
ifVal := reflect.ValueOf(iface)
480+
if ifType.Kind() == reflect.Pointer {
481+
ifType = ifType.Elem()
482+
ifVal = ifVal.Elem()
483+
}
484+
485+
for i := 0; i < ifVal.NumField(); i++ {
486+
typeField := ifType.Field(i)
487+
tag, ok := typeField.Tag.Lookup("cbor")
488+
if !ok {
489+
continue
490+
}
491+
492+
codePointText := strings.Split(tag, ",")[0]
493+
valField := ifVal.Field(i)
494+
if !valField.IsZero() {
495+
(*mp)[codePointText] = valField.Interface()
496+
}
497+
}
498+
}
499+
500+
func updateInterfaceFromMap(iface any, m map[string]any) {
501+
if iface == nil {
502+
panic("nil interface")
503+
}
504+
505+
ifType := reflect.TypeOf(iface)
506+
if ifType.Kind() != reflect.Pointer {
507+
panic("interface must be a pointer")
508+
}
509+
510+
ifType = ifType.Elem()
511+
ifVal := reflect.ValueOf(iface).Elem()
512+
513+
for i := 0; i < ifVal.NumField(); i++ {
514+
var fieldJSONTag, fieldCBORTag string
515+
typeField := ifType.Field(i)
516+
valField := ifVal.Field(i)
517+
518+
tag, ok := typeField.Tag.Lookup("json")
519+
if ok {
520+
fieldJSONTag = strings.Split(tag, ",")[0]
521+
}
522+
523+
tag, ok = typeField.Tag.Lookup("cbor")
524+
if ok {
525+
fieldCBORTag = strings.Split(tag, ",")[0]
526+
}
527+
528+
mapKey := fieldJSONTag
529+
rawMapVal, ok := m[mapKey]
530+
if !ok {
531+
mapKey = fieldCBORTag
532+
rawMapVal, ok = m[mapKey]
533+
if !ok {
534+
continue
535+
}
536+
}
537+
538+
mapVal := reflect.ValueOf(rawMapVal)
539+
if !mapVal.Type().AssignableTo(typeField.Type) {
540+
if mapVal.Type().ConvertibleTo(typeField.Type) {
541+
mapVal = mapVal.Convert(typeField.Type)
542+
} else {
543+
// We cannot return an error here, and we don't
544+
// want to panic, so we're just going to keep the
545+
// entry in the cache.
546+
continue
547+
}
548+
}
549+
550+
valField.Set(mapVal)
551+
delete(m, mapKey)
552+
}
553+
}

extensions/extensions_test.go

Lines changed: 115 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2023-2024 Contributors to the Veraison project.
1+
// Copyright 2023-2025 Contributors to the Veraison project.
22
// SPDX-License-Identifier: Apache-2.0
33
package extensions
44

@@ -7,11 +7,12 @@ import (
77

88
"github.com/stretchr/testify/assert"
99
"github.com/stretchr/testify/require"
10+
"github.com/veraison/corim/encoding"
1011
)
1112

1213
type Entity struct {
13-
EntityName string
14-
Roles []int64
14+
EntityName string `cbor:"0,keyasint" json:"entity-name"`
15+
Roles []int64 `cbor:"1,keyasint,omitempty" json:"roles,omitempty"`
1516

1617
Extensions
1718
}
@@ -168,3 +169,114 @@ func Test_Extensions_Values(t *testing.T) {
168169
vals = exts.Values()
169170
assert.Len(t, vals, 0)
170171
}
172+
173+
func Test_Extensions_unknown_handling_CBOR(t *testing.T) {
174+
// nolint: gocritic
175+
data := []byte{
176+
0xa4, // map(4) [entity]
177+
178+
0x00, // key: 0 [entity-name]
179+
0x63, // value: tstr(3)
180+
0x66, 0x6f, 0x6f, // "foo"
181+
182+
0x1, // key: 1 [roles]
183+
0x82, // value: array(2)
184+
0x01, 0x02, // [1, 2]
185+
186+
0x21, // key: -2 [extension(size)]
187+
0x07, // value: 7
188+
189+
0x27, // key: -8 [extension(<unknown>)]
190+
0xf5, // value: true
191+
}
192+
193+
entity := Entity{}
194+
err := encoding.PopulateStructFromCBOR(dm, data, &entity)
195+
assert.NoError(t, err)
196+
assert.Equal(t, "foo", entity.EntityName)
197+
assert.Equal(t, []int64{1, 2}, entity.Roles)
198+
assert.Equal(t, uint64(7), entity.Extensions.Cached["-2"]) // nolint: staticcheck
199+
200+
// Check that the cached value has been populated into the
201+
// newly-registered struct.
202+
entity.Register(&TestExtensions{})
203+
assert.Equal(t, 7, entity.MustGetInt("size"))
204+
205+
// Check that the populated value is no longer cached.
206+
_, ok := entity.Extensions.Cached["-2"] // nolint: staticcheck
207+
assert.False(t, ok)
208+
209+
entity = Entity{}
210+
entity.Register(&TestExtensions{})
211+
err = encoding.PopulateStructFromCBOR(dm, data, &entity)
212+
assert.NoError(t, err)
213+
214+
// If extensions were registered before unmarshalling, the value gets
215+
// populated directily into the registred struct, bypassing the cache.
216+
assert.Equal(t, 7, entity.MustGetInt("size"))
217+
_, ok = entity.Extensions.Cached["-2"] // nolint: staticcheck
218+
assert.False(t, ok)
219+
220+
// Values for keys in in the registered struct still go into cache.
221+
val, ok := entity.Extensions.Cached["-8"] // nolint: staticcheck
222+
assert.True(t, ok)
223+
assert.True(t, val.(bool))
224+
225+
encoded, err := encoding.SerializeStructToCBOR(em, &entity)
226+
assert.NoError(t, err)
227+
assert.Equal(t, data, encoded)
228+
}
229+
230+
func Test_Extensions_unknown_handling_JSON(t *testing.T) {
231+
data := []byte(`{"entity-name":"foo","roles":[1,2],"size":7,"-8":true}`)
232+
233+
entity := Entity{}
234+
err := encoding.PopulateStructFromJSON(data, &entity)
235+
assert.NoError(t, err)
236+
assert.Equal(t, "foo", entity.EntityName)
237+
assert.Equal(t, []int64{1, 2}, entity.Roles)
238+
assert.Equal(t, float64(7), entity.Extensions.Cached["size"]) // nolint: staticcheck
239+
240+
// since we only have the JSON field name "size", and we don't know
241+
// what extension it corresponds to, CBOR encoding fails.
242+
_, err = encoding.SerializeStructToCBOR(em, &entity)
243+
assert.ErrorContains(t, err, "cached field name not an integer")
244+
245+
// Check that the cached value has been populated into the
246+
// newly-registered struct.
247+
entity.Register(&TestExtensions{})
248+
assert.Equal(t, 7, entity.MustGetInt("size"))
249+
250+
// Check that the populated value is no longer cached.
251+
_, ok := entity.Extensions.Cached["size"] // nolint: staticcheck
252+
assert.False(t, ok)
253+
254+
// "size" has been recoginized and removed form cache; we can now
255+
// serialize it to CBOR as we now know its code point. The only
256+
// remaining unknown extension has a name that can parse to an integer,
257+
// so we can use that as the code point for CBOR, and serialization
258+
// should succeed.
259+
_, err = encoding.SerializeStructToCBOR(em, &entity)
260+
assert.NoError(t, err)
261+
262+
entity = Entity{}
263+
entity.Register(&TestExtensions{})
264+
err = encoding.PopulateStructFromJSON(data, &entity)
265+
assert.NoError(t, err)
266+
267+
// If extensions were registered before unmarshalling, the value gets
268+
// populated directily into the registred struct, bypassing the cache.
269+
assert.Equal(t, 7, entity.MustGetInt("size"))
270+
_, ok = entity.Extensions.Cached["size"] // nolint: staticcheck
271+
assert.False(t, ok)
272+
273+
// Values for keys in in the registered struct still go into cache.
274+
val, ok := entity.Extensions.Cached["-8"] // nolint: staticcheck
275+
assert.True(t, ok)
276+
assert.True(t, val.(bool))
277+
278+
encoded, err := encoding.SerializeStructToJSON(&entity)
279+
assert.NoError(t, err)
280+
281+
assert.JSONEq(t, string(data), string(encoded))
282+
}

0 commit comments

Comments
 (0)