diff --git a/src/CloudNative.CloudEvents.Amqp/AmqpExtensions.cs b/src/CloudNative.CloudEvents.Amqp/AmqpExtensions.cs index 2e8ebdd..001b832 100644 --- a/src/CloudNative.CloudEvents.Amqp/AmqpExtensions.cs +++ b/src/CloudNative.CloudEvents.Amqp/AmqpExtensions.cs @@ -18,9 +18,14 @@ namespace CloudNative.CloudEvents.Amqp /// public static class AmqpExtensions { - internal const string AmqpHeaderPrefix = "cloudEvents:"; + // This is internal in CloudEventsSpecVersion. + private const string SpecVersionAttributeName = "specversion"; - internal const string SpecVersionAmqpHeader = AmqpHeaderPrefix + "specversion"; + internal const string AmqpHeaderUnderscorePrefix = "cloudEvents_"; + internal const string AmqpHeaderColonPrefix = "cloudEvents:"; + + internal const string SpecVersionAmqpHeaderWithUnderscore = AmqpHeaderUnderscorePrefix + SpecVersionAttributeName; + internal const string SpecVersionAmqpHeaderWithColon = AmqpHeaderColonPrefix + SpecVersionAttributeName; /// /// Indicates whether this holds a single CloudEvent. @@ -32,7 +37,8 @@ public static class AmqpExtensions /// true, if the request is a CloudEvent public static bool IsCloudEvent(this Message message) => HasCloudEventsContentType(Validation.CheckNotNull(message, nameof(message)), out _) || - message.ApplicationProperties.Map.ContainsKey(SpecVersionAmqpHeader); + message.ApplicationProperties.Map.ContainsKey(SpecVersionAmqpHeaderWithUnderscore) || + message.ApplicationProperties.Map.ContainsKey(SpecVersionAmqpHeaderWithColon); /// /// Converts this AMQP message into a CloudEvent object. @@ -69,7 +75,8 @@ public static CloudEvent ToCloudEvent( else { var propertyMap = message.ApplicationProperties.Map; - if (!propertyMap.TryGetValue(SpecVersionAmqpHeader, out var versionId)) + if (!propertyMap.TryGetValue(SpecVersionAmqpHeaderWithUnderscore, out var versionId) && + !propertyMap.TryGetValue(SpecVersionAmqpHeaderWithColon, out versionId)) { throw new ArgumentException("Request is not a CloudEvent"); } @@ -84,11 +91,14 @@ public static CloudEvent ToCloudEvent( foreach (var property in propertyMap) { - if (!(property.Key is string key && key.StartsWith(AmqpHeaderPrefix))) + if (!(property.Key is string key && + (key.StartsWith(AmqpHeaderColonPrefix) || key.StartsWith(AmqpHeaderUnderscorePrefix)))) { continue; } - string attributeName = key.Substring(AmqpHeaderPrefix.Length).ToLowerInvariant(); + // Note: both prefixes have the same length. If we ever need any prefixes with a different length, we'll need to know which + // prefix we're looking at. + string attributeName = key.Substring(AmqpHeaderUnderscorePrefix.Length).ToLowerInvariant(); // We've already dealt with the spec version. if (attributeName == CloudEventsSpecVersion.SpecVersionAttribute.Name) @@ -142,17 +152,43 @@ private static bool HasCloudEventsContentType(Message message, out string? conte } /// - /// Converts a CloudEvent to . + /// Converts a CloudEvent to using the default property prefix. Versions released prior to March 2023 + /// use a default property prefix of "cloudEvents:". Versions released from March 2023 onwards use a property prefix of "cloudEvents_". + /// Code wishing to express the prefix explicitly should use or + /// . + /// + /// The CloudEvent to convert. Must not be null, and must be a valid CloudEvent. + /// Content mode. Structured or binary. + /// The formatter to use within the conversion. Must not be null. + public static Message ToAmqpMessage(this CloudEvent cloudEvent, ContentMode contentMode, CloudEventFormatter formatter) => + ToAmqpMessage(cloudEvent, contentMode, formatter, AmqpHeaderColonPrefix); + + /// + /// Converts a CloudEvent to using a property prefix of "cloudEvents_". This prefix was introduced as the preferred + /// prefix for the AMQP binding in August 2022. /// /// The CloudEvent to convert. Must not be null, and must be a valid CloudEvent. /// Content mode. Structured or binary. /// The formatter to use within the conversion. Must not be null. - public static Message ToAmqpMessage(this CloudEvent cloudEvent, ContentMode contentMode, CloudEventFormatter formatter) + public static Message ToAmqpMessageWithUnderscorePrefix(this CloudEvent cloudEvent, ContentMode contentMode, CloudEventFormatter formatter) => + ToAmqpMessage(cloudEvent, contentMode, formatter, AmqpHeaderUnderscorePrefix); + + /// + /// Converts a CloudEvent to using a property prefix of "cloudEvents:". This prefix + /// is a legacy retained only for compatibility purposes; it can't be used by JMS due to constraints in JMS property names. + /// + /// The CloudEvent to convert. Must not be null, and must be a valid CloudEvent. + /// Content mode. Structured or binary. + /// The formatter to use within the conversion. Must not be null. + public static Message ToAmqpMessageWithColonPrefix(this CloudEvent cloudEvent, ContentMode contentMode, CloudEventFormatter formatter) => + ToAmqpMessage(cloudEvent, contentMode, formatter, AmqpHeaderColonPrefix); + + private static Message ToAmqpMessage(CloudEvent cloudEvent, ContentMode contentMode, CloudEventFormatter formatter, string prefix) { Validation.CheckCloudEventArgument(cloudEvent, nameof(cloudEvent)); Validation.CheckNotNull(formatter, nameof(formatter)); - var applicationProperties = MapHeaders(cloudEvent); + var applicationProperties = MapHeaders(cloudEvent, prefix); RestrictedDescribed bodySection; Properties properties; @@ -181,11 +217,11 @@ public static Message ToAmqpMessage(this CloudEvent cloudEvent, ContentMode cont }; } - private static ApplicationProperties MapHeaders(CloudEvent cloudEvent) + private static ApplicationProperties MapHeaders(CloudEvent cloudEvent, string prefix) { var applicationProperties = new ApplicationProperties(); var properties = applicationProperties.Map; - properties.Add(SpecVersionAmqpHeader, cloudEvent.SpecVersion.VersionId); + properties.Add(prefix + SpecVersionAttributeName, cloudEvent.SpecVersion.VersionId); foreach (var pair in cloudEvent.GetPopulatedAttributes()) { @@ -197,7 +233,7 @@ private static ApplicationProperties MapHeaders(CloudEvent cloudEvent) continue; } - string propKey = AmqpHeaderPrefix + attribute.Name; + string propKey = prefix + attribute.Name; // TODO: Check that AMQP can handle byte[], bool and int values object propValue = pair.Value switch diff --git a/test/CloudNative.CloudEvents.UnitTests/Amqp/AmqpTest.cs b/test/CloudNative.CloudEvents.UnitTests/Amqp/AmqpTest.cs index db14bd4..c944b2f 100644 --- a/test/CloudNative.CloudEvents.UnitTests/Amqp/AmqpTest.cs +++ b/test/CloudNative.CloudEvents.UnitTests/Amqp/AmqpTest.cs @@ -5,7 +5,6 @@ using Amqp; using Amqp.Framing; using CloudNative.CloudEvents.NewtonsoftJson; -using Newtonsoft.Json.Linq; using System; using System.Net.Mime; using System.Text; @@ -20,55 +19,18 @@ public class AmqpTest public void AmqpStructuredMessageTest() { // The AMQPNetLite library is factored such that we don't need to do a wire test here. - var cloudEvent = new CloudEvent - { - Type = "com.github.pull.create", - Source = new Uri("https://github.com/cloudevents/spec/pull"), - Subject = "123", - Id = "A234-1234-1234", - Time = new DateTimeOffset(2018, 4, 5, 17, 31, 0, TimeSpan.Zero), - DataContentType = MediaTypeNames.Text.Xml, - Data = "", - ["comexampleextension1"] = "value" - }; - + var cloudEvent = CreateSampleCloudEvent(); var message = cloudEvent.ToAmqpMessage(ContentMode.Structured, new JsonEventFormatter()); Assert.True(message.IsCloudEvent()); - var encodedAmqpMessage = message.Encode(); - - var message1 = Message.Decode(encodedAmqpMessage); - Assert.True(message1.IsCloudEvent()); - var receivedCloudEvent = message1.ToCloudEvent(new JsonEventFormatter()); - - Assert.Equal(CloudEventsSpecVersion.Default, receivedCloudEvent.SpecVersion); - Assert.Equal("com.github.pull.create", receivedCloudEvent.Type); - Assert.Equal(new Uri("https://github.com/cloudevents/spec/pull"), receivedCloudEvent.Source); - Assert.Equal("123", receivedCloudEvent.Subject); - Assert.Equal("A234-1234-1234", receivedCloudEvent.Id); - AssertTimestampsEqual("2018-04-05T17:31:00Z", receivedCloudEvent.Time!.Value); - Assert.Equal(MediaTypeNames.Text.Xml, receivedCloudEvent.DataContentType); - Assert.Equal("", receivedCloudEvent.Data); - - Assert.Equal("value", (string?)receivedCloudEvent["comexampleextension1"]); + AssertDecodeThenEqual(cloudEvent, message); } [Fact] public void AmqpBinaryMessageTest() { // The AMQPNetLite library is factored such that we don't need to do a wire test here. - var cloudEvent = new CloudEvent - { - Type = "com.github.pull.create", - Source = new Uri("https://github.com/cloudevents/spec/pull/123"), - Subject = "123", - Id = "A234-1234-1234", - Time = new DateTimeOffset(2018, 4, 5, 17, 31, 0, TimeSpan.Zero), - DataContentType = MediaTypeNames.Text.Xml, - Data = "", - ["comexampleextension1"] = "value" - }; - - var message = cloudEvent.ToAmqpMessage(ContentMode.Binary, new JsonEventFormatter()); + var cloudEvent = CreateSampleCloudEvent(); + var message = cloudEvent.ToAmqpMessage(ContentMode.Binary, new JsonEventFormatter()); Assert.True(message.IsCloudEvent()); var encodedAmqpMessage = message.Encode(); @@ -76,15 +38,7 @@ public void AmqpBinaryMessageTest() Assert.True(message1.IsCloudEvent()); var receivedCloudEvent = message1.ToCloudEvent(new JsonEventFormatter()); - Assert.Equal(CloudEventsSpecVersion.Default, receivedCloudEvent.SpecVersion); - Assert.Equal("com.github.pull.create", receivedCloudEvent.Type); - Assert.Equal(new Uri("https://github.com/cloudevents/spec/pull/123"), receivedCloudEvent.Source); - Assert.Equal("A234-1234-1234", receivedCloudEvent.Id); - AssertTimestampsEqual("2018-04-05T17:31:00Z", receivedCloudEvent.Time!.Value); - Assert.Equal(MediaTypeNames.Text.Xml, receivedCloudEvent.DataContentType); - Assert.Equal("", receivedCloudEvent.Data); - - Assert.Equal("value", (string?)receivedCloudEvent["comexampleextension1"]); + AssertCloudEventsEqual(cloudEvent, receivedCloudEvent); } [Fact] @@ -108,9 +62,7 @@ public void AmqpNormalizesTimestampsToUtc() Source = new Uri("https://github.com/cloudevents/spec/pull/123"), Id = "A234-1234-1234", // 2018-04-05T18:31:00+01:00 => 2018-04-05T17:31:00Z - Time = new DateTimeOffset(2018, 4, 5, 18, 31, 0, TimeSpan.FromHours(1)), - DataContentType = MediaTypeNames.Text.Xml, - Data = "" + Time = new DateTimeOffset(2018, 4, 5, 18, 31, 0, TimeSpan.FromHours(1)) }; var message = cloudEvent.ToAmqpMessage(ContentMode.Binary, new JsonEventFormatter()); @@ -134,5 +86,57 @@ public void EncodeTextDataInBinaryMode_PopulatesDataProperty() var text = Encoding.UTF8.GetString(body.Binary); Assert.Equal("some text", text); } + + [Fact] + public void DefaultPrefix() + { + var cloudEvent = CreateSampleCloudEvent(); + + var message = cloudEvent.ToAmqpMessage(ContentMode.Binary, new JsonEventFormatter()); + Assert.Equal(cloudEvent.Id, message.ApplicationProperties["cloudEvents:id"]); + AssertDecodeThenEqual(cloudEvent, message); + } + + [Fact] + public void UnderscorePrefix() + { + var cloudEvent = CreateSampleCloudEvent(); + var message = cloudEvent.ToAmqpMessageWithUnderscorePrefix(ContentMode.Binary, new JsonEventFormatter()); + Assert.Equal(cloudEvent.Id, message.ApplicationProperties["cloudEvents_id"]); + AssertDecodeThenEqual(cloudEvent, message); + } + + [Fact] + public void ColonPrefix() + { + var cloudEvent = CreateSampleCloudEvent(); + var message = cloudEvent.ToAmqpMessageWithColonPrefix(ContentMode.Binary, new JsonEventFormatter()); + Assert.Equal(cloudEvent.Id, message.ApplicationProperties["cloudEvents:id"]); + AssertDecodeThenEqual(cloudEvent, message); + } + + private void AssertDecodeThenEqual(CloudEvent cloudEvent, Message message) + { + var encodedAmqpMessage = message.Encode(); + + var message1 = Message.Decode(encodedAmqpMessage); + var receivedCloudEvent = message1.ToCloudEvent(new JsonEventFormatter()); + AssertCloudEventsEqual(cloudEvent, receivedCloudEvent); + } + + /// + /// Returns a CloudEvent with XML data and an extension. + /// + private static CloudEvent CreateSampleCloudEvent() => new CloudEvent + { + Type = "com.github.pull.create", + Source = new Uri("https://github.com/cloudevents/spec/pull"), + Subject = "123", + Id = "A234-1234-1234", + Time = new DateTimeOffset(2018, 4, 5, 17, 31, 0, TimeSpan.Zero), + DataContentType = MediaTypeNames.Text.Xml, + Data = "", + ["comexampleextension1"] = "value" + }; } } \ No newline at end of file