forked from cloudevents/sdk-csharp
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add methods to copy CloudEvents to HttpResponse
One part of cloudevents#148 Signed-off-by: Jon Skeet <[email protected]>
- Loading branch information
Showing
2 changed files
with
214 additions
and
0 deletions.
There are no files selected for viewing
102 changes: 102 additions & 0 deletions
102
src/CloudNative.CloudEvents.AspNetCore/HttpResponseExtensions.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,102 @@ | ||
// 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.Text; | ||
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); | ||
} | ||
} | ||
} |
112 changes: 112 additions & 0 deletions
112
test/CloudNative.CloudEvents.UnitTests/AspNetCore/HttpResponseExtensionsTest.cs
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
} |