Skip to content

Commit 9cf04dc

Browse files
committed
fix: Minor tweaks to the Json.NET JsonEventFormatter for compliance
- Make "JSON media type detection" configurable, and default to */json and */*+json - Default to JSON content type (including making it present in the serialized event) - Make a "binary vs non-binary" distinction as the first part of serialization (instead of as a fallback) - Deserialize string data values regardless of content type - Prohibit non-string data values for non-JSON content types Signed-off-by: Jon Skeet <[email protected]>
1 parent 4f13307 commit 9cf04dc

File tree

2 files changed

+302
-51
lines changed

2 files changed

+302
-51
lines changed

src/CloudNative.CloudEvents.NewtonsoftJson/JsonEventFormatter.cs

Lines changed: 91 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,11 @@ namespace CloudNative.CloudEvents.NewtonsoftJson
3030
/// nor "data_base64" property is populated in a structured mode message.
3131
/// </description></item>
3232
/// <item><description>
33-
/// If the data content type is absent or has a media type of "application/json", the data is encoded as JSON.
33+
/// If the data value is a byte array, it is serialized either directly as binary data
34+
/// (for binary mode messages) or as base64 data (for structured mode messages).
35+
/// </description></item>
36+
/// <item><description>
37+
/// Otherwise, if the data content type is absent or has a media type indicating JSON, the data is encoded as JSON.
3438
/// If the data is already a <see cref="JToken"/>, that is serialized directly as JSON. Otherwise, the data
3539
/// is converted using the <see cref="JsonSerializer"/> passed into the constructor, or a
3640
/// default serializer.
@@ -40,29 +44,35 @@ namespace CloudNative.CloudEvents.NewtonsoftJson
4044
/// the data is serialized as a string.
4145
/// </description></item>
4246
/// <item><description>
43-
/// Otherwise, if the data value is a byte array, it is serialized either directly as binary data
44-
/// (for binary mode messages) or as base64 data (for structured mode messages).
45-
/// </description></item>
46-
/// <item><description>
4747
/// Otherwise, the encoding operation fails.
4848
/// </description></item>
4949
/// </list>
5050
/// <para>
51-
/// When decoding CloudEvent data, this implementation uses the following rules:
52-
/// </para>
53-
/// <para>
54-
/// In a structured mode message, any data is either binary data within the "data_base64" property value,
55-
/// or is a JSON token as the "data" property value. Binary data is represented as a byte array.
56-
/// A JSON token is decoded as a string if is just a string value and the data content type is specified
57-
/// and has a media type beginning with "text/". A JSON token representing the null value always
58-
/// leads to a null data result. In any other situation, the JSON token is preserved as a <see cref="JToken"/>
59-
/// that can be used for further deserialization (e.g. to a specific CLR type). This behavior can be modified
60-
/// by overriding <see cref="DecodeStructuredModeDataBase64Property(JToken, CloudEvent)"/> and
61-
/// <see cref="DecodeStructuredModeDataProperty(JToken, CloudEvent)"/>.
51+
/// When decoding structured mode CloudEvent data, this implementation uses the following rules,
52+
/// which can be modified by overriding <see cref="DecodeStructuredModeDataBase64Property(JToken, CloudEvent)"/>
53+
/// and <see cref="DecodeStructuredModeDataProperty(JToken, CloudEvent)"/>.
6254
/// </para>
55+
/// <list type="bullet">
56+
/// <item><description>
57+
/// If the "data_base64" property is present, its value is decoded as a byte array.
58+
/// </description></item>
59+
/// <item><description>
60+
/// If the "data" property is present (and non-null) and the content type is absent or indicates a JSON media type,
61+
/// the JSON token present in the property is preserved as a <see cref="JToken"/> that can be used for further
62+
/// deserialization (e.g. to a specific CLR type).
63+
/// </description></item>
64+
/// <item><description>
65+
/// If the "data" property has a string value and a non-JSON content type has been specified, the data is
66+
/// deserialized as a string.
67+
/// </description></item>
68+
/// <item><description>
69+
/// If the "data" property has a non-null, non-string value and a non-JSON content type has been specified,
70+
/// the deserialization operation fails.
71+
/// </description></item>
72+
/// </list>
6373
/// <para>
6474
/// In a binary mode message, the data is parsed based on the content type of the message. When the content
65-
/// type is absent or has a media type of "application/json", the data is parsed as JSON, with the result as
75+
/// type is absent or has a JSON media type, the data is parsed as JSON, with the result as
6676
/// a <see cref="JToken"/> (or null if the data is empty). When the content type has a media type beginning
6777
/// with "text/", the data is parsed as a string. In all other cases, the data is left as a byte array.
6878
/// This behavior can be specialized by overriding <see cref="DecodeBinaryModeEventData(ReadOnlyMemory{byte}, CloudEvent)"/>.
@@ -296,6 +306,9 @@ private void PopulateDataFromStructuredEvent(CloudEvent cloudEvent, JObject jObj
296306
}
297307
else
298308
{
309+
// If no content type has been specified, default to application/json
310+
cloudEvent.DataContentType ??= JsonMediaType;
311+
299312
// We know that dataToken must be non-null here, due to the above conditions.
300313
DecodeStructuredModeDataProperty(dataToken!, cloudEvent);
301314
}
@@ -334,8 +347,9 @@ protected virtual void DecodeStructuredModeDataBase64Property(JToken dataBase64T
334347
/// </summary>
335348
/// <remarks>
336349
/// <para>
337-
/// This implementation converts JSON string tokens to strings when the content type suggests
338-
/// that's appropriate, but otherwise returns the token directly.
350+
/// This implementation will populate the Data property with the verbatim <see cref="JToken"/> if
351+
/// the content type is deemed to be JSON according to <see cref="IsJsonMediaType(string)"/>. Otherwise,
352+
/// it validates that the token is a string, and the Data property is populated with that string.
339353
/// </para>
340354
/// <para>
341355
/// Override this method to provide more specialized conversions.
@@ -345,12 +359,24 @@ protected virtual void DecodeStructuredModeDataBase64Property(JToken dataBase64T
345359
/// not have a null token type.</param>
346360
/// <param name="cloudEvent">The event being decoded. This should not be modified except to
347361
/// populate the <see cref="CloudEvent.Data"/> property, but may be used to provide extra
348-
/// information such as the data content type. Will not be null.</param>
362+
/// information such as the data content type. Will not be null, and the <see cref="CloudEvent.DataContentType"/>
363+
/// property will be non-null.</param>
349364
/// <returns>The data to populate in the <see cref="CloudEvent.Data"/> property.</returns>
350-
protected virtual void DecodeStructuredModeDataProperty(JToken dataToken, CloudEvent cloudEvent) =>
351-
cloudEvent.Data = dataToken.Type == JTokenType.String && cloudEvent.DataContentType?.StartsWith("text/") == true
352-
? (string?) dataToken
353-
: (object) dataToken; // Deliberately cast to object to avoid any implicit conversions
365+
protected virtual void DecodeStructuredModeDataProperty(JToken dataToken, CloudEvent cloudEvent)
366+
{
367+
if (IsJsonMediaType(cloudEvent.DataContentType!))
368+
{
369+
cloudEvent.Data = dataToken;
370+
}
371+
else
372+
{
373+
if (dataToken.Type != JTokenType.String)
374+
{
375+
throw new ArgumentException("CloudEvents with a non-JSON datacontenttype can only have string data values.");
376+
}
377+
cloudEvent.Data = (string?) dataToken;
378+
}
379+
}
354380

355381
/// <inheritdoc />
356382
public override ReadOnlyMemory<byte> EncodeStructuredModeMessage(CloudEvent cloudEvent, out ContentType contentType)
@@ -420,6 +446,11 @@ private void WriteCloudEventForBatchOrStructuredMode(JsonWriter writer, CloudEve
420446

421447
if (cloudEvent.Data is object)
422448
{
449+
if (cloudEvent.DataContentType is null)
450+
{
451+
writer.WritePropertyName(cloudEvent.SpecVersion.DataContentTypeAttribute.Name);
452+
writer.WriteValue(JsonMediaType);
453+
}
423454
EncodeStructuredModeData(cloudEvent, writer);
424455
}
425456
writer.WriteEndObject();
@@ -440,26 +471,31 @@ private void WriteCloudEventForBatchOrStructuredMode(JsonWriter writer, CloudEve
440471
/// <param name="writer"/>The writer to serialize the data to. Will not be null.</param>
441472
protected virtual void EncodeStructuredModeData(CloudEvent cloudEvent, JsonWriter writer)
442473
{
443-
ContentType dataContentType = new ContentType(cloudEvent.DataContentType ?? JsonMediaType);
444-
if (dataContentType.MediaType == JsonMediaType)
445-
{
446-
writer.WritePropertyName(DataPropertyName);
447-
Serializer.Serialize(writer, cloudEvent.Data);
448-
}
449-
else if (cloudEvent.Data is string text && dataContentType.MediaType.StartsWith("text/"))
450-
{
451-
writer.WritePropertyName(DataPropertyName);
452-
writer.WriteValue(text);
453-
}
454-
else if (cloudEvent.Data is byte[] binary)
474+
// Binary data is encoded using the data_base64 property, regardless of content type.
475+
// TODO: Support other forms of binary data, e.g. ReadOnlyMemory<byte>
476+
if (cloudEvent.Data is byte[] binary)
455477
{
456478
writer.WritePropertyName(DataBase64PropertyName);
457479
writer.WriteValue(Convert.ToBase64String(binary));
458480
}
459481
else
460482
{
461-
// We assume CloudEvent.Data is not null due to the way this is called.
462-
throw new ArgumentException($"{nameof(JsonEventFormatter)} cannot serialize data of type {cloudEvent.Data!.GetType()} with content type '{cloudEvent.DataContentType}'");
483+
ContentType dataContentType = new ContentType(cloudEvent.DataContentType ?? JsonMediaType);
484+
if (IsJsonMediaType(dataContentType.MediaType))
485+
{
486+
writer.WritePropertyName(DataPropertyName);
487+
Serializer.Serialize(writer, cloudEvent.Data);
488+
}
489+
else if (cloudEvent.Data is string text && dataContentType.MediaType.StartsWith("text/"))
490+
{
491+
writer.WritePropertyName(DataPropertyName);
492+
writer.WriteValue(text);
493+
}
494+
else
495+
{
496+
// We assume CloudEvent.Data is not null due to the way this is called.
497+
throw new ArgumentException($"{nameof(JsonEventFormatter)} cannot serialize data of type {cloudEvent.Data!.GetType()} with content type '{cloudEvent.DataContentType}'");
498+
}
463499
}
464500
}
465501

@@ -472,8 +508,14 @@ public override ReadOnlyMemory<byte> EncodeBinaryModeEventData(CloudEvent cloudE
472508
{
473509
return Array.Empty<byte>();
474510
}
511+
// Binary data is left alone, regardless of the content type.
512+
// TODO: Support other forms of binary data, e.g. ReadOnlyMemory<byte>
513+
if (cloudEvent.Data is byte[] bytes)
514+
{
515+
return bytes;
516+
}
475517
ContentType contentType = new ContentType(cloudEvent.DataContentType ?? JsonMediaType);
476-
if (contentType.MediaType == JsonMediaType)
518+
if (IsJsonMediaType(contentType.MediaType))
477519
{
478520
// TODO: Make this more efficient. We could write to a StreamWriter with a MemoryStream,
479521
// but then we end up with a BOM in most cases, which I suspect we don't want.
@@ -487,10 +529,6 @@ public override ReadOnlyMemory<byte> EncodeBinaryModeEventData(CloudEvent cloudE
487529
{
488530
return MimeUtilities.GetEncoding(contentType).GetBytes(text);
489531
}
490-
if (cloudEvent.Data is byte[] bytes)
491-
{
492-
return bytes;
493-
}
494532
throw new ArgumentException($"{nameof(JsonEventFormatter)} cannot serialize data of type {cloudEvent.Data.GetType()} with content type '{cloudEvent.DataContentType}'");
495533
}
496534

@@ -503,7 +541,7 @@ public override void DecodeBinaryModeEventData(ReadOnlyMemory<byte> body, CloudE
503541

504542
Encoding encoding = MimeUtilities.GetEncoding(contentType);
505543

506-
if (contentType.MediaType == JsonMediaType)
544+
if (IsJsonMediaType(contentType.MediaType))
507545
{
508546
if (body.Length > 0)
509547
{
@@ -547,6 +585,15 @@ protected virtual JsonReader CreateJsonReader(Stream stream, Encoding? encoding)
547585
{
548586
DateParseHandling = DateParseHandling.None
549587
};
588+
589+
/// <summary>
590+
/// Determines whether the given media type should be handled as JSON.
591+
/// The default implementation treats anything ending with "/json" or "+json"
592+
/// as JSON.
593+
/// </summary>
594+
/// <param name="mediaType">The media type to check for JSON. Will not be null.</param>
595+
/// <returns>Whether or not <paramref name="mediaType"/> indicates JSON data.</returns>
596+
protected virtual bool IsJsonMediaType(string mediaType) => mediaType.EndsWith("/json") || mediaType.EndsWith("+json");
550597
}
551598

552599
/// <summary>

0 commit comments

Comments
 (0)