Implement JsonEventFormatter for System.Text.Json

This leads to two different formatters both called
JsonEventFormatter, just in different namespaces. That probably
wouldn't cause too much ambiguity, as few applications would depend
on both packages. It avoids longer names such as
"SystemTextJsonEventFormatter" which is pretty unwieldy.

Both the implementation and the tests are very much based on the
Newtonsoft.Json implementation.

Fixes #42.

Signed-off-by: Jon Skeet <jonskeet@google.com>
This commit is contained in:
Jon Skeet 2021-03-19 17:17:00 +00:00 committed by Jon Skeet
parent 1a03dac19e
commit 93c49cad9f
7 changed files with 1580 additions and 4 deletions

View File

@ -31,7 +31,9 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CloudNative.CloudEvents.Int
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CloudNative.CloudEvents.Avro", "src\CloudNative.CloudEvents.Avro\CloudNative.CloudEvents.Avro.csproj", "{E4BE54BF-F4D7-495F-9278-07E5A8C79935}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudNative.CloudEvents.NewtonsoftJson", "src\CloudNative.CloudEvents.NewtonsoftJson\CloudNative.CloudEvents.NewtonsoftJson.csproj", "{9DC17081-21D8-4123-8650-D97C2153DB8C}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CloudNative.CloudEvents.NewtonsoftJson", "src\CloudNative.CloudEvents.NewtonsoftJson\CloudNative.CloudEvents.NewtonsoftJson.csproj", "{9DC17081-21D8-4123-8650-D97C2153DB8C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CloudNative.CloudEvents.SystemTextJson", "src\CloudNative.CloudEvents.SystemTextJson\CloudNative.CloudEvents.SystemTextJson.csproj", "{FACB3EF2-F078-479A-A91C-719894CB66BF}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@ -175,6 +177,18 @@ Global
{9DC17081-21D8-4123-8650-D97C2153DB8C}.Release|x64.Build.0 = Release|Any CPU
{9DC17081-21D8-4123-8650-D97C2153DB8C}.Release|x86.ActiveCfg = Release|Any CPU
{9DC17081-21D8-4123-8650-D97C2153DB8C}.Release|x86.Build.0 = Release|Any CPU
{FACB3EF2-F078-479A-A91C-719894CB66BF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FACB3EF2-F078-479A-A91C-719894CB66BF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FACB3EF2-F078-479A-A91C-719894CB66BF}.Debug|x64.ActiveCfg = Debug|Any CPU
{FACB3EF2-F078-479A-A91C-719894CB66BF}.Debug|x64.Build.0 = Debug|Any CPU
{FACB3EF2-F078-479A-A91C-719894CB66BF}.Debug|x86.ActiveCfg = Debug|Any CPU
{FACB3EF2-F078-479A-A91C-719894CB66BF}.Debug|x86.Build.0 = Debug|Any CPU
{FACB3EF2-F078-479A-A91C-719894CB66BF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FACB3EF2-F078-479A-A91C-719894CB66BF}.Release|Any CPU.Build.0 = Release|Any CPU
{FACB3EF2-F078-479A-A91C-719894CB66BF}.Release|x64.ActiveCfg = Release|Any CPU
{FACB3EF2-F078-479A-A91C-719894CB66BF}.Release|x64.Build.0 = Release|Any CPU
{FACB3EF2-F078-479A-A91C-719894CB66BF}.Release|x86.ActiveCfg = Release|Any CPU
{FACB3EF2-F078-479A-A91C-719894CB66BF}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@ -13,10 +13,8 @@ using System.Threading.Tasks;
namespace CloudNative.CloudEvents.NewtonsoftJson
{
// TODO: Rename to JsonCloudEventFormatter? NewtonsoftJsonCloudEventFormatter?
/// <summary>
/// Formatter that implements the JSON Event Format.
/// Formatter that implements the JSON Event Format, using Newtonsoft.Json (also known as Json.NET) for JSON serialization and deserialization.
/// </summary>
/// <remarks>
/// <para>

View File

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<Description>JSON support for the CNCF CloudEvents SDK, based on System.Text.Json.</Description>
<LangVersion>8.0</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" Version="5.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CloudNative.CloudEvents\CloudNative.CloudEvents.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,489 @@
// Copyright (c) Cloud Native Foundation.
// Licensed under the Apache 2.0 license.
// See LICENSE file in the project root for full license information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Mime;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
namespace CloudNative.CloudEvents.SystemTextJson
{
/// <summary>
/// Formatter that implements the JSON Event Format, using System.Text.Json for JSON serialization and deserialization.
/// </summary>
/// <remarks>
/// <para>
/// When encoding CloudEvent data, the behavior of this implementation depends on the data
/// content type of the CloudEvent and the type of the <see cref="CloudEvent.Data"/> property value,
/// following the rules below. Derived classes can specialize this behavior by overriding
/// <see cref="EncodeStructuredModeData(CloudEvent, Utf8JsonWriter)"/> or <see cref="EncodeBinaryModeEventData(CloudEvent)"/>.
/// </para>
/// <list type="bullet">
/// <item><description>
/// If the data value is null, the content is empty for a binary mode message, and neither the "data"
/// nor "data_base64" property is populated in a structured mode message.
/// </description></item>
/// <item><description>
/// If the data content type is absent or has a media type of "application/json", the data is encoded as JSON,
/// using the <see cref="JsonSerializerOptions"/> passed into the constructor, or the default options.
/// </description></item>
/// <item><description>
/// Otherwise, if the data content type has a media type beginning with "text/" and the data value is a string,
/// the data is serialized as a string.
/// </description></item>
/// <item><description>
/// 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).
/// </description></item>
/// <item><description>
/// Otherwise, the encoding operation fails.
/// </description></item>
/// </list>
/// <para>
/// When decoding CloudEvent data, this implementation uses the following rules:
/// </para>
/// <para>
/// 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 <see cref="JsonElement"/>
/// that can be used for further deserialization (e.g. to a specific CLR type). This behavior can be modified
/// by overriding <see cref="DecodeStructuredModeDataBase64Property(JsonElement, CloudEvent)"/> and
/// <see cref="DecodeStructuredModeDataProperty(JsonElement, CloudEvent)"/>.
/// </para>
/// <para>
/// 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
/// a <see cref="JsonElement"/> (or null if the data is empty). When the content type has a media type beginning
/// with "text/", the data is parsed as a string. In all other cases, the data is left as a byte array.
/// This behavior can be specialized by overriding <see cref="DecodeBinaryModeEventData(byte[], CloudEvent)"/>.
/// </para>
/// </remarks>
public class JsonEventFormatter : CloudEventFormatter
{
private const string JsonMediaType = "application/json";
private const string MediaTypeSuffix = "+json";
/// <summary>
/// The property name to use for base64-encoded binary data in a structured-mode message.
/// </summary>
protected const string DataBase64PropertyName = "data_base64";
/// <summary>
/// The property name to use for general data in a structured-mode message.
/// </summary>
protected const string DataPropertyName = "data";
/// <summary>
/// The options to use when serializing objects to JSON.
/// </summary>
protected JsonSerializerOptions SerializerOptions { get; }
/// <summary>
/// The options to use when parsing JSON documents.
/// </summary>
protected JsonDocumentOptions DocumentOptions { get; }
/// <summary>
/// Creates a JsonEventFormatter that uses a default <see cref="JsonSerializer"/>.
/// </summary>
public JsonEventFormatter() : this(null, default)
{
}
/// <summary>
/// Creates a JsonEventFormatter that uses the specified <see cref="JsonSerializer"/>
/// to serialize objects as JSON.
/// </summary>
/// <param name="serializerOptions">The options to use when serializing objects to JSON. May be null.</param>
/// <param name="documentOptions">The options to use when parsing JSON documents.</param>
public JsonEventFormatter(JsonSerializerOptions serializerOptions, JsonDocumentOptions documentOptions)
{
SerializerOptions = serializerOptions;
DocumentOptions = documentOptions;
}
public override async Task<CloudEvent> DecodeStructuredModeMessageAsync(Stream data, ContentType contentType, IEnumerable<CloudEventAttribute> extensionAttributes)
{
data = data ?? throw new ArgumentNullException(nameof(data));
var encoding = contentType.GetEncoding();
JsonDocument document;
if (encoding is UTF8Encoding)
{
document = await JsonDocument.ParseAsync(data, DocumentOptions).ConfigureAwait(false);
}
else
{
using var reader = new StreamReader(data, encoding);
string json = await reader.ReadToEndAsync().ConfigureAwait(false);
document = JsonDocument.Parse(json, DocumentOptions);
}
using (document)
{
return DecodeJsonDocument(document, extensionAttributes);
}
}
public override CloudEvent DecodeStructuredModeMessage(Stream data, ContentType contentType, IEnumerable<CloudEventAttribute> extensionAttributes)
{
data = data ?? throw new ArgumentNullException(nameof(data));
var encoding = contentType.GetEncoding();
JsonDocument document;
if (encoding is UTF8Encoding)
{
document = JsonDocument.Parse(data, DocumentOptions);
}
else
{
using var reader = new StreamReader(data, encoding);
string json = reader.ReadToEnd();
document = JsonDocument.Parse(json, DocumentOptions);
}
using (document)
{
return DecodeJsonDocument(document, extensionAttributes);
}
}
public override CloudEvent DecodeStructuredModeMessage(byte[] data, ContentType contentType, IEnumerable<CloudEventAttribute> extensionAttributes) =>
DecodeStructuredModeMessage(new MemoryStream(data), contentType, extensionAttributes);
private CloudEvent DecodeJsonDocument(JsonDocument document, IEnumerable<CloudEventAttribute> extensionAttributes = null)
{
if (!document.RootElement.TryGetProperty(CloudEventsSpecVersion.SpecVersionAttribute.Name, out var specVersionProperty)
|| specVersionProperty.ValueKind != JsonValueKind.String)
{
throw new ArgumentException($"Structured mode content does not represent a CloudEvent");
}
var specVersion = CloudEventsSpecVersion.FromVersionId(specVersionProperty.GetString())
?? throw new ArgumentException($"Unsupported CloudEvents spec version '{specVersionProperty.GetString()}'");
var cloudEvent = new CloudEvent(specVersion, extensionAttributes);
PopulateAttributesFromStructuredEvent(cloudEvent, document);
PopulateDataFromStructuredEvent(cloudEvent, document);
// "data" is always the parameter from the public method. It's annoying not to be able to use
// nameof here, but this will give the appropriate result.
return cloudEvent.ValidateForConversion("data");
}
private void PopulateAttributesFromStructuredEvent(CloudEvent cloudEvent, JsonDocument document)
{
foreach (var jsonProperty in document.RootElement.EnumerateObject())
{
var name = jsonProperty.Name;
var value = jsonProperty.Value;
// Skip the spec version attribute, which we've already taken account of.
// Data is handled later, when everything else (importantly, the data content type)
// has been populated.
if (name == CloudEventsSpecVersion.SpecVersionAttribute.Name ||
name == DataBase64PropertyName ||
name == DataPropertyName)
{
continue;
}
// For non-extension attributes, validate that the token type is as expected.
// We're more forgiving for extension attributes: if an integer-typed extension attribute
// has a value of "10" (i.e. as a string), that's fine. (If it has a value of "garbage",
// that will throw in SetAttributeFromString.)
ValidateTokenTypeForAttribute(cloudEvent.GetAttribute(name), value.ValueKind);
// TODO: This currently performs more conversions than it really should, in the cause of simplicity.
// We basically need a matrix of "attribute type vs token type" but that's rather complicated.
string attributeValue = value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.True => CloudEventAttributeType.Boolean.Format(true),
JsonValueKind.False => CloudEventAttributeType.Boolean.Format(false),
JsonValueKind.Null => null,
// Note: this will fail if the value isn't an integer, or is out of range for Int32.
JsonValueKind.Number => CloudEventAttributeType.Integer.Format(value.GetInt32()),
_ => throw new ArgumentException($"Invalid token type '{value.ValueKind}' for CloudEvent attribute")
};
if (attributeValue is null)
{
continue;
}
// Note: we *could* infer an extension type of integer and Boolean, but not other extension types.
// (We don't want to assume that everything that looks like a timestamp is a timestamp, etc.)
// Stick to strings for consistency.
cloudEvent.SetAttributeFromString(name, attributeValue);
}
}
private void ValidateTokenTypeForAttribute(CloudEventAttribute attribute, JsonValueKind valueKind)
{
// We can't validate unknown attributes, don't check for extension attributes,
// and null values will be ignored anyway.
if (attribute is null || attribute.IsExtension || valueKind == JsonValueKind.Null)
{
return;
}
// This is deliberately written so that if a new attribute type is added without this being updated, we "fail valid".
// (That should only happen in major versions anyway, but it's worth being somewhat forgiving here.)
// TODO: Can we avoid hard-coding the strings here? We could potentially introduce an enum for attribute types.
var valid = attribute.Type.Name switch
{
"Binary" => valueKind == JsonValueKind.String,
"Boolean" => valueKind == JsonValueKind.True || valueKind == JsonValueKind.False,
"Integer" => valueKind == JsonValueKind.Number,
"String" => valueKind == JsonValueKind.String,
"Timestamp" => valueKind == JsonValueKind.String,
"URI" => valueKind == JsonValueKind.String,
"URI-Reference" => valueKind == JsonValueKind.String,
_ => true
};
if (!valid)
{
throw new ArgumentException($"Invalid token type '{valueKind}' for CloudEvent attribute '{attribute.Name}' with type '{attribute.Type}'");
}
}
private void PopulateDataFromStructuredEvent(CloudEvent cloudEvent, JsonDocument document)
{
// Fetch data and data_base64 tokens, and treat null as missing.
document.RootElement.TryGetProperty(DataPropertyName, out var dataElement);
if (dataElement is JsonElement { ValueKind: JsonValueKind.Null })
{
dataElement = new JsonElement();
}
document.RootElement.TryGetProperty(DataBase64PropertyName, out var dataBase64Token);
if (dataBase64Token is JsonElement { ValueKind: JsonValueKind.Null })
{
dataBase64Token = new JsonElement();
}
// If we don't have any data, we're done.
if (dataElement.ValueKind == JsonValueKind.Undefined && dataBase64Token.ValueKind == JsonValueKind.Undefined)
{
return;
}
// We can't handle both properties being set.
if (dataElement.ValueKind != JsonValueKind.Undefined && dataBase64Token.ValueKind != JsonValueKind.Undefined)
{
throw new ArgumentException($"Structured mode content cannot contain both '{DataPropertyName}' and '{DataBase64PropertyName}' properties.");
}
// Okay, we have exactly one non-null data/data_base64 property.
// Decode it, potentially using overridden methods for specialization.
if (dataBase64Token.ValueKind != JsonValueKind.Undefined)
{
DecodeStructuredModeDataBase64Property(dataBase64Token, cloudEvent);
}
else
{
DecodeStructuredModeDataProperty(dataElement, cloudEvent);
}
}
/// <summary>
/// Decodes the "data_base64" property provided within a structured-mode message,
/// populating the <see cref="CloudEvent.Data"/> property accordingly.
/// </summary>
/// <param name="cloudEvent"></param>
/// <remarks>
/// <para>
/// This implementation converts JSON string tokens to byte arrays, and fails for any other token type.
/// </para>
/// <para>
/// Override this method to provide more specialized conversions.
/// </para>
/// </remarks>
/// <param name="dataBase64Element">The "data_base64" property value within the structured-mode message. Will not be null, and will
/// not have a null token type.</param>
/// <param name="cloudEvent">The event being decoded. This should not be modified except to
/// populate the <see cref="CloudEvent.Data"/> property, but may be used to provide extra
/// information such as the data content type. Will not be null.</param>
/// <returns>The data to populate in the <see cref="CloudEvent.Data"/> property.</returns>
protected virtual void DecodeStructuredModeDataBase64Property(JsonElement dataBase64Element, CloudEvent cloudEvent)
{
if (dataBase64Element.ValueKind != JsonValueKind.String)
{
throw new ArgumentException($"Structured mode property '{DataBase64PropertyName}' must be a string, when present.");
}
cloudEvent.Data = dataBase64Element.GetBytesFromBase64();
}
/// <summary>
/// Decodes the "data" property provided within a structured-mode message,
/// populating the <see cref="CloudEvent.Data"/> property accordingly.
/// </summary>
/// <remarks>
/// <para>
/// This implementation converts JSON string tokens to strings when the content type suggests
/// that's appropriate, but otherwise returns the token directly.
/// </para>
/// <para>
/// Override this method to provide more specialized conversions.
/// </para>
/// </remarks>
/// <param name="dataElement">The "data" property value within the structured-mode message. Will not be null, and will
/// not have a null token type.</param>
/// <param name="cloudEvent">The event being decoded. This should not be modified except to
/// populate the <see cref="CloudEvent.Data"/> property, but may be used to provide extra
/// information such as the data content type. Will not be null.</param>
/// <returns>The data to populate in the <see cref="CloudEvent.Data"/> property.</returns>
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.
public override byte[] EncodeStructuredModeMessage(CloudEvent cloudEvent, out ContentType contentType)
{
cloudEvent = cloudEvent ?? throw new ArgumentNullException(nameof(cloudEvent));
cloudEvent.ValidateForConversion(nameof(cloudEvent));
contentType = new ContentType("application/cloudevents+json")
{
CharSet = Encoding.UTF8.WebName
};
var stream = new MemoryStream();
var writer = new Utf8JsonWriter(stream);
writer.WriteStartObject();
writer.WritePropertyName(CloudEventsSpecVersion.SpecVersionAttribute.Name);
writer.WriteStringValue(cloudEvent.SpecVersion.VersionId);
var attributes = cloudEvent.GetPopulatedAttributes();
foreach (var keyValuePair in attributes)
{
var attribute = keyValuePair.Key;
var value = keyValuePair.Value;
writer.WritePropertyName(attribute.Name);
// TODO: Maybe we should have an enum associated with CloudEventsAttributeType?
if (attribute.Type == CloudEventAttributeType.Integer)
{
writer.WriteNumberValue((int) value);
}
else if (attribute.Type == CloudEventAttributeType.Boolean)
{
writer.WriteBooleanValue((bool) value);
}
else
{
writer.WriteStringValue(attribute.Type.Format(value));
}
}
if (cloudEvent.Data is object)
{
EncodeStructuredModeData(cloudEvent, writer);
}
writer.WriteEndObject();
writer.Flush();
return stream.ToArray();
}
/// <summary>
/// Encodes structured mode data within a CloudEvent, writing it to the specified <see cref="JsonWriter"/>.
/// </summary>
/// <remarks>
/// <para>
/// This implementation follows the rules listed in the class remarks. Override this method
/// to provide more specialized behavior, writing only <see cref="DataPropertyName"/> or
/// <see cref="DataBase64PropertyName"/> properties.
/// </para>
/// </remarks>
/// <param name="cloudEvent">The CloudEvent being encoded, which will have a non-null value for
/// its <see cref="CloudEvent.Data"/> property.
/// <paramref name="writer"/>The writer to serialize the data to. Will not be null.</param>
/// <see cref="CloudEvent.Data"/>.</param>
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)
{
writer.WritePropertyName(DataBase64PropertyName);
writer.WriteStringValue(Convert.ToBase64String(binary));
}
else
{
throw new ArgumentException($"{nameof(JsonEventFormatter)} cannot serialize data of type {cloudEvent.Data.GetType()} with content type '{cloudEvent.DataContentType}'");
}
}
public override byte[] EncodeBinaryModeEventData(CloudEvent cloudEvent)
{
cloudEvent = cloudEvent ?? throw new ArgumentNullException(nameof(cloudEvent));
cloudEvent.ValidateForConversion(nameof(cloudEvent));
if (cloudEvent.Data is null)
{
return Array.Empty<byte>();
}
ContentType contentType = new ContentType(cloudEvent.DataContentType ?? JsonMediaType);
if (contentType.MediaType == JsonMediaType)
{
var encoding = contentType.GetEncoding();
if (encoding is UTF8Encoding)
{
return JsonSerializer.SerializeToUtf8Bytes(cloudEvent.Data, SerializerOptions);
}
else
{
return contentType.GetEncoding().GetBytes(JsonSerializer.Serialize(cloudEvent.Data, SerializerOptions));
}
}
if (contentType.MediaType.StartsWith("text/") && cloudEvent.Data is string text)
{
return contentType.GetEncoding().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}'");
}
public override void DecodeBinaryModeEventData(byte[] value, CloudEvent cloudEvent)
{
value = value ?? throw new ArgumentNullException(nameof(value));
cloudEvent = cloudEvent ?? throw new ArgumentNullException(nameof(cloudEvent));
ContentType contentType = new ContentType(cloudEvent.DataContentType ?? JsonMediaType);
Encoding encoding = contentType.GetEncoding();
if (contentType.MediaType == JsonMediaType)
{
if (value.Length > 0)
{
using JsonDocument document = encoding is UTF8Encoding
? JsonDocument.Parse(value, DocumentOptions)
: JsonDocument.Parse(encoding.GetString(value), DocumentOptions);
// We have to clone the data so that we can dispose of the JsonDocument.
cloudEvent.Data = document.RootElement.Clone();
}
else
{
cloudEvent.Data = null;
}
}
else if (contentType.MediaType.StartsWith("text/") == true)
{
cloudEvent.Data = encoding.GetString(value);
}
else
{
cloudEvent.Data = value;
}
}
}
}

