Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -589,6 +589,51 @@ await channel.BasicPublishAsync(
);
```

### Extension Attribute Encoding

CloudEvents extension attributes use the JSON Event Format context-attribute mapping, not each
`MetadataKind`'s natural JSON shape:

| Metadata value | Extension-attribute JSON |
|---|---|
| `Null` | Omitted (unset) |
| `Boolean` | JSON boolean |
| `Int64` from `-2147483648` through `2147483647` | JSON number |
| Every other non-null primitive, including larger `Int64`, `Double`, `Single`, and `Decimal` | JSON string containing canonical invariant text |
| `Array` or `Object` | Rejected |

CloudEvents `String` values are validated without normalization: controls, Unicode noncharacters, and
unpaired surrogates are rejected. This mapping affects only extension attributes; metadata inside `data`
and `problem+json` keeps its normal JSON representation.

The `Int64` rule is value-dependent. For example, one extension name can be emitted as `2147483647` in
one event and as `"2147483648"` in another. This is a deliberate deviation from CloudEvents' stable-type
recommendation so all in-range CloudEvents integers retain their natural JSON representation while the
full `long` domain remains lossless. If a key requires a stable string shape, convert it to
`MetadataKind.String` with a `CloudEventsAttributeConverter`:

```csharp
public sealed class StableSequenceConverter : CloudEventsAttributeConverter
{
public StableSequenceConverter() : base(["sequence"]) { }

public override KeyValuePair<string, MetadataValue> PrepareCloudEventsAttribute(
string metadataKey,
MetadataValue value
) =>
new (
metadataKey,
MetadataValue.FromString(value.ToCanonicalString(), value.Annotation)
);
}
```

On read-back, a JSON boolean becomes `MetadataKind.Boolean`, an integer-number becomes
`MetadataKind.Int64`, and every string-mapped value initially becomes `MetadataKind.String`. Canonical
out-of-range integer text remains accessible through `MetadataValue.TryGetInt64`. Register a
`CloudEventsAttributeParser` when a particular attribute must be restored to another original kind.
Inbound `null` means unset and is not added to extension metadata.

### Consume from RabbitMQ

```csharp
Expand Down
113 changes: 113 additions & 0 deletions ai-plans/0053-cloud-events-extension-attribute-types.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
using System;
using Light.PortableResults.Metadata;

namespace Light.PortableResults.CloudEvents;

/// <summary>
/// Identifies the JSON Event Format encoding used for a CloudEvents extension attribute value.
/// </summary>
/// <remarks>
/// <para>
/// These values describe JSON encodings, not the seven abstract CloudEvents context-attribute types.
/// <see cref="Null" /> represents an unset attribute and is omitted from the JSON object.
/// </para>
/// <para>
/// An <see cref="MetadataKind.Int64" /> uses <see cref="Integer" /> only while its value is in the
/// inclusive 32-bit signed range; values outside that range use <see cref="String" />. Publishers that
/// require a stable string shape for one attribute name can use a
/// <see cref="Writing.CloudEventsAttributeConverter" /> to convert its value to
/// <see cref="MetadataKind.String" /> before writing.
/// </para>
/// </remarks>
public enum CloudEventsAttributeJsonEncoding
{
/// <summary>The attribute is unset and is omitted.</summary>
Null,

/// <summary>The value is written as a JSON Boolean.</summary>
Boolean,

/// <summary>The value is written as a JSON integer number.</summary>
Integer,

/// <summary>The value is written as a JSON string containing its canonical invariant text.</summary>
String
}

#pragma warning disable CS8524 // Unnamed enum values intentionally throw SwitchExpressionException.

