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();