From 9a7443aa3c2387fded9bc3ebcb20f6dac01132f4 Mon Sep 17 00:00:00 2001 From: Alex Shovlin Date: Mon, 4 Mar 2024 09:23:38 -0600 Subject: [PATCH] feat: Add ToJson for DynamoDBEvent (#1685) --- .../Amazon.Lambda.DynamoDBEvents.csproj | 2 +- .../ExtensionMethods.cs | 157 ++++++ .../EventsTests.NET6/EventsTests.NET6.csproj | 4 +- .../EventsTests.NETCore31.csproj | 4 +- .../DynamoDBEventJsonTests.cs | 445 ++++++++++++++++++ .../EventsTests.Shared.projitems | 1 + 6 files changed, 610 insertions(+), 3 deletions(-) create mode 100644 Libraries/src/Amazon.Lambda.DynamoDBEvents/ExtensionMethods.cs create mode 100644 Libraries/test/EventsTests.Shared/DynamoDBEventJsonTests.cs diff --git a/Libraries/src/Amazon.Lambda.DynamoDBEvents/Amazon.Lambda.DynamoDBEvents.csproj b/Libraries/src/Amazon.Lambda.DynamoDBEvents/Amazon.Lambda.DynamoDBEvents.csproj index 7a0c0cccc..a81d1bc89 100644 --- a/Libraries/src/Amazon.Lambda.DynamoDBEvents/Amazon.Lambda.DynamoDBEvents.csproj +++ b/Libraries/src/Amazon.Lambda.DynamoDBEvents/Amazon.Lambda.DynamoDBEvents.csproj @@ -6,7 +6,7 @@ netcoreapp3.1;net8.0 Amazon Lambda .NET Core support - DynamoDBEvents package. Amazon.Lambda.DynamoDBEvents - 3.0.0 + 3.1.0 Amazon.Lambda.DynamoDBEvents Amazon.Lambda.DynamoDBEvents AWS;Amazon;Lambda diff --git a/Libraries/src/Amazon.Lambda.DynamoDBEvents/ExtensionMethods.cs b/Libraries/src/Amazon.Lambda.DynamoDBEvents/ExtensionMethods.cs new file mode 100644 index 000000000..9fc46b38c --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DynamoDBEvents/ExtensionMethods.cs @@ -0,0 +1,157 @@ +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Json; +using static Amazon.Lambda.DynamoDBEvents.DynamoDBEvent; + +namespace Amazon.Lambda.DynamoDBEvents +{ + /// + /// Extension methods for working with + /// + public static class ExtensionMethods + { + /// + /// Converts a dictionary representing a DynamoDB item to a JSON string. + /// This may be useful when casting a DynamoDB Lambda event to the AWS SDK's + /// higher-level document and object persistence classes. + /// + /// Dictionary representing a DynamoDB item + /// Unformatted JSON string representing the DynamoDB item + public static string ToJson(this Dictionary item) + { + return ToJson(item, false); + } + + /// + /// Converts a dictionary representing a DynamoDB item to a JSON string. + /// This may be useful when casting a DynamoDB Lambda event to the AWS SDK's + /// higher-level document and object persistence classes. + /// + /// Dictionary representing a DynamoDB item + /// Formatted JSON string representing the DynamoDB item + public static string ToJsonPretty(this Dictionary item) + { + return ToJson(item, true); + } + + /// + /// Internal entry point for converting a dictionary representing a DynamoDB item to a JSON string. + /// + /// Dictionary representing a DynamoDB item + /// Whether the resulting JSON should be formatted + /// JSON string representing the DynamoDB item + private static string ToJson(Dictionary item, bool prettyPrint) + { + if (item == null || item.Count == 0) + { + return "{}"; + } + + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = prettyPrint}); + + WriteJson(writer, item); + + writer.Flush(); + return Encoding.UTF8.GetString(stream.ToArray()); + } + + /// + /// Writes a single DynamoDB attribute as a json. May be called recursively for maps. + /// + /// JSON writer + /// Dictionary representing a DynamoDB item, or a map within an item + private static void WriteJson(Utf8JsonWriter writer, Dictionary item) + { + writer.WriteStartObject(); + + foreach (var attribute in item) + { + writer.WritePropertyName(attribute.Key); + WriteJsonValue(writer, attribute.Value); + } + + writer.WriteEndObject(); + } + + /// + /// Writes a single DynamoDB attribute value as a json value + /// + /// JSON writer + /// DynamoDB attribute + private static void WriteJsonValue(Utf8JsonWriter writer, AttributeValue attribute) + { + if (attribute.S != null) + { + writer.WriteStringValue(attribute.S); + } + else if (attribute.N != null) + { +#if NETCOREAPP3_1 // WriteRawValue was added in .NET 6, but we need to write out Number values without quotes + using var document = JsonDocument.Parse(attribute.N); + document.WriteTo(writer); +#else + writer.WriteRawValue(attribute.N); +#endif + } + else if (attribute.B != null) + { + writer.WriteBase64StringValue(attribute.B.ToArray()); + } + else if (attribute.BOOL != null) + { + writer.WriteBooleanValue(attribute.BOOL.Value); + } + else if (attribute.NULL != null) + { + writer.WriteNullValue(); + } + else if (attribute.M != null) + { + WriteJson(writer, attribute.M); + } + else if (attribute.L != null) + { + writer.WriteStartArray(); + foreach (var item in attribute.L) + { + WriteJsonValue(writer, item); + } + writer.WriteEndArray(); + } + else if (attribute.SS != null) + { + writer.WriteStartArray(); + foreach (var item in attribute.SS) + { + writer.WriteStringValue(item); + } + writer.WriteEndArray(); + } + else if (attribute.NS != null) + { + writer.WriteStartArray(); + foreach (var item in attribute.NS) + { +#if NETCOREAPP3_1 // WriteRawValue was added in .NET 6, but we need to write out Number values without quotes + using var document = JsonDocument.Parse(item); + document.WriteTo(writer); +#else + writer.WriteRawValue(item); +#endif + } + writer.WriteEndArray(); + } + else if (attribute.BS != null) + { + writer.WriteStartArray(); + foreach (var item in attribute.BS) + { + writer.WriteBase64StringValue(item.ToArray()); + } + writer.WriteEndArray(); + } + } + } +} diff --git a/Libraries/test/EventsTests.NET6/EventsTests.NET6.csproj b/Libraries/test/EventsTests.NET6/EventsTests.NET6.csproj index de4c6febd..af60ce665 100644 --- a/Libraries/test/EventsTests.NET6/EventsTests.NET6.csproj +++ b/Libraries/test/EventsTests.NET6/EventsTests.NET6.csproj @@ -5,6 +5,7 @@ EventsTests31 true EventsTests.NET6 + latest @@ -54,13 +55,14 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/Libraries/test/EventsTests.NETCore31/EventsTests.NETCore31.csproj b/Libraries/test/EventsTests.NETCore31/EventsTests.NETCore31.csproj index 362aa8a3c..1c5812340 100644 --- a/Libraries/test/EventsTests.NETCore31/EventsTests.NETCore31.csproj +++ b/Libraries/test/EventsTests.NETCore31/EventsTests.NETCore31.csproj @@ -6,6 +6,7 @@ true win7-x64;win7-x86 EventsTests31 + latest @@ -54,10 +55,11 @@ + - + diff --git a/Libraries/test/EventsTests.Shared/DynamoDBEventJsonTests.cs b/Libraries/test/EventsTests.Shared/DynamoDBEventJsonTests.cs new file mode 100644 index 000000000..f188ef5bb --- /dev/null +++ b/Libraries/test/EventsTests.Shared/DynamoDBEventJsonTests.cs @@ -0,0 +1,445 @@ +using Amazon.DynamoDBv2.DocumentModel; +using Amazon.Lambda.DynamoDBEvents; +using System.Collections.Generic; +using System.IO; +using System.Text; +using Xunit; +using static Amazon.Lambda.DynamoDBEvents.DynamoDBEvent; + +namespace Amazon.Lambda.Tests +{ + /// + /// Tests converting to JSON and AWS SDK types + /// + public class DynamoDBEventTests + { + /// + /// Internal helper that prepares a Lambda DynamoDB event containing a given DynamoDB item + /// + private DynamoDBEvent PrepareEvent(Dictionary attributes) + { + return new DynamoDBEvent + { + Records = new List + { + new DynamodbStreamRecord + { + Dynamodb = new StreamRecord() + { + NewImage = attributes + } + } + } + }; + } + + [Fact] + public void String_ToJson() + { + var evnt = PrepareEvent(new Dictionary() + { + { "Message", new AttributeValue {S = "This is a string" } } + }); + + var json = evnt.Records[0].Dynamodb.NewImage.ToJson(); + + Assert.Equal("{\"Message\":\"This is a string\"}", json); + } + + [Fact] + public void String_ToDocument() + { + var evnt = PrepareEvent(new Dictionary() + { + { "Message", new AttributeValue {S = "This is a string" } } + }); + + // Convert the event from the Lambda package to the SDK type + var json = evnt.Records[0].Dynamodb.NewImage.ToJson(); + var document = Document.FromJson(json); + + Assert.NotNull(document["Message"]); + Assert.Equal("This is a string", document["Message"].AsString()); + } + + [Fact] + public void Number_ToJson() + { + var evnt = PrepareEvent(new Dictionary() + { + { "Integer", new AttributeValue {N = "123" } }, + { "Double", new AttributeValue {N = "123.45" } } + }); + + var json = evnt.Records[0].Dynamodb.NewImage.ToJson(); + + Assert.Equal("{\"Integer\":123,\"Double\":123.45}", json); + } + + [Fact] + public void Number_ToDocument() + { + var evnt = PrepareEvent(new Dictionary() + { + { "Integer", new AttributeValue {N = "123" } }, + { "Double", new AttributeValue {N = "123.45" } } + }); + + // Convert the event from the Lambda package to the SDK type + var json = evnt.Records[0].Dynamodb.NewImage.ToJson(); + var document = Document.FromJson(json); + + Assert.Equal(123, document["Integer"].AsInt()); + Assert.Equal(123.45, document["Double"].AsDouble()); + } + + [Fact] + public void Binary_ToJson() + { + var evnt = PrepareEvent(new Dictionary() + { + { "Binary", new AttributeValue {B = new MemoryStream(Encoding.UTF8.GetBytes("hello world")) } } + }); + + var json = evnt.Records[0].Dynamodb.NewImage.ToJson(); + + Assert.Equal("{\"Binary\":\"aGVsbG8gd29ybGQ=\"}", json); + } + + [Fact] + public void Binary_ToDocument() + { + var evnt = PrepareEvent(new Dictionary() + { + { "Binary", new AttributeValue {B = new MemoryStream(Encoding.UTF8.GetBytes("hello world")) } } + }); + + // Convert the event from the Lambda package to the SDK type + var json = evnt.Records[0].Dynamodb.NewImage.ToJson(); + var document = Document.FromJson(json); + + // Must opt in to binary decoding, since we can't distinguish from strings + document.DecodeBase64Attributes("Binary"); + + Assert.Equal("hello world", Encoding.UTF8.GetString(document["Binary"].AsByteArray())); + } + + [Fact] + public void Bool_ToJson() + { + var evnt = PrepareEvent(new Dictionary() + { + { "False", new AttributeValue {BOOL = false } }, + }); + + var json = evnt.Records[0].Dynamodb.NewImage.ToJson(); + + Assert.Equal("{\"False\":false}", json); + } + + [Fact] + public void Bool_ToDocument() + { + var evnt = PrepareEvent(new Dictionary() + { + { "False", new AttributeValue {BOOL = false } }, + }); + + // Convert the event from the Lambda package to the SDK type + var json = evnt.Records[0].Dynamodb.NewImage.ToJson(); + var document = Document.FromJson(json); + + Assert.False(document["False"].AsBoolean()); + } + + [Fact] + public void Null_ToJson() + { + var evnt = PrepareEvent(new Dictionary() + { + { "Null", new AttributeValue {NULL = true } } + }); + + var json = evnt.Records[0].Dynamodb.NewImage.ToJson(); + + Assert.Equal("{\"Null\":null}", json); + } + + [Fact] + public void Null_ToDocument() + { + var evnt = PrepareEvent(new Dictionary() + { + { "Null", new AttributeValue {NULL = true } } + }); + + // Convert the event from the Lambda package to the SDK type + var json = evnt.Records[0].Dynamodb.NewImage.ToJson(); + var document = Document.FromJson(json); + + Assert.Equal(DynamoDBNull.Null, document["Null"].AsDynamoDBNull()); + } + + [Fact] + public void Map_ToJson() + { + var map = new Dictionary + { + { "string", new AttributeValue {S = "string"} }, + { "number", new AttributeValue {N = "123.45"} }, + { "boolean", new AttributeValue {BOOL = false} } + }; + + var evnt = PrepareEvent(new Dictionary + { + { "Map", new AttributeValue { M = map } } + }); + + var json = evnt.Records[0].Dynamodb.NewImage.ToJson(); + + Assert.Equal("{\"Map\":{\"string\":\"string\",\"number\":123.45,\"boolean\":false}}", json); + } + + [Fact] + public void Map_ToDocument() + { + var map = new Dictionary + { + { "string", new AttributeValue {S = "string"} }, + { "number", new AttributeValue {N = "123.45"} }, + { "boolean", new AttributeValue {BOOL = false} } + }; + + var evnt = PrepareEvent(new Dictionary + { + { "Map", new AttributeValue { M = map } } + }); + + // Convert the event from the Lambda package to the SDK type + var json = evnt.Records[0].Dynamodb.NewImage.ToJson(); + var document = Document.FromJson(json); + + Assert.NotNull(document["Map"]); + Assert.NotNull(document["Map"].AsDocument()); + Assert.Equal("string", document["Map"].AsDocument()["string"].AsString()); + Assert.Equal(123.45, document["Map"].AsDocument()["number"].AsDouble()); + Assert.Equal(false, document["Map"].AsDocument()["boolean"].AsBoolean()); + } + + [Fact] + public void EmptyMap_ToJson() + { + var evnt = PrepareEvent(new Dictionary + { + { "Map", new AttributeValue { M = new Dictionary() } } + }); + + var json = evnt.Records[0].Dynamodb.NewImage.ToJson(); + + Assert.Equal("{\"Map\":{}}", json); + } + + [Fact] + public void EmptyMap_ToDocument() + { + var evnt = PrepareEvent(new Dictionary + { + { "Map", new AttributeValue { M = new Dictionary() } } + }); + + // Convert the event from the Lambda package to the SDK type + var json = evnt.Records[0].Dynamodb.NewImage.ToJson(); + var document = Document.FromJson(json); + + Assert.NotNull(document["Map"]); + Assert.NotNull(document["Map"].AsDocument()); + } + + [Fact] + public void List_ToJson() + { + var list = new List + { + new AttributeValue { S = "string"}, + new AttributeValue { N = "123"}, + new AttributeValue { BOOL = false} + }; + + var evnt = PrepareEvent(new Dictionary() + { + { "List", new AttributeValue { L = list } } + }); + + var json = evnt.Records[0].Dynamodb.NewImage.ToJson(); + + Assert.Equal("{\"List\":[\"string\",123,false]}", json); + } + + [Fact] + public void List_ToDocument() + { + var list = new List + { + new AttributeValue { S = "string"}, + new AttributeValue { N = "123"}, + new AttributeValue { BOOL = false} + }; + + var evnt = PrepareEvent(new Dictionary() + { + { "List", new AttributeValue { L = list } } + }); + + // Convert the event from the Lambda package to the SDK type + var json = evnt.Records[0].Dynamodb.NewImage.ToJson(); + var document = Document.FromJson(json); + + Assert.NotNull(document["List"].AsDynamoDBList()); + Assert.Equal("string", document["List"].AsDynamoDBList()[0].AsString()); + Assert.Equal(123, document["List"].AsDynamoDBList()[1].AsInt()); + Assert.False(document["List"].AsDynamoDBList()[2].AsBoolean()); + } + + [Fact] + public void EmptyList_ToJson() + { + var evnt = PrepareEvent(new Dictionary() + { + { "List", new AttributeValue { L = new List() } } + }); + + var json = evnt.Records[0].Dynamodb.NewImage.ToJson(); + + Assert.Equal("{\"List\":[]}", json); + } + + [Fact] + public void EmptyList_ToDocument() + { + var evnt = PrepareEvent(new Dictionary() + { + { "List", new AttributeValue { L = new List() } } + }); + + // Convert the event from the Lambda package to the SDK type + var json = evnt.Records[0].Dynamodb.NewImage.ToJson(); + var document = Document.FromJson(json); + + Assert.NotNull(document["List"].AsDynamoDBList()); + Assert.Empty(document["List"].AsDynamoDBList().AsArrayOfDynamoDBEntry()); + } + + [Fact] + public void StringSet_ToJson() + { + var evnt = PrepareEvent(new Dictionary() + { + { "StringSet", new AttributeValue { SS = new List { "Black", "Green", "Red" }}} + }); + + var json = evnt.Records[0].Dynamodb.NewImage.ToJson(); + + Assert.Equal("{\"StringSet\":[\"Black\",\"Green\",\"Red\"]}", json); + } + + [Fact] + public void StringSet_ToDocument() + { + var evnt = PrepareEvent(new Dictionary() + { + { "StringSet", new AttributeValue { SS = new List { "Black", "Green", "Red" }}} + }); + + // Convert the event from the Lambda package to the SDK type + var json = evnt.Records[0].Dynamodb.NewImage.ToJson(); + var document = Document.FromJson(json); + + var hashSet = document["StringSet"].AsHashSetOfString(); + Assert.NotNull(hashSet); + Assert.Equal(3, hashSet.Count); + Assert.True(hashSet.Contains("Black")); + Assert.True(hashSet.Contains("Green")); + Assert.True(hashSet.Contains("Red")); + } + + [Fact] + public void NumberSet_ToJson() + { + var evnt = PrepareEvent(new Dictionary() + { + { "NumberSet", new AttributeValue { NS = new List { "123", "123.45", "-123.45" } } } + }); + + var json = evnt.Records[0].Dynamodb.NewImage.ToJson(); + + Assert.Equal("{\"NumberSet\":[123,123.45,-123.45]}", json); + } + + [Fact] + public void NumberSet_ToDocument() + { + var evnt = PrepareEvent(new Dictionary() + { + { "NumberSet", new AttributeValue { NS = new List { "123", "123.45", "-123.45" } } } + }); + + // Convert the event from the Lambda package to the SDK type + var json = evnt.Records[0].Dynamodb.NewImage.ToJson(); + var document = Document.FromJson(json); + + var list = document["NumberSet"].AsListOfDynamoDBEntry(); + Assert.NotNull(list); + Assert.Equal(3, list.Count); + Assert.Equal(123, list[0].AsInt()); + Assert.Equal(123.45, list[1].AsDouble()); + Assert.Equal(-123.45, list[2].AsDouble()); + } + + [Fact] + public void BinarySet_ToJson() + { + var set = new List + { + new MemoryStream(Encoding.UTF8.GetBytes("hello world")), + new MemoryStream(Encoding.UTF8.GetBytes("hello world!")) + }; + + var evnt = PrepareEvent(new Dictionary() + { + { "BinarySet", new AttributeValue { BS = set } } + }); + + var json = evnt.Records[0].Dynamodb.NewImage.ToJson(); + + Assert.Equal("{\"BinarySet\":[\"aGVsbG8gd29ybGQ=\",\"aGVsbG8gd29ybGQh\"]}", json); + } + + [Fact] + public void BinarySet_ToDocument() + { + var set = new List + { + new MemoryStream(Encoding.UTF8.GetBytes("hello world")), + new MemoryStream(Encoding.UTF8.GetBytes("hello world!")) + }; + + var evnt = PrepareEvent(new Dictionary() + { + { "BinarySet", new AttributeValue { BS = set } } + }); + + // Convert the event from the Lambda package to the SDK type + var json = evnt.Records[0].Dynamodb.NewImage.ToJson(); + var document = Document.FromJson(json); + + document.DecodeBase64Attributes("BinarySet"); + + var list = document["BinarySet"].AsListOfDynamoDBEntry(); + Assert.NotNull(list); + + Assert.Equal(2, list.Count); + Assert.Equal("hello world", Encoding.UTF8.GetString(list[0].AsByteArray())); + Assert.Equal("hello world!", Encoding.UTF8.GetString(list[1].AsByteArray())); + } + } +} diff --git a/Libraries/test/EventsTests.Shared/EventsTests.Shared.projitems b/Libraries/test/EventsTests.Shared/EventsTests.Shared.projitems index 93ab72ceb..89675d006 100644 --- a/Libraries/test/EventsTests.Shared/EventsTests.Shared.projitems +++ b/Libraries/test/EventsTests.Shared/EventsTests.Shared.projitems @@ -75,5 +75,6 @@ + \ No newline at end of file