Skip to content

Commit c4a938c

Browse files
committed
fix: Minor tweaks to the System.Text.Json 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 d61aa17 commit c4a938c

File tree

3 files changed

+275
-51
lines changed

3 files changed

+275
-51
lines changed

src/CloudNative.CloudEvents.SystemTextJson/JsonEventFormatter.cs

Lines changed: 89 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -29,34 +29,44 @@ namespace CloudNative.CloudEvents.SystemTextJson
2929
/// nor "data_base64" property is populated in a structured mode message.
3030
/// </description></item>
3131
/// <item><description>
32-
/// If the data content type is absent or has a media type of "application/json", the data is encoded as JSON,
32+
/// If the data value is a byte array, it is serialized either directly as binary data
33+
/// (for binary mode messages) or as base64 data (for structured mode messages).
34+
/// </description></item>
35+
/// <item><description>
36+
/// Otherwise, if the data content type is absent or has a media type indicating JSON, the data is encoded as JSON,
3337
/// using the <see cref="JsonSerializerOptions"/> passed into the constructor, or the default options.
3438
/// </description></item>
3539
/// <item><description>
3640
/// Otherwise, if the data content type has a media type beginning with "text/" and the data value is a string,
3741
/// the data is serialized as a string.
3842
/// </description></item>
3943
/// <item><description>
40-
/// Otherwise, if the data value is a byte array, it is serialized either directly as binary data
41-
/// (for binary mode messages) or as base64 data (for structured mode messages).
42-
/// </description></item>
43-
/// <item><description>
4444
/// Otherwise, the encoding operation fails.
4545
/// </description></item>
4646
/// </list>
4747
/// <para>
48-
/// When decoding CloudEvent data, this implementation uses the following rules:
49-
/// </para>
50-
/// <para>
51-
/// In a structured mode message, any data is either binary data within the "data_base64" property value,
52-
/// or is a JSON token as the "data" property value. Binary data is represented as a byte array.
53-
/// A JSON token is decoded as a string if is just a string value and the data content type is specified
54-
/// and has a media type beginning with "text/". A JSON token representing the null value always
55-
/// leads to a null data result. In any other situation, the JSON token is preserved as a <see cref="JsonElement"/>
56-
/// that can be used for further deserialization (e.g. to a specific CLR type). This behavior can be modified
57-
/// by overriding <see cref="DecodeStructuredModeDataBase64Property(JsonElement, CloudEvent)"/> and
58-
/// <see cref="DecodeStructuredModeDataProperty(JsonElement, CloudEvent)"/>.
48+
/// When decoding structured mode CloudEvent data, this implementation uses the following rules,
49+
/// which can be modified by overriding <see cref="DecodeStructuredModeDataBase64Property(JsonElement, CloudEvent)"/>
50+
/// and <see cref="DecodeStructuredModeDataProperty(JsonElement, CloudEvent)"/>.
5951
/// </para>
52+
/// <list type="bullet">
53+
/// <item><description>
54+
/// If the "data_base64" property is present, its value is decoded as a byte array.
55+
/// </description></item>
56+
/// <item><description>
57+
/// If the "data" property is present (and non-null) and the content type is absent or indicates a JSON media type,
58+
/// the JSON token present in the property is preserved as a <see cref="JsonElement"/> that can be used for further
59+
/// deserialization (e.g. to a specific CLR type).
60+
/// </description></item>
61+
/// <item><description>
62+
/// If the "data" property has a string value and a non-JSON content type has been specified, the data is
63+
/// deserialized as a string.
64+
/// </description></item>
65+
/// <item><description>
66+
/// If the "data" property has a non-null, non-string value and a non-JSON content type has been specified,
67+
/// the deserialization operation fails.
68+
/// </description></item>
69+
/// </list>
6070
/// <para>
6171
/// In a binary mode message, the data is parsed based on the content type of the message. When the content
6272
/// 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 @@ private void PopulateDataFromStructuredEvent(CloudEvent cloudEvent, JsonElement
310320
}
311321
else
312322
{
323+
// If no content type has been specified, default to application/json
324+
cloudEvent.DataContentType ??= JsonMediaType;
325+
313326
DecodeStructuredModeDataProperty(dataElement, cloudEvent);
314327
}
315328
}
@@ -347,8 +360,9 @@ protected virtual void DecodeStructuredModeDataBase64Property(JsonElement dataBa
347360
/// </summary>
348361
/// <remarks>
349362
/// <para>
350-
/// This implementation converts JSON string tokens to strings when the content type suggests
351-
/// that's appropriate, but otherwise returns the token directly.
363+
/// This implementation will populate the Data property with the verbatim <see cref="JsonElement"/> if
364+
/// the content type is deemed to be JSON according to <see cref="IsJsonMediaType(string)"/>. Otherwise,
365+
/// it validates that the token is a string, and the Data property is populated with that string.
352366
/// </para>
353367
/// <para>
354368
/// Override this method to provide more specialized conversions.
@@ -358,12 +372,24 @@ protected virtual void DecodeStructuredModeDataBase64Property(JsonElement dataBa
358372
/// not have a null token type.</param>
359373
/// <param name="cloudEvent">The event being decoded. This should not be modified except to
360374
/// populate the <see cref="CloudEvent.Data"/> property, but may be used to provide extra
361-
/// information such as the data content type. Will not be null.</param>
375+
/// information such as the data content type. Will not be null, and the <see cref="CloudEvent.DataContentType"/>
376+
/// property will be non-null.</param>
362377
/// <returns>The data to populate in the <see cref="CloudEvent.Data"/> property.</returns>
363-
protected virtual void DecodeStructuredModeDataProperty(JsonElement dataElement, CloudEvent cloudEvent) =>
364-
cloudEvent.Data = dataElement.ValueKind == JsonValueKind.String && cloudEvent.DataContentType?.StartsWith("text/") == true
365-
? dataElement.GetString()
366-
: (object) dataElement.Clone(); // Deliberately cast to object to provide the conditional operator expression type.
378+
protected virtual void DecodeStructuredModeDataProperty(JsonElement dataElement, CloudEvent cloudEvent)
379+
{
380+
if (IsJsonMediaType(cloudEvent.DataContentType!))
381+
{
382+
cloudEvent.Data = dataElement.Clone();
383+
}
384+
else
385+
{
386+
if (dataElement.ValueKind != JsonValueKind.String)
387+
{
388+
throw new ArgumentException("CloudEvents with a non-JSON datacontenttype can only have string data values.");
389+
}
390+
cloudEvent.Data = dataElement.GetString();
391+
}
392+
}
367393

368394
/// <inheritdoc />
369395
public override ReadOnlyMemory<byte> EncodeBatchModeMessage(IEnumerable<CloudEvent> cloudEvents, out ContentType contentType)
@@ -426,12 +452,16 @@ private void WriteCloudEventForBatchOrStructuredMode(Utf8JsonWriter writer, Clou
426452
default:
427453
writer.WriteStringValue(attribute.Type.Format(value));
428454
break;
429-
430455
}
431456
}
432457

433458
if (cloudEvent.Data is object)
434459
{
460+
if (cloudEvent.DataContentType is null)
461+
{
462+
writer.WritePropertyName(cloudEvent.SpecVersion.DataContentTypeAttribute.Name);
463+
writer.WriteStringValue(JsonMediaType);
464+
}
435465
EncodeStructuredModeData(cloudEvent, writer);
436466
}
437467
writer.WriteEndObject();
@@ -452,26 +482,31 @@ private void WriteCloudEventForBatchOrStructuredMode(Utf8JsonWriter writer, Clou
452482
/// <param name="writer"/>The writer to serialize the data to. Will not be null.</param>
453483
protected virtual void EncodeStructuredModeData(CloudEvent cloudEvent, Utf8JsonWriter writer)
454484
{
455-
ContentType dataContentType = new ContentType(cloudEvent.DataContentType ?? JsonMediaType);
456-
if (dataContentType.MediaType == JsonMediaType)
457-
{
458-
writer.WritePropertyName(DataPropertyName);
459-
JsonSerializer.Serialize(writer, cloudEvent.Data, SerializerOptions);
460-
}
461-
else if (cloudEvent.Data is string text && dataContentType.MediaType.StartsWith("text/"))
462-
{
463-
writer.WritePropertyName(DataPropertyName);
464-
writer.WriteStringValue(text);
465-
}
466-
else if (cloudEvent.Data is byte[] binary)
485+
// Binary data is encoded using the data_base64 property, regardless of content type.
486+
// TODO: Support other forms of binary data, e.g. ReadOnlyMemory<byte>
487+
if (cloudEvent.Data is byte[] binary)
467488
{
468489
writer.WritePropertyName(DataBase64PropertyName);
469490
writer.WriteStringValue(Convert.ToBase64String(binary));
470491
}
471492
else
472493
{
473-
// We assume CloudEvent.Data is not null due to the way this is called.
474-
throw new ArgumentException($"{nameof(JsonEventFormatter)} cannot serialize data of type {cloudEvent.Data!.GetType()} with content type '{cloudEvent.DataContentType}'");
494+
ContentType dataContentType = new ContentType(cloudEvent.DataContentType ?? JsonMediaType);
495+
if (IsJsonMediaType(dataContentType.MediaType))
496+
{
497+
writer.WritePropertyName(DataPropertyName);
498+
JsonSerializer.Serialize(writer, cloudEvent.Data, SerializerOptions);
499+
}
500+
else if (cloudEvent.Data is string text && dataContentType.MediaType.StartsWith("text/"))
501+
{
502+
writer.WritePropertyName(DataPropertyName);
503+
writer.WriteStringValue(text);
504+
}
505+
else
506+
{
507+
// We assume CloudEvent.Data is not null due to the way this is called.
508+
throw new ArgumentException($"{nameof(JsonEventFormatter)} cannot serialize data of type {cloudEvent.Data!.GetType()} with content type '{cloudEvent.DataContentType}'");
509+
}
475510
}
476511
}
477512

@@ -484,8 +519,14 @@ public override ReadOnlyMemory<byte> EncodeBinaryModeEventData(CloudEvent cloudE
484519
{
485520
return Array.Empty<byte>();
486521
}
522+
// Binary data is left alone, regardless of the content type.
523+
// TODO: Support other forms of binary data, e.g. ReadOnlyMemory<byte>
524+
if (cloudEvent.Data is byte[] bytes)
525+
{
526+
return bytes;
527+
}
487528
ContentType contentType = new ContentType(cloudEvent.DataContentType ?? JsonMediaType);
488-
if (contentType.MediaType == JsonMediaType)
529+
if (IsJsonMediaType(contentType.MediaType))
489530
{
490531
var encoding = MimeUtilities.GetEncoding(contentType);
491532
if (encoding is UTF8Encoding)
@@ -501,10 +542,6 @@ public override ReadOnlyMemory<byte> EncodeBinaryModeEventData(CloudEvent cloudE
501542
{
502543
return MimeUtilities.GetEncoding(contentType).GetBytes(text);
503544
}
504-
if (cloudEvent.Data is byte[] bytes)
505-
{
506-
return bytes;
507-
}
508545
throw new ArgumentException($"{nameof(JsonEventFormatter)} cannot serialize data of type {cloudEvent.Data.GetType()} with content type '{cloudEvent.DataContentType}'");
509546
}
510547

@@ -541,6 +578,15 @@ public override void DecodeBinaryModeEventData(ReadOnlyMemory<byte> body, CloudE
541578
cloudEvent.Data = body.ToArray();
542579
}
543580
}
581+
582+
/// <summary>
583+
/// Determines whether the given media type should be handled as JSON.
584+
/// The default implementation treats anything ending with "/json" or "+json"
585+
/// as JSON.
586+
/// </summary>
587+
/// <param name="mediaType">The media type to check for JSON. Will not be null.</param>
588+
/// <returns>Whether or not <paramref name="mediaType"/> indicates JSON data.</returns>
589+
protected virtual bool IsJsonMediaType(string mediaType) => mediaType.EndsWith("/json") || mediaType.EndsWith("+json");
544590
}
545591

546592
/// <summary>

test/CloudNative.CloudEvents.UnitTests/SystemTextJson/JsonElementAsserter.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ public void AssertProperties(JsonElement obj, bool assertCount)
3939
JsonValueKind.String => property.GetString(),
4040
JsonValueKind.Number => property.GetInt32(),
4141
JsonValueKind.Null => (object?) null,
42+
JsonValueKind.Object => JsonSerializer.Deserialize(property.GetRawText(), expectation.value.GetType()),
4243
_ => throw new Exception($"Unhandled value kind: {property.ValueKind}")
4344
};
4445

0 commit comments

Comments
 (0)