diff --git a/src/CloudNative.CloudEvents.SystemTextJson/JsonEventFormatter.cs b/src/CloudNative.CloudEvents.SystemTextJson/JsonEventFormatter.cs index f3f29b5..41420b9 100644 --- a/src/CloudNative.CloudEvents.SystemTextJson/JsonEventFormatter.cs +++ b/src/CloudNative.CloudEvents.SystemTextJson/JsonEventFormatter.cs @@ -29,7 +29,11 @@ namespace CloudNative.CloudEvents.SystemTextJson /// nor "data_base64" property is populated in a structured mode message. /// /// - /// If the data content type is absent or has a media type of "application/json", the data is encoded as JSON, + /// If the data value is a byte array, it is serialized either directly as binary data + /// (for binary mode messages) or as base64 data (for structured mode messages). + /// + /// + /// Otherwise, if the data content type is absent or has a media type indicating JSON, the data is encoded as JSON, /// using the passed into the constructor, or the default options. /// /// @@ -37,26 +41,32 @@ namespace CloudNative.CloudEvents.SystemTextJson /// the data is serialized as a string. /// /// - /// Otherwise, if the data value is a byte array, it is serialized either directly as binary data - /// (for binary mode messages) or as base64 data (for structured mode messages). - /// - /// /// Otherwise, the encoding operation fails. /// /// /// - /// When decoding CloudEvent data, this implementation uses the following rules: - /// - /// - /// In a structured mode message, any data is either binary data within the "data_base64" property value, - /// or is a JSON token as the "data" property value. Binary data is represented as a byte array. - /// A JSON token is decoded as a string if is just a string value and the data content type is specified - /// and has a media type beginning with "text/". A JSON token representing the null value always - /// leads to a null data result. In any other situation, the JSON token is preserved as a - /// that can be used for further deserialization (e.g. to a specific CLR type). This behavior can be modified - /// by overriding and - /// . + /// When decoding structured mode CloudEvent data, this implementation uses the following rules, + /// which can be modified by overriding + /// and . /// + /// + /// + /// If the "data_base64" property is present, its value is decoded as a byte array. + /// + /// + /// If the "data" property is present (and non-null) and the content type is absent or indicates a JSON media type, + /// the JSON token present in the property is preserved as a that can be used for further + /// deserialization (e.g. to a specific CLR type). + /// + /// + /// If the "data" property has a string value and a non-JSON content type has been specified, the data is + /// deserialized as a string. + /// + /// + /// If the "data" property has a non-null, non-string value and a non-JSON content type has been specified, + /// the deserialization operation fails. + /// + /// /// /// In a binary mode message, the data is parsed based on the content type of the message. When the content /// type is absent or has a media type of "application/json", the data is parsed as JSON, with the result as @@ -310,6 +320,9 @@ namespace CloudNative.CloudEvents.SystemTextJson } else { + // If no content type has been specified, default to application/json + cloudEvent.DataContentType ??= JsonMediaType; + DecodeStructuredModeDataProperty(dataElement, cloudEvent); } } @@ -347,8 +360,9 @@ namespace CloudNative.CloudEvents.SystemTextJson /// /// /// - /// This implementation converts JSON string tokens to strings when the content type suggests - /// that's appropriate, but otherwise returns the token directly. + /// This implementation will populate the Data property with the verbatim if + /// the content type is deemed to be JSON according to . Otherwise, + /// it validates that the token is a string, and the Data property is populated with that string. /// /// /// Override this method to provide more specialized conversions. @@ -358,12 +372,24 @@ namespace CloudNative.CloudEvents.SystemTextJson /// not have a null token type. /// The event being decoded. This should not be modified except to /// populate the property, but may be used to provide extra - /// information such as the data content type. Will not be null. + /// information such as the data content type. Will not be null, and the + /// property will be non-null. /// The data to populate in the property. - protected virtual void DecodeStructuredModeDataProperty(JsonElement dataElement, CloudEvent cloudEvent) => - cloudEvent.Data = dataElement.ValueKind == JsonValueKind.String && cloudEvent.DataContentType?.StartsWith("text/") == true - ? dataElement.GetString() - : (object) dataElement.Clone(); // Deliberately cast to object to provide the conditional operator expression type. + protected virtual void DecodeStructuredModeDataProperty(JsonElement dataElement, CloudEvent cloudEvent) + { + if (IsJsonMediaType(cloudEvent.DataContentType!)) + { + cloudEvent.Data = dataElement.Clone(); + } + else + { + if (dataElement.ValueKind != JsonValueKind.String) + { + throw new ArgumentException("CloudEvents with a non-JSON datacontenttype can only have string data values."); + } + cloudEvent.Data = dataElement.GetString(); + } + } /// public override ReadOnlyMemory EncodeBatchModeMessage(IEnumerable cloudEvents, out ContentType contentType) @@ -426,12 +452,16 @@ namespace CloudNative.CloudEvents.SystemTextJson default: writer.WriteStringValue(attribute.Type.Format(value)); break; - } } if (cloudEvent.Data is object) { + if (cloudEvent.DataContentType is null) + { + writer.WritePropertyName(cloudEvent.SpecVersion.DataContentTypeAttribute.Name); + writer.WriteStringValue(JsonMediaType); + } EncodeStructuredModeData(cloudEvent, writer); } writer.WriteEndObject(); @@ -452,26 +482,31 @@ namespace CloudNative.CloudEvents.SystemTextJson /// The writer to serialize the data to. Will not be null. protected virtual void EncodeStructuredModeData(CloudEvent cloudEvent, Utf8JsonWriter writer) { - ContentType dataContentType = new ContentType(cloudEvent.DataContentType ?? JsonMediaType); - if (dataContentType.MediaType == JsonMediaType) - { - writer.WritePropertyName(DataPropertyName); - JsonSerializer.Serialize(writer, cloudEvent.Data, SerializerOptions); - } - else if (cloudEvent.Data is string text && dataContentType.MediaType.StartsWith("text/")) - { - writer.WritePropertyName(DataPropertyName); - writer.WriteStringValue(text); - } - else if (cloudEvent.Data is byte[] binary) + // Binary data is encoded using the data_base64 property, regardless of content type. + // TODO: Support other forms of binary data, e.g. ReadOnlyMemory + if (cloudEvent.Data is byte[] binary) { writer.WritePropertyName(DataBase64PropertyName); writer.WriteStringValue(Convert.ToBase64String(binary)); } else { - // We assume CloudEvent.Data is not null due to the way this is called. - throw new ArgumentException($"{nameof(JsonEventFormatter)} cannot serialize data of type {cloudEvent.Data!.GetType()} with content type '{cloudEvent.DataContentType}'"); + ContentType dataContentType = new ContentType(cloudEvent.DataContentType ?? JsonMediaType); + if (IsJsonMediaType(dataContentType.MediaType)) + { + writer.WritePropertyName(DataPropertyName); + JsonSerializer.Serialize(writer, cloudEvent.Data, SerializerOptions); + } + else if (cloudEvent.Data is string text && dataContentType.MediaType.StartsWith("text/")) + { + writer.WritePropertyName(DataPropertyName); + writer.WriteStringValue(text); + } + else + { + // We assume CloudEvent.Data is not null due to the way this is called. + throw new ArgumentException($"{nameof(JsonEventFormatter)} cannot serialize data of type {cloudEvent.Data!.GetType()} with content type '{cloudEvent.DataContentType}'"); + } } } @@ -484,8 +519,14 @@ namespace CloudNative.CloudEvents.SystemTextJson { return Array.Empty(); } + // Binary data is left alone, regardless of the content type. + // TODO: Support other forms of binary data, e.g. ReadOnlyMemory + if (cloudEvent.Data is byte[] bytes) + { + return bytes; + } ContentType contentType = new ContentType(cloudEvent.DataContentType ?? JsonMediaType); - if (contentType.MediaType == JsonMediaType) + if (IsJsonMediaType(contentType.MediaType)) { var encoding = MimeUtilities.GetEncoding(contentType); if (encoding is UTF8Encoding) @@ -501,10 +542,6 @@ namespace CloudNative.CloudEvents.SystemTextJson { return MimeUtilities.GetEncoding(contentType).GetBytes(text); } - if (cloudEvent.Data is byte[] bytes) - { - return bytes; - } throw new ArgumentException($"{nameof(JsonEventFormatter)} cannot serialize data of type {cloudEvent.Data.GetType()} with content type '{cloudEvent.DataContentType}'"); } @@ -541,6 +578,15 @@ namespace CloudNative.CloudEvents.SystemTextJson cloudEvent.Data = body.ToArray(); } } + + /// + /// Determines whether the given media type should be handled as JSON. + /// The default implementation treats anything ending with "/json" or "+json" + /// as JSON. + /// + /// The media type to check for JSON. Will not be null. + /// Whether or not indicates JSON data. + protected virtual bool IsJsonMediaType(string mediaType) => mediaType.EndsWith("/json") || mediaType.EndsWith("+json"); } /// diff --git a/test/CloudNative.CloudEvents.UnitTests/SystemTextJson/JsonElementAsserter.cs b/test/CloudNative.CloudEvents.UnitTests/SystemTextJson/JsonElementAsserter.cs index 01a89f8..ad7cbc7 100644 --- a/test/CloudNative.CloudEvents.UnitTests/SystemTextJson/JsonElementAsserter.cs +++ b/test/CloudNative.CloudEvents.UnitTests/SystemTextJson/JsonElementAsserter.cs @@ -39,6 +39,7 @@ namespace CloudNative.CloudEvents.SystemTextJson.UnitTests JsonValueKind.String => property.GetString(), JsonValueKind.Number => property.GetInt32(), JsonValueKind.Null => (object?) null, + JsonValueKind.Object => JsonSerializer.Deserialize(property.GetRawText(), expectation.value.GetType()), _ => throw new Exception($"Unhandled value kind: {property.ValueKind}") }; diff --git a/test/CloudNative.CloudEvents.UnitTests/SystemTextJson/JsonEventFormatterTest.cs b/test/CloudNative.CloudEvents.UnitTests/SystemTextJson/JsonEventFormatterTest.cs index d88bf20..7ab00b2 100644 --- a/test/CloudNative.CloudEvents.UnitTests/SystemTextJson/JsonEventFormatterTest.cs +++ b/test/CloudNative.CloudEvents.UnitTests/SystemTextJson/JsonEventFormatterTest.cs @@ -110,6 +110,20 @@ namespace CloudNative.CloudEvents.SystemTextJson.UnitTests asserter.AssertProperties(dataProperty, assertCount: true); } + [Fact] + public void EncodeStructuredModeMessage_JsonDataType_NumberSerialization() + { + var cloudEvent = new CloudEvent().PopulateRequiredAttributes(); + cloudEvent.Data = 10; + cloudEvent.DataContentType = "application/json"; + JsonElement element = EncodeAndParseStructured(cloudEvent); + var asserter = new JsonElementAsserter + { + { "data", JsonValueKind.Number, 10 } + }; + asserter.AssertProperties(element, assertCount: false); + } + [Fact] public void EncodeStructuredModeMessage_JsonDataType_CustomSerializerOptions() { @@ -146,9 +160,47 @@ namespace CloudNative.CloudEvents.SystemTextJson.UnitTests }; asserter.AssertProperties(dataProperty, assertCount: true); } - + [Fact] - public void EncodeStructuredModeMessage_JsonDataType_JsonElement() + public void EncodeStructuredModeMessage_JsonDataType_JsonElementObject() + { + var cloudEvent = new CloudEvent().PopulateRequiredAttributes(); + cloudEvent.Data = ParseJson("{ \"value\": { \"Key\": \"value\" } }").GetProperty("value"); + cloudEvent.DataContentType = "application/json"; + JsonElement element = EncodeAndParseStructured(cloudEvent); + JsonElement data = element.GetProperty("data"); + var asserter = new JsonElementAsserter + { + { "Key", JsonValueKind.String, "value" } + }; + asserter.AssertProperties(data, assertCount: true); + } + + [Fact] + public void EncodeStructuredModeMessage_JsonDataType_JsonElementString() + { + var cloudEvent = new CloudEvent().PopulateRequiredAttributes(); + cloudEvent.Data = ParseJson("{ \"value\": \"text\" }").GetProperty("value"); + cloudEvent.DataContentType = "application/json"; + JsonElement element = EncodeAndParseStructured(cloudEvent); + JsonElement data = element.GetProperty("data"); + Assert.Equal(JsonValueKind.String, data.ValueKind); + Assert.Equal("text", data.GetString()); + } + + [Fact] + public void EncodeStructuredModeMessage_JsonDataType_JsonElementNull() + { + var cloudEvent = new CloudEvent().PopulateRequiredAttributes(); + cloudEvent.Data = ParseJson("{ \"value\": null }").GetProperty("value"); + cloudEvent.DataContentType = "application/json"; + JsonElement element = EncodeAndParseStructured(cloudEvent); + JsonElement data = element.GetProperty("data"); + Assert.Equal(JsonValueKind.Null, data.ValueKind); + } + + [Fact] + public void EncodeStructuredModeMessage_JsonDataType_JsonElementNumeric() { var cloudEvent = new CloudEvent().PopulateRequiredAttributes(); cloudEvent.Data = ParseJson("{ \"value\": 100 }").GetProperty("value"); @@ -355,6 +407,35 @@ namespace CloudNative.CloudEvents.SystemTextJson.UnitTests Assert.Throws(() => formatter.EncodeBinaryModeEventData(cloudEvent)); } + [Fact] + public void EncodeBinaryModeEventData_NoContentType_ConvertsStringToJson() + { + var cloudEvent = new CloudEvent + { + Data = "some text" + }.PopulateRequiredAttributes(); + + // EncodeBinaryModeEventData doesn't actually populate the content type of the CloudEvent, + // but treat the data as if we'd explicitly specified application/json. + var data = new JsonEventFormatter().EncodeBinaryModeEventData(cloudEvent); + string text = BinaryDataUtilities.GetString(data, Encoding.UTF8); + Assert.Equal("\"some text\"", text); + } + + [Fact] + public void EncodeBinaryModeEventData_NoContentType_LeavesBinaryData() + { + var cloudEvent = new CloudEvent + { + Data = SampleBinaryData + }.PopulateRequiredAttributes(); + + // EncodeBinaryModeEventData does *not* implicitly encode binary data as JSON. + var data = new JsonEventFormatter().EncodeBinaryModeEventData(cloudEvent); + var array = BinaryDataUtilities.AsArray(data); + Assert.Equal(array, SampleBinaryData); + } + // Note: batch mode testing is restricted to the batch aspects; we assume that the // per-CloudEvent implementation is shared with structured mode, so we rely on // structured mode testing for things like custom serialization. @@ -691,11 +772,13 @@ namespace CloudNative.CloudEvents.SystemTextJson.UnitTests Assert.Equal(SampleBinaryData, cloudEvent.Data); } - [Fact] - public void DecodeStructuredModeMessage_TextContentTypeStringToken() + [Theory] + [InlineData("text/plain")] + [InlineData("image/png")] + public void DecodeStructuredModeMessage_NonJsonContentType_JsonStringToken(string contentType) { var obj = CreateMinimalValidJObject(); - obj["datacontenttype"] = "text/plain"; + obj["datacontenttype"] = contentType; obj["data"] = "some text"; var cloudEvent = DecodeStructuredModeMessage(obj); Assert.Equal("some text", cloudEvent.Data); @@ -704,9 +787,25 @@ namespace CloudNative.CloudEvents.SystemTextJson.UnitTests [Theory] [InlineData(null)] [InlineData("application/json")] - [InlineData("text/plain")] - [InlineData("application/not-quite-json")] - public void DecodeStructuredModeMessage_JsonToken(string contentType) + public void DecodeStructuredModeMessage_JsonContentType_JsonStringToken(string contentType) + { + var obj = CreateMinimalValidJObject(); + if (contentType is object) + { + obj["datacontenttype"] = contentType; + } + obj["data"] = "text"; + var cloudEvent = DecodeStructuredModeMessage(obj); + var element = (JsonElement) cloudEvent.Data!; + Assert.Equal(JsonValueKind.String, element.ValueKind); + Assert.Equal("text", element.GetString()); + } + + [Theory] + [InlineData(null)] + [InlineData("application/json")] + [InlineData("application/xyz+json")] + public void DecodeStructuredModeMessage_JsonContentType_NonStringValue(string contentType) { var obj = CreateMinimalValidJObject(); if (contentType is object) @@ -720,6 +819,15 @@ namespace CloudNative.CloudEvents.SystemTextJson.UnitTests Assert.Equal(10, element.GetInt32()); } + [Fact] + public void DecodeStructuredModeMessage_NonJsonContentType_NonStringValue() + { + var obj = CreateMinimalValidJObject(); + obj["datacontenttype"] = "text/plain"; + obj["data"] = 10; + Assert.Throws(() => DecodeStructuredModeMessage(obj)); + } + [Fact] public void DecodeStructuredModeMessage_NullDataBase64Ignored() { @@ -910,6 +1018,75 @@ namespace CloudNative.CloudEvents.SystemTextJson.UnitTests Assert.Null(event2.DataContentType); } + // Additional tests for the changes/clarifications in https://github.com/cloudevents/spec/pull/861 + [Fact] + public void EncodeStructured_DefaultContentTypeToApplicationJson() + { + var cloudEvent = new CloudEvent + { + Data = new { Key = "value" } + }.PopulateRequiredAttributes(); + + var encoded = new JsonEventFormatter().EncodeStructuredModeMessage(cloudEvent, out var contentType); + Assert.Equal("application/cloudevents+json; charset=utf-8", contentType.ToString()); + JsonElement obj = ParseJson(encoded); + var asserter = new JsonElementAsserter + { + { "data", JsonValueKind.Object, cloudEvent.Data }, + { "datacontenttype", JsonValueKind.String, "application/json" }, + { "id", JsonValueKind.String, "test-id" }, + { "source", JsonValueKind.String, "//test" }, + { "specversion", JsonValueKind.String, "1.0" }, + { "type", JsonValueKind.String, "test-type" }, + }; + asserter.AssertProperties(obj, assertCount: true); + } + + [Fact] + public void EncodeStructured_BinaryData_DefaultContentTypeToApplicationJson() + { + var cloudEvent = new CloudEvent + { + Data = SampleBinaryData + }.PopulateRequiredAttributes(); + + // While it's odd for a CloudEvent to have binary data but no data content type, + // the spec says the data should be placed in data_base64, and the content type should + // default to application/json. (Checking in https://github.com/cloudevents/spec/issues/933) + var encoded = new JsonEventFormatter().EncodeStructuredModeMessage(cloudEvent, out var contentType); + Assert.Equal("application/cloudevents+json; charset=utf-8", contentType.ToString()); + JsonElement obj = ParseJson(encoded); + var asserter = new JsonElementAsserter + { + { "data_base64", JsonValueKind.String, SampleBinaryDataBase64 }, + { "datacontenttype", JsonValueKind.String, "application/json" }, + { "id", JsonValueKind.String, "test-id" }, + { "source", JsonValueKind.String, "//test" }, + { "specversion", JsonValueKind.String, "1.0" }, + { "type", JsonValueKind.String, "test-type" }, + }; + asserter.AssertProperties(obj, assertCount: true); + } + + [Fact] + public void DecodeStructured_DefaultContentTypeToApplicationJson() + { + var obj = new JObject + { + ["specversion"] = "1.0", + ["type"] = "test-type", + ["id"] = "test-id", + ["source"] = SampleUriText, + ["data"] = "some text" + }; + var cloudEvent = DecodeStructuredModeMessage(obj); + Assert.Equal("application/json", cloudEvent.DataContentType); + var jsonData = Assert.IsType(cloudEvent.Data); + Assert.Equal(JsonValueKind.String, jsonData.ValueKind); + Assert.Equal("some text", jsonData.GetString()); + } + + // Utility methods private static object DecodeBinaryModeEventData(byte[] bytes, string contentType) { var cloudEvent = new CloudEvent().PopulateRequiredAttributes();