diff --git a/src/CloudNative.CloudEvents.AspNetCore/HttpResponseExtensions.cs b/src/CloudNative.CloudEvents.AspNetCore/HttpResponseExtensions.cs new file mode 100644 index 0000000..f753fa5 --- /dev/null +++ b/src/CloudNative.CloudEvents.AspNetCore/HttpResponseExtensions.cs @@ -0,0 +1,101 @@ +// 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.Core; +using CloudNative.CloudEvents.Http; +using Microsoft.AspNetCore.Http; +using System; +using System.Collections.Generic; +using System.Net.Mime; +using System.Threading.Tasks; + +namespace CloudNative.CloudEvents.AspNetCore +{ + /// + /// Extension methods to convert between HTTP responses and CloudEvents. + /// + public static class HttpResponseExtensions + { + /// + /// Copies a into an . + /// + /// The CloudEvent to copy. Must not be null, and must be a valid CloudEvent. + /// The response to copy the CloudEvent to. Must not be null. + /// Content mode (structured or binary) + /// The formatter to use within the conversion. Must not be null. + /// A task representing the asynchronous operation. + public static async Task CopyToHttpResponseAsync(this CloudEvent cloudEvent, HttpResponse destination, + ContentMode contentMode, CloudEventFormatter formatter) + { + Validation.CheckCloudEventArgument(cloudEvent, nameof(cloudEvent)); + Validation.CheckNotNull(destination, nameof(destination)); + Validation.CheckNotNull(formatter, nameof(formatter)); + + ReadOnlyMemory content; + ContentType contentType; + switch (contentMode) + { + case ContentMode.Structured: + content = formatter.EncodeStructuredModeMessage(cloudEvent, out contentType); + break; + case ContentMode.Binary: + content = formatter.EncodeBinaryModeEventData(cloudEvent); + contentType = MimeUtilities.CreateContentTypeOrNull(cloudEvent.DataContentType); + break; + default: + throw new ArgumentOutOfRangeException(nameof(contentMode), $"Unsupported content mode: {contentMode}"); + } + if (contentType is object) + { + destination.ContentType = contentType.ToString(); + } + else if (content.Length != 0) + { + throw new ArgumentException("The 'datacontenttype' attribute value must be specified", nameof(cloudEvent)); + } + + // Map headers in either mode. + // Including the headers in structured mode is optional in the spec (as they're already within the body) but + // can be useful. + destination.Headers.Add(HttpUtilities.SpecVersionHttpHeader, HttpUtilities.EncodeHeaderValue(cloudEvent.SpecVersion.VersionId)); + foreach (var attributeAndValue in cloudEvent.GetPopulatedAttributes()) + { + var attribute = attributeAndValue.Key; + var value = attributeAndValue.Value; + // The content type is already handled based on the content mode. + if (attribute != cloudEvent.SpecVersion.DataContentTypeAttribute) + { + string headerValue = HttpUtilities.EncodeHeaderValue(attribute.Format(value)); + destination.Headers.Add(HttpUtilities.HttpHeaderPrefix + attribute.Name, headerValue); + } + } + + destination.ContentLength = content.Length; + await BinaryDataUtilities.CopyToStreamAsync(content, destination.Body).ConfigureAwait(false); + } + + /// + /// Copies a batch into an . + /// + /// The CloudEvent batch to copy. Must not be null, and must be a valid CloudEvent. + /// The response to copy the CloudEvent to. Must not be null. + /// The formatter to use within the conversion. Must not be null. + /// A task representing the asynchronous operation. + public static async Task CopyToHttpResponseAsync(this IReadOnlyList cloudEvents, + HttpResponse destination, CloudEventFormatter formatter) + { + Validation.CheckCloudEventBatchArgument(cloudEvents, nameof(cloudEvents)); + Validation.CheckNotNull(destination, nameof(destination)); + Validation.CheckNotNull(formatter, nameof(formatter)); + + // TODO: Validate that all events in the batch have the same version? + // See https://github.com/cloudevents/spec/issues/807 + + ReadOnlyMemory content = formatter.EncodeBatchModeMessage(cloudEvents, out var contentType); + destination.ContentType = contentType.ToString(); + destination.ContentLength = content.Length; + await BinaryDataUtilities.CopyToStreamAsync(content, destination.Body).ConfigureAwait(false); + } + } +} diff --git a/test/CloudNative.CloudEvents.UnitTests/AspNetCore/HttpResponseExtensionsTest.cs b/test/CloudNative.CloudEvents.UnitTests/AspNetCore/HttpResponseExtensionsTest.cs new file mode 100644 index 0000000..70b1460 --- /dev/null +++ b/test/CloudNative.CloudEvents.UnitTests/AspNetCore/HttpResponseExtensionsTest.cs @@ -0,0 +1,112 @@ +// 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.Core; +using CloudNative.CloudEvents.NewtonsoftJson; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Internal; +using System; +using System.IO; +using System.Net.Mime; +using System.Text; +using System.Threading.Tasks; +using Xunit; +using static CloudNative.CloudEvents.UnitTests.TestHelpers; + +namespace CloudNative.CloudEvents.AspNetCore.UnitTests +{ + public class HttpResponseExtensionsTest + { + [Fact] + public async Task CopyToHttpResponseAsync_BinaryMode() + { + var cloudEvent = new CloudEvent + { + Data = "plain text", + DataContentType = "text/plain" + }.PopulateRequiredAttributes(); + var formatter = new JsonEventFormatter(); + var response = CreateResponse(); + await cloudEvent.CopyToHttpResponseAsync(response, ContentMode.Binary, formatter); + + var content = GetContent(response); + Assert.Equal("text/plain", response.ContentType); + Assert.Equal("plain text", Encoding.UTF8.GetString(content.Span)); + Assert.Equal("1.0", response.Headers["ce-specversion"]); + Assert.Equal(cloudEvent.Type, response.Headers["ce-type"]); + Assert.Equal(cloudEvent.Id, response.Headers["ce-id"]); + Assert.Equal(CloudEventAttributeType.UriReference.Format(cloudEvent.Source), response.Headers["ce-source"]); + // 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() + { + var cloudEvent = new CloudEvent + { + Data = "plain text", + }.PopulateRequiredAttributes(); + var formatter = new JsonEventFormatter(); + var response = CreateResponse(); + await Assert.ThrowsAsync(() => cloudEvent.CopyToHttpResponseAsync(response, ContentMode.Binary, formatter)); + } + + [Fact] + public async Task CopyToHttpResponseAsync_BadContentMode() + { + var cloudEvent = new CloudEvent().PopulateRequiredAttributes(); + var formatter = new JsonEventFormatter(); + var response = CreateResponse(); + await Assert.ThrowsAsync(() => cloudEvent.CopyToHttpResponseAsync(response, (ContentMode)100, formatter)); + } + + [Fact] + public async Task CopyToHttpResponseAsync_StructuredMode() + { + var cloudEvent = new CloudEvent + { + Data = "plain text", + DataContentType = "text/plain" + }.PopulateRequiredAttributes(); + var formatter = new JsonEventFormatter(); + var response = CreateResponse(); + await cloudEvent.CopyToHttpResponseAsync(response, ContentMode.Structured, formatter); + var content = GetContent(response); + Assert.Equal(MimeUtilities.MediaType + "+json; charset=utf-8", response.ContentType); + + var parsed = new JsonEventFormatter().DecodeStructuredModeMessage(content, new ContentType(response.ContentType), extensionAttributes: null); + AssertCloudEventsEqual(cloudEvent, parsed); + Assert.Equal(cloudEvent.Data, parsed.Data); + + // We populate headers even though we don't strictly need to; let's validate that. + Assert.Equal("1.0", response.Headers["ce-specversion"]); + Assert.Equal(cloudEvent.Type, response.Headers["ce-type"]); + Assert.Equal(cloudEvent.Id, response.Headers["ce-id"]); + Assert.Equal(CloudEventAttributeType.UriReference.Format(cloudEvent.Source), response.Headers["ce-source"]); + // We don't populate the data content type header + Assert.False(response.Headers.ContainsKey("ce-datacontenttype")); + } + + [Fact] + public async Task CopyToHttpResponseAsync_Batch() + { + var batch = CreateSampleBatch(); + var response = CreateResponse(); + await batch.CopyToHttpResponseAsync(response, new JsonEventFormatter()); + + var content = GetContent(response); + Assert.Equal(MimeUtilities.BatchMediaType + "+json; charset=utf-8", response.ContentType); + var parsedBatch = new JsonEventFormatter().DecodeBatchModeMessage(content, new ContentType(response.ContentType), extensionAttributes: null); + AssertBatchesEqual(batch, parsedBatch); + } + + private static HttpResponse CreateResponse() => new DefaultHttpResponse(new DefaultHttpContext()) { Body = new MemoryStream() }; + private static ReadOnlyMemory GetContent(HttpResponse response) + { + response.Body.Position = 0; + return BinaryDataUtilities.ToReadOnlyMemory(response.Body); + } + } +}