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);
+ }
+ }
+}