diff --git a/README.md b/README.md index d9600598..a49df6ec 100644 --- a/README.md +++ b/README.md @@ -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 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 diff --git a/ai-plans/0053-cloud-events-extension-attribute-types.md b/ai-plans/0053-cloud-events-extension-attribute-types.md new file mode 100644 index 00000000..851c9922 --- /dev/null +++ b/ai-plans/0053-cloud-events-extension-attribute-types.md @@ -0,0 +1,113 @@ +# CloudEvents Extension Attributes Follow the CloudEvents Type System + +## Rationale + +`JsonCloudEventsExtensions.WriteExtensionAttributes` currently uses `SharedJsonSerialization.Writing.MetadataExtensions.WriteMetadataValue`, which emits each `MetadataKind` in its natural JSON form. That is correct for event `data` and `problem+json`, but not for context attributes: CloudEvents core §2.4 limits them to `Boolean`, `Integer`, `String`, `Binary`, `URI`, `URI-reference`, and `Timestamp`; §2.3 applies the same types to extensions; and JSON Event Format §2.2 permits only boolean, integer-number, and string representations, with `null` meaning unset. `Double`, `Single`, `Decimal`, and out-of-range `Int64` therefore violate the format today. + +Extension attributes will instead use the CloudEvents mapping, falling back to `String` when a metadata value has no valid native attribute type and enforcing `String`'s character contract. Conforming non-null values retain their canonical text; null is deliberately lossy because the format requires readers to treat it as absent. + +## Acceptance Criteria + +- [x] The public encoding API and README document that extension attributes use the JSON Event Format mapping rather than each metadata kind's natural JSON shape. +- [x] `Boolean` writes a JSON boolean; an `Int64` in the inclusive int32 range writes a JSON number; every other conforming non-null primitive writes a JSON string containing its canonical invariant text. A test pinning the quoted, canonical decimal text replaces `ToCloudEvent_ShouldWriteDecimalExtensionAttribute_AsUnquotedNumber`, and a public-surface matrix covers every non-null primitive plus all four `Int64` boundaries. +- [x] A `Null` value omits the property and produces bytes identical to an envelope without that entry. An inbound null attribute is absent from extension metadata and cannot replace payload metadata with the same key. +- [x] One public, value-level API classifies the JSON encoding and one public writer owns the complete name/value operation. Its exhaustive `MetadataKind` switch makes a later named kind fail the Release build; complex values throw with the kind named. +- [x] The public writer rejects null, empty, whitespace, non-lowercase-alphanumeric, reserved, and standard attribute names before writing anything. Direct guard tests cover every category, including an in-range `Int64` named `type`. +- [x] CloudEvents-disallowed `String` text is rejected without normalization before a property name is written, with the attribute and offending code point identified. This holds for custom conversion services and directly constructed envelopes. The public character rule is tested with C0, C1, lone high and low surrogates, a noncharacter, an invalid `Char`, and an accepted surrogate pair plus non-ASCII text. +- [x] The default path scans only caller-controlled text — `String`, `Char`, and `Uri` — and performs at most one CloudEvents character-validation scan per conforming attribute. A custom service may deliberately preflight through the public rule, paying for a second scan to fail before serialization. +- [x] `MetadataValue.TryGetInt64` additionally accepts a `MetadataKind.String` holding a `long`'s canonical text, and only the canonical form: the parsed value must reproduce the source text, rejecting `"+5"`, `"-0"`, `"01234"`, `" 5"`, and out-of-range text. An out-of-int32 extension value reads back through this accessor unchanged. +- [x] The public API and README document the value-dependent `Int64` encoding, its stable-type deviation, and the `CloudEventsAttributeConverter` remedy that converts a key to `MetadataKind.String`. Tests pin both the shape change for one attribute name across two events and the stable converter. +- [x] Read-back tests cover every JSON encoding: string-mapped values return as `MetadataKind.String`, null is absent, and a registered `CloudEventsAttributeParser` restores an original kind. The encoding API documents this asymmetry like `MetadataJsonReader` documents numeric tokens. +- [x] Standard attributes resolved from metadata (`type`, `source`, `subject`, `dataschema`, `time`, `id`) retain their current string rendering. +- [x] Writing `Double` and `Single` extension attributes allocates nothing after warm-up. Other string-mapped kinds allocate no more than the one canonical string that `MetadataValue.TryFormatCanonical` materializes today. +- [x] The 0.7.0 `` records every behavior change with the correct scope and corrects the existing claim that decimals always serialize as JSON numbers. +- [x] Test code coverage remains above 95%. + +## Technical Details + +### Encoding policy + +| `MetadataKind` | Abstract CloudEvents type | JSON encoding | Change | +| --- | --- | --- | --- | +| `Null` | — (unset) | omitted | **new** | +| `Boolean` | `Boolean` | boolean | — | +| `Int64`, `-2147483648..2147483647` | `Integer` | number | — | +| `Int64`, outside that range | `String` | string | **new** | +| `Double`, `Single`, `Decimal` | `String` | string | **new** | +| `UInt64`, `String`, `Char`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`, `TimeSpan`, `Guid`, `Uri` | `String` | string | — | +| `Array`, `Object` | — | throws | **new** | + +String encodings use `MetadataValue.ToCanonicalString()`, matching HTTP headers and `MetadataValue.ToString()`. Only the JSON encoding is modeled: `Binary`, `URI`, `URI-reference`, and `Timestamp` share JSON's string representation, while the metadata value alone cannot always justify the narrower abstract type (`DateTimeKind.Unspecified` is not RFC 3339 and `Uri` may be relative). A future binary-mode writer would need the seven abstract types plus information unavailable here; it would not reuse this JSON enum. + +Mapping wins over documenting the deviation: portability across CloudEvents implementations is why the feature exists, and a receiver validating attribute types against the spec is entitled to reject a fractional number. Mapping is also preferred to an opt-out on `PortableResultsCloudEventsWriteOptions`: that would double the write matrix and preserve an invalid mode, while pre-1.0 callers needing a specific valid shape already have `CloudEventsAttributeConverter`. Out-of-range `Int64` values are string-mapped rather than rejected because common values such as snowflake IDs and nanosecond timestamps remain representable without loss. + +### Value-dependent `Int64` + +One key may appear as `2147483647` and later as `"2147483648"`, contrary to core §2.3's stable-type expectation. A stable `Integer` is impossible across the `long` domain. Always using `String` would be stable and, after adding canonical parsing to `TryGetInt64`, would preserve typed accessor use; however, every integer would become quoted, `MetadataKind.Int64` would never round-trip, and no kind would produce CloudEvents `Integer` until a narrower integer kind existed. Its strongest argument is the failure mode: always-`String` takes one uniform break, pre-1.0 and stated in release notes, in exchange for removing a latent, data-dependent one — a consumer that types the attribute as `Integer` works until a value crosses 2^31 and then fails in production. The chosen value-dependent rule preserves natural JSON and `MetadataKind.Int64` wherever CloudEvents permits it. A straddling key still changes raw JSON and `Kind`, and a foreign consumer declaring it as `Integer` can fail at the boundary; a consistently small or large key remains stable in practice. The library can guarantee valid emitted attributes, not compatibility with an extension type declaration it cannot see. + +`TryGetInt64` (`MetadataValue.cs:385`) therefore gains the same canonical-string path as `TryGetUInt64` (`MetadataValue.cs:475`), `TryGetSingle`, `TryGetChar`, and `TryGetDecimal`: parse with `CultureInfo.InvariantCulture` and `NumberStyles.AllowLeadingSign`, then require an exact formatting round trip. This makes the accessor work on both sides of the int32 threshold without becoming lenient. It changes every `MetadataValue` of kind `String`, not only CloudEvents values, and is release-noted accordingly. Existing production writing remains unaffected: outside `MetadataObject.TryGetInt64`, only `MetadataExtensions.WriteNumberValue` calls it, after `GetNumberEncoding() == Int64`; and `DefaultHttpHeaderParsingService` already sniffs integral header text into `MetadataValue.FromInt64`, so the header reader never depended on the string arm. + +`UInt64` stays uniformly `String`: its accessor already parses canonical text, and a JSON number would not restore `MetadataKind.UInt64`, so a magnitude-dependent form would buy no fidelity. Publishers needing a stable `String` for `Int64` can use `CloudEventsAttributeConverter` to convert the value before writing. + +### Deferred `Bytes` + +This supersedes `0055-0`'s sequencing note assigning `Bytes` to #53; completed plans remain unedited. A `Bytes` kind would still select JSON `String` and emit the same base64 text callers use today, so it changes neither this wire contract nor its four encodings. Its unresolved questions—structural versus reference equality for `byte[]`, O(n) hashing, and defensive copying—belong to the metadata model. Adding it also touches every exhaustive kind dispatch (`GetJsonShape`, `GetNumberEncoding`, canonical/debug formatting, equality/hash, HTTP, OpenAPI, validation messages, and this mapping), so it moves to its own issue. Deferral is additive and not wire-visible because enum values do not leave the process and base64 is allowable ASCII text. + +### Public API and write boundary + +```csharp +namespace Light.PortableResults.CloudEvents; + +public enum CloudEventsAttributeJsonEncoding { Null, Boolean, Integer, String } + +public static class CloudEventsAttributeJsonEncodingExtensions +{ + public static CloudEventsAttributeJsonEncoding GetCloudEventsAttributeJsonEncoding(this MetadataValue value); +} + +public static class CloudEventsAttributeText +{ + public static int IndexOfDisallowedCharacter(ReadOnlySpan text); +} +``` + +```csharp +namespace Light.PortableResults.CloudEvents.Writing.Json; + +public static class JsonCloudEventsExtensions +{ + public static void WriteCloudEventsExtensionAttribute( + this Utf8JsonWriter writer, + string attributeName, + MetadataValue value + ); +} +``` + +The enum names the four JSON encodings, not the seven abstract CloudEvents types; `Null` represents the format's unset encoding rather than an abstract type. It lives in the root CloudEvents namespace because the same classification can support read-side conformance. Classification is value-level because out-of-range `Int64` differs from in-range values. Its switch lists every `MetadataKind` without a default, following `MetadataKindExtensions`; suppressing only `CS8524` preserves `CS8509`, which `TreatWarningsAsErrors` turns into a Release error for a newly declared kind. `Array` and `Object` explicitly throw `InvalidOperationException`. + +The writer takes the name and value together so it can validate before `WritePropertyName`, omit null atomically, identify failures, and prevent partial properties. It rejects null/blank names, names outside lowercase ASCII letters and digits, `ForbiddenConvertedAttributeNames`, and every `CloudEventsConstants.StandardAttributeNames` member. The grammar rule exists today only as the private `DefaultCloudEventsAttributeConversionService.IsValidExtensionAttributeName` (`DefaultCloudEventsAttributeConversionService.cs:99`), so it moves to a shared location both the service and the writer call rather than being reimplemented. Standard names cannot use generic extension encoding—for example, an `Int64` `type` would become a number even though `type` is defined as `String`. `WriteExtensionAttributes` skips standard names already emitted through `ResolveAttributes`, then delegates every remaining pair to this method. Custom writers get the identical contract, including paths that bypass `DefaultCloudEventsAttributeConversionService` through a custom service or direct `CloudEventsEnvelopeForWriting` construction. Direct public guard tests are appropriate because these paths are otherwise unreachable through the default sociable surface. + +Complex values fail here even if conversion validation was bypassed, matching the existing `MetadataNumberEncoding.None` failure in `MetadataExtensions.WriteNumberValue`. The old `MetadataValueAnnotation` argument is unnecessary: it filtered only complex children, while top-level entries were already selected in `ConvertMetadataToCloudEventsAttributes`. + +### CloudEvents `String` validation + +`String` excludes C0/C1 controls (U+0000–U+001F, U+007F–U+009F), noncharacters (U+FDD0–U+FDEF and U+FFFE/U+FFFF in every plane), and unpaired surrogates. JSON escaping does not make these values valid. Only caller-controlled `String`, `Char`, and `Uri` text is scanned; numeric, boolean, date/time, `Guid`, and `TimeSpan` text is safe by construction. Values are rejected, never normalized. + +The public `CloudEventsAttributeText.IndexOfDisallowedCharacter` returns the UTF-16 index of the first invalid code point, or `-1` when the text conforms; it treats a valid surrogate pair as one scalar and still rejects a pair that encodes a noncharacter. The writer throws `InvalidOperationException` naming the attribute and code point. A conversion service that preflights may instead throw its normal `ArgumentException`; this deliberately adds a second scan to gain failure before serialization. The default service does not preflight, so the mandatory writer check scans once. The fast path is one ASCII range check, with surrogate/noncharacter work only above U+D7FF. + +Do not test `Utf8JsonWriter`'s replacement/exception behavior for malformed UTF-16: it is not this library's contract, and a `System.Text.Json` update could break such a test without changing anything here. The public serialization test must prove invalid text never reaches it. Two deliberate gaps remain: standard attributes are rendered elsewhere and retain their current JSON string shape without this character scan, which belongs in a follow-up review of that path; inbound invalid strings remain accepted to avoid breaking consumers because of a producer defect. + +### Null, reading, and standard attributes + +The writer omits null before committing a property name. The reader skips a null token before adding it to `CloudEventsEnvelopeJsonReader`'s builder, so it is indistinguishable from omission, cannot participate in `CloudEventsAttributeConflictStrategy`, and cannot replace payload metadata under `MergeStrategy`. A null extension therefore cannot round-trip, as required by the format. Tests cover omission, a hand-written inbound null, and collision with payload metadata. Standard attributes already treat null as absent through `GetStringAttribute` and `ReadOptionalStringValue`. + +Apart from null, reading is unchanged. String-mapped `Double`, `Single`, `Decimal`, and out-of-range `Int64` return as `MetadataKind.String`, the same kind of asymmetry already documented because `MetadataJsonReader` never produces `Decimal`. The public encoding API and README document this, and a registered `CloudEventsAttributeParser` is the supported way to restore a specific original kind. Standard attributes never enter this writer: `ResolveAttributes` continues writing their canonical text as strings. Their JSON shape is unchanged, although their separate path does not yet enforce the CloudEvents `String` character restrictions. + +### Allocation, release notes, and verification + +The string arm first calls `MetadataValue.TryFormatCanonical` into a 32-character stack buffer and passes the resulting span to `Utf8JsonWriter.WriteStringValue`, falling back to `ToCanonicalString()` when necessary. The widest numeric text is the 31-character scale-28 value `-0.0000000000000000000000000001`; `decimal.MinValue` is 30. Only `Double` and `Single` span-format without allocation today; `Int64`, `UInt64`, and `Decimal` still materialize one string (`MetadataValue.cs:848`). Removing that requires netstandard2.0 `TryFormat` shims following the `BitOperationsCompat` pattern, or a UTF-8 `Utf8Formatter` path with differential tests, and remains follow-up work from `0058`. Thus floating-point attributes improve, decimal and out-of-range `Int64` regress by one required string, and existing string-shaped kinds are unchanged; the intermediate copy keeps the call site ready for the deferred formatter. The allocation assertion follows `CanonicalFloatingPointFormatterTests`; the existing CloudEvents benchmark already exercises the path, so no new benchmark is required. + +Release notes go into unreleased 0.7.0. Four breaking changes are CloudEvents-extension-only: string encoding for `Double`, `Single`, `Decimal`, and out-of-range `Int64`; null omission on write; null-as-unset on read; and rejection of invalid `String` text. Their entries explicitly exclude data and `problem+json`. The fifth change—canonical-string support in `TryGetInt64`—applies globally to strings from headers, JSON, or caller code and is documented separately, including its rejection of noncanonical forms. The existing decimal note is narrowed to payload serialization. + +Verification uses the public `ToCloudEvent` matrix for every non-null primitive, asserting `JsonValueKind` and text. The four `Int64` cases are `int.MinValue`, `int.MaxValue`, `(long) int.MinValue - 1`, and `(long) int.MaxValue + 1`; `Null` is covered separately. Read-back covers every encoding and one parser restoration. Release builds, allocation tests, and solution coverage complete the checks. diff --git a/src/Light.PortableResults/CloudEvents/CloudEventsAttributeJsonEncoding.cs b/src/Light.PortableResults/CloudEvents/CloudEventsAttributeJsonEncoding.cs new file mode 100644 index 00000000..58cd5c00 --- /dev/null +++ b/src/Light.PortableResults/CloudEvents/CloudEventsAttributeJsonEncoding.cs @@ -0,0 +1,102 @@ +using System; +using Light.PortableResults.Metadata; + +namespace Light.PortableResults.CloudEvents; + +/// +/// Identifies the JSON Event Format encoding used for a CloudEvents extension attribute value. +/// +/// +/// +/// These values describe JSON encodings, not the seven abstract CloudEvents context-attribute types. +/// represents an unset attribute and is omitted from the JSON object. +/// +/// +/// An uses only while its value is in the +/// inclusive 32-bit signed range; values outside that range use . Publishers that +/// require a stable string shape for one attribute name can use a +/// to convert its value to +/// before writing. +/// +/// +public enum CloudEventsAttributeJsonEncoding +{ + /// The attribute is unset and is omitted. + Null, + + /// The value is written as a JSON Boolean. + Boolean, + + /// The value is written as a JSON integer number. + Integer, + + /// The value is written as a JSON string containing its canonical invariant text. + String +} + +#pragma warning disable CS8524 // Unnamed enum values intentionally throw SwitchExpressionException. + +/// +/// Provides CloudEvents JSON Event Format encoding classification for metadata values. +/// +public static class CloudEventsAttributeJsonEncodingExtensions +{ + /// + /// Gets the JSON Event Format encoding for a CloudEvents extension attribute value. + /// + /// The metadata value to classify. + /// The CloudEvents attribute JSON encoding. + /// + /// + /// 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. + /// + /// + /// 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 . + /// Register a when an extension attribute must be + /// restored to a specific metadata kind. + /// + /// + /// + /// Thrown when contains an array or object. + /// + 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 diff --git a/src/Light.PortableResults/CloudEvents/CloudEventsAttributeName.cs b/src/Light.PortableResults/CloudEvents/CloudEventsAttributeName.cs new file mode 100644 index 00000000..274b6f0f --- /dev/null +++ b/src/Light.PortableResults/CloudEvents/CloudEventsAttributeName.cs @@ -0,0 +1,43 @@ +using System; + +namespace Light.PortableResults.CloudEvents; + +/// +/// Provides low-level syntax validation for CloudEvents extension attribute names. +/// +public static class CloudEventsAttributeName +{ + /// + /// Determines whether an attribute name contains only lowercase ASCII letters and decimal digits. + /// + /// The attribute name whose character syntax is checked. + /// + /// when every character is a lowercase ASCII letter or decimal digit; + /// otherwise, . + /// + /// + /// 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; WriteCloudEventsExtensionAttribute does so. + /// + /// + /// Thrown when is . + /// + 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; + } +} diff --git a/src/Light.PortableResults/CloudEvents/CloudEventsAttributeText.cs b/src/Light.PortableResults/CloudEvents/CloudEventsAttributeText.cs new file mode 100644 index 00000000..4d8efbb6 --- /dev/null +++ b/src/Light.PortableResults/CloudEvents/CloudEventsAttributeText.cs @@ -0,0 +1,77 @@ +using System; + +namespace Light.PortableResults.CloudEvents; + +/// +/// Provides validation for the CloudEvents String context-attribute type. +/// +public static class CloudEventsAttributeText +{ + /// + /// Finds the first character that is not allowed by the CloudEvents String type. + /// + /// The text to inspect. + /// + /// The UTF-16 index of the first disallowed Unicode code point, or -1 when the text conforms. + /// + /// + /// 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. + /// + public static int IndexOfDisallowedCharacter(ReadOnlySpan 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; + } +} diff --git a/src/Light.PortableResults/CloudEvents/Reading/Json/CloudEventsEnvelopeJsonReader.cs b/src/Light.PortableResults/CloudEvents/Reading/Json/CloudEventsEnvelopeJsonReader.cs index 9cfe0c67..d129ff40 100644 --- a/src/Light.PortableResults/CloudEvents/Reading/Json/CloudEventsEnvelopeJsonReader.cs +++ b/src/Light.PortableResults/CloudEvents/Reading/Json/CloudEventsEnvelopeJsonReader.cs @@ -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); + } } } diff --git a/src/Light.PortableResults/CloudEvents/Reading/PortableResultsCloudEventsReadOptions.cs b/src/Light.PortableResults/CloudEvents/Reading/PortableResultsCloudEventsReadOptions.cs index 4071da04..085dcc84 100644 --- a/src/Light.PortableResults/CloudEvents/Reading/PortableResultsCloudEventsReadOptions.cs +++ b/src/Light.PortableResults/CloudEvents/Reading/PortableResultsCloudEventsReadOptions.cs @@ -32,7 +32,9 @@ public sealed record PortableResultsCloudEventsReadOptions public Func? IsFailureType { get; init; } /// - /// 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 even when the writer + /// mapped another primitive kind to canonical text; register an attribute parser to restore that kind. /// public ICloudEventsAttributeParsingService? ParsingService { get; init; } diff --git a/src/Light.PortableResults/CloudEvents/Writing/DefaultCloudEventsAttributeConversionService.cs b/src/Light.PortableResults/CloudEvents/Writing/DefaultCloudEventsAttributeConversionService.cs index 4ea58d91..899da7ba 100644 --- a/src/Light.PortableResults/CloudEvents/Writing/DefaultCloudEventsAttributeConversionService.cs +++ b/src/Light.PortableResults/CloudEvents/Writing/DefaultCloudEventsAttributeConversionService.cs @@ -43,7 +43,10 @@ FrozenDictionary converters /// The metadata value to convert. /// The CloudEvents attribute key and value pair. /// Thrown when is . - /// Thrown when the resulting attribute name is invalid, reserved, or the value is not a primitive type for extension attributes. + /// + /// Thrown when the resulting attribute name is invalid, reserved, or the value is not a primitive type for extension + /// attributes. + /// public KeyValuePair PrepareCloudEventsAttribute( string metadataKey, MetadataValue metadataValue @@ -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.", @@ -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)) diff --git a/src/Light.PortableResults/CloudEvents/Writing/Json/JsonCloudEventsExtensions.cs b/src/Light.PortableResults/CloudEvents/Writing/Json/JsonCloudEventsExtensions.cs index 81e7c6a2..fa9e5328 100644 --- a/src/Light.PortableResults/CloudEvents/Writing/Json/JsonCloudEventsExtensions.cs +++ b/src/Light.PortableResults/CloudEvents/Writing/Json/JsonCloudEventsExtensions.cs @@ -11,6 +11,10 @@ namespace Light.PortableResults.CloudEvents.Writing.Json; /// public static class JsonCloudEventsExtensions { + // Guid's canonical D format is the longest bounded primitive encoding. Arbitrarily long String and Uri + // values reuse their existing text when this buffer is insufficient. + private const int CanonicalTextBufferLength = 36; + /// /// Serializes the contents of a into the provided /// using the supplied serializer options. @@ -130,6 +134,72 @@ JsonSerializerOptions serializerOptions writer.WriteEndObject(); } + /// + /// Writes one CloudEvents extension attribute using the JSON Event Format context-attribute mapping. + /// + /// The JSON writer that receives the complete name/value operation. + /// The lowercase alphanumeric extension attribute name. + /// The metadata value to encode. + /// + /// + /// Null values are omitted. Booleans use JSON booleans, signed integers in the inclusive 32-bit range + /// use JSON numbers, and every other conforming primitive uses a JSON string containing its canonical + /// invariant text. This differs from the metadata value's natural JSON shape. + /// + /// + /// The attribute name and string text are validated before the property name is written, so a rejected + /// attribute cannot leave the writer with a partial JSON property. + /// + /// + /// + /// Thrown when or is null. + /// + /// + /// Thrown when is empty, invalid, reserved, or a standard CloudEvents + /// attribute name. + /// + /// + /// Thrown when is complex or its string encoding violates the CloudEvents + /// character contract. + /// + public static void WriteCloudEventsExtensionAttribute( + this Utf8JsonWriter writer, + string attributeName, + MetadataValue value + ) + { + if (writer is null) + { + throw new ArgumentNullException(nameof(writer)); + } + + ValidateExtensionAttributeName(attributeName); + var encoding = value.GetCloudEventsAttributeJsonEncoding(); + + switch (encoding) + { + case CloudEventsAttributeJsonEncoding.Null: + return; + + case CloudEventsAttributeJsonEncoding.Boolean: + value.TryGetBoolean(out var booleanValue); + writer.WritePropertyName(attributeName); + writer.WriteBooleanValue(booleanValue); + return; + + case CloudEventsAttributeJsonEncoding.Integer: + value.TryGetInt64(out var int64Value); + writer.WritePropertyName(attributeName); + writer.WriteNumberValue(int64Value); + return; + + case CloudEventsAttributeJsonEncoding.String: + ValidateCallerControlledText(attributeName, value); + WriteStringAttribute(writer, attributeName, value); + return; + } + } + private static void WriteEnvelopeStart( Utf8JsonWriter writer, string type, @@ -202,17 +272,119 @@ private static void WriteExtensionAttributes(Utf8JsonWriter writer, MetadataObje foreach (var keyValuePair in convertedAttributes.Value) { - if (CloudEventsConstants.StandardAttributeNames.Contains(keyValuePair.Key) || - CloudEventsConstants.ForbiddenConvertedAttributeNames.Contains(keyValuePair.Key)) + if (CloudEventsConstants.StandardAttributeNames.Contains(keyValuePair.Key)) { continue; } - writer.WritePropertyName(keyValuePair.Key); - writer.WriteMetadataValue( - keyValuePair.Value, - MetadataValueAnnotation.SerializeInCloudEventsExtensionAttributes + writer.WriteCloudEventsExtensionAttribute(keyValuePair.Key, keyValuePair.Value); + } + } + + private static void ValidateExtensionAttributeName(string attributeName) + { + if (attributeName is null) + { + throw new ArgumentNullException(nameof(attributeName)); + } + + if (string.IsNullOrWhiteSpace(attributeName)) + { + throw new ArgumentException( + "CloudEvents extension attribute names must not be empty or whitespace.", + nameof(attributeName) + ); + } + + if (!CloudEventsAttributeName.IsValidExtensionAttributeName(attributeName)) + { + throw new ArgumentException( + $"The CloudEvents extension attribute '{attributeName}' is invalid. Only lowercase alphanumeric names are allowed.", + nameof(attributeName) + ); + } + + if (CloudEventsConstants.ForbiddenConvertedAttributeNames.Contains(attributeName)) + { + throw new ArgumentException( + $"The CloudEvents extension attribute '{attributeName}' is reserved.", + nameof(attributeName) ); } + + if (CloudEventsConstants.StandardAttributeNames.Contains(attributeName)) + { + throw new ArgumentException( + $"The CloudEvents attribute '{attributeName}' is standard and cannot use extension encoding.", + nameof(attributeName) + ); + } + } + + private static void ValidateCallerControlledText(string attributeName, MetadataValue value) + { + switch (value.Kind) + { + case MetadataKind.String: + value.TryGetString(out var stringValue); + ValidateAttributeText(attributeName, stringValue.AsSpan()); + return; + + case MetadataKind.Char: + value.TryGetChar(out var character); + Span characterText = stackalloc char[1]; + characterText[0] = character; + ValidateAttributeText(attributeName, characterText); + return; + + case MetadataKind.Uri: + value.TryGetUri(out var uri); + ValidateAttributeText(attributeName, uri!.OriginalString.AsSpan()); + return; + } + } + + private static void ValidateAttributeText(string attributeName, ReadOnlySpan text) + { + var invalidIndex = CloudEventsAttributeText.IndexOfDisallowedCharacter(text); + if (invalidIndex < 0) + { + return; + } + + var codePoint = GetCodePoint(text, invalidIndex); + throw new InvalidOperationException( + $"CloudEvents extension attribute '{attributeName}' contains disallowed code point " + + $"U+{codePoint.ToString("X4", CultureInfo.InvariantCulture)} at UTF-16 index {invalidIndex}." + ); + } + + private static int GetCodePoint(ReadOnlySpan text, int index) + { + var first = text[index]; + return first is >= '\uD800' and <= '\uDBFF' && + index + 1 < text.Length && + text[index + 1] is >= '\uDC00' and <= '\uDFFF' ? + 0x10000 + ((first - '\uD800') << 10) + text[index + 1] - '\uDC00' : + first; + } + + private static void WriteStringAttribute( + Utf8JsonWriter writer, + string attributeName, + MetadataValue value + ) + { + Span canonicalText = stackalloc char[CanonicalTextBufferLength]; + if (value.TryFormatCanonical(canonicalText, out var charsWritten)) + { + writer.WritePropertyName(attributeName); + writer.WriteStringValue(canonicalText.Slice(0, charsWritten)); + return; + } + + var materializedText = value.ToCanonicalString(); + writer.WritePropertyName(attributeName); + writer.WriteStringValue(materializedText); } } diff --git a/src/Light.PortableResults/CloudEvents/Writing/PortableResultsCloudEventsWriteOptions.cs b/src/Light.PortableResults/CloudEvents/Writing/PortableResultsCloudEventsWriteOptions.cs index 8ea6ddf4..41946e5a 100644 --- a/src/Light.PortableResults/CloudEvents/Writing/PortableResultsCloudEventsWriteOptions.cs +++ b/src/Light.PortableResults/CloudEvents/Writing/PortableResultsCloudEventsWriteOptions.cs @@ -2,6 +2,7 @@ using System.Buffers; using System.Text.Json; using Light.PortableResults.Buffers; +using Light.PortableResults.Metadata; using Light.PortableResults.SharedJsonSerialization; namespace Light.PortableResults.CloudEvents.Writing; @@ -33,7 +34,11 @@ public sealed record PortableResultsCloudEventsWriteOptions PortableResultsCloudEventsWritingModule.DefaultSerializerOptions; /// - /// Gets or sets the conversion service used to map metadata entries to CloudEvents attributes. + /// Gets or sets the conversion service used to map metadata entries to CloudEvents attributes. After + /// conversion, extension values use the JSON Event Format mapping described by + /// . + /// Convert an to + /// when one attribute name requires a stable string encoding across the full signed 64-bit range. /// public ICloudEventsAttributeConversionService ConversionService { get; set; } = DefaultCloudEventsAttributeConversionService.Instance; diff --git a/src/Light.PortableResults/Light.PortableResults.csproj b/src/Light.PortableResults/Light.PortableResults.csproj index cc215223..9c322664 100644 --- a/src/Light.PortableResults/Light.PortableResults.csproj +++ b/src/Light.PortableResults/Light.PortableResults.csproj @@ -31,14 +31,32 @@ - Default HTTP header conversion no longer surrounds string metadata values with quotes. - Float metadata values now use MetadataKind.Single rather than MetadataKind.Double. - Decimal metadata values now have the dedicated kind MetadataKind.Decimal instead of MetadataKind.String, - and they are serialized as JSON numbers instead of quoted strings. This aligns the response bodies with - the generated OpenAPI documents, which have always described decimals as numbers. + and they are serialized as JSON numbers instead of quoted strings in response bodies, problem+json, and + CloudEvents data payloads. This aligns those payloads with the generated OpenAPI documents, which have + always described decimals as numbers. CloudEvents extension attributes use the separate mapping below. - MetadataValue.TryGetString no longer returns true for decimal metadata values. Use TryGetDecimal instead. - Decimal metadata values that differ only in trailing zeros (19.50 and 19.5) now compare equal, because equality is delegated to decimal. The scale is still preserved during serialization. - The numeric values of MetadataKind.Array and MetadataKind.Object changed to 200 and 201 so that the values 16 to 199 are reserved for future primitive kinds. This only affects callers that persisted or transmitted the numeric value of MetadataKind. + - CloudEvents extension attributes now follow the JSON Event Format context-attribute mapping. Double, + Single, Decimal, and Int64 values outside the inclusive 32-bit signed range are written as JSON strings + containing canonical invariant text. This does not change CloudEvents data or problem+json payloads. + - Null CloudEvents extension attributes are now omitted on write because null means unset. This does not + change null metadata in CloudEvents data or problem+json payloads. + - Null CloudEvents extension attributes are now treated as absent on read and cannot participate in metadata + conflict resolution. This does not change null metadata in CloudEvents data or problem+json payloads. + - CloudEvents extension attributes whose String encoding contains controls, Unicode noncharacters, or + unpaired surrogates are now rejected. Values are not normalized. This validation is limited to extension + attributes and does not change CloudEvents data or problem+json payloads. + - A custom ICloudEventsAttributeConversionService or directly constructed CloudEvents envelope that supplies + the reserved lproutcome attribute now causes an ArgumentException during JSON writing instead of having + that supplied value silently dropped. Supplied data and data_base64 entries remain skipped as standard + attributes. + - MetadataValue.TryGetInt64 now accepts canonical signed-integer text from any string metadata value, + including values originating in headers, JSON, or caller code. Noncanonical forms such as a leading plus, + negative zero, leading zeros, surrounding whitespace, and out-of-range text remain rejected. diff --git a/src/Light.PortableResults/Metadata/MetadataValue.cs b/src/Light.PortableResults/Metadata/MetadataValue.cs index 24920160..d5bb530f 100644 --- a/src/Light.PortableResults/Metadata/MetadataValue.cs +++ b/src/Light.PortableResults/Metadata/MetadataValue.cs @@ -115,7 +115,7 @@ public static MetadataValue FromDecimal( decimal value, MetadataValueAnnotation annotation = DefaultAnnotation ) => - new (MetadataKind.Decimal, new MetadataPayload((object) value), annotation); + new (MetadataKind.Decimal, new MetadataPayload(value), annotation); /// /// Creates a metadata value from an unsigned 64-bit integer. @@ -136,7 +136,7 @@ public static MetadataValue FromSingle( ) { ValidateFinite(value, nameof(value)); - return new MetadataValue(MetadataKind.Single, new MetadataPayload((double) value), annotation); + return new MetadataValue(MetadataKind.Single, new MetadataPayload(value), annotation); } /// @@ -182,7 +182,7 @@ public static MetadataValue FromDateTimeOffset( DateTimeOffset value, MetadataValueAnnotation annotation = DefaultAnnotation ) => - new (MetadataKind.DateTimeOffset, new MetadataPayload((object) value), annotation); + new (MetadataKind.DateTimeOffset, new MetadataPayload(value), annotation); #if NET10_0_OR_GREATER /// @@ -220,7 +220,7 @@ public static MetadataValue FromGuid( Guid value, MetadataValueAnnotation annotation = DefaultAnnotation ) => - new (MetadataKind.Guid, new MetadataPayload((object) value), annotation); + new (MetadataKind.Guid, new MetadataPayload(value), annotation); /// /// Creates a metadata value from a URI. A null URI becomes a null metadata value. @@ -381,7 +381,7 @@ public bool TryGetBoolean(out bool value) return false; } - /// Attempts to get the stored signed 64-bit integer. + /// Attempts to get a signed 64-bit integer or parse its canonical string encoding. public bool TryGetInt64(out long value) { if (Kind == MetadataKind.Int64) @@ -390,6 +390,13 @@ public bool TryGetInt64(out long value) return true; } + if (TryGetRawString(out var text) && + long.TryParse(text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out value) && + string.Equals(value.ToString(CultureInfo.InvariantCulture), text, StringComparison.Ordinal)) + { + return true; + } + value = 0; return false; } diff --git a/tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeJsonEncodingTests.cs b/tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeJsonEncodingTests.cs new file mode 100644 index 00000000..fc39696a --- /dev/null +++ b/tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeJsonEncodingTests.cs @@ -0,0 +1,89 @@ +using System; +using System.Runtime.CompilerServices; +using FluentAssertions; +using Light.PortableResults.CloudEvents; +using Light.PortableResults.Metadata; +using Light.PortableResults.Tests.Metadata; +using Xunit; + +namespace Light.PortableResults.Tests.CloudEvents; + +public sealed class CloudEventsAttributeJsonEncodingTests +{ + public static TheoryData PrimitiveValues => + new () + { + { MetadataValue.Null, CloudEventsAttributeJsonEncoding.Null }, + { MetadataValue.FromBoolean(true), CloudEventsAttributeJsonEncoding.Boolean }, + { MetadataValue.FromInt64(int.MinValue), CloudEventsAttributeJsonEncoding.Integer }, + { MetadataValue.FromInt64(int.MaxValue), CloudEventsAttributeJsonEncoding.Integer }, + { MetadataValue.FromInt64((long) int.MinValue - 1), CloudEventsAttributeJsonEncoding.String }, + { MetadataValue.FromInt64((long) int.MaxValue + 1), CloudEventsAttributeJsonEncoding.String }, + { MetadataValue.FromDouble(5), CloudEventsAttributeJsonEncoding.String }, + { MetadataValue.FromString("text"), CloudEventsAttributeJsonEncoding.String }, + { MetadataValue.FromDecimal(19.50m), CloudEventsAttributeJsonEncoding.String }, + { MetadataValue.FromUInt64(ulong.MaxValue), CloudEventsAttributeJsonEncoding.String }, + { MetadataValue.FromSingle(0.1f), CloudEventsAttributeJsonEncoding.String }, + { MetadataValue.FromChar('x'), CloudEventsAttributeJsonEncoding.String }, + { + MetadataValue.FromDateTime(new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Utc)), + CloudEventsAttributeJsonEncoding.String + }, + { + MetadataValue.FromDateTimeOffset( + new DateTimeOffset(2026, 7, 26, 13, 45, 30, TimeSpan.FromHours(2)) + ), + CloudEventsAttributeJsonEncoding.String + }, +#if !TESTING_NETSTANDARD_ASSET + { MetadataValue.FromDateOnly(new DateOnly(2026, 7, 26)), CloudEventsAttributeJsonEncoding.String }, + { MetadataValue.FromTimeOnly(new TimeOnly(13, 45, 30)), CloudEventsAttributeJsonEncoding.String }, +#endif + { MetadataValue.FromTimeSpan(TimeSpan.FromSeconds(5)), CloudEventsAttributeJsonEncoding.String }, + { + MetadataValue.FromGuid(new Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890")), + CloudEventsAttributeJsonEncoding.String + }, + { + MetadataValue.FromUri(new Uri("https://example.com/items/42")), + CloudEventsAttributeJsonEncoding.String + } + }; + + [Theory] + [MemberData(nameof(PrimitiveValues))] + public void GetCloudEventsAttributeJsonEncodingShouldClassifyEveryPrimitive( + MetadataValue value, + CloudEventsAttributeJsonEncoding expected + ) + { + value.GetCloudEventsAttributeJsonEncoding().Should().Be(expected); + } + + [Fact] + public void GetCloudEventsAttributeJsonEncodingShouldNameComplexKindsInFailures() + { + var array = MetadataValue.FromArray(MetadataArray.Empty); + var @object = MetadataValue.FromObject(MetadataObject.Empty); + + Action arrayAct = () => array.GetCloudEventsAttributeJsonEncoding(); + Action objectAct = () => @object.GetCloudEventsAttributeJsonEncoding(); + + arrayAct.Should().Throw().WithMessage("*Array*"); + objectAct.Should().Throw().WithMessage("*Object*"); + } + + [Fact] + public void GetCloudEventsAttributeJsonEncodingShouldThrowForUndeclaredKind() + { + var value = MetadataValueTestFactory.CreateWithUndeclaredKind(); + + Action act = () => value.GetCloudEventsAttributeJsonEncoding(); + +#if TESTING_NETSTANDARD_ASSET + act.Should().Throw(); +#else + act.Should().Throw(); +#endif + } +} diff --git a/tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeNameTests.cs b/tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeNameTests.cs new file mode 100644 index 00000000..d685a20e --- /dev/null +++ b/tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeNameTests.cs @@ -0,0 +1,18 @@ +using System; +using FluentAssertions; +using Light.PortableResults.CloudEvents; +using Xunit; + +namespace Light.PortableResults.Tests.CloudEvents; + +public sealed class CloudEventsAttributeNameTests +{ + [Fact] + public void IsValidExtensionAttributeNameShouldThrowWhenAttributeNameIsNull() + { + Action act = () => CloudEventsAttributeName.IsValidExtensionAttributeName(null!); + + act.Should().Throw() + .Where(exception => exception.ParamName == "attributeName"); + } +} diff --git a/tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeTextTests.cs b/tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeTextTests.cs new file mode 100644 index 00000000..837c00f3 --- /dev/null +++ b/tests/Light.PortableResults.Tests/CloudEvents/CloudEventsAttributeTextTests.cs @@ -0,0 +1,50 @@ +using FluentAssertions; +using Light.PortableResults.CloudEvents; +using Xunit; + +namespace Light.PortableResults.Tests.CloudEvents; + +public sealed class CloudEventsAttributeTextTests +{ + [Theory] + [InlineData("a\u0000b", 1)] + [InlineData("a\u001Fb", 1)] + [InlineData("a\u007Fb", 1)] + [InlineData("a\u009Fb", 1)] + [InlineData("a\uFDD0b", 1)] + public void IndexOfDisallowedCharacterShouldReturnFirstInvalidUtf16Index(string text, int expectedIndex) + { + CloudEventsAttributeText.IndexOfDisallowedCharacter(text).Should().Be(expectedIndex); + } + + [Fact] + public void IndexOfDisallowedCharacterShouldRejectLoneHighAndLowSurrogates() + { + var loneHighSurrogate = new string(['a', '\uD800', 'b']); + var loneLowSurrogate = new string(['a', '\uDC00', 'b']); + + CloudEventsAttributeText.IndexOfDisallowedCharacter(loneHighSurrogate).Should().Be(1); + CloudEventsAttributeText.IndexOfDisallowedCharacter(loneLowSurrogate).Should().Be(1); + } + + [Fact] + public void IndexOfDisallowedCharacterShouldRejectLastNoncharactersInEveryPlane() + { + var basicPlaneNoncharacter = new string(['a', '\uFFFE', 'b']); + var supplementaryPlaneNoncharacter = new string(['a', '\uD83F', '\uDFFE', 'b']); + + CloudEventsAttributeText.IndexOfDisallowedCharacter(basicPlaneNoncharacter).Should().Be(1); + CloudEventsAttributeText.IndexOfDisallowedCharacter(supplementaryPlaneNoncharacter).Should().Be(1); + } + + [Theory] + [InlineData("")] + [InlineData("plain ASCII")] + [InlineData("Grüße 日本語 😀")] + [InlineData("\uD83D\uDE00")] + [InlineData("\uFFFD")] + public void IndexOfDisallowedCharacterShouldAcceptConformingText(string text) + { + CloudEventsAttributeText.IndexOfDisallowedCharacter(text).Should().Be(-1); + } +} diff --git a/tests/Light.PortableResults.Tests/CloudEvents/Reading/ReadOnlyMemoryCloudEventsExtensionsTests.cs b/tests/Light.PortableResults.Tests/CloudEvents/Reading/ReadOnlyMemoryCloudEventsExtensionsTests.cs index aa0f9785..7376c6d0 100644 --- a/tests/Light.PortableResults.Tests/CloudEvents/Reading/ReadOnlyMemoryCloudEventsExtensionsTests.cs +++ b/tests/Light.PortableResults.Tests/CloudEvents/Reading/ReadOnlyMemoryCloudEventsExtensionsTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Immutable; using System.Text; using System.Text.Json; using FluentAssertions; @@ -558,5 +559,119 @@ public void ReadResult_ShouldIgnoreLprOutcomeWhenMergingExtensionMetadata() result.Metadata.Should().BeNull(); } + [Fact] + public void ReadResultWithCloudEventsEnvelopeShouldReadEveryAttributeEncodingAndOmitNull() + { + var cloudEvent = CreateUtf8( + """ + { + "specversion": "1.0", + "type": "app.success", + "source": "urn:test:source", + "id": "evt-encodings", + "lproutcome": "success", + "booleanattribute": true, + "integerattribute": 2147483647, + "stringattribute": "19.50", + "largeinteger": "2147483648", + "nullattribute": null + } + """ + ); + + var envelope = cloudEvent.ReadResultWithCloudEventsEnvelope(); + + var attributes = envelope.ExtensionAttributes!.Value; + attributes["booleanattribute"].Kind.Should().Be(MetadataKind.Boolean); + attributes["integerattribute"].Kind.Should().Be(MetadataKind.Int64); + attributes["stringattribute"].Kind.Should().Be(MetadataKind.String); + attributes["largeinteger"].Kind.Should().Be(MetadataKind.String); + attributes["largeinteger"].TryGetInt64(out var largeInteger).Should().BeTrue(); + largeInteger.Should().Be(2147483648L); + attributes.ContainsKey("nullattribute").Should().BeFalse(); + } + + [Fact] + public void ReadResultShouldNotLetNullExtensionReplacePayloadMetadata() + { + var cloudEvent = CreateUtf8( + """ + { + "specversion": "1.0", + "type": "app.success", + "source": "urn:test:source", + "id": "evt-null-collision", + "lproutcome": "success", + "traceid": null, + "data": { + "metadata": { + "traceid": "from-payload" + } + } + } + """ + ); + var options = new PortableResultsCloudEventsReadOptions + { + ParsingService = new DefaultCloudEventsAttributeParsingService(), + MergeStrategy = MetadataMergeStrategy.PreserveExisting + }; + + var result = cloudEvent.ReadResult(options); + + result.Metadata!.Value.TryGetString("traceid", out var traceId).Should().BeTrue(); + traceId.Should().Be("from-payload"); + } + + [Fact] + public void ReadResultShouldUseRegisteredParserToRestoreStringMappedKind() + { + var cloudEvent = CreateUtf8( + """ + { + "specversion": "1.0", + "type": "app.success", + "source": "urn:test:source", + "id": "evt-parser", + "lproutcome": "success", + "price": "19.50" + } + """ + ); + var parsers = CloudEventsAttributeParserRegistry.Create([new DecimalAttributeParser()]); + var options = new PortableResultsCloudEventsReadOptions + { + ParsingService = new DefaultCloudEventsAttributeParsingService(parsers) + }; + + var result = cloudEvent.ReadResult(options); + + var price = result.Metadata!.Value["price"]; + price.Kind.Should().Be(MetadataKind.Decimal); + price.TryGetDecimal(out var decimalValue).Should().BeTrue(); + decimalValue.Should().Be(19.50m); + } + private static ReadOnlyMemory CreateUtf8(string json) => Encoding.UTF8.GetBytes(json); + + private sealed class DecimalAttributeParser : CloudEventsAttributeParser + { + public DecimalAttributeParser() : base("price", ImmutableArray.Create("price")) { } + + public override MetadataValue ParseAttribute( + string attributeName, + MetadataValue value, + MetadataValueAnnotation annotation + ) + { + if (!value.TryGetDecimal(out var decimalValue)) + { + throw new InvalidOperationException( + $"CloudEvents extension attribute '{attributeName}' is not a decimal." + ); + } + + return MetadataValue.FromDecimal(decimalValue, annotation); + } + } } diff --git a/tests/Light.PortableResults.Tests/CloudEvents/Writing/CloudEventsResultExtensionsTests.cs b/tests/Light.PortableResults.Tests/CloudEvents/Writing/CloudEventsResultExtensionsTests.cs index d3c13eb3..b4bacfac 100644 --- a/tests/Light.PortableResults.Tests/CloudEvents/Writing/CloudEventsResultExtensionsTests.cs +++ b/tests/Light.PortableResults.Tests/CloudEvents/Writing/CloudEventsResultExtensionsTests.cs @@ -1,7 +1,13 @@ using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; using System.Text.Json; using FluentAssertions; +using Light.PortableResults.CloudEvents; using Light.PortableResults.CloudEvents.Writing; +using Light.PortableResults.CloudEvents.Writing.Json; using Light.PortableResults.Metadata; using Light.PortableResults.SharedJsonSerialization; using Xunit; @@ -537,7 +543,7 @@ public void ToCloudEvent_ShouldFallBackToCurrentTimestamp_WhenTimeAttributeIsNul } [Fact] - public void ToCloudEvent_ShouldWriteDecimalExtensionAttribute_AsUnquotedNumber() + public void ToCloudEventShouldWriteDecimalExtensionAttributeAsQuotedCanonicalText() { var metadata = MetadataObject.Create( ( @@ -557,8 +563,225 @@ public void ToCloudEvent_ShouldWriteDecimalExtensionAttribute_AsUnquotedNumber() using var document = JsonDocument.Parse(json); var price = document.RootElement.GetProperty("price"); - price.ValueKind.Should().Be(JsonValueKind.Number); - price.GetDecimal().Should().Be(19.99m); + price.ValueKind.Should().Be(JsonValueKind.String); + price.GetString().Should().Be("19.99"); + } + + [Fact] + public void ToCloudEventShouldUseCloudEventsMappingForEveryNonNullPrimitive() + { + var values = new (MetadataValue Value, JsonValueKind JsonKind, string CanonicalText)[] + { + (MetadataValue.FromBoolean(true), JsonValueKind.True, "true"), + (MetadataValue.FromInt64(int.MinValue), JsonValueKind.Number, "-2147483648"), + (MetadataValue.FromInt64(int.MaxValue), JsonValueKind.Number, "2147483647"), + (MetadataValue.FromInt64((long) int.MinValue - 1), JsonValueKind.String, "-2147483649"), + (MetadataValue.FromInt64((long) int.MaxValue + 1), JsonValueKind.String, "2147483648"), + (MetadataValue.FromDouble(5), JsonValueKind.String, "5.0"), + (MetadataValue.FromString("plain text"), JsonValueKind.String, "plain text"), + (MetadataValue.FromDecimal(19.50m), JsonValueKind.String, "19.50"), + (MetadataValue.FromUInt64(ulong.MaxValue), JsonValueKind.String, "18446744073709551615"), + (MetadataValue.FromSingle(0.1f), JsonValueKind.String, "0.1"), + (MetadataValue.FromChar('ß'), JsonValueKind.String, "ß"), + ( + MetadataValue.FromDateTime(new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Utc)), + JsonValueKind.String, + "2026-07-26T13:45:30Z" + ), + ( + MetadataValue.FromDateTimeOffset( + new DateTimeOffset(2026, 7, 26, 13, 45, 30, TimeSpan.FromHours(2)) + ), + JsonValueKind.String, + "2026-07-26T13:45:30+02:00" + ), +#if !TESTING_NETSTANDARD_ASSET + ( + MetadataValue.FromDateOnly(new DateOnly(2026, 7, 26)), + JsonValueKind.String, + "2026-07-26" + ), + (MetadataValue.FromTimeOnly(new TimeOnly(13, 45, 30)), JsonValueKind.String, "13:45:30"), +#endif + (MetadataValue.FromTimeSpan(TimeSpan.FromSeconds(5)), JsonValueKind.String, "PT5S"), + ( + MetadataValue.FromGuid(new Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890")), + JsonValueKind.String, + "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + ), + ( + MetadataValue.FromUri(new Uri("https://example.com/items/42")), + JsonValueKind.String, + "https://example.com/items/42" + ) + }; + + foreach (var (value, expectedKind, expectedText) in values) + { + var annotatedValue = MetadataValueAnnotationHelper.WithAnnotation( + value, + MetadataValueAnnotation.SerializeInCloudEventsExtensionAttributes + ); + var result = Result.Ok(MetadataObject.Create(("attribute", annotatedValue))); + + var json = result.ToCloudEvent( + successType: "app.success", + failureType: "app.failure", + id: "evt-matrix", + time: new DateTimeOffset(2026, 7, 26, 13, 45, 30, TimeSpan.Zero), + options: CreateWriteOptions() + ); + + using var document = JsonDocument.Parse(json); + var attribute = document.RootElement.GetProperty("attribute"); + attribute.ValueKind.Should().Be(expectedKind, "{0} has a normative CloudEvents encoding", value.Kind); + var actualText = expectedKind == JsonValueKind.String ? attribute.GetString() : attribute.GetRawText(); + actualText.Should().Be(expectedText, "{0} uses canonical invariant text", value.Kind); + } + } + + [Fact] + public void ToCloudEventShouldOmitNullExtensionAttributeWithoutChangingEnvelopeBytes() + { + var annotation = MetadataValueAnnotation.SerializeInCloudEventsExtensionAttributes; + var withNull = Result.Ok(MetadataObject.Create(("optional", MetadataValue.FromNull(annotation)))); + var withoutAttribute = Result.Ok(); + var time = new DateTimeOffset(2026, 7, 26, 13, 45, 30, TimeSpan.Zero); + + var withNullJson = withNull.ToCloudEvent( + successType: "app.success", + failureType: "app.failure", + id: "evt-null", + time: time, + options: CreateWriteOptions() + ); + var withoutAttributeJson = withoutAttribute.ToCloudEvent( + successType: "app.success", + failureType: "app.failure", + id: "evt-null", + time: time, + options: CreateWriteOptions() + ); + + withNullJson.Should().Equal(withoutAttributeJson); + } + + [Fact] + public void ToCloudEventShouldExposeValueDependentInt64ShapeForOneAttributeName() + { + using var inRange = WriteInt64Attribute(int.MaxValue, CreateWriteOptions()); + using var outOfRange = WriteInt64Attribute((long) int.MaxValue + 1, CreateWriteOptions()); + + inRange.RootElement.GetProperty("sequence").ValueKind.Should().Be(JsonValueKind.Number); + outOfRange.RootElement.GetProperty("sequence").ValueKind.Should().Be(JsonValueKind.String); + } + + [Fact] + public void ToCloudEventShouldKeepInt64ShapeStableWhenConverterUsesStringKind() + { + var converter = new Int64ToStringAttributeConverter(); + var converters = new Dictionary(StringComparer.Ordinal) + { + ["sequence"] = converter + }.ToFrozenDictionary(StringComparer.Ordinal); + var options = CreateWriteOptions(); + options.ConversionService = new DefaultCloudEventsAttributeConversionService(converters); + + using var inRange = WriteInt64Attribute(int.MaxValue, options); + using var outOfRange = WriteInt64Attribute((long) int.MaxValue + 1, options); + + inRange.RootElement.GetProperty("sequence").ValueKind.Should().Be(JsonValueKind.String); + outOfRange.RootElement.GetProperty("sequence").ValueKind.Should().Be(JsonValueKind.String); + inRange.RootElement.GetProperty("sequence").GetString().Should().Be("2147483647"); + outOfRange.RootElement.GetProperty("sequence").GetString().Should().Be("2147483648"); + } + + [Fact] + public void ToCloudEventShouldRejectInvalidTextReturnedByCustomConversionService() + { + var annotation = MetadataValueAnnotation.SerializeInCloudEventsExtensionAttributes; + var metadata = MetadataObject.Create(("original", MetadataValue.FromString("safe", annotation))); + var options = CreateWriteOptions(); + options.ConversionService = new InvalidTextConversionService(); + + Action act = () => Result.Ok(metadata).ToCloudEvent( + successType: "app.success", + failureType: "app.failure", + id: "evt-invalid-custom", + options: options + ); + + act.Should().Throw().WithMessage("*converted*U+0001*"); + } + + [Fact] + public void WriteCloudEventsShouldRejectInvalidTextFromDirectlyConstructedEnvelope() + { + var extensionAttributes = MetadataObject.Create( + ("direct", MetadataValue.FromString("invalid\uFDD0text")) + ); + var envelope = new CloudEventsEnvelopeForWriting( + "app.success", + "urn:test:source", + "evt-direct", + Result.Ok(), + new ResolvedCloudEventsWriteOptions(MetadataSerializationMode.ErrorsOnly), + ExtensionAttributes: extensionAttributes + ); + + Action act = () => + { + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + writer.WriteCloudEvents(envelope, PortableResultsCloudEventsWriteOptions.Default.SerializerOptions); + }; + + act.Should().Throw().WithMessage("*direct*U+FDD0*"); + } + + [Fact] + public void ToCloudEventShouldKeepMetadataResolvedStandardAttributesAsStrings() + { + var annotation = MetadataValueAnnotation.SerializeInCloudEventsExtensionAttributes; + var metadata = MetadataObject.Create( + ("type", MetadataValue.FromInt64(42, annotation)), + ("source", MetadataValue.FromUri(new Uri("urn:test:source"), annotation)), + ("subject", MetadataValue.FromDecimal(19.50m, annotation)), + ( + "dataschema", + MetadataValue.FromUri(new Uri("https://example.com/schema"), annotation) + ), + ( + "time", + MetadataValue.FromDateTimeOffset( + new DateTimeOffset(2026, 7, 26, 13, 45, 30, TimeSpan.Zero), + annotation + ) + ), + ( + "id", + MetadataValue.FromGuid( + new Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"), + annotation + ) + ) + ); + + var json = Result.Ok(metadata).ToCloudEvent(options: CreateWriteOptions(source: null)); + + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + foreach (var attributeName in new[] { "type", "source", "subject", "dataschema", "time", "id" }) + { + root.GetProperty(attributeName).ValueKind.Should().Be(JsonValueKind.String); + } + + root.GetProperty("type").GetString().Should().Be("42"); + root.GetProperty("source").GetString().Should().Be("urn:test:source"); + root.GetProperty("subject").GetString().Should().Be("19.50"); + root.GetProperty("dataschema").GetString().Should().Be("https://example.com/schema"); + root.GetProperty("time").GetString().Should().Be("2026-07-26T13:45:30.0000000+00:00"); + root.GetProperty("id").GetString().Should().Be("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); } [Fact] @@ -758,4 +981,53 @@ private static PortableResultsCloudEventsWriteOptions CreateWriteOptions(string? Source = source }; } + + private static JsonDocument WriteInt64Attribute( + long value, + PortableResultsCloudEventsWriteOptions options + ) + { + var metadata = MetadataObject.Create( + ( + "sequence", + MetadataValue.FromInt64( + value, + MetadataValueAnnotation.SerializeInCloudEventsExtensionAttributes + ) + ) + ); + var json = Result.Ok(metadata).ToCloudEvent( + successType: "app.success", + failureType: "app.failure", + id: "evt-int64-shape", + options: options + ); + return JsonDocument.Parse(json); + } + + private sealed class Int64ToStringAttributeConverter : CloudEventsAttributeConverter + { + public Int64ToStringAttributeConverter() : base(ImmutableArray.Create("sequence")) { } + + public override KeyValuePair PrepareCloudEventsAttribute( + string metadataKey, + MetadataValue value + ) => + new ( + metadataKey, + MetadataValue.FromString(value.ToCanonicalString(), value.Annotation) + ); + } + + private sealed class InvalidTextConversionService : ICloudEventsAttributeConversionService + { + public KeyValuePair PrepareCloudEventsAttribute( + string metadataKey, + MetadataValue metadataValue + ) => + new ( + "converted", + MetadataValue.FromString("invalid\u0001text", metadataValue.Annotation) + ); + } } diff --git a/tests/Light.PortableResults.Tests/CloudEvents/Writing/JsonCloudEventsExtensionsTests.cs b/tests/Light.PortableResults.Tests/CloudEvents/Writing/JsonCloudEventsExtensionsTests.cs new file mode 100644 index 00000000..c59f4d1c --- /dev/null +++ b/tests/Light.PortableResults.Tests/CloudEvents/Writing/JsonCloudEventsExtensionsTests.cs @@ -0,0 +1,315 @@ +using System; +using System.Buffers; +using System.IO; +using System.Text.Json; +using FluentAssertions; +using Light.PortableResults.CloudEvents.Writing.Json; +using Light.PortableResults.Metadata; +using Xunit; + +namespace Light.PortableResults.Tests.CloudEvents.Writing; + +public sealed class JsonCloudEventsExtensionsTests +{ + private const int AllocationSampleCount = 5; + + [Fact] + public void WriteCloudEventsExtensionAttributeShouldThrowWhenWriterIsNull() + { + Utf8JsonWriter writer = null!; + + var act = () => writer.WriteCloudEventsExtensionAttribute("attempt", MetadataValue.FromInt64(1)); + + act.Should().Throw().Where(exception => exception.ParamName == "writer"); + } + + [Fact] + public void WriteCloudEventsExtensionAttributeShouldRejectNullNameBeforeWriting() + { + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + writer.WriteStartObject(); + var bytesPending = writer.BytesPending; + + var act = () => writer.WriteCloudEventsExtensionAttribute(null!, MetadataValue.FromInt64(1)); + + act.Should().Throw().Where(exception => exception.ParamName == "attributeName"); + writer.BytesPending.Should().Be(bytesPending); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void WriteCloudEventsExtensionAttributeShouldRejectBlankNameBeforeWriting(string attributeName) + { + AssertNameRejectedBeforeWriting(attributeName, "*empty or whitespace*"); + } + + [Theory] + [InlineData("Traceid")] + [InlineData("trace-id")] + [InlineData("trace_id")] + [InlineData("tracé")] + public void WriteCloudEventsExtensionAttributeShouldRejectNonLowercaseAlphanumericNameBeforeWriting( + string attributeName + ) + { + AssertNameRejectedBeforeWriting(attributeName, "*lowercase alphanumeric*"); + } + + [Fact] + public void WriteCloudEventsExtensionAttributeShouldRejectReservedNameBeforeWriting() + { + AssertNameRejectedBeforeWriting("lproutcome", "*reserved*"); + } + + [Fact] + public void WriteCloudEventsExtensionAttributeShouldRejectStandardNameBeforeWriting() + { + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + writer.WriteStartObject(); + var bytesPending = writer.BytesPending; + + var act = () => writer.WriteCloudEventsExtensionAttribute("type", MetadataValue.FromInt64(1)); + + act.Should().Throw().WithMessage("*standard*"); + writer.BytesPending.Should().Be(bytesPending); + } + + [Theory] + [InlineData('\u0001', "U+0001")] + [InlineData('\u0080', "U+0080")] + [InlineData('\uD800', "U+D800")] + [InlineData('\uDC00', "U+DC00")] + [InlineData('\uFDD0', "U+FDD0")] + public void WriteCloudEventsExtensionAttributeShouldRejectInvalidCharBeforeWriting( + char character, + string expectedCodePoint + ) + { + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + writer.WriteStartObject(); + var bytesPending = writer.BytesPending; + + var act = () => writer.WriteCloudEventsExtensionAttribute( + "attempt", + MetadataValue.FromChar(character) + ); + + act.Should().Throw() + .WithMessage($"*attempt*{expectedCodePoint}*"); + writer.BytesPending.Should().Be(bytesPending); + } + + [Fact] + public void WriteCloudEventsExtensionAttributeShouldAcceptSurrogatePairAndNonAsciiText() + { + using var document = WriteAttribute(MetadataValue.FromString("Grüße 日本語 😀")); + + document.RootElement.GetProperty("attribute").GetString().Should().Be("Grüße 日本語 😀"); + } + + [Fact] + public void WriteCloudEventsExtensionAttributeShouldValidateUriOriginalText() + { + var uri = new Uri("https://example.com/invalid\uFDD0text"); + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + writer.WriteStartObject(); + var bytesPending = writer.BytesPending; + + var act = () => writer.WriteCloudEventsExtensionAttribute( + "location", + MetadataValue.FromUri(uri) + ); + + act.Should().Throw().WithMessage("*location*U+FDD0*"); + writer.BytesPending.Should().Be(bytesPending); + } + + [Fact] + public void WriteCloudEventsExtensionAttributeShouldNameComplexKindBeforeWriting() + { + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + writer.WriteStartObject(); + var bytesPending = writer.BytesPending; + + var act = () => writer.WriteCloudEventsExtensionAttribute( + "attribute", + MetadataValue.FromArray(MetadataArray.Empty) + ); + + act.Should().Throw().WithMessage("*Array*"); + writer.BytesPending.Should().Be(bytesPending); + } + + [Fact] + public void WriteCloudEventsExtensionAttributeShouldOmitNullAtomically() + { + using var withNull = WriteAttribute(MetadataValue.Null); + using var withoutAttribute = JsonDocument.Parse("{}"); + + withNull.RootElement.GetRawText().Should().Be(withoutAttribute.RootElement.GetRawText()); + } + + [Fact] + public void FloatingPointExtensionAttributeWritingShouldAllocateNothingAfterWarmup() + { + var output = new ArrayBufferWriter(1024 * 1024); + using var writer = new Utf8JsonWriter(output); + var doubleValue = MetadataValue.FromDouble(36_028_797_018_963_968.0); + var singleValue = MetadataValue.FromSingle(123_456_789f); + writer.WriteStartObject(); + + for (var index = 0; index < 100; index++) + { + writer.WriteCloudEventsExtensionAttribute("doublevalue", doubleValue); + writer.WriteCloudEventsExtensionAttribute("singlevalue", singleValue); + } + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var index = 0; index < 1_000; index++) + { + writer.WriteCloudEventsExtensionAttribute("doublevalue", doubleValue); + writer.WriteCloudEventsExtensionAttribute("singlevalue", singleValue); + } + + var after = GC.GetAllocatedBytesForCurrentThread(); + after.Should().Be(before); + } + + [Fact] + public void OtherStringMappedKindsShouldNotAllocateMoreThanCanonicalFormatting() + { + var values = new[] + { + MetadataValue.FromInt64((long) int.MaxValue + 1), + MetadataValue.FromString(new string('x', 64)), + MetadataValue.FromDecimal(decimal.MinValue), + MetadataValue.FromUInt64(ulong.MaxValue), + MetadataValue.FromChar('ß'), + MetadataValue.FromDateTime( + new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Utc).AddTicks(1_234_567) + ), + MetadataValue.FromDateTimeOffset( + new DateTimeOffset(2026, 7, 26, 13, 45, 30, TimeSpan.FromHours(2)).AddTicks(1_234_567) + ), +#if !TESTING_NETSTANDARD_ASSET + MetadataValue.FromDateOnly(new DateOnly(2026, 7, 26)), + MetadataValue.FromTimeOnly(new TimeOnly(13, 45, 30, 123)), +#endif + MetadataValue.FromTimeSpan(TimeSpan.MaxValue), + MetadataValue.FromGuid(new Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890")), + MetadataValue.FromUri(new Uri("https://example.com/a/path/longer/than/thirty-six/characters")) + }; + + foreach (var value in values) + { + var canonicalAllocations = MeasureMinimumCanonicalFormattingAllocations(value); + var writerAllocations = MeasureMinimumWriterAllocations(value); + + writerAllocations.Should().BeLessThanOrEqualTo( + canonicalAllocations, + "writing {0} should materialize at most its existing canonical string", + value.Kind + ); + } + } + + private static void AssertNameRejectedBeforeWriting(string attributeName, string expectedMessage) + { + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + writer.WriteStartObject(); + var bytesPending = writer.BytesPending; + + var act = () => writer.WriteCloudEventsExtensionAttribute( + attributeName, + MetadataValue.FromInt64(1) + ); + + act.Should().Throw().WithMessage(expectedMessage); + writer.BytesPending.Should().Be(bytesPending); + } + + private static JsonDocument WriteAttribute(MetadataValue value) + { + using var stream = new MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + writer.WriteCloudEventsExtensionAttribute("attribute", value); + writer.WriteEndObject(); + writer.Flush(); + } + + return JsonDocument.Parse(stream.ToArray()); + } + + // A persistent per-write allocation appears in every sample; taking the minimum filters out + // occasional runtime or test-host allocations recorded on the current thread. + private static long MeasureMinimumCanonicalFormattingAllocations(MetadataValue value) + { + var minimumAllocations = long.MaxValue; + for (var sample = 0; sample < AllocationSampleCount; sample++) + { + minimumAllocations = Math.Min( + minimumAllocations, + MeasureCanonicalFormattingAllocations(value) + ); + } + + return minimumAllocations; + } + + private static long MeasureMinimumWriterAllocations(MetadataValue value) + { + var minimumAllocations = long.MaxValue; + for (var sample = 0; sample < AllocationSampleCount; sample++) + { + minimumAllocations = Math.Min(minimumAllocations, MeasureWriterAllocations(value)); + } + + return minimumAllocations; + } + + private static long MeasureCanonicalFormattingAllocations(MetadataValue value) + { + Span destination = stackalloc char[36]; + for (var index = 0; index < 100; index++) + { + value.TryFormatCanonical(destination, out _); + } + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var index = 0; index < 1_000; index++) + { + value.TryFormatCanonical(destination, out _); + } + + return GC.GetAllocatedBytesForCurrentThread() - before; + } + + private static long MeasureWriterAllocations(MetadataValue value) + { + var output = new ArrayBufferWriter(1024 * 1024); + using var writer = new Utf8JsonWriter(output); + writer.WriteStartObject(); + for (var index = 0; index < 100; index++) + { + writer.WriteCloudEventsExtensionAttribute("attribute", value); + } + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var index = 0; index < 1_000; index++) + { + writer.WriteCloudEventsExtensionAttribute("attribute", value); + } + + return GC.GetAllocatedBytesForCurrentThread() - before; + } +} diff --git a/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs b/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs index 64d26268..6a4bcb42 100644 --- a/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs +++ b/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs @@ -216,6 +216,13 @@ public void DateTimeAccessorShouldRejectTextCarryingANumericOffset() [Fact] public void StringAccessorsShouldAcceptOnlyCanonicalEncodings() { + MetadataValue + .FromString(long.MinValue.ToString()) + .TryGetInt64(out var int64) + .Should() + .BeTrue(); + int64.Should().Be(long.MinValue); + MetadataValue.FromString(ulong.MaxValue.ToString()) .TryGetUInt64(out var uint64) .Should() @@ -271,6 +278,21 @@ public void StringAccessorsShouldAcceptOnlyCanonicalEncodings() MetadataValue.FromString("hello world").TryGetUri(out _).Should().BeFalse(); } + [Theory] + [InlineData("+5")] + [InlineData("-0")] + [InlineData("01234")] + [InlineData(" 5")] + [InlineData("9223372036854775808")] + [InlineData("-9223372036854775809")] + public void Int64AccessorShouldRejectNoncanonicalOrOutOfRangeText(string text) + { + var value = MetadataValue.FromString(text); + + value.TryGetInt64(out var result).Should().BeFalse(); + result.Should().Be(0); + } + [Fact] public void EveryKindShouldSerializeWithItsVocabularyEncoding() {