Skip to content

Commit 304bc06

Browse files
committed
Implement underscore prefixes for AMQP
The [binding specification](https://github.com/cloudevents/spec/blob/main/cloudevents/bindings/amqp-protocol-binding.md) has changed to prefer `cloudEvents_` over `cloudEvents:`. Previously `cloudEvents_` wouldn't even have been valid. With this change, users can either: - Stick with the default prefix, which doesn't change immediately, but which will change in the first release on or after March 1st 2023 - Explicitly use one or other prefix using the explicitly-named methods Other options considered: - Changing the default now: that's too much of a breaking change. (I don't want to take a major version bump for this, and with enough time for the change, I think that's okay.) - Adding a char or string parameter: that would invite using non-standard prefixes - Adding a Boolean parameter: that would become problematic if we ever end up with a third prefix. (Let's hope we don't, but still...) - Adding an enum and then a parameter for it: feels like overkill Signed-off-by: Jon Skeet <[email protected]>
1 parent 81c82e0 commit 304bc06

File tree

2 files changed

+106
-66
lines changed

2 files changed

+106
-66
lines changed

src/CloudNative.CloudEvents.Amqp/AmqpExtensions.cs

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,14 @@ namespace CloudNative.CloudEvents.Amqp
1818
/// </summary>
1919
public static class AmqpExtensions
2020
{
21-
internal const string AmqpHeaderPrefix = "cloudEvents:";
21+
// This is internal in CloudEventsSpecVersion.
22+
private const string SpecVersionAttributeName = "specversion";
2223

23-
internal const string SpecVersionAmqpHeader = AmqpHeaderPrefix + "specversion";
24+
internal const string AmqpHeaderUnderscorePrefix = "cloudEvents_";
25+
internal const string AmqpHeaderColonPrefix = "cloudEvents:";
26+
27+
internal const string SpecVersionAmqpHeaderWithUnderscore = AmqpHeaderUnderscorePrefix + SpecVersionAttributeName;
28+
internal const string SpecVersionAmqpHeaderWithColon = AmqpHeaderColonPrefix + SpecVersionAttributeName;
2429

2530
/// <summary>
2631
/// Indicates whether this <see cref="Message"/> holds a single CloudEvent.
@@ -32,7 +37,8 @@ public static class AmqpExtensions
3237
/// <returns>true, if the request is a CloudEvent</returns>
3338
public static bool IsCloudEvent(this Message message) =>
3439
HasCloudEventsContentType(Validation.CheckNotNull(message, nameof(message)), out _) ||
35-
message.ApplicationProperties.Map.ContainsKey(SpecVersionAmqpHeader);
40+
message.ApplicationProperties.Map.ContainsKey(SpecVersionAmqpHeaderWithUnderscore) ||
41+
message.ApplicationProperties.Map.ContainsKey(SpecVersionAmqpHeaderWithColon);
3642

3743
/// <summary>
3844
/// Converts this AMQP message into a CloudEvent object.
@@ -69,7 +75,8 @@ public static CloudEvent ToCloudEvent(
6975
else
7076
{
7177
var propertyMap = message.ApplicationProperties.Map;
72-
if (!propertyMap.TryGetValue(SpecVersionAmqpHeader, out var versionId))
78+
if (!propertyMap.TryGetValue(SpecVersionAmqpHeaderWithUnderscore, out var versionId) &&
79+
!propertyMap.TryGetValue(SpecVersionAmqpHeaderWithColon, out versionId))
7380
{
7481
throw new ArgumentException("Request is not a CloudEvent");
7582
}
@@ -84,11 +91,14 @@ public static CloudEvent ToCloudEvent(
8491

8592
foreach (var property in propertyMap)
8693
{
87-
if (!(property.Key is string key && key.StartsWith(AmqpHeaderPrefix)))
94+
if (!(property.Key is string key &&
95+
(key.StartsWith(AmqpHeaderColonPrefix) || key.StartsWith(AmqpHeaderUnderscorePrefix))))
8896
{
8997
continue;
9098
}
91-
string attributeName = key.Substring(AmqpHeaderPrefix.Length).ToLowerInvariant();
99+
// Note: both prefixes have the same length. If we ever need any prefixes with a different length, we'll need to know which
100+
// prefix we're looking at.
101+
string attributeName = key.Substring(AmqpHeaderUnderscorePrefix.Length).ToLowerInvariant();
92102

93103
// We've already dealt with the spec version.
94104
if (attributeName == CloudEventsSpecVersion.SpecVersionAttribute.Name)
@@ -142,17 +152,43 @@ private static bool HasCloudEventsContentType(Message message, out string? conte
142152
}
143153

144154
/// <summary>
145-
/// Converts a CloudEvent to <see cref="Message"/>.
155+
/// Converts a CloudEvent to <see cref="Message"/> using the default property prefix. Versions released prior to March 2023
156+
/// use a default property prefix of "cloudEvents:". Versions released from March 2023 onwards use a property prefix of "cloudEvents_".
157+
/// Code wishing to express the prefix explicitly should use <see cref="ToAmqpMessageWithColonPrefix(CloudEvent, ContentMode, CloudEventFormatter)"/> or
158+
/// <see cref="ToAmqpMessageWithUnderscorePrefix(CloudEvent, ContentMode, CloudEventFormatter)"/>.
159+
/// </summary>
160+
/// <param name="cloudEvent">The CloudEvent to convert. Must not be null, and must be a valid CloudEvent.</param>
161+
/// <param name="contentMode">Content mode. Structured or binary.</param>
162+
/// <param name="formatter">The formatter to use within the conversion. Must not be null.</param>
163+
public static Message ToAmqpMessage(this CloudEvent cloudEvent, ContentMode contentMode, CloudEventFormatter formatter) =>
164+
ToAmqpMessage(cloudEvent, contentMode, formatter, AmqpHeaderColonPrefix);
165+
166+
/// <summary>
167+
/// Converts a CloudEvent to <see cref="Message"/> using a property prefix of "cloudEvents_". This prefix was introduced as the preferred
168+
/// prefix for the AMQP binding in August 2022.
146169
/// </summary>
147170
/// <param name="cloudEvent">The CloudEvent to convert. Must not be null, and must be a valid CloudEvent.</param>
148171
/// <param name="contentMode">Content mode. Structured or binary.</param>
149172
/// <param name="formatter">The formatter to use within the conversion. Must not be null.</param>
150-
public static Message ToAmqpMessage(this CloudEvent cloudEvent, ContentMode contentMode, CloudEventFormatter formatter)
173+
public static Message ToAmqpMessageWithUnderscorePrefix(this CloudEvent cloudEvent, ContentMode contentMode, CloudEventFormatter formatter) =>
174+
ToAmqpMessage(cloudEvent, contentMode, formatter, AmqpHeaderUnderscorePrefix);
175+
176+
/// <summary>
177+
/// Converts a CloudEvent to <see cref="Message"/> using a property prefix of "cloudEvents:". This prefix
178+
/// is a legacy retained only for compatibility purposes; it can't be used by JMS due to constraints in JMS property names.
179+
/// </summary>
180+
/// <param name="cloudEvent">The CloudEvent to convert. Must not be null, and must be a valid CloudEvent.</param>
181+
/// <param name="contentMode">Content mode. Structured or binary.</param>
182+
/// <param name="formatter">The formatter to use within the conversion. Must not be null.</param>
183+
public static Message ToAmqpMessageWithColonPrefix(this CloudEvent cloudEvent, ContentMode contentMode, CloudEventFormatter formatter) =>
184+
ToAmqpMessage(cloudEvent, contentMode, formatter, AmqpHeaderColonPrefix);
185+
186+
private static Message ToAmqpMessage(CloudEvent cloudEvent, ContentMode contentMode, CloudEventFormatter formatter, string prefix)
151187
{
152188
Validation.CheckCloudEventArgument(cloudEvent, nameof(cloudEvent));
153189
Validation.CheckNotNull(formatter, nameof(formatter));
154190

155-
var applicationProperties = MapHeaders(cloudEvent);
191+
var applicationProperties = MapHeaders(cloudEvent, prefix);
156192
RestrictedDescribed bodySection;
157193
Properties properties;
158194

@@ -181,11 +217,11 @@ public static Message ToAmqpMessage(this CloudEvent cloudEvent, ContentMode cont
181217
};
182218
}
183219

184-
private static ApplicationProperties MapHeaders(CloudEvent cloudEvent)
220+
private static ApplicationProperties MapHeaders(CloudEvent cloudEvent, string prefix)
185221
{
186222
var applicationProperties = new ApplicationProperties();
187223
var properties = applicationProperties.Map;
188-
properties.Add(SpecVersionAmqpHeader, cloudEvent.SpecVersion.VersionId);
224+
properties.Add(prefix + SpecVersionAttributeName, cloudEvent.SpecVersion.VersionId);
189225

190226
foreach (var pair in cloudEvent.GetPopulatedAttributes())
191227
{
@@ -197,7 +233,7 @@ private static ApplicationProperties MapHeaders(CloudEvent cloudEvent)
197233
continue;
198234
}
199235

200-
string propKey = AmqpHeaderPrefix + attribute.Name;
236+
string propKey = prefix + attribute.Name;
201237

202238
// TODO: Check that AMQP can handle byte[], bool and int values
203239
object propValue = pair.Value switch

test/CloudNative.CloudEvents.UnitTests/Amqp/AmqpTest.cs

Lines changed: 58 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
using Amqp;
66
using Amqp.Framing;
77
using CloudNative.CloudEvents.NewtonsoftJson;
8-
using Newtonsoft.Json.Linq;
98
using System;
109
using System.Net.Mime;
1110
using System.Text;
@@ -20,71 +19,26 @@ public class AmqpTest
2019
public void AmqpStructuredMessageTest()
2120
{
2221
// The AMQPNetLite library is factored such that we don't need to do a wire test here.
23-
var cloudEvent = new CloudEvent
24-
{
25-
Type = "com.github.pull.create",
26-
Source = new Uri("https://github.com/cloudevents/spec/pull"),
27-
Subject = "123",
28-
Id = "A234-1234-1234",
29-
Time = new DateTimeOffset(2018, 4, 5, 17, 31, 0, TimeSpan.Zero),
30-
DataContentType = MediaTypeNames.Text.Xml,
31-
Data = "<much wow=\"xml\"/>",
32-
["comexampleextension1"] = "value"
33-
};
34-
22+
var cloudEvent = CreateSampleCloudEvent();
3523
var message = cloudEvent.ToAmqpMessage(ContentMode.Structured, new JsonEventFormatter());
3624
Assert.True(message.IsCloudEvent());
37-
var encodedAmqpMessage = message.Encode();
38-
39-
var message1 = Message.Decode(encodedAmqpMessage);
40-
Assert.True(message1.IsCloudEvent());
41-
var receivedCloudEvent = message1.ToCloudEvent(new JsonEventFormatter());
42-
43-
Assert.Equal(CloudEventsSpecVersion.Default, receivedCloudEvent.SpecVersion);
44-
Assert.Equal("com.github.pull.create", receivedCloudEvent.Type);
45-
Assert.Equal(new Uri("https://github.com/cloudevents/spec/pull"), receivedCloudEvent.Source);
46-
Assert.Equal("123", receivedCloudEvent.Subject);
47-
Assert.Equal("A234-1234-1234", receivedCloudEvent.Id);
48-
AssertTimestampsEqual("2018-04-05T17:31:00Z", receivedCloudEvent.Time!.Value);
49-
Assert.Equal(MediaTypeNames.Text.Xml, receivedCloudEvent.DataContentType);
50-
Assert.Equal("<much wow=\"xml\"/>", receivedCloudEvent.Data);
51-
52-
Assert.Equal("value", (string?)receivedCloudEvent["comexampleextension1"]);
25+
AssertDecodeThenEqual(cloudEvent, message);
5326
}
5427

5528
[Fact]
5629
public void AmqpBinaryMessageTest()
5730
{
5831
// The AMQPNetLite library is factored such that we don't need to do a wire test here.
59-
var cloudEvent = new CloudEvent
60-
{
61-
Type = "com.github.pull.create",
62-
Source = new Uri("https://github.com/cloudevents/spec/pull/123"),
63-
Subject = "123",
64-
Id = "A234-1234-1234",
65-
Time = new DateTimeOffset(2018, 4, 5, 17, 31, 0, TimeSpan.Zero),
66-
DataContentType = MediaTypeNames.Text.Xml,
67-
Data = "<much wow=\"xml\"/>",
68-
["comexampleextension1"] = "value"
69-
};
70-
71-
var message = cloudEvent.ToAmqpMessage(ContentMode.Binary, new JsonEventFormatter());
32+
var cloudEvent = CreateSampleCloudEvent();
33+
var message = cloudEvent.ToAmqpMessage(ContentMode.Binary, new JsonEventFormatter());
7234
Assert.True(message.IsCloudEvent());
7335
var encodedAmqpMessage = message.Encode();
7436

7537
var message1 = Message.Decode(encodedAmqpMessage);
7638
Assert.True(message1.IsCloudEvent());
7739
var receivedCloudEvent = message1.ToCloudEvent(new JsonEventFormatter());
7840

79-
Assert.Equal(CloudEventsSpecVersion.Default, receivedCloudEvent.SpecVersion);
80-
Assert.Equal("com.github.pull.create", receivedCloudEvent.Type);
81-
Assert.Equal(new Uri("https://github.com/cloudevents/spec/pull/123"), receivedCloudEvent.Source);
82-
Assert.Equal("A234-1234-1234", receivedCloudEvent.Id);
83-
AssertTimestampsEqual("2018-04-05T17:31:00Z", receivedCloudEvent.Time!.Value);
84-
Assert.Equal(MediaTypeNames.Text.Xml, receivedCloudEvent.DataContentType);
85-
Assert.Equal("<much wow=\"xml\"/>", receivedCloudEvent.Data);
86-
87-
Assert.Equal("value", (string?)receivedCloudEvent["comexampleextension1"]);
41+
AssertCloudEventsEqual(cloudEvent, receivedCloudEvent);
8842
}
8943

9044
[Fact]
@@ -108,9 +62,7 @@ public void AmqpNormalizesTimestampsToUtc()
10862
Source = new Uri("https://github.com/cloudevents/spec/pull/123"),
10963
Id = "A234-1234-1234",
11064
// 2018-04-05T18:31:00+01:00 => 2018-04-05T17:31:00Z
111-
Time = new DateTimeOffset(2018, 4, 5, 18, 31, 0, TimeSpan.FromHours(1)),
112-
DataContentType = MediaTypeNames.Text.Xml,
113-
Data = "<much wow=\"xml\"/>"
65+
Time = new DateTimeOffset(2018, 4, 5, 18, 31, 0, TimeSpan.FromHours(1))
11466
};
11567

11668
var message = cloudEvent.ToAmqpMessage(ContentMode.Binary, new JsonEventFormatter());
@@ -134,5 +86,57 @@ public void EncodeTextDataInBinaryMode_PopulatesDataProperty()
13486
var text = Encoding.UTF8.GetString(body.Binary);
13587
Assert.Equal("some text", text);
13688
}
89+
90+
[Fact]
91+
public void DefaultPrefix()
92+
{
93+
var cloudEvent = CreateSampleCloudEvent();
94+
95+
var message = cloudEvent.ToAmqpMessage(ContentMode.Binary, new JsonEventFormatter());
96+
Assert.Equal(cloudEvent.Id, message.ApplicationProperties["cloudEvents:id"]);
97+
AssertDecodeThenEqual(cloudEvent, message);
98+
}
99+
100+
[Fact]
101+
public void UnderscorePrefix()
102+
{
103+
var cloudEvent = CreateSampleCloudEvent();
104+
var message = cloudEvent.ToAmqpMessageWithUnderscorePrefix(ContentMode.Binary, new JsonEventFormatter());
105+
Assert.Equal(cloudEvent.Id, message.ApplicationProperties["cloudEvents_id"]);
106+
AssertDecodeThenEqual(cloudEvent, message);
107+
}
108+
109+
[Fact]
110+
public void ColonPrefix()
111+
{
112+
var cloudEvent = CreateSampleCloudEvent();
113+
var message = cloudEvent.ToAmqpMessageWithColonPrefix(ContentMode.Binary, new JsonEventFormatter());
114+
Assert.Equal(cloudEvent.Id, message.ApplicationProperties["cloudEvents:id"]);
115+
AssertDecodeThenEqual(cloudEvent, message);
116+
}
117+
118+
private void AssertDecodeThenEqual(CloudEvent cloudEvent, Message message)
119+
{
120+
var encodedAmqpMessage = message.Encode();
121+
122+
var message1 = Message.Decode(encodedAmqpMessage);
123+
var receivedCloudEvent = message1.ToCloudEvent(new JsonEventFormatter());
124+
AssertCloudEventsEqual(cloudEvent, receivedCloudEvent);
125+
}
126+
127+
/// <summary>
128+
/// Returns a CloudEvent with XML data and an extension.
129+
/// </summary>
130+
private static CloudEvent CreateSampleCloudEvent() => new CloudEvent
131+
{
132+
Type = "com.github.pull.create",
133+
Source = new Uri("https://github.com/cloudevents/spec/pull"),
134+
Subject = "123",
135+
Id = "A234-1234-1234",
136+
Time = new DateTimeOffset(2018, 4, 5, 17, 31, 0, TimeSpan.Zero),
137+
DataContentType = MediaTypeNames.Text.Xml,
138+
Data = "<much wow=\"xml\"/>",
139+
["comexampleextension1"] = "value"
140+
};
137141
}
138142
}

0 commit comments

Comments
 (0)