/// <summary>
/// Provides CloudEvents JSON Event Format encoding classification for metadata values.
/// </summary>
public static class CloudEventsAttributeJsonEncodingExtensions
{
/// <summary>
/// Gets the JSON Event Format encoding for a CloudEvents extension attribute value.
/// </summary>
/// <param name="value">The metadata value to classify.</param>
/// <returns>The CloudEvents attribute JSON encoding.</returns>
/// <remarks>
/// <para>
/// Boolean values use JSON booleans, signed integers in the inclusive 32-bit range use JSON numbers,
/// and all other conforming non-null primitive values use JSON strings containing their canonical
/// invariant text. Null represents an unset attribute and is omitted.
/// </para>
/// <para>
/// The mapping is intentionally different from each metadata kind's natural JSON shape. On read-back,
/// every string-mapped value is therefore initially represented as <see cref="MetadataKind.String" />.
/// Register a <see cref="Reading.CloudEventsAttributeParser" /> when an extension attribute must be
/// restored to a specific metadata kind.
/// </para>
/// </remarks>
/// <exception cref="InvalidOperationException">
/// Thrown when <paramref name="value" /> contains an array or object.
/// </exception>
public static CloudEventsAttributeJsonEncoding GetCloudEventsAttributeJsonEncoding(this MetadataValue value) =>
value.Kind switch
{
MetadataKind.Null => CloudEventsAttributeJsonEncoding.Null,
MetadataKind.Boolean => CloudEventsAttributeJsonEncoding.Boolean,
MetadataKind.Int64 => GetInt64Encoding(value),
MetadataKind.Double => CloudEventsAttributeJsonEncoding.String,
MetadataKind.String => CloudEventsAttributeJsonEncoding.String,
MetadataKind.Decimal => CloudEventsAttributeJsonEncoding.String,
MetadataKind.UInt64 => CloudEventsAttributeJsonEncoding.String,
MetadataKind.Single => CloudEventsAttributeJsonEncoding.String,
MetadataKind.Char => CloudEventsAttributeJsonEncoding.String,
MetadataKind.DateTime => CloudEventsAttributeJsonEncoding.String,
MetadataKind.DateTimeOffset => CloudEventsAttributeJsonEncoding.String,
MetadataKind.DateOnly => CloudEventsAttributeJsonEncoding.String,
MetadataKind.TimeOnly => CloudEventsAttributeJsonEncoding.String,
MetadataKind.TimeSpan => CloudEventsAttributeJsonEncoding.String,
MetadataKind.Guid => CloudEventsAttributeJsonEncoding.String,
MetadataKind.Uri => CloudEventsAttributeJsonEncoding.String,
MetadataKind.Array => ThrowComplexValue(MetadataKind.Array),
MetadataKind.Object => ThrowComplexValue(MetadataKind.Object)
};

private static CloudEventsAttributeJsonEncoding GetInt64Encoding(MetadataValue value)
{
value.TryGetInt64(out var int64Value);
return int64Value is >= int.MinValue and <= int.MaxValue ?
CloudEventsAttributeJsonEncoding.Integer :
CloudEventsAttributeJsonEncoding.String;
}

private static CloudEventsAttributeJsonEncoding ThrowComplexValue(MetadataKind kind) =>
throw new InvalidOperationException(
$"CloudEvents extension attributes cannot encode metadata kind '{kind}'."
);
}

#pragma warning restore CS8524
43 changes: 43 additions & 0 deletions src/Light.PortableResults/CloudEvents/CloudEventsAttributeName.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System;

namespace Light.PortableResults.CloudEvents;

/// <summary>
/// Provides low-level syntax validation for CloudEvents extension attribute names.
/// </summary>
public static class CloudEventsAttributeName
{
/// <summary>
/// Determines whether an attribute name contains only lowercase ASCII letters and decimal digits.
/// </summary>
/// <param name="attributeName">The attribute name whose character syntax is checked.</param>
/// <returns>
/// <see langword="true" /> when every character is a lowercase ASCII letter or decimal digit;
/// otherwise, <see langword="false" />.
/// </returns>
/// <remarks>
/// This method validates only the character syntax. An empty name vacuously satisfies this predicate,
/// and standard or reserved CloudEvents names can also satisfy it. Callers that write complete extension
/// attributes must enforce those additional constraints; <c>WriteCloudEventsExtensionAttribute</c> does so.
/// </remarks>
/// <exception cref="ArgumentNullException">
/// Thrown when <paramref name="attributeName" /> is <see langword="null" />.
/// </exception>
public static bool IsValidExtensionAttributeName(string attributeName)
{
if (attributeName is null)
{
throw new ArgumentNullException(nameof(attributeName));
}

foreach (var character in attributeName)
{
if (character is (< 'a' or > 'z') and (< '0' or > '9'))
{
return false;
}
}

return true;
}
}
77 changes: 77 additions & 0 deletions src/Light.PortableResults/CloudEvents/CloudEventsAttributeText.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
using System;

namespace Light.PortableResults.CloudEvents;

