Skip to content

Commit

Permalink
Add methods to copy CloudEvents to HttpResponse
Browse files Browse the repository at this point in the history
One part of cloudevents#148

Signed-off-by: Jon Skeet <[email protected]>
  • Loading branch information
jskeet committed Jun 29, 2021
1 parent d84ab6e commit a205a44
Show file tree
Hide file tree
Showing 2 changed files with 213 additions and 0 deletions.
101 changes: 101 additions & 0 deletions src/CloudNative.CloudEvents.AspNetCore/HttpResponseExtensions.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Extension methods to convert between HTTP responses and CloudEvents.
/// </summary>
public static class HttpResponseExtensions
{
/// <summary>
/// Copies a <see cref="CloudEvent"/> into an <see cref="HttpResponse" />.
/// </summary>
/// <param name="cloudEvent">The CloudEvent to copy. Must not be null, and must be a valid CloudEvent.</param>
/// <param name="destination">The response to copy the CloudEvent to. Must not be null.</param>
/// <param name="contentMode">Content mode (structured or binary)</param>
/// <param name="formatter">The formatter to use within the conversion. Must not be null.</param>
/// <returns>A task representing the asynchronous operation.</returns>
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<byte> 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);
}

/// <summary>
/// Copies a <see cref="CloudEvent"/> batch into an <see cref="HttpResponse" />.
/// </summary>
/// <param name="cloudEvents">The CloudEvent batch to copy. Must not be null, and must be a valid CloudEvent.</param>
/// <param name="destination">The response to copy the CloudEvent to. Must not be null.</param>
/// <param name="formatter">The formatter to use within the conversion. Must not be null.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public static async Task CopyToHttpResponseAsync(this IReadOnlyList<CloudEvent> 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<byte> content = formatter.EncodeBatchModeMessage(cloudEvents, out var contentType);
destination.ContentType = contentType.ToString();
destination.ContentLength = content.Length;
await BinaryDataUtilities.CopyToStreamAsync(content, destination.Body).ConfigureAwait(false);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<ArgumentException>(() => 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<ArgumentOutOfRangeException>(() => 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<byte> GetContent(HttpResponse response)
{
response.Body.Position = 0;
return BinaryDataUtilities.ToReadOnlyMemory(response.Body);
}
}
}

0 comments on commit a205a44

Please sign in to comment.