Skip to content

Commit 0c8cf09

Browse files
committed
Add methods to copy CloudEvents to HttpResponse
One part of cloudevents#148 Signed-off-by: Jon Skeet <[email protected]>
1 parent d84ab6e commit 0c8cf09

File tree

2 files changed

+214
-0
lines changed

2 files changed

+214
-0
lines changed
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright (c) Cloud Native Foundation.
2+
// Licensed under the Apache 2.0 license.
3+
// See LICENSE file in the project root for full license information.
4+
5+
using CloudNative.CloudEvents.Core;
6+
using CloudNative.CloudEvents.Http;
7+
using Microsoft.AspNetCore.Http;
8+
using System;
9+
using System.Collections.Generic;
10+
using System.Net.Mime;
11+
using System.Text;
12+
using System.Threading.Tasks;
13+
14+
namespace CloudNative.CloudEvents.AspNetCore
15+
{
16+
/// <summary>
17+
/// Extension methods to convert between HTTP responses and CloudEvents.
18+
/// </summary>
19+
public static class HttpResponseExtensions
20+
{
21+
/// <summary>
22+
/// Copies a <see cref="CloudEvent"/> into an <see cref="HttpResponse" />.
23+
/// </summary>
24+
/// <param name="cloudEvent">The CloudEvent to copy. Must not be null, and must be a valid CloudEvent.</param>
25+
/// <param name="destination">The response to copy the CloudEvent to. Must not be null.</param>
26+
/// <param name="contentMode">Content mode (structured or binary)</param>
27+
/// <param name="formatter">The formatter to use within the conversion. Must not be null.</param>
28+
/// <returns>A task representing the asynchronous operation.</returns>
29+
public static async Task CopyToHttpResponseAsync(this CloudEvent cloudEvent, HttpResponse destination,
30+
ContentMode contentMode, CloudEventFormatter formatter)
31+
{
32+
Validation.CheckCloudEventArgument(cloudEvent, nameof(cloudEvent));
33+
Validation.CheckNotNull(destination, nameof(destination));
34+
Validation.CheckNotNull(formatter, nameof(formatter));
35+
36+
ReadOnlyMemory<byte> content;
37+
ContentType contentType;
38+
switch (contentMode)
39+
{
40+
case ContentMode.Structured:
41+
content = formatter.EncodeStructuredModeMessage(cloudEvent, out contentType);
42+
break;
43+
case ContentMode.Binary:
44+
content = formatter.EncodeBinaryModeEventData(cloudEvent);
45+
contentType = MimeUtilities.CreateContentTypeOrNull(cloudEvent.DataContentType);
46+
break;
47+
default:
48+
throw new ArgumentOutOfRangeException(nameof(contentMode), $"Unsupported content mode: {contentMode}");
49+
}
50+
if (contentType is object)
51+
{
52+
destination.ContentType = contentType.ToString();
53+
}
54+
else if (content.Length != 0)
55+
{
56+
throw new ArgumentException("The 'datacontenttype' attribute value must be specified", nameof(cloudEvent));
57+
}
58+
59+
// Map headers in either mode.
60+
// Including the headers in structured mode is optional in the spec (as they're already within the body) but
61+
// can be useful.
62+
destination.Headers.Add(HttpUtilities.SpecVersionHttpHeader, HttpUtilities.EncodeHeaderValue(cloudEvent.SpecVersion.VersionId));
63+
foreach (var attributeAndValue in cloudEvent.GetPopulatedAttributes())
64+
{
65+
var attribute = attributeAndValue.Key;
66+
var value = attributeAndValue.Value;
67+
// The content type is already handled based on the content mode.
68+
if (attribute != cloudEvent.SpecVersion.DataContentTypeAttribute)
69+
{
70+
string headerValue = HttpUtilities.EncodeHeaderValue(attribute.Format(value));
71+
destination.Headers.Add(HttpUtilities.HttpHeaderPrefix + attribute.Name, headerValue);
72+
}
73+
}
74+
75+
destination.ContentLength = content.Length;
76+
await BinaryDataUtilities.CopyToStreamAsync(content, destination.Body).ConfigureAwait(false);
77+
}
78+
79+
/// <summary>
80+
/// Copies a <see cref="CloudEvent"/> batch into an <see cref="HttpResponse" />.
81+
/// </summary>
82+
/// <param name="cloudEvents">The CloudEvent batch to copy. Must not be null, and must be a valid CloudEvent.</param>
83+
/// <param name="destination">The response to copy the CloudEvent to. Must not be null.</param>
84+
/// <param name="formatter">The formatter to use within the conversion. Must not be null.</param>
85+
/// <returns>A task representing the asynchronous operation.</returns>
86+
public static async Task CopyToHttpResponseAsync(this IReadOnlyList<CloudEvent> cloudEvents,
87+
HttpResponse destination, CloudEventFormatter formatter)
88+
{
89+
Validation.CheckCloudEventBatchArgument(cloudEvents, nameof(cloudEvents));
90+
Validation.CheckNotNull(destination, nameof(destination));
91+
Validation.CheckNotNull(formatter, nameof(formatter));
92+
93+
// TODO: Validate that all events in the batch have the same version?
94+
// See https://github.com/cloudevents/spec/issues/807
95+
96+
ReadOnlyMemory<byte> content = formatter.EncodeBatchModeMessage(cloudEvents, out var contentType);
97+
destination.ContentType = contentType.ToString();
98+
destination.ContentLength = content.Length;
99+
await BinaryDataUtilities.CopyToStreamAsync(content, destination.Body).ConfigureAwait(false);
100+
}
101+
}
102+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright 2021 Cloud Native Foundation.
2+
// Licensed under the Apache 2.0 license.
3+
// See LICENSE file in the project root for full license information.
4+
5+
using CloudNative.CloudEvents.Core;
6+
using CloudNative.CloudEvents.NewtonsoftJson;
7+
using Microsoft.AspNetCore.Http;
8+
using Microsoft.AspNetCore.Http.Internal;
9+
using System;
10+
using System.IO;
11+
using System.Net.Mime;
12+
using System.Text;
13+
using System.Threading.Tasks;
14+
using Xunit;
15+
using static CloudNative.CloudEvents.UnitTests.TestHelpers;
16+
17+
namespace CloudNative.CloudEvents.AspNetCore.UnitTests
18+
{
19+
public class HttpResponseExtensionsTest
20+
{
21+
[Fact]
22+
public async Task CopyToHttpResponseAsync_BinaryMode()
23+
{
24+
var cloudEvent = new CloudEvent
25+
{
26+
Data = "plain text",
27+
DataContentType = "text/plain"
28+
}.PopulateRequiredAttributes();
29+
var formatter = new JsonEventFormatter();
30+
var response = CreateResponse();
31+
await cloudEvent.CopyToHttpResponseAsync(response, ContentMode.Binary, formatter);
32+
33+
var content = GetContent(response);
34+
Assert.Equal("text/plain", response.ContentType);
35+
Assert.Equal("plain text", Encoding.UTF8.GetString(content.Span));
36+
Assert.Equal("1.0", response.Headers["ce-specversion"]);
37+
Assert.Equal(cloudEvent.Type, response.Headers["ce-type"]);
38+
Assert.Equal(cloudEvent.Id, response.Headers["ce-id"]);
39+
Assert.Equal(CloudEventAttributeType.UriReference.Format(cloudEvent.Source), response.Headers["ce-source"]);
40+
// There's no data content type header; the content type itself is used for that.
41+
Assert.False(response.Headers.ContainsKey("ce-datacontenttype"));
42+
}
43+
44+
[Fact]
45+
public async Task CopyToHttpResponseAsync_ContentButNoContentType()
46+
{
47+
var cloudEvent = new CloudEvent
48+
{
49+
Data = "plain text",
50+
}.PopulateRequiredAttributes();
51+
var formatter = new JsonEventFormatter();
52+
var response = CreateResponse();
53+
await Assert.ThrowsAsync<ArgumentException>(() => cloudEvent.CopyToHttpResponseAsync(response, ContentMode.Binary, formatter));
54+
}
55+
56+
[Fact]
57+
public async Task CopyToHttpResponseAsync_BadContentMode()
58+
{
59+
var cloudEvent = new CloudEvent().PopulateRequiredAttributes();
60+
var formatter = new JsonEventFormatter();
61+
var response = CreateResponse();
62+
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => cloudEvent.CopyToHttpResponseAsync(response, (ContentMode)100, formatter));
63+
}
64+
65+
[Fact]
66+
public async Task CopyToHttpResponseAsync_StructuredMode()
67+
{
68+
var cloudEvent = new CloudEvent
69+
{
70+
Data = "plain text",
71+
DataContentType = "text/plain"
72+
}.PopulateRequiredAttributes();
73+
var formatter = new JsonEventFormatter();
74+
var response = CreateResponse();
75+
await cloudEvent.CopyToHttpResponseAsync(response, ContentMode.Structured, formatter);
76+
var content = GetContent(response);
77+
Assert.Equal(MimeUtilities.MediaType + "+json; charset=utf-8", response.ContentType);
78+
79+
var parsed = new JsonEventFormatter().DecodeStructuredModeMessage(content, new ContentType(response.ContentType), extensionAttributes: null);
80+
AssertCloudEventsEqual(cloudEvent, parsed);
81+
Assert.Equal(cloudEvent.Data, parsed.Data);
82+
83+
// We populate headers even though we don't strictly need to; let's validate that.
84+
Assert.Equal("1.0", response.Headers["ce-specversion"]);
85+
Assert.Equal(cloudEvent.Type, response.Headers["ce-type"]);
86+
Assert.Equal(cloudEvent.Id, response.Headers["ce-id"]);
87+
Assert.Equal(CloudEventAttributeType.UriReference.Format(cloudEvent.Source), response.Headers["ce-source"]);
88+
// We don't populate the data content type header
89+
Assert.False(response.Headers.ContainsKey("ce-datacontenttype"));
90+
}
91+
92+
[Fact]
93+
public async Task CopyToHttpResponseAsync_Batch()
94+
{
95+
var batch = CreateSampleBatch();
96+
var response = CreateResponse();
97+
await batch.CopyToHttpResponseAsync(response, new JsonEventFormatter());
98+
99+
var content = GetContent(response);
100+
Assert.Equal(MimeUtilities.BatchMediaType + "+json; charset=utf-8", response.ContentType);
101+
var parsedBatch = new JsonEventFormatter().DecodeBatchModeMessage(content, new ContentType(response.ContentType), extensionAttributes: null);
102+
AssertBatchesEqual(batch, parsedBatch);
103+
}
104+
105+
private static HttpResponse CreateResponse() => new DefaultHttpResponse(new DefaultHttpContext()) { Body = new MemoryStream() };
106+
private static ReadOnlyMemory<byte> GetContent(HttpResponse response)
107+
{
108+
response.Body.Position = 0;
109+
return BinaryDataUtilities.ToReadOnlyMemory(response.Body);
110+
}
111+
}
112+
}

0 commit comments

Comments
 (0)