/// <summary>
/// Provides validation for the CloudEvents <c>String</c> context-attribute type.
/// </summary>
public static class CloudEventsAttributeText
{
/// <summary>
/// Finds the first character that is not allowed by the CloudEvents <c>String</c> type.
/// </summary>
/// <param name="text">The text to inspect.</param>
/// <returns>
/// The UTF-16 index of the first disallowed Unicode code point, or <c>-1</c> when the text conforms.
/// </returns>
/// <remarks>
/// C0 and C1 control characters, Unicode noncharacters, and unpaired UTF-16 surrogates are disallowed.
/// A valid surrogate pair is treated as one Unicode scalar value. The JSON extension-attribute writer
/// always applies this rule. A custom conversion service can call it earlier when failure before
/// serialization is more important than avoiding the writer's second validation scan.
/// </remarks>
public static int IndexOfDisallowedCharacter(ReadOnlySpan<char> text)
{
for (var index = 0; index < text.Length; index++)
{
var character = text[index];

if (character <= '\u007E')
{
if (character < '\u0020')
{
return index;
}

continue;
}

if (character <= '\u009F')
{
return index;
}

if (character < '\uD800')
{
continue;
}

if (character > '\uDFFF')
{
if (character is >= '\uFDD0' and <= '\uFDEF' || character >= '\uFFFE')
{
return index;
}

continue;
}

if (character >= '\uDC00' ||
index + 1 >= text.Length ||
text[index + 1] is < '\uDC00' or > '\uDFFF')
{
return index;
}

var codePoint = 0x10000 + ((character - '\uD800') << 10) + text[index + 1] - '\uDC00';
if ((codePoint & 0xFFFF) >= 0xFFFE)
{
return index;
}

index++;
}

return -1;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,11 @@ out var parsed
throw new JsonException("Unexpected end of JSON while reading extension attribute value.");
}

var extensionValue = ReadExtensionAttributeValue(ref reader);
extensionBuilder.AddOrReplace(extensionAttributeName, extensionValue);
if (reader.TokenType != JsonTokenType.Null)
{
var extensionValue = ReadExtensionAttributeValue(ref reader);
extensionBuilder.AddOrReplace(extensionAttributeName, extensionValue);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ public sealed record PortableResultsCloudEventsReadOptions
public Func<string, bool>? IsFailureType { get; init; }

/// <summary>
/// Gets or sets an optional parsing service used to convert extension attributes into metadata for tier-1 methods.
/// Gets or sets an optional parsing service used to convert extension attributes into metadata for tier-1
/// methods. JSON string attributes initially have <see cref="MetadataKind.String" /> even when the writer
/// mapped another primitive kind to canonical text; register an attribute parser to restore that kind.
/// </summary>
public ICloudEventsAttributeParsingService? ParsingService { get; init; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ FrozenDictionary<string, CloudEventsAttributeConverter> converters
/// <param name="metadataValue">The metadata value to convert.</param>
/// <returns>The CloudEvents attribute key and value pair.</returns>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="metadataKey" /> is <see langword="null" />.</exception>
/// <exception cref="ArgumentException">Thrown when the resulting attribute name is invalid, reserved, or the value is not a primitive type for extension attributes.</exception>
/// <exception cref="ArgumentException">
/// Thrown when the resulting attribute name is invalid, reserved, or the value is not a primitive type for extension
/// attributes.
/// </exception>
public KeyValuePair<string, MetadataValue> PrepareCloudEventsAttribute(
string metadataKey,
MetadataValue metadataValue
Expand Down Expand Up @@ -87,7 +90,7 @@ private static void ValidateAttributeName(string attributeName)
return;
}

if (!IsValidExtensionAttributeName(attributeName))
if (!CloudEventsAttributeName.IsValidExtensionAttributeName(attributeName))
{
throw new ArgumentException(
$"The CloudEvents extension attribute '{attributeName}' is invalid. Only lowercase alphanumeric names are allowed.",
Expand All @@ -96,19 +99,6 @@ private static void ValidateAttributeName(string attributeName)
}
}

private static bool IsValidExtensionAttributeName(string attributeName)
{
foreach (var character in attributeName)
{
if (character is (< 'a' or > 'z') and (< '0' or > '9'))
{
return false;
}
}

return true;
}

private static void ValidateAttributeValue(string attributeName, MetadataValue value)
{
if (CloudEventsConstants.StandardAttributeNames.Contains(attributeName))
Expand Down
Loading
Loading