diff --git a/docs/bindings.md b/docs/bindings.md index c110f50..59af2ce 100644 --- a/docs/bindings.md +++ b/docs/bindings.md @@ -169,8 +169,11 @@ The conversion should follow the following steps of pseudo-code: - For binary mode encoding: - Call `formatter.EncodeBinaryModeEventData` to encode the data within the CloudEvent + - Call `formatter.GetOrInferDataContentType` to obtain the + appropriate content type, which may be inferred from the + data if the CloudEvent itself does not specify the data content type. - Populate metadata in the message from the attributes in the - CloudEvent. + CloudEvent and the content type. - For `To...` methods, return the resulting protocol message. This must not be null. (`CopyTo...` messages do not return anything.) diff --git a/docs/formatters.md b/docs/formatters.md index 47346bd..416af4d 100644 --- a/docs/formatters.md +++ b/docs/formatters.md @@ -105,3 +105,18 @@ The formatter should *not* perform validation on the `CloudEvent` accepted in `DecodeBinaryModeEventData`, beyond asserting that the argument is not null. This is typically called by a protocol binding which should perform validation itself later. + +## Data content type inference + +Some event formats (e.g. JSON) infer the data content type from the +actual data provided. In the C# SDK, this is implemented via the +`CloudEventFormatter` methods `GetOrInferDataContentType` and +`InferDataContentType`. The first of these is primarily a +convenience method to be called by bindings; the second may be +overridden by any formatter implementation that wishes to infer +a data content type when one is not specified. Implementations *can* +override `GetOrInferDataContentType` if they have unusual +requirements, but the default implementation is usually sufficient. + +The base implementation of `InferDataContentType` always returns +null; this means that no content type is inferred by default. diff --git a/src/CloudNative.CloudEvents.Amqp/AmqpExtensions.cs b/src/CloudNative.CloudEvents.Amqp/AmqpExtensions.cs index d947e0d..2e8ebdd 100644 --- a/src/CloudNative.CloudEvents.Amqp/AmqpExtensions.cs +++ b/src/CloudNative.CloudEvents.Amqp/AmqpExtensions.cs @@ -168,7 +168,7 @@ namespace CloudNative.CloudEvents.Amqp break; case ContentMode.Binary: bodySection = new Data { Binary = BinaryDataUtilities.AsArray(formatter.EncodeBinaryModeEventData(cloudEvent)) }; - properties = new Properties { ContentType = cloudEvent.DataContentType }; + properties = new Properties { ContentType = formatter.GetOrInferDataContentType(cloudEvent) }; break; default: throw new ArgumentOutOfRangeException(nameof(contentMode), $"Unsupported content mode: {contentMode}"); diff --git a/src/CloudNative.CloudEvents.AspNetCore/HttpResponseExtensions.cs b/src/CloudNative.CloudEvents.AspNetCore/HttpResponseExtensions.cs index b8f45d6..102a871 100644 --- a/src/CloudNative.CloudEvents.AspNetCore/HttpResponseExtensions.cs +++ b/src/CloudNative.CloudEvents.AspNetCore/HttpResponseExtensions.cs @@ -41,7 +41,7 @@ namespace CloudNative.CloudEvents.AspNetCore break; case ContentMode.Binary: content = formatter.EncodeBinaryModeEventData(cloudEvent); - contentType = MimeUtilities.CreateContentTypeOrNull(cloudEvent.DataContentType); + contentType = MimeUtilities.CreateContentTypeOrNull(formatter.GetOrInferDataContentType(cloudEvent)); break; default: throw new ArgumentOutOfRangeException(nameof(contentMode), $"Unsupported content mode: {contentMode}"); diff --git a/src/CloudNative.CloudEvents.Avro/AvroEventFormatter.cs b/src/CloudNative.CloudEvents.Avro/AvroEventFormatter.cs index e8f472b..1099f46 100644 --- a/src/CloudNative.CloudEvents.Avro/AvroEventFormatter.cs +++ b/src/CloudNative.CloudEvents.Avro/AvroEventFormatter.cs @@ -30,6 +30,9 @@ namespace CloudNative.CloudEvents /// Avro record, so the value will have the natural Avro deserialization type for that data (which may /// not be exactly the same as the type that was serialized). /// + /// + /// This event formatter does not infer any data content type. + /// /// public class AvroEventFormatter : CloudEventFormatter { diff --git a/src/CloudNative.CloudEvents.Kafka/AssemblyInfo.cs b/src/CloudNative.CloudEvents.Kafka/AssemblyInfo.cs new file mode 100644 index 0000000..3a2e4ff --- /dev/null +++ b/src/CloudNative.CloudEvents.Kafka/AssemblyInfo.cs @@ -0,0 +1,12 @@ +// Copyright 2022 Cloud Native Foundation. +// Licensed under the Apache 2.0 license. +// See LICENSE file in the project root for full license information. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CloudNative.CloudEvents.UnitTests,PublicKey=" + + "0024000004800000940000000602000000240000525341310004000001000100e945e99352d0b8" + + "90ddb645995bc05ef5a22497d97e78196b9f6148ea33b0c1b219f0c28df523878d1d8c9d042a02" + + "f005777461dffe455b348f82b39fcbc64985ef091295c0ad2dcb265c23589e9ce8e48dbe84c8e1" + + "7fc37555938b2669aea7575cee288809065aa9dc04dff67ce1dfc5a3167770323c1a2c632f0eb2" + + "f8c64acf")] \ No newline at end of file diff --git a/src/CloudNative.CloudEvents.Kafka/KafkaExtensions.cs b/src/CloudNative.CloudEvents.Kafka/KafkaExtensions.cs index e3d0118..f016092 100644 --- a/src/CloudNative.CloudEvents.Kafka/KafkaExtensions.cs +++ b/src/CloudNative.CloudEvents.Kafka/KafkaExtensions.cs @@ -20,7 +20,8 @@ namespace CloudNative.CloudEvents.Kafka { private const string KafkaHeaderPrefix = "ce_"; - private const string KafkaContentTypeAttributeName = "content-type"; + // Visible for testing + internal const string KafkaContentTypeAttributeName = "content-type"; private const string SpecVersionKafkaHeader = KafkaHeaderPrefix + "specversion"; /// @@ -155,7 +156,7 @@ namespace CloudNative.CloudEvents.Kafka break; case ContentMode.Binary: value = BinaryDataUtilities.AsArray(formatter.EncodeBinaryModeEventData(cloudEvent)); - contentTypeHeaderValue = cloudEvent.DataContentType; + contentTypeHeaderValue = formatter.GetOrInferDataContentType(cloudEvent); break; default: throw new ArgumentOutOfRangeException(nameof(contentMode), $"Unsupported content mode: {contentMode}"); diff --git a/src/CloudNative.CloudEvents.NewtonsoftJson/JsonEventFormatter.cs b/src/CloudNative.CloudEvents.NewtonsoftJson/JsonEventFormatter.cs index 46ebea7..476c3a2 100644 --- a/src/CloudNative.CloudEvents.NewtonsoftJson/JsonEventFormatter.cs +++ b/src/CloudNative.CloudEvents.NewtonsoftJson/JsonEventFormatter.cs @@ -446,23 +446,33 @@ namespace CloudNative.CloudEvents.NewtonsoftJson if (cloudEvent.Data is object) { - if (cloudEvent.DataContentType is null) + if (cloudEvent.DataContentType is null && GetOrInferDataContentType(cloudEvent) is string inferredDataContentType) { + cloudEvent.SpecVersion.DataContentTypeAttribute.Validate(inferredDataContentType); writer.WritePropertyName(cloudEvent.SpecVersion.DataContentTypeAttribute.Name); - writer.WriteValue(JsonMediaType); + writer.WriteValue(inferredDataContentType); } EncodeStructuredModeData(cloudEvent, writer); } writer.WriteEndObject(); } + /// + /// Infers the data content type of a CloudEvent based on its data. This implementation + /// infers a data content type of "application/json" for any non-binary data, and performs + /// no inference for binary data. + /// + /// The CloudEvent to infer the data content from. Must not be null. + /// The inferred data content type, or null if no inference is performed. + protected override string? InferDataContentType(object data) => data is byte[]? null : JsonMediaType; + /// /// Encodes structured mode data within a CloudEvent, writing it to the specified . /// /// /// /// This implementation follows the rules listed in the class remarks. Override this method - /// to provide more specialized behavior, writing only or + /// to provide more specialized behavior, usually writing only or /// properties. /// /// @@ -480,7 +490,7 @@ namespace CloudNative.CloudEvents.NewtonsoftJson } else { - ContentType dataContentType = new ContentType(cloudEvent.DataContentType ?? JsonMediaType); + ContentType dataContentType = new ContentType(GetOrInferDataContentType(cloudEvent)); if (IsJsonMediaType(dataContentType.MediaType)) { writer.WritePropertyName(DataPropertyName); diff --git a/src/CloudNative.CloudEvents.Protobuf/ProtobufEventFormatter.cs b/src/CloudNative.CloudEvents.Protobuf/ProtobufEventFormatter.cs index 9d041cc..1266fc4 100644 --- a/src/CloudNative.CloudEvents.Protobuf/ProtobufEventFormatter.cs +++ b/src/CloudNative.CloudEvents.Protobuf/ProtobufEventFormatter.cs @@ -59,6 +59,9 @@ namespace CloudNative.CloudEvents.Protobuf /// a string, otherwise it is left as a byte array. Derived classes can specialize this behavior by overriding /// . /// + /// + /// This event formatter does not infer any data content type. + /// /// public class ProtobufEventFormatter : CloudEventFormatter { diff --git a/src/CloudNative.CloudEvents.SystemTextJson/JsonEventFormatter.cs b/src/CloudNative.CloudEvents.SystemTextJson/JsonEventFormatter.cs index 41420b9..3aad7ed 100644 --- a/src/CloudNative.CloudEvents.SystemTextJson/JsonEventFormatter.cs +++ b/src/CloudNative.CloudEvents.SystemTextJson/JsonEventFormatter.cs @@ -457,16 +457,26 @@ namespace CloudNative.CloudEvents.SystemTextJson if (cloudEvent.Data is object) { - if (cloudEvent.DataContentType is null) + if (cloudEvent.DataContentType is null && GetOrInferDataContentType(cloudEvent) is string inferredDataContentType) { + cloudEvent.SpecVersion.DataContentTypeAttribute.Validate(inferredDataContentType); writer.WritePropertyName(cloudEvent.SpecVersion.DataContentTypeAttribute.Name); - writer.WriteStringValue(JsonMediaType); + writer.WriteStringValue(inferredDataContentType); } EncodeStructuredModeData(cloudEvent, writer); } writer.WriteEndObject(); } + /// + /// Infers the data content type of a CloudEvent based on its data. This implementation + /// infers a data content type of "application/json" for any non-binary data, and performs + /// no inference for binary data. + /// + /// The CloudEvent to infer the data content from. Must not be null. + /// The inferred data content type, or null if no inference is performed. + protected override string? InferDataContentType(object data) => data is byte[]? null : JsonMediaType; + /// /// Encodes structured mode data within a CloudEvent, writing it to the specified . /// diff --git a/src/CloudNative.CloudEvents/CloudEventFormatter.cs b/src/CloudNative.CloudEvents/CloudEventFormatter.cs index 4f4bf23..0cb3e96 100644 --- a/src/CloudNative.CloudEvents/CloudEventFormatter.cs +++ b/src/CloudNative.CloudEvents/CloudEventFormatter.cs @@ -164,5 +164,41 @@ namespace CloudNative.CloudEvents /// Must not be null (on return). /// The batch representation of the CloudEvent. public abstract ReadOnlyMemory EncodeBatchModeMessage(IEnumerable cloudEvents, out ContentType contentType); + + /// + /// Determines the effective data content type of the given CloudEvent. + /// + /// + /// + /// This implementation validates that is not null, + /// returns the existing if that's not null, + /// and otherwise returns null if is null or + /// delegates to to infer the data content type + /// from the actual data. + /// + /// + /// Derived classes may override this if additional information is needed from the CloudEvent + /// in order to determine the effective data content type, but most cases can be handled by + /// simply overriding . + /// + /// + /// The CloudEvent to get or infer the data content type from. Must not be null. + /// The data content type of the CloudEvent, or null for no data content type. + public virtual string? GetOrInferDataContentType(CloudEvent cloudEvent) + { + Validation.CheckNotNull(cloudEvent, nameof(cloudEvent)); + return cloudEvent.DataContentType is string dataContentType ? dataContentType + : cloudEvent.Data is not object data ? null + : InferDataContentType(data); + } + + /// + /// Infers the effective data content type based on the actual data. This base implementation + /// always returns null, but derived classes may override this method to effectively provide + /// a default data content type based on the in-memory data type. + /// + /// The data within a CloudEvent. Should not be null. + /// The inferred content type, or null if no content type is inferred. + protected virtual string? InferDataContentType(object data) => null; } } \ No newline at end of file diff --git a/src/CloudNative.CloudEvents/CloudNative.CloudEvents.csproj b/src/CloudNative.CloudEvents/CloudNative.CloudEvents.csproj index d574eb5..9339a50 100644 --- a/src/CloudNative.CloudEvents/CloudNative.CloudEvents.csproj +++ b/src/CloudNative.CloudEvents/CloudNative.CloudEvents.csproj @@ -3,7 +3,7 @@ netstandard2.0;netstandard2.1 CNCF CloudEvents SDK - 8.0 + latest enable cloudnative;cloudevents;events diff --git a/src/CloudNative.CloudEvents/Http/HttpClientExtensions.cs b/src/CloudNative.CloudEvents/Http/HttpClientExtensions.cs index acd975b..374bd96 100644 --- a/src/CloudNative.CloudEvents/Http/HttpClientExtensions.cs +++ b/src/CloudNative.CloudEvents/Http/HttpClientExtensions.cs @@ -273,7 +273,7 @@ namespace CloudNative.CloudEvents.Http break; case ContentMode.Binary: content = formatter.EncodeBinaryModeEventData(cloudEvent); - contentType = MimeUtilities.CreateContentTypeOrNull(cloudEvent.DataContentType); + contentType = MimeUtilities.CreateContentTypeOrNull(formatter.GetOrInferDataContentType(cloudEvent)); break; default: throw new ArgumentOutOfRangeException(nameof(contentMode), $"Unsupported content mode: {contentMode}"); diff --git a/src/CloudNative.CloudEvents/Http/HttpListenerExtensions.cs b/src/CloudNative.CloudEvents/Http/HttpListenerExtensions.cs index 61ad2c8..7e83936 100644 --- a/src/CloudNative.CloudEvents/Http/HttpListenerExtensions.cs +++ b/src/CloudNative.CloudEvents/Http/HttpListenerExtensions.cs @@ -41,7 +41,7 @@ namespace CloudNative.CloudEvents.Http break; case ContentMode.Binary: content = formatter.EncodeBinaryModeEventData(cloudEvent); - contentType = MimeUtilities.CreateContentTypeOrNull(cloudEvent.DataContentType); + contentType = MimeUtilities.CreateContentTypeOrNull(formatter.GetOrInferDataContentType(cloudEvent)); break; default: throw new ArgumentOutOfRangeException(nameof(contentMode), $"Unsupported content mode: {contentMode}"); diff --git a/src/CloudNative.CloudEvents/Http/HttpWebExtensions.cs b/src/CloudNative.CloudEvents/Http/HttpWebExtensions.cs index 7b3a0cc..5c2d3f3 100644 --- a/src/CloudNative.CloudEvents/Http/HttpWebExtensions.cs +++ b/src/CloudNative.CloudEvents/Http/HttpWebExtensions.cs @@ -43,7 +43,7 @@ namespace CloudNative.CloudEvents.Http break; case ContentMode.Binary: content = formatter.EncodeBinaryModeEventData(cloudEvent); - contentType = MimeUtilities.CreateContentTypeOrNull(cloudEvent.DataContentType); + contentType = MimeUtilities.CreateContentTypeOrNull(formatter.GetOrInferDataContentType(cloudEvent)); break; default: throw new ArgumentOutOfRangeException(nameof(contentMode), $"Unsupported content mode: {contentMode}"); diff --git a/test/CloudNative.CloudEvents.UnitTests/Amqp/AmqpTest.cs b/test/CloudNative.CloudEvents.UnitTests/Amqp/AmqpTest.cs index d94d270..db14bd4 100644 --- a/test/CloudNative.CloudEvents.UnitTests/Amqp/AmqpTest.cs +++ b/test/CloudNative.CloudEvents.UnitTests/Amqp/AmqpTest.cs @@ -5,6 +5,7 @@ using Amqp; using Amqp.Framing; using CloudNative.CloudEvents.NewtonsoftJson; +using Newtonsoft.Json.Linq; using System; using System.Net.Mime; using System.Text; @@ -18,8 +19,7 @@ namespace CloudNative.CloudEvents.Amqp.UnitTests [Fact] public void AmqpStructuredMessageTest() { - // the AMQPNetLite library is factored such - // that we don't need to do a wire test here + // The AMQPNetLite library is factored such that we don't need to do a wire test here. var cloudEvent = new CloudEvent { Type = "com.github.pull.create", @@ -55,9 +55,7 @@ namespace CloudNative.CloudEvents.Amqp.UnitTests [Fact] public void AmqpBinaryMessageTest() { - // the AMQPNetLite library is factored such - // that we don't need to do a wire test here - + // The AMQPNetLite library is factored such that we don't need to do a wire test here. var cloudEvent = new CloudEvent { Type = "com.github.pull.create", @@ -89,6 +87,18 @@ namespace CloudNative.CloudEvents.Amqp.UnitTests Assert.Equal("value", (string?)receivedCloudEvent["comexampleextension1"]); } + [Fact] + public void BinaryMode_ContentTypeCanBeInferredByFormatter() + { + var cloudEvent = new CloudEvent + { + Data = "plain text" + }.PopulateRequiredAttributes(); + + var message = cloudEvent.ToAmqpMessage(ContentMode.Binary, new JsonEventFormatter()); + Assert.Equal("application/json", message.Properties.ContentType); + } + [Fact] public void AmqpNormalizesTimestampsToUtc() { diff --git a/test/CloudNative.CloudEvents.UnitTests/AspNetCore/HttpResponseExtensionsTest.cs b/test/CloudNative.CloudEvents.UnitTests/AspNetCore/HttpResponseExtensionsTest.cs index d99b68b..b511a22 100644 --- a/test/CloudNative.CloudEvents.UnitTests/AspNetCore/HttpResponseExtensionsTest.cs +++ b/test/CloudNative.CloudEvents.UnitTests/AspNetCore/HttpResponseExtensionsTest.cs @@ -40,9 +40,22 @@ namespace CloudNative.CloudEvents.AspNetCore.UnitTests // There's no data content type header; the content type itself is used for that. Assert.False(response.Headers.ContainsKey("ce-datacontenttype")); } - + [Fact] - public async Task CopyToHttpResponseAsync_ContentButNoContentType() + public async Task CopyToHttpResponseAsync_BinaryDataButNoDataContentType() + { + var cloudEvent = new CloudEvent + { + Data = new byte[10], + }.PopulateRequiredAttributes(); + var formatter = new JsonEventFormatter(); + var response = CreateResponse(); + // The formatter doesn't infer the data content type for binary data. + await Assert.ThrowsAsync(() => cloudEvent.CopyToHttpResponseAsync(response, ContentMode.Binary, formatter)); + } + + [Fact] + public async Task CopyToHttpResponseAsync_NonBinaryDataButNoDataContentType_ContentTypeIsInferred() { var cloudEvent = new CloudEvent { @@ -50,7 +63,11 @@ namespace CloudNative.CloudEvents.AspNetCore.UnitTests }.PopulateRequiredAttributes(); var formatter = new JsonEventFormatter(); var response = CreateResponse(); - await Assert.ThrowsAsync(() => cloudEvent.CopyToHttpResponseAsync(response, ContentMode.Binary, formatter)); + await cloudEvent.CopyToHttpResponseAsync(response, ContentMode.Binary, formatter); + var content = GetContent(response); + // The formatter infers that it should encode the string as a JSON value (so it includes the double quotes) + Assert.Equal("application/json", response.ContentType); + Assert.Equal("\"plain text\"", Encoding.UTF8.GetString(content.Span)); } [Fact] diff --git a/test/CloudNative.CloudEvents.UnitTests/CloudEventFormatterTest.cs b/test/CloudNative.CloudEvents.UnitTests/CloudEventFormatterTest.cs new file mode 100644 index 0000000..c05900c --- /dev/null +++ b/test/CloudNative.CloudEvents.UnitTests/CloudEventFormatterTest.cs @@ -0,0 +1,87 @@ +// Copyright 2022 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.Threading.Tasks; +using Xunit; + +namespace CloudNative.CloudEvents.UnitTests +{ + public class CloudEventFormatterTest + { + [Fact] + public void GetOrInferDataContentType_NullCloudEvent() + { + var formatter = new ContentTypeInferringFormatter(); + Assert.Throws(() => formatter.GetOrInferDataContentType(null!)); + } + + [Fact] + public void GetOrInferDataContentType_NoDataOrDataContentType() + { + var formatter = new ContentTypeInferringFormatter(); + var cloudEvent = new CloudEvent(); + Assert.Null(formatter.GetOrInferDataContentType(cloudEvent)); + } + + [Fact] + public void GetOrInferDataContentType_HasDataContentType() + { + var formatter = new ContentTypeInferringFormatter(); + var cloudEvent = new CloudEvent { DataContentType = "test/pass" }; + Assert.Equal(cloudEvent.DataContentType, formatter.GetOrInferDataContentType(cloudEvent)); + } + + [Fact] + public void GetOrInferDataContentType_HasDataButNoContentType_OverriddenInferDataContentType() + { + var formatter = new ContentTypeInferringFormatter(); + var cloudEvent = new CloudEvent { Data = "some-data" }; + Assert.Equal("test/some-data", formatter.GetOrInferDataContentType(cloudEvent)); + } + + [Fact] + public void GetOrInferDataContentType_DataButNoContentType_DefaultInferDataContentType() + { + var formatter = new ThrowingEventFormatter(); + var cloudEvent = new CloudEvent { Data = "some-data" }; + Assert.Null(formatter.GetOrInferDataContentType(cloudEvent)); + } + + private class ContentTypeInferringFormatter : ThrowingEventFormatter + { + protected override string? InferDataContentType(object data) => $"test/{data}"; + } + + /// + /// Event formatter that overrides every abstract method to throw NotImplementedException. + /// This can be derived from (and further overridden) to easily test concrete methods + /// in CloudEventFormatter itself. + /// + private class ThrowingEventFormatter : CloudEventFormatter + { + public override IReadOnlyList DecodeBatchModeMessage(ReadOnlyMemory body, ContentType? contentType, IEnumerable? extensionAttributes) => + throw new NotImplementedException(); + + public override void DecodeBinaryModeEventData(ReadOnlyMemory body, CloudEvent cloudEvent) => + throw new NotImplementedException(); + + public override CloudEvent DecodeStructuredModeMessage(ReadOnlyMemory body, ContentType? contentType, IEnumerable? extensionAttributes) => + throw new NotImplementedException(); + + public override ReadOnlyMemory EncodeBatchModeMessage(IEnumerable cloudEvents, out ContentType contentType) => + throw new NotImplementedException(); + + public override ReadOnlyMemory EncodeBinaryModeEventData(CloudEvent cloudEvent) => + throw new NotImplementedException(); + + public override ReadOnlyMemory EncodeStructuredModeMessage(CloudEvent cloudEvent, out ContentType contentType) => + throw new NotImplementedException(); + } + } +} diff --git a/test/CloudNative.CloudEvents.UnitTests/Http/HttpClientExtensionsTest.cs b/test/CloudNative.CloudEvents.UnitTests/Http/HttpClientExtensionsTest.cs index fb512e8..3707306 100644 --- a/test/CloudNative.CloudEvents.UnitTests/Http/HttpClientExtensionsTest.cs +++ b/test/CloudNative.CloudEvents.UnitTests/Http/HttpClientExtensionsTest.cs @@ -397,7 +397,7 @@ namespace CloudNative.CloudEvents.Http.UnitTests // It should be okay to not set a DataContentType if there's no data... // but what if there's a data value which is an empty string, empty byte array or empty stream? [Fact] - public void NoContentType_NoContent() + public void NoDataContentType_NoData() { var cloudEvent = new CloudEvent().PopulateRequiredAttributes(); var content = cloudEvent.ToHttpContent(ContentMode.Binary, new JsonEventFormatter()); @@ -405,10 +405,22 @@ namespace CloudNative.CloudEvents.Http.UnitTests } [Fact] - public void NoContentType_WithContent() + public void NoDataContentType_ContentTypeInferredFromFormatter() { var cloudEvent = new CloudEvent().PopulateRequiredAttributes(); - cloudEvent.Data = "Some text"; + // The JSON event format infers application/json for non-binary data + cloudEvent.Data = new { Name = "xyz" }; + var content = cloudEvent.ToHttpContent(ContentMode.Binary, new JsonEventFormatter()); + var expectedContentType = new MediaTypeHeaderValue("application/json"); + Assert.Equal(expectedContentType, content.Headers.ContentType); + } + + [Fact] + public void NoDataContentType_NoContentTypeInferredFromFormatter() + { + var cloudEvent = new CloudEvent().PopulateRequiredAttributes(); + // The JSON event format does not infer a data content type for binary data + cloudEvent.Data = new byte[10]; var exception = Assert.Throws(() => cloudEvent.ToHttpContent(ContentMode.Binary, new JsonEventFormatter())); Assert.StartsWith(Strings.ErrorContentTypeUnspecified, exception.Message); } diff --git a/test/CloudNative.CloudEvents.UnitTests/Http/HttpListenerExtensionsTest.cs b/test/CloudNative.CloudEvents.UnitTests/Http/HttpListenerExtensionsTest.cs index bfb8654..0e3b91f 100644 --- a/test/CloudNative.CloudEvents.UnitTests/Http/HttpListenerExtensionsTest.cs +++ b/test/CloudNative.CloudEvents.UnitTests/Http/HttpListenerExtensionsTest.cs @@ -207,15 +207,30 @@ namespace CloudNative.CloudEvents.Http.UnitTests } [Fact] - public async Task CopyToListenerResponseAsync_ContentButNoContentType() + public async Task CopyToListenerResponseAsync_BinaryDataButNoDataContentType() + { + var cloudEvent = new CloudEvent + { + Data = new byte[10], + }.PopulateRequiredAttributes(); + var formatter = new JsonEventFormatter(); + await GetResponseAsync( + async context => await Assert.ThrowsAsync(() => cloudEvent.CopyToHttpListenerResponseAsync(context.Response, ContentMode.Binary, formatter))); + } + + [Fact] + public async Task CopyToListenerResponseAsync_NonBinaryDataButNoDataContentType_ContentTypeIsInferred() { var cloudEvent = new CloudEvent { Data = "plain text", }.PopulateRequiredAttributes(); var formatter = new JsonEventFormatter(); - await GetResponseAsync( - async context => await Assert.ThrowsAsync(() => cloudEvent.CopyToHttpListenerResponseAsync(context.Response, ContentMode.Binary, formatter))); + var response = await GetResponseAsync( + async context => await cloudEvent.CopyToHttpListenerResponseAsync(context.Response, ContentMode.Binary, formatter)); + var content = response.Content; + Assert.Equal("application/json", content.Headers.ContentType.MediaType); + Assert.Equal("\"plain text\"", await content.ReadAsStringAsync()); } [Fact] diff --git a/test/CloudNative.CloudEvents.UnitTests/Http/HttpWebExtensionsTest.cs b/test/CloudNative.CloudEvents.UnitTests/Http/HttpWebExtensionsTest.cs index 9496c78..e6ab1ea 100644 --- a/test/CloudNative.CloudEvents.UnitTests/Http/HttpWebExtensionsTest.cs +++ b/test/CloudNative.CloudEvents.UnitTests/Http/HttpWebExtensionsTest.cs @@ -108,6 +108,32 @@ namespace CloudNative.CloudEvents.Http.UnitTests Assert.True(result.StatusCode == HttpStatusCode.NoContent, content); } + [Fact] + public async Task CopyToHttpWebRequestAsync_BinaryDataButNoDataContentType() + { + var cloudEvent = new CloudEvent + { + Data = new byte[10], + }.PopulateRequiredAttributes(); + HttpWebRequest httpWebRequest = WebRequest.CreateHttp(ListenerAddress + "ep"); + httpWebRequest.Method = "POST"; + await Assert.ThrowsAsync( + async () => await cloudEvent.CopyToHttpWebRequestAsync(httpWebRequest, ContentMode.Binary, new JsonEventFormatter())); + } + + [Fact] + public async Task CopyToHttpWebRequestAsync_NonBinaryDataButNoDataContentType_ContentTypeIsInferred() + { + var cloudEvent = new CloudEvent + { + Data = "plain text", + }.PopulateRequiredAttributes(); + HttpWebRequest httpWebRequest = WebRequest.CreateHttp(ListenerAddress + "ep"); + httpWebRequest.Method = "POST"; + await cloudEvent.CopyToHttpWebRequestAsync(httpWebRequest, ContentMode.Binary, new JsonEventFormatter()); + Assert.Equal("application/json", httpWebRequest.ContentType); + } + [Fact] public async Task CopyToHttpWebRequestAsync_Batch() { diff --git a/test/CloudNative.CloudEvents.UnitTests/Kafka/KafkaTest.cs b/test/CloudNative.CloudEvents.UnitTests/Kafka/KafkaTest.cs index 77c704d..e50aff5 100644 --- a/test/CloudNative.CloudEvents.UnitTests/Kafka/KafkaTest.cs +++ b/test/CloudNative.CloudEvents.UnitTests/Kafka/KafkaTest.cs @@ -8,6 +8,7 @@ using Confluent.Kafka; using Newtonsoft.Json; using System; using System.Collections.Generic; +using System.Linq; using System.Net.Mime; using System.Text; using Xunit; @@ -61,8 +62,8 @@ namespace CloudNative.CloudEvents.Kafka.UnitTests Assert.True(message.IsCloudEvent()); - // using serialization to create fully independent copy thus simulating message transport - // real transport will work in a similar way + // Using serialization to create fully independent copy thus simulating message transport. + // The real transport will work in a similar way. var serialized = JsonConvert.SerializeObject(message, new HeaderConverter()); var messageCopy = JsonConvert.DeserializeObject>(serialized, new HeadersConverter(), new HeaderConverter())!; @@ -104,8 +105,8 @@ namespace CloudNative.CloudEvents.Kafka.UnitTests var message = cloudEvent.ToKafkaMessage(ContentMode.Binary, new JsonEventFormatter()); Assert.True(message.IsCloudEvent()); - // using serialization to create fully independent copy thus simulating message transport - // real transport will work in a similar way + // Using serialization to create fully independent copy thus simulating message transport. + // The real transport will work in a similar way. var serialized = JsonConvert.SerializeObject(message, new HeaderConverter()); var settings = new JsonSerializerSettings { @@ -128,6 +129,20 @@ namespace CloudNative.CloudEvents.Kafka.UnitTests Assert.Equal("value", (string?)receivedCloudEvent["comexampleextension1"]); } + [Fact] + public void ContentTypeCanBeInferredByFormatter() + { + var cloudEvent = new CloudEvent + { + Data = "plain text" + }.PopulateRequiredAttributes(); + + var message = cloudEvent.ToKafkaMessage(ContentMode.Binary, new JsonEventFormatter()); + var contentTypeHeader = message.Headers.Single(h => h.Key == KafkaExtensions.KafkaContentTypeAttributeName); + var contentTypeValue = Encoding.UTF8.GetString(contentTypeHeader.GetValueBytes()); + Assert.Equal("application/json", contentTypeValue); + } + private class HeadersConverter : JsonConverter { public override bool CanConvert(Type objectType) diff --git a/test/CloudNative.CloudEvents.UnitTests/NewtonsoftJson/JsonEventFormatterTest.cs b/test/CloudNative.CloudEvents.UnitTests/NewtonsoftJson/JsonEventFormatterTest.cs index 223e890..1901a90 100644 --- a/test/CloudNative.CloudEvents.UnitTests/NewtonsoftJson/JsonEventFormatterTest.cs +++ b/test/CloudNative.CloudEvents.UnitTests/NewtonsoftJson/JsonEventFormatterTest.cs @@ -1036,23 +1036,22 @@ namespace CloudNative.CloudEvents.NewtonsoftJson.UnitTests } [Fact] - public void EncodeStructured_BinaryData_DefaultContentTypeToApplicationJson() + public void EncodeStructured_BinaryData_DefaultContentTypeIsNotImplied() { 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) + // If a CloudEvent to have binary data but no data content type, + // the spec says the data should be placed in data_base64, but the content type + // should *not* be defaulted to application/json, as clarified 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()); JObject obj = ParseJson(encoded); var asserter = new JTokenAsserter { { "data_base64", JTokenType.String, SampleBinaryDataBase64 }, - { "datacontenttype", JTokenType.String, "application/json" }, { "id", JTokenType.String, "test-id" }, { "source", JTokenType.String, "//test" }, { "specversion", JTokenType.String, "1.0" }, diff --git a/test/CloudNative.CloudEvents.UnitTests/SystemTextJson/JsonEventFormatterTest.cs b/test/CloudNative.CloudEvents.UnitTests/SystemTextJson/JsonEventFormatterTest.cs index 7ab00b2..b417ead 100644 --- a/test/CloudNative.CloudEvents.UnitTests/SystemTextJson/JsonEventFormatterTest.cs +++ b/test/CloudNative.CloudEvents.UnitTests/SystemTextJson/JsonEventFormatterTest.cs @@ -1043,23 +1043,22 @@ namespace CloudNative.CloudEvents.SystemTextJson.UnitTests } [Fact] - public void EncodeStructured_BinaryData_DefaultContentTypeToApplicationJson() + public void EncodeStructured_BinaryData_DefaultContentTypeIsNotImplied() { 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) + // If a CloudEvent to have binary data but no data content type, + // the spec says the data should be placed in data_base64, but the content type + // should *not* be defaulted to application/json, as clarified 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" },