View File

@ -2,6 +2,7 @@
<PropertyGroup>
<TargetFramework>netcoreapp2.1</TargetFramework>
<LangVersion>8.0</LangVersion>
</PropertyGroup>
<ItemGroup>
@ -22,6 +23,7 @@
<ProjectReference Include="..\..\src\CloudNative.CloudEvents.Kafka\CloudNative.CloudEvents.Kafka.csproj" />
<ProjectReference Include="..\..\src\CloudNative.CloudEvents.Mqtt\CloudNative.CloudEvents.Mqtt.csproj" />
<ProjectReference Include="..\..\src\CloudNative.CloudEvents.NewtonsoftJson\CloudNative.CloudEvents.NewtonsoftJson.csproj" />
<ProjectReference Include="..\..\src\CloudNative.CloudEvents.SystemTextJson\CloudNative.CloudEvents.SystemTextJson.csproj" />
<ProjectReference Include="..\..\src\CloudNative.CloudEvents\CloudNative.CloudEvents.csproj" />
</ItemGroup>

View File

@ -0,0 +1,850 @@
// Copyright (c) Cloud Native Foundation.
// Licensed under the Apache 2.0 license.
// See LICENSE file in the project root for full license information.
using CloudNative.CloudEvents.UnitTests;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Mime;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using Xunit;
using static CloudNative.CloudEvents.UnitTests.TestHelpers;
// JObject is a really handy way of creating JSON which we can then parse with System.Text.Json
using JObject = Newtonsoft.Json.Linq.JObject;
namespace CloudNative.CloudEvents.SystemTextJson.UnitTests
{
public class JsonEventFormatterTest
{
private static readonly ContentType s_jsonCloudEventContentType = new ContentType("application/cloudevents+json; charset=utf-8");
private const string NonAsciiValue = "GBP=\u00a3";
/// <summary>
/// A simple test that populates all known v1.0 attributes, so we don't need to test that
/// aspect in the future.
/// </summary>
[Fact]
public void EncodeStructuredModeMessage_V1Attributes()
{
var cloudEvent = new CloudEvent(CloudEventsSpecVersion.V1_0)
{
Data = "text", // Just so that it's reasonable to have a DataContentType
DataContentType = "text/plain",
DataSchema = new Uri("https://data-schema"),
Id = "event-id",
Source = new Uri("https://event-source"),
Subject = "event-subject",
Time = new DateTimeOffset(2021, 2, 19, 12, 34, 56, 789, TimeSpan.FromHours(1)),
Type = "event-type"
};
byte[] 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.String, "text" },
{ "datacontenttype", JsonValueKind.String, "text/plain" },
{ "dataschema", JsonValueKind.String, "https://data-schema" },
{ "id", JsonValueKind.String, "event-id" },
{ "source", JsonValueKind.String, "https://event-source" },
{ "specversion", JsonValueKind.String, "1.0" },
{ "subject", JsonValueKind.String, "event-subject" },
{ "time", JsonValueKind.String, "2021-02-19T12:34:56.789+01:00" },
{ "type", JsonValueKind.String, "event-type" },
};
asserter.AssertProperties(obj, assertCount: true);
}
[Fact]
public void EncodeStructuredModeMessage_AllAttributeTypes()
{
var cloudEvent = new CloudEvent(AllTypesExtensions)
{
["binary"] = SampleBinaryData,
["boolean"] = true,
["integer"] = 10,
["string"] = "text",
["timestamp"] = SampleTimestamp,
["uri"] = SampleUri,
["urireference"] = SampleUriReference
};
// We're not going to check these.
cloudEvent.PopulateRequiredAttributes();
JsonElement element = EncodeAndParseStructured(cloudEvent);
var asserter = new JsonElementAsserter
{
{ "binary", JsonValueKind.String, SampleBinaryDataBase64 },
{ "boolean", JsonValueKind.True, true },
{ "integer", JsonValueKind.Number, 10 },
{ "string", JsonValueKind.String, "text" },
{ "timestamp", JsonValueKind.String, SampleTimestampText },
{ "uri", JsonValueKind.String, SampleUriText },
{ "urireference", JsonValueKind.String, SampleUriReferenceText },
};
asserter.AssertProperties(element, assertCount: false);
}
[Fact]
public void EncodeStructuredModeMessage_JsonDataType_ObjectSerialization()
{
var cloudEvent = new CloudEvent().PopulateRequiredAttributes();
cloudEvent.Data = new { Text = "simple text" };
cloudEvent.DataContentType = "application/json";
JsonElement element = EncodeAndParseStructured(cloudEvent);
JsonElement dataProperty = element.GetProperty("data");
var asserter = new JsonElementAsserter
{
{ "Text", JsonValueKind.String, "simple text" }
};
asserter.AssertProperties(dataProperty, assertCount: true);
}
[Fact]
public void EncodeStructuredModeMessage_JsonDataType_CustomSerializerOptions()
{
var cloudEvent = new CloudEvent().PopulateRequiredAttributes();
cloudEvent.Data = new { DateValue = new DateTime(2021, 2, 19, 12, 49, 34, DateTimeKind.Utc) };
cloudEvent.DataContentType = "application/json";
var serializerOptions = new JsonSerializerOptions
{
Converters = { new YearMonthDayConverter() },
};
var formatter = new JsonEventFormatter(serializerOptions, default);
byte[] encoded = formatter.EncodeStructuredModeMessage(cloudEvent, out _);
JsonElement element = ParseJson(encoded);
JsonElement dataProperty = element.GetProperty("data");
var asserter = new JsonElementAsserter
{
{ "DateValue", JsonValueKind.String, "2021-02-19" }
};
asserter.AssertProperties(dataProperty, assertCount: true);
}
[Fact]
public void EncodeStructuredModeMessage_JsonDataType_AttributedModel()
{
var cloudEvent = new CloudEvent().PopulateRequiredAttributes();
cloudEvent.Data = new AttributedModel { AttributedProperty = "simple text" };
cloudEvent.DataContentType = "application/json";
JsonElement element = EncodeAndParseStructured(cloudEvent);
JsonElement dataProperty = element.GetProperty("data");
var asserter = new JsonElementAsserter
{
{ AttributedModel.JsonPropertyName, JsonValueKind.String, "simple text" }
};
asserter.AssertProperties(dataProperty, assertCount: true);
}
[Fact]
public void EncodeStructuredModeMessage_JsonDataType_JsonElement()
{
var cloudEvent = new CloudEvent().PopulateRequiredAttributes();
cloudEvent.Data = ParseJson("{ \"value\": 100 }").GetProperty("value");
cloudEvent.DataContentType = "application/json";
JsonElement element = EncodeAndParseStructured(cloudEvent);
JsonElement data = element.GetProperty("data");
Assert.Equal(JsonValueKind.Number, data.ValueKind);
Assert.Equal(100, data.GetInt32());
}
[Fact]
public void EncodeStructuredModeMessage_JsonDataType_NullValue()
{
var cloudEvent = new CloudEvent().PopulateRequiredAttributes();
cloudEvent.Data = null;
cloudEvent.DataContentType = "application/json";
JsonElement element = EncodeAndParseStructured(cloudEvent);
Assert.False(element.TryGetProperty("data", out _));
}
[Fact]
public void EncodeStructuredModeMessage_TextType_String()
{
var cloudEvent = new CloudEvent().PopulateRequiredAttributes();
cloudEvent.Data = "some text";
cloudEvent.DataContentType = "text/anything";
JsonElement element = EncodeAndParseStructured(cloudEvent);
var dataProperty = element.GetProperty("data");
Assert.Equal(JsonValueKind.String, dataProperty.ValueKind);
Assert.Equal("some text", dataProperty.GetString());
}
// A text content type with bytes as data is serialized like any other bytes.
[Fact]
public void EncodeStructuredModeMessage_TextType_Bytes()
{
var cloudEvent = new CloudEvent().PopulateRequiredAttributes();
cloudEvent.Data = SampleBinaryData;
cloudEvent.DataContentType = "text/anything";
JsonElement element = EncodeAndParseStructured(cloudEvent);
Assert.False(element.TryGetProperty("data", out _));
var dataBase64 = element.GetProperty("data_base64");
Assert.Equal(JsonValueKind.String, dataBase64.ValueKind);
Assert.Equal(SampleBinaryDataBase64, dataBase64.GetString());
}
[Fact]
public void EncodeStructuredModeMessage_TextType_NotStringOrBytes()
{
var cloudEvent = new CloudEvent().PopulateRequiredAttributes();
cloudEvent.Data = new object();
cloudEvent.DataContentType = "text/anything";
var formatter = new JsonEventFormatter();
Assert.Throws<ArgumentException>(() => formatter.EncodeStructuredModeMessage(cloudEvent, out _));
}
[Fact]
public void EncodeStructuredModeMessage_ArbitraryType_Bytes()
{
var cloudEvent = new CloudEvent().PopulateRequiredAttributes();
cloudEvent.Data = SampleBinaryData;
cloudEvent.DataContentType = "not_text/or_json";
JsonElement element = EncodeAndParseStructured(cloudEvent);
Assert.False(element.TryGetProperty("data", out _));
var dataBase64 = element.GetProperty("data_base64");
Assert.Equal(JsonValueKind.String, dataBase64.ValueKind);
Assert.Equal(SampleBinaryDataBase64, dataBase64.GetString());
}
[Fact]
public void EncodeStructuredModeMessage_ArbitraryType_NotBytes()
{
var cloudEvent = new CloudEvent().PopulateRequiredAttributes();
cloudEvent.Data = new object();
cloudEvent.DataContentType = "not_text/or_json";
var formatter = new JsonEventFormatter();
Assert.Throws<ArgumentException>(() => formatter.EncodeStructuredModeMessage(cloudEvent, out _));
}
[Fact]
public void EncodeBinaryModeEventData_JsonDataType_ObjectSerialization()
{
var cloudEvent = new CloudEvent().PopulateRequiredAttributes();
cloudEvent.Data = new { Text = "simple text" };
cloudEvent.DataContentType = "application/json";
byte[] bytes = new JsonEventFormatter().EncodeBinaryModeEventData(cloudEvent);
JsonElement data = ParseJson(bytes);
var asserter = new JsonElementAsserter
{
{ "Text", JsonValueKind.String, "simple text" }
};
asserter.AssertProperties(data, assertCount: true);
}
[Fact]
public void EncodeBinaryModeEventData_JsonDataType_CustomSerializer()
{
var cloudEvent = new CloudEvent().PopulateRequiredAttributes();
cloudEvent.Data = new { DateValue = new DateTime(2021, 2, 19, 12, 49, 34, DateTimeKind.Utc) };
cloudEvent.DataContentType = "application/json";
var serializerOptions = new JsonSerializerOptions
{
Converters = { new YearMonthDayConverter() },
};
var formatter = new JsonEventFormatter(serializerOptions, default);
byte[] bytes = formatter.EncodeBinaryModeEventData(cloudEvent);
JsonElement data = ParseJson(bytes);
var asserter = new JsonElementAsserter
{
{ "DateValue", JsonValueKind.String, "2021-02-19" }
};
asserter.AssertProperties(data, assertCount: true);
}
[Fact]
public void EncodeBinaryModeEventData_JsonDataType_AttributedModel()
{
var cloudEvent = new CloudEvent().PopulateRequiredAttributes();
cloudEvent.Data = new AttributedModel { AttributedProperty = "simple text" };
cloudEvent.DataContentType = "application/json";
byte[] bytes = new JsonEventFormatter().EncodeBinaryModeEventData(cloudEvent);
JsonElement data = ParseJson(bytes);
var asserter = new JsonElementAsserter
{
{ AttributedModel.JsonPropertyName, JsonValueKind.String, "simple text" }
};
asserter.AssertProperties(data, assertCount: true);
}
[Theory]
[InlineData("utf-8")]
[InlineData("utf-16")]
public void EncodeBinaryModeEventData_JsonDataType_JsonElement(string charset)
{
// This would definitely be an odd thing to do, admittedly...
var cloudEvent = new CloudEvent().PopulateRequiredAttributes();
cloudEvent.Data = ParseJson($"{{ \"value\": \"some text\" }}").GetProperty("value");
cloudEvent.DataContentType = $"application/json; charset={charset}";
byte[] bytes = new JsonEventFormatter().EncodeBinaryModeEventData(cloudEvent);
Assert.Equal("\"some text\"", Encoding.GetEncoding(charset).GetString(bytes));
}
[Fact]
public void EncodeBinaryModeEventData_JsonDataType_NullValue()
{
var cloudEvent = new CloudEvent().PopulateRequiredAttributes();
cloudEvent.Data = null;
cloudEvent.DataContentType = "application/json";
byte[] bytes = new JsonEventFormatter().EncodeBinaryModeEventData(cloudEvent);
Assert.Empty(bytes);
}
[Theory]
[InlineData("utf-8")]
[InlineData("iso-8859-1")]
public void EncodeBinaryModeEventData_TextType_String(string charset)
{
var cloudEvent = new CloudEvent().PopulateRequiredAttributes();
cloudEvent.Data = "some text";
cloudEvent.DataContentType = $"text/anything; charset={charset}";
byte[] bytes = new JsonEventFormatter().EncodeBinaryModeEventData(cloudEvent);
Assert.Equal("some text", Encoding.GetEncoding(charset).GetString(bytes));
}
// A text content type with bytes as data is serialized like any other bytes.
[Fact]
public void EncodeBinaryModeEventData_TextType_Bytes()
{
var cloudEvent = new CloudEvent().PopulateRequiredAttributes();
cloudEvent.Data = SampleBinaryData;
cloudEvent.DataContentType = "text/anything";
byte[] bytes = new JsonEventFormatter().EncodeBinaryModeEventData(cloudEvent);
Assert.Equal(SampleBinaryData, bytes);
}
[Fact]
public void EncodeBinaryModeEventData_TextType_NotStringOrBytes()
{
var cloudEvent = new CloudEvent().PopulateRequiredAttributes();
cloudEvent.Data = new object();
cloudEvent.DataContentType = "text/anything";
var formatter = new JsonEventFormatter();
Assert.Throws<ArgumentException>(() => formatter.EncodeBinaryModeEventData(cloudEvent));
}
[Fact]
public void EncodeBinaryModeEventData_ArbitraryType_Bytes()
{
var cloudEvent = new CloudEvent().PopulateRequiredAttributes();
cloudEvent.Data = SampleBinaryData;
cloudEvent.DataContentType = "not_text/or_json";
byte[] bytes = new JsonEventFormatter().EncodeBinaryModeEventData(cloudEvent);
Assert.Equal(SampleBinaryData, bytes);
}
[Fact]
public void EncodeBinaryModeEventData_ArbitraryType_NotBytes()
{
var cloudEvent = new CloudEvent().PopulateRequiredAttributes();
cloudEvent.Data = new object();
cloudEvent.DataContentType = "not_text/or_json";
var formatter = new JsonEventFormatter();
Assert.Throws<ArgumentException>(() => formatter.EncodeBinaryModeEventData(cloudEvent));
}
[Fact]
public void DecodeStructuredModeMessage_NotJson()
{
var formatter = new JsonEventFormatter();
Assert.ThrowsAny<JsonException>(() => formatter.DecodeStructuredModeMessage(new byte[10], new ContentType("application/json"), null));
}
// Just a single test for the code that parses asynchronously... the guts are all the same.
[Theory]
[InlineData("utf-8")]
[InlineData("iso-8859-1")]
public async Task DecodeStructuredModeMessageAsync_Minimal(string charset)
{
// Note: just using Json.NET to get the JSON in a simple way...
var obj = new JObject
{
["specversion"] = "1.0",
["type"] = "test-type",
["id"] = "test-id",
["source"] = SampleUriText,
["text"] = NonAsciiValue
};
byte[] bytes = Encoding.GetEncoding(charset).GetBytes(obj.ToString());
var stream = new MemoryStream(bytes);
var formatter = new JsonEventFormatter();
var cloudEvent = await formatter.DecodeStructuredModeMessageAsync(stream, new ContentType($"application/cloudevents+json; charset={charset}"), null);
Assert.Equal("test-type", cloudEvent.Type);
Assert.Equal("test-id", cloudEvent.Id);
Assert.Equal(SampleUri, cloudEvent.Source);
Assert.Equal(NonAsciiValue, cloudEvent["text"]);
}
[Theory]
[InlineData("utf-8")]
[InlineData("iso-8859-1")]
public void DecodeStructuredModeMessage_Minimal(string charset)
{
var obj = new JObject
{
["specversion"] = "1.0",
["type"] = "test-type",
["id"] = "test-id",
["source"] = SampleUriText,
["text"] = NonAsciiValue
};
byte[] bytes = Encoding.GetEncoding(charset).GetBytes(obj.ToString());
var stream = new MemoryStream(bytes);
var formatter = new JsonEventFormatter();
var cloudEvent = formatter.DecodeStructuredModeMessage(stream, new ContentType($"application/cloudevents+json; charset={charset}"), null);
Assert.Equal("test-type", cloudEvent.Type);
Assert.Equal("test-id", cloudEvent.Id);
Assert.Equal(SampleUri, cloudEvent.Source);
}
[Fact]
public void DecodeStructuredModeMessage_NoSpecVersion()
{
var obj = new JObject
{
["type"] = "test-type",
["id"] = "test-id",
["source"] = SampleUriText,
};
Assert.Throws<ArgumentException>(() => DecodeStructuredModeMessage(obj));
}
[Fact]
public void DecodeStructuredModeMessage_UnknownSpecVersion()
{
var obj = new JObject
{
["specversion"] = "0.5",
["type"] = "test-type",
["id"] = "test-id",
["source"] = SampleUriText,
};
Assert.Throws<ArgumentException>(() => DecodeStructuredModeMessage(obj));
}
[Fact]
public void DecodeStructuredModeMessage_MissingRequiredAttributes()
{
var obj = new JObject
{
["specversion"] = "1.0",
["type"] = "test-type",
["id"] = "test-id"
// Source is missing
};
Assert.Throws<ArgumentException>(() => DecodeStructuredModeMessage(obj));
}
[Fact]
public void DecodeStructuredModeMessage_SpecVersionNotString()
{
var obj = new JObject
{
["specversion"] = 1,
["type"] = "test-type",
["id"] = "test-id",
["source"] = SampleUriText,
};
Assert.Throws<ArgumentException>(() => DecodeStructuredModeMessage(obj));
}
[Fact]
public void DecodeStructuredModeMessage_TypeNotString()
{
var obj = new JObject
{
["specversion"] = "1.0",
["type"] = 1,
["id"] = "test-id",
["source"] = SampleUriText,
};
Assert.Throws<ArgumentException>(() => DecodeStructuredModeMessage(obj));
}
[Fact]
public void DecodeStructuredModeMessage_V1Attributes()
{
var obj = new JObject
{
["specversion"] = "1.0",
["type"] = "test-type",
["id"] = "test-id",
["data"] = "text", // Just so that it's reasonable to have a DataContentType,
["datacontenttype"] = "text/plain",
["dataschema"] = "https://data-schema",
["subject"] = "event-subject",
["source"] = "//event-source",
["time"] = SampleTimestampText
};
var cloudEvent = DecodeStructuredModeMessage(obj);
Assert.Equal(CloudEventsSpecVersion.V1_0, cloudEvent.SpecVersion);
Assert.Equal("test-type", cloudEvent.Type);
Assert.Equal("test-id", cloudEvent.Id);
Assert.Equal("text/plain", cloudEvent.DataContentType);
Assert.Equal(new Uri("https://data-schema"), cloudEvent.DataSchema);
Assert.Equal("event-subject", cloudEvent.Subject);
Assert.Equal(new Uri("//event-source", UriKind.RelativeOrAbsolute), cloudEvent.Source);
AssertTimestampsEqual(SampleTimestamp, cloudEvent.Time);
}
[Fact]
public void DecodeStructuredModeMessage_AllAttributeTypes()
{
var obj = new JObject
{
// Required attributes
["specversion"] = "1.0",
["type"] = "test-type",
["id"] = "test-id",
["source"] = "//source",
// Extension attributes
["binary"] = SampleBinaryDataBase64,
["boolean"] = true,
["integer"] = 10,
["string"] = "text",
["timestamp"] = SampleTimestampText,
["uri"] = SampleUriText,
["urireference"] = SampleUriReferenceText
};
byte[] bytes = Encoding.UTF8.GetBytes(obj.ToString());
var formatter = new JsonEventFormatter();
var cloudEvent = formatter.DecodeStructuredModeMessage(bytes, s_jsonCloudEventContentType, AllTypesExtensions);
Assert.Equal(SampleBinaryData, cloudEvent["binary"]);
Assert.True((bool)cloudEvent["boolean"]);
Assert.Equal(10, cloudEvent["integer"]);
Assert.Equal("text", cloudEvent["string"]);
AssertTimestampsEqual(SampleTimestamp, (DateTimeOffset)cloudEvent["timestamp"]);
Assert.Equal(SampleUri, cloudEvent["uri"]);
Assert.Equal(SampleUriReference, cloudEvent["urireference"]);
}
[Fact]
public void DecodeStructuredModeMessage_IncorrectExtensionTypeWithValidValue()
{
var obj = new JObject
{
["specversion"] = "1.0",
["type"] = "test-type",
["id"] = "test-id",
["source"] = "//source",
// Incorrect type, but is a valid value for the extension
["integer"] = "10",
};
// Decode the event, providing the extension with the correct type.
byte[] bytes = Encoding.UTF8.GetBytes(obj.ToString());
var formatter = new JsonEventFormatter();
var cloudEvent = formatter.DecodeStructuredModeMessage(bytes, s_jsonCloudEventContentType, AllTypesExtensions);
// The value will have been decoded according to the extension.
Assert.Equal(10, cloudEvent["integer"]);
}
// There are other invalid token types as well; this is just one of them.
[Fact]
public void DecodeStructuredModeMessage_AttributeValueAsArrayToken()
{
var obj = CreateMinimalValidJObject();
obj["attr"] = new Newtonsoft.Json.Linq.JArray();
Assert.Throws<ArgumentException>(() => DecodeStructuredModeMessage(obj));
}
[Fact]
public void DecodeStructuredModeMessage_Null()
{
var obj = CreateMinimalValidJObject();
obj["attr"] = Newtonsoft.Json.Linq.JValue.CreateNull();
var cloudEvent = DecodeStructuredModeMessage(obj);
// The JSON event format spec demands that we ignore null values, so we shouldn't
// have created an extension attribute.
Assert.Null(cloudEvent.GetAttribute("attr"));
}
[Theory]
[InlineData(null)]
[InlineData("application/json")]
[InlineData("text/plain")]
[InlineData("application/binary")]
public void DecodeStructuredModeMessage_NoData(string contentType)
{
var obj = CreateMinimalValidJObject();
if (contentType is object)
{
obj["datacontenttype"] = contentType;
}
var cloudEvent = DecodeStructuredModeMessage(obj);
Assert.Null(cloudEvent.Data);
}
[Fact]
public void DecodeStructuredModeMessage_BothDataAndDataBase64()
{
var obj = CreateMinimalValidJObject();
obj["data"] = "text";
obj["data_base64"] = SampleBinaryDataBase64;
Assert.Throws<ArgumentException>(() => DecodeStructuredModeMessage(obj));
}
[Fact]
public void DecodeStructuredModeMessage_DataBase64NonString()
{
var obj = CreateMinimalValidJObject();
obj["data_base64"] = 10;
Assert.Throws<ArgumentException>(() => DecodeStructuredModeMessage(obj));
}
// data_base64 always ends up as bytes, regardless of content type.
[Theory]
[InlineData(null)]
[InlineData("application/json")]
[InlineData("text/plain")]
[InlineData("application/binary")]
public void DecodeStructuredModeMessage_Base64(string contentType)
{
var obj = CreateMinimalValidJObject();
if (contentType is object)
{
obj["datacontenttype"] = contentType;
}
obj["data_base64"] = SampleBinaryDataBase64;
var cloudEvent = DecodeStructuredModeMessage(obj);
Assert.Equal(SampleBinaryData, cloudEvent.Data);
}
[Fact]
public void DecodeStructuredModeMessage_TextContentTypeStringToken()
{
var obj = CreateMinimalValidJObject();
obj["datacontenttype"] = "text/plain";
obj["data"] = "some text";
var cloudEvent = DecodeStructuredModeMessage(obj);
Assert.Equal("some text", cloudEvent.Data);
}
[Theory]
[InlineData(null)]
[InlineData("application/json")]
[InlineData("text/plain")]
[InlineData("application/not-quite-json")]
public void DecodeStructuredModeMessage_JsonToken(string contentType)
{
var obj = CreateMinimalValidJObject();
if (contentType is object)
{
obj["datacontenttype"] = contentType;
}
obj["data"] = 10;
var cloudEvent = DecodeStructuredModeMessage(obj);
var element = (JsonElement) cloudEvent.Data;
Assert.Equal(JsonValueKind.Number, element.ValueKind);
Assert.Equal(10, element.GetInt32());
}
[Fact]
public void DecodeStructuredModeMessage_NullDataBase64Ignored()
{
var obj = CreateMinimalValidJObject();
obj["data_base64"] = Newtonsoft.Json.Linq.JValue.CreateNull();
obj["data"] = "some text";
obj["datacontenttype"] = "text/plain";
var cloudEvent = DecodeStructuredModeMessage(obj);
Assert.Equal("some text", cloudEvent.Data);
}
[Fact]
public void DecodeStructuredModeMessage_NullDataIgnored()
{
var obj = CreateMinimalValidJObject();
obj["data_base64"] = SampleBinaryDataBase64;
obj["data"] = Newtonsoft.Json.Linq.JValue.CreateNull();
obj["datacontenttype"] = "application/binary";
var cloudEvent = DecodeStructuredModeMessage(obj);
Assert.Equal(SampleBinaryData, cloudEvent.Data);
}
[Fact]
public void DecodeBinaryModeEventData_EmptyData_JsonContentType()
{
var data = DecodeBinaryModeEventData(new byte[0], "application/json");
Assert.Null(data);
}
[Fact]
public void DecodeBinaryModeEventData_EmptyData_TextContentType()
{
var data = DecodeBinaryModeEventData(new byte[0], "text/plain");
var text = Assert.IsType<string>(data);
Assert.Equal("", text);
}
[Fact]
public void DecodeBinaryModeEventData_EmptyData_OtherContentType()
{
var data = DecodeBinaryModeEventData(new byte[0], "application/binary");
var byteArray = Assert.IsType<byte[]>(data);
Assert.Empty(byteArray);
}
[Theory]
[InlineData("utf-8")]
[InlineData("utf-16")]
public void DecodeBinaryModeEventData_Json(string charset)
{
var encoding = Encoding.GetEncoding(charset);
var bytes = encoding.GetBytes(new JObject { ["test"] = "some text" }.ToString());
var data = DecodeBinaryModeEventData(bytes, $"application/json; charset={charset}");
var element = Assert.IsType<JsonElement>(data);
var asserter = new JsonElementAsserter
{
{ "test", JsonValueKind.String, "some text"}
};
asserter.AssertProperties(element, assertCount: true);
}
[Theory]
[InlineData("utf-8")]
[InlineData("iso-8859-1")]
public void DecodeBinaryModeEventData_Text(string charset)
{
var encoding = Encoding.GetEncoding(charset);
var bytes = encoding.GetBytes(NonAsciiValue);
var data = DecodeBinaryModeEventData(bytes, $"text/plain; charset={charset}");
var text = Assert.IsType<string>(data);
Assert.Equal(NonAsciiValue, text);
}
[Fact]
public void DecodeBinaryModeEventData_Binary()
{
byte[] bytes = { 0, 1, 2, 3 };
var data = DecodeBinaryModeEventData(bytes, "application/binary");
Assert.Same(bytes, data);
}
private static object DecodeBinaryModeEventData(byte[] bytes, string contentType)
{
var cloudEvent = new CloudEvent().PopulateRequiredAttributes();
cloudEvent.DataContentType = contentType;
new JsonEventFormatter().DecodeBinaryModeEventData(bytes, cloudEvent);
return cloudEvent.Data;
}
internal static JObject CreateMinimalValidJObject() =>
new JObject
{
["specversion"] = "1.0",
["type"] = "event-type",
["id"] = "event-id",
["source"] = "//event-source"
};
/// <summary>
/// Parses JSON as a JsonElement.
/// </summary>
internal static JsonElement ParseJson(string text)
{
using var document = JsonDocument.Parse(text);
return document.RootElement.Clone();
}
/// <summary>
/// Parses JSON as a JsonElement.
/// </summary>
internal static JsonElement ParseJson(byte[] data)
{
using var document = JsonDocument.Parse(data);
return document.RootElement.Clone();
}
/// <summary>
/// Convenience method to format a CloudEvent with the default JsonEventFormatter in
/// structured mode, then parse the result as a JObject.
/// </summary>
private static JsonElement EncodeAndParseStructured(CloudEvent cloudEvent)
{
var formatter = new JsonEventFormatter();
byte[] encoded = formatter.EncodeStructuredModeMessage(cloudEvent, out _);
return ParseJson(encoded);
}
/// <summary>
/// Convenience method to serialize a JObject to bytes, then
/// decode it as a structured event with the default (System.Text.Json) JsonEventFormatter and no extension attributes.
/// </summary>
private static CloudEvent DecodeStructuredModeMessage(Newtonsoft.Json.Linq.JObject obj)
{
byte[] bytes = Encoding.UTF8.GetBytes(obj.ToString());
var formatter = new JsonEventFormatter();
return formatter.DecodeStructuredModeMessage(bytes, s_jsonCloudEventContentType, null);
}
private class JsonElementAsserter : IEnumerable
{
private readonly List<(string name, JsonValueKind type, object value)> expectations = new List<(string, JsonValueKind, object)>();
// Just for collection initializers
public IEnumerator GetEnumerator() => throw new NotImplementedException();
public void Add<T>(string name, JsonValueKind type, T value) =>
expectations.Add((name, type, value));
public void AssertProperties(JsonElement obj, bool assertCount)
{
foreach (var expectation in expectations)
{
Assert.True(
obj.TryGetProperty(expectation.name, out var property),
$"Expected property '{expectation.name}' to be present");
Assert.Equal(expectation.type, property.ValueKind);
// No need to check null values, as they'll have a null token type.
if (expectation.value is object)
{
var value = property.ValueKind switch
{
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.String => property.GetString(),
JsonValueKind.Number => property.GetInt32(),
JsonValueKind.Null => (object) null,
_ => throw new Exception($"Unhandled value kind: {property.ValueKind}")
};
Assert.Equal(expectation.value, value);
}
}
if (assertCount)
{
Assert.Equal(expectations.Count, obj.EnumerateObject().Count());
}
}
}
private class AttributedModel
{
public const string JsonPropertyName = "customattribute";
[JsonPropertyName(JsonPropertyName)]
public string AttributedProperty { get; set; }
}
private class YearMonthDayConverter : JsonConverter<DateTime>
{
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
DateTime.ParseExact(reader.GetString(), "yyyy-MM-dd", CultureInfo.InvariantCulture);
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) =>
writer.WriteStringValue(value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
}
}
}

