Add methods to copy CloudEvents to HttpResponse
One part of #148 Signed-off-by: Jon Skeet <jonskeet@google.com>
This commit is contained in:
parent
2554006b23
commit
d4ff2b596e
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue