Skip to content

Commit 40688d8

Browse files
committed
events: add UnmarshalStreamImage and ToDynamoDBJSON helpers (#58)
Provide a backwards-compatible bridge between events.DynamoDBStreamRecord and the AWS SDK's dynamodb.AttributeValue without forcing a hard SDK dependency on aws-lambda-go. - UnmarshalStreamImage: decode a Keys/NewImage/OldImage map directly into a user struct using standard json tags. - DynamoDBAttributeValue.ToDynamoDBJSON / ToDynamoDBJSONMap: emit the canonical DynamoDB JSON wire form for hand-off to either aws-sdk-go or aws-sdk-go-v2. - DynamoDBStreamRecord.ToDynamoDBJSON: convenience envelope for all three image fields. Tests cover scalar, nested, null, set and testdata cases. Existing public API is unchanged.
1 parent 815d21f commit 40688d8

2 files changed

Lines changed: 325 additions & 0 deletions

File tree

events/dynamodb_helpers.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
3+
package events
4+
5+
import (
6+
"encoding/json"
7+
"fmt"
8+
)
9+
10+
// UnmarshalStreamImage unmarshals a stream image (a map of DynamoDBAttributeValue
11+
// keyed by attribute name, as found in DynamoDBStreamRecord.Keys, NewImage and
12+
// OldImage) into the provided destination value.
13+
//
14+
// The destination is decoded using the standard encoding/json package, so
15+
// `json` struct tags work as expected. This sidesteps the long-standing
16+
// limitation that DynamoDBAttributeValue is not directly compatible with the
17+
// dynamodbattribute helpers shipped by the AWS SDK (see issue #58).
18+
//
19+
// Typical usage:
20+
//
21+
// type MyItem struct {
22+
// ID string `json:"id"`
23+
// Name string `json:"name"`
24+
// }
25+
//
26+
// var item MyItem
27+
// if err := events.UnmarshalStreamImage(record.Change.NewImage, &item); err != nil {
28+
// return err
29+
// }
30+
//
31+
// Note that this helper does NOT honor `dynamodbav` tags used by the AWS SDK's
32+
// dynamodbattribute package. For SDK-tag-aware decoding, use ToDynamoDBJSON to
33+
// emit canonical DynamoDB JSON and feed it to your SDK of choice.
34+
func UnmarshalStreamImage(image map[string]DynamoDBAttributeValue, out interface{}) error {
35+
flat := make(map[string]interface{}, len(image))
36+
for k, v := range image {
37+
raw, err := flattenAttributeValue(v)
38+
if err != nil {
39+
return fmt.Errorf("UnmarshalStreamImage: %q: %w", k, err)
40+
}
41+
flat[k] = raw
42+
}
43+
44+
encoded, err := json.Marshal(flat)
45+
if err != nil {
46+
return fmt.Errorf("UnmarshalStreamImage: encode: %w", err)
47+
}
48+
if err := json.Unmarshal(encoded, out); err != nil {
49+
return fmt.Errorf("UnmarshalStreamImage: decode: %w", err)
50+
}
51+
return nil
52+
}
53+
54+
// ToDynamoDBJSON returns the canonical DynamoDB JSON wire form of an attribute
55+
// value (for example {"S":"hello"} or {"N":"123"}). The returned bytes are
56+
// directly compatible with json.Unmarshal-ing into the AttributeValue type
57+
// defined by either aws-sdk-go (service/dynamodb.AttributeValue) or
58+
// aws-sdk-go-v2 (service/dynamodb/types.AttributeValueMemberX), via the
59+
// standard encoding/json package.
60+
//
61+
// This avoids forcing aws-lambda-go to take a hard dependency on either SDK
62+
// version while still giving callers a stable bridge into SDK types.
63+
func (av DynamoDBAttributeValue) ToDynamoDBJSON() ([]byte, error) {
64+
return av.MarshalJSON()
65+
}
66+
67+
// ToDynamoDBJSONMap returns the canonical DynamoDB JSON wire form of an
68+
// attribute-value map, suitable for json.Unmarshal-ing into
69+
// map[string]*dynamodb.AttributeValue (SDK v1) or
70+
// map[string]types.AttributeValue (SDK v2).
71+
func ToDynamoDBJSONMap(image map[string]DynamoDBAttributeValue) ([]byte, error) {
72+
return json.Marshal(image)
73+
}
74+
75+
// ToDynamoDBJSON returns the canonical DynamoDB JSON wire form of this stream
76+
// record's Keys, NewImage and OldImage, structured as a top-level object with
77+
// those three keys (any of which may be omitted when empty). This is a
78+
// convenience for callers who want a single round-trip into SDK types:
79+
//
80+
// raw, _ := record.Change.ToDynamoDBJSON()
81+
// // json.Unmarshal(raw, &someSDKShape)
82+
func (r DynamoDBStreamRecord) ToDynamoDBJSON() ([]byte, error) {
83+
envelope := struct {
84+
Keys map[string]DynamoDBAttributeValue `json:"Keys,omitempty"`
85+
NewImage map[string]DynamoDBAttributeValue `json:"NewImage,omitempty"`
86+
OldImage map[string]DynamoDBAttributeValue `json:"OldImage,omitempty"`
87+
}{
88+
Keys: r.Keys,
89+
NewImage: r.NewImage,
90+
OldImage: r.OldImage,
91+
}
92+
return json.Marshal(envelope)
93+
}
94+
95+
// flattenAttributeValue converts a DynamoDBAttributeValue into a plain Go
96+
// value (string, float64, bool, []byte, []interface{}, map[string]interface{}
97+
// or nil) so it can be re-encoded as ordinary JSON for downstream
98+
// json.Unmarshal calls. Numbers are decoded with json.Number to preserve
99+
// precision when the destination uses json.Number / json.Decoder.UseNumber.
100+
func flattenAttributeValue(av DynamoDBAttributeValue) (interface{}, error) {
101+
switch av.DataType() {
102+
case DataTypeNull:
103+
return nil, nil
104+
case DataTypeString:
105+
return av.String(), nil
106+
case DataTypeNumber:
107+
return json.Number(av.Number()), nil
108+
case DataTypeBoolean:
109+
return av.Boolean(), nil
110+
case DataTypeBinary:
111+
// Mirror DynamoDB's wire shape: binaries are base64 strings on the wire.
112+
return av.Binary(), nil
113+
case DataTypeStringSet:
114+
return av.StringSet(), nil
115+
case DataTypeNumberSet:
116+
ns := av.NumberSet()
117+
out := make([]json.Number, len(ns))
118+
for i, n := range ns {
119+
out[i] = json.Number(n)
120+
}
121+
return out, nil
122+
case DataTypeBinarySet:
123+
return av.BinarySet(), nil
124+
case DataTypeList:
125+
list := av.List()
126+
out := make([]interface{}, len(list))
127+
for i, item := range list {
128+
v, err := flattenAttributeValue(item)
129+
if err != nil {
130+
return nil, err
131+
}
132+
out[i] = v
133+
}
134+
return out, nil
135+
case DataTypeMap:
136+
m := av.Map()
137+
out := make(map[string]interface{}, len(m))
138+
for k, item := range m {
139+
v, err := flattenAttributeValue(item)
140+
if err != nil {
141+
return nil, err
142+
}
143+
out[k] = v
144+
}
145+
return out, nil
146+
default:
147+
return nil, fmt.Errorf("unsupported DynamoDB data type %v", av.DataType())
148+
}
149+
}

events/dynamodb_helpers_test.go

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
3+
package events
4+
5+
import (
6+
"encoding/json"
7+
"io/ioutil"
8+
"testing"
9+
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func TestUnmarshalStreamImage_ScalarFields(t *testing.T) {
15+
image := map[string]DynamoDBAttributeValue{
16+
"id": NewStringAttribute("abc-123"),
17+
"count": NewNumberAttribute("42"),
18+
"score": NewNumberAttribute("3.14"),
19+
"active": NewBooleanAttribute(true),
20+
"tags": NewStringSetAttribute([]string{"alpha", "beta"}),
21+
}
22+
23+
type item struct {
24+
ID string `json:"id"`
25+
Count int `json:"count"`
26+
Score float64 `json:"score"`
27+
Active bool `json:"active"`
28+
Tags []string `json:"tags"`
29+
}
30+
31+
var got item
32+
require.NoError(t, UnmarshalStreamImage(image, &got))
33+
assert.Equal(t, "abc-123", got.ID)
34+
assert.Equal(t, 42, got.Count)
35+
assert.InDelta(t, 3.14, got.Score, 1e-9)
36+
assert.True(t, got.Active)
37+
assert.ElementsMatch(t, []string{"alpha", "beta"}, got.Tags)
38+
}
39+
40+
func TestUnmarshalStreamImage_NestedMapAndList(t *testing.T) {
41+
image := map[string]DynamoDBAttributeValue{
42+
"user": NewMapAttribute(map[string]DynamoDBAttributeValue{
43+
"name": NewStringAttribute("Joe"),
44+
"age": NewNumberAttribute("35"),
45+
}),
46+
"items": NewListAttribute([]DynamoDBAttributeValue{
47+
NewStringAttribute("Cookies"),
48+
NewStringAttribute("Coffee"),
49+
}),
50+
}
51+
52+
type user struct {
53+
Name string `json:"name"`
54+
Age int `json:"age"`
55+
}
56+
type item struct {
57+
User user `json:"user"`
58+
Items []string `json:"items"`
59+
}
60+
61+
var got item
62+
require.NoError(t, UnmarshalStreamImage(image, &got))
63+
assert.Equal(t, "Joe", got.User.Name)
64+
assert.Equal(t, 35, got.User.Age)
65+
assert.Equal(t, []string{"Cookies", "Coffee"}, got.Items)
66+
}
67+
68+
func TestUnmarshalStreamImage_NullAttribute(t *testing.T) {
69+
image := map[string]DynamoDBAttributeValue{
70+
"deleted_at": NewNullAttribute(),
71+
"name": NewStringAttribute("ada"),
72+
}
73+
74+
type item struct {
75+
Name string `json:"name"`
76+
DeletedAt *string `json:"deleted_at"`
77+
}
78+
79+
var got item
80+
require.NoError(t, UnmarshalStreamImage(image, &got))
81+
assert.Equal(t, "ada", got.Name)
82+
assert.Nil(t, got.DeletedAt)
83+
}
84+
85+
func TestUnmarshalStreamImage_FromTestdata(t *testing.T) {
86+
raw, err := ioutil.ReadFile("./testdata/dynamodb-event.json")
87+
require.NoError(t, err)
88+
89+
var evt DynamoDBEvent
90+
require.NoError(t, json.Unmarshal(raw, &evt))
91+
require.NotEmpty(t, evt.Records)
92+
93+
first := evt.Records[0].Change.NewImage
94+
type partial struct {
95+
Val string `json:"val"`
96+
Key string `json:"key"`
97+
}
98+
var got partial
99+
require.NoError(t, UnmarshalStreamImage(first, &got))
100+
assert.Equal(t, "data", got.Val)
101+
assert.Equal(t, "binary", got.Key)
102+
}
103+
104+
func TestToDynamoDBJSON_AttributeValueRoundTrip(t *testing.T) {
105+
av := NewStringAttribute("hello")
106+
raw, err := av.ToDynamoDBJSON()
107+
require.NoError(t, err)
108+
109+
var decoded DynamoDBAttributeValue
110+
require.NoError(t, json.Unmarshal(raw, &decoded))
111+
assert.Equal(t, "hello", decoded.String())
112+
}
113+
114+
func TestToDynamoDBJSONMap_PreservesShape(t *testing.T) {
115+
image := map[string]DynamoDBAttributeValue{
116+
"id": NewStringAttribute("k1"),
117+
"qty": NewNumberAttribute("7"),
118+
}
119+
120+
raw, err := ToDynamoDBJSONMap(image)
121+
require.NoError(t, err)
122+
123+
var decoded map[string]DynamoDBAttributeValue
124+
require.NoError(t, json.Unmarshal(raw, &decoded))
125+
assert.Equal(t, "k1", decoded["id"].String())
126+
assert.Equal(t, "7", decoded["qty"].Number())
127+
}
128+
129+
func TestStreamRecord_ToDynamoDBJSON_OmitsEmpty(t *testing.T) {
130+
rec := DynamoDBStreamRecord{
131+
Keys: map[string]DynamoDBAttributeValue{
132+
"pk": NewStringAttribute("k1"),
133+
},
134+
}
135+
136+
raw, err := rec.ToDynamoDBJSON()
137+
require.NoError(t, err)
138+
139+
var envelope map[string]json.RawMessage
140+
require.NoError(t, json.Unmarshal(raw, &envelope))
141+
_, hasKeys := envelope["Keys"]
142+
_, hasNew := envelope["NewImage"]
143+
_, hasOld := envelope["OldImage"]
144+
assert.True(t, hasKeys)
145+
assert.False(t, hasNew)
146+
assert.False(t, hasOld)
147+
}
148+
149+
func TestStreamRecord_ToDynamoDBJSON_AllSections(t *testing.T) {
150+
rec := DynamoDBStreamRecord{
151+
Keys: map[string]DynamoDBAttributeValue{
152+
"pk": NewStringAttribute("k1"),
153+
},
154+
NewImage: map[string]DynamoDBAttributeValue{
155+
"pk": NewStringAttribute("k1"),
156+
"qty": NewNumberAttribute("7"),
157+
},
158+
OldImage: map[string]DynamoDBAttributeValue{
159+
"pk": NewStringAttribute("k1"),
160+
"qty": NewNumberAttribute("3"),
161+
},
162+
}
163+
164+
raw, err := rec.ToDynamoDBJSON()
165+
require.NoError(t, err)
166+
167+
var envelope struct {
168+
Keys map[string]DynamoDBAttributeValue `json:"Keys"`
169+
NewImage map[string]DynamoDBAttributeValue `json:"NewImage"`
170+
OldImage map[string]DynamoDBAttributeValue `json:"OldImage"`
171+
}
172+
require.NoError(t, json.Unmarshal(raw, &envelope))
173+
assert.Equal(t, "k1", envelope.Keys["pk"].String())
174+
assert.Equal(t, "7", envelope.NewImage["qty"].Number())
175+
assert.Equal(t, "3", envelope.OldImage["qty"].Number())
176+
}

0 commit comments

Comments
 (0)