View File

@ -0,0 +1,206 @@
// Copyright 2021 Cloud Native Foundation.
// Licensed under the Apache 2.0 license.
// See LICENSE file in the project root for full license information.
using CloudNative.CloudEvents.UnitTests;
using System;
using System.Text;
using System.Text.Json;
using Xunit;
using static CloudNative.CloudEvents.UnitTests.TestHelpers;
// JObject is a really handy way of creating JSON which we can then parse with System.Text.Json
using JObject = Newtonsoft.Json.Linq.JObject;
namespace CloudNative.CloudEvents.SystemTextJson.UnitTests
{
/// <summary>
/// Tests for encoding/decoding using a subclass of <see cref="JsonEventFormatter"/>.
/// This is effectively testing when the virtual methods are invoked.
/// </summary>
public class SpecializedFormatterTest
{
private const string GuidPrefix = "guid:";
private const string TextBinaryContentType = "text/binary";
private const string GuidContentType = "application/guid";
// Just to validate delegation to base methods
[Fact]
public void EncodePlainText()
{
var cloudEvent = new CloudEvent
{
Data = "some text",
DataContentType = "text/plain"
}.PopulateRequiredAttributes();
var obj = EncodeAndParseStructured(cloudEvent);
AssertStringElement("some text", obj.GetProperty("data"));
}
// Just to validate delegation to base methods
[Fact]
public void EncodeByteArray()
{
var cloudEvent = new CloudEvent
{
Data = SampleBinaryData,
DataContentType = "application/binary"
}.PopulateRequiredAttributes();
var obj = EncodeAndParseStructured(cloudEvent);
AssertStringElement(SampleBinaryDataBase64, obj.GetProperty("data_base64"));
}
[Fact]
public void EncodeGuid()
{
Guid guid = Guid.NewGuid();
var cloudEvent = new CloudEvent
{
Data = guid,
DataContentType = GuidContentType
}.PopulateRequiredAttributes();
var obj = EncodeAndParseStructured(cloudEvent);
string expectedText = GuidPrefix + Convert.ToBase64String(guid.ToByteArray());
AssertStringElement(expectedText, obj.GetProperty("data"));
}
[Fact]
public void EncodeTextBinary()
{
var cloudEvent = new CloudEvent
{
Data = "some text",
DataContentType = TextBinaryContentType
}.PopulateRequiredAttributes();
var obj = EncodeAndParseStructured(cloudEvent);
string expectedText = Convert.ToBase64String(Encoding.UTF8.GetBytes("some text"));
AssertStringElement(expectedText, obj.GetProperty("data_base64"));
}
// Just to validate delegation to base methods
[Fact]
public void DecodePlainText()
{
var obj = JsonEventFormatterTest.CreateMinimalValidJObject();
obj["data"] = "some text";
obj["datacontenttype"] = "text/plain";
var cloudEvent = DecodeStructuredModeMessage(obj);
Assert.Equal("some text", cloudEvent.Data);
}
// Just to validate delegation to base methods
[Fact]
public void DecodeByteArray()
{
var obj = JsonEventFormatterTest.CreateMinimalValidJObject();
obj["data_base64"] = SampleBinaryDataBase64;
obj["datacontenttype"] = "application/binary";
var cloudEvent = DecodeStructuredModeMessage(obj);
Assert.Equal(SampleBinaryData, cloudEvent.Data);
}
[Fact]
public void DecodeGuid()
{
Guid guid = Guid.NewGuid();
var obj = JsonEventFormatterTest.CreateMinimalValidJObject();
obj["data"] = GuidPrefix + Convert.ToBase64String(guid.ToByteArray());
obj["datacontenttype"] = GuidContentType;
var cloudEvent = DecodeStructuredModeMessage(obj);
Assert.Equal(guid, cloudEvent.Data);
}
[Fact]
public void DecodeTextBinary()
{
var obj = JsonEventFormatterTest.CreateMinimalValidJObject();
obj["data_base64"] = Convert.ToBase64String(Encoding.UTF8.GetBytes("some text"));
obj["datacontenttype"] = TextBinaryContentType;
var cloudEvent = DecodeStructuredModeMessage(obj);
Assert.Equal("some text", cloudEvent.Data);
}
private static void AssertStringElement(string expectedValue, JsonElement element)
{
Assert.Equal(JsonValueKind.String, element.ValueKind);
Assert.Equal(expectedValue, element.GetString());
}
/// <summary>
/// Convenience method to format a CloudEvent with a specialized formatter in
/// structured mode, then parse the result as a JObject.
/// </summary>
private static JsonElement EncodeAndParseStructured(CloudEvent cloudEvent)
{
var formatter = new SpecializedFormatter();
byte[] encoded = formatter.EncodeStructuredModeMessage(cloudEvent, out _);
return JsonEventFormatterTest.ParseJson(encoded);
}
/// <summary>
/// Convenience method to serialize a JObject to bytes, then
/// decode it as a structured event with a specialized formatter and no extension attributes.
/// </summary>
private static CloudEvent DecodeStructuredModeMessage(JObject obj)
{
byte[] bytes = Encoding.UTF8.GetBytes(obj.ToString());
var formatter = new SpecializedFormatter();
return formatter.DecodeStructuredModeMessage(bytes, null, null);
}
/// <summary>
/// Specialized formatter:
/// - Content type of "text/binary" is encoded in base64
/// - Guid with a content type of "application/guid" is encoded as a string with content "guid:base64-data"
/// </summary>
private class SpecializedFormatter : JsonEventFormatter
{
protected override void DecodeStructuredModeDataBase64Property(JsonElement dataBase64Element, CloudEvent cloudEvent)
{
if (cloudEvent.DataContentType == TextBinaryContentType && dataBase64Element.ValueKind == JsonValueKind.String)
{
cloudEvent.Data = Encoding.UTF8.GetString(dataBase64Element.GetBytesFromBase64());
}
else
{
base.DecodeStructuredModeDataBase64Property(dataBase64Element, cloudEvent);
}
}
protected override void DecodeStructuredModeDataProperty(JsonElement dataElement, CloudEvent cloudEvent)
{
if (cloudEvent.DataContentType == GuidContentType && dataElement.ValueKind == JsonValueKind.String)
{
string text = dataElement.GetString();
if (!text.StartsWith(GuidPrefix))
{
throw new ArgumentException("Invalid GUID text data");
}
cloudEvent.Data = new Guid(Convert.FromBase64String(text.Substring(GuidPrefix.Length)));
}
else
{
base.DecodeStructuredModeDataProperty(dataElement, cloudEvent);
}
}
protected override void EncodeStructuredModeData(CloudEvent cloudEvent, Utf8JsonWriter writer)
{
var data = cloudEvent.Data;
if (data is Guid guid && cloudEvent.DataContentType == GuidContentType)
{
writer.WritePropertyName(DataPropertyName);
writer.WriteStringValue(GuidPrefix + Convert.ToBase64String(guid.ToByteArray()));
}
else if (data is string text && cloudEvent.DataContentType == TextBinaryContentType)
{
writer.WritePropertyName(DataBase64PropertyName);
writer.WriteBase64StringValue(Encoding.UTF8.GetBytes(text));
}
else
{
base.EncodeStructuredModeData(cloudEvent, writer);
}
}
}
}
}