From 00cdcc177ac3e24ab9343306f956b51fa4a08585 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Mon, 27 Jul 2026 07:42:42 +0200 Subject: [PATCH 01/15] chore: add initial plan which will be replaced Signed-off-by: Kenny Pflug --- ai-plans/0054-metadata-format.md | 140 +++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 ai-plans/0054-metadata-format.md diff --git a/ai-plans/0054-metadata-format.md b/ai-plans/0054-metadata-format.md new file mode 100644 index 0000000..af61e2e --- /dev/null +++ b/ai-plans/0054-metadata-format.md @@ -0,0 +1,140 @@ +# Metadata Format Discriminator + +## Rationale + +`MetadataKind` is a JSON-shaped discriminator with six members. Every .NET type that is not one of them is flattened into `String` by the `IFormattable` fallback in `BuiltInValidationErrorDefinitions.CreateMetadataValue`, and the resulting text is not interoperable: a `DateTime` boundary emits `07/26/2026 13:45:30` rather than RFC 3339, a `TimeSpan` emits `00:00:05` rather than an ISO 8601 duration, and a `TimeOnly` emits `13:45` — silently dropping seconds. These values appear in `problem+json` bodies produced by comparison and range rules, so the published contract disagrees with the payload in the same way `0052` documented for decimals, and a non-.NET client cannot parse them at all. + +The type information is also unavailable in-process. Generic caller code — a reducer, a log enricher, a mapper onto another metadata system — can only switch on `MetadataKind` and cannot tell a UUID from an arbitrary string. This plan introduces `MetadataFormat`, an orthogonal refinement aligned with the OpenAPI Format Registry, plus an opt-in wire envelope that makes metadata round-trippable across technology stacks. + +## Acceptance Criteria + +- [ ] `MetadataValue` exposes a `MetadataFormat` property, and `Unsafe.SizeOf()` remains `24`. +- [ ] `MetadataKind.Decimal` is removed; `Array` and `Object` return to `5` and `6`, and every member is asserted to be classified correctly as primitive or complex by a test enumerating the enum. +- [ ] A `decimal` metadata value has `Kind == MetadataKind.Double` and `Format == MetadataFormat.Decimal`, and still serializes into JSON bodies as an unquoted number preserving all significant digits and the original scale. +- [ ] The `problem+json` conformance test introduced by `0052` for decimal comparison and range rules still passes unchanged. +- [ ] `MetadataValue` accepts `byte`, `sbyte`, `short`, `ushort`, `int`, `uint`, `ulong`, `float`, `char`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`, `TimeSpan`, `Guid`, `Uri`, and `byte[]`, each carrying the format listed in the vocabulary table. +- [ ] Date, time, duration, UUID, URI, and binary values serialize using their registered canonical encodings; in particular `TimeOnly` preserves seconds and `TimeSpan` emits an ISO 8601 duration. +- [ ] A `float` metadata value serializes using its shortest round-trippable representation, so `0.1f` emits `0.1` rather than `0.10000000149011612`. +- [ ] Each `MetadataFormat` member maps to its OpenAPI Format Registry name, asserted by a test covering every declared member. +- [ ] Generated OpenAPI schemas carry the matching `format` keyword for metadata values whose format is known at documentation time. +- [ ] Illegal kind/format pairings cannot be constructed through the public API. +- [ ] Round-trip envelopes are **off by default**: with the default options the serialized bytes for every metadata value that existed before this plan are unchanged, apart from the encoding fixes above. +- [ ] With round-tripping enabled, a value is enveloped only when reading the bare JSON token back would not reproduce its `(Kind, Format)` pair; `null`, booleans, unformatted strings, arrays, and objects are never enveloped. +- [ ] With round-tripping enabled, every `(Kind, Format)` pair survives a write/read cycle intact, asserted by a matrix test over all pairs. +- [ ] The reader never infers a format from an unenveloped token, and never interprets an envelope unless round-tripping is enabled. +- [ ] Test code coverage stays above 95%. + +## Technical Details + +### Layout + +`MetadataFormat` is a `byte` enum stored in the padding that already exists between `MetadataValue.Kind` (1 byte) and `MetadataValue.Annotation` (a 4-byte `int` enum). The struct stays at 24 bytes, so `MetadataArrayData`'s inline `MetadataValue[]` does not grow. `None = 0` keeps `default(MetadataValue)` a `(Null, None)` value. + +### The kind/format contract + +`MetadataKind` selects the JSON shape and therefore the writer arm. `MetadataFormat` refines the meaning of a value **within** that shape. The general rule is that a format must not change the lexical form the writer produces, which is what allows formats to be added later without touching any dispatch site. + +`MetadataFormat.Decimal` is the one deliberate exception. A `decimal` is stored boxed in `MetadataPayload.Reference` under `MetadataKind.Double`, so for that kind alone the live payload slot is determined by `(Kind, Format)` rather than by `Kind`. Two consequences must be handled explicitly, because neither produces a compiler diagnostic: + +- Any site that reads `_payload.Float64` for `Kind.Double` without checking `Format` reads the zeroed overlapping slot and silently yields `0.0`. +- Any writer arm that calls the `double` overload for `Kind.Double` truncates the value and loses scale, regressing `0052`. + +To keep that rule in one place rather than repeated across dispatch sites, `MetadataValue` exposes the numeric shape and the writers branch on it instead of re-deriving the condition: + +```csharp +public enum MetadataNumberShape : byte { Int64, Double, Decimal } + +public MetadataNumberShape GetNumberShape(); +``` + +`TryGetDouble` returns a lossy `(double)` conversion for a decimal-formatted value rather than `false`, so numeric consumers keep working; `TryGetDecimal` returns the exact value. + +### Format vocabulary + +Names come from the OpenAPI Format Registry (`spec.openapis.org/registry/format/`), which layers on the JSON Schema `format` keyword. Nothing is invented, and the vocabulary is language-neutral. Note that `format` is annotation-only in JSON Schema unless assertion is explicitly enabled, so adopting it commits the library to no validation semantics. + +| .NET type | Kind | Format | Registry name | Encoding | +| --- | --- | --- | --- | --- | +| `sbyte` / `short` / `int` | `Int64` | `Int8` / `Int16` / `Int32` | `int8` / `int16` / `int32` | JSON number | +| `byte` / `ushort` / `uint` | `Int64` | `UInt8` / `UInt16` / `UInt32` | `uint8` / `uint16` / `uint32` | JSON number | +| `ulong` | `String` | `UInt64` | `uint64` | decimal text (registered as number-or-string; string avoids the `long` overflow that the current `FromString` fallback already works around) | +| `float` | `Double` | `Float` | `float` | shortest round-trippable text, normalised at construction | +| `decimal` | `Double` | `Decimal` | `decimal` | JSON number, scale preserved | +| `DateTimeOffset` / `DateTime` | `String` | `DateTime` | `date-time` | RFC 3339 | +| `DateOnly` | `String` | `Date` | `date` | RFC 3339 full-date | +| `TimeOnly` | `String` | `Time` | `time` | RFC 3339 full-time | +| `TimeSpan` | `String` | `Duration` | `duration` | RFC 3339 duration | +| `Guid` | `String` | `Uuid` | `uuid` | RFC 4122, lowercase canonical | +| `Uri` | `String` | `Uri` / `UriReference` | `uri` / `uri-reference` | RFC 3986 | +| `byte[]` | `String` | `Byte` | `byte` | base64, RFC 4648 | +| `char` | `String` | `Char` | `char` | single character | + +`decimal128` is **not** used: it denotes IEEE 754-2008 decimal128 with 34 significant digits, whereas `System.Decimal` has a 96-bit mantissa and a 0–28 scale. Labelling it `decimal128` would mislead exactly the cross-stack consumers this feature serves. + +A `DateTime` is normalised to UTC on construction. A `Local` value is meaningless once it leaves the process, and `DateTimeOffset` is the type to reach for when the offset matters. + +Every string-shaped format stores its already-canonical text in `Reference`, so `Reference is string` holds for all `Kind.String` values and no existing site changes. Packing ticks inline is a later optimisation and is out of scope; the current code already allocates a string for these types. + +Formats are reachable only through the typed factory methods and conversion operators, so pairs such as `(Int64, Uuid)` are unconstructible. Any general-purpose factory must validate the pairing. + +### Equality + +`Format` participates in `Equals` and `GetHashCode`. Excluding it would make values that serialize differently under the envelope compare equal, and the decimal arm has to branch on it regardless. This is a behaviour change: `FromInt32(5)` and `FromInt64(5)` are no longer equal, and a UUID-formatted string is no longer equal to the identical plain string. + +### Round-trip envelope + +Disabled by default, enabled through the existing write and read options (following the precedent of `ValidationProblemSerializationFormat` and `HeaderValueParsingMode`). When enabled, a value is wrapped only if the reader's default inference would not reproduce its `(Kind, Format)` pair: + +```json +{ + "trace-id": { "format": "uuid", "value": "dd6a721c-7438-4755-bf60-1960fae12dcd" }, + "scopes": ["profile", "email"], + "price": { "format": "decimal", "value": 19.50 } +} +``` + +`null`, booleans, unformatted strings, arrays, and objects are never wrapped, because their token already determines the pair. Integral numbers read back as `Int64` and fractional numbers as `Double`, so only numeric formats that deviate from those defaults are wrapped. Array elements are wrapped individually. The discriminator key is `format`, not `type`: RFC 9457 reserves `type` for the problem-type URI at the top level of the same document, and reusing it with different semantics one level down is ambiguous. + +The envelope is deliberately not the default. The generated OpenAPI document already carries `format` out of band for HTTP consumers, and wrapping would replace precise per-member schemas with `{ format: string, value: any }` — degrading the artifact the OpenAPI packages exist to produce — while multiplying payload size. Self-description earns its keep where no schema travels with the message: asynchronous messaging, CloudEvents `data`, and cross-runtime boundaries. + +### Read side + +The reader never infers a format from a bare token. Sniffing would make the resulting format depend on the content of the value, which `0052` rejected for numeric tokens, and would misclassify strings that merely look like timestamps. The envelope is the explicit override of that rule rather than a parallel mechanism. + +Envelope recognition is gated on the opt-in read mode. Without the gate, a legitimate `MetadataObject` carrying `format` and `value` members would be silently reinterpreted as an envelope. + +An unrecognised format name reads as the underlying kind with `Format.None` rather than failing, so a consumer on an older version of the library stays forward-compatible. + +### Affected components + +Adding `MetadataFormat` requires no change at the sites that dispatch on `MetadataKind`, with the exception of the number arms described above. Removing `MetadataKind.Decimal` does change all of them, and — as `0052` recorded — none of these fail to compile: + +| Site | Change | +| --- | --- | +| `SharedJsonSerialization/Writing/MetadataExtensions.WriteMetadataValue` | drop the `Decimal` arm; the `Double` arm branches on `GetNumberShape()` | +| `MetadataValue.Equals` / `GetHashCode` / `ToString` | same, plus folding `Format` into equality and hashing | +| `CloudEvents/MetadataValueAnnotationHelper.WithAnnotation` | drop the `Decimal` arm | +| `CloudEvents/Writing/CloudEventsResultExtensions.GetStringAttribute` | the explicit `Kind == Decimal` check at line 611 becomes a format check | +| `Http/Reading/Json` and `SharedJsonSerialization/Reading` readers | envelope handling behind the opt-in mode | +| `BuiltInValidationErrorDefinitions.CreateMetadataValue` | route the new type codes to the typed factories instead of the `IFormattable` fallback | +| `PortableOpenApiSchemaTypeMapper` and the schema builders | emit `format` | + +### Breaking changes + +Pre-1.0, but silent at compile time for downstream callers and to be listed in the release notes: + +- `MetadataKind.Decimal` no longer exists; decimals report `MetadataKind.Double`. +- `MetadataKind.Array` and `MetadataKind.Object` change from `200`/`201` back to `5`/`6`, reverting the renumbering from `0052`, which has not shipped. +- Values differing only in `Format` no longer compare equal. +- Date, time, duration, and `TimeOnly` values change their serialized text; `TimeOnly` stops losing seconds. +- `float` values change their serialized text. + +The reserved primitive range documented on `MetadataKind` is removed. Its stated justification — that CloudEvents `Binary`, `URI`, `URI-reference`, and `Timestamp` would become kinds — no longer holds, since all four are formats under this design. + +### Sequencing + +Issue #53 (CloudEvents extension attributes written with JSON types outside the CloudEvents type system) should land after this plan: the CloudEvents type system maps onto a subset of this vocabulary — `Timestamp` to `date-time`, `Binary` to `byte`, `URI` to `uri`, `URI-reference` to `uri-reference` — so the mapping becomes a lookup rather than a second classification. Issue #51 (HTTP header value formatting) likewise gains the `sf-*` structured-field formats as its natural vocabulary. + +### Out of scope + +Packing date and time formats inline in the `Int64` slot. The visitor API that would turn the dispatch sites above into compile errors and give generic in-process callers a push-based accessor; that is a follow-up plan. From 00bb57fba63f9148d7df5efbe2f831fb9eb44982 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Mon, 27 Jul 2026 08:41:48 +0200 Subject: [PATCH 02/15] docs: add initial plan for MetadataKind Restructuring Signed-off-by: Kenny Pflug --- ai-plans/0054-metadata-format.md | 140 ------------------------ ai-plans/0054-metadata-restructuring.md | 115 +++++++++++++++++++ 2 files changed, 115 insertions(+), 140 deletions(-) delete mode 100644 ai-plans/0054-metadata-format.md create mode 100644 ai-plans/0054-metadata-restructuring.md diff --git a/ai-plans/0054-metadata-format.md b/ai-plans/0054-metadata-format.md deleted file mode 100644 index af61e2e..0000000 --- a/ai-plans/0054-metadata-format.md +++ /dev/null @@ -1,140 +0,0 @@ -# Metadata Format Discriminator - -## Rationale - -`MetadataKind` is a JSON-shaped discriminator with six members. Every .NET type that is not one of them is flattened into `String` by the `IFormattable` fallback in `BuiltInValidationErrorDefinitions.CreateMetadataValue`, and the resulting text is not interoperable: a `DateTime` boundary emits `07/26/2026 13:45:30` rather than RFC 3339, a `TimeSpan` emits `00:00:05` rather than an ISO 8601 duration, and a `TimeOnly` emits `13:45` — silently dropping seconds. These values appear in `problem+json` bodies produced by comparison and range rules, so the published contract disagrees with the payload in the same way `0052` documented for decimals, and a non-.NET client cannot parse them at all. - -The type information is also unavailable in-process. Generic caller code — a reducer, a log enricher, a mapper onto another metadata system — can only switch on `MetadataKind` and cannot tell a UUID from an arbitrary string. This plan introduces `MetadataFormat`, an orthogonal refinement aligned with the OpenAPI Format Registry, plus an opt-in wire envelope that makes metadata round-trippable across technology stacks. - -## Acceptance Criteria - -- [ ] `MetadataValue` exposes a `MetadataFormat` property, and `Unsafe.SizeOf()` remains `24`. -- [ ] `MetadataKind.Decimal` is removed; `Array` and `Object` return to `5` and `6`, and every member is asserted to be classified correctly as primitive or complex by a test enumerating the enum. -- [ ] A `decimal` metadata value has `Kind == MetadataKind.Double` and `Format == MetadataFormat.Decimal`, and still serializes into JSON bodies as an unquoted number preserving all significant digits and the original scale. -- [ ] The `problem+json` conformance test introduced by `0052` for decimal comparison and range rules still passes unchanged. -- [ ] `MetadataValue` accepts `byte`, `sbyte`, `short`, `ushort`, `int`, `uint`, `ulong`, `float`, `char`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`, `TimeSpan`, `Guid`, `Uri`, and `byte[]`, each carrying the format listed in the vocabulary table. -- [ ] Date, time, duration, UUID, URI, and binary values serialize using their registered canonical encodings; in particular `TimeOnly` preserves seconds and `TimeSpan` emits an ISO 8601 duration. -- [ ] A `float` metadata value serializes using its shortest round-trippable representation, so `0.1f` emits `0.1` rather than `0.10000000149011612`. -- [ ] Each `MetadataFormat` member maps to its OpenAPI Format Registry name, asserted by a test covering every declared member. -- [ ] Generated OpenAPI schemas carry the matching `format` keyword for metadata values whose format is known at documentation time. -- [ ] Illegal kind/format pairings cannot be constructed through the public API. -- [ ] Round-trip envelopes are **off by default**: with the default options the serialized bytes for every metadata value that existed before this plan are unchanged, apart from the encoding fixes above. -- [ ] With round-tripping enabled, a value is enveloped only when reading the bare JSON token back would not reproduce its `(Kind, Format)` pair; `null`, booleans, unformatted strings, arrays, and objects are never enveloped. -- [ ] With round-tripping enabled, every `(Kind, Format)` pair survives a write/read cycle intact, asserted by a matrix test over all pairs. -- [ ] The reader never infers a format from an unenveloped token, and never interprets an envelope unless round-tripping is enabled. -- [ ] Test code coverage stays above 95%. - -## Technical Details - -### Layout - -`MetadataFormat` is a `byte` enum stored in the padding that already exists between `MetadataValue.Kind` (1 byte) and `MetadataValue.Annotation` (a 4-byte `int` enum). The struct stays at 24 bytes, so `MetadataArrayData`'s inline `MetadataValue[]` does not grow. `None = 0` keeps `default(MetadataValue)` a `(Null, None)` value. - -### The kind/format contract - -`MetadataKind` selects the JSON shape and therefore the writer arm. `MetadataFormat` refines the meaning of a value **within** that shape. The general rule is that a format must not change the lexical form the writer produces, which is what allows formats to be added later without touching any dispatch site. - -`MetadataFormat.Decimal` is the one deliberate exception. A `decimal` is stored boxed in `MetadataPayload.Reference` under `MetadataKind.Double`, so for that kind alone the live payload slot is determined by `(Kind, Format)` rather than by `Kind`. Two consequences must be handled explicitly, because neither produces a compiler diagnostic: - -- Any site that reads `_payload.Float64` for `Kind.Double` without checking `Format` reads the zeroed overlapping slot and silently yields `0.0`. -- Any writer arm that calls the `double` overload for `Kind.Double` truncates the value and loses scale, regressing `0052`. - -To keep that rule in one place rather than repeated across dispatch sites, `MetadataValue` exposes the numeric shape and the writers branch on it instead of re-deriving the condition: - -```csharp -public enum MetadataNumberShape : byte { Int64, Double, Decimal } - -public MetadataNumberShape GetNumberShape(); -``` - -`TryGetDouble` returns a lossy `(double)` conversion for a decimal-formatted value rather than `false`, so numeric consumers keep working; `TryGetDecimal` returns the exact value. - -### Format vocabulary - -Names come from the OpenAPI Format Registry (`spec.openapis.org/registry/format/`), which layers on the JSON Schema `format` keyword. Nothing is invented, and the vocabulary is language-neutral. Note that `format` is annotation-only in JSON Schema unless assertion is explicitly enabled, so adopting it commits the library to no validation semantics. - -| .NET type | Kind | Format | Registry name | Encoding | -| --- | --- | --- | --- | --- | -| `sbyte` / `short` / `int` | `Int64` | `Int8` / `Int16` / `Int32` | `int8` / `int16` / `int32` | JSON number | -| `byte` / `ushort` / `uint` | `Int64` | `UInt8` / `UInt16` / `UInt32` | `uint8` / `uint16` / `uint32` | JSON number | -| `ulong` | `String` | `UInt64` | `uint64` | decimal text (registered as number-or-string; string avoids the `long` overflow that the current `FromString` fallback already works around) | -| `float` | `Double` | `Float` | `float` | shortest round-trippable text, normalised at construction | -| `decimal` | `Double` | `Decimal` | `decimal` | JSON number, scale preserved | -| `DateTimeOffset` / `DateTime` | `String` | `DateTime` | `date-time` | RFC 3339 | -| `DateOnly` | `String` | `Date` | `date` | RFC 3339 full-date | -| `TimeOnly` | `String` | `Time` | `time` | RFC 3339 full-time | -| `TimeSpan` | `String` | `Duration` | `duration` | RFC 3339 duration | -| `Guid` | `String` | `Uuid` | `uuid` | RFC 4122, lowercase canonical | -| `Uri` | `String` | `Uri` / `UriReference` | `uri` / `uri-reference` | RFC 3986 | -| `byte[]` | `String` | `Byte` | `byte` | base64, RFC 4648 | -| `char` | `String` | `Char` | `char` | single character | - -`decimal128` is **not** used: it denotes IEEE 754-2008 decimal128 with 34 significant digits, whereas `System.Decimal` has a 96-bit mantissa and a 0–28 scale. Labelling it `decimal128` would mislead exactly the cross-stack consumers this feature serves. - -A `DateTime` is normalised to UTC on construction. A `Local` value is meaningless once it leaves the process, and `DateTimeOffset` is the type to reach for when the offset matters. - -Every string-shaped format stores its already-canonical text in `Reference`, so `Reference is string` holds for all `Kind.String` values and no existing site changes. Packing ticks inline is a later optimisation and is out of scope; the current code already allocates a string for these types. - -Formats are reachable only through the typed factory methods and conversion operators, so pairs such as `(Int64, Uuid)` are unconstructible. Any general-purpose factory must validate the pairing. - -### Equality - -`Format` participates in `Equals` and `GetHashCode`. Excluding it would make values that serialize differently under the envelope compare equal, and the decimal arm has to branch on it regardless. This is a behaviour change: `FromInt32(5)` and `FromInt64(5)` are no longer equal, and a UUID-formatted string is no longer equal to the identical plain string. - -### Round-trip envelope - -Disabled by default, enabled through the existing write and read options (following the precedent of `ValidationProblemSerializationFormat` and `HeaderValueParsingMode`). When enabled, a value is wrapped only if the reader's default inference would not reproduce its `(Kind, Format)` pair: - -```json -{ - "trace-id": { "format": "uuid", "value": "dd6a721c-7438-4755-bf60-1960fae12dcd" }, - "scopes": ["profile", "email"], - "price": { "format": "decimal", "value": 19.50 } -} -``` - -`null`, booleans, unformatted strings, arrays, and objects are never wrapped, because their token already determines the pair. Integral numbers read back as `Int64` and fractional numbers as `Double`, so only numeric formats that deviate from those defaults are wrapped. Array elements are wrapped individually. The discriminator key is `format`, not `type`: RFC 9457 reserves `type` for the problem-type URI at the top level of the same document, and reusing it with different semantics one level down is ambiguous. - -The envelope is deliberately not the default. The generated OpenAPI document already carries `format` out of band for HTTP consumers, and wrapping would replace precise per-member schemas with `{ format: string, value: any }` — degrading the artifact the OpenAPI packages exist to produce — while multiplying payload size. Self-description earns its keep where no schema travels with the message: asynchronous messaging, CloudEvents `data`, and cross-runtime boundaries. - -### Read side - -The reader never infers a format from a bare token. Sniffing would make the resulting format depend on the content of the value, which `0052` rejected for numeric tokens, and would misclassify strings that merely look like timestamps. The envelope is the explicit override of that rule rather than a parallel mechanism. - -Envelope recognition is gated on the opt-in read mode. Without the gate, a legitimate `MetadataObject` carrying `format` and `value` members would be silently reinterpreted as an envelope. - -An unrecognised format name reads as the underlying kind with `Format.None` rather than failing, so a consumer on an older version of the library stays forward-compatible. - -### Affected components - -Adding `MetadataFormat` requires no change at the sites that dispatch on `MetadataKind`, with the exception of the number arms described above. Removing `MetadataKind.Decimal` does change all of them, and — as `0052` recorded — none of these fail to compile: - -| Site | Change | -| --- | --- | -| `SharedJsonSerialization/Writing/MetadataExtensions.WriteMetadataValue` | drop the `Decimal` arm; the `Double` arm branches on `GetNumberShape()` | -| `MetadataValue.Equals` / `GetHashCode` / `ToString` | same, plus folding `Format` into equality and hashing | -| `CloudEvents/MetadataValueAnnotationHelper.WithAnnotation` | drop the `Decimal` arm | -| `CloudEvents/Writing/CloudEventsResultExtensions.GetStringAttribute` | the explicit `Kind == Decimal` check at line 611 becomes a format check | -| `Http/Reading/Json` and `SharedJsonSerialization/Reading` readers | envelope handling behind the opt-in mode | -| `BuiltInValidationErrorDefinitions.CreateMetadataValue` | route the new type codes to the typed factories instead of the `IFormattable` fallback | -| `PortableOpenApiSchemaTypeMapper` and the schema builders | emit `format` | - -### Breaking changes - -Pre-1.0, but silent at compile time for downstream callers and to be listed in the release notes: - -- `MetadataKind.Decimal` no longer exists; decimals report `MetadataKind.Double`. -- `MetadataKind.Array` and `MetadataKind.Object` change from `200`/`201` back to `5`/`6`, reverting the renumbering from `0052`, which has not shipped. -- Values differing only in `Format` no longer compare equal. -- Date, time, duration, and `TimeOnly` values change their serialized text; `TimeOnly` stops losing seconds. -- `float` values change their serialized text. - -The reserved primitive range documented on `MetadataKind` is removed. Its stated justification — that CloudEvents `Binary`, `URI`, `URI-reference`, and `Timestamp` would become kinds — no longer holds, since all four are formats under this design. - -### Sequencing - -Issue #53 (CloudEvents extension attributes written with JSON types outside the CloudEvents type system) should land after this plan: the CloudEvents type system maps onto a subset of this vocabulary — `Timestamp` to `date-time`, `Binary` to `byte`, `URI` to `uri`, `URI-reference` to `uri-reference` — so the mapping becomes a lookup rather than a second classification. Issue #51 (HTTP header value formatting) likewise gains the `sf-*` structured-field formats as its natural vocabulary. - -### Out of scope - -Packing date and time formats inline in the `Int64` slot. The visitor API that would turn the dispatch sites above into compile errors and give generic in-process callers a push-based accessor; that is a follow-up plan. diff --git a/ai-plans/0054-metadata-restructuring.md b/ai-plans/0054-metadata-restructuring.md new file mode 100644 index 0000000..468b295 --- /dev/null +++ b/ai-plans/0054-metadata-restructuring.md @@ -0,0 +1,115 @@ +# Typed Metadata Kinds + +## Rationale + +`MetadataKind` covers only the JSON-shaped types. Every other .NET value reaches metadata through the `IFormattable` fallback in `BuiltInValidationErrorDefinitions.CreateMetadataValue`, producing non-interoperable text: a `DateTime` boundary emits `07/26/2026 13:45:30` instead of RFC 3339, a `TimeSpan` emits `00:00:05` instead of an ISO 8601 duration, and a `TimeOnly` silently drops seconds. These values appear in `problem+json` bodies, so the published OpenAPI contract disagrees with the runtime payload. In-process consumers are equally underserved: a `Guid` or `DateTimeOffset` stored as text costs an allocation on write and a parse on every read, in a library whose primary claim is low allocation. + +This plan extends the primitive range that `0052` reserved with kinds for the common scalar BCL types, stored natively. It restructures the dispatch sites so that adding a kind is compiler-checked instead of silently corrupting data, and adds an opt-in JSON envelope that makes every kind round-trippable. It supersedes the withdrawn `MetadataFormat` design: one discriminator, no second axis. + +## Acceptance Criteria + +- [ ] `MetadataKind` declares `UInt64`, `Float32`, `Char`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`, `TimeSpan`, `Guid`, `Uri`, and `Bytes` as values 6–16; `Array` and `Object` keep 200 and 201; the existing test enumerating the enum asserts every member's primitive/complex classification. +- [ ] `Unsafe.SizeOf()` remains 24. +- [ ] Each new kind has a factory method, an implicit conversion where the BCL type allows it, and a typed `TryGet*` accessor that returns the exact stored value without text parsing when the kind matches. +- [ ] Each `TryGet*` accessor additionally converts from `MetadataKind.String` when the string holds the kind's canonical encoding, so consumers work identically on in-process and wire-degraded values. +- [ ] Every kind serializes into JSON bodies using the canonical encoding in the vocabulary table; in particular `TimeOnly` preserves seconds, `TimeSpan` emits an ISO 8601 duration, and `0.1f` emits `0.1`. +- [ ] A whole-number `Double` or `Float32` value emits a trailing `.0` (`5.0`, not `5`), so a bare `Double` JSON token always reads back as `MetadataKind.Double` without an envelope. +- [ ] `MetadataValue` exposes its JSON shape (`Null`, `Boolean`, `Number`, `String`, `Array`, `Object`) through a public API, and one shared canonical-text formatter backs JSON string values, HTTP header values, and CloudEvents attribute strings. +- [ ] The default HTTP header conversion emits unquoted canonical text for all primitive kinds, including plain strings. +- [ ] A metadata value of any primitive kind resolves correctly as a CloudEvents core string attribute; the special-cased decimal branch in `GetStringAttribute` is replaced by the shared formatter. +- [ ] `MetadataValueAnnotationHelper.WithAnnotation` rewrites primitive values without per-kind dispatch via a payload-preserving constructor; annotation constraints for arrays and objects are still enforced. +- [ ] Every `switch` over `MetadataKind` inside `MetadataValue` lists all members without a discard arm, so declaring a new member fails the Release build (CS8509 with `TreatWarningsAsErrors`) until every switch is updated. +- [ ] Equality and hashing cover all kinds: values of different kinds are never equal, boxed kinds compare by value, `Annotation` stays excluded, and a test stores every kind in a `MetadataObject` and reads it back by key. +- [ ] With default options, serialized output for all previously existing kinds is byte-identical to today, apart from the trailing-`.0` change for whole-number doubles; the envelope is opt-in on both the write and the read side, and a reader never interprets an envelope unless enabled. +- [ ] With the envelope enabled, a write/read cycle reproduces the kind and value of every metadata value, asserted by a matrix test over all kinds; the documented exception is `MetadataKind.DateTime`, which reads back as `DateTimeOffset` with the same instant. +- [ ] `CreateMetadataValue` routes `ulong`, `float`, `char`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`, `TimeSpan`, `Guid`, `Uri`, and `byte[]` to the typed factories; the `problem+json` OpenAPI conformance test from `0052` passes unchanged, and an equivalent test covers a `DateTime` or `TimeSpan` boundary. +- [ ] Generated OpenAPI schemas and examples match the runtime encodings: `TimeSpan` maps to `format: duration`, and example values for the new kinds render as their canonical text. +- [ ] Validation error message text formats these types with the same canonical encodings as the metadata. +- [ ] Test code coverage stays above 95%. + +## Technical Details + +### One discriminator + +`MetadataKind` remains the single discriminator: it determines the live payload slot, the JSON shape, and the canonical encoding. The `0052` layout is kept as merged — `Decimal = 5`, reserved range 6–199, `Array = 200`, `Object = 201`, `IsPrimitive` as a single comparison. The new kinds fill the reserved range in declaration order starting at 6. Semantic tags on strings (a "uuid-formatted string") are deliberately not expressible: parse at the boundary into the typed kind instead. + +### Vocabulary + +| Kind | .NET type | Storage | JSON encoding | Envelope name | +| --- | --- | --- | --- | --- | +| `UInt64` = 6 | `ulong` | inline | decimal digits as JSON **string** | `uint64` | +| `Float32` = 7 | `float` | inline, normalized `double` | shortest round-trippable number | `float` | +| `Char` = 8 | `char` | inline | single-character string | `char` | +| `DateTime` = 9 | `DateTime` | inline UTC ticks | RFC 3339 date-time, `Z` suffix | `date-time` | +| `DateTimeOffset` = 10 | `DateTimeOffset` | boxed | RFC 3339 date-time with offset | `date-time` | +| `DateOnly` = 11 | `DateOnly` | inline day number | RFC 3339 full-date | `date` | +| `TimeOnly` = 12 | `TimeOnly` | inline ticks | RFC 3339 full-time | `time` | +| `TimeSpan` = 13 | `TimeSpan` | inline ticks | ISO 8601 duration | `duration` | +| `Guid` = 14 | `Guid` | boxed | RFC 4122 lowercase string | `uuid` | +| `Uri` = 15 | `Uri` | reference | `OriginalString` | `uri` / `uri-reference` by `IsAbsoluteUri` | +| `Bytes` = 16 | `byte[]` | reference, no defensive copy | base64 string (RFC 4648) | `byte` | + +Envelope names come from the OpenAPI Format Registry. Notes: + +- `ulong` is written as a JSON string because values above `long.MaxValue` are silently corrupted by common JSON consumers. +- `Float32` stores `double.Parse(value.ToString("R", InvariantCulture))` at construction, so the shortest representation of the stored double equals the float's; a plain `(double)` cast would emit `0.10000000149011612`. +- `FromDateTime` converts `Local` to UTC and throws for `DateTimeKind.Unspecified`. +- `Bytes` documents that the array is held by reference; mutation after construction is the caller's responsibility. +- `DateTimeOffset` boxes like `Decimal` (see `MetadataValue.FromDecimal` for the rationale); packing its offset into the struct's 3 spare padding bytes is a possible later optimization, out of scope here. +- The existing `Double` kind gains a canonical rule shared with `Float32`: when the shortest round-trippable text contains neither a fraction nor an exponent, `.0` is appended (via an integral check and `WriteRawValue`). A bare `Double` token consequently never reads back as `Int64`, which removes `Double` from the envelope entirely. `Decimal` is deliberately excluded from this rule: `5m` has scale 0 and must keep emitting `5`. + +The core project multi-targets `netstandard2.0;net10.0`. All kinds, wire behavior, and equality are identical on both targets; only the `DateOnly`/`TimeOnly` factory and accessor signatures are `net10.0`-only, because Polyfill's types are internal and cannot appear in public APIs. On `netstandard2.0`, envelope reads of `date`/`time` still produce the correct kinds from the inline payload. + +### Dispatch safety + +`0052` documented that adding a kind breaks nothing at compile time and corrupts silently. This plan removes that hazard structurally: + +- **Derived shape:** `GetJsonShape()` is backed by a 256-entry `ReadOnlySpan` lookup (a byte index is bounds-check-free). The JSON writers switch on the six shapes; the `Number` arm dispatches over `Int64`/`Double`/`Float32`/`Decimal` only. +- **One formatter:** a single canonical-text method (with a `TryFormat`-style span overload) is the only place that knows how string-shaped kinds render. JSON string values, `DefaultHttpHeaderConversionService`'s fallback (currently `ToString()`, which wrongly quotes strings), and `GetStringAttribute` all call it. Adding a kind touches this one site instead of eight. `ToString()` becomes debug-only output. +- **Payload-preserving constructor:** `WithAnnotation`'s per-kind switch exists only because no constructor copies an existing payload. An internal `MetadataValue(Kind, payload, annotation)` path reduces the primitive case to one line; `Array`/`Object` still recurse to rewrite children and revalidate annotation constraints. +- **Exhaustive switches:** the remaining kind switches (`Equals`, `GetHashCode`, `ToString`, the formatter) become switch expressions listing every member with no discard arm. + +### Accessors and equality + +Exact-kind reads never parse. Lenient conversions mirror the existing `TryGetDecimal` precedent: every new `TryGet*` also accepts a `String` whose content is the canonical encoding, and `TryGetDouble` converts from `Float32`. This is the pressure valve for wire degradation: code calling `TryGetGuid` works whether the value arrived typed or as a bare string. + +Equality stays strict — kind plus payload, `Annotation` excluded. `FromGuid(g)` is not equal to `FromString(g.ToString())`; round-trip fidelity is the envelope's job, not `Equals`'. Boxed kinds unbox and compare by value with the same defensive `is` pattern as the `Decimal` arm. + +### Round-trip envelope + +Off by default; enabled through a setting on the existing write and read options (HTTP, CloudEvents, shared serialization), threaded to the readers/writers by turning the parameterless metadata converters into configured instances and adding an options parameter to the static `Read*`/`Write*` methods. The module-level default `JsonSerializerOptions` keep the envelope disabled; enabling requires building serializer options through the module with the setting. + +```json +{ "$format": "uuid", "$value": "dd6a721c-7438-4755-bf60-1960fae12dcd" } +``` + +- A value is wrapped only when reading its bare token back would not reproduce its kind. This is static per kind: `Null`, `Boolean`, `Int64`, `Double`, `String`, `Array`, and `Object` are never wrapped (the trailing-`.0` rule guarantees this for `Double`); every kind in the vocabulary table always is. +- Array elements wrap individually. The `$` prefix minimizes collisions with user data; an object is an envelope only when reading is enabled and it has exactly a `$format` string member and a `$value` member. A genuine two-member `MetadataObject` with these keys cannot round-trip while the envelope is enabled — a documented limitation. +- An unknown `$format` name reads as the bare `$value` token (forward compatibility); a known name whose value does not parse throws `JsonException`. +- `date-time` always reads as `DateTimeOffset`, which is why `MetadataKind.DateTime` round-trips with kind degradation but exact instant. +- The envelope never applies to CloudEvents extension attributes (the spec requires plain JSON types) or HTTP headers; those write paths always pass the disabled mode regardless of options. + +Bare tokens are never sniffed: without an envelope, the reader keeps producing exactly the base kinds it produces today. + +### Affected components + +| Site | Change | +| --- | --- | +| `MetadataValue`, `MetadataPayload` | new factories, accessors, equality/hash/ToString arms, shape API, payload-preserving constructor | +| `SharedJsonSerialization` writers/readers | shape-driven dispatch, formatter, envelope behind options | +| `Http` and `CloudEvents` converters and options | configured converter instances, envelope setting, header fallback via formatter | +| `CloudEventsResultExtensions.GetStringAttribute`, `MetadataValueAnnotationHelper` | formatter / payload-preserving constructor | +| `BuiltInValidationErrorDefinitions.CreateMetadataValue`, `ValidationErrorMessageFormatting` | typed routing, canonical message text | +| `PortableOpenApiSchemaTypeMapper`, `PortableResultsOpenApiDocumentTransformer`, source-gen emitter | `TimeSpan` → `duration`, canonical example values | + +### Breaking changes + +Pre-1.0, silent at compile time, to be listed in release notes: values previously flattened to strings by `CreateMetadataValue` now carry typed kinds, so `TryGetString` returns `false` for them and their serialized text changes (`DateTime`, `TimeSpan`, `TimeOnly`, `float`); whole-number doubles serialize as `5.0` instead of `5`; default header serialization of strings loses the surrounding quotes; the core package gains a `net10.0` target. The `0052` enum numbering is unchanged. + +### Sequencing + +Issues #51 (HTTP header formatting) and #53 (CloudEvents extension attribute types) land after this plan: both become lookups from `MetadataKind` (structured-field formats, CloudEvents `Timestamp`/`Binary`/`URI` types) instead of independent classifications. + +### Out of scope + +Packing `DateTimeOffset` into the padding bytes, a JSON-equivalence comparer for cross-serialization comparisons, additional scalar kinds (`Half`, `Int128`), and gRPC mappings. From 05c11efc87ae500093a97c5001315842335fea72 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Tue, 28 Jul 2026 07:18:58 +0200 Subject: [PATCH 03/15] docs: refine plan 0054 Signed-off-by: Kenny Pflug --- ai-plans/0054-metadata-restructuring.md | 38 ++++++++++++++++--------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/ai-plans/0054-metadata-restructuring.md b/ai-plans/0054-metadata-restructuring.md index 468b295..389fb79 100644 --- a/ai-plans/0054-metadata-restructuring.md +++ b/ai-plans/0054-metadata-restructuring.md @@ -4,16 +4,16 @@ `MetadataKind` covers only the JSON-shaped types. Every other .NET value reaches metadata through the `IFormattable` fallback in `BuiltInValidationErrorDefinitions.CreateMetadataValue`, producing non-interoperable text: a `DateTime` boundary emits `07/26/2026 13:45:30` instead of RFC 3339, a `TimeSpan` emits `00:00:05` instead of an ISO 8601 duration, and a `TimeOnly` silently drops seconds. These values appear in `problem+json` bodies, so the published OpenAPI contract disagrees with the runtime payload. In-process consumers are equally underserved: a `Guid` or `DateTimeOffset` stored as text costs an allocation on write and a parse on every read, in a library whose primary claim is low allocation. -This plan extends the primitive range that `0052` reserved with kinds for the common scalar BCL types, stored natively. It restructures the dispatch sites so that adding a kind is compiler-checked instead of silently corrupting data, and adds an opt-in JSON envelope that makes every kind round-trippable. It supersedes the withdrawn `MetadataFormat` design: one discriminator, no second axis. +This plan extends the primitive range that `0052` reserved with kinds for the common scalar BCL types, stored natively. It restructures the dispatch sites so that adding a kind is compiler-checked instead of silently corrupting data, and adds an opt-in JSON envelope that makes every kind round-trippable. ## Acceptance Criteria -- [ ] `MetadataKind` declares `UInt64`, `Float32`, `Char`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`, `TimeSpan`, `Guid`, `Uri`, and `Bytes` as values 6–16; `Array` and `Object` keep 200 and 201; the existing test enumerating the enum asserts every member's primitive/complex classification. +- [ ] `MetadataKind` declares `UInt64`, `Single`, `Char`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`, `TimeSpan`, `Guid`, and `Uri` as values 6–15; `Array` and `Object` keep 200 and 201; the existing test enumerating the enum asserts every member's primitive/complex classification. - [ ] `Unsafe.SizeOf()` remains 24. -- [ ] Each new kind has a factory method, an implicit conversion where the BCL type allows it, and a typed `TryGet*` accessor that returns the exact stored value without text parsing when the kind matches. +- [ ] Each new kind has a factory method, an implicit conversion where the BCL type allows it, and a typed `TryGet*` accessor that returns the exact stored value without text parsing when the kind matches. `ulong.MaxValue` survives construction, serialization, and reading without precision loss. - [ ] Each `TryGet*` accessor additionally converts from `MetadataKind.String` when the string holds the kind's canonical encoding, so consumers work identically on in-process and wire-degraded values. - [ ] Every kind serializes into JSON bodies using the canonical encoding in the vocabulary table; in particular `TimeOnly` preserves seconds, `TimeSpan` emits an ISO 8601 duration, and `0.1f` emits `0.1`. -- [ ] A whole-number `Double` or `Float32` value emits a trailing `.0` (`5.0`, not `5`), so a bare `Double` JSON token always reads back as `MetadataKind.Double` without an envelope. +- [ ] A whole-number `Double` or `Single` value emits a trailing `.0` (`5.0`, not `5`), so a bare `Double` JSON token always reads back as `MetadataKind.Double` without an envelope. - [ ] `MetadataValue` exposes its JSON shape (`Null`, `Boolean`, `Number`, `String`, `Array`, `Object`) through a public API, and one shared canonical-text formatter backs JSON string values, HTTP header values, and CloudEvents attribute strings. - [ ] The default HTTP header conversion emits unquoted canonical text for all primitive kinds, including plain strings. - [ ] A metadata value of any primitive kind resolves correctly as a CloudEvents core string attribute; the special-cased decimal branch in `GetStringAttribute` is replaced by the shared formatter. @@ -22,7 +22,7 @@ This plan extends the primitive range that `0052` reserved with kinds for the co - [ ] Equality and hashing cover all kinds: values of different kinds are never equal, boxed kinds compare by value, `Annotation` stays excluded, and a test stores every kind in a `MetadataObject` and reads it back by key. - [ ] With default options, serialized output for all previously existing kinds is byte-identical to today, apart from the trailing-`.0` change for whole-number doubles; the envelope is opt-in on both the write and the read side, and a reader never interprets an envelope unless enabled. - [ ] With the envelope enabled, a write/read cycle reproduces the kind and value of every metadata value, asserted by a matrix test over all kinds; the documented exception is `MetadataKind.DateTime`, which reads back as `DateTimeOffset` with the same instant. -- [ ] `CreateMetadataValue` routes `ulong`, `float`, `char`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`, `TimeSpan`, `Guid`, `Uri`, and `byte[]` to the typed factories; the `problem+json` OpenAPI conformance test from `0052` passes unchanged, and an equivalent test covers a `DateTime` or `TimeSpan` boundary. +- [ ] `CreateMetadataValue` routes `ulong`, `float`, `char`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`, `TimeSpan`, `Guid`, and `Uri` to the typed factories; the `problem+json` OpenAPI conformance test from `0052` passes unchanged, and an equivalent test covers a `DateTime` or `TimeSpan` boundary. - [ ] Generated OpenAPI schemas and examples match the runtime encodings: `TimeSpan` maps to `format: duration`, and example values for the new kinds render as their canonical text. - [ ] Validation error message text formats these types with the same canonical encodings as the metadata. - [ ] Test code coverage stays above 95%. @@ -33,12 +33,14 @@ This plan extends the primitive range that `0052` reserved with kinds for the co `MetadataKind` remains the single discriminator: it determines the live payload slot, the JSON shape, and the canonical encoding. The `0052` layout is kept as merged — `Decimal = 5`, reserved range 6–199, `Array = 200`, `Object = 201`, `IsPrimitive` as a single comparison. The new kinds fill the reserved range in declaration order starting at 6. Semantic tags on strings (a "uuid-formatted string") are deliberately not expressible: parse at the boundary into the typed kind instead. +Scalar members are named after their `System.*` type, which is the convention the existing members already follow (`Int64` over C#'s `long`). Hence `Single` rather than `Float32`, and `Double` is not renamed. Width-explicit naming is not an option for the whole family, because `0052` rejected `decimal128` as misleading for `System.Decimal`. Factory and accessor names follow the member (`FromSingle`, `TryGetSingle`), matching the `TypeCode.Single` / `TypeCode.Double` arms that `CreateMetadataValue` already dispatches on. + ### Vocabulary | Kind | .NET type | Storage | JSON encoding | Envelope name | | --- | --- | --- | --- | --- | | `UInt64` = 6 | `ulong` | inline | decimal digits as JSON **string** | `uint64` | -| `Float32` = 7 | `float` | inline, normalized `double` | shortest round-trippable number | `float` | +| `Single` = 7 | `float` | inline, normalized `double` | shortest round-trippable number | `float` | | `Char` = 8 | `char` | inline | single-character string | `char` | | `DateTime` = 9 | `DateTime` | inline UTC ticks | RFC 3339 date-time, `Z` suffix | `date-time` | | `DateTimeOffset` = 10 | `DateTimeOffset` | boxed | RFC 3339 date-time with offset | `date-time` | @@ -47,31 +49,39 @@ This plan extends the primitive range that `0052` reserved with kinds for the co | `TimeSpan` = 13 | `TimeSpan` | inline ticks | ISO 8601 duration | `duration` | | `Guid` = 14 | `Guid` | boxed | RFC 4122 lowercase string | `uuid` | | `Uri` = 15 | `Uri` | reference | `OriginalString` | `uri` / `uri-reference` by `IsAbsoluteUri` | -| `Bytes` = 16 | `byte[]` | reference, no defensive copy | base64 string (RFC 4648) | `byte` | Envelope names come from the OpenAPI Format Registry. Notes: - `ulong` is written as a JSON string because values above `long.MaxValue` are silently corrupted by common JSON consumers. -- `Float32` stores `double.Parse(value.ToString("R", InvariantCulture))` at construction, so the shortest representation of the stored double equals the float's; a plain `(double)` cast would emit `0.10000000149011612`. +- `Single` stores `double.Parse(value.ToString("R", InvariantCulture))` at construction, so the shortest representation of the stored double equals the float's; a plain `(double)` cast would emit `0.10000000149011612`. - `FromDateTime` converts `Local` to UTC and throws for `DateTimeKind.Unspecified`. -- `Bytes` documents that the array is held by reference; mutation after construction is the caller's responsibility. - `DateTimeOffset` boxes like `Decimal` (see `MetadataValue.FromDecimal` for the rationale); packing its offset into the struct's 3 spare padding bytes is a possible later optimization, out of scope here. -- The existing `Double` kind gains a canonical rule shared with `Float32`: when the shortest round-trippable text contains neither a fraction nor an exponent, `.0` is appended (via an integral check and `WriteRawValue`). A bare `Double` token consequently never reads back as `Int64`, which removes `Double` from the envelope entirely. `Decimal` is deliberately excluded from this rule: `5m` has scale 0 and must keep emitting `5`. +- The existing `Double` kind gains a canonical rule shared with `Single`: when the shortest round-trippable text contains neither a fraction nor an exponent, `.0` is appended (via an integral check and `WriteRawValue`). A bare `Double` token consequently never reads back as `Int64`, which removes `Double` from the envelope entirely. `Decimal` is deliberately excluded from this rule: `5m` has scale 0 and must keep emitting `5`. The core project multi-targets `netstandard2.0;net10.0`. All kinds, wire behavior, and equality are identical on both targets; only the `DateOnly`/`TimeOnly` factory and accessor signatures are `net10.0`-only, because Polyfill's types are internal and cannot appear in public APIs. On `netstandard2.0`, envelope reads of `date`/`time` still produce the correct kinds from the inline payload. +### Payload storage + +`MetadataPayload` stays a bit-level union and gains no typed views for the date and time kinds: those are stored as ticks or day number through the existing `Int64` slot, and `MetadataValue` owns the conversion. Overlaying `DateTime`, `DateOnly`, or `TimeOnly` at offset 0 would save only a mask and a range check on paths dominated by dictionary lookups and JSON writing, while forcing `#if` into the layout-critical struct (`DateOnly` and `TimeOnly` do not exist on `netstandard2.0`), making its layout differ per target, and introducing views whose bit patterns are not all valid — where every bit pattern is a valid `long`. Storing ticks also enforces the UTC normalization structurally instead of by documentation. + +One typed view is required, and for correctness rather than speed: **`ulong` binds to the `MetadataPayload(double)` constructor**, because `ulong` has an implicit conversion to `double` but none to `long`. Every `UInt64` value above 2^53 would be silently corrupted with no diagnostic. Add a `ulong` view at offset 0 with a named factory — a `ulong` view is total and carries none of the objections above. `char` is unaffected (it binds to the `long` overload), and `Single` needs no view because it is normalized into the `Float64` slot. + +While editing the struct, replace `record struct` with a plain `readonly struct`. The compiler-generated equality members compare every overlapping view of the same eight bytes, and nothing calls them: no `Equals`, `GetHashCode`, `==`, `with`, or `ToString` usage of the payload exists anywhere in the solution, because `MetadataValue` compares payload slots itself. Dropping `record` does not change the layout, so the size pin is unaffected. The inherited `ValueType` members then use reflection over fields rather than a bitwise comparison, since the struct holds an object reference; overriding both to throw `NotSupportedException` is optional but states the real invariant, namely that payload equality is meaningless without a kind — identical bits mean different values under `Int64`, `Double`, and `Boolean`. + ### Dispatch safety `0052` documented that adding a kind breaks nothing at compile time and corrupts silently. This plan removes that hazard structurally: -- **Derived shape:** `GetJsonShape()` is backed by a 256-entry `ReadOnlySpan` lookup (a byte index is bounds-check-free). The JSON writers switch on the six shapes; the `Number` arm dispatches over `Int64`/`Double`/`Float32`/`Decimal` only. +- **Derived shape:** `GetJsonShape()` is backed by a 256-entry `ReadOnlySpan` lookup (a byte index is bounds-check-free). The JSON writers switch on the six shapes; the `Number` arm dispatches over `Int64`/`Double`/`Single`/`Decimal` only. - **One formatter:** a single canonical-text method (with a `TryFormat`-style span overload) is the only place that knows how string-shaped kinds render. JSON string values, `DefaultHttpHeaderConversionService`'s fallback (currently `ToString()`, which wrongly quotes strings), and `GetStringAttribute` all call it. Adding a kind touches this one site instead of eight. `ToString()` becomes debug-only output. - **Payload-preserving constructor:** `WithAnnotation`'s per-kind switch exists only because no constructor copies an existing payload. An internal `MetadataValue(Kind, payload, annotation)` path reduces the primitive case to one line; `Array`/`Object` still recurse to rewrite children and revalidate annotation constraints. - **Exhaustive switches:** the remaining kind switches (`Equals`, `GetHashCode`, `ToString`, the formatter) become switch expressions listing every member with no discard arm. +`MetadataValueTestFactory` builds values with undeclared kinds and empty payloads to pin the fallback arms. Without discard arms, an undeclared kind now surfaces as `SwitchExpressionException` instead of the custom `InvalidOperationException` from `ToString()`, so those expectations change. The helper stays valuable: it still guards the arms that a declared kind reaches with a payload the factories never produce. + ### Accessors and equality -Exact-kind reads never parse. Lenient conversions mirror the existing `TryGetDecimal` precedent: every new `TryGet*` also accepts a `String` whose content is the canonical encoding, and `TryGetDouble` converts from `Float32`. This is the pressure valve for wire degradation: code calling `TryGetGuid` works whether the value arrived typed or as a bare string. +Exact-kind reads never parse. Lenient conversions mirror the existing `TryGetDecimal` precedent: every new `TryGet*` also accepts a `String` whose content is the canonical encoding, and `TryGetDouble` converts from `Single`. This is the pressure valve for wire degradation: code calling `TryGetGuid` works whether the value arrived typed or as a bare string. Equality stays strict — kind plus payload, `Annotation` excluded. `FromGuid(g)` is not equal to `FromString(g.ToString())`; round-trip fidelity is the envelope's job, not `Equals`'. Boxed kinds unbox and compare by value with the same defensive `is` pattern as the `Decimal` arm. @@ -108,8 +118,10 @@ Pre-1.0, silent at compile time, to be listed in release notes: values previousl ### Sequencing -Issues #51 (HTTP header formatting) and #53 (CloudEvents extension attribute types) land after this plan: both become lookups from `MetadataKind` (structured-field formats, CloudEvents `Timestamp`/`Binary`/`URI` types) instead of independent classifications. +Issues #51 (HTTP header formatting) and #53 (CloudEvents extension attribute types) land after this plan: both become lookups from `MetadataKind` (structured-field formats, CloudEvents `Timestamp`/`URI` types) instead of independent classifications. #53 also carries the deferred `Bytes` kind, since CloudEvents `Binary` is the first concrete consumer of it. ### Out of scope Packing `DateTimeOffset` into the padding bytes, a JSON-equivalence comparer for cross-serialization comparisons, additional scalar kinds (`Half`, `Int128`), and gRPC mappings. + +A `Bytes` kind for `byte[]` (OpenAPI `format: byte`, base64 on the wire) is deliberately excluded. It is the only candidate that is not a fixed-size immutable value type, and it raises two questions the scalar kinds do not: whether equality is structural (O(n) comparison and hashing on values used as `MetadataObject` entries) or by reference (inconsistent with every other kind), and whether the factory copies defensively to preserve immutability. Binary data stays a base64 `String` for now, which is what callers do today. Appending the kind later is additive and not wire-visible, since the enum's numeric values never leave the process. From bbff38d6db85e2d80fa806431e52f23dfc332dde Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Tue, 28 Jul 2026 07:59:21 +0200 Subject: [PATCH 04/15] docs: further refine 0054-0, move metadata round-tripping into dedicated plan Signed-off-by: Kenny Pflug --- ai-plans/0054-0-metadata-restructuring.md | 118 ++++++++++++++++ .../0054-1-metadata-round-trip-envelope.md | 87 ++++++++++++ ai-plans/0054-metadata-restructuring.md | 127 ------------------ 3 files changed, 205 insertions(+), 127 deletions(-) create mode 100644 ai-plans/0054-0-metadata-restructuring.md create mode 100644 ai-plans/0054-1-metadata-round-trip-envelope.md delete mode 100644 ai-plans/0054-metadata-restructuring.md diff --git a/ai-plans/0054-0-metadata-restructuring.md b/ai-plans/0054-0-metadata-restructuring.md new file mode 100644 index 0000000..ca4cada --- /dev/null +++ b/ai-plans/0054-0-metadata-restructuring.md @@ -0,0 +1,118 @@ +# Typed Metadata Kinds + +## Rationale + +`MetadataKind` covers only the JSON-shaped types. Every other .NET value reaches metadata through the `IFormattable` fallback in `BuiltInValidationErrorDefinitions.CreateMetadataValue`, producing non-interoperable text: a `DateTime` boundary emits `07/26/2026 13:45:30` instead of RFC 3339, a `TimeSpan` emits `00:00:05` instead of an ISO 8601 duration, and a `TimeOnly` silently drops seconds. These values appear in `problem+json` bodies, so the published OpenAPI contract disagrees with the runtime payload. In-process consumers are equally underserved: a `Guid` or `DateTimeOffset` stored as text costs an allocation on write and a parse on every read, in a library whose primary claim is low allocation. + +This plan extends the primitive range that `0052` reserved with kinds for the common scalar BCL types, stored natively, and restructures the dispatch sites so that adding a kind is compiler-checked instead of silently corrupting data. `0054-1` follows with an opt-in JSON envelope that makes every kind round-trippable. + +## Acceptance Criteria + +- [ ] `MetadataKind` declares the kinds of the vocabulary table as values 6–15; `Array` and `Object` keep 200 and 201; the expected classifications in the existing enum test are updated. +- [ ] `Unsafe.SizeOf()` remains 24. +- [ ] Each kind has a factory method, an implicit conversion where the BCL type allows one, and a typed `TryGet*` accessor that returns the exact stored value without text parsing. `ulong.MaxValue` survives construction, serialization, and reading without precision loss. +- [ ] Each `TryGet*` additionally converts from `MetadataKind.String` when the string holds the kind's canonical encoding and rejects any other text, so consumers work identically on in-process and wire-degraded values without accepting input the writers never produce. +- [ ] Every kind serializes into JSON bodies using the encoding in the vocabulary table; in particular `TimeOnly` preserves seconds, `TimeSpan` emits an ISO 8601 duration, `0.1f` emits `0.1`, and a whole-number `Double` or `Single` emits a trailing `.0` (`5.0`, not `5`). +- [ ] Generated OpenAPI schemas and examples match the JSON encoding of every kind, asserted against the vocabulary table: `ulong` is a string, `TimeSpan` is `format: duration` rather than `time`, and `char` and `Uri` no longer degrade to a schema without a type. +- [ ] HTTP header values, CloudEvents core string attributes, and validation error message text use the same canonical encodings: header output is unquoted for every primitive kind including plain strings, and a value of any primitive kind resolves as a core string attribute. +- [ ] The JSON shape of a kind (`Null`, `Boolean`, `Number`, `String`, `Array`, `Object`) is publicly derivable from `MetadataKind` alone. +- [ ] Every `switch` over `MetadataKind` inside `MetadataValue` lists all members without a discard arm, so declaring a new member fails the Release build (CS8509 with `TreatWarningsAsErrors`) until every switch is updated. +- [ ] `MetadataValueAnnotationHelper.WithAnnotation` preserves the value of every kind; annotation constraints for arrays and objects are still enforced. +- [ ] Equality and hashing cover all kinds: values of different kinds are never equal, boxed kinds compare by value, `Uri` values compare by ordinal `OriginalString`, `Annotation` stays excluded, and a test stores every kind in a `MetadataObject` and reads it back by key. +- [ ] Serialized output for all previously existing kinds is byte-identical to today, apart from the trailing `.0` for whole-number doubles. +- [ ] `CreateMetadataValue` routes the ten BCL types of the vocabulary table to the typed factories; the `problem+json` OpenAPI conformance test from `0052` passes unchanged, and an equivalent test covers a `DateTime` or `TimeSpan` boundary. +- [ ] The core project builds for `netstandard2.0` and `net10.0` in Release with warnings as errors, and the only public API difference between the targets is the `DateOnly`/`TimeOnly` factories and accessors. +- [ ] Test code coverage stays above 95%. + +## Technical Details + +### One discriminator + +`MetadataKind` determines the live payload slot, the JSON shape, and the canonical encoding; nothing else discriminates. The new kinds fill the range that `0052` reserved, in declaration order from 6. Semantic tags on strings (a "uuid-formatted string") are deliberately not expressible: parse at the boundary into the typed kind instead. + +Members are named after their `System.*` type, as the existing ones are (`Int64`, not C#'s `long`), hence `Single` rather than `Float32`, and `Double` is not renamed; factories and accessors follow the member (`FromSingle`, `TryGetSingle`), matching the `TypeCode` arms that `CreateMetadataValue` already dispatches on. Width-explicit naming is not available to the whole family because `0052` rejected `decimal128` as misleading for `System.Decimal`. + +### Vocabulary + +| Kind | .NET type | Storage | JSON encoding | OpenAPI schema | +| --- | --- | --- | --- | --- | +| `UInt64` = 6 | `ulong` | inline | decimal digits as JSON **string** | `string`, `uint64` | +| `Single` = 7 | `float` | inline, widened `double` | shortest round-trippable number | `number`, `float` | +| `Char` = 8 | `char` | inline | single-character string | `string`, `char` | +| `DateTime` = 9 | `DateTime` | inline UTC ticks | RFC 3339 date-time, `Z` suffix | `string`, `date-time` | +| `DateTimeOffset` = 10 | `DateTimeOffset` | boxed | RFC 3339 date-time with offset | `string`, `date-time` | +| `DateOnly` = 11 | `DateOnly` | inline day number | RFC 3339 full-date | `string`, `date` | +| `TimeOnly` = 12 | `TimeOnly` | inline ticks | RFC 3339 full-time | `string`, `time` | +| `TimeSpan` = 13 | `TimeSpan` | inline ticks | ISO 8601 duration | `string`, `duration` | +| `Guid` = 14 | `Guid` | boxed | RFC 4122 lowercase string | `string`, `uuid` | +| `Uri` = 15 | `Uri` | reference | `OriginalString` | `string`, `uri-reference` | + +The schema column is normative: `PortableOpenApiSchemaTypeMapper` and the source-generation emitter transcribe this table instead of maintaining a parallel one, and a test asserts them against it. The table also corrects two existing mappings — `TimeSpan` currently shares the `time` row with `TimeOnly`, and `float` carries no format — and adds `char` and `Uri`, which fall through to a typeless schema today. Every format name used here is registered in the [OpenAPI Format Registry](https://spec.openapis.org/registry/format/). Notes: + +- `ulong` is written as a JSON string because values above `long.MaxValue` are silently corrupted by common JSON consumers. The registry sanctions this: `uint64` lists `number, string` as its base types and recommends the string form above the 53-bit range. We always emit a string, so the schema does not depend on the value. +- `Uri` maps to `uri-reference` rather than `uri` because the schema is per CLR type while absoluteness is a per-value property. +- `Single` is widened with a plain `(double)` cast at construction and narrowed back with `(float)` before formatting; the float → double → float round trip is lossless, so `0.1f` still emits `0.1` at no construction cost. The consequence is that `TryGetDouble` on a `Single` yields the widened value (`0.10000000149011612` for `0.1f`), not the double nearest `0.1`. +- The existing `Double` kind gains a canonical rule shared with `Single`: when the shortest round-trippable text contains neither a fraction nor an exponent, `.0` is appended (via an integral check and `WriteRawValue`), so a bare `Double` token never reads back as `Int64`. `Decimal` is deliberately excluded from this rule: `5m` has scale 0 and must keep emitting `5`. +- `DateTime` and `DateTimeOffset` are separate kinds because the common UTC case then stores inline ticks and avoids the boxing an offset requires. `FromDateTime` converts `Local` to UTC and throws for `DateTimeKind.Unspecified`. +- `DateTimeOffset` boxes like `Decimal` — see `MetadataValue.FromDecimal` for the rationale. `FromUri(null)` returns `Null`, mirroring `FromString`. + +### Payload storage + +`MetadataPayload` stays a bit-level union and gains no typed views for the date and time kinds: those are stored as ticks or a day number through the existing `Int64` slot, and `MetadataValue` owns the conversion. Overlaying them at offset 0 would save only a mask and a range check on paths dominated by dictionary lookups and JSON writing, while forcing `#if` into the layout-critical struct (`DateOnly` and `TimeOnly` do not exist on `netstandard2.0`), making its layout differ per target, and introducing views whose bit patterns are not all valid — where every bit pattern is a valid `long`. Storing ticks also enforces the UTC normalization structurally instead of by documentation. + +One typed view is required, and for correctness rather than speed: **`ulong` binds to the `MetadataPayload(double)` constructor**, because `ulong` has an implicit conversion to `double` but none to `long`. Every `UInt64` value above 2^53 would be silently corrupted with no diagnostic. Add a `ulong` view at offset 0 with a named factory — that view is total and carries none of the objections above. `char` is unaffected (it binds to the `long` overload), and `Single` needs no view because it is widened into the `Float64` slot. + +While editing the struct, replace `record struct` with a plain `readonly struct`: nothing in the solution uses the compiler-generated `Equals`, `GetHashCode`, `==`, `with`, or `ToString`, because `MetadataValue` compares payload slots itself. Dropping `record` does not change the layout, so the size pin is unaffected. Override `Equals` and `GetHashCode` to throw `NotSupportedException` — the inherited `ValueType` versions would reflect over fields rather than compare bitwise, since the struct holds an object reference, and payload equality is meaningless without a kind in any case: identical bits mean different values under `Int64`, `Double`, and `Boolean`. + +### Dispatch safety + +`0052` documented that adding a kind breaks nothing at compile time and corrupts silently. This plan removes that hazard structurally: + +- **Derived shape:** `GetJsonShape()` is an extension method on `MetadataKind` next to `IsPrimitive`, implemented as an exhaustive switch expression. The shape is a function of the kind alone, and the callers that need it most — the schema mapper, the header conversion service — hold a kind rather than a value; `MetadataValue` exposes a forwarding property. A lookup table is deliberately not used: it would map an unlisted kind to shape `0` silently, which is the failure mode this section exists to remove, and the switch is what the JIT turns into a jump table anyway. The JSON writers switch on the six shapes; the `Number` arm dispatches over `Int64`/`Double`/`Single`/`Decimal` only. +- **One formatter:** a single canonical-text method (with a `TryFormat`-style span overload) is the only place that knows how string-shaped kinds render. JSON string values, `DefaultHttpHeaderConversionService`'s fallback (currently `ToString()`, which wrongly quotes strings), and `GetStringAttribute` all call it, replacing the latter's special-cased decimal branch. Adding a kind touches this one site instead of eight. `ToString()` becomes debug-only output. +- **Payload-preserving constructor:** `WithAnnotation`'s per-kind switch exists only because no constructor copies an existing payload. An internal `MetadataValue(Kind, payload, annotation)` path reduces the primitive case to one line; `Array`/`Object` still recurse to rewrite children and revalidate annotation constraints. +- **Exhaustive switches:** the remaining kind switches (`Equals`, `GetHashCode`, `ToString`, the formatter) become switch expressions listing every member with no discard arm. + +`MetadataValueTestFactory` keeps its value — it still guards the arms that a declared kind reaches with a payload the factories never produce — but without discard arms an undeclared kind now surfaces as `SwitchExpressionException` instead of the custom `InvalidOperationException` from `ToString()`, so those expectations change. + +### Accessors and equality + +Exact-kind reads never parse. Lenient conversions mirror the existing `TryGetDecimal` precedent: every new `TryGet*` also accepts a `String` whose content is the canonical encoding, and `TryGetDouble` converts from `Single`. This is the pressure valve for wire degradation: code calling `TryGetGuid` works whether the value arrived typed or as a bare string. + +Strictness is part of that contract, because the framework defaults are too permissive in two places: + +- `Uri` accepts `UriKind.Absolute` only. `UriKind.RelativeOrAbsolute` succeeds for nearly any text, which would make `TryGetUri` return `true` for `"hello world"` and turn the accessor into a footgun for callers that probe kinds in sequence. +- Date and time kinds parse the exact canonical format with the invariant culture and `DateTimeStyles.RoundtripKind` — never `DateTime.Parse`, which accepts `07/26/2026 13:45:30` and would resurrect inside the accessors the ambiguity this plan removes from the writers. + +Equality stays strict — kind plus payload, `Annotation` excluded. `FromGuid(g)` is not equal to `FromString(g.ToString())`; round-trip fidelity is `0054-1`'s job, not `Equals`'. Boxed kinds unbox and compare by value with the same defensive `is` pattern as the `Decimal` arm. `Uri` is a reference rather than a boxed value and must not use `Uri.Equals`, which ignores the fragment and compares hosts case-insensitively: two URIs that serialize differently would compare equal. It compares and hashes its `OriginalString` ordinally, which is exactly what is written to the wire. + +### Targets + +The core project multi-targets `netstandard2.0;net10.0`. All kinds, wire behavior, and equality are identical on both; only the `DateOnly`/`TimeOnly` factory and accessor signatures are `net10.0`-only, because Polyfill's types are internal and cannot appear in public APIs. The `#if` surface is confined to those signatures: storage (day number, ticks), formatting, and parsing stay target-agnostic. + +The test projects stay single-targeted at `net10.0`. Exercising the `netstandard2.0` assets would require a TFM for which they are the best match (net472 or older), doubling the CI matrix in order to cover differences that are signature-only by construction and that the Release build of both targets already catches. + +### Affected components + +| Site | Change | +| --- | --- | +| `MetadataValue`, `MetadataPayload` | new factories, accessors, equality/hash/ToString arms, payload-preserving constructor | +| `MetadataKindExtensions` | `GetJsonShape()` alongside `IsPrimitive` | +| `SharedJsonSerialization` writers/readers | shape-driven dispatch, canonical formatter | +| `DefaultHttpHeaderConversionService`, `CloudEventsResultExtensions.GetStringAttribute`, `MetadataValueAnnotationHelper` | formatter / payload-preserving constructor | +| `BuiltInValidationErrorDefinitions.CreateMetadataValue`, `ValidationErrorMessageFormatting` | typed routing, canonical message text | +| `PortableOpenApiSchemaTypeMapper`, `PortableResultsOpenApiDocumentTransformer`, source-gen emitter | schema column of the vocabulary table, canonical example values | + +### Breaking changes + +Pre-1.0, silent at compile time, to be listed in release notes: values previously flattened to strings by `CreateMetadataValue` now carry typed kinds, so `TryGetString` returns `false` for them and their serialized text changes (`DateTime`, `TimeSpan`, `TimeOnly`, `float`); whole-number doubles serialize as `5.0` instead of `5`; default header serialization of strings loses the surrounding quotes; `TimeSpan` schemas change from `format: time` to `format: duration`; the core package gains a `net10.0` target. The `0052` enum numbering is unchanged. + +### Sequencing + +`0054-1` adds the round-trip envelope on top of this plan and depends on the vocabulary and the shape API. Issues #51 (HTTP header formatting) and #53 (CloudEvents extension attribute types) land afterwards: both become lookups from `MetadataKind` (structured-field formats, CloudEvents `Timestamp`/`URI` types) instead of independent classifications. #53 also carries the deferred `Bytes` kind, since CloudEvents `Binary` is the first concrete consumer of it. + +### Out of scope + +Packing `DateTimeOffset` into the struct's three spare padding bytes, a JSON-equivalence comparer for cross-serialization comparisons, additional scalar kinds (`Half`, `Int128`), gRPC mappings, and any user-configurable override of the kind-to-shape or kind-to-schema mapping. + +A `Bytes` kind for `byte[]` (OpenAPI `format: byte`, base64 on the wire) is deliberately excluded. It is the only candidate that is not a fixed-size immutable value type, and it raises two questions the scalar kinds do not: whether equality is structural (O(n) comparison and hashing on values used as `MetadataObject` entries) or by reference (inconsistent with every other kind), and whether the factory copies defensively to preserve immutability. Binary data stays a base64 `String` for now, which is what callers do today. Appending the kind later is additive and not wire-visible, since the enum's numeric values never leave the process. diff --git a/ai-plans/0054-1-metadata-round-trip-envelope.md b/ai-plans/0054-1-metadata-round-trip-envelope.md new file mode 100644 index 0000000..6d3a260 --- /dev/null +++ b/ai-plans/0054-1-metadata-round-trip-envelope.md @@ -0,0 +1,87 @@ +# Metadata Round-Trip Envelope + +## Rationale + +After `0054-0`, every metadata kind has a canonical JSON encoding, but JSON carries no discriminator for most of them: a `Guid`, a `TimeSpan`, and a `UInt64` all arrive as `MetadataKind.String`, and a `DateTimeOffset` is indistinguishable from a timestamp someone typed by hand. A producer and a consumer that both use this library therefore cannot reconstruct what was sent. The lenient `TryGet*` conversions cover the case where the consumer already knows which type it wants; they do not help generic code that compares, re-emits, or forwards metadata, where kind loss silently changes `Equals` results and re-serialized output. + +This plan adds an opt-in JSON envelope that makes the kind explicit on the wire. It stays off by default because the plain encoding is what non-.NET consumers and the OpenAPI document generated in `0054-0` expect. + +## Acceptance Criteria + +- [ ] The envelope is opt-in through a setting on the existing write and read options for HTTP, CloudEvents, and shared serialization; the module-level default `JsonSerializerOptions` keep it disabled. +- [ ] With the setting disabled, written output and read results are byte-identical and kind-identical to `0054-0`: no envelope is emitted, no envelope is interpreted, and no bare token is sniffed. +- [ ] With the setting enabled on both sides, a write/read cycle reproduces the kind and value of every metadata value, asserted by a matrix test over all kinds; the documented exception is `MetadataKind.DateTime`, which reads back as `DateTimeOffset` with the same instant. +- [ ] A value is wrapped only when reading its bare token back would not reproduce its kind, so `Null`, `Boolean`, `Int64`, `Double`, `String`, `Array`, and `Object` are never wrapped. +- [ ] Envelopes nest: array elements and object member values wrap individually, and values inside arrays and objects round-trip at any depth. +- [ ] Deserialization handles every envelope-shaped input without corrupting data: an unknown `$format` reads as the bare `$value` token, a known `$format` whose value does not parse throws `JsonException`, and any object that is not exactly a `$format` string member plus a `$value` member reads as a plain `MetadataObject`. +- [ ] An envelope written by a producer and read by a consumer that has the setting disabled reads as a plain `MetadataObject` with the two members, without an exception. +- [ ] CloudEvents extension attributes and HTTP headers never emit an envelope, regardless of the setting. +- [ ] On `netstandard2.0`, reading a `date` or `time` envelope produces the correct kind and payload even though no typed accessor exists on that target. +- [ ] Test code coverage stays above 95%. + +## Technical Details + +### Shape and format names + +```json +{ "$format": "uuid", "$value": "dd6a721c-7438-4755-bf60-1960fae12dcd" } +``` + +`$value` always holds the canonical encoding from the `0054-0` vocabulary table, so the envelope adds a discriminator without introducing a second representation of the value. + +| Kind | `$format` | +| --- | --- | +| `UInt64` | `uint64` | +| `Single` | `float` | +| `Char` | `char` | +| `DateTime`, `DateTimeOffset` | `date-time` | +| `DateOnly` | `date` | +| `TimeOnly` | `time` | +| `TimeSpan` | `duration` | +| `Guid` | `uuid` | +| `Uri` | `uri` when `IsAbsoluteUri`, otherwise `uri-reference` | + +All names are registered in the [OpenAPI Format Registry](https://spec.openapis.org/registry/format/), matching the schema formats emitted in `0054-0`. `date-time` always reads back as `DateTimeOffset`, which is why `MetadataKind.DateTime` round-trips with kind degradation but an exact instant: the two kinds are distinguishable in memory only by whether an offset was supplied, and the registry has no name for that distinction. + +Wrapping is static per kind, decided by whether the bare token reproduces the kind — never by inspecting the value. `Double` is not wrapped because the trailing-`.0` rule from `0054-0` already makes a bare `Double` token self-describing. + +### Options and threading + +A setting on the existing write and read options, threaded to the readers and writers by turning the parameterless metadata converters into configured instances and adding a trailing parameter to the static `Read*`/`Write*` methods (illustrative): + +```csharp +public enum MetadataEnvelopeMode : byte { Disabled = 0, Enabled = 1 } + +public static MetadataValue ReadMetadataValue( + ref Utf8JsonReader reader, + MetadataValueAnnotation annotation = MetadataValueAnnotation.SerializeInHttpResponseBody, + MetadataEnvelopeMode envelopeMode = MetadataEnvelopeMode.Disabled +); +``` + +`Disabled` as the default of both the enum and the parameter keeps every existing call site behaving as it does today. The CloudEvents extension-attribute and HTTP header write paths pass `Disabled` unconditionally rather than reading the option: the CloudEvents specification requires plain JSON types for extension attributes, and a header carries text. + +### Reading + +An object is an envelope only when envelope reading is enabled **and** it has exactly two members, a `$format` whose token is a string and a `$value`. Anything else — a third member, a missing `$value`, a non-string `$format` — is an ordinary `MetadataObject`, and so is any envelope-shaped object seen while the setting is disabled. That last case is the interop failure mode worth designing for explicitly: a producer with envelopes enabled talking to a consumer with them disabled degrades to two-member objects silently rather than throwing, so the setting is meant to be configured symmetrically at both ends of a link. + +An unknown `$format` reads as the bare `$value` token, which keeps a reader forward-compatible with kinds added later. A known `$format` whose `$value` does not parse throws `JsonException`, because that is malformed input rather than an unknown vocabulary. + +Bare tokens are never sniffed. Without an envelope the reader produces exactly the kinds it produces in `0054-0`, so enabling the setting on the read side alone cannot change how existing payloads are interpreted. + +### Limitations + +- A genuine `MetadataObject` with exactly the members `$format` (string) and `$value` cannot round-trip while the envelope is enabled. The `$` prefix minimizes the collision; the case is documented rather than escaped. +- With the envelope enabled, metadata in a `problem+json` body no longer matches the OpenAPI schemas generated in `0054-0`, since a scalar becomes an object. The envelope is therefore a closed-world optimization for .NET-to-.NET links, and enabling it on a publicly documented endpoint is a deliberate trade-off. + +### Affected components + +| Site | Change | +| --- | --- | +| `SharedJsonSerialization` writers/readers | envelope write and read behind the mode parameter | +| `Http` and `CloudEvents` writing/reading options and modules | envelope setting, configured converter instances | +| `HttpWriteMetadataValueJsonConverter` and its reading counterparts | constructor taking the mode; no parameterless registration | + +### Out of scope + +Envelopes for CloudEvents extension attributes or HTTP headers, a schema dialect describing the envelope shape, and any per-value or per-key opt-in — the setting is per serializer configuration. diff --git a/ai-plans/0054-metadata-restructuring.md b/ai-plans/0054-metadata-restructuring.md deleted file mode 100644 index 389fb79..0000000 --- a/ai-plans/0054-metadata-restructuring.md +++ /dev/null @@ -1,127 +0,0 @@ -# Typed Metadata Kinds - -## Rationale - -`MetadataKind` covers only the JSON-shaped types. Every other .NET value reaches metadata through the `IFormattable` fallback in `BuiltInValidationErrorDefinitions.CreateMetadataValue`, producing non-interoperable text: a `DateTime` boundary emits `07/26/2026 13:45:30` instead of RFC 3339, a `TimeSpan` emits `00:00:05` instead of an ISO 8601 duration, and a `TimeOnly` silently drops seconds. These values appear in `problem+json` bodies, so the published OpenAPI contract disagrees with the runtime payload. In-process consumers are equally underserved: a `Guid` or `DateTimeOffset` stored as text costs an allocation on write and a parse on every read, in a library whose primary claim is low allocation. - -This plan extends the primitive range that `0052` reserved with kinds for the common scalar BCL types, stored natively. It restructures the dispatch sites so that adding a kind is compiler-checked instead of silently corrupting data, and adds an opt-in JSON envelope that makes every kind round-trippable. - -## Acceptance Criteria - -- [ ] `MetadataKind` declares `UInt64`, `Single`, `Char`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`, `TimeSpan`, `Guid`, and `Uri` as values 6–15; `Array` and `Object` keep 200 and 201; the existing test enumerating the enum asserts every member's primitive/complex classification. -- [ ] `Unsafe.SizeOf()` remains 24. -- [ ] Each new kind has a factory method, an implicit conversion where the BCL type allows it, and a typed `TryGet*` accessor that returns the exact stored value without text parsing when the kind matches. `ulong.MaxValue` survives construction, serialization, and reading without precision loss. -- [ ] Each `TryGet*` accessor additionally converts from `MetadataKind.String` when the string holds the kind's canonical encoding, so consumers work identically on in-process and wire-degraded values. -- [ ] Every kind serializes into JSON bodies using the canonical encoding in the vocabulary table; in particular `TimeOnly` preserves seconds, `TimeSpan` emits an ISO 8601 duration, and `0.1f` emits `0.1`. -- [ ] A whole-number `Double` or `Single` value emits a trailing `.0` (`5.0`, not `5`), so a bare `Double` JSON token always reads back as `MetadataKind.Double` without an envelope. -- [ ] `MetadataValue` exposes its JSON shape (`Null`, `Boolean`, `Number`, `String`, `Array`, `Object`) through a public API, and one shared canonical-text formatter backs JSON string values, HTTP header values, and CloudEvents attribute strings. -- [ ] The default HTTP header conversion emits unquoted canonical text for all primitive kinds, including plain strings. -- [ ] A metadata value of any primitive kind resolves correctly as a CloudEvents core string attribute; the special-cased decimal branch in `GetStringAttribute` is replaced by the shared formatter. -- [ ] `MetadataValueAnnotationHelper.WithAnnotation` rewrites primitive values without per-kind dispatch via a payload-preserving constructor; annotation constraints for arrays and objects are still enforced. -- [ ] Every `switch` over `MetadataKind` inside `MetadataValue` lists all members without a discard arm, so declaring a new member fails the Release build (CS8509 with `TreatWarningsAsErrors`) until every switch is updated. -- [ ] Equality and hashing cover all kinds: values of different kinds are never equal, boxed kinds compare by value, `Annotation` stays excluded, and a test stores every kind in a `MetadataObject` and reads it back by key. -- [ ] With default options, serialized output for all previously existing kinds is byte-identical to today, apart from the trailing-`.0` change for whole-number doubles; the envelope is opt-in on both the write and the read side, and a reader never interprets an envelope unless enabled. -- [ ] With the envelope enabled, a write/read cycle reproduces the kind and value of every metadata value, asserted by a matrix test over all kinds; the documented exception is `MetadataKind.DateTime`, which reads back as `DateTimeOffset` with the same instant. -- [ ] `CreateMetadataValue` routes `ulong`, `float`, `char`, `DateTime`, `DateTimeOffset`, `DateOnly`, `TimeOnly`, `TimeSpan`, `Guid`, and `Uri` to the typed factories; the `problem+json` OpenAPI conformance test from `0052` passes unchanged, and an equivalent test covers a `DateTime` or `TimeSpan` boundary. -- [ ] Generated OpenAPI schemas and examples match the runtime encodings: `TimeSpan` maps to `format: duration`, and example values for the new kinds render as their canonical text. -- [ ] Validation error message text formats these types with the same canonical encodings as the metadata. -- [ ] Test code coverage stays above 95%. - -## Technical Details - -### One discriminator - -`MetadataKind` remains the single discriminator: it determines the live payload slot, the JSON shape, and the canonical encoding. The `0052` layout is kept as merged — `Decimal = 5`, reserved range 6–199, `Array = 200`, `Object = 201`, `IsPrimitive` as a single comparison. The new kinds fill the reserved range in declaration order starting at 6. Semantic tags on strings (a "uuid-formatted string") are deliberately not expressible: parse at the boundary into the typed kind instead. - -Scalar members are named after their `System.*` type, which is the convention the existing members already follow (`Int64` over C#'s `long`). Hence `Single` rather than `Float32`, and `Double` is not renamed. Width-explicit naming is not an option for the whole family, because `0052` rejected `decimal128` as misleading for `System.Decimal`. Factory and accessor names follow the member (`FromSingle`, `TryGetSingle`), matching the `TypeCode.Single` / `TypeCode.Double` arms that `CreateMetadataValue` already dispatches on. - -### Vocabulary - -| Kind | .NET type | Storage | JSON encoding | Envelope name | -| --- | --- | --- | --- | --- | -| `UInt64` = 6 | `ulong` | inline | decimal digits as JSON **string** | `uint64` | -| `Single` = 7 | `float` | inline, normalized `double` | shortest round-trippable number | `float` | -| `Char` = 8 | `char` | inline | single-character string | `char` | -| `DateTime` = 9 | `DateTime` | inline UTC ticks | RFC 3339 date-time, `Z` suffix | `date-time` | -| `DateTimeOffset` = 10 | `DateTimeOffset` | boxed | RFC 3339 date-time with offset | `date-time` | -| `DateOnly` = 11 | `DateOnly` | inline day number | RFC 3339 full-date | `date` | -| `TimeOnly` = 12 | `TimeOnly` | inline ticks | RFC 3339 full-time | `time` | -| `TimeSpan` = 13 | `TimeSpan` | inline ticks | ISO 8601 duration | `duration` | -| `Guid` = 14 | `Guid` | boxed | RFC 4122 lowercase string | `uuid` | -| `Uri` = 15 | `Uri` | reference | `OriginalString` | `uri` / `uri-reference` by `IsAbsoluteUri` | - -Envelope names come from the OpenAPI Format Registry. Notes: - -- `ulong` is written as a JSON string because values above `long.MaxValue` are silently corrupted by common JSON consumers. -- `Single` stores `double.Parse(value.ToString("R", InvariantCulture))` at construction, so the shortest representation of the stored double equals the float's; a plain `(double)` cast would emit `0.10000000149011612`. -- `FromDateTime` converts `Local` to UTC and throws for `DateTimeKind.Unspecified`. -- `DateTimeOffset` boxes like `Decimal` (see `MetadataValue.FromDecimal` for the rationale); packing its offset into the struct's 3 spare padding bytes is a possible later optimization, out of scope here. -- The existing `Double` kind gains a canonical rule shared with `Single`: when the shortest round-trippable text contains neither a fraction nor an exponent, `.0` is appended (via an integral check and `WriteRawValue`). A bare `Double` token consequently never reads back as `Int64`, which removes `Double` from the envelope entirely. `Decimal` is deliberately excluded from this rule: `5m` has scale 0 and must keep emitting `5`. - -The core project multi-targets `netstandard2.0;net10.0`. All kinds, wire behavior, and equality are identical on both targets; only the `DateOnly`/`TimeOnly` factory and accessor signatures are `net10.0`-only, because Polyfill's types are internal and cannot appear in public APIs. On `netstandard2.0`, envelope reads of `date`/`time` still produce the correct kinds from the inline payload. - -### Payload storage - -`MetadataPayload` stays a bit-level union and gains no typed views for the date and time kinds: those are stored as ticks or day number through the existing `Int64` slot, and `MetadataValue` owns the conversion. Overlaying `DateTime`, `DateOnly`, or `TimeOnly` at offset 0 would save only a mask and a range check on paths dominated by dictionary lookups and JSON writing, while forcing `#if` into the layout-critical struct (`DateOnly` and `TimeOnly` do not exist on `netstandard2.0`), making its layout differ per target, and introducing views whose bit patterns are not all valid — where every bit pattern is a valid `long`. Storing ticks also enforces the UTC normalization structurally instead of by documentation. - -One typed view is required, and for correctness rather than speed: **`ulong` binds to the `MetadataPayload(double)` constructor**, because `ulong` has an implicit conversion to `double` but none to `long`. Every `UInt64` value above 2^53 would be silently corrupted with no diagnostic. Add a `ulong` view at offset 0 with a named factory — a `ulong` view is total and carries none of the objections above. `char` is unaffected (it binds to the `long` overload), and `Single` needs no view because it is normalized into the `Float64` slot. - -While editing the struct, replace `record struct` with a plain `readonly struct`. The compiler-generated equality members compare every overlapping view of the same eight bytes, and nothing calls them: no `Equals`, `GetHashCode`, `==`, `with`, or `ToString` usage of the payload exists anywhere in the solution, because `MetadataValue` compares payload slots itself. Dropping `record` does not change the layout, so the size pin is unaffected. The inherited `ValueType` members then use reflection over fields rather than a bitwise comparison, since the struct holds an object reference; overriding both to throw `NotSupportedException` is optional but states the real invariant, namely that payload equality is meaningless without a kind — identical bits mean different values under `Int64`, `Double`, and `Boolean`. - -### Dispatch safety - -`0052` documented that adding a kind breaks nothing at compile time and corrupts silently. This plan removes that hazard structurally: - -- **Derived shape:** `GetJsonShape()` is backed by a 256-entry `ReadOnlySpan` lookup (a byte index is bounds-check-free). The JSON writers switch on the six shapes; the `Number` arm dispatches over `Int64`/`Double`/`Single`/`Decimal` only. -- **One formatter:** a single canonical-text method (with a `TryFormat`-style span overload) is the only place that knows how string-shaped kinds render. JSON string values, `DefaultHttpHeaderConversionService`'s fallback (currently `ToString()`, which wrongly quotes strings), and `GetStringAttribute` all call it. Adding a kind touches this one site instead of eight. `ToString()` becomes debug-only output. -- **Payload-preserving constructor:** `WithAnnotation`'s per-kind switch exists only because no constructor copies an existing payload. An internal `MetadataValue(Kind, payload, annotation)` path reduces the primitive case to one line; `Array`/`Object` still recurse to rewrite children and revalidate annotation constraints. -- **Exhaustive switches:** the remaining kind switches (`Equals`, `GetHashCode`, `ToString`, the formatter) become switch expressions listing every member with no discard arm. - -`MetadataValueTestFactory` builds values with undeclared kinds and empty payloads to pin the fallback arms. Without discard arms, an undeclared kind now surfaces as `SwitchExpressionException` instead of the custom `InvalidOperationException` from `ToString()`, so those expectations change. The helper stays valuable: it still guards the arms that a declared kind reaches with a payload the factories never produce. - -### Accessors and equality - -Exact-kind reads never parse. Lenient conversions mirror the existing `TryGetDecimal` precedent: every new `TryGet*` also accepts a `String` whose content is the canonical encoding, and `TryGetDouble` converts from `Single`. This is the pressure valve for wire degradation: code calling `TryGetGuid` works whether the value arrived typed or as a bare string. - -Equality stays strict — kind plus payload, `Annotation` excluded. `FromGuid(g)` is not equal to `FromString(g.ToString())`; round-trip fidelity is the envelope's job, not `Equals`'. Boxed kinds unbox and compare by value with the same defensive `is` pattern as the `Decimal` arm. - -### Round-trip envelope - -Off by default; enabled through a setting on the existing write and read options (HTTP, CloudEvents, shared serialization), threaded to the readers/writers by turning the parameterless metadata converters into configured instances and adding an options parameter to the static `Read*`/`Write*` methods. The module-level default `JsonSerializerOptions` keep the envelope disabled; enabling requires building serializer options through the module with the setting. - -```json -{ "$format": "uuid", "$value": "dd6a721c-7438-4755-bf60-1960fae12dcd" } -``` - -- A value is wrapped only when reading its bare token back would not reproduce its kind. This is static per kind: `Null`, `Boolean`, `Int64`, `Double`, `String`, `Array`, and `Object` are never wrapped (the trailing-`.0` rule guarantees this for `Double`); every kind in the vocabulary table always is. -- Array elements wrap individually. The `$` prefix minimizes collisions with user data; an object is an envelope only when reading is enabled and it has exactly a `$format` string member and a `$value` member. A genuine two-member `MetadataObject` with these keys cannot round-trip while the envelope is enabled — a documented limitation. -- An unknown `$format` name reads as the bare `$value` token (forward compatibility); a known name whose value does not parse throws `JsonException`. -- `date-time` always reads as `DateTimeOffset`, which is why `MetadataKind.DateTime` round-trips with kind degradation but exact instant. -- The envelope never applies to CloudEvents extension attributes (the spec requires plain JSON types) or HTTP headers; those write paths always pass the disabled mode regardless of options. - -Bare tokens are never sniffed: without an envelope, the reader keeps producing exactly the base kinds it produces today. - -### Affected components - -| Site | Change | -| --- | --- | -| `MetadataValue`, `MetadataPayload` | new factories, accessors, equality/hash/ToString arms, shape API, payload-preserving constructor | -| `SharedJsonSerialization` writers/readers | shape-driven dispatch, formatter, envelope behind options | -| `Http` and `CloudEvents` converters and options | configured converter instances, envelope setting, header fallback via formatter | -| `CloudEventsResultExtensions.GetStringAttribute`, `MetadataValueAnnotationHelper` | formatter / payload-preserving constructor | -| `BuiltInValidationErrorDefinitions.CreateMetadataValue`, `ValidationErrorMessageFormatting` | typed routing, canonical message text | -| `PortableOpenApiSchemaTypeMapper`, `PortableResultsOpenApiDocumentTransformer`, source-gen emitter | `TimeSpan` → `duration`, canonical example values | - -### Breaking changes - -Pre-1.0, silent at compile time, to be listed in release notes: values previously flattened to strings by `CreateMetadataValue` now carry typed kinds, so `TryGetString` returns `false` for them and their serialized text changes (`DateTime`, `TimeSpan`, `TimeOnly`, `float`); whole-number doubles serialize as `5.0` instead of `5`; default header serialization of strings loses the surrounding quotes; the core package gains a `net10.0` target. The `0052` enum numbering is unchanged. - -### Sequencing - -Issues #51 (HTTP header formatting) and #53 (CloudEvents extension attribute types) land after this plan: both become lookups from `MetadataKind` (structured-field formats, CloudEvents `Timestamp`/`URI` types) instead of independent classifications. #53 also carries the deferred `Bytes` kind, since CloudEvents `Binary` is the first concrete consumer of it. - -### Out of scope - -Packing `DateTimeOffset` into the padding bytes, a JSON-equivalence comparer for cross-serialization comparisons, additional scalar kinds (`Half`, `Int128`), and gRPC mappings. - -A `Bytes` kind for `byte[]` (OpenAPI `format: byte`, base64 on the wire) is deliberately excluded. It is the only candidate that is not a fixed-size immutable value type, and it raises two questions the scalar kinds do not: whether equality is structural (O(n) comparison and hashing on values used as `MetadataObject` entries) or by reference (inconsistent with every other kind), and whether the factory copies defensively to preserve immutability. Binary data stays a base64 `String` for now, which is what callers do today. Appending the kind later is additive and not wire-visible, since the enum's numeric values never leave the process. From 12481afa15af0541967380fbaecb01d103ad8d5a Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Tue, 28 Jul 2026 19:45:58 +0200 Subject: [PATCH 05/15] feat(metadata)!: expand typed metadata vocabulary Add canonical typed metadata support across serialization, validation, CloudEvents, HTTP headers, OpenAPI, and source generation. Multi-target the core library and cover the new vocabulary with conformance tests. --- ai-plans/0054-0-metadata-restructuring.md | 30 +- ...rtableResultsOpenApiDocumentTransformer.cs | 67 +- ....PortableResults.AspNetCore.OpenApi.csproj | 2 + .../PortableOpenApiSchemaTypeMapper.cs | 57 +- .../ValidatorOpenApiEmitter.cs | 57 +- ...uiltInValidationErrorDefinitions.Shared.cs | 40 +- .../Light.PortableResults.Validation.csproj | 6 +- .../ValidationErrorMessageFormatting.cs | 52 + .../packages.lock.json | 79 ++ .../MetadataValueAnnotationHelper.cs | 32 +- .../Writing/CloudEventsResultExtensions.cs | 30 +- .../DefaultHttpHeaderConversionService.cs | 7 +- .../Light.PortableResults.csproj | 13 +- .../Metadata/MetadataJsonShape.cs | 26 + .../Metadata/MetadataKind.cs | 70 +- .../Metadata/MetadataKindExtensions.cs | 43 + .../Metadata/MetadataPayload.cs | 20 +- .../Metadata/MetadataValue.cs | 976 +++++++++++++----- .../Writing/MetadataExtensions.cs | 47 +- src/Light.PortableResults/packages.lock.json | 82 ++ ...otationFlags_ForGenericResult.verified.txt | 2 +- ...enGenericMetadataIsHeaderOnly.verified.txt | 2 +- ...onGenericMetadataIsHeaderOnly.verified.txt | 2 +- ...otationFlags_ForGenericResult.verified.txt | 2 +- ...enGenericMetadataIsHeaderOnly.verified.txt | 2 +- ...onGenericMetadataIsHeaderOnly.verified.txt | 2 +- .../PortableOpenApiSchemaTypeMapperTests.cs | 62 ++ ...eResultsOpenApiDocumentTransformerTests.cs | 76 ++ .../MetadataValueAnnotationHelperTests.cs | 7 +- .../CloudEventsResultExtensionsTests.cs | 66 ++ ...DefaultHttpHeaderConversionServiceTests.cs | 45 + .../Metadata/MetadataKindTests.cs | 39 +- .../Metadata/MetadataValueTestFactory.cs | 23 +- .../Metadata/MetadataValueTests.cs | 6 +- .../Metadata/TypedMetadataValueTests.cs | 375 +++++++ .../UndeclaredMetadataKindFallbackTests.cs | 32 +- .../ValidatorOpenApiEmitterTests.cs | 25 +- ...TemporalMetadataOpenApiConformanceTests.cs | 145 +++ .../TypedMetadataRoutingTests.cs | 89 ++ .../ValidationErrorDefinitionTests.cs | 12 +- 40 files changed, 2315 insertions(+), 435 deletions(-) create mode 100644 src/Light.PortableResults/Metadata/MetadataJsonShape.cs create mode 100644 src/Light.PortableResults/Metadata/MetadataKindExtensions.cs create mode 100644 tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiSchemaTypeMapperTests.cs create mode 100644 tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs create mode 100644 tests/Light.PortableResults.Validation.OpenApi.Tests/TemporalMetadataOpenApiConformanceTests.cs create mode 100644 tests/Light.PortableResults.Validation.Tests/TypedMetadataRoutingTests.cs diff --git a/ai-plans/0054-0-metadata-restructuring.md b/ai-plans/0054-0-metadata-restructuring.md index ca4cada..98ef42b 100644 --- a/ai-plans/0054-0-metadata-restructuring.md +++ b/ai-plans/0054-0-metadata-restructuring.md @@ -8,21 +8,21 @@ This plan extends the primitive range that `0052` reserved with kinds for the co ## Acceptance Criteria -- [ ] `MetadataKind` declares the kinds of the vocabulary table as values 6–15; `Array` and `Object` keep 200 and 201; the expected classifications in the existing enum test are updated. -- [ ] `Unsafe.SizeOf()` remains 24. -- [ ] Each kind has a factory method, an implicit conversion where the BCL type allows one, and a typed `TryGet*` accessor that returns the exact stored value without text parsing. `ulong.MaxValue` survives construction, serialization, and reading without precision loss. -- [ ] Each `TryGet*` additionally converts from `MetadataKind.String` when the string holds the kind's canonical encoding and rejects any other text, so consumers work identically on in-process and wire-degraded values without accepting input the writers never produce. -- [ ] Every kind serializes into JSON bodies using the encoding in the vocabulary table; in particular `TimeOnly` preserves seconds, `TimeSpan` emits an ISO 8601 duration, `0.1f` emits `0.1`, and a whole-number `Double` or `Single` emits a trailing `.0` (`5.0`, not `5`). -- [ ] Generated OpenAPI schemas and examples match the JSON encoding of every kind, asserted against the vocabulary table: `ulong` is a string, `TimeSpan` is `format: duration` rather than `time`, and `char` and `Uri` no longer degrade to a schema without a type. -- [ ] HTTP header values, CloudEvents core string attributes, and validation error message text use the same canonical encodings: header output is unquoted for every primitive kind including plain strings, and a value of any primitive kind resolves as a core string attribute. -- [ ] The JSON shape of a kind (`Null`, `Boolean`, `Number`, `String`, `Array`, `Object`) is publicly derivable from `MetadataKind` alone. -- [ ] Every `switch` over `MetadataKind` inside `MetadataValue` lists all members without a discard arm, so declaring a new member fails the Release build (CS8509 with `TreatWarningsAsErrors`) until every switch is updated. -- [ ] `MetadataValueAnnotationHelper.WithAnnotation` preserves the value of every kind; annotation constraints for arrays and objects are still enforced. -- [ ] Equality and hashing cover all kinds: values of different kinds are never equal, boxed kinds compare by value, `Uri` values compare by ordinal `OriginalString`, `Annotation` stays excluded, and a test stores every kind in a `MetadataObject` and reads it back by key. -- [ ] Serialized output for all previously existing kinds is byte-identical to today, apart from the trailing `.0` for whole-number doubles. -- [ ] `CreateMetadataValue` routes the ten BCL types of the vocabulary table to the typed factories; the `problem+json` OpenAPI conformance test from `0052` passes unchanged, and an equivalent test covers a `DateTime` or `TimeSpan` boundary. -- [ ] The core project builds for `netstandard2.0` and `net10.0` in Release with warnings as errors, and the only public API difference between the targets is the `DateOnly`/`TimeOnly` factories and accessors. -- [ ] Test code coverage stays above 95%. +- [x] `MetadataKind` declares the kinds of the vocabulary table as values 6–15; `Array` and `Object` keep 200 and 201; the expected classifications in the existing enum test are updated. +- [x] `Unsafe.SizeOf()` remains 24. +- [x] Each kind has a factory method, an implicit conversion where the BCL type allows one, and a typed `TryGet*` accessor that returns the exact stored value without text parsing. `ulong.MaxValue` survives construction, serialization, and reading without precision loss. +- [x] Each `TryGet*` additionally converts from `MetadataKind.String` when the string holds the kind's canonical encoding and rejects any other text, so consumers work identically on in-process and wire-degraded values without accepting input the writers never produce. +- [x] Every kind serializes into JSON bodies using the encoding in the vocabulary table; in particular `TimeOnly` preserves seconds, `TimeSpan` emits an ISO 8601 duration, `0.1f` emits `0.1`, and a whole-number `Double` or `Single` emits a trailing `.0` (`5.0`, not `5`). +- [x] Generated OpenAPI schemas and examples match the JSON encoding of every kind, asserted against the vocabulary table: `ulong` is a string, `TimeSpan` is `format: duration` rather than `time`, and `char` and `Uri` no longer degrade to a schema without a type. +- [x] HTTP header values, CloudEvents core string attributes, and validation error message text use the same canonical encodings: header output is unquoted for every primitive kind including plain strings, and a value of any primitive kind resolves as a core string attribute. +- [x] The JSON shape of a kind (`Null`, `Boolean`, `Number`, `String`, `Array`, `Object`) is publicly derivable from `MetadataKind` alone. +- [x] Every `switch` over `MetadataKind` inside `MetadataValue` lists all members without a discard arm, so declaring a new member fails the Release build (CS8509 with `TreatWarningsAsErrors`) until every switch is updated. +- [x] `MetadataValueAnnotationHelper.WithAnnotation` preserves the value of every kind; annotation constraints for arrays and objects are still enforced. +- [x] Equality and hashing cover all kinds: values of different kinds are never equal, boxed kinds compare by value, `Uri` values compare by ordinal `OriginalString`, `Annotation` stays excluded, and a test stores every kind in a `MetadataObject` and reads it back by key. +- [x] Serialized output for all previously existing kinds is byte-identical to today, apart from the trailing `.0` for whole-number doubles. +- [x] `CreateMetadataValue` routes the ten BCL types of the vocabulary table to the typed factories; the `problem+json` OpenAPI conformance test from `0052` passes unchanged, and an equivalent test covers a `DateTime` or `TimeSpan` boundary. +- [x] The core project builds for `netstandard2.0` and `net10.0` in Release with warnings as errors, and the only public API difference between the targets is the `DateOnly`/`TimeOnly` factories and accessors. +- [x] Test code coverage stays above 95%. ## Technical Details diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs index 9c7a801..bf60c2e 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs @@ -11,6 +11,7 @@ using Light.PortableResults.AspNetCore.OpenApi.Schemas; using Light.PortableResults.Http; using Light.PortableResults.Http.Writing; +using Light.PortableResults.Metadata; using Light.PortableResults.SharedJsonSerialization; using Microsoft.AspNetCore.Mvc.ApiExplorer; using Microsoft.AspNetCore.OpenApi; @@ -256,7 +257,8 @@ int statusCode }; if (attribute is ProducesPortableValidationProblemAttribute validationAttribute && - ResolveValidationProblemFormat(validationAttribute) == ValidationProblemSerializationFormat.AspNetCoreCompatible) + ResolveValidationProblemFormat(validationAttribute) == + ValidationProblemSerializationFormat.AspNetCoreCompatible) { AddAspNetCoreCompatibleValidationExample(example, validationAttribute.ErrorExamples!); } @@ -382,28 +384,47 @@ private static void AddMetadata( private static JsonNode? CreateJsonValue(object? value) { - return value switch + var metadataValue = value switch { - null => null, - string stringValue => JsonValue.Create(stringValue), - bool boolValue => JsonValue.Create(boolValue), - byte byteValue => JsonValue.Create(byteValue), - sbyte sbyteValue => JsonValue.Create(sbyteValue), - short shortValue => JsonValue.Create(shortValue), - ushort ushortValue => JsonValue.Create(ushortValue), - int intValue => JsonValue.Create(intValue), - uint uintValue => JsonValue.Create(uintValue), - long longValue => JsonValue.Create(longValue), - ulong ulongValue => JsonValue.Create(ulongValue), - float floatValue => JsonValue.Create(floatValue), - double doubleValue => JsonValue.Create(doubleValue), - decimal decimalValue => JsonValue.Create(decimalValue), - DateTime dateTimeValue => JsonValue.Create(dateTimeValue), - DateTimeOffset dateTimeOffsetValue => JsonValue.Create(dateTimeOffsetValue), - Guid guidValue => JsonValue.Create(guidValue), - Enum enumValue => JsonValue.Create(Convert.ToInt64(enumValue, CultureInfo.InvariantCulture)), - _ => JsonValue.Create(value.ToString()) + null => MetadataValue.Null, + string stringValue => MetadataValue.FromString(stringValue), + bool boolValue => MetadataValue.FromBoolean(boolValue), + byte byteValue => MetadataValue.FromInt64(byteValue), + sbyte sbyteValue => MetadataValue.FromInt64(sbyteValue), + short shortValue => MetadataValue.FromInt64(shortValue), + ushort ushortValue => MetadataValue.FromInt64(ushortValue), + int intValue => MetadataValue.FromInt64(intValue), + uint uintValue => MetadataValue.FromInt64(uintValue), + long longValue => MetadataValue.FromInt64(longValue), + ulong ulongValue => MetadataValue.FromUInt64(ulongValue), + float floatValue => MetadataValue.FromSingle(floatValue), + double doubleValue => MetadataValue.FromDouble(doubleValue), + decimal decimalValue => MetadataValue.FromDecimal(decimalValue), + char charValue => MetadataValue.FromChar(charValue), + DateTime dateTimeValue => MetadataValue.FromDateTime(dateTimeValue), + DateTimeOffset dateTimeOffsetValue => MetadataValue.FromDateTimeOffset(dateTimeOffsetValue), + DateOnly dateOnlyValue => MetadataValue.FromDateOnly(dateOnlyValue), + TimeOnly timeOnlyValue => MetadataValue.FromTimeOnly(timeOnlyValue), + TimeSpan timeSpanValue => MetadataValue.FromTimeSpan(timeSpanValue), + Guid guidValue => MetadataValue.FromGuid(guidValue), + Uri uriValue => MetadataValue.FromUri(uriValue), + Enum enumValue => MetadataValue.FromInt64(Convert.ToInt64(enumValue, CultureInfo.InvariantCulture)), + _ => MetadataValue.FromString(value.ToString()) }; + +#pragma warning disable CS8524 // Unnamed enum values intentionally throw SwitchExpressionException. + return metadataValue.JsonShape switch + { + MetadataJsonShape.Null => null, + MetadataJsonShape.Boolean => JsonValue.Create( + metadataValue.TryGetBoolean(out var booleanValue) && booleanValue + ), + MetadataJsonShape.Number => JsonNode.Parse(metadataValue.ToCanonicalString()), + MetadataJsonShape.String => JsonValue.Create(metadataValue.ToCanonicalString()), + MetadataJsonShape.Array => throw new InvalidOperationException("OpenAPI metadata examples must be scalar."), + MetadataJsonShape.Object => throw new InvalidOperationException("OpenAPI metadata examples must be scalar.") + }; +#pragma warning restore CS8524 } private async Task CreateContributingSchemaAsync( @@ -692,7 +713,9 @@ CancellationToken cancellationToken { var fallbackSchema = PortableResultsOpenApiSchemas.CreateSchemaReference(document, itemBaseSchemaId); var anyOfSchemas = new List(documentedVariants.Count + 1); - anyOfSchemas.AddRange(documentedVariants.Select(static variant => (IOpenApiSchema) variant.SchemaReference)); + anyOfSchemas.AddRange( + documentedVariants.Select(static variant => (IOpenApiSchema) variant.SchemaReference) + ); anyOfSchemas.Add(fallbackSchema); return new OpenApiSchema diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/Light.PortableResults.AspNetCore.OpenApi.csproj b/src/Light.PortableResults.AspNetCore.OpenApi/Light.PortableResults.AspNetCore.OpenApi.csproj index 4edf798..285db5d 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/Light.PortableResults.AspNetCore.OpenApi.csproj +++ b/src/Light.PortableResults.AspNetCore.OpenApi/Light.PortableResults.AspNetCore.OpenApi.csproj @@ -10,6 +10,8 @@ - Opt-in OpenAPI integration via IServiceCollection.AddPortableResultsOpenApi. - Three public OpenAPI helpers, three public attributes, and a global error-metadata registry. - Native AOT compatible OpenAPI support without schema-only CLR surrogate types. + - Scalar metadata schemas and examples now match the typed metadata vocabulary, including string-encoded + UInt64, float, char, duration, and URI-reference formats. diff --git a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiSchemaTypeMapper.cs b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiSchemaTypeMapper.cs index a392954..c56ffc5 100644 --- a/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiSchemaTypeMapper.cs +++ b/src/Light.PortableResults.AspNetCore.OpenApi/PortableOpenApiSchemaTypeMapper.cs @@ -16,11 +16,19 @@ public static class PortableOpenApiSchemaTypeMapper /// Recognized types and their schemas: /// /// - /// int, short, long, byte, sbyte, uint, ushort, ulong + /// int, short, long, byte, sbyte, uint, ushort /// { type: integer } /// /// - /// float, double, decimal + /// ulong + /// { type: string, format: uint64 } + /// + /// + /// float + /// { type: number, format: float } + /// + /// + /// double, decimal /// { type: number } /// /// @@ -32,6 +40,10 @@ public static class PortableOpenApiSchemaTypeMapper /// { type: string } /// /// + /// char + /// { type: string, format: char } + /// + /// /// DateTime, DateTimeOffset /// { type: string, format: date-time } /// @@ -40,13 +52,21 @@ public static class PortableOpenApiSchemaTypeMapper /// { type: string, format: date } /// /// - /// TimeOnly, TimeSpan + /// TimeOnly /// { type: string, format: time } /// /// + /// TimeSpan + /// { type: string, format: duration } + /// + /// /// Guid /// { type: string, format: uuid } /// + /// + /// Uri + /// { type: string, format: uri-reference } + /// /// /// /// Nullable<T> is unwrapped to its inner type before mapping. @@ -66,12 +86,22 @@ public static OpenApiSchema Map(Type type) if (type == typeof(int) || type == typeof(short) || type == typeof(long) || type == typeof(byte) || type == typeof(sbyte) || - type == typeof(uint) || type == typeof(ushort) || type == typeof(ulong)) + type == typeof(uint) || type == typeof(ushort)) { return new OpenApiSchema { Type = JsonSchemaType.Integer }; } - if (type == typeof(float) || type == typeof(double) || type == typeof(decimal)) + if (type == typeof(ulong)) + { + return new OpenApiSchema { Type = JsonSchemaType.String, Format = "uint64" }; + } + + if (type == typeof(float)) + { + return new OpenApiSchema { Type = JsonSchemaType.Number, Format = "float" }; + } + + if (type == typeof(double) || type == typeof(decimal)) { return new OpenApiSchema { Type = JsonSchemaType.Number }; } @@ -86,6 +116,11 @@ public static OpenApiSchema Map(Type type) return new OpenApiSchema { Type = JsonSchemaType.String }; } + if (type == typeof(char)) + { + return new OpenApiSchema { Type = JsonSchemaType.String, Format = "char" }; + } + if (type == typeof(DateTime) || type == typeof(DateTimeOffset)) { return new OpenApiSchema { Type = JsonSchemaType.String, Format = "date-time" }; @@ -96,16 +131,26 @@ public static OpenApiSchema Map(Type type) return new OpenApiSchema { Type = JsonSchemaType.String, Format = "date" }; } - if (type == typeof(TimeOnly) || type == typeof(TimeSpan)) + if (type == typeof(TimeOnly)) { return new OpenApiSchema { Type = JsonSchemaType.String, Format = "time" }; } + if (type == typeof(TimeSpan)) + { + return new OpenApiSchema { Type = JsonSchemaType.String, Format = "duration" }; + } + if (type == typeof(Guid)) { return new OpenApiSchema { Type = JsonSchemaType.String, Format = "uuid" }; } + if (type == typeof(Uri)) + { + return new OpenApiSchema { Type = JsonSchemaType.String, Format = "uri-reference" }; + } + return new OpenApiSchema(); } diff --git a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs index 0a56dd8..8aa3ef2 100644 --- a/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs +++ b/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs @@ -82,7 +82,7 @@ private static CodeWriter EmitSchemaConfiguration(CodeWriter writer, ValidatorMo hint.MetadataSchemaProperties.Length == 0 ) .Select(static hint => hint.Code) - ) + ) .Concat( model.Examples .Where(example => !schemaCodes.Contains(example.Code)) @@ -261,7 +261,8 @@ private static CodeWriter EmitExamples(CodeWriter writer, ValidatorModel model) Metadata = string.Join( "\u001F", example.MetadataValues.Select( - static metadata => metadata.Key + "=" + (metadata.Value?.ToString() ?? string.Empty) + static metadata => + metadata.Key + "=" + (metadata.Value?.ToString() ?? string.Empty) ) ) } @@ -329,6 +330,7 @@ IEnumerable metadataValues }; private static string ToLiteral(object? value) => + TryCreateDateOnlyOrTimeOnlyLiteral(value) ?? value switch { null => "null", @@ -346,9 +348,60 @@ private static string ToLiteral(object? value) => float floatValue => floatValue.ToString("R", CultureInfo.InvariantCulture) + "F", double doubleValue => doubleValue.ToString("R", CultureInfo.InvariantCulture) + "D", decimal decimalValue => decimalValue.ToString(CultureInfo.InvariantCulture) + "M", + DateTime dateTimeValue => + "new global::System.DateTime(" + + dateTimeValue.Ticks.ToString(CultureInfo.InvariantCulture) + + "L, global::System.DateTimeKind." + + dateTimeValue.Kind + + ")", + DateTimeOffset dateTimeOffsetValue => + "new global::System.DateTimeOffset(" + + dateTimeOffsetValue.Ticks.ToString(CultureInfo.InvariantCulture) + + "L, global::System.TimeSpan.FromTicks(" + + dateTimeOffsetValue.Offset.Ticks.ToString(CultureInfo.InvariantCulture) + + "L))", + TimeSpan timeSpanValue => + "global::System.TimeSpan.FromTicks(" + + timeSpanValue.Ticks.ToString(CultureInfo.InvariantCulture) + + "L)", + Guid guidValue => + "global::System.Guid.ParseExact(" + + ToStringLiteral(guidValue.ToString("D")) + + ", \"D\")", + Uri uriValue => + "new global::System.Uri(" + + ToStringLiteral(uriValue.OriginalString) + + ", global::System.UriKind.RelativeOrAbsolute)", _ => ToStringLiteral(value.ToString() ?? string.Empty) }; + private static string? TryCreateDateOnlyOrTimeOnlyLiteral(object? value) + { + if (value is null) + { + return null; + } + + var type = value.GetType(); + if (string.Equals(type.FullName, "System.DateOnly", StringComparison.Ordinal)) + { + var dayNumber = (int) type.GetProperty("DayNumber")!.GetValue(value, index: null)!; + return "global::System.DateOnly.FromDayNumber(" + + dayNumber.ToString(CultureInfo.InvariantCulture) + + ")"; + } + + if (string.Equals(type.FullName, "System.TimeOnly", StringComparison.Ordinal)) + { + var ticks = (long) type.GetProperty("Ticks")!.GetValue(value, index: null)!; + return "new global::System.TimeOnly(" + + ticks.ToString(CultureInfo.InvariantCulture) + + "L)"; + } + + return null; + } + private static string ToStringLiteral(string value) { var builder = new StringBuilder(value.Length + 2).Append('"'); diff --git a/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Shared.cs b/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Shared.cs index 3c053f8..5d3400a 100644 --- a/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Shared.cs +++ b/src/Light.PortableResults.Validation/Definitions/BuiltInValidationErrorDefinitions.Shared.cs @@ -86,17 +86,51 @@ private static MetadataValue CreateMetadataValue(T value) case TypeCode.Int64: return MetadataValue.FromInt64((long) (object) value); case TypeCode.UInt64: - return MetadataValue.FromString(((ulong) (object) value).ToString(CultureInfo.InvariantCulture)); + return MetadataValue.FromUInt64((ulong) (object) value); case TypeCode.Single: - return MetadataValue.FromDouble((float) (object) value); + return MetadataValue.FromSingle((float) (object) value); case TypeCode.Double: return MetadataValue.FromDouble((double) (object) value); case TypeCode.Decimal: return MetadataValue.FromDecimal((decimal) (object) value); case TypeCode.Char: - return MetadataValue.FromString(((char) (object) value).ToString()); + return MetadataValue.FromChar((char) (object) value); case TypeCode.String: return MetadataValue.FromString((string?) (object?) value); + case TypeCode.DateTime: + return MetadataValue.FromDateTime((DateTime) (object) value); + } + + if (value is DateTimeOffset dateTimeOffset) + { + return MetadataValue.FromDateTimeOffset(dateTimeOffset); + } + +#if NET10_0_OR_GREATER + if (value is DateOnly dateOnly) + { + return MetadataValue.FromDateOnly(dateOnly); + } + + if (value is TimeOnly timeOnly) + { + return MetadataValue.FromTimeOnly(timeOnly); + } +#endif + + if (value is TimeSpan timeSpan) + { + return MetadataValue.FromTimeSpan(timeSpan); + } + + if (value is Guid guid) + { + return MetadataValue.FromGuid(guid); + } + + if (value is Uri uri) + { + return MetadataValue.FromUri(uri); } if (value is MetadataObject metadataObject) diff --git a/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj b/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj index 3cf8d28..13b83cc 100644 --- a/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj +++ b/src/Light.PortableResults.Validation/Light.PortableResults.Validation.csproj @@ -1,6 +1,7 @@ - netstandard2.0 + + netstandard2.0;net10.0 false Framework-agnostic validation foundations for Light.PortableResults, including validation contexts, low-allocation checks, validator base classes, and validated value pipelines. @@ -11,6 +12,9 @@ - Supports validation errors that integrate with Light.PortableResults HTTP and messaging transports. - Validate your Microsoft.Extensions.Configuration options with Validator<T>. - Compatible with .NET Native AOT. + - Built-in comparison and range metadata now preserves the typed scalar vocabulary and its canonical + validation-message encodings. + - Adds a net10.0 asset so DateOnly and TimeOnly validation boundaries retain their dedicated metadata kinds. diff --git a/src/Light.PortableResults.Validation/Messaging/ValidationErrorMessageFormatting.cs b/src/Light.PortableResults.Validation/Messaging/ValidationErrorMessageFormatting.cs index 1f5bde0..4ae98dc 100644 --- a/src/Light.PortableResults.Validation/Messaging/ValidationErrorMessageFormatting.cs +++ b/src/Light.PortableResults.Validation/Messaging/ValidationErrorMessageFormatting.cs @@ -1,4 +1,5 @@ using System; +using Light.PortableResults.Metadata; namespace Light.PortableResults.Validation.Messaging; @@ -21,9 +22,60 @@ public static string FormatParameter(T value, ReadOnlyValidationContext conte return string.Empty; } + if (TryFormatCanonical(value, out var canonicalValue)) + { + return canonicalValue; + } + var culture = context.Options.CultureInfo; return value is IFormattable formattable ? formattable.ToString(null, culture) : value.ToString() ?? string.Empty; } + + private static bool TryFormatCanonical(T value, out string formattedValue) + { + MetadataValue metadataValue; + switch (value) + { + case ulong uint64: + metadataValue = MetadataValue.FromUInt64(uint64); + break; + case float single: + metadataValue = MetadataValue.FromSingle(single); + break; + case char character: + metadataValue = MetadataValue.FromChar(character); + break; + case DateTime dateTime: + metadataValue = MetadataValue.FromDateTime(dateTime); + break; + case DateTimeOffset dateTimeOffset: + metadataValue = MetadataValue.FromDateTimeOffset(dateTimeOffset); + break; +#if NET10_0_OR_GREATER + case DateOnly dateOnly: + metadataValue = MetadataValue.FromDateOnly(dateOnly); + break; + case TimeOnly timeOnly: + metadataValue = MetadataValue.FromTimeOnly(timeOnly); + break; +#endif + case TimeSpan timeSpan: + metadataValue = MetadataValue.FromTimeSpan(timeSpan); + break; + case Guid guid: + metadataValue = MetadataValue.FromGuid(guid); + break; + case Uri uri: + metadataValue = MetadataValue.FromUri(uri); + break; + default: + formattedValue = string.Empty; + return false; + } + + formattedValue = metadataValue.ToCanonicalString(); + return true; + } } diff --git a/src/Light.PortableResults.Validation/packages.lock.json b/src/Light.PortableResults.Validation/packages.lock.json index 5d7c93d..192a7c8 100644 --- a/src/Light.PortableResults.Validation/packages.lock.json +++ b/src/Light.PortableResults.Validation/packages.lock.json @@ -204,6 +204,85 @@ "System.Runtime.CompilerServices.Unsafe": "4.5.2" } } + }, + "net10.0": { + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.301, )", + "resolved": "10.0.301", + "contentHash": "S9U9Ye2bylMeDkzdNh+TsbRUNr6rkBORrnXDk45maPIxVvTmHV1SN2/qtZ/6yO7cdWmQv1LHv77yIeG6Q3nvMQ==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.301", + "Microsoft.SourceLink.Common": "10.0.301", + "System.IO.Hashing": "10.0.10" + } + }, + "Polyfill": { + "type": "Direct", + "requested": "[11.0.1, )", + "resolved": "11.0.1", + "contentHash": "9Jqm26Yb5D833BSU6rH3taFB8Y+A67Y3GJRC6XFEBKc2ueQSfd0B4bU3cAGsUNrWUJd1V9Sgm5U7I8SNG5zJvA==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.301", + "contentHash": "9Tah8f2BYuXlff9nIsJAeQJcqnijJTXNraIcTOF2f/f9kLmg0HNWPlMemnD6FBLd/UQcE0s9Vze2zCkA6nrZAA==", + "dependencies": { + "System.IO.Hashing": "10.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.301", + "contentHash": "oz44EclJdAYVZcRb9dSbWd+6RaE+8a4j5zdE+O7yw5qPRZZhu3pcpDZ/Moy6pLeIXZqBP1FMd8CSE0sgJAGV0g==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "eE839zyWZD/UBZYzP2lu9fhHX6RWe63/jz3Jxf+JumzO/db+Vc2s7CMiDwxk9ti2NTUft2tdRx31Rw2OjKbecA==" + }, + "light.portableresults": { + "type": "Project", + "dependencies": { + "Microsoft.Bcl.HashCode": "[6.0.0, )", + "Microsoft.Extensions.Options": "[10.0.10, )", + "Microsoft.Extensions.Primitives": "[10.0.10, )", + "Ulid": "[1.4.1, )" + } + }, + "Microsoft.Bcl.HashCode": { + "type": "CentralTransitive", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "GI4jcoi6eC9ZhNOQylIBaWOQjyGaR8T6N3tC1u8p3EXfndLCVNNWa+Zp+ocjvvS3kNBN09Zma2HXL0ezO0dRfw==" + }, + "Microsoft.Extensions.Options": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "CentralTransitive", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ==" + }, + "Ulid": { + "type": "CentralTransitive", + "requested": "[1.4.1, )", + "resolved": "1.4.1", + "contentHash": "V6crLJ8a29raWeNwxYGfH9RTKA3H0nR0D9LAGzN3KtEsbiiaWkUjDor6OT5Oz7pxCK+NaY2hu2FLoYEOa8oCkA==" + } } } } \ No newline at end of file diff --git a/src/Light.PortableResults/CloudEvents/MetadataValueAnnotationHelper.cs b/src/Light.PortableResults/CloudEvents/MetadataValueAnnotationHelper.cs index 2e69fc4..1071828 100644 --- a/src/Light.PortableResults/CloudEvents/MetadataValueAnnotationHelper.cs +++ b/src/Light.PortableResults/CloudEvents/MetadataValueAnnotationHelper.cs @@ -23,34 +23,22 @@ public static class MetadataValueAnnotationHelper /// public static MetadataValue WithAnnotation(MetadataValue value, MetadataValueAnnotation annotation) { - switch (value.Kind) + switch (value.JsonShape) { - case MetadataKind.Null: - return MetadataValue.FromNull(annotation); - case MetadataKind.Boolean: - value.TryGetBoolean(out var boolValue); - return MetadataValue.FromBoolean(boolValue, annotation); - case MetadataKind.Int64: - value.TryGetInt64(out var int64Value); - return MetadataValue.FromInt64(int64Value, annotation); - case MetadataKind.Double: - value.TryGetDouble(out var doubleValue); - return MetadataValue.FromDouble(doubleValue, annotation); - case MetadataKind.String: - value.TryGetString(out var stringValue); - return MetadataValue.FromString(stringValue, annotation); - case MetadataKind.Decimal: - value.TryGetDecimal(out var decimalValue); - return MetadataValue.FromDecimal(decimalValue, annotation); - case MetadataKind.Array: + case MetadataJsonShape.Array: value.TryGetArray(out var arrayValue); return MetadataValue.FromArray(WithAnnotation(arrayValue, annotation), annotation); - case MetadataKind.Object: + case MetadataJsonShape.Object: value.TryGetObject(out var objectValue); return MetadataValue.FromObject(WithAnnotation(objectValue, annotation), annotation); - default: - throw new ArgumentOutOfRangeException(nameof(value), value.Kind, "Unsupported metadata kind."); + case MetadataJsonShape.Null: + case MetadataJsonShape.Boolean: + case MetadataJsonShape.Number: + case MetadataJsonShape.String: + return value.WithAnnotation(annotation); } + + throw new ArgumentOutOfRangeException(nameof(value), value.Kind, "Unsupported metadata kind."); } /// diff --git a/src/Light.PortableResults/CloudEvents/Writing/CloudEventsResultExtensions.cs b/src/Light.PortableResults/CloudEvents/Writing/CloudEventsResultExtensions.cs index b4597ae..2792b34 100644 --- a/src/Light.PortableResults/CloudEvents/Writing/CloudEventsResultExtensions.cs +++ b/src/Light.PortableResults/CloudEvents/Writing/CloudEventsResultExtensions.cs @@ -585,35 +585,7 @@ private static ResolvedAttributes ResolveAttributes( return null; } - if (metadataValue.TryGetString(out var stringValue)) - { - return stringValue; - } - - if (metadataValue.TryGetBoolean(out var boolValue)) - { - return boolValue ? "true" : "false"; - } - - if (metadataValue.TryGetInt64(out var int64Value)) - { - return int64Value.ToString(CultureInfo.InvariantCulture); - } - - if (metadataValue.TryGetDouble(out var doubleValue)) - { - return doubleValue.ToString(CultureInfo.InvariantCulture); - } - - // TryGetDecimal also converts from Int64, Double, and numeric strings. The checks above are all strict - // kind checks and cannot be reached by a decimal, thus the position of this branch does not matter today - // - but the kind is checked explicitly so that it cannot swallow those values if it is ever moved up. - if (metadataValue.Kind == MetadataKind.Decimal && metadataValue.TryGetDecimal(out var decimalValue)) - { - return decimalValue.ToString(CultureInfo.InvariantCulture); - } - - return null; + return metadataValue.Kind.IsPrimitive() ? metadataValue.ToCanonicalString() : null; } private static DateTimeOffset? GetDateTimeOffsetAttribute(MetadataObject? attributes, string attributeName) diff --git a/src/Light.PortableResults/Http/Writing/Headers/DefaultHttpHeaderConversionService.cs b/src/Light.PortableResults/Http/Writing/Headers/DefaultHttpHeaderConversionService.cs index fcef76e..01f8885 100644 --- a/src/Light.PortableResults/Http/Writing/Headers/DefaultHttpHeaderConversionService.cs +++ b/src/Light.PortableResults/Http/Writing/Headers/DefaultHttpHeaderConversionService.cs @@ -34,5 +34,10 @@ public DefaultHttpHeaderConversionService(FrozenDictionary PrepareHttpHeader(string metadataKey, MetadataValue metadataValue) => Converters.TryGetValue(metadataKey, out var targetConverter) ? targetConverter.PrepareHttpHeader(metadataKey, metadataValue) : - new KeyValuePair(metadataKey, metadataValue.ToString()); + new KeyValuePair( + metadataKey, + metadataValue.Kind.IsPrimitive() ? + metadataValue.ToCanonicalString() : + metadataValue.ToString() + ); } diff --git a/src/Light.PortableResults/Light.PortableResults.csproj b/src/Light.PortableResults/Light.PortableResults.csproj index b983be8..79ebe26 100644 --- a/src/Light.PortableResults/Light.PortableResults.csproj +++ b/src/Light.PortableResults/Light.PortableResults.csproj @@ -1,6 +1,7 @@ - netstandard2.0 + + netstandard2.0;net10.0 false The Light.PortableResults package implements the core functionality: Results, Errors, Metadata, Functional Extensions, and serialization support for various formats like HTTP and CloudEvents. Compatible with Native AOT. Check out the integration packages Light.PortableResults.AspNetCore.MinimalApis or Light.PortableResults.AspNetCore.Mvc. @@ -9,10 +10,18 @@ - Contains core functionality: Results, Errors, Metadata, Functional Extensions, and JSON serialization support for HTTP and CloudEvents. - Compatible with .NET Native AOT. + - Adds native metadata kinds for UInt64, Single, Char, DateTime, DateTimeOffset, DateOnly, TimeOnly, + TimeSpan, Guid, and Uri, with canonical JSON, HTTP header, and CloudEvents string encodings. + - Adds a net10.0 asset with DateOnly and TimeOnly metadata APIs while retaining netstandard2.0. Breaking changes --------------------------------- + - Values of the ten newly typed BCL metadata types no longer flatten to MetadataKind.String. + Their JSON representations now use the canonical encoding of their dedicated kind. + - Whole-number Double and Single metadata values serialize with a trailing .0. + - 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. @@ -20,7 +29,7 @@ - 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 6 to 199 are reserved for future primitive kinds. This only affects callers that persisted or + values 16 to 199 are reserved for future primitive kinds. This only affects callers that persisted or transmitted the numeric value of MetadataKind. diff --git a/src/Light.PortableResults/Metadata/MetadataJsonShape.cs b/src/Light.PortableResults/Metadata/MetadataJsonShape.cs new file mode 100644 index 0000000..99301e3 --- /dev/null +++ b/src/Light.PortableResults/Metadata/MetadataJsonShape.cs @@ -0,0 +1,26 @@ +namespace Light.PortableResults.Metadata; + +#pragma warning disable CS8524 +/// +/// Describes the JSON shape of a metadata kind. +/// +public enum MetadataJsonShape : byte +{ + /// The JSON null shape. + Null, + + /// The JSON boolean shape. + Boolean, + + /// The JSON number shape. + Number, + + /// The JSON string shape. + String, + + /// The JSON array shape. + Array, + + /// The JSON object shape. + Object +} diff --git a/src/Light.PortableResults/Metadata/MetadataKind.cs b/src/Light.PortableResults/Metadata/MetadataKind.cs index 14e445f..35e13aa 100644 --- a/src/Light.PortableResults/Metadata/MetadataKind.cs +++ b/src/Light.PortableResults/Metadata/MetadataKind.cs @@ -8,9 +8,8 @@ namespace Light.PortableResults.Metadata; /// The numeric values of the members are a hard constraint: /// decides membership of the primitive set with a single /// comparison against . All primitive kinds must therefore be declared with values below -/// . The values 6 to 199 are reserved for future primitive kinds (CloudEvents, for example, -/// defines Binary, URI, URI-reference, and Timestamp attribute types that are currently flattened into -/// ), so that adding one of them later does not renumber the complex kinds again. +/// . Values 16 to 199 are reserved for future primitive kinds, so that adding one of them +/// later does not renumber the complex kinds again. /// /// public enum MetadataKind : byte @@ -45,7 +44,57 @@ public enum MetadataKind : byte /// Decimal = 5, - // 6 - 199 are reserved for future primitive kinds. Do not declare a primitive kind at 200 or above, + /// + /// The metadata value represents an unsigned integer number with 64 bits. This is considered a primitive value. + /// + UInt64 = 6, + + /// + /// The metadata value represents a floating-point number with 32 bits. This is considered a primitive value. + /// + Single = 7, + + /// + /// The metadata value represents a UTF-16 character. This is considered a primitive value. + /// + Char = 8, + + /// + /// The metadata value represents a UTC date and time. This is considered a primitive value. + /// + DateTime = 9, + + /// + /// The metadata value represents a date and time with an offset. This is considered a primitive value. + /// + DateTimeOffset = 10, + + /// + /// The metadata value represents a date without a time. This is considered a primitive value. + /// + DateOnly = 11, + + /// + /// The metadata value represents a time without a date. This is considered a primitive value. + /// + TimeOnly = 12, + + /// + /// The metadata value represents a duration. This is considered a primitive value. + /// + TimeSpan = 13, + + /// + /// The metadata value represents a globally unique identifier. This is considered a primitive value. + /// + Guid = 14, + + /// + /// The metadata value represents a URI reference. This is considered a primitive value. + /// + Uri = 15, + + // 16 - 199 are reserved for future primitive kinds. Do not declare a primitive kind at 200 or above, // and do not declare a complex kind below 200 - see the remarks on this enum for details. /// @@ -59,16 +108,3 @@ public enum MetadataKind : byte /// Object = 201 } - -/// -/// Provides extension methods for . -/// -public static class MetadataKindExtensions -{ - /// - /// Gets the value indicating whether the specified kind represents a primitive value. - /// - /// The metadata kind. - /// if the kind is primitive; otherwise, . - public static bool IsPrimitive(this MetadataKind kind) => kind < MetadataKind.Array; -} diff --git a/src/Light.PortableResults/Metadata/MetadataKindExtensions.cs b/src/Light.PortableResults/Metadata/MetadataKindExtensions.cs new file mode 100644 index 0000000..07ef4a8 --- /dev/null +++ b/src/Light.PortableResults/Metadata/MetadataKindExtensions.cs @@ -0,0 +1,43 @@ +namespace Light.PortableResults.Metadata; + +#pragma warning disable CS8524 +/// +/// Provides extension methods for . +/// +public static class MetadataKindExtensions +{ + /// + /// Gets the value indicating whether the specified kind represents a primitive value. + /// + /// The metadata kind. + /// if the kind is primitive; otherwise, . + public static bool IsPrimitive(this MetadataKind kind) => kind < MetadataKind.Array; + + /// + /// Gets the JSON shape represented by the specified metadata kind. + /// + /// The metadata kind. + /// The JSON shape. + public static MetadataJsonShape GetJsonShape(this MetadataKind kind) => + kind switch + { + MetadataKind.Null => MetadataJsonShape.Null, + MetadataKind.Boolean => MetadataJsonShape.Boolean, + MetadataKind.Int64 => MetadataJsonShape.Number, + MetadataKind.Double => MetadataJsonShape.Number, + MetadataKind.String => MetadataJsonShape.String, + MetadataKind.Decimal => MetadataJsonShape.Number, + MetadataKind.UInt64 => MetadataJsonShape.String, + MetadataKind.Single => MetadataJsonShape.Number, + MetadataKind.Char => MetadataJsonShape.String, + MetadataKind.DateTime => MetadataJsonShape.String, + MetadataKind.DateTimeOffset => MetadataJsonShape.String, + MetadataKind.DateOnly => MetadataJsonShape.String, + MetadataKind.TimeOnly => MetadataJsonShape.String, + MetadataKind.TimeSpan => MetadataJsonShape.String, + MetadataKind.Guid => MetadataJsonShape.String, + MetadataKind.Uri => MetadataJsonShape.String, + MetadataKind.Array => MetadataJsonShape.Array, + MetadataKind.Object => MetadataJsonShape.Object + }; +} diff --git a/src/Light.PortableResults/Metadata/MetadataPayload.cs b/src/Light.PortableResults/Metadata/MetadataPayload.cs index b99c667..b426fb0 100644 --- a/src/Light.PortableResults/Metadata/MetadataPayload.cs +++ b/src/Light.PortableResults/Metadata/MetadataPayload.cs @@ -1,3 +1,4 @@ +using System; using System.Runtime.InteropServices; namespace Light.PortableResults.Metadata; @@ -7,7 +8,7 @@ namespace Light.PortableResults.Metadata; /// Internal storage for . Uses explicit layout to minimize memory: /// /// -/// - and share offset 0 (union-style, 8 bytes). Only one is +/// - , , and share offset 0 (union-style, 8 bytes). Only one is /// meaningful at a time, determined by . /// /// @@ -22,7 +23,7 @@ namespace Light.PortableResults.Metadata; /// /// [StructLayout(LayoutKind.Explicit)] -internal readonly record struct MetadataPayload +internal readonly struct MetadataPayload { public MetadataPayload(long int64) => Int64 = int64; @@ -30,10 +31,17 @@ internal readonly record struct MetadataPayload public MetadataPayload(object? reference) => Reference = reference; - // Int64 and Float64 overlap at offset 0 - only one is valid based on MetadataKind + private MetadataPayload(ulong uint64) => UInt64 = uint64; + + public static MetadataPayload FromUInt64(ulong value) => new (value); + + // Int64, UInt64, and Float64 overlap at offset 0 - only one is valid based on MetadataKind [field: FieldOffset(0)] public long Int64 { get; } + [field: FieldOffset(0)] + public ulong UInt64 { get; } + [field: FieldOffset(0)] public double Float64 { get; } @@ -41,4 +49,10 @@ internal readonly record struct MetadataPayload // This prevents the GC from misinterpreting primitive values as object references. [field: FieldOffset(8)] public object? Reference { get; } + + public override bool Equals(object? obj) => + throw new NotSupportedException("A metadata payload cannot be compared without its metadata kind."); + + public override int GetHashCode() => + throw new NotSupportedException("A metadata payload cannot be hashed without its metadata kind."); } diff --git a/src/Light.PortableResults/Metadata/MetadataValue.cs b/src/Light.PortableResults/Metadata/MetadataValue.cs index e6fc4a9..2050fcb 100644 --- a/src/Light.PortableResults/Metadata/MetadataValue.cs +++ b/src/Light.PortableResults/Metadata/MetadataValue.cs @@ -1,14 +1,22 @@ using System; using System.Globalization; +using System.Xml; namespace Light.PortableResults.Metadata; +#pragma warning disable CS8524 // Unnamed enum values intentionally throw SwitchExpressionException. + /// -/// Represents a JSON-compatible metadata value. This is a discriminated union -/// that can hold null, boolean, int64, double, string, decimal, array, or object values. +/// Represents a JSON-compatible metadata value. This is a discriminated union whose payload, JSON shape, +/// and canonical text encoding are determined by . /// public readonly struct MetadataValue : IEquatable { + private const string DateTimeFormat = "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK"; + private const string DateTimeOffsetFormat = "yyyy-MM-dd'T'HH:mm:ss.FFFFFFFzzz"; + private const string DateOnlyFormat = "yyyy-MM-dd"; + private const string TimeOnlyFormat = "HH:mm:ss.FFFFFFF"; + /// /// Gets the default annotation for metadata values, which is . /// @@ -33,7 +41,12 @@ private MetadataValue( public MetadataKind Kind { get; } /// - /// Gets the annotation that specifies where this value should be serialized in HTTP responses. + /// Gets the JSON shape of the stored value. + /// + public MetadataJsonShape JsonShape => Kind.GetJsonShape(); + + /// + /// Gets the annotation that specifies where this value should be serialized. /// public MetadataValueAnnotation Annotation { get; } @@ -43,21 +56,14 @@ private MetadataValue( public static MetadataValue Null => new (MetadataKind.Null, default); /// - /// Creates a representing a null value with the specified annotation. + /// Creates a null metadata value. /// - /// The serialization annotation. - /// The metadata value. - public static MetadataValue FromNull( - MetadataValueAnnotation annotation = DefaultAnnotation - ) => + public static MetadataValue FromNull(MetadataValueAnnotation annotation = DefaultAnnotation) => new (MetadataKind.Null, default, annotation); /// - /// Creates a from a boolean. + /// Creates a metadata value from a Boolean. /// - /// The boolean value. - /// The serialization annotation. - /// The metadata value. public static MetadataValue FromBoolean( bool value, MetadataValueAnnotation annotation = DefaultAnnotation @@ -65,11 +71,8 @@ public static MetadataValue FromBoolean( new (MetadataKind.Boolean, new MetadataPayload(value ? 1L : 0L), annotation); /// - /// Creates a from an int64 value. + /// Creates a metadata value from a signed 64-bit integer. /// - /// The int64 value. - /// The serialization annotation. - /// The metadata value. public static MetadataValue FromInt64( long value, MetadataValueAnnotation annotation = DefaultAnnotation @@ -77,52 +80,36 @@ public static MetadataValue FromInt64( new (MetadataKind.Int64, new MetadataPayload(value), annotation); /// - /// Creates a from a double value. + /// Creates a metadata value from a double-precision floating-point number. /// - /// The double value. - /// The serialization annotation. - /// The metadata value. - /// - /// Thrown when is NaN or Infinity. - /// + /// Thrown when is not finite. public static MetadataValue FromDouble( double value, MetadataValueAnnotation annotation = DefaultAnnotation ) { - if (double.IsNaN(value) || double.IsInfinity(value)) - { - throw new ArgumentException("NaN and Infinity are not allowed in metadata values.", nameof(value)); - } - + ValidateFinite(value, nameof(value)); return new MetadataValue(MetadataKind.Double, new MetadataPayload(value), annotation); } /// - /// Creates a from a string value. + /// Creates a metadata value from a string. A null string becomes a null metadata value. /// - /// The string value. - /// The serialization annotation. - /// The metadata value. public static MetadataValue FromString( string? value, MetadataValueAnnotation annotation = DefaultAnnotation ) => - value is null ? Null : new MetadataValue(MetadataKind.String, new MetadataPayload(value), annotation); + value is null ? + FromNull(annotation) : + new MetadataValue(MetadataKind.String, new MetadataPayload(value), annotation); /// - /// - /// Creates a from a decimal value. - /// - /// - /// The decimal is boxed and stored in the reference slot of the payload. Storing it inline would push the - /// reference slot to offset 16 and grow every - including the ones stored inline - /// in arrays and objects - by 8 bytes. - /// + /// Creates a metadata value from a decimal number. /// - /// The decimal value. - /// The serialization annotation. - /// The metadata value. + /// + /// The decimal is boxed so that the reference slot remains at offset 8 and + /// remains 24 bytes on a 64-bit process. + /// public static MetadataValue FromDecimal( decimal value, MetadataValueAnnotation annotation = DefaultAnnotation @@ -130,16 +117,118 @@ public static MetadataValue FromDecimal( new (MetadataKind.Decimal, new MetadataPayload((object) value), annotation); /// - /// Creates a from a . + /// Creates a metadata value from an unsigned 64-bit integer. + /// + public static MetadataValue FromUInt64( + ulong value, + MetadataValueAnnotation annotation = DefaultAnnotation + ) => + new (MetadataKind.UInt64, MetadataPayload.FromUInt64(value), annotation); + + /// + /// Creates a metadata value from a single-precision floating-point number. + /// + /// Thrown when is not finite. + public static MetadataValue FromSingle( + float value, + MetadataValueAnnotation annotation = DefaultAnnotation + ) + { + ValidateFinite(value, nameof(value)); + return new MetadataValue(MetadataKind.Single, new MetadataPayload((double) value), annotation); + } + + /// + /// Creates a metadata value from a UTF-16 character. + /// + public static MetadataValue FromChar( + char value, + MetadataValueAnnotation annotation = DefaultAnnotation + ) => + new (MetadataKind.Char, new MetadataPayload(value), annotation); + + /// + /// Creates a metadata value from a date and time. Local values are converted to UTC. /// - /// The array value. - /// The serialization annotation. - /// The metadata value. /// - /// Thrown when includes CloudEvents extension attribute serialization. - /// Also thrown when includes header serialization - /// and contains non-primitive children. + /// Thrown when has . /// + public static MetadataValue FromDateTime( + DateTime value, + MetadataValueAnnotation annotation = DefaultAnnotation + ) + { + if (value.Kind == DateTimeKind.Unspecified) + { + throw new ArgumentException("A metadata DateTime must specify either Local or Utc kind.", nameof(value)); + } + + var utcValue = value.Kind == DateTimeKind.Utc ? value : value.ToUniversalTime(); + return new MetadataValue(MetadataKind.DateTime, new MetadataPayload(utcValue.Ticks), annotation); + } + + /// + /// Creates a metadata value from a date and time with an offset. + /// + public static MetadataValue FromDateTimeOffset( + DateTimeOffset value, + MetadataValueAnnotation annotation = DefaultAnnotation + ) => + new (MetadataKind.DateTimeOffset, new MetadataPayload((object) value), annotation); + +#if NET10_0_OR_GREATER + /// + /// Creates a metadata value from a date without a time. + /// + public static MetadataValue FromDateOnly( + DateOnly value, + MetadataValueAnnotation annotation = DefaultAnnotation + ) => + new (MetadataKind.DateOnly, new MetadataPayload(value.DayNumber), annotation); + + /// + /// Creates a metadata value from a time without a date. + /// + public static MetadataValue FromTimeOnly( + TimeOnly value, + MetadataValueAnnotation annotation = DefaultAnnotation + ) => + new (MetadataKind.TimeOnly, new MetadataPayload(value.Ticks), annotation); +#endif + + /// + /// Creates a metadata value from a duration. + /// + public static MetadataValue FromTimeSpan( + TimeSpan value, + MetadataValueAnnotation annotation = DefaultAnnotation + ) => + new (MetadataKind.TimeSpan, new MetadataPayload(value.Ticks), annotation); + + /// + /// Creates a metadata value from a globally unique identifier. + /// + public static MetadataValue FromGuid( + Guid value, + MetadataValueAnnotation annotation = DefaultAnnotation + ) => + new (MetadataKind.Guid, new MetadataPayload((object) value), annotation); + + /// + /// Creates a metadata value from a URI. A null URI becomes a null metadata value. + /// + public static MetadataValue FromUri( + Uri? value, + MetadataValueAnnotation annotation = DefaultAnnotation + ) => + value is null ? + FromNull(annotation) : + new MetadataValue(MetadataKind.Uri, new MetadataPayload(value), annotation); + + /// + /// Creates a metadata value from a metadata array. + /// + /// Thrown when the annotation cannot be applied to the array. public static MetadataValue FromArray( MetadataArray array, MetadataValueAnnotation annotation = DefaultAnnotation @@ -150,14 +239,9 @@ public static MetadataValue FromArray( } /// - /// Creates a from a . + /// Creates a metadata value from a metadata object. /// - /// The object value. - /// The serialization annotation. - /// The metadata value. - /// - /// Thrown when includes header serialization. - /// + /// Thrown when the annotation cannot be applied to an object. public static MetadataValue FromObject( MetadataObject @object, MetadataValueAnnotation annotation = DefaultAnnotation @@ -192,12 +276,8 @@ private static void ValidateArrayAnnotation(MetadataArray array, MetadataValueAn ); } - if (array.HasOnlyPrimitiveChildren) - { - return; - } - - if ((annotation & MetadataValueAnnotation.SerializeInHttpHeader) != 0) + if (!array.HasOnlyPrimitiveChildren && + (annotation & MetadataValueAnnotation.SerializeInHttpHeader) != 0) { throw new ArgumentException( "Arrays containing nested arrays or objects cannot be serialized as HTTP headers.", @@ -206,87 +286,81 @@ private static void ValidateArrayAnnotation(MetadataArray array, MetadataValueAn } } - // Implicit conversions for ergonomics - /// - /// Converts a boolean to a . - /// - /// The boolean value. - /// The metadata value. + /// Converts a Boolean to a metadata value. public static implicit operator MetadataValue(bool value) => FromBoolean(value); - /// - /// Converts an int32 to a . - /// - /// The int32 value. - /// The metadata value. + /// Converts a 32-bit signed integer to a metadata value. public static implicit operator MetadataValue(int value) => FromInt64(value); - /// - /// Converts an int64 to a . - /// - /// The int64 value. - /// The metadata value. + /// Converts an 8-bit unsigned integer to a metadata value. + public static implicit operator MetadataValue(byte value) => FromInt64(value); + + /// Converts an 8-bit signed integer to a metadata value. + public static implicit operator MetadataValue(sbyte value) => FromInt64(value); + + /// Converts a 16-bit signed integer to a metadata value. + public static implicit operator MetadataValue(short value) => FromInt64(value); + + /// Converts a 16-bit unsigned integer to a metadata value. + public static implicit operator MetadataValue(ushort value) => FromInt64(value); + + /// Converts a 32-bit unsigned integer to a metadata value. + public static implicit operator MetadataValue(uint value) => FromInt64(value); + + /// Converts a 64-bit signed integer to a metadata value. public static implicit operator MetadataValue(long value) => FromInt64(value); - /// - /// Converts a float to a . - /// - /// The float value. - /// The metadata value. - /// - /// Thrown when is NaN or Infinity. - /// - public static implicit operator MetadataValue(float value) => FromDouble(value); + /// Converts a 64-bit unsigned integer to a metadata value. + public static implicit operator MetadataValue(ulong value) => FromUInt64(value); - /// - /// Converts a double to a . - /// - /// The double value. - /// The metadata value. - /// - /// Thrown when is NaN or Infinity. - /// + /// Converts a single-precision floating-point number to a metadata value. + public static implicit operator MetadataValue(float value) => FromSingle(value); + + /// Converts a double-precision floating-point number to a metadata value. public static implicit operator MetadataValue(double value) => FromDouble(value); - /// - /// Converts a decimal to a . - /// - /// The decimal value. - /// The metadata value. + /// Converts a decimal number to a metadata value. public static implicit operator MetadataValue(decimal value) => FromDecimal(value); - /// - /// Converts a string to a . - /// - /// The string value. - /// The metadata value. + /// Converts a UTF-16 character to a metadata value. + public static implicit operator MetadataValue(char value) => FromChar(value); + + /// Converts a string to a metadata value. public static implicit operator MetadataValue(string? value) => FromString(value); - /// - /// Converts a to a . - /// - /// The array value. - /// The metadata value. + /// Converts a date and time to a metadata value. + public static implicit operator MetadataValue(DateTime value) => FromDateTime(value); + + /// Converts a date and time with an offset to a metadata value. + public static implicit operator MetadataValue(DateTimeOffset value) => FromDateTimeOffset(value); + +#if NET10_0_OR_GREATER + /// Converts a date without a time to a metadata value. + public static implicit operator MetadataValue(DateOnly value) => FromDateOnly(value); + + /// Converts a time without a date to a metadata value. + public static implicit operator MetadataValue(TimeOnly value) => FromTimeOnly(value); +#endif + + /// Converts a duration to a metadata value. + public static implicit operator MetadataValue(TimeSpan value) => FromTimeSpan(value); + + /// Converts a globally unique identifier to a metadata value. + public static implicit operator MetadataValue(Guid value) => FromGuid(value); + + /// Converts a URI to a metadata value. + public static implicit operator MetadataValue(Uri? value) => FromUri(value); + + /// Converts a metadata array to a metadata value. public static implicit operator MetadataValue(MetadataArray array) => FromArray(array); - /// - /// Converts a to a . - /// - /// The object value. - /// The metadata value. + /// Converts a metadata object to a metadata value. public static implicit operator MetadataValue(MetadataObject obj) => FromObject(obj); - // Typed getters - /// - /// Gets the value indicating whether this instance represents null. - /// + /// Gets whether this value represents null. public bool IsNull => Kind == MetadataKind.Null; - /// - /// Attempts to get a boolean value. - /// - /// When this method returns, contains the boolean value if present. - /// if the value is a boolean; otherwise, . + /// Attempts to get the stored Boolean. public bool TryGetBoolean(out bool value) { if (Kind == MetadataKind.Boolean) @@ -299,11 +373,7 @@ public bool TryGetBoolean(out bool value) return false; } - /// - /// Attempts to get an int64 value. - /// - /// When this method returns, contains the int64 value if present. - /// if the value is an int64; otherwise, . + /// Attempts to get the stored signed 64-bit integer. public bool TryGetInt64(out long value) { if (Kind == MetadataKind.Int64) @@ -317,13 +387,11 @@ public bool TryGetInt64(out long value) } /// - /// Attempts to get a double value. + /// Attempts to get a double-precision floating-point number. Single values are widened without loss. /// - /// When this method returns, contains the double value if present. - /// if the value is a double; otherwise, . public bool TryGetDouble(out double value) { - if (Kind == MetadataKind.Double) + if (Kind == MetadataKind.Double || Kind == MetadataKind.Single) { value = _payload.Float64; return true; @@ -333,11 +401,7 @@ public bool TryGetDouble(out double value) return false; } - /// - /// Attempts to get a string value. - /// - /// When this method returns, contains the string value if present. - /// if the value is a string; otherwise, . + /// Attempts to get the stored string. public bool TryGetString(out string? value) { if (Kind == MetadataKind.String) @@ -351,46 +415,311 @@ public bool TryGetString(out string? value) } /// - /// Attempts to get a decimal value. Besides , this method also converts - /// from , , and numeric strings. + /// Attempts to get a decimal number. Signed integers, doubles, and invariant numeric strings are converted. /// - /// When this method returns, contains the decimal value if present. - /// if the value can be represented as a decimal; otherwise, . public bool TryGetDecimal(out decimal value) { - switch (Kind) + if (Kind == MetadataKind.Decimal && _payload.Reference is decimal decimalValue) + { + value = decimalValue; + return true; + } + + if (Kind == MetadataKind.String && _payload.Reference is string stringValue) + { + return decimal.TryParse( + stringValue, + NumberStyles.Number, + CultureInfo.InvariantCulture, + out value + ); + } + + if (Kind == MetadataKind.Int64) + { + value = _payload.Int64; + return true; + } + + if (Kind == MetadataKind.Double) + { + try + { + value = (decimal) _payload.Float64; + return true; + } + catch (OverflowException) + { + value = 0; + return false; + } + } + + value = 0; + return false; + } + + /// Attempts to get an unsigned 64-bit integer or parse its canonical string encoding. + public bool TryGetUInt64(out ulong value) + { + if (Kind == MetadataKind.UInt64) + { + value = _payload.UInt64; + return true; + } + + if (TryGetRawString(out var text) && + ulong.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out value) && + string.Equals(FormatUInt64(value), text, StringComparison.Ordinal)) + { + return true; + } + + value = 0; + return false; + } + + /// Attempts to get a single-precision number or parse its canonical string encoding. + public bool TryGetSingle(out float value) + { + if (Kind == MetadataKind.Single) + { + value = (float) _payload.Float64; + return true; + } + + if (TryGetRawString(out var text) && + float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value) && + !float.IsNaN(value) && + !float.IsInfinity(value) && + string.Equals(FormatSingle(value), text, StringComparison.Ordinal)) + { + return true; + } + + value = 0; + return false; + } + + /// Attempts to get a character or parse its canonical single-character string encoding. + public bool TryGetChar(out char value) + { + if (Kind == MetadataKind.Char) + { + value = (char) _payload.Int64; + return true; + } + + if (TryGetRawString(out var text) && text.Length == 1) + { + value = text[0]; + return true; + } + + value = default; + return false; + } + + /// Attempts to get a UTC date-time or parse its canonical RFC 3339 encoding. + public bool TryGetDateTime(out DateTime value) + { + if (Kind == MetadataKind.DateTime) { - case MetadataKind.Decimal when _payload.Reference is decimal @decimal: - value = @decimal; + try + { + value = new DateTime(_payload.Int64, DateTimeKind.Utc); return true; - case MetadataKind.String when _payload.Reference is string @string: - return decimal.TryParse(@string, NumberStyles.Number, CultureInfo.InvariantCulture, out value); - case MetadataKind.Double: - try + } + catch (ArgumentOutOfRangeException) + { + value = default; + return false; + } + } + + if (TryGetRawString(out var text) && + DateTime.TryParse( + text, + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out value + ) && + value.Kind == DateTimeKind.Utc && + string.Equals(FormatDateTime(value), text, StringComparison.Ordinal)) + { + return true; + } + + value = default; + return false; + } + + /// Attempts to get a date-time offset or parse its canonical RFC 3339 encoding. + public bool TryGetDateTimeOffset(out DateTimeOffset value) + { + if (Kind == MetadataKind.DateTimeOffset && _payload.Reference is DateTimeOffset dateTimeOffset) + { + value = dateTimeOffset; + return true; + } + + if (TryGetRawString(out var text) && + DateTimeOffset.TryParse( + text, + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out value + ) && + string.Equals(FormatDateTimeOffset(value), text, StringComparison.Ordinal)) + { + return true; + } + + value = default; + return false; + } + +#if NET10_0_OR_GREATER + /// Attempts to get a date or parse its canonical RFC 3339 full-date encoding. + public bool TryGetDateOnly(out DateOnly value) + { + if (Kind == MetadataKind.DateOnly) + { + try + { + value = DateOnly.FromDayNumber(checked((int) _payload.Int64)); + return true; + } + catch (Exception exception) when (exception is ArgumentOutOfRangeException or OverflowException) + { + value = default; + return false; + } + } + + if (TryGetRawString(out var text) && + DateOnly.TryParseExact( + text, + DateOnlyFormat, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out value + ) && + string.Equals(FormatDateOnly(value.DayNumber), text, StringComparison.Ordinal)) + { + return true; + } + + value = default; + return false; + } + + /// Attempts to get a time or parse its canonical RFC 3339 full-time encoding. + public bool TryGetTimeOnly(out TimeOnly value) + { + if (Kind == MetadataKind.TimeOnly) + { + try + { + value = new TimeOnly(_payload.Int64); + return true; + } + catch (ArgumentOutOfRangeException) + { + value = default; + return false; + } + } + + if (TryGetRawString(out var text) && + TimeOnly.TryParse( + text, + CultureInfo.InvariantCulture, + DateTimeStyles.None, + out value + ) && + string.Equals(FormatTimeOnly(value.Ticks), text, StringComparison.Ordinal)) + { + return true; + } + + value = default; + return false; + } +#endif + + /// Attempts to get a duration or parse its canonical ISO 8601 encoding. + public bool TryGetTimeSpan(out TimeSpan value) + { + if (Kind == MetadataKind.TimeSpan) + { + value = new TimeSpan(_payload.Int64); + return true; + } + + if (TryGetRawString(out var text)) + { + try + { + value = XmlConvert.ToTimeSpan(text); + if (string.Equals(FormatTimeSpan(value), text, StringComparison.Ordinal)) { - value = (decimal) _payload.Float64; return true; } - catch (OverflowException) - { - value = 0; - return false; - } + } + catch (FormatException) + { + // The common false path returns below. + } + } - case MetadataKind.Int64: - value = _payload.Int64; - return true; - default: - value = 0; - return false; + value = default; + return false; + } + + /// Attempts to get a GUID or parse its canonical lowercase RFC 4122 encoding. + public bool TryGetGuid(out Guid value) + { + if (Kind == MetadataKind.Guid && _payload.Reference is Guid guid) + { + value = guid; + return true; + } + + if (TryGetRawString(out var text) && + Guid.TryParseExact(text, "D", out value) && + string.Equals(FormatGuid(value), text, StringComparison.Ordinal)) + { + return true; } + + value = default; + return false; } /// - /// Attempts to get a value. + /// Attempts to get a URI. String values are accepted only when they are absolute URI canonical encodings. /// - /// When this method returns, contains the array value if present. - /// if the value is an array; otherwise, . + public bool TryGetUri(out Uri? value) + { + if (Kind == MetadataKind.Uri && _payload.Reference is Uri uri) + { + value = uri; + return true; + } + + if (TryGetRawString(out var text) && + Uri.TryCreate(text, UriKind.Absolute, out value) && + string.Equals(value.OriginalString, text, StringComparison.Ordinal)) + { + return true; + } + + value = null; + return false; + } + + /// Attempts to get a metadata array. public bool TryGetArray(out MetadataArray value) { if (Kind == MetadataKind.Array && _payload.Reference is MetadataArrayData data) @@ -403,11 +732,7 @@ public bool TryGetArray(out MetadataArray value) return false; } - /// - /// Attempts to get a value. - /// - /// When this method returns, contains the object value if present. - /// if the value is an object; otherwise, . + /// Attempts to get a metadata object. public bool TryGetObject(out MetadataObject value) { if (Kind == MetadataKind.Object && _payload.Reference is MetadataObjectData data) @@ -420,27 +745,72 @@ public bool TryGetObject(out MetadataObject value) return false; } - /// - /// Gets the value as a . - /// - /// The array value. + /// Gets the value as a metadata array. /// Thrown when the value is not an array. public MetadataArray AsArray() => - TryGetArray(out var arr) ? arr : throw new InvalidOperationException($"Cannot convert {Kind} to Array."); + TryGetArray(out var array) ? + array : + throw new InvalidOperationException($"Cannot convert {Kind} to Array."); - /// - /// Gets the value as a . - /// - /// The object value. + /// Gets the value as a metadata object. /// Thrown when the value is not an object. public MetadataObject AsObject() => - TryGetObject(out var obj) ? obj : throw new InvalidOperationException($"Cannot convert {Kind} to Object."); + TryGetObject(out var @object) ? + @object : + throw new InvalidOperationException($"Cannot convert {Kind} to Object."); /// - /// Determines whether this instance and another specified have the same value. + /// Returns the canonical text encoding of a primitive metadata value. /// - /// The other value to compare. - /// if the values are equal; otherwise, . + /// + /// Thrown for an array, object, or malformed payload. + /// + public string ToCanonicalString() => + Kind switch + { + MetadataKind.Null => "null", + MetadataKind.Boolean => _payload.Int64 != 0 ? "true" : "false", + MetadataKind.Int64 => _payload.Int64.ToString(CultureInfo.InvariantCulture), + MetadataKind.Double => FormatDouble(_payload.Float64), + MetadataKind.String => GetRequiredReference(), + MetadataKind.Decimal => GetRequiredReference().ToString(CultureInfo.InvariantCulture), + MetadataKind.UInt64 => FormatUInt64(_payload.UInt64), + MetadataKind.Single => FormatSingle((float) _payload.Float64), + MetadataKind.Char => ((char) _payload.Int64).ToString(), + MetadataKind.DateTime => FormatStoredDateTime(_payload.Int64), + MetadataKind.DateTimeOffset => FormatDateTimeOffset(GetRequiredReference()), + MetadataKind.DateOnly => FormatDateOnly(_payload.Int64), + MetadataKind.TimeOnly => FormatTimeOnly(_payload.Int64), + MetadataKind.TimeSpan => FormatTimeSpan(new TimeSpan(_payload.Int64)), + MetadataKind.Guid => FormatGuid(GetRequiredReference()), + MetadataKind.Uri => GetRequiredReference().OriginalString, + MetadataKind.Array => ThrowComplexCanonicalText(MetadataKind.Array), + MetadataKind.Object => ThrowComplexCanonicalText(MetadataKind.Object) + }; + + /// + /// Attempts to write the canonical text encoding to the supplied destination. + /// + /// The destination for the encoded characters. + /// The number of characters written. + /// when the destination was large enough; otherwise, . + public bool TryFormatCanonical(Span destination, out int charsWritten) + { + var canonicalText = ToCanonicalString(); + if (canonicalText.AsSpan().TryCopyTo(destination)) + { + charsWritten = canonicalText.Length; + return true; + } + + charsWritten = 0; + return false; + } + + internal MetadataValue WithAnnotation(MetadataValueAnnotation annotation) => + new (Kind, _payload, annotation); + + /// public bool Equals(MetadataValue other) { if (Kind != other.Kind) @@ -451,20 +821,25 @@ public bool Equals(MetadataValue other) return Kind switch { MetadataKind.Null => true, - MetadataKind.Boolean or MetadataKind.Int64 => _payload.Int64 == other._payload.Int64, + MetadataKind.Boolean => _payload.Int64 == other._payload.Int64, + MetadataKind.Int64 => _payload.Int64 == other._payload.Int64, MetadataKind.Double => _payload.Float64.Equals(other._payload.Float64), MetadataKind.String => string.Equals( (string?) _payload.Reference, (string?) other._payload.Reference, StringComparison.Ordinal ), - // decimal.Equals is scale-insensitive, thus 19.50m and 19.5m are equal metadata values, - // although the scale is preserved when they are serialized. The reference is matched instead of - // being cast so that a payload which does not carry a boxed decimal is unequal like every other - // kind that cannot be interpreted, rather than throwing. - MetadataKind.Decimal => _payload.Reference is decimal @decimal && - other._payload.Reference is decimal otherDecimal && - @decimal == otherDecimal, + MetadataKind.Decimal => BoxedEquals(other), + MetadataKind.UInt64 => _payload.UInt64 == other._payload.UInt64, + MetadataKind.Single => ((float) _payload.Float64).Equals((float) other._payload.Float64), + MetadataKind.Char => _payload.Int64 == other._payload.Int64, + MetadataKind.DateTime => _payload.Int64 == other._payload.Int64, + MetadataKind.DateTimeOffset => BoxedEquals(other), + MetadataKind.DateOnly => _payload.Int64 == other._payload.Int64, + MetadataKind.TimeOnly => _payload.Int64 == other._payload.Int64, + MetadataKind.TimeSpan => _payload.Int64 == other._payload.Int64, + MetadataKind.Guid => BoxedEquals(other), + MetadataKind.Uri => UriEquals(other), MetadataKind.Array => ((MetadataArrayData?) _payload.Reference)?.Equals( (MetadataArrayData?) other._payload.Reference ) ?? @@ -472,84 +847,183 @@ other._payload.Reference is decimal otherDecimal && MetadataKind.Object => ((MetadataObjectData?) _payload.Reference)?.Equals( (MetadataObjectData?) other._payload.Reference ) ?? - false, - _ => false + false }; } - /// - /// Determines whether this instance and a specified object have the same value. - /// - /// The object to compare. - /// if the objects are equal; otherwise, . + /// public override bool Equals(object? obj) => obj is MetadataValue other && Equals(other); - /// - /// Gets the hash code for this instance. - /// - /// The hash code. + /// public override int GetHashCode() { - var hashCodeBuilder = new HashCode(); - hashCodeBuilder.Add(Kind); - switch (Kind) + var payloadHashCode = Kind switch { - case MetadataKind.Null: - break; - case MetadataKind.Boolean or MetadataKind.Int64: - hashCodeBuilder.Add(_payload.Int64); - break; - case MetadataKind.Double: - hashCodeBuilder.Add(_payload.Float64); - break; - // decimal.GetHashCode is scale-insensitive and consistent with decimal.Equals, thus boxed decimals - // can be hashed through the reference like the other reference-backed kinds. - case MetadataKind.String or MetadataKind.Decimal or MetadataKind.Array or MetadataKind.Object: - hashCodeBuilder.Add(_payload.Reference?.GetHashCode() ?? 0); - break; - } + MetadataKind.Null => 0, + MetadataKind.Boolean => _payload.Int64.GetHashCode(), + MetadataKind.Int64 => _payload.Int64.GetHashCode(), + MetadataKind.Double => _payload.Float64.GetHashCode(), + MetadataKind.String => StringComparer.Ordinal.GetHashCode((string?) _payload.Reference ?? string.Empty), + MetadataKind.Decimal => _payload.Reference is decimal decimalValue ? decimalValue.GetHashCode() : 0, + MetadataKind.UInt64 => _payload.UInt64.GetHashCode(), + MetadataKind.Single => ((float) _payload.Float64).GetHashCode(), + MetadataKind.Char => ((char) _payload.Int64).GetHashCode(), + MetadataKind.DateTime => _payload.Int64.GetHashCode(), + MetadataKind.DateTimeOffset => _payload.Reference is DateTimeOffset dateTimeOffset ? + dateTimeOffset.GetHashCode() : + 0, + MetadataKind.DateOnly => _payload.Int64.GetHashCode(), + MetadataKind.TimeOnly => _payload.Int64.GetHashCode(), + MetadataKind.TimeSpan => _payload.Int64.GetHashCode(), + MetadataKind.Guid => _payload.Reference is Guid guid ? guid.GetHashCode() : 0, + MetadataKind.Uri => _payload.Reference is Uri uri ? + StringComparer.Ordinal.GetHashCode(uri.OriginalString) : + 0, + MetadataKind.Array => _payload.Reference?.GetHashCode() ?? 0, + MetadataKind.Object => _payload.Reference?.GetHashCode() ?? 0 + }; - return hashCodeBuilder.ToHashCode(); + return HashCode.Combine(Kind, payloadHashCode); } - /// - /// Determines whether two instances are equal. - /// - /// The left instance. - /// The right instance. - /// if the instances are equal; otherwise, . + /// Determines whether two metadata values are equal. public static bool operator ==(MetadataValue left, MetadataValue right) => left.Equals(right); - /// - /// Determines whether two instances are not equal. - /// - /// The left instance. - /// The right instance. - /// if the instances are not equal; otherwise, . + /// Determines whether two metadata values are unequal. public static bool operator !=(MetadataValue left, MetadataValue right) => !left.Equals(right); /// - /// Returns the string representation of this instance. + /// Returns a debug-oriented representation of the metadata value. /// - /// The string representation. - /// - /// Thrown when the value kind is unknown or when its payload cannot be interpreted for that kind. - /// public override string ToString() => Kind switch { - MetadataKind.Null => "null", - MetadataKind.Boolean => _payload.Int64 != 0 ? "true" : "false", - MetadataKind.Int64 => _payload.Int64.ToString(CultureInfo.InvariantCulture), - MetadataKind.Double => _payload.Float64.ToString(CultureInfo.InvariantCulture), - MetadataKind.String => $"\"{_payload.Reference}\"", - // A payload that does not carry a boxed decimal falls through to the arm below instead of throwing - // a NullReferenceException or an InvalidCastException, mirroring the other reference-backed kinds. - MetadataKind.Decimal when _payload.Reference is decimal @decimal => - @decimal.ToString(CultureInfo.InvariantCulture), + MetadataKind.Null => ToCanonicalString(), + MetadataKind.Boolean => ToCanonicalString(), + MetadataKind.Int64 => ToCanonicalString(), + MetadataKind.Double => ToCanonicalString(), + MetadataKind.String => "\"" + ToCanonicalString() + "\"", + MetadataKind.Decimal => ToCanonicalString(), + MetadataKind.UInt64 => "\"" + ToCanonicalString() + "\"", + MetadataKind.Single => ToCanonicalString(), + MetadataKind.Char => "\"" + ToCanonicalString() + "\"", + MetadataKind.DateTime => "\"" + ToCanonicalString() + "\"", + MetadataKind.DateTimeOffset => "\"" + ToCanonicalString() + "\"", + MetadataKind.DateOnly => "\"" + ToCanonicalString() + "\"", + MetadataKind.TimeOnly => "\"" + ToCanonicalString() + "\"", + MetadataKind.TimeSpan => "\"" + ToCanonicalString() + "\"", + MetadataKind.Guid => "\"" + ToCanonicalString() + "\"", + MetadataKind.Uri => "\"" + ToCanonicalString() + "\"", MetadataKind.Array => - ((MetadataArrayData?) _payload.Reference)?.ToString() ?? MetadataArray.EmptyArrayStringRepresentation, - MetadataKind.Object => "{...}", - _ => throw new InvalidOperationException($"Kind '{Kind}' is unknown or its payload is invalid") + ((MetadataArrayData?) _payload.Reference)?.ToString() ?? + MetadataArray.EmptyArrayStringRepresentation, + MetadataKind.Object => "{...}" }; + + private bool TryGetRawString(out string value) + { + if (Kind == MetadataKind.String && _payload.Reference is string text) + { + value = text; + return true; + } + + value = string.Empty; + return false; + } + + private T GetRequiredReference() + { + if (_payload.Reference is T value) + { + return value; + } + + throw new InvalidOperationException($"Kind '{Kind}' has an invalid payload."); + } + + private bool BoxedEquals(MetadataValue other) + where T : struct, IEquatable => + _payload.Reference is T value && + other._payload.Reference is T otherValue && + value.Equals(otherValue); + + private bool UriEquals(MetadataValue other) => + _payload.Reference is Uri uri && + other._payload.Reference is Uri otherUri && + string.Equals(uri.OriginalString, otherUri.OriginalString, StringComparison.Ordinal); + + private static void ValidateFinite(double value, string parameterName) + { + if (double.IsNaN(value) || double.IsInfinity(value)) + { + throw new ArgumentException("NaN and Infinity are not allowed in metadata values.", parameterName); + } + } + + private static string FormatUInt64(ulong value) => value.ToString(CultureInfo.InvariantCulture); + + private static string FormatSingle(float value) => + EnsureFloatingPointMarker(value.ToString("R", CultureInfo.InvariantCulture)); + + private static string FormatDouble(double value) => + EnsureFloatingPointMarker(value.ToString("R", CultureInfo.InvariantCulture)); + + private static string EnsureFloatingPointMarker(string value) => + value.IndexOf('.') < 0 && value.IndexOf('E') < 0 && value.IndexOf('e') < 0 ? + value + ".0" : + value; + + private static string FormatStoredDateTime(long ticks) + { + try + { + return FormatDateTime(new DateTime(ticks, DateTimeKind.Utc)); + } + catch (ArgumentOutOfRangeException exception) + { + throw new InvalidOperationException("The DateTime metadata payload is invalid.", exception); + } + } + + private static string FormatDateTime(DateTime value) => + value.ToString(DateTimeFormat, CultureInfo.InvariantCulture); + + private static string FormatDateTimeOffset(DateTimeOffset value) => + value.ToString(DateTimeOffsetFormat, CultureInfo.InvariantCulture); + + private static string FormatDateOnly(long dayNumber) + { + try + { + var ticks = checked(dayNumber * TimeSpan.TicksPerDay); + return new DateTime(ticks, DateTimeKind.Unspecified).ToString( + DateOnlyFormat, + CultureInfo.InvariantCulture + ); + } + catch (Exception exception) when (exception is ArgumentOutOfRangeException or OverflowException) + { + throw new InvalidOperationException("The DateOnly metadata payload is invalid.", exception); + } + } + + private static string FormatTimeOnly(long ticks) + { + if (ticks < 0 || ticks >= TimeSpan.TicksPerDay) + { + throw new InvalidOperationException("The TimeOnly metadata payload is invalid."); + } + + return new DateTime(ticks, DateTimeKind.Unspecified).ToString(TimeOnlyFormat, CultureInfo.InvariantCulture); + } + + private static string FormatTimeSpan(TimeSpan value) => XmlConvert.ToString(value); + + private static string FormatGuid(Guid value) => value.ToString("D", CultureInfo.InvariantCulture).ToLowerInvariant(); + + private static string ThrowComplexCanonicalText(MetadataKind kind) => + throw new InvalidOperationException($"Kind '{kind}' does not have a primitive canonical text encoding."); } + +#pragma warning restore CS8524 diff --git a/src/Light.PortableResults/SharedJsonSerialization/Writing/MetadataExtensions.cs b/src/Light.PortableResults/SharedJsonSerialization/Writing/MetadataExtensions.cs index c000236..b943429 100644 --- a/src/Light.PortableResults/SharedJsonSerialization/Writing/MetadataExtensions.cs +++ b/src/Light.PortableResults/SharedJsonSerialization/Writing/MetadataExtensions.cs @@ -53,45 +53,48 @@ MetadataValueAnnotation requiredAnnotation throw new ArgumentNullException(nameof(writer)); } - switch (value.Kind) + switch (value.JsonShape) { - case MetadataKind.Null: + case MetadataJsonShape.Null: writer.WriteNullValue(); break; - case MetadataKind.Boolean: + case MetadataJsonShape.Boolean: value.TryGetBoolean(out var booleanMetadataValue); writer.WriteBooleanValue(booleanMetadataValue); break; - case MetadataKind.Int64: - value.TryGetInt64(out var int64MetadataValue); - writer.WriteNumberValue(int64MetadataValue); + case MetadataJsonShape.Number: + WriteNumberValue(writer, value); break; - case MetadataKind.Double: - value.TryGetDouble(out var doubleMetadataValue); - writer.WriteNumberValue(doubleMetadataValue); + case MetadataJsonShape.String: + writer.WriteStringValue(value.ToCanonicalString()); break; - case MetadataKind.String: - value.TryGetString(out var stringMetadataValue); - writer.WriteStringValue(stringMetadataValue); - break; - case MetadataKind.Decimal: - value.TryGetDecimal(out var decimalMetadataValue); - writer.WriteNumberValue(decimalMetadataValue); - break; - case MetadataKind.Array: + case MetadataJsonShape.Array: value.TryGetArray(out var arrayMetadataValue); writer.WriteMetadataArray(arrayMetadataValue, requiredAnnotation); break; - case MetadataKind.Object: + case MetadataJsonShape.Object: value.TryGetObject(out var objectMetadataValue); writer.WriteMetadataObject(objectMetadataValue, requiredAnnotation); break; - default: - writer.WriteNullValue(); - break; } } + private static void WriteNumberValue(Utf8JsonWriter writer, MetadataValue value) + { + if (value.Kind is not MetadataKind.Int64 and + not MetadataKind.Double and + not MetadataKind.Decimal and + not MetadataKind.Single) + { + throw CreateNonNumericKindException(value.Kind); + } + + writer.WriteRawValue(value.ToCanonicalString()); + } + + private static InvalidOperationException CreateNonNumericKindException(MetadataKind kind) => + new ($"Metadata kind '{kind}' does not have a JSON number shape."); + /// /// Writes the JSON representation for the specified metadata array. /// diff --git a/src/Light.PortableResults/packages.lock.json b/src/Light.PortableResults/packages.lock.json index f58a949..84ec8ac 100644 --- a/src/Light.PortableResults/packages.lock.json +++ b/src/Light.PortableResults/packages.lock.json @@ -193,6 +193,88 @@ "System.Threading.Tasks.Extensions": "4.6.3" } } + }, + "net10.0": { + "Microsoft.Bcl.HashCode": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "GI4jcoi6eC9ZhNOQylIBaWOQjyGaR8T6N3tC1u8p3EXfndLCVNNWa+Zp+ocjvvS3kNBN09Zma2HXL0ezO0dRfw==" + }, + "Microsoft.Extensions.Options": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "srnhnk7nE8krBiIXp71LvBmKBtraBONWSRzdjJgRv1Ko9Mp8IVNqv4vIS9hGeVteBig8aQkva9ZG+sC+o5sVcA==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "10.0.10", + "Microsoft.Extensions.Primitives": "10.0.10" + } + }, + "Microsoft.Extensions.Primitives": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "5wu/GrYVd8mG2DVUw3vFJzF+O336TyTGg/Kmcgw9bfwYhCoFiV5lR5QeEmKecJyrW4W54nMfD3p3589E8a7czQ==" + }, + "Microsoft.SourceLink.GitHub": { + "type": "Direct", + "requested": "[10.0.301, )", + "resolved": "10.0.301", + "contentHash": "S9U9Ye2bylMeDkzdNh+TsbRUNr6rkBORrnXDk45maPIxVvTmHV1SN2/qtZ/6yO7cdWmQv1LHv77yIeG6Q3nvMQ==", + "dependencies": { + "Microsoft.Build.Tasks.Git": "10.0.301", + "Microsoft.SourceLink.Common": "10.0.301", + "System.IO.Hashing": "10.0.10" + } + }, + "Polyfill": { + "type": "Direct", + "requested": "[11.0.1, )", + "resolved": "11.0.1", + "contentHash": "9Jqm26Yb5D833BSU6rH3taFB8Y+A67Y3GJRC6XFEBKc2ueQSfd0B4bU3cAGsUNrWUJd1V9Sgm5U7I8SNG5zJvA==" + }, + "System.Collections.Immutable": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "Ih5zrydoDc1H5I0eNP6f4Lzw3cjsPMN4Nikd86kbyi66y0flkM9GfjVo62aUbcxeTbypzBCFAFQ+/6RF2jRORg==" + }, + "System.Text.Json": { + "type": "Direct", + "requested": "[10.0.10, )", + "resolved": "10.0.10", + "contentHash": "bmsO6UdYtBdtn32zYXfsh7KlyTIzV/3V9hdT9RIb4pXKgYOsNxXR+VbWigNwBtNFVGYGm6Hwmqw5a+/IWFd36Q==" + }, + "Ulid": { + "type": "Direct", + "requested": "[1.4.1, )", + "resolved": "1.4.1", + "contentHash": "V6crLJ8a29raWeNwxYGfH9RTKA3H0nR0D9LAGzN3KtEsbiiaWkUjDor6OT5Oz7pxCK+NaY2hu2FLoYEOa8oCkA==" + }, + "Microsoft.Build.Tasks.Git": { + "type": "Transitive", + "resolved": "10.0.301", + "contentHash": "9Tah8f2BYuXlff9nIsJAeQJcqnijJTXNraIcTOF2f/f9kLmg0HNWPlMemnD6FBLd/UQcE0s9Vze2zCkA6nrZAA==", + "dependencies": { + "System.IO.Hashing": "10.0.10" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "z/2xXlFw2aLGjHyEm6E0tQ+In6VfzQzTrtArbQ2c0TQE16ZbyDCMGPvaUT9I0s8rgy9sRWlU2P9waW37qV04qA==" + }, + "Microsoft.SourceLink.Common": { + "type": "Transitive", + "resolved": "10.0.301", + "contentHash": "oz44EclJdAYVZcRb9dSbWd+6RaE+8a4j5zdE+O7yw5qPRZZhu3pcpDZ/Moy6pLeIXZqBP1FMd8CSE0sgJAGV0g==" + }, + "System.IO.Hashing": { + "type": "Transitive", + "resolved": "10.0.10", + "contentHash": "eE839zyWZD/UBZYzP2lu9fhHX6RWe63/jz3Jxf+JumzO/db+Vc2s7CMiDwxk9ti2NTUft2tdRx31Rw2OjKbecA==" + } } } } \ No newline at end of file diff --git a/tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/IntegrationTests/ExtendedAppIntegrationTests.ToMinimalApiResult_ShouldHonorHeaderAnnotationFlags_ForGenericResult.verified.txt b/tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/IntegrationTests/ExtendedAppIntegrationTests.ToMinimalApiResult_ShouldHonorHeaderAnnotationFlags_ForGenericResult.verified.txt index 7273252..bbd2d51 100644 --- a/tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/IntegrationTests/ExtendedAppIntegrationTests.ToMinimalApiResult_ShouldHonorHeaderAnnotationFlags_ForGenericResult.verified.txt +++ b/tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/IntegrationTests/ExtendedAppIntegrationTests.ToMinimalApiResult_ShouldHonorHeaderAnnotationFlags_ForGenericResult.verified.txt @@ -1,7 +1,7 @@ { Status: 200 OK, Headers: { - X-HeaderAndBody: "both" + X-HeaderAndBody: both }, Content: { Headers: { diff --git a/tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/IntegrationTests/ExtendedAppIntegrationTests.ToMinimalApiResult_ShouldNotSerializeMetadata_WhenGenericMetadataIsHeaderOnly.verified.txt b/tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/IntegrationTests/ExtendedAppIntegrationTests.ToMinimalApiResult_ShouldNotSerializeMetadata_WhenGenericMetadataIsHeaderOnly.verified.txt index 82aa6f9..cb9954f 100644 --- a/tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/IntegrationTests/ExtendedAppIntegrationTests.ToMinimalApiResult_ShouldNotSerializeMetadata_WhenGenericMetadataIsHeaderOnly.verified.txt +++ b/tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/IntegrationTests/ExtendedAppIntegrationTests.ToMinimalApiResult_ShouldNotSerializeMetadata_WhenGenericMetadataIsHeaderOnly.verified.txt @@ -1,7 +1,7 @@ { Status: 200 OK, Headers: { - X-TraceId: "trace-43" + X-TraceId: trace-43 }, Content: { Headers: { diff --git a/tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/IntegrationTests/ExtendedAppIntegrationTests.ToMinimalApiResult_ShouldNotSetContentType_WhenNonGenericMetadataIsHeaderOnly.verified.txt b/tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/IntegrationTests/ExtendedAppIntegrationTests.ToMinimalApiResult_ShouldNotSetContentType_WhenNonGenericMetadataIsHeaderOnly.verified.txt index 71111b8..fecc3e6 100644 --- a/tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/IntegrationTests/ExtendedAppIntegrationTests.ToMinimalApiResult_ShouldNotSetContentType_WhenNonGenericMetadataIsHeaderOnly.verified.txt +++ b/tests/Light.PortableResults.AspNetCore.MinimalApis.Tests/IntegrationTests/ExtendedAppIntegrationTests.ToMinimalApiResult_ShouldNotSetContentType_WhenNonGenericMetadataIsHeaderOnly.verified.txt @@ -2,7 +2,7 @@ Response: { Status: 200 OK, Headers: { - X-TraceId: "trace-42" + X-TraceId: trace-42 } }, Request: http://localhost/api/extended/non-generic-header-only-metadata diff --git a/tests/Light.PortableResults.AspNetCore.Mvc.Tests/IntegrationTests/ExtendedAppIntegrationTests.ToMvcActionResult_ShouldHonorHeaderAnnotationFlags_ForGenericResult.verified.txt b/tests/Light.PortableResults.AspNetCore.Mvc.Tests/IntegrationTests/ExtendedAppIntegrationTests.ToMvcActionResult_ShouldHonorHeaderAnnotationFlags_ForGenericResult.verified.txt index 7273252..bbd2d51 100644 --- a/tests/Light.PortableResults.AspNetCore.Mvc.Tests/IntegrationTests/ExtendedAppIntegrationTests.ToMvcActionResult_ShouldHonorHeaderAnnotationFlags_ForGenericResult.verified.txt +++ b/tests/Light.PortableResults.AspNetCore.Mvc.Tests/IntegrationTests/ExtendedAppIntegrationTests.ToMvcActionResult_ShouldHonorHeaderAnnotationFlags_ForGenericResult.verified.txt @@ -1,7 +1,7 @@ { Status: 200 OK, Headers: { - X-HeaderAndBody: "both" + X-HeaderAndBody: both }, Content: { Headers: { diff --git a/tests/Light.PortableResults.AspNetCore.Mvc.Tests/IntegrationTests/ExtendedAppIntegrationTests.ToMvcActionResult_ShouldNotSerializeMetadata_WhenGenericMetadataIsHeaderOnly.verified.txt b/tests/Light.PortableResults.AspNetCore.Mvc.Tests/IntegrationTests/ExtendedAppIntegrationTests.ToMvcActionResult_ShouldNotSerializeMetadata_WhenGenericMetadataIsHeaderOnly.verified.txt index 82aa6f9..cb9954f 100644 --- a/tests/Light.PortableResults.AspNetCore.Mvc.Tests/IntegrationTests/ExtendedAppIntegrationTests.ToMvcActionResult_ShouldNotSerializeMetadata_WhenGenericMetadataIsHeaderOnly.verified.txt +++ b/tests/Light.PortableResults.AspNetCore.Mvc.Tests/IntegrationTests/ExtendedAppIntegrationTests.ToMvcActionResult_ShouldNotSerializeMetadata_WhenGenericMetadataIsHeaderOnly.verified.txt @@ -1,7 +1,7 @@ { Status: 200 OK, Headers: { - X-TraceId: "trace-43" + X-TraceId: trace-43 }, Content: { Headers: { diff --git a/tests/Light.PortableResults.AspNetCore.Mvc.Tests/IntegrationTests/ExtendedAppIntegrationTests.ToMvcActionResult_ShouldNotSetContentType_WhenNonGenericMetadataIsHeaderOnly.verified.txt b/tests/Light.PortableResults.AspNetCore.Mvc.Tests/IntegrationTests/ExtendedAppIntegrationTests.ToMvcActionResult_ShouldNotSetContentType_WhenNonGenericMetadataIsHeaderOnly.verified.txt index 71111b8..fecc3e6 100644 --- a/tests/Light.PortableResults.AspNetCore.Mvc.Tests/IntegrationTests/ExtendedAppIntegrationTests.ToMvcActionResult_ShouldNotSetContentType_WhenNonGenericMetadataIsHeaderOnly.verified.txt +++ b/tests/Light.PortableResults.AspNetCore.Mvc.Tests/IntegrationTests/ExtendedAppIntegrationTests.ToMvcActionResult_ShouldNotSetContentType_WhenNonGenericMetadataIsHeaderOnly.verified.txt @@ -2,7 +2,7 @@ Response: { Status: 200 OK, Headers: { - X-TraceId: "trace-42" + X-TraceId: trace-42 } }, Request: http://localhost/api/extended/non-generic-header-only-metadata diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiSchemaTypeMapperTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiSchemaTypeMapperTests.cs new file mode 100644 index 0000000..506bdd8 --- /dev/null +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiSchemaTypeMapperTests.cs @@ -0,0 +1,62 @@ +using System; +using System.Collections.Generic; +using FluentAssertions; +using Microsoft.OpenApi; +using Xunit; + +namespace Light.PortableResults.AspNetCore.OpenApi.Tests; + +public sealed class PortableOpenApiSchemaTypeMapperTests +{ + public static TheoryData VocabularyTypes => + new () + { + { typeof(bool), JsonSchemaType.Boolean, null }, + { typeof(long), JsonSchemaType.Integer, null }, + { typeof(double), JsonSchemaType.Number, null }, + { typeof(string), JsonSchemaType.String, null }, + { typeof(decimal), JsonSchemaType.Number, null }, + { typeof(ulong), JsonSchemaType.String, "uint64" }, + { typeof(float), JsonSchemaType.Number, "float" }, + { typeof(char), JsonSchemaType.String, "char" }, + { typeof(DateTime), JsonSchemaType.String, "date-time" }, + { typeof(DateTimeOffset), JsonSchemaType.String, "date-time" }, + { typeof(DateOnly), JsonSchemaType.String, "date" }, + { typeof(TimeOnly), JsonSchemaType.String, "time" }, + { typeof(TimeSpan), JsonSchemaType.String, "duration" }, + { typeof(Guid), JsonSchemaType.String, "uuid" }, + { typeof(Uri), JsonSchemaType.String, "uri-reference" } + }; + + [Theory] + [MemberData(nameof(VocabularyTypes))] + public void MapShouldMatchTheMetadataVocabulary( + Type type, + JsonSchemaType expectedType, + string? expectedFormat + ) + { + var schema = PortableOpenApiSchemaTypeMapper.Map(type); + + schema.Type.Should().Be(expectedType); + schema.Format.Should().Be(expectedFormat); + } + + [Fact] + public void MapShouldUnwrapNullableVocabularyTypes() + { + var schema = PortableOpenApiSchemaTypeMapper.Map(typeof(TimeSpan?)); + + schema.Type.Should().Be(JsonSchemaType.String); + schema.Format.Should().Be("duration"); + } + + [Fact] + public void MapShouldReturnUnconstrainedSchemaForUnknownTypes() + { + var schema = PortableOpenApiSchemaTypeMapper.Map(typeof(Dictionary)); + + schema.Type.Should().BeNull(); + schema.Format.Should().BeNull(); + } +} diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs index e7f5004..0aa1d22 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs @@ -611,6 +611,82 @@ await GetOpenApiDocumentAsync(app), errors[0]!["category"]!.ToString().Should().Be(nameof(ErrorCategory.Conflict)); } + [Fact] + public async Task Transformer_ShouldEncodeExampleMetadataAccordingToTheTypedVocabulary() + { + var guid = new Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + await using var app = CreateMinimalApiApp( + configureEndpoints: webApplication => + { + webApplication + .MapGet("/minimal/problems/vocabulary-example", static () => TypedResults.Problem()) + .WithName("VocabularyExample") + .ProducesPortableProblem( + StatusCodes.Status400BadRequest, + configure: builder => builder.WithErrorExample( + "Vocabulary", + target: null, + message: null, + new Dictionary(StringComparer.Ordinal) + { + ["uint64"] = ulong.MaxValue, + ["single"] = 0.1f, + ["double"] = 5.0, + ["char"] = 'x', + ["dateTime"] = new DateTime( + 2026, + 7, + 26, + 13, + 45, + 30, + DateTimeKind.Utc + ), + ["dateTimeOffset"] = new DateTimeOffset( + 2026, + 7, + 26, + 13, + 45, + 30, + TimeSpan.FromHours(2) + ), + ["dateOnly"] = new DateOnly(2026, 7, 26), + ["timeOnly"] = new TimeOnly(13, 45, 30), + ["timeSpan"] = TimeSpan.FromSeconds(5), + ["guid"] = guid, + ["uri"] = new Uri("https://example.com/items/42") + } + ) + ); + } + ); + + var response = GetResponse( + await GetOpenApiDocumentAsync(app), + "/minimal/problems/vocabulary-example", + HttpMethod.Get, + StatusCodes.Status400BadRequest + ); + var example = ((OpenApiExample) response.Content!["application/problem+json"].Examples!["Problem"]).Value + .Should() + .BeOfType() + .Subject; + var metadata = example["errors"]![0]!["metadata"].Should().BeOfType().Subject; + + metadata["uint64"]!.GetValue().Should().Be(ulong.MaxValue.ToString(CultureInfo.InvariantCulture)); + metadata["single"]!.ToJsonString().Should().Be("0.1"); + metadata["double"]!.ToJsonString().Should().Be("5.0"); + metadata["char"]!.GetValue().Should().Be("x"); + metadata["dateTime"]!.GetValue().Should().Be("2026-07-26T13:45:30Z"); + metadata["dateTimeOffset"]!.GetValue().Should().Be("2026-07-26T13:45:30+02:00"); + metadata["dateOnly"]!.GetValue().Should().Be("2026-07-26"); + metadata["timeOnly"]!.GetValue().Should().Be("13:45:30"); + metadata["timeSpan"]!.GetValue().Should().Be("PT5S"); + metadata["guid"]!.GetValue().Should().Be(guid.ToString("D")); + metadata["uri"]!.GetValue().Should().Be("https://example.com/items/42"); + } + [Fact] public async Task Transformer_ShouldMaterializeReferencedResponsesBeforeWritingContent() { diff --git a/tests/Light.PortableResults.Tests/CloudEvents/MetadataValueAnnotationHelperTests.cs b/tests/Light.PortableResults.Tests/CloudEvents/MetadataValueAnnotationHelperTests.cs index 3591e7e..d7d0be5 100644 --- a/tests/Light.PortableResults.Tests/CloudEvents/MetadataValueAnnotationHelperTests.cs +++ b/tests/Light.PortableResults.Tests/CloudEvents/MetadataValueAnnotationHelperTests.cs @@ -1,4 +1,4 @@ -using System; +using System.Runtime.CompilerServices; using FluentAssertions; using Light.PortableResults.CloudEvents; using Light.PortableResults.Metadata; @@ -151,7 +151,7 @@ public void WithAnnotation_ForEmptyMetadataObject_ShouldReturnEmpty() } [Fact] - public void WithAnnotation_WithUnsupportedMetadataKind_ShouldThrowArgumentOutOfRangeException() + public void WithAnnotation_WithUnsupportedMetadataKind_ShouldThrowSwitchExpressionException() { var invalidValue = MetadataValueTestFactory.CreateWithUndeclaredKind(); @@ -160,8 +160,7 @@ public void WithAnnotation_WithUnsupportedMetadataKind_ShouldThrowArgumentOutOfR MetadataValueAnnotation.SerializeInCloudEventsData ); - act.Should().Throw() - .Which.ParamName.Should().Be("value"); + act.Should().Throw(); } } diff --git a/tests/Light.PortableResults.Tests/CloudEvents/Writing/CloudEventsResultExtensionsTests.cs b/tests/Light.PortableResults.Tests/CloudEvents/Writing/CloudEventsResultExtensionsTests.cs index 7209288..85a401f 100644 --- a/tests/Light.PortableResults.Tests/CloudEvents/Writing/CloudEventsResultExtensionsTests.cs +++ b/tests/Light.PortableResults.Tests/CloudEvents/Writing/CloudEventsResultExtensionsTests.cs @@ -416,6 +416,72 @@ public void ToCloudEvent_ShouldResolveCoreStringAttributes_FromDecimalMetadataVa document.RootElement.GetProperty("subject").GetString().Should().Be("19.50"); } + [Fact] + public void ToCloudEvent_ShouldResolveCoreStringAttributeFromEveryPrimitiveKind() + { + var annotation = MetadataValueAnnotation.SerializeInCloudEventsExtensionAttributes; + var values = new (MetadataValue Value, string Expected)[] + { + (MetadataValue.FromNull(annotation), "null"), + (MetadataValue.FromBoolean(true, annotation), "true"), + (MetadataValue.FromInt64(42, annotation), "42"), + (MetadataValue.FromDouble(5, annotation), "5.0"), + (MetadataValue.FromString("plain text", annotation), "plain text"), + (MetadataValue.FromDecimal(19.50m, annotation), "19.50"), + (MetadataValue.FromUInt64(ulong.MaxValue, annotation), "18446744073709551615"), + (MetadataValue.FromSingle(0.1f, annotation), "0.1"), + (MetadataValue.FromChar('x', annotation), "x"), + ( + MetadataValue.FromDateTime( + new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Utc), + annotation + ), + "2026-07-26T13:45:30Z" + ), + ( + MetadataValue.FromDateTimeOffset( + new DateTimeOffset(2026, 7, 26, 13, 45, 30, TimeSpan.FromHours(2)), + annotation + ), + "2026-07-26T13:45:30+02:00" + ), + (MetadataValue.FromDateOnly(new DateOnly(2026, 7, 26), annotation), "2026-07-26"), + (MetadataValue.FromTimeOnly(new TimeOnly(13, 45, 30), annotation), "13:45:30"), + (MetadataValue.FromTimeSpan(TimeSpan.FromSeconds(5), annotation), "PT5S"), + ( + MetadataValue.FromGuid( + new Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"), + annotation + ), + "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + ), + ( + MetadataValue.FromUri(new Uri("https://example.com/items/42"), annotation), + "https://example.com/items/42" + ) + }; + + foreach (var (value, expected) in values) + { + var metadata = MetadataObject.Create( + ( + "type", + MetadataValue.FromString("app.success", annotation) + ), + ( + "source", + MetadataValue.FromString("urn:test:source", annotation) + ), + ("subject", value) + ); + + var json = Result.Ok(metadata).ToCloudEvent(options: CreateWriteOptions(source: null)); + + using var document = JsonDocument.Parse(json); + document.RootElement.GetProperty("subject").GetString().Should().Be(expected); + } + } + [Fact] public void ToCloudEvent_ShouldWriteDecimalExtensionAttribute_AsUnquotedNumber() { diff --git a/tests/Light.PortableResults.Tests/Http/Writing/Headers/DefaultHttpHeaderConversionServiceTests.cs b/tests/Light.PortableResults.Tests/Http/Writing/Headers/DefaultHttpHeaderConversionServiceTests.cs index f8a7dd0..69264d6 100644 --- a/tests/Light.PortableResults.Tests/Http/Writing/Headers/DefaultHttpHeaderConversionServiceTests.cs +++ b/tests/Light.PortableResults.Tests/Http/Writing/Headers/DefaultHttpHeaderConversionServiceTests.cs @@ -64,6 +64,51 @@ public void PrepareHttpHeader_ShouldFormatDecimalWithoutQuotes() header.Value.ToString().Should().NotContain("\""); } + [Fact] + public void PrepareHttpHeader_ShouldUseCanonicalUnquotedTextForEveryPrimitiveKind() + { + var service = new DefaultHttpHeaderConversionService( + new Dictionary().ToFrozenDictionary() + ); + var values = new (MetadataValue Value, string Expected)[] + { + (MetadataValue.Null, "null"), + (MetadataValue.FromBoolean(true), "true"), + (MetadataValue.FromInt64(42), "42"), + (MetadataValue.FromDouble(5), "5.0"), + (MetadataValue.FromString("plain text"), "plain text"), + (MetadataValue.FromDecimal(19.50m), "19.50"), + (MetadataValue.FromUInt64(ulong.MaxValue), "18446744073709551615"), + (MetadataValue.FromSingle(0.1f), "0.1"), + (MetadataValue.FromChar('x'), "x"), + ( + MetadataValue.FromDateTime(new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Utc)), + "2026-07-26T13:45:30Z" + ), + ( + MetadataValue.FromDateTimeOffset( + new DateTimeOffset(2026, 7, 26, 13, 45, 30, TimeSpan.FromHours(2)) + ), + "2026-07-26T13:45:30+02:00" + ), + (MetadataValue.FromDateOnly(new DateOnly(2026, 7, 26)), "2026-07-26"), + (MetadataValue.FromTimeOnly(new TimeOnly(13, 45, 30)), "13:45:30"), + (MetadataValue.FromTimeSpan(TimeSpan.FromSeconds(5)), "PT5S"), + ( + MetadataValue.FromGuid(new Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890")), + "a1b2c3d4-e5f6-7890-abcd-ef1234567890" + ), + (MetadataValue.FromUri(new Uri("https://example.com/items/42")), "https://example.com/items/42") + }; + + foreach (var (value, expected) in values) + { + var header = service.PrepareHttpHeader("value", value); + + header.Value.ToString().Should().Be(expected, "the {0} header encoding is canonical", value.Kind); + } + } + private sealed class TraceIdConverter : HttpHeaderConverter { public TraceIdConverter() : base(["traceId"]) { } diff --git a/tests/Light.PortableResults.Tests/Metadata/MetadataKindTests.cs b/tests/Light.PortableResults.Tests/Metadata/MetadataKindTests.cs index df0955b..eb4a9b8 100644 --- a/tests/Light.PortableResults.Tests/Metadata/MetadataKindTests.cs +++ b/tests/Light.PortableResults.Tests/Metadata/MetadataKindTests.cs @@ -11,7 +11,7 @@ public sealed class MetadataKindTests // MetadataKindExtensions.IsPrimitive decides membership of the primitive set purely by ordering // (kind < MetadataKind.Array). A new member declared on the wrong side of that boundary compiles cleanly // and silently changes behavior, thus every declared member is pinned here - both its numeric value, which - // keeps the range 6 - 199 reserved for future primitive kinds and is visible to callers that persisted or + // keeps the range 16 - 199 reserved for future primitive kinds and is visible to callers that persisted or // transmitted it, and its classification. private static readonly Dictionary ExpectedMembers = new () { @@ -21,6 +21,16 @@ public sealed class MetadataKindTests [MetadataKind.Double] = (3, true), [MetadataKind.String] = (4, true), [MetadataKind.Decimal] = (5, true), + [MetadataKind.UInt64] = (6, true), + [MetadataKind.Single] = (7, true), + [MetadataKind.Char] = (8, true), + [MetadataKind.DateTime] = (9, true), + [MetadataKind.DateTimeOffset] = (10, true), + [MetadataKind.DateOnly] = (11, true), + [MetadataKind.TimeOnly] = (12, true), + [MetadataKind.TimeSpan] = (13, true), + [MetadataKind.Guid] = (14, true), + [MetadataKind.Uri] = (15, true), [MetadataKind.Array] = (200, false), [MetadataKind.Object] = (201, false) }; @@ -62,4 +72,31 @@ public void EveryDeclaredKindShouldBeClassifiedCorrectly() ); } } + + [Theory] + [InlineData(MetadataKind.Null, MetadataJsonShape.Null)] + [InlineData(MetadataKind.Boolean, MetadataJsonShape.Boolean)] + [InlineData(MetadataKind.Int64, MetadataJsonShape.Number)] + [InlineData(MetadataKind.Double, MetadataJsonShape.Number)] + [InlineData(MetadataKind.String, MetadataJsonShape.String)] + [InlineData(MetadataKind.Decimal, MetadataJsonShape.Number)] + [InlineData(MetadataKind.UInt64, MetadataJsonShape.String)] + [InlineData(MetadataKind.Single, MetadataJsonShape.Number)] + [InlineData(MetadataKind.Char, MetadataJsonShape.String)] + [InlineData(MetadataKind.DateTime, MetadataJsonShape.String)] + [InlineData(MetadataKind.DateTimeOffset, MetadataJsonShape.String)] + [InlineData(MetadataKind.DateOnly, MetadataJsonShape.String)] + [InlineData(MetadataKind.TimeOnly, MetadataJsonShape.String)] + [InlineData(MetadataKind.TimeSpan, MetadataJsonShape.String)] + [InlineData(MetadataKind.Guid, MetadataJsonShape.String)] + [InlineData(MetadataKind.Uri, MetadataJsonShape.String)] + [InlineData(MetadataKind.Array, MetadataJsonShape.Array)] + [InlineData(MetadataKind.Object, MetadataJsonShape.Object)] + public void EveryDeclaredKindShouldHaveTheExpectedJsonShape( + MetadataKind kind, + MetadataJsonShape expectedShape + ) + { + kind.GetJsonShape().Should().Be(expectedShape); + } } diff --git a/tests/Light.PortableResults.Tests/Metadata/MetadataValueTestFactory.cs b/tests/Light.PortableResults.Tests/Metadata/MetadataValueTestFactory.cs index 05f6586..2c65099 100644 --- a/tests/Light.PortableResults.Tests/Metadata/MetadataValueTestFactory.cs +++ b/tests/Light.PortableResults.Tests/Metadata/MetadataValueTestFactory.cs @@ -22,7 +22,28 @@ public static MetadataValue CreateWithEmptyPayload(MetadataKind kind) "Light.PortableResults.Metadata.MetadataPayload", throwOnError: true )!; + var payload = Activator.CreateInstance(metadataPayloadType)!; + return Create(kind, metadataValueType, metadataPayloadType, payload); + } + public static MetadataValue CreateWithInt64Payload(MetadataKind kind, long value) + { + var metadataValueType = typeof(MetadataValue); + var metadataPayloadType = metadataValueType.Assembly.GetType( + "Light.PortableResults.Metadata.MetadataPayload", + throwOnError: true + )!; + var payload = Activator.CreateInstance(metadataPayloadType, [value])!; + return Create(kind, metadataValueType, metadataPayloadType, payload); + } + + private static MetadataValue Create( + MetadataKind kind, + Type metadataValueType, + Type metadataPayloadType, + object payload + ) + { var constructor = metadataValueType.GetConstructor( BindingFlags.Instance | BindingFlags.NonPublic, binder: null, @@ -30,8 +51,6 @@ public static MetadataValue CreateWithEmptyPayload(MetadataKind kind) modifiers: null )!; - var payload = Activator.CreateInstance(metadataPayloadType)!; - return (MetadataValue) constructor.Invoke([kind, payload, MetadataValue.DefaultAnnotation]); } } diff --git a/tests/Light.PortableResults.Tests/Metadata/MetadataValueTests.cs b/tests/Light.PortableResults.Tests/Metadata/MetadataValueTests.cs index 9b95944..5a2f713 100644 --- a/tests/Light.PortableResults.Tests/Metadata/MetadataValueTests.cs +++ b/tests/Light.PortableResults.Tests/Metadata/MetadataValueTests.cs @@ -213,9 +213,9 @@ public void ImplicitConversion_FromFloat_ShouldWork() { MetadataValue value = 3.14f; - value.Kind.Should().Be(MetadataKind.Double); + value.Kind.Should().Be(MetadataKind.Single); value.TryGetDouble(out var result).Should().BeTrue(); - result.Should().BeApproximately(3.14, 0.001); + result.Should().Be((double) 3.14f); } [Fact] @@ -472,7 +472,7 @@ public void Equals_Object_WithNull_ShouldReturnFalse() { var value = MetadataValue.FromInt64(42); - value.Equals(null).Should().BeFalse(); + value.Equals((object?) null).Should().BeFalse(); } [Fact] diff --git a/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs b/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs new file mode 100644 index 0000000..e638284 --- /dev/null +++ b/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs @@ -0,0 +1,375 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Text.Json; +using FluentAssertions; +using Light.PortableResults.CloudEvents; +using Light.PortableResults.Metadata; +using Light.PortableResults.SharedJsonSerialization.Writing; +using Xunit; + +namespace Light.PortableResults.Tests.Metadata; + +public sealed class TypedMetadataValueTests +{ + private static readonly DateTime UtcDateTime = new (2026, 7, 26, 13, 45, 30, DateTimeKind.Utc); + private static readonly DateTimeOffset OffsetDateTime = new ( + 2026, + 7, + 26, + 13, + 45, + 30, + TimeSpan.FromHours(2) + ); + private static readonly Guid GuidValue = new ("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + private static readonly Uri UriValue = new ("https://example.com/items/42?view=full#summary"); + + [Fact] + public void TypedFactoriesAndAccessorsShouldPreserveExactValues() + { + MetadataValue.FromUInt64(ulong.MaxValue).TryGetUInt64(out var uint64).Should().BeTrue(); + uint64.Should().Be(ulong.MaxValue); + + MetadataValue.FromSingle(0.1f).TryGetSingle(out var single).Should().BeTrue(); + single.Should().Be(0.1f); + + MetadataValue.FromChar('ß').TryGetChar(out var character).Should().BeTrue(); + character.Should().Be('ß'); + + MetadataValue.FromDateTime(UtcDateTime).TryGetDateTime(out var dateTime).Should().BeTrue(); + dateTime.Should().Be(UtcDateTime); + dateTime.Kind.Should().Be(DateTimeKind.Utc); + + MetadataValue.FromDateTimeOffset(OffsetDateTime) + .TryGetDateTimeOffset(out var dateTimeOffset) + .Should() + .BeTrue(); + dateTimeOffset.Should().Be(OffsetDateTime); + + var dateOnly = new DateOnly(2026, 7, 26); + MetadataValue.FromDateOnly(dateOnly).TryGetDateOnly(out var storedDateOnly).Should().BeTrue(); + storedDateOnly.Should().Be(dateOnly); + + var timeOnly = new TimeOnly(13, 45, 30, 123); + MetadataValue.FromTimeOnly(timeOnly).TryGetTimeOnly(out var storedTimeOnly).Should().BeTrue(); + storedTimeOnly.Should().Be(timeOnly); + + var timeSpan = TimeSpan.FromDays(2) + TimeSpan.FromMilliseconds(125); + MetadataValue.FromTimeSpan(timeSpan).TryGetTimeSpan(out var storedTimeSpan).Should().BeTrue(); + storedTimeSpan.Should().Be(timeSpan); + + MetadataValue.FromGuid(GuidValue).TryGetGuid(out var guid).Should().BeTrue(); + guid.Should().Be(GuidValue); + + MetadataValue.FromUri(UriValue).TryGetUri(out var uri).Should().BeTrue(); + uri.Should().BeSameAs(UriValue); + } + + [Fact] + public void TypedImplicitConversionsShouldUseDedicatedKinds() + { + MetadataValue uint64 = ulong.MaxValue; + MetadataValue single = 0.1f; + MetadataValue character = 'x'; + MetadataValue dateTime = UtcDateTime; + MetadataValue dateTimeOffset = OffsetDateTime; + MetadataValue dateOnly = new DateOnly(2026, 7, 26); + MetadataValue timeOnly = new TimeOnly(13, 45, 30); + MetadataValue timeSpan = TimeSpan.FromSeconds(5); + MetadataValue guid = GuidValue; + MetadataValue uri = UriValue; + + new[] + { + uint64.Kind, + single.Kind, + character.Kind, + dateTime.Kind, + dateTimeOffset.Kind, + dateOnly.Kind, + timeOnly.Kind, + timeSpan.Kind, + guid.Kind, + uri.Kind + } + .Should() + .Equal( + MetadataKind.UInt64, + MetadataKind.Single, + MetadataKind.Char, + MetadataKind.DateTime, + MetadataKind.DateTimeOffset, + MetadataKind.DateOnly, + MetadataKind.TimeOnly, + MetadataKind.TimeSpan, + MetadataKind.Guid, + MetadataKind.Uri + ); + } + + [Fact] + public void IntegralImplicitConversionsShouldRemainUnambiguous() + { + MetadataValue byteValue = (byte) 1; + MetadataValue sbyteValue = (sbyte) -2; + MetadataValue int16Value = (short) -3; + MetadataValue uint16Value = (ushort) 4; + MetadataValue uint32Value = 5U; + + new[] { byteValue, sbyteValue, int16Value, uint16Value, uint32Value } + .Should() + .OnlyContain(value => value.Kind == MetadataKind.Int64); + } + + [Fact] + public void DateTimeFactoryShouldNormalizeLocalAndRejectUnspecifiedValues() + { + var local = new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Local); + + var value = MetadataValue.FromDateTime(local); + + value.TryGetDateTime(out var stored).Should().BeTrue(); + stored.Should().Be(local.ToUniversalTime()); + stored.Kind.Should().Be(DateTimeKind.Utc); + + var act = () => MetadataValue.FromDateTime( + new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Unspecified) + ); + + act.Should().Throw().WithParameterName("value"); + } + + [Fact] + public void StringAccessorsShouldAcceptOnlyCanonicalEncodings() + { + MetadataValue.FromString(ulong.MaxValue.ToString()) + .TryGetUInt64(out var uint64) + .Should() + .BeTrue(); + uint64.Should().Be(ulong.MaxValue); + MetadataValue.FromString("01").TryGetUInt64(out _).Should().BeFalse(); + + MetadataValue.FromString("0.1").TryGetSingle(out var single).Should().BeTrue(); + single.Should().Be(0.1f); + MetadataValue.FromString("0.10").TryGetSingle(out _).Should().BeFalse(); + + MetadataValue.FromString("x").TryGetChar(out var character).Should().BeTrue(); + character.Should().Be('x'); + MetadataValue.FromString("xy").TryGetChar(out _).Should().BeFalse(); + + MetadataValue.FromString("2026-07-26T13:45:30Z") + .TryGetDateTime(out var dateTime) + .Should() + .BeTrue(); + dateTime.Should().Be(UtcDateTime); + MetadataValue.FromString("07/26/2026 13:45:30").TryGetDateTime(out _).Should().BeFalse(); + + MetadataValue.FromString("2026-07-26T13:45:30+02:00") + .TryGetDateTimeOffset(out var dateTimeOffset) + .Should() + .BeTrue(); + dateTimeOffset.Should().Be(OffsetDateTime); + MetadataValue.FromString("2026-07-26T11:45:30Z") + .TryGetDateTimeOffset(out _) + .Should() + .BeFalse(); + + MetadataValue.FromString("2026-07-26").TryGetDateOnly(out var dateOnly).Should().BeTrue(); + dateOnly.Should().Be(new DateOnly(2026, 7, 26)); + MetadataValue.FromString("07/26/2026").TryGetDateOnly(out _).Should().BeFalse(); + + MetadataValue.FromString("13:45:30").TryGetTimeOnly(out var timeOnly).Should().BeTrue(); + timeOnly.Should().Be(new TimeOnly(13, 45, 30)); + MetadataValue.FromString("13:45").TryGetTimeOnly(out _).Should().BeFalse(); + + MetadataValue.FromString("PT5S").TryGetTimeSpan(out var timeSpan).Should().BeTrue(); + timeSpan.Should().Be(TimeSpan.FromSeconds(5)); + MetadataValue.FromString("00:00:05").TryGetTimeSpan(out _).Should().BeFalse(); + MetadataValue.FromString("not a duration").TryGetTimeSpan(out _).Should().BeFalse(); + + MetadataValue.FromString(GuidValue.ToString("D")).TryGetGuid(out var guid).Should().BeTrue(); + guid.Should().Be(GuidValue); + MetadataValue.FromString(GuidValue.ToString("D").ToUpperInvariant()).TryGetGuid(out _).Should().BeFalse(); + + MetadataValue.FromString(UriValue.OriginalString).TryGetUri(out var uri).Should().BeTrue(); + uri!.OriginalString.Should().Be(UriValue.OriginalString); + MetadataValue.FromString("hello world").TryGetUri(out _).Should().BeFalse(); + } + + [Fact] + public void EveryKindShouldSerializeWithItsVocabularyEncoding() + { + var values = new (MetadataValue Value, string Json)[] + { + (MetadataValue.Null, "null"), + (MetadataValue.FromBoolean(true), "true"), + (MetadataValue.FromInt64(-42), "-42"), + (MetadataValue.FromDouble(5), "5.0"), + (MetadataValue.FromString("text"), "\"text\""), + (MetadataValue.FromDecimal(5m), "5"), + (MetadataValue.FromUInt64(ulong.MaxValue), "\"18446744073709551615\""), + (MetadataValue.FromSingle(0.1f), "0.1"), + (MetadataValue.FromChar('x'), "\"x\""), + (MetadataValue.FromDateTime(UtcDateTime), "\"2026-07-26T13:45:30Z\""), + (MetadataValue.FromDateTimeOffset(OffsetDateTime), "\"2026-07-26T13:45:30\\u002B02:00\""), + (MetadataValue.FromDateOnly(new DateOnly(2026, 7, 26)), "\"2026-07-26\""), + (MetadataValue.FromTimeOnly(new TimeOnly(13, 45, 30)), "\"13:45:30\""), + (MetadataValue.FromTimeSpan(TimeSpan.FromSeconds(5)), "\"PT5S\""), + (MetadataValue.FromGuid(GuidValue), "\"a1b2c3d4-e5f6-7890-abcd-ef1234567890\""), + (MetadataValue.FromUri(UriValue), "\"https://example.com/items/42?view=full#summary\""), + (MetadataValue.FromArray(MetadataArray.Create(1, "x")), "[1,\"x\"]"), + (MetadataValue.FromObject(MetadataObject.Create(("value", 1))), "{\"value\":1}") + }; + + foreach (var (value, expectedJson) in values) + { + Serialize(value).Should().Be(expectedJson, "the {0} vocabulary encoding is normative", value.Kind); + } + } + + [Fact] + public void SingleShouldWidenToDoubleWithoutChangingItsOwnAccessor() + { + var value = MetadataValue.FromSingle(0.1f); + + value.TryGetSingle(out var single).Should().BeTrue(); + value.TryGetDouble(out var @double).Should().BeTrue(); + + single.Should().Be(0.1f); + @double.Should().Be((double) 0.1f); + @double.Should().NotBe(0.1); + } + + [Fact] + public void EqualityHashingAnnotationsAndObjectStorageShouldCoverEveryKind() + { + var values = CreateEveryKind(); + using var builder = MetadataObjectBuilder.Create(values.Count); + + foreach (var (key, value) in values) + { + var rewritten = MetadataValueAnnotationHelper.WithAnnotation( + value, + MetadataValueAnnotation.SerializeInCloudEventsData + ); + + rewritten.Should().Be(value); + rewritten.GetHashCode().Should().Be(value.GetHashCode()); + rewritten.ToString().Should().NotBeNull(); + builder.Add(key, value); + } + + var metadata = builder.Build(); + foreach (var (key, value) in values) + { + metadata[key].Should().Be(value); + } + + MetadataValue.FromUri(new Uri("https://EXAMPLE.com/items#fragment")) + .Should() + .NotBe(MetadataValue.FromUri(new Uri("https://example.com/items#fragment"))); + MetadataValue.FromGuid(GuidValue).Should().NotBe(MetadataValue.FromString(GuidValue.ToString("D"))); + } + + [Fact] + public void CanonicalFormatterShouldReportDestinationCapacity() + { + Span sufficient = stackalloc char[36]; + Span insufficient = stackalloc char[4]; + var value = MetadataValue.FromGuid(GuidValue); + + value.TryFormatCanonical(sufficient, out var charsWritten).Should().BeTrue(); + charsWritten.Should().Be(36); + sufficient.ToString().Should().Be(GuidValue.ToString("D")); + + value.TryFormatCanonical(insufficient, out charsWritten).Should().BeFalse(); + charsWritten.Should().Be(0); + } + + [Fact] + public void ComplexValuesShouldNotHavePrimitiveCanonicalText() + { + var arrayAct = () => MetadataValue.FromArray(MetadataArray.Empty).ToCanonicalString(); + var objectAct = () => MetadataValue.FromObject(MetadataObject.Empty).ToCanonicalString(); + + arrayAct.Should().Throw(); + objectAct.Should().Throw(); + } + + [Fact] + public void MetadataPayloadShouldRejectStandaloneEqualityAndHashing() + { + var payloadType = typeof(MetadataValue).Assembly.GetType( + "Light.PortableResults.Metadata.MetadataPayload", + throwOnError: true + )!; + var payload = Activator.CreateInstance(payloadType)!; + + var equalityAct = () => payload.Equals(payload); + var hashAct = () => payload.GetHashCode(); + + equalityAct.Should().Throw(); + hashAct.Should().Throw(); + } + + [Fact] + public void MalformedInlineTemporalPayloadsShouldBeRejected() + { + var dateTime = MetadataValueTestFactory.CreateWithInt64Payload(MetadataKind.DateTime, long.MaxValue); + var dateOnly = MetadataValueTestFactory.CreateWithInt64Payload(MetadataKind.DateOnly, long.MaxValue); + var timeOnly = MetadataValueTestFactory.CreateWithInt64Payload(MetadataKind.TimeOnly, long.MaxValue); + + dateTime.TryGetDateTime(out _).Should().BeFalse(); + dateOnly.TryGetDateOnly(out _).Should().BeFalse(); + timeOnly.TryGetTimeOnly(out _).Should().BeFalse(); + + var dateTimeFormatAct = () => dateTime.ToCanonicalString(); + var dateOnlyFormatAct = () => dateOnly.ToCanonicalString(); + var timeOnlyFormatAct = () => timeOnly.ToCanonicalString(); + + dateTimeFormatAct.Should().Throw(); + dateOnlyFormatAct.Should().Throw(); + timeOnlyFormatAct.Should().Throw(); + } + + [Fact] + public void TypedAccessorShouldReturnFalseForNonStringWrongKind() + { + MetadataValue.Null.TryGetUInt64(out var value).Should().BeFalse(); + value.Should().Be(0); + } + + private static Dictionary CreateEveryKind() => + new (StringComparer.Ordinal) + { + ["null"] = MetadataValue.Null, + ["boolean"] = MetadataValue.FromBoolean(true), + ["int64"] = MetadataValue.FromInt64(42), + ["double"] = MetadataValue.FromDouble(12.5), + ["string"] = MetadataValue.FromString("text"), + ["decimal"] = MetadataValue.FromDecimal(19.50m), + ["uint64"] = MetadataValue.FromUInt64(ulong.MaxValue), + ["single"] = MetadataValue.FromSingle(0.1f), + ["char"] = MetadataValue.FromChar('x'), + ["dateTime"] = MetadataValue.FromDateTime(UtcDateTime), + ["dateTimeOffset"] = MetadataValue.FromDateTimeOffset(OffsetDateTime), + ["dateOnly"] = MetadataValue.FromDateOnly(new DateOnly(2026, 7, 26)), + ["timeOnly"] = MetadataValue.FromTimeOnly(new TimeOnly(13, 45, 30)), + ["timeSpan"] = MetadataValue.FromTimeSpan(TimeSpan.FromSeconds(5)), + ["guid"] = MetadataValue.FromGuid(GuidValue), + ["uri"] = MetadataValue.FromUri(UriValue), + ["array"] = MetadataValue.FromArray(MetadataArray.Create(1, "x")), + ["object"] = MetadataValue.FromObject(MetadataObject.Create(("value", 1))) + }; + + private static string Serialize(MetadataValue value) + { + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); + writer.WriteMetadataValue(value, MetadataValueAnnotation.SerializeInHttpResponseBody); + writer.Flush(); + return Encoding.UTF8.GetString(stream.ToArray()); + } +} diff --git a/tests/Light.PortableResults.Tests/Metadata/UndeclaredMetadataKindFallbackTests.cs b/tests/Light.PortableResults.Tests/Metadata/UndeclaredMetadataKindFallbackTests.cs index 308c865..77a154c 100644 --- a/tests/Light.PortableResults.Tests/Metadata/UndeclaredMetadataKindFallbackTests.cs +++ b/tests/Light.PortableResults.Tests/Metadata/UndeclaredMetadataKindFallbackTests.cs @@ -1,6 +1,6 @@ using System; using System.IO; -using System.Text; +using System.Runtime.CompilerServices; using System.Text.Json; using FluentAssertions; using Light.PortableResults.Metadata; @@ -9,27 +9,28 @@ namespace Light.PortableResults.Tests.Metadata; -// Every site that dispatches on MetadataKind has a fallback arm, thus adding a kind and forgetting a site breaks -// nothing at compile time. These tests pin what the fallback arms do so that the blast radius of a half-finished -// kind is at least documented. +// Exhaustive kind switches intentionally have no fallback arm. Undeclared values therefore surface as +// SwitchExpressionException instead of being silently interpreted as another kind. public sealed class UndeclaredMetadataKindFallbackTests { [Fact] - public void Equals_ShouldReturnFalse_ForUndeclaredKind() + public void Equals_ShouldThrow_ForUndeclaredKind() { var first = MetadataValueTestFactory.CreateWithUndeclaredKind(); var second = MetadataValueTestFactory.CreateWithUndeclaredKind(); - first.Equals(second).Should().BeFalse(); + var act = () => first.Equals(second); + + act.Should().Throw(); } [Fact] - public void GetHashCode_ShouldOnlyHashTheKind_ForUndeclaredKind() + public void GetHashCode_ShouldThrow_ForUndeclaredKind() { var first = MetadataValueTestFactory.CreateWithUndeclaredKind(); - var second = MetadataValueTestFactory.CreateWithUndeclaredKind((MetadataKind) (byte.MaxValue - 1)); + var act = () => first.GetHashCode(); - first.GetHashCode().Should().NotBe(second.GetHashCode()); + act.Should().Throw(); } [Fact] @@ -39,21 +40,22 @@ public void ToString_ShouldThrow_ForUndeclaredKind() var act = () => value.ToString(); - act.Should().Throw().WithMessage("*is unknown*"); + act.Should().Throw(); } [Fact] - public void WriteMetadataValue_ShouldWriteNull_ForUndeclaredKind() + public void WriteMetadataValue_ShouldThrow_ForUndeclaredKind() { var value = MetadataValueTestFactory.CreateWithUndeclaredKind(); - using var stream = new MemoryStream(); - using (var writer = new Utf8JsonWriter(stream)) + var act = () => { + using var stream = new MemoryStream(); + using var writer = new Utf8JsonWriter(stream); writer.WriteMetadataValue(value, MetadataValueAnnotation.SerializeInHttpResponseBody); writer.Flush(); - } + }; - Encoding.UTF8.GetString(stream.ToArray()).Should().Be("null"); + act.Should().Throw(); } } diff --git a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiEmitterTests.cs b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiEmitterTests.cs index 66d851c..bc4b66f 100644 --- a/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiEmitterTests.cs +++ b/tests/Light.PortableResults.Validation.OpenApi.SourceGeneration.Tests/ValidatorOpenApiEmitterTests.cs @@ -190,6 +190,15 @@ public void Emit_ShouldRenderAllConstantMetadataLiteralTypes() MetadataValue("m", 3.5M), MetadataValue("nothing", null), MetadataValue("fallback", Guid.Empty), + MetadataValue("dateTime", new DateTime(638891343300000000L, DateTimeKind.Utc)), + MetadataValue( + "dateTimeOffset", + new DateTimeOffset(638891343300000000L, TimeSpan.FromHours(2)) + ), + MetadataValue("dateOnly", DateOnly.FromDayNumber(739822)), + MetadataValue("timeOnly", new TimeOnly(495300000000L)), + MetadataValue("timeSpan", TimeSpan.FromSeconds(5)), + MetadataValue("uri", new Uri("https://example.com/items/42")), MetadataValue("escapes", "a\\b\"c\nd\te\rfg\0h\ai\bj\fk\vl'm") ); var model = ModelWithRules( @@ -214,7 +223,21 @@ public void Emit_ShouldRenderAllConstantMetadataLiteralTypes() source.Should().Contain("[\"d\"] = 2.5D"); source.Should().Contain("[\"m\"] = 3.5M"); source.Should().Contain("[\"nothing\"] = null"); - source.Should().Contain("[\"fallback\"] = \"00000000-0000-0000-0000-000000000000\""); + source.Should().Contain( + "[\"fallback\"] = global::System.Guid.ParseExact(\"00000000-0000-0000-0000-000000000000\", \"D\")" + ); + source.Should().Contain( + "[\"dateTime\"] = new global::System.DateTime(638891343300000000L, global::System.DateTimeKind.Utc)" + ); + source.Should().Contain( + "[\"dateTimeOffset\"] = new global::System.DateTimeOffset(638891343300000000L, global::System.TimeSpan.FromTicks(72000000000L))" + ); + source.Should().Contain("[\"dateOnly\"] = global::System.DateOnly.FromDayNumber(739822)"); + source.Should().Contain("[\"timeOnly\"] = new global::System.TimeOnly(495300000000L)"); + source.Should().Contain("[\"timeSpan\"] = global::System.TimeSpan.FromTicks(50000000L)"); + source.Should().Contain( + "[\"uri\"] = new global::System.Uri(\"https://example.com/items/42\", global::System.UriKind.RelativeOrAbsolute)" + ); source.Should() .Contain("[\"escapes\"] = \"a\\\\b\\\"c\\nd\\te\\rf\\u0001g\\0h\\ai\\bj\\fk\\vl\\'m\""); } diff --git a/tests/Light.PortableResults.Validation.OpenApi.Tests/TemporalMetadataOpenApiConformanceTests.cs b/tests/Light.PortableResults.Validation.OpenApi.Tests/TemporalMetadataOpenApiConformanceTests.cs new file mode 100644 index 0000000..98203b7 --- /dev/null +++ b/tests/Light.PortableResults.Validation.OpenApi.Tests/TemporalMetadataOpenApiConformanceTests.cs @@ -0,0 +1,145 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using Light.PortableResults.AspNetCore.MinimalApis; +using Light.PortableResults.AspNetCore.OpenApi; +using Light.PortableResults.Http.Writing; +using Light.PortableResults.Validation.Definitions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.OpenApi; +using Xunit; + +namespace Light.PortableResults.Validation.OpenApi.Tests; + +public sealed class TemporalMetadataOpenApiConformanceTests +{ + [Fact] + public async Task DateTimeValidationProblemBodyShouldConformToGeneratedOpenApiDocument() + { + await using var app = CreateApp(); + var document = await ValidationOpenApiDocumentTestUtilities.GetOpenApiDocumentAsync(app); + using var httpClient = app.GetTestClient(); + + using var response = await httpClient.PostAsync( + "/temporal-validation", + content: null, + TestContext.Current.CancellationToken + ); + var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); + + using var jsonDocument = JsonDocument.Parse(body); + var error = jsonDocument.RootElement + .GetProperty("errors") + .EnumerateArray() + .Single(element => element.GetProperty("code").GetString() == ValidationErrorCodes.InRange); + var metadata = error.GetProperty("metadata"); + + metadata.GetProperty("lowerBoundary").ValueKind.Should().Be(JsonValueKind.String); + metadata.GetProperty("lowerBoundary").GetString().Should().Be("2026-07-26T13:45:30Z"); + metadata.GetProperty("upperBoundary").GetString().Should().Be("2026-07-27T13:45:30Z"); + + var schema = GetMetadataPropertySchema( + document, + ValidationErrorCodes.InRange, + ValidationErrorMetadataKeys.LowerBoundary + ); + ValidationOpenApiDocumentTestUtilities.SchemaIncludesType(schema, JsonSchemaType.String).Should().BeTrue(); + schema.Format.Should().Be("date-time"); + } + + private static WebApplication CreateApp() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Services.AddPortableResultsForMinimalApis(); + builder.Services.AddValidationForPortableResults(); + builder.Services.AddSingleton(); + builder.Services.AddPortableResultsOpenApi(contracts => contracts.RegisterBuiltInValidationErrors()); + builder.Services.Configure( + options => options.ValidationProblemSerializationFormat = ValidationProblemSerializationFormat.Rich + ); + builder.Services.AddOpenApi(); + + var app = builder.Build(); + app.MapPost("/temporal-validation", ValidateTimestamp) + .WithName("TemporalValidation") + .ProducesPortableValidationProblemFor( + configure: static openApi => openApi.UseFormat(ValidationProblemSerializationFormat.Rich) + ); + return app; + } + + private static IResult ValidateTimestamp(TemporalBoundaryValidator validator) + { + var dto = new TemporalDto + { + Timestamp = new DateTime(2026, 7, 25, 13, 45, 30, DateTimeKind.Utc) + }; + var validationContext = validator.ValidationContextFactory.CreateValidationContext(); + return validator.CheckForErrors(dto, validationContext, out var errorResult) ? + Result.Fail(errorResult.Errors).ToMinimalApiResult() : + Result.Ok(dto).ToMinimalApiResult(); + } + + private static OpenApiSchema GetMetadataPropertySchema( + OpenApiDocument document, + string errorCode, + string metadataKey + ) + { + var response = (OpenApiResponse) document.Paths["/temporal-validation"] + .Operations![HttpMethod.Post] + .Responses![StatusCodes.Status400BadRequest.ToString()]; + var envelopeReference = (OpenApiSchemaReference) response.Content!["application/problem+json"].Schema!; + var envelope = ValidationOpenApiDocumentTestUtilities.GetSchemaComponent( + document, + ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId(envelopeReference) + ); + var errorItems = (OpenApiSchema) ((OpenApiSchema) envelope.Properties!["errors"]).Items!; + var errorSchemaId = errorItems.OneOf! + .Select( + static item => + ValidationOpenApiDocumentTestUtilities.GetSchemaReferenceId((OpenApiSchemaReference) item) + ) + .Single(schemaId => schemaId.EndsWith("__" + errorCode, StringComparison.Ordinal)); + var metadataSchema = ValidationOpenApiDocumentTestUtilities.GetSchemaComponent( + document, + errorSchemaId + "__Metadata" + ); + return (OpenApiSchema) metadataSchema.Properties![metadataKey]; + } +} + +public sealed class TemporalDto +{ + public DateTime Timestamp { get; init; } +} + +[GeneratePortableValidationOpenApi] +public sealed partial class TemporalBoundaryValidator : Validator +{ + private static readonly DateTime LowerBoundary = + new (2026, 7, 26, 13, 45, 30, DateTimeKind.Utc); + + private static readonly DateTime UpperBoundary = + new (2026, 7, 27, 13, 45, 30, DateTimeKind.Utc); + + public TemporalBoundaryValidator(IValidationContextFactory validationContextFactory) + : base(validationContextFactory) { } + + protected override ValidatedValue PerformValidation( + ValidationContext context, + ValidationCheckpoint checkpoint, + TemporalDto dto + ) + { + context.Check(dto.Timestamp).IsInRange(LowerBoundary, UpperBoundary); + return checkpoint.ToValidatedValue(dto); + } +} diff --git a/tests/Light.PortableResults.Validation.Tests/TypedMetadataRoutingTests.cs b/tests/Light.PortableResults.Validation.Tests/TypedMetadataRoutingTests.cs new file mode 100644 index 0000000..3634e61 --- /dev/null +++ b/tests/Light.PortableResults.Validation.Tests/TypedMetadataRoutingTests.cs @@ -0,0 +1,89 @@ +using System; +using FluentAssertions; +using Light.PortableResults.Metadata; +using Light.PortableResults.Validation.Definitions; +using Light.PortableResults.Validation.Messaging; +using Xunit; + +namespace Light.PortableResults.Validation.Tests; + +public sealed class TypedMetadataRoutingTests +{ + [Fact] + public void BuiltInDefinitionsShouldRouteTheTypedMetadataVocabulary() + { + AssertKind(ulong.MaxValue, MetadataKind.UInt64); + AssertKind(0.1f, MetadataKind.Single); + AssertKind('x', MetadataKind.Char); + AssertKind(new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Utc), MetadataKind.DateTime); + AssertKind( + new DateTimeOffset(2026, 7, 26, 13, 45, 30, TimeSpan.FromHours(2)), + MetadataKind.DateTimeOffset + ); + AssertKind(new DateOnly(2026, 7, 26), MetadataKind.DateOnly); + AssertKind(new TimeOnly(13, 45, 30), MetadataKind.TimeOnly); + AssertKind(TimeSpan.FromSeconds(5), MetadataKind.TimeSpan); + AssertKind(new Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"), MetadataKind.Guid); + AssertKind(new Uri("https://example.com/items/42"), MetadataKind.Uri); + } + + [Theory] + [InlineData(0.1f, "0.1")] + [InlineData(5f, "5.0")] + public void ValidationMessagesShouldUseCanonicalSingleEncoding(float value, string expected) + { + var context = new ReadOnlyValidationContext( + new ValidationState(new ValidationContextOptions()), + string.Empty + ); + + ValidationErrorMessageFormatting.FormatParameter(value, context).Should().Be(expected); + } + + [Fact] + public void ValidationMessagesShouldUseCanonicalEncodingForTheNewStringShapedKinds() + { + var context = new ReadOnlyValidationContext( + new ValidationState(new ValidationContextOptions()), + string.Empty + ); + var guid = new Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); + + ValidationErrorMessageFormatting + .FormatParameter(ulong.MaxValue, context) + .Should() + .Be("18446744073709551615"); + ValidationErrorMessageFormatting.FormatParameter('x', context).Should().Be("x"); + ValidationErrorMessageFormatting.FormatParameter( + new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Utc), + context + ).Should().Be("2026-07-26T13:45:30Z"); + ValidationErrorMessageFormatting.FormatParameter( + new DateTimeOffset(2026, 7, 26, 13, 45, 30, TimeSpan.FromHours(2)), + context + ).Should().Be("2026-07-26T13:45:30+02:00"); + ValidationErrorMessageFormatting.FormatParameter(new DateOnly(2026, 7, 26), context) + .Should() + .Be("2026-07-26"); + ValidationErrorMessageFormatting.FormatParameter(new TimeOnly(13, 45, 30), context) + .Should() + .Be("13:45:30"); + ValidationErrorMessageFormatting.FormatParameter(TimeSpan.FromSeconds(5), context) + .Should() + .Be("PT5S"); + ValidationErrorMessageFormatting.FormatParameter(guid, context) + .Should() + .Be(guid.ToString("D")); + ValidationErrorMessageFormatting.FormatParameter( + new Uri("https://example.com/items/42"), + context + ).Should().Be("https://example.com/items/42"); + } + + private static void AssertKind(T value, MetadataKind expectedKind) + { + var definition = BuiltInValidationErrorDefinitions.EqualTo(value); + + definition.Metadata!.Value[ValidationErrorMetadataKeys.ComparativeValue].Kind.Should().Be(expectedKind); + } +} diff --git a/tests/Light.PortableResults.Validation.Tests/ValidationErrorDefinitionTests.cs b/tests/Light.PortableResults.Validation.Tests/ValidationErrorDefinitionTests.cs index 31ceff8..1769468 100644 --- a/tests/Light.PortableResults.Validation.Tests/ValidationErrorDefinitionTests.cs +++ b/tests/Light.PortableResults.Validation.Tests/ValidationErrorDefinitionTests.cs @@ -177,8 +177,8 @@ public void IsIn_ShouldExposeExpectedMetadata() var inRange = BuiltInValidationErrorDefinitions.IsInRange(6UL, 7UL); inRange.Metadata.Should().Be( MetadataObject.Create( - (ValidationErrorMetadataKeys.LowerBoundary, "6"), - (ValidationErrorMetadataKeys.UpperBoundary, "7") + (ValidationErrorMetadataKeys.LowerBoundary, MetadataValue.FromUInt64(6)), + (ValidationErrorMetadataKeys.UpperBoundary, MetadataValue.FromUInt64(7)) ) ); } @@ -189,8 +189,8 @@ public void IsNotIn_ShouldExposeExpectedMetadata() var notInRange = BuiltInValidationErrorDefinitions.IsNotInRange('A', 'Z'); notInRange.Metadata.Should().Be( MetadataObject.Create( - (ValidationErrorMetadataKeys.LowerBoundary, "A"), - (ValidationErrorMetadataKeys.UpperBoundary, "Z") + (ValidationErrorMetadataKeys.LowerBoundary, MetadataValue.FromChar('A')), + (ValidationErrorMetadataKeys.UpperBoundary, MetadataValue.FromChar('Z')) ) ); } @@ -286,9 +286,7 @@ public void EqualTo_ShouldConvertComplexMetadataValues_DateTime() { var date = new DateTime(2024, 12, 24, 10, 30, 00, DateTimeKind.Utc); BuiltInValidationErrorDefinitions.EqualTo(date).Metadata.Should().Be( - MetadataObject.Create( - (ValidationErrorMetadataKeys.ComparativeValue, date.ToString(null, CultureInfo.InvariantCulture)) - ) + MetadataObject.Create((ValidationErrorMetadataKeys.ComparativeValue, MetadataValue.FromDateTime(date))) ); } From 706ff5aa8d506bf40a8782d7781eb7f2b9ce6734 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Tue, 28 Jul 2026 19:51:29 +0200 Subject: [PATCH 06/15] chore: change prefix of Metadata Restructuring plan to 0055 Signed-off-by: Kenny Pflug --- ...0-metadata-restructuring.md => 0055-metadata-restructuring.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename ai-plans/{0054-0-metadata-restructuring.md => 0055-metadata-restructuring.md} (100%) diff --git a/ai-plans/0054-0-metadata-restructuring.md b/ai-plans/0055-metadata-restructuring.md similarity index 100% rename from ai-plans/0054-0-metadata-restructuring.md rename to ai-plans/0055-metadata-restructuring.md From a0f52d0bd39aeda3e8d013e6548944e6e7314738 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Jul 2026 05:58:46 +0200 Subject: [PATCH 07/15] chore: remove obsolete #pragma in MetadataJsonShape.cs Signed-off-by: Kenny Pflug --- src/Light.PortableResults/Metadata/MetadataJsonShape.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Light.PortableResults/Metadata/MetadataJsonShape.cs b/src/Light.PortableResults/Metadata/MetadataJsonShape.cs index 99301e3..b333fa2 100644 --- a/src/Light.PortableResults/Metadata/MetadataJsonShape.cs +++ b/src/Light.PortableResults/Metadata/MetadataJsonShape.cs @@ -1,6 +1,5 @@ namespace Light.PortableResults.Metadata; -#pragma warning disable CS8524 /// /// Describes the JSON shape of a metadata kind. /// From 08e251addd4aeb5f972f3f03827ef00c72deea71 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Jul 2026 07:02:01 +0200 Subject: [PATCH 08/15] fix: DateTimeKind.Unspecified is now accepted by the overhauled metadata system Signed-off-by: Kenny Pflug --- ai-plans/0055-metadata-restructuring.md | 18 ++++- .../Metadata/MetadataValue.cs | 80 ++++++++++++------- ...eResultsOpenApiDocumentTransformerTests.cs | 17 +++- .../Metadata/TypedMetadataValueTests.cs | 60 +++++++++++++- .../TypedMetadataRoutingTests.cs | 37 +++++++++ 5 files changed, 178 insertions(+), 34 deletions(-) diff --git a/ai-plans/0055-metadata-restructuring.md b/ai-plans/0055-metadata-restructuring.md index 98ef42b..f11eef0 100644 --- a/ai-plans/0055-metadata-restructuring.md +++ b/ai-plans/0055-metadata-restructuring.md @@ -1,5 +1,15 @@ # Typed Metadata Kinds +> AMENDED after implementation, during the review of branch `54-metadatakind-restructuring`. +`FromDateTime` originally threw for `DateTimeKind.Unspecified`. Combined with the criterion routing the ten +BCL types to the typed factories, that turned the kind of every `new DateTime(...)` literal into an exception +in `CreateMetadataValue`, `ValidationErrorMessageFormatting`, and OpenAPI example generation - a validation +comparison against an `Unspecified` boundary threw instead of producing a validation error. +Changed sections: the `DateTime` row of the vocabulary table, its notes in Vocabulary, the `TryGetDateTime` +strictness rule in Accessors and equality, and one added acceptance criterion. Everything else is original. +The reversal and the RFC 3339 trade-off it carries are argued in the Vocabulary notes; read those before +re-tightening the factory. Remaining review findings are not yet reflected here. + ## Rationale `MetadataKind` covers only the JSON-shaped types. Every other .NET value reaches metadata through the `IFormattable` fallback in `BuiltInValidationErrorDefinitions.CreateMetadataValue`, producing non-interoperable text: a `DateTime` boundary emits `07/26/2026 13:45:30` instead of RFC 3339, a `TimeSpan` emits `00:00:05` instead of an ISO 8601 duration, and a `TimeOnly` silently drops seconds. These values appear in `problem+json` bodies, so the published OpenAPI contract disagrees with the runtime payload. In-process consumers are equally underserved: a `Guid` or `DateTimeOffset` stored as text costs an allocation on write and a parse on every read, in a library whose primary claim is low allocation. @@ -21,6 +31,7 @@ This plan extends the primitive range that `0052` reserved with kinds for the co - [x] Equality and hashing cover all kinds: values of different kinds are never equal, boxed kinds compare by value, `Uri` values compare by ordinal `OriginalString`, `Annotation` stays excluded, and a test stores every kind in a `MetadataObject` and reads it back by key. - [x] Serialized output for all previously existing kinds is byte-identical to today, apart from the trailing `.0` for whole-number doubles. - [x] `CreateMetadataValue` routes the ten BCL types of the vocabulary table to the typed factories; the `problem+json` OpenAPI conformance test from `0052` passes unchanged, and an equivalent test covers a `DateTime` or `TimeSpan` boundary. +- [x] A `DateTime` of any `DateTimeKind` reaches metadata without throwing, keeps its kind through storage and reading, and renders through one encoding at every site. A validation comparison against a `DateTimeKind.Unspecified` boundary produces a validation error, never an exception. - [x] The core project builds for `netstandard2.0` and `net10.0` in Release with warnings as errors, and the only public API difference between the targets is the `DateOnly`/`TimeOnly` factories and accessors. - [x] Test code coverage stays above 95%. @@ -39,7 +50,7 @@ Members are named after their `System.*` type, as the existing ones are (`Int64` | `UInt64` = 6 | `ulong` | inline | decimal digits as JSON **string** | `string`, `uint64` | | `Single` = 7 | `float` | inline, widened `double` | shortest round-trippable number | `number`, `float` | | `Char` = 8 | `char` | inline | single-character string | `string`, `char` | -| `DateTime` = 9 | `DateTime` | inline UTC ticks | RFC 3339 date-time, `Z` suffix | `string`, `date-time` | +| `DateTime` = 9 | `DateTime` | inline `ToBinary()` | RFC 3339 date-time with `Z`, or ISO 8601 local time without a designator when the kind is `Unspecified` | `string`, `date-time` | | `DateTimeOffset` = 10 | `DateTimeOffset` | boxed | RFC 3339 date-time with offset | `string`, `date-time` | | `DateOnly` = 11 | `DateOnly` | inline day number | RFC 3339 full-date | `string`, `date` | | `TimeOnly` = 12 | `TimeOnly` | inline ticks | RFC 3339 full-time | `string`, `time` | @@ -53,7 +64,9 @@ The schema column is normative: `PortableOpenApiSchemaTypeMapper` and the source - `Uri` maps to `uri-reference` rather than `uri` because the schema is per CLR type while absoluteness is a per-value property. - `Single` is widened with a plain `(double)` cast at construction and narrowed back with `(float)` before formatting; the float → double → float round trip is lossless, so `0.1f` still emits `0.1` at no construction cost. The consequence is that `TryGetDouble` on a `Single` yields the widened value (`0.10000000149011612` for `0.1f`), not the double nearest `0.1`. - The existing `Double` kind gains a canonical rule shared with `Single`: when the shortest round-trippable text contains neither a fraction nor an exponent, `.0` is appended (via an integral check and `WriteRawValue`), so a bare `Double` token never reads back as `Int64`. `Decimal` is deliberately excluded from this rule: `5m` has scale 0 and must keep emitting `5`. -- `DateTime` and `DateTimeOffset` are separate kinds because the common UTC case then stores inline ticks and avoids the boxing an offset requires. `FromDateTime` converts `Local` to UTC and throws for `DateTimeKind.Unspecified`. +- `DateTime` and `DateTimeOffset` are separate kinds because the common UTC case then stores inline and avoids the boxing an offset requires. `FromDateTime` converts `Local` to UTC — a local wall clock is meaningless once it leaves the process — and accepts `DateTimeKind.Unspecified` as it is. Rejecting `Unspecified` is not an option: it is what every `new DateTime(...)` literal and every zone-less `DateTime.Parse` produces, so it is the common case for a validation boundary, and throwing turns a validation failure into an exception in `CreateMetadataValue`, `ValidationErrorMessageFormatting`, and OpenAPI example generation alike. +- Because the kind must survive, the payload stores `DateTime.ToBinary()` rather than raw ticks: it packs the kind into the two spare high bits of the tick count, in the same `Int64` slot and with no loss (for `Utc` and `Unspecified` it is a bit-exact copy of the internal state). Only `Utc` and `Unspecified` are ever stored, so decoding is deterministic; `FromBinary` resolves a `Local` payload against the reading machine's time zone, so a `Local` result is treated as a corrupt payload alongside the out-of-range case. +- An `Unspecified` value renders without a designator (`2026-07-26T13:45:30`). That is valid ISO 8601 local time but **not** RFC 3339, which makes the offset mandatory, so it does not satisfy the `format: date-time` that the schema mapper emits for `System.DateTime`. This is accepted deliberately: the alternative is inventing a `Z` the caller never asserted, which is exactly the silent-wrong-data failure mode this plan exists to remove. The one encoding is used everywhere — JSON bodies, headers, CloudEvents attributes, message text, OpenAPI examples — so the document never disagrees with the payload. Steering callers toward `Utc` at API boundaries belongs in an analyzer diagnostic, not a runtime throw. - `DateTimeOffset` boxes like `Decimal` — see `MetadataValue.FromDecimal` for the rationale. `FromUri(null)` returns `Null`, mirroring `FromString`. ### Payload storage @@ -83,6 +96,7 @@ Strictness is part of that contract, because the framework defaults are too perm - `Uri` accepts `UriKind.Absolute` only. `UriKind.RelativeOrAbsolute` succeeds for nearly any text, which would make `TryGetUri` return `true` for `"hello world"` and turn the accessor into a footgun for callers that probe kinds in sequence. - Date and time kinds parse the exact canonical format with the invariant culture and `DateTimeStyles.RoundtripKind` — never `DateTime.Parse`, which accepts `07/26/2026 13:45:30` and would resurrect inside the accessors the ambiguity this plan removes from the writers. +- `TryGetDateTime` accepts both `DateTime` encodings (with and without the `Z`) and rejects text carrying a numeric offset. That text is the `DateTimeOffset` encoding, and resolving it into a `DateTime` would make the result depend on the reading machine's time zone. Equality stays strict — kind plus payload, `Annotation` excluded. `FromGuid(g)` is not equal to `FromString(g.ToString())`; round-trip fidelity is `0054-1`'s job, not `Equals`'. Boxed kinds unbox and compare by value with the same defensive `is` pattern as the `Decimal` arm. `Uri` is a reference rather than a boxed value and must not use `Uri.Equals`, which ignores the fragment and compares hosts case-insensitively: two URIs that serialize differently would compare equal. It compares and hashes its `OriginalString` ordinally, which is exactly what is written to the wire. diff --git a/src/Light.PortableResults/Metadata/MetadataValue.cs b/src/Light.PortableResults/Metadata/MetadataValue.cs index 2050fcb..d493ae8 100644 --- a/src/Light.PortableResults/Metadata/MetadataValue.cs +++ b/src/Light.PortableResults/Metadata/MetadataValue.cs @@ -148,23 +148,30 @@ public static MetadataValue FromChar( new (MetadataKind.Char, new MetadataPayload(value), annotation); /// - /// Creates a metadata value from a date and time. Local values are converted to UTC. + /// + /// Creates a metadata value from a date and time. + /// + /// + /// values are converted to UTC, because a local wall clock is meaningless + /// once it leaves the process. values are stored as they are and + /// render without a UTC designator: they carry no zone, and inventing one would assert an instant the caller + /// never chose. Such a value is valid ISO 8601 but not RFC 3339 - see the remarks on + /// . + /// /// - /// - /// Thrown when has . - /// public static MetadataValue FromDateTime( DateTime value, MetadataValueAnnotation annotation = DefaultAnnotation ) { - if (value.Kind == DateTimeKind.Unspecified) - { - throw new ArgumentException("A metadata DateTime must specify either Local or Utc kind.", nameof(value)); - } - - var utcValue = value.Kind == DateTimeKind.Utc ? value : value.ToUniversalTime(); - return new MetadataValue(MetadataKind.DateTime, new MetadataPayload(utcValue.Ticks), annotation); + // ToBinary packs the kind into the two spare high bits of the tick count, so the Utc/Unspecified + // distinction survives in the same 8-byte Int64 slot that raw ticks used to occupy. + var storedValue = value.Kind == DateTimeKind.Local ? value.ToUniversalTime() : value; + return new MetadataValue( + MetadataKind.DateTime, + new MetadataPayload(storedValue.ToBinary()), + annotation + ); } /// @@ -520,21 +527,16 @@ public bool TryGetChar(out char value) return false; } - /// Attempts to get a UTC date-time or parse its canonical RFC 3339 encoding. + /// + /// Attempts to get a UTC or unspecified date-time, or parse its canonical encoding. Text carrying a numeric + /// UTC offset is rejected: that is the encoding of , and resolving + /// it here would depend on the local time zone of the reading machine. + /// public bool TryGetDateTime(out DateTime value) { if (Kind == MetadataKind.DateTime) { - try - { - value = new DateTime(_payload.Int64, DateTimeKind.Utc); - return true; - } - catch (ArgumentOutOfRangeException) - { - value = default; - return false; - } + return TryReadStoredDateTime(_payload.Int64, out value); } if (TryGetRawString(out var text) && @@ -544,7 +546,7 @@ public bool TryGetDateTime(out DateTime value) DateTimeStyles.RoundtripKind, out value ) && - value.Kind == DateTimeKind.Utc && + value.Kind != DateTimeKind.Local && string.Equals(FormatDateTime(value), text, StringComparison.Ordinal)) { return true; @@ -762,6 +764,13 @@ public MetadataObject AsObject() => /// /// Returns the canonical text encoding of a primitive metadata value. /// + /// + /// A holding a value is encoded + /// without a UTC designator, for example 2026-07-26T13:45:30. That form is valid ISO 8601 local time + /// but not RFC 3339, which makes the offset mandatory, so it does not satisfy the OpenAPI + /// format: date-time that this library generates for . Prefer + /// or at API boundaries. + /// /// /// Thrown for an array, object, or malformed payload. /// @@ -974,18 +983,35 @@ private static string EnsureFloatingPointMarker(string value) => value + ".0" : value; - private static string FormatStoredDateTime(long ticks) + private static bool TryReadStoredDateTime(long binaryValue, out DateTime value) { try { - return FormatDateTime(new DateTime(ticks, DateTimeKind.Utc)); + var storedValue = DateTime.FromBinary(binaryValue); + + // FromBinary resolves a Local payload against the time zone of the reading machine. FromDateTime + // never stores one, so seeing it here means the payload is corrupt rather than merely out of range. + if (storedValue.Kind == DateTimeKind.Local) + { + value = default; + return false; + } + + value = storedValue; + return true; } - catch (ArgumentOutOfRangeException exception) + catch (ArgumentException) { - throw new InvalidOperationException("The DateTime metadata payload is invalid.", exception); + value = default; + return false; } } + private static string FormatStoredDateTime(long binaryValue) => + TryReadStoredDateTime(binaryValue, out var value) ? + FormatDateTime(value) : + throw new InvalidOperationException("The DateTime metadata payload is invalid."); + private static string FormatDateTime(DateTime value) => value.ToString(DateTimeFormat, CultureInfo.InvariantCulture); diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs index 0aa1d22..ae719c2 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableResultsOpenApiDocumentTransformerTests.cs @@ -590,7 +590,11 @@ public async Task Transformer_ShouldDeriveProblemExampleCategoryFromStatusCode() .WithName("ConflictExample") .ProducesPortableProblem( StatusCodes.Status409Conflict, - configure: builder => builder.WithErrorExample("VersionMismatch", target: null, "version mismatch") + configure: builder => builder.WithErrorExample( + "VersionMismatch", + target: null, + "version mismatch" + ) ); } ); @@ -642,6 +646,15 @@ public async Task Transformer_ShouldEncodeExampleMetadataAccordingToTheTypedVoca 30, DateTimeKind.Utc ), + ["dateTimeUnspecified"] = new DateTime( + 2026, + 7, + 26, + 13, + 45, + 30, + DateTimeKind.Unspecified + ), ["dateTimeOffset"] = new DateTimeOffset( 2026, 7, @@ -679,6 +692,8 @@ await GetOpenApiDocumentAsync(app), metadata["double"]!.ToJsonString().Should().Be("5.0"); metadata["char"]!.GetValue().Should().Be("x"); metadata["dateTime"]!.GetValue().Should().Be("2026-07-26T13:45:30Z"); + // No UTC designator: the example carries no zone because the caller never chose one. + metadata["dateTimeUnspecified"]!.GetValue().Should().Be("2026-07-26T13:45:30"); metadata["dateTimeOffset"]!.GetValue().Should().Be("2026-07-26T13:45:30+02:00"); metadata["dateOnly"]!.GetValue().Should().Be("2026-07-26"); metadata["timeOnly"]!.GetValue().Should().Be("13:45:30"); diff --git a/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs b/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs index e638284..4b4840b 100644 --- a/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs +++ b/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs @@ -14,6 +14,7 @@ namespace Light.PortableResults.Tests.Metadata; public sealed class TypedMetadataValueTests { private static readonly DateTime UtcDateTime = new (2026, 7, 26, 13, 45, 30, DateTimeKind.Utc); + private static readonly DateTimeOffset OffsetDateTime = new ( 2026, 7, @@ -23,6 +24,7 @@ public sealed class TypedMetadataValueTests 30, TimeSpan.FromHours(2) ); + private static readonly Guid GuidValue = new ("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); private static readonly Uri UriValue = new ("https://example.com/items/42?view=full#summary"); @@ -124,7 +126,7 @@ public void IntegralImplicitConversionsShouldRemainUnambiguous() } [Fact] - public void DateTimeFactoryShouldNormalizeLocalAndRejectUnspecifiedValues() + public void DateTimeFactoryShouldNormalizeLocalValuesToUtc() { var local = new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Local); @@ -133,12 +135,53 @@ public void DateTimeFactoryShouldNormalizeLocalAndRejectUnspecifiedValues() value.TryGetDateTime(out var stored).Should().BeTrue(); stored.Should().Be(local.ToUniversalTime()); stored.Kind.Should().Be(DateTimeKind.Utc); + value.ToCanonicalString().Should().EndWith("Z"); + } + + [Fact] + public void DateTimeFactoryShouldPreserveUnspecifiedValuesWithoutADesignator() + { + var unspecified = new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Unspecified); + + var value = MetadataValue.FromDateTime(unspecified); + + value.Kind.Should().Be(MetadataKind.DateTime); + value.TryGetDateTime(out var stored).Should().BeTrue(); + stored.Should().Be(unspecified); + stored.Kind.Should().Be(DateTimeKind.Unspecified); + value.ToCanonicalString().Should().Be("2026-07-26T13:45:30"); + Serialize(value).Should().Be("\"2026-07-26T13:45:30\""); + } - var act = () => MetadataValue.FromDateTime( + [Fact] + public void UnspecifiedAndUtcDateTimesWithTheSameWallClockShouldNotBeEqual() + { + var unspecified = MetadataValue.FromDateTime( new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Unspecified) ); + var utc = MetadataValue.FromDateTime(new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Utc)); + + unspecified.Should().NotBe(utc); + unspecified.ToCanonicalString().Should().NotBe(utc.ToCanonicalString()); + } + + [Theory] + [InlineData("2026-07-26T13:45:30", DateTimeKind.Unspecified)] + [InlineData("2026-07-26T13:45:30Z", DateTimeKind.Utc)] + public void DateTimeAccessorShouldAcceptBothCanonicalStringEncodings(string text, DateTimeKind expectedKind) + { + MetadataValue.FromString(text).TryGetDateTime(out var value).Should().BeTrue(); + + value.Kind.Should().Be(expectedKind); + MetadataValue.FromDateTime(value).ToCanonicalString().Should().Be(text); + } - act.Should().Throw().WithParameterName("value"); + [Fact] + public void DateTimeAccessorShouldRejectTextCarryingANumericOffset() + { + // This is the DateTimeOffset encoding. Resolving it into a DateTime would depend on the time zone of + // whichever machine happens to read the value. + MetadataValue.FromString("2026-07-26T13:45:30+02:00").TryGetDateTime(out _).Should().BeFalse(); } [Fact] @@ -238,7 +281,7 @@ public void SingleShouldWidenToDoubleWithoutChangingItsOwnAccessor() value.TryGetDouble(out var @double).Should().BeTrue(); single.Should().Be(0.1f); - @double.Should().Be((double) 0.1f); + @double.Should().Be(0.1f); @double.Should().NotBe(0.1); } @@ -318,18 +361,24 @@ public void MetadataPayloadShouldRejectStandaloneEqualityAndHashing() public void MalformedInlineTemporalPayloadsShouldBeRejected() { var dateTime = MetadataValueTestFactory.CreateWithInt64Payload(MetadataKind.DateTime, long.MaxValue); + // DateTime.FromBinary does not throw for this one - it decodes into a Local value, which FromDateTime + // never stores and whose rendering would depend on the reading machine's time zone. + var localDateTime = MetadataValueTestFactory.CreateWithInt64Payload(MetadataKind.DateTime, -1L); var dateOnly = MetadataValueTestFactory.CreateWithInt64Payload(MetadataKind.DateOnly, long.MaxValue); var timeOnly = MetadataValueTestFactory.CreateWithInt64Payload(MetadataKind.TimeOnly, long.MaxValue); dateTime.TryGetDateTime(out _).Should().BeFalse(); + localDateTime.TryGetDateTime(out _).Should().BeFalse(); dateOnly.TryGetDateOnly(out _).Should().BeFalse(); timeOnly.TryGetTimeOnly(out _).Should().BeFalse(); var dateTimeFormatAct = () => dateTime.ToCanonicalString(); + var localDateTimeFormatAct = () => localDateTime.ToCanonicalString(); var dateOnlyFormatAct = () => dateOnly.ToCanonicalString(); var timeOnlyFormatAct = () => timeOnly.ToCanonicalString(); dateTimeFormatAct.Should().Throw(); + localDateTimeFormatAct.Should().Throw(); dateOnlyFormatAct.Should().Throw(); timeOnlyFormatAct.Should().Throw(); } @@ -354,6 +403,9 @@ private static Dictionary CreateEveryKind() => ["single"] = MetadataValue.FromSingle(0.1f), ["char"] = MetadataValue.FromChar('x'), ["dateTime"] = MetadataValue.FromDateTime(UtcDateTime), + ["dateTimeUnspecified"] = MetadataValue.FromDateTime( + new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Unspecified) + ), ["dateTimeOffset"] = MetadataValue.FromDateTimeOffset(OffsetDateTime), ["dateOnly"] = MetadataValue.FromDateOnly(new DateOnly(2026, 7, 26)), ["timeOnly"] = MetadataValue.FromTimeOnly(new TimeOnly(13, 45, 30)), diff --git a/tests/Light.PortableResults.Validation.Tests/TypedMetadataRoutingTests.cs b/tests/Light.PortableResults.Validation.Tests/TypedMetadataRoutingTests.cs index 3634e61..8bf93b3 100644 --- a/tests/Light.PortableResults.Validation.Tests/TypedMetadataRoutingTests.cs +++ b/tests/Light.PortableResults.Validation.Tests/TypedMetadataRoutingTests.cs @@ -16,6 +16,10 @@ public void BuiltInDefinitionsShouldRouteTheTypedMetadataVocabulary() AssertKind(0.1f, MetadataKind.Single); AssertKind('x', MetadataKind.Char); AssertKind(new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Utc), MetadataKind.DateTime); + // DateTimeKind.Unspecified is what every `new DateTime(...)` literal produces, so it is the common case + // for a validation boundary rather than an edge case. + AssertKind(new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Unspecified), MetadataKind.DateTime); + AssertKind(new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Local), MetadataKind.DateTime); AssertKind( new DateTimeOffset(2026, 7, 26, 13, 45, 30, TimeSpan.FromHours(2)), MetadataKind.DateTimeOffset @@ -80,6 +84,39 @@ public void ValidationMessagesShouldUseCanonicalEncodingForTheNewStringShapedKin ).Should().Be("https://example.com/items/42"); } + [Fact] + public void ComparisonAgainstAnUnspecifiedDateTimeShouldProduceAnErrorRatherThanThrow() + { + var context = ValidationWorkflowTestData.ValidationContextFactory.CreateValidationContext(); + + context + .Check(new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Unspecified), target: "when") + .IsGreaterThan(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Unspecified)); + + var error = context.Errors.Should().ContainSingle().Subject; + error.Message.Should().Be("when must be greater than 2026-01-01T00:00:00"); + error.Metadata!.Value[ValidationErrorMetadataKeys.ComparativeValue] + .ToCanonicalString() + .Should() + .Be("2026-01-01T00:00:00"); + } + + [Fact] + public void ValidationMessagesShouldNotInventAZoneForUnspecifiedDateTimes() + { + var context = new ReadOnlyValidationContext( + new ValidationState(new ValidationContextOptions()), + string.Empty + ); + + ValidationErrorMessageFormatting + .FormatParameter( + new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Unspecified), + context + ) + .Should().Be("2026-07-26T13:45:30"); + } + private static void AssertKind(T value, MetadataKind expectedKind) { var definition = BuiltInValidationErrorDefinitions.EqualTo(value); From 04dd01e3c061c97de972d17dfafa2991656f791c Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Jul 2026 07:15:33 +0200 Subject: [PATCH 09/15] fix: GetStringAttribute now return null instead of string "null" Signed-off-by: Kenny Pflug --- ai-plans/0055-metadata-restructuring.md | 29 ++++++---- .../Writing/CloudEventsResultExtensions.cs | 8 ++- .../CloudEventsResultExtensionsTests.cs | 56 ++++++++++++++++++- 3 files changed, 80 insertions(+), 13 deletions(-) diff --git a/ai-plans/0055-metadata-restructuring.md b/ai-plans/0055-metadata-restructuring.md index f11eef0..1a86cec 100644 --- a/ai-plans/0055-metadata-restructuring.md +++ b/ai-plans/0055-metadata-restructuring.md @@ -1,14 +1,23 @@ # Typed Metadata Kinds -> AMENDED after implementation, during the review of branch `54-metadatakind-restructuring`. -`FromDateTime` originally threw for `DateTimeKind.Unspecified`. Combined with the criterion routing the ten -BCL types to the typed factories, that turned the kind of every `new DateTime(...)` literal into an exception -in `CreateMetadataValue`, `ValidationErrorMessageFormatting`, and OpenAPI example generation - a validation -comparison against an `Unspecified` boundary threw instead of producing a validation error. -Changed sections: the `DateTime` row of the vocabulary table, its notes in Vocabulary, the `TryGetDateTime` -strictness rule in Accessors and equality, and one added acceptance criterion. Everything else is original. -The reversal and the RFC 3339 trade-off it carries are argued in the Vocabulary notes; read those before -re-tightening the factory. Remaining review findings are not yet reflected here. +> **AMENDED** after implementation, during the review of branch `54-metadatakind-restructuring`. Two decisions +> below were reversed because they specified behavior that turned out to be wrong. Remaining review findings +> are not yet reflected here. +> +> **`DateTime` and `DateTimeKind.Unspecified`.** `FromDateTime` originally threw for `Unspecified`. Combined +> with the criterion routing the ten BCL types to the typed factories, that turned the kind of every +> `new DateTime(...)` literal into an exception in `CreateMetadataValue`, `ValidationErrorMessageFormatting`, +> and OpenAPI example generation - a validation comparison against an `Unspecified` boundary threw instead of +> producing a validation error. Changed: the `DateTime` row of the vocabulary table, its notes in Vocabulary, +> the `TryGetDateTime` strictness rule in Accessors and equality, and one added acceptance criterion. The +> reversal and the RFC 3339 trade-off it carries are argued in the Vocabulary notes; read those before +> re-tightening the factory. +> +> **CloudEvents core string attributes and `Null`.** The criterion below originally required a value of *any* +> primitive kind to resolve as a core string attribute. `Null` is a primitive kind whose canonical text is +> `"null"`, so the letter of that criterion made an explicitly null attribute resolve to the four-character +> string - a `source` of `"null"` passed the required-attribute check and shipped an invalid event. The +> criterion now excludes `Null`, restoring the pre-plan behavior of treating it as absent. ## Rationale @@ -24,7 +33,7 @@ This plan extends the primitive range that `0052` reserved with kinds for the co - [x] Each `TryGet*` additionally converts from `MetadataKind.String` when the string holds the kind's canonical encoding and rejects any other text, so consumers work identically on in-process and wire-degraded values without accepting input the writers never produce. - [x] Every kind serializes into JSON bodies using the encoding in the vocabulary table; in particular `TimeOnly` preserves seconds, `TimeSpan` emits an ISO 8601 duration, `0.1f` emits `0.1`, and a whole-number `Double` or `Single` emits a trailing `.0` (`5.0`, not `5`). - [x] Generated OpenAPI schemas and examples match the JSON encoding of every kind, asserted against the vocabulary table: `ulong` is a string, `TimeSpan` is `format: duration` rather than `time`, and `char` and `Uri` no longer degrade to a schema without a type. -- [x] HTTP header values, CloudEvents core string attributes, and validation error message text use the same canonical encodings: header output is unquoted for every primitive kind including plain strings, and a value of any primitive kind resolves as a core string attribute. +- [x] HTTP header values, CloudEvents core string attributes, and validation error message text use the same canonical encodings: header output is unquoted for every primitive kind including plain strings, and a value of any primitive kind except `Null` resolves as a core string attribute. `Null` resolves to no attribute at all, as it did before this plan. - [x] The JSON shape of a kind (`Null`, `Boolean`, `Number`, `String`, `Array`, `Object`) is publicly derivable from `MetadataKind` alone. - [x] Every `switch` over `MetadataKind` inside `MetadataValue` lists all members without a discard arm, so declaring a new member fails the Release build (CS8509 with `TreatWarningsAsErrors`) until every switch is updated. - [x] `MetadataValueAnnotationHelper.WithAnnotation` preserves the value of every kind; annotation constraints for arrays and objects are still enforced. diff --git a/src/Light.PortableResults/CloudEvents/Writing/CloudEventsResultExtensions.cs b/src/Light.PortableResults/CloudEvents/Writing/CloudEventsResultExtensions.cs index 2792b34..01ef593 100644 --- a/src/Light.PortableResults/CloudEvents/Writing/CloudEventsResultExtensions.cs +++ b/src/Light.PortableResults/CloudEvents/Writing/CloudEventsResultExtensions.cs @@ -585,7 +585,13 @@ private static ResolvedAttributes ResolveAttributes( return null; } - return metadataValue.Kind.IsPrimitive() ? metadataValue.ToCanonicalString() : null; + // Null is a primitive kind whose canonical text is "null", so it must be excluded explicitly. Rendering + // it would turn an absent attribute into the four-character string "null": a 'source' of "null" passes + // the required-attribute check and ships an invalid event, and a 'time' of "null" fails RFC 3339 + // parsing instead of falling back to the current timestamp. + return !metadataValue.IsNull && metadataValue.Kind.IsPrimitive() ? + metadataValue.ToCanonicalString() : + null; } private static DateTimeOffset? GetDateTimeOffsetAttribute(MetadataObject? attributes, string attributeName) diff --git a/tests/Light.PortableResults.Tests/CloudEvents/Writing/CloudEventsResultExtensionsTests.cs b/tests/Light.PortableResults.Tests/CloudEvents/Writing/CloudEventsResultExtensionsTests.cs index 85a401f..16705f5 100644 --- a/tests/Light.PortableResults.Tests/CloudEvents/Writing/CloudEventsResultExtensionsTests.cs +++ b/tests/Light.PortableResults.Tests/CloudEvents/Writing/CloudEventsResultExtensionsTests.cs @@ -419,10 +419,10 @@ public void ToCloudEvent_ShouldResolveCoreStringAttributes_FromDecimalMetadataVa [Fact] public void ToCloudEvent_ShouldResolveCoreStringAttributeFromEveryPrimitiveKind() { - var annotation = MetadataValueAnnotation.SerializeInCloudEventsExtensionAttributes; + const MetadataValueAnnotation annotation = MetadataValueAnnotation.SerializeInCloudEventsExtensionAttributes; + // Null is deliberately absent here - it resolves to no attribute at all, see the dedicated test below. var values = new (MetadataValue Value, string Expected)[] { - (MetadataValue.FromNull(annotation), "null"), (MetadataValue.FromBoolean(true, annotation), "true"), (MetadataValue.FromInt64(42, annotation), "42"), (MetadataValue.FromDouble(5, annotation), "5.0"), @@ -482,6 +482,58 @@ public void ToCloudEvent_ShouldResolveCoreStringAttributeFromEveryPrimitiveKind( } } + [Fact] + public void ToCloudEvent_ShouldTreatNullCoreAttribute_AsAbsent() + { + const MetadataValueAnnotation annotation = MetadataValueAnnotation.SerializeInCloudEventsExtensionAttributes; + var metadata = MetadataObject.Create( + ("type", MetadataValue.FromString("app.success", annotation)), + ("source", MetadataValue.FromString("urn:test:source", annotation)), + ("subject", MetadataValue.FromNull(annotation)), + ("dataschema", MetadataValue.FromNull(annotation)) + ); + + var json = Result.Ok(metadata).ToCloudEvent(options: CreateWriteOptions(source: null)); + + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + root.TryGetProperty("subject", out _).Should().BeFalse(); + root.TryGetProperty("dataschema", out _).Should().BeFalse(); + } + + [Fact] + public void ToCloudEvent_ShouldFallBackToDefaultSource_WhenSourceAttributeIsNull() + { + const MetadataValueAnnotation annotation = MetadataValueAnnotation.SerializeInCloudEventsExtensionAttributes; + var metadata = MetadataObject.Create( + ("type", MetadataValue.FromString("app.success", annotation)), + ("source", MetadataValue.FromNull(annotation)) + ); + + var json = Result.Ok(metadata).ToCloudEvent(options: CreateWriteOptions()); + + using var document = JsonDocument.Parse(json); + document.RootElement.GetProperty("source").GetString().Should().Be("urn:test:source"); + } + + [Fact] + public void ToCloudEvent_ShouldFallBackToCurrentTimestamp_WhenTimeAttributeIsNull() + { + const MetadataValueAnnotation annotation = MetadataValueAnnotation.SerializeInCloudEventsExtensionAttributes; + var before = DateTimeOffset.UtcNow; + var metadata = MetadataObject.Create( + ("type", MetadataValue.FromString("app.success", annotation)), + ("source", MetadataValue.FromString("urn:test:source", annotation)), + ("time", MetadataValue.FromNull(annotation)) + ); + + var json = Result.Ok(metadata).ToCloudEvent(options: CreateWriteOptions(source: null)); + + using var document = JsonDocument.Parse(json); + var time = DateTimeOffset.Parse(document.RootElement.GetProperty("time").GetString()!); + time.Should().BeOnOrAfter(before); + } + [Fact] public void ToCloudEvent_ShouldWriteDecimalExtensionAttribute_AsUnquotedNumber() { From ef4aa78d12d55c32c53b6ce10099184fba58c0e9 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Jul 2026 19:17:29 +0200 Subject: [PATCH 10/15] perf(metadata): write Int64 and Decimal numbers without allocating The JSON number arm routed all four numeric kinds through the canonical text and WriteRawValue, costing a string allocation and a JSON validation pass per value on the most common metadata kinds. Int64 and Decimal now use Utf8JsonWriter's own number formatting, which is byte-identical and allocation-free; only Double and Single keep the raw path, because only they carry the trailing ".0" that stops a whole number from reading back as an Int64. That path now skips input validation, since the text comes from our own formatter and non-finite values are rejected at construction. The kind list guarding that arm was also the last dispatch in the chain that was not compiler-checked, and re-deriving the JSON shape inside the arm would be circular. MetadataKindExtensions.GetNumberEncoding replaces the list with an exhaustive switch expression over MetadataKind, so a kind added with the Number shape but no encoding fails the Release build next to GetJsonShape instead of throwing at runtime. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RtfndM4yhWgCtShY4trph5 Signed-off-by: Kenny Pflug --- ai-plans/0055-metadata-restructuring.md | 16 ++++++--- .../Metadata/MetadataKindExtensions.cs | 35 +++++++++++++++++++ .../Metadata/MetadataNumberEncoding.cs | 30 ++++++++++++++++ .../Writing/MetadataExtensions.cs | 32 +++++++++++------ .../Metadata/MetadataKindTests.cs | 32 +++++++++++++++++ .../Metadata/TypedMetadataValueTests.cs | 31 ++++++++++++++++ 6 files changed, 162 insertions(+), 14 deletions(-) create mode 100644 src/Light.PortableResults/Metadata/MetadataNumberEncoding.cs diff --git a/ai-plans/0055-metadata-restructuring.md b/ai-plans/0055-metadata-restructuring.md index 1a86cec..c04b905 100644 --- a/ai-plans/0055-metadata-restructuring.md +++ b/ai-plans/0055-metadata-restructuring.md @@ -1,8 +1,8 @@ # Typed Metadata Kinds -> **AMENDED** after implementation, during the review of branch `54-metadatakind-restructuring`. Two decisions -> below were reversed because they specified behavior that turned out to be wrong. Remaining review findings -> are not yet reflected here. +> **AMENDED** after implementation, during the review of branch `54-metadatakind-restructuring`. The decisions +> recorded below were revised because they specified behavior that turned out to be wrong. Remaining review +> findings are not yet reflected here. > > **`DateTime` and `DateTimeKind.Unspecified`.** `FromDateTime` originally threw for `Unspecified`. Combined > with the criterion routing the ten BCL types to the typed factories, that turned the kind of every @@ -13,6 +13,12 @@ > reversal and the RFC 3339 trade-off it carries are argued in the Vocabulary notes; read those before > re-tightening the factory. > +> **Number writing.** The `Number` arm originally routed all four numeric kinds through the canonical text and +> `WriteRawValue`, costing a string allocation and a JSON validation pass per value on `Int64` and `Decimal`, +> where the writer's own formatting is byte-identical and allocation-free. The kind list guarding that arm was +> also the last dispatch in the chain that was not compiler-checked. Both are addressed by the two new bullets +> in Dispatch safety and the `MetadataNumberEncoding` classification they introduce. +> > **CloudEvents core string attributes and `Null`.** The criterion below originally required a value of *any* > primitive kind to resolve as a core string attribute. `Null` is a primitive kind whose canonical text is > `"null"`, so the letter of that criterion made an explicitly null attribute resolve to the four-character @@ -90,7 +96,9 @@ While editing the struct, replace `record struct` with a plain `readonly struct` `0052` documented that adding a kind breaks nothing at compile time and corrupts silently. This plan removes that hazard structurally: -- **Derived shape:** `GetJsonShape()` is an extension method on `MetadataKind` next to `IsPrimitive`, implemented as an exhaustive switch expression. The shape is a function of the kind alone, and the callers that need it most — the schema mapper, the header conversion service — hold a kind rather than a value; `MetadataValue` exposes a forwarding property. A lookup table is deliberately not used: it would map an unlisted kind to shape `0` silently, which is the failure mode this section exists to remove, and the switch is what the JIT turns into a jump table anyway. The JSON writers switch on the six shapes; the `Number` arm dispatches over `Int64`/`Double`/`Single`/`Decimal` only. +- **Derived shape:** `GetJsonShape()` is an extension method on `MetadataKind` next to `IsPrimitive`, implemented as an exhaustive switch expression. The shape is a function of the kind alone, and the callers that need it most — the schema mapper, the header conversion service — hold a kind rather than a value; `MetadataValue` exposes a forwarding property. A lookup table is deliberately not used: it would map an unlisted kind to shape `0` silently, which is the failure mode this section exists to remove, and the switch is what the JIT turns into a jump table anyway. The JSON writers switch on the six shapes. +- **Derived number encoding:** the `Number` arm needs to know which primitive overload to write a value with, which the shape alone does not say. `GetNumberEncoding()` sits next to `GetJsonShape()` as a second exhaustive switch expression over `MetadataKind`, returning `Int64`/`Double`/`Single`/`Decimal` or `None`. Re-deriving the shape inside the arm would be circular, and a `switch` *statement* over the kind in the writer would compile silently, so the classification has to be a value-returning switch to be checked at all — the enum is what buys the diagnostic. Both classifications then fail the Release build together when a kind is added, and a test pins them against each other so a `Number` shape without an encoding cannot ship. +- **Native number writing:** only `Double` and `Single` take the canonical-text `WriteRawValue` path, because only they carry the trailing `.0`. `Int64` and `Decimal` use `Utf8JsonWriter`'s own number formatting, which is byte-identical and allocation-free; routing them through the formatter costs a string allocation and a validation pass per value on the most common metadata kind in the library. The raw path skips input validation: the text comes from our own formatter and non-finite values are rejected at construction. - **One formatter:** a single canonical-text method (with a `TryFormat`-style span overload) is the only place that knows how string-shaped kinds render. JSON string values, `DefaultHttpHeaderConversionService`'s fallback (currently `ToString()`, which wrongly quotes strings), and `GetStringAttribute` all call it, replacing the latter's special-cased decimal branch. Adding a kind touches this one site instead of eight. `ToString()` becomes debug-only output. - **Payload-preserving constructor:** `WithAnnotation`'s per-kind switch exists only because no constructor copies an existing payload. An internal `MetadataValue(Kind, payload, annotation)` path reduces the primitive case to one line; `Array`/`Object` still recurse to rewrite children and revalidate annotation constraints. - **Exhaustive switches:** the remaining kind switches (`Equals`, `GetHashCode`, `ToString`, the formatter) become switch expressions listing every member with no discard arm. diff --git a/src/Light.PortableResults/Metadata/MetadataKindExtensions.cs b/src/Light.PortableResults/Metadata/MetadataKindExtensions.cs index 07ef4a8..0c6bc7e 100644 --- a/src/Light.PortableResults/Metadata/MetadataKindExtensions.cs +++ b/src/Light.PortableResults/Metadata/MetadataKindExtensions.cs @@ -40,4 +40,39 @@ public static MetadataJsonShape GetJsonShape(this MetadataKind kind) => MetadataKind.Array => MetadataJsonShape.Array, MetadataKind.Object => MetadataJsonShape.Object }; + + /// + /// Gets the .NET numeric type that the specified metadata kind is written from, or + /// when the kind does not have the + /// shape. + /// + /// The metadata kind. + /// The number encoding. + /// + /// This is an exhaustive switch on purpose. A kind added to with the + /// shape but forgotten here would otherwise reach the JSON writer + /// as an unwritable number, so both classifications fail the Release build together. + /// + public static MetadataNumberEncoding GetNumberEncoding(this MetadataKind kind) => + kind switch + { + MetadataKind.Null => MetadataNumberEncoding.None, + MetadataKind.Boolean => MetadataNumberEncoding.None, + MetadataKind.Int64 => MetadataNumberEncoding.Int64, + MetadataKind.Double => MetadataNumberEncoding.Double, + MetadataKind.String => MetadataNumberEncoding.None, + MetadataKind.Decimal => MetadataNumberEncoding.Decimal, + MetadataKind.UInt64 => MetadataNumberEncoding.None, + MetadataKind.Single => MetadataNumberEncoding.Single, + MetadataKind.Char => MetadataNumberEncoding.None, + MetadataKind.DateTime => MetadataNumberEncoding.None, + MetadataKind.DateTimeOffset => MetadataNumberEncoding.None, + MetadataKind.DateOnly => MetadataNumberEncoding.None, + MetadataKind.TimeOnly => MetadataNumberEncoding.None, + MetadataKind.TimeSpan => MetadataNumberEncoding.None, + MetadataKind.Guid => MetadataNumberEncoding.None, + MetadataKind.Uri => MetadataNumberEncoding.None, + MetadataKind.Array => MetadataNumberEncoding.None, + MetadataKind.Object => MetadataNumberEncoding.None + }; } diff --git a/src/Light.PortableResults/Metadata/MetadataNumberEncoding.cs b/src/Light.PortableResults/Metadata/MetadataNumberEncoding.cs new file mode 100644 index 0000000..876492f --- /dev/null +++ b/src/Light.PortableResults/Metadata/MetadataNumberEncoding.cs @@ -0,0 +1,30 @@ +namespace Light.PortableResults.Metadata; + +/// +/// +/// Describes which .NET numeric type a metadata kind is written from when its JSON shape is +/// . +/// +/// +/// tells a serializer that a value is a JSON number; +/// this tells it which primitive overload to write it with. Serializers for other protocols need the same +/// distinction, which is why it is public rather than an implementation detail of the JSON writer. +/// +/// +public enum MetadataNumberEncoding : byte +{ + /// The kind does not have the shape. + None, + + /// The value is written from a . + Int64, + + /// The value is written from a . + Double, + + /// The value is written from a . + Single, + + /// The value is written from a . + Decimal +} diff --git a/src/Light.PortableResults/SharedJsonSerialization/Writing/MetadataExtensions.cs b/src/Light.PortableResults/SharedJsonSerialization/Writing/MetadataExtensions.cs index b943429..f7ee235 100644 --- a/src/Light.PortableResults/SharedJsonSerialization/Writing/MetadataExtensions.cs +++ b/src/Light.PortableResults/SharedJsonSerialization/Writing/MetadataExtensions.cs @@ -81,20 +81,32 @@ MetadataValueAnnotation requiredAnnotation private static void WriteNumberValue(Utf8JsonWriter writer, MetadataValue value) { - if (value.Kind is not MetadataKind.Int64 and - not MetadataKind.Double and - not MetadataKind.Decimal and - not MetadataKind.Single) + switch (value.Kind.GetNumberEncoding()) { - throw CreateNonNumericKindException(value.Kind); + case MetadataNumberEncoding.Int64: + value.TryGetInt64(out var int64MetadataValue); + writer.WriteNumberValue(int64MetadataValue); + return; + case MetadataNumberEncoding.Decimal: + value.TryGetDecimal(out var decimalMetadataValue); + writer.WriteNumberValue(decimalMetadataValue); + return; + case MetadataNumberEncoding.Double: + case MetadataNumberEncoding.Single: + // These are the only kinds whose JSON text is not what the writer's own number formatting + // produces: a whole-number Double or Single carries a trailing ".0" so that it does not read + // back as an Int64. The canonical text is therefore built and written raw. Validation is + // skipped because the text comes from our own formatter and non-finite values are rejected at + // construction, so it is always a well-formed JSON number. + writer.WriteRawValue(value.ToCanonicalString(), skipInputValidation: true); + return; + case MetadataNumberEncoding.None: + throw new InvalidOperationException( + $"Metadata kind '{value.Kind}' does not have a JSON number shape." + ); } - - writer.WriteRawValue(value.ToCanonicalString()); } - private static InvalidOperationException CreateNonNumericKindException(MetadataKind kind) => - new ($"Metadata kind '{kind}' does not have a JSON number shape."); - /// /// Writes the JSON representation for the specified metadata array. /// diff --git a/tests/Light.PortableResults.Tests/Metadata/MetadataKindTests.cs b/tests/Light.PortableResults.Tests/Metadata/MetadataKindTests.cs index eb4a9b8..263efac 100644 --- a/tests/Light.PortableResults.Tests/Metadata/MetadataKindTests.cs +++ b/tests/Light.PortableResults.Tests/Metadata/MetadataKindTests.cs @@ -99,4 +99,36 @@ MetadataJsonShape expectedShape { kind.GetJsonShape().Should().Be(expectedShape); } + + // Every kind with the Number shape must name the primitive it is written from, and no other kind may. + // A mismatch between the two classifications would reach the JSON writer as an unwritable number. + [Theory] + [InlineData(MetadataKind.Null, MetadataNumberEncoding.None)] + [InlineData(MetadataKind.Boolean, MetadataNumberEncoding.None)] + [InlineData(MetadataKind.Int64, MetadataNumberEncoding.Int64)] + [InlineData(MetadataKind.Double, MetadataNumberEncoding.Double)] + [InlineData(MetadataKind.String, MetadataNumberEncoding.None)] + [InlineData(MetadataKind.Decimal, MetadataNumberEncoding.Decimal)] + [InlineData(MetadataKind.UInt64, MetadataNumberEncoding.None)] + [InlineData(MetadataKind.Single, MetadataNumberEncoding.Single)] + [InlineData(MetadataKind.Char, MetadataNumberEncoding.None)] + [InlineData(MetadataKind.DateTime, MetadataNumberEncoding.None)] + [InlineData(MetadataKind.DateTimeOffset, MetadataNumberEncoding.None)] + [InlineData(MetadataKind.DateOnly, MetadataNumberEncoding.None)] + [InlineData(MetadataKind.TimeOnly, MetadataNumberEncoding.None)] + [InlineData(MetadataKind.TimeSpan, MetadataNumberEncoding.None)] + [InlineData(MetadataKind.Guid, MetadataNumberEncoding.None)] + [InlineData(MetadataKind.Uri, MetadataNumberEncoding.None)] + [InlineData(MetadataKind.Array, MetadataNumberEncoding.None)] + [InlineData(MetadataKind.Object, MetadataNumberEncoding.None)] + public void EveryDeclaredKindShouldHaveTheExpectedNumberEncoding( + MetadataKind kind, + MetadataNumberEncoding expectedEncoding + ) + { + kind.GetNumberEncoding().Should().Be(expectedEncoding); + + var hasNumberShape = kind.GetJsonShape() == MetadataJsonShape.Number; + (expectedEncoding != MetadataNumberEncoding.None).Should().Be(hasNumberShape); + } } diff --git a/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs b/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs index 4b4840b..ac91cd4 100644 --- a/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs +++ b/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs @@ -272,6 +272,37 @@ public void EveryKindShouldSerializeWithItsVocabularyEncoding() } } + // Int64 and Decimal are written through the writer's own number formatting while Double and Single go + // through the canonical text so that whole numbers keep their trailing ".0". These are the values where + // the two paths could diverge - exponent forms, negative zero, and the extremes of each range. + [Fact] + public void NumericKindsShouldSerializeIdenticallyAcrossTheirRange() + { + var values = new (MetadataValue Value, string Json)[] + { + (MetadataValue.FromInt64(0), "0"), + (MetadataValue.FromInt64(long.MinValue), "-9223372036854775808"), + (MetadataValue.FromInt64(long.MaxValue), "9223372036854775807"), + (MetadataValue.FromDecimal(19.50m), "19.50"), + (MetadataValue.FromDecimal(-0.0m), "0.0"), + (MetadataValue.FromDecimal(0.0000000001m), "0.0000000001"), + (MetadataValue.FromDecimal(decimal.MaxValue), "79228162514264337593543950335"), + (MetadataValue.FromDecimal(decimal.MinValue), "-79228162514264337593543950335"), + (MetadataValue.FromDouble(0.1), "0.1"), + (MetadataValue.FromDouble(-0.0), "-0.0"), + (MetadataValue.FromDouble(1e21), "1E+21"), + (MetadataValue.FromDouble(5e-324), "5E-324"), + (MetadataValue.FromDouble(double.MaxValue), "1.7976931348623157E+308"), + (MetadataValue.FromSingle(1e20f), "1E+20"), + (MetadataValue.FromSingle(float.Epsilon), "1E-45") + }; + + foreach (var (value, expectedJson) in values) + { + Serialize(value).Should().Be(expectedJson, "the {0} number encoding is normative", value.Kind); + } + } + [Fact] public void SingleShouldWidenToDoubleWithoutChangingItsOwnAccessor() { From 98972cede785f6b01d568673f21c663c85c391d1 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Jul 2026 19:18:02 +0200 Subject: [PATCH 11/15] fix(metadata): accept Z-suffixed timestamps in TryGetDateTimeOffset The accessor only accepted the +00:00 designator that its own writer produces, so every Z-suffixed RFC 3339 timestamp failed to read back - including the canonical encoding of this library's own DateTime kind, which is the form a wire-degraded value most often arrives in. Both zero-offset designators are now accepted. Text without any offset stays rejected: it is not a point in time, and resolving it against the reading machine's local offset would make the same text mean different instants on different hosts. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RtfndM4yhWgCtShY4trph5 Signed-off-by: Kenny Pflug --- ai-plans/0055-metadata-restructuring.md | 6 +++++ .../Metadata/MetadataValue.cs | 24 ++++++++++++++---- .../Metadata/TypedMetadataValueTests.cs | 25 ++++++++++++++++--- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/ai-plans/0055-metadata-restructuring.md b/ai-plans/0055-metadata-restructuring.md index c04b905..f59b1df 100644 --- a/ai-plans/0055-metadata-restructuring.md +++ b/ai-plans/0055-metadata-restructuring.md @@ -13,6 +13,11 @@ > reversal and the RFC 3339 trade-off it carries are argued in the Vocabulary notes; read those before > re-tightening the factory. > +> **Zero-offset designators.** `TryGetDateTimeOffset` originally accepted only the exact text its own writer +> produces, which rejected every `Z`-suffixed RFC 3339 timestamp - the form a wire-degraded value almost always +> arrives in, and the one this library's own `DateTime` kind emits. The relaxation and the limit placed on it +> are recorded in Accessors and equality. +> > **Number writing.** The `Number` arm originally routed all four numeric kinds through the canonical text and > `WriteRawValue`, costing a string allocation and a JSON validation pass per value on `Int64` and `Decimal`, > where the writer's own formatting is byte-identical and allocation-free. The kind list guarding that arm was @@ -114,6 +119,7 @@ Strictness is part of that contract, because the framework defaults are too perm - `Uri` accepts `UriKind.Absolute` only. `UriKind.RelativeOrAbsolute` succeeds for nearly any text, which would make `TryGetUri` return `true` for `"hello world"` and turn the accessor into a footgun for callers that probe kinds in sequence. - Date and time kinds parse the exact canonical format with the invariant culture and `DateTimeStyles.RoundtripKind` — never `DateTime.Parse`, which accepts `07/26/2026 13:45:30` and would resurrect inside the accessors the ambiguity this plan removes from the writers. - `TryGetDateTime` accepts both `DateTime` encodings (with and without the `Z`) and rejects text carrying a numeric offset. That text is the `DateTimeOffset` encoding, and resolving it into a `DateTime` would make the result depend on the reading machine's time zone. +- `TryGetDateTimeOffset` accepts both zero-offset designators, `+00:00` and `Z`, although the writer only ever produces the former. Strictly matching the writer would defeat the purpose of the lenient path here: `Z` is what RFC 3339 emitters produce almost everywhere, including this library's own `DateTime` kind, so it is the form a degraded value most often arrives in. Text without any offset stays rejected — it is not a point in time, and resolving it against the reader's local offset would make the same text mean different instants on different hosts. Equality stays strict — kind plus payload, `Annotation` excluded. `FromGuid(g)` is not equal to `FromString(g.ToString())`; round-trip fidelity is `0054-1`'s job, not `Equals`'. Boxed kinds unbox and compare by value with the same defensive `is` pattern as the `Decimal` arm. `Uri` is a reference rather than a boxed value and must not use `Uri.Equals`, which ignores the fragment and compares hosts case-insensitively: two URIs that serialize differently would compare equal. It compares and hashes its `OriginalString` ordinally, which is exactly what is written to the wire. diff --git a/src/Light.PortableResults/Metadata/MetadataValue.cs b/src/Light.PortableResults/Metadata/MetadataValue.cs index d493ae8..aa26f0a 100644 --- a/src/Light.PortableResults/Metadata/MetadataValue.cs +++ b/src/Light.PortableResults/Metadata/MetadataValue.cs @@ -523,7 +523,7 @@ public bool TryGetChar(out char value) return true; } - value = default; + value = '\0'; return false; } @@ -556,7 +556,12 @@ out value return false; } - /// Attempts to get a date-time offset or parse its canonical RFC 3339 encoding. + /// + /// Attempts to get a date-time offset or parse its RFC 3339 encoding. Both designators for a zero offset + /// are accepted - the canonical +00:00 that this library writes and the Z that RFC 3339 + /// emitters commonly produce - so a value degraded to a string on the wire still reads back. Text without + /// any offset is rejected, because it is not a point in time. + /// public bool TryGetDateTimeOffset(out DateTimeOffset value) { if (Kind == MetadataKind.DateTimeOffset && _payload.Reference is DateTimeOffset dateTimeOffset) @@ -572,7 +577,7 @@ public bool TryGetDateTimeOffset(out DateTimeOffset value) DateTimeStyles.RoundtripKind, out value ) && - string.Equals(FormatDateTimeOffset(value), text, StringComparison.Ordinal)) + IsCanonicalDateTimeOffsetText(value, text)) { return true; } @@ -581,6 +586,15 @@ out value return false; } + private static bool IsCanonicalDateTimeOffsetText(DateTimeOffset value, string text) => + string.Equals(FormatDateTimeOffset(value), text, StringComparison.Ordinal) || + // A zero offset is also written as "Z" by RFC 3339 emitters everywhere, including this library's own + // DateTime kind, so it is accepted although the DateTimeOffset writer never produces it. Text without + // any designator is still rejected: it parses against the reading machine's local offset and matches + // neither form. + (value.Offset == TimeSpan.Zero && + string.Equals(FormatDateTime(value.UtcDateTime), text, StringComparison.Ordinal)); + #if NET10_0_OR_GREATER /// Attempts to get a date or parse its canonical RFC 3339 full-date encoding. public bool TryGetDateOnly(out DateOnly value) @@ -675,7 +689,7 @@ public bool TryGetTimeSpan(out TimeSpan value) } } - value = default; + value = TimeSpan.Zero; return false; } @@ -695,7 +709,7 @@ public bool TryGetGuid(out Guid value) return true; } - value = default; + value = Guid.Empty; return false; } diff --git a/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs b/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs index ac91cd4..5f62af6 100644 --- a/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs +++ b/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs @@ -176,6 +176,27 @@ public void DateTimeAccessorShouldAcceptBothCanonicalStringEncodings(string text MetadataValue.FromDateTime(value).ToCanonicalString().Should().Be(text); } + [Theory] + [InlineData("2026-07-26T11:45:30+00:00")] + [InlineData("2026-07-26T11:45:30Z")] + public void DateTimeOffsetAccessorShouldAcceptBothZeroOffsetDesignators(string text) + { + // Only "+00:00" is written by this library, but "Z" is what RFC 3339 emitters produce almost + // everywhere, so a value degraded to a string on the wire has to read back either way. + MetadataValue.FromString(text).TryGetDateTimeOffset(out var value).Should().BeTrue(); + + value.Should().Be(OffsetDateTime); + value.Offset.Should().Be(TimeSpan.Zero); + } + + [Fact] + public void DateTimeOffsetAccessorShouldRejectTextWithoutAnOffset() + { + // Without an offset this is not a point in time. Accepting it would resolve against the reading + // machine's local offset, so the same text would mean different instants on different hosts. + MetadataValue.FromString("2026-07-26T13:45:30").TryGetDateTimeOffset(out _).Should().BeFalse(); + } + [Fact] public void DateTimeAccessorShouldRejectTextCarryingANumericOffset() { @@ -214,10 +235,6 @@ public void StringAccessorsShouldAcceptOnlyCanonicalEncodings() .Should() .BeTrue(); dateTimeOffset.Should().Be(OffsetDateTime); - MetadataValue.FromString("2026-07-26T11:45:30Z") - .TryGetDateTimeOffset(out _) - .Should() - .BeFalse(); MetadataValue.FromString("2026-07-26").TryGetDateOnly(out var dateOnly).Should().BeTrue(); dateOnly.Should().Be(new DateOnly(2026, 7, 26)); From 300d169440e6de7df6fb4334cafb041dbca6850e Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Jul 2026 19:25:00 +0200 Subject: [PATCH 12/15] test(openapi): tie the schema mapper to the metadata vocabulary The mapper test only covered the types that have a MetadataKind of their own, leaving int, short, byte, sbyte, uint and ushort unpinned - the side of the ulong split that stayed on the integer schema. It also never exercised Map(), which is the overload generated validator code calls, and which the validation source generator relies on instead of keeping a schema table of its own. That makes this test the only guard on the normative schema column, so the gaps mattered more than their size suggested. The expectations are now keyed on MetadataKind rather than a hand-kept list of types, and a test asserts that every declared kind has either a schema row or a deliberate exclusion. Declaring a kind therefore fails a test that names it, alongside the switch expressions that already fail the Release build. The smaller integer types assert against the Int64 row rather than a literal, so they cannot drift from the kind they widen into. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RtfndM4yhWgCtShY4trph5 Signed-off-by: Kenny Pflug --- .../PortableOpenApiSchemaTypeMapperTests.cs | 135 ++++++++++++++---- 1 file changed, 107 insertions(+), 28 deletions(-) diff --git a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiSchemaTypeMapperTests.cs b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiSchemaTypeMapperTests.cs index 506bdd8..ee28a93 100644 --- a/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiSchemaTypeMapperTests.cs +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiSchemaTypeMapperTests.cs @@ -1,6 +1,9 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Reflection; using FluentAssertions; +using Light.PortableResults.Metadata; using Microsoft.OpenApi; using Xunit; @@ -8,34 +11,84 @@ namespace Light.PortableResults.AspNetCore.OpenApi.Tests; public sealed class PortableOpenApiSchemaTypeMapperTests { - public static TheoryData VocabularyTypes => - new () + // The schema column of the vocabulary table in ai-plans/0055-metadata-restructuring.md is normative, and + // this is its only guard: the validation source generator emits Map() calls rather than keeping a table + // of its own. Keying the expectations on MetadataKind means declaring a kind fails this test until its + // schema has been decided, instead of silently degrading to a schema without a type. + private static readonly + Dictionary VocabularySchemas = + new () + { + [MetadataKind.Boolean] = (typeof(bool), JsonSchemaType.Boolean, null), + [MetadataKind.Int64] = (typeof(long), JsonSchemaType.Integer, null), + [MetadataKind.Double] = (typeof(double), JsonSchemaType.Number, null), + [MetadataKind.String] = (typeof(string), JsonSchemaType.String, null), + [MetadataKind.Decimal] = (typeof(decimal), JsonSchemaType.Number, null), + [MetadataKind.UInt64] = (typeof(ulong), JsonSchemaType.String, "uint64"), + [MetadataKind.Single] = (typeof(float), JsonSchemaType.Number, "float"), + [MetadataKind.Char] = (typeof(char), JsonSchemaType.String, "char"), + [MetadataKind.DateTime] = (typeof(DateTime), JsonSchemaType.String, "date-time"), + [MetadataKind.DateTimeOffset] = (typeof(DateTimeOffset), JsonSchemaType.String, "date-time"), + [MetadataKind.DateOnly] = (typeof(DateOnly), JsonSchemaType.String, "date"), + [MetadataKind.TimeOnly] = (typeof(TimeOnly), JsonSchemaType.String, "time"), + [MetadataKind.TimeSpan] = (typeof(TimeSpan), JsonSchemaType.String, "duration"), + [MetadataKind.Guid] = (typeof(Guid), JsonSchemaType.String, "uuid"), + [MetadataKind.Uri] = (typeof(Uri), JsonSchemaType.String, "uri-reference") + }; + + // Null has no CLR type of its own, and arrays and objects are not scalar schemas. + private static readonly MetadataKind[] KindsWithoutAScalarSchema = + [MetadataKind.Null, MetadataKind.Array, MetadataKind.Object]; + + // The smaller integer types have no kind of their own - MetadataValue widens them to Int64 - so their + // schema has to be the Int64 schema rather than an independently maintained one. ulong is deliberately + // absent: it is the one integral type with a dedicated kind, because it does not fit into Int64. + public static TheoryData IntegerTypesWideningToInt64 => + [typeof(int), typeof(short), typeof(byte), typeof(sbyte), typeof(uint), typeof(ushort)]; + + public static TheoryData VocabularyKinds + { + get + { + var data = new TheoryData(); + foreach (var kind in VocabularySchemas.Keys) + { + data.Add(kind); + } + + return data; + } + } + + [Fact] + public void EveryDeclaredKindShouldHaveASchemaOrAnExplicitExclusion() + { + foreach (var kind in Enum.GetValues()) { - { typeof(bool), JsonSchemaType.Boolean, null }, - { typeof(long), JsonSchemaType.Integer, null }, - { typeof(double), JsonSchemaType.Number, null }, - { typeof(string), JsonSchemaType.String, null }, - { typeof(decimal), JsonSchemaType.Number, null }, - { typeof(ulong), JsonSchemaType.String, "uint64" }, - { typeof(float), JsonSchemaType.Number, "float" }, - { typeof(char), JsonSchemaType.String, "char" }, - { typeof(DateTime), JsonSchemaType.String, "date-time" }, - { typeof(DateTimeOffset), JsonSchemaType.String, "date-time" }, - { typeof(DateOnly), JsonSchemaType.String, "date" }, - { typeof(TimeOnly), JsonSchemaType.String, "time" }, - { typeof(TimeSpan), JsonSchemaType.String, "duration" }, - { typeof(Guid), JsonSchemaType.String, "uuid" }, - { typeof(Uri), JsonSchemaType.String, "uri-reference" } - }; + (VocabularySchemas.ContainsKey(kind) || KindsWithoutAScalarSchema.Contains(kind)) + .Should() + .BeTrue("MetadataKind.{0} needs a row in the vocabulary table or an explicit exclusion", kind); + } + } + + [Theory] + [MemberData(nameof(VocabularyKinds))] + public void MapShouldMatchTheMetadataVocabulary(MetadataKind kind) + { + var (clrType, expectedType, expectedFormat) = VocabularySchemas[kind]; + + var schema = PortableOpenApiSchemaTypeMapper.Map(clrType); + + schema.Type.Should().Be(expectedType); + schema.Format.Should().Be(expectedFormat); + } [Theory] - [MemberData(nameof(VocabularyTypes))] - public void MapShouldMatchTheMetadataVocabulary( - Type type, - JsonSchemaType expectedType, - string? expectedFormat - ) + [MemberData(nameof(IntegerTypesWideningToInt64))] + public void MapShouldGiveTheInt64SchemaToTypesThatWidenIntoIt(Type type) { + var (_, expectedType, expectedFormat) = VocabularySchemas[MetadataKind.Int64]; + var schema = PortableOpenApiSchemaTypeMapper.Map(type); schema.Type.Should().Be(expectedType); @@ -43,12 +96,38 @@ public void MapShouldMatchTheMetadataVocabulary( } [Fact] - public void MapShouldUnwrapNullableVocabularyTypes() + public void GenericMapShouldMatchTheVocabularyToo() + { + // Generated validator code calls Map(), so this is the overload that decides the schemas in a + // published document. It must not drift from the reflection overload. + var genericMap = typeof(PortableOpenApiSchemaTypeMapper) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .Single(method => method.Name == nameof(PortableOpenApiSchemaTypeMapper.Map) && + method.IsGenericMethodDefinition); + + foreach (var (kind, (clrType, expectedType, expectedFormat)) in VocabularySchemas) + { + var schema = (OpenApiSchema) genericMap.MakeGenericMethod(clrType).Invoke(null, parameters: null)!; + + schema.Type.Should().Be(expectedType, "Map<{0}>() maps the {1} kind", clrType.Name, kind); + schema.Format.Should().Be(expectedFormat, "Map<{0}>() maps the {1} kind", clrType.Name, kind); + } + } + + [Theory] + [InlineData(typeof(int?))] + [InlineData(typeof(TimeSpan?))] + [InlineData(typeof(Guid?))] + [InlineData(typeof(DateOnly?))] + public void MapShouldUnwrapNullableVocabularyTypes(Type nullableType) { - var schema = PortableOpenApiSchemaTypeMapper.Map(typeof(TimeSpan?)); + var underlyingType = Nullable.GetUnderlyingType(nullableType)!; + + var schema = PortableOpenApiSchemaTypeMapper.Map(nullableType); + var underlyingSchema = PortableOpenApiSchemaTypeMapper.Map(underlyingType); - schema.Type.Should().Be(JsonSchemaType.String); - schema.Format.Should().Be("duration"); + schema.Type.Should().Be(underlyingSchema.Type); + schema.Format.Should().Be(underlyingSchema.Format); } [Fact] From 81023e937c84a194acd44b8e405cc512f95ed051 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Jul 2026 19:46:19 +0200 Subject: [PATCH 13/15] docs: record 0055 plan deviations in a dedicated file Four decisions in plan 0055 were revised during the post-implementation review: DateTime accepting DateTimeKind.Unspecified via ToBinary storage, CloudEvents core string attributes treating Null as absent, the relaxation of TryGetDateTimeOffset to both zero-offset designators, and writing Int64 and Decimal through the JSON writer's native number overloads. These were previously carried as an amendment note and inline edits in the plan itself, which left it unclear what had originally been specified. Move them into 0055-plan-deviations.md in the format ai-plans/AGENTS.md prescribes, and restore the plan to its original text so it stays the record of what was planned rather than a mix of both. The restored plan therefore contains the acceptance criterion requiring a value of any primitive kind to resolve as a CloudEvents core string attribute, which the code deliberately violates for Null; deviation 2 records that. Also note the two known gaps: TryFormatCanonical still allocates, and temporal validation boundaries produce degraded OpenAPI examples (#57). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RtfndM4yhWgCtShY4trph5 Signed-off-by: Kenny Pflug --- ai-plans/0055-metadata-restructuring.md | 45 ++----------- ai-plans/0055-plan-deviations.md | 86 +++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 41 deletions(-) create mode 100644 ai-plans/0055-plan-deviations.md diff --git a/ai-plans/0055-metadata-restructuring.md b/ai-plans/0055-metadata-restructuring.md index f59b1df..98ef42b 100644 --- a/ai-plans/0055-metadata-restructuring.md +++ b/ai-plans/0055-metadata-restructuring.md @@ -1,35 +1,5 @@ # Typed Metadata Kinds -> **AMENDED** after implementation, during the review of branch `54-metadatakind-restructuring`. The decisions -> recorded below were revised because they specified behavior that turned out to be wrong. Remaining review -> findings are not yet reflected here. -> -> **`DateTime` and `DateTimeKind.Unspecified`.** `FromDateTime` originally threw for `Unspecified`. Combined -> with the criterion routing the ten BCL types to the typed factories, that turned the kind of every -> `new DateTime(...)` literal into an exception in `CreateMetadataValue`, `ValidationErrorMessageFormatting`, -> and OpenAPI example generation - a validation comparison against an `Unspecified` boundary threw instead of -> producing a validation error. Changed: the `DateTime` row of the vocabulary table, its notes in Vocabulary, -> the `TryGetDateTime` strictness rule in Accessors and equality, and one added acceptance criterion. The -> reversal and the RFC 3339 trade-off it carries are argued in the Vocabulary notes; read those before -> re-tightening the factory. -> -> **Zero-offset designators.** `TryGetDateTimeOffset` originally accepted only the exact text its own writer -> produces, which rejected every `Z`-suffixed RFC 3339 timestamp - the form a wire-degraded value almost always -> arrives in, and the one this library's own `DateTime` kind emits. The relaxation and the limit placed on it -> are recorded in Accessors and equality. -> -> **Number writing.** The `Number` arm originally routed all four numeric kinds through the canonical text and -> `WriteRawValue`, costing a string allocation and a JSON validation pass per value on `Int64` and `Decimal`, -> where the writer's own formatting is byte-identical and allocation-free. The kind list guarding that arm was -> also the last dispatch in the chain that was not compiler-checked. Both are addressed by the two new bullets -> in Dispatch safety and the `MetadataNumberEncoding` classification they introduce. -> -> **CloudEvents core string attributes and `Null`.** The criterion below originally required a value of *any* -> primitive kind to resolve as a core string attribute. `Null` is a primitive kind whose canonical text is -> `"null"`, so the letter of that criterion made an explicitly null attribute resolve to the four-character -> string - a `source` of `"null"` passed the required-attribute check and shipped an invalid event. The -> criterion now excludes `Null`, restoring the pre-plan behavior of treating it as absent. - ## Rationale `MetadataKind` covers only the JSON-shaped types. Every other .NET value reaches metadata through the `IFormattable` fallback in `BuiltInValidationErrorDefinitions.CreateMetadataValue`, producing non-interoperable text: a `DateTime` boundary emits `07/26/2026 13:45:30` instead of RFC 3339, a `TimeSpan` emits `00:00:05` instead of an ISO 8601 duration, and a `TimeOnly` silently drops seconds. These values appear in `problem+json` bodies, so the published OpenAPI contract disagrees with the runtime payload. In-process consumers are equally underserved: a `Guid` or `DateTimeOffset` stored as text costs an allocation on write and a parse on every read, in a library whose primary claim is low allocation. @@ -44,14 +14,13 @@ This plan extends the primitive range that `0052` reserved with kinds for the co - [x] Each `TryGet*` additionally converts from `MetadataKind.String` when the string holds the kind's canonical encoding and rejects any other text, so consumers work identically on in-process and wire-degraded values without accepting input the writers never produce. - [x] Every kind serializes into JSON bodies using the encoding in the vocabulary table; in particular `TimeOnly` preserves seconds, `TimeSpan` emits an ISO 8601 duration, `0.1f` emits `0.1`, and a whole-number `Double` or `Single` emits a trailing `.0` (`5.0`, not `5`). - [x] Generated OpenAPI schemas and examples match the JSON encoding of every kind, asserted against the vocabulary table: `ulong` is a string, `TimeSpan` is `format: duration` rather than `time`, and `char` and `Uri` no longer degrade to a schema without a type. -- [x] HTTP header values, CloudEvents core string attributes, and validation error message text use the same canonical encodings: header output is unquoted for every primitive kind including plain strings, and a value of any primitive kind except `Null` resolves as a core string attribute. `Null` resolves to no attribute at all, as it did before this plan. +- [x] HTTP header values, CloudEvents core string attributes, and validation error message text use the same canonical encodings: header output is unquoted for every primitive kind including plain strings, and a value of any primitive kind resolves as a core string attribute. - [x] The JSON shape of a kind (`Null`, `Boolean`, `Number`, `String`, `Array`, `Object`) is publicly derivable from `MetadataKind` alone. - [x] Every `switch` over `MetadataKind` inside `MetadataValue` lists all members without a discard arm, so declaring a new member fails the Release build (CS8509 with `TreatWarningsAsErrors`) until every switch is updated. - [x] `MetadataValueAnnotationHelper.WithAnnotation` preserves the value of every kind; annotation constraints for arrays and objects are still enforced. - [x] Equality and hashing cover all kinds: values of different kinds are never equal, boxed kinds compare by value, `Uri` values compare by ordinal `OriginalString`, `Annotation` stays excluded, and a test stores every kind in a `MetadataObject` and reads it back by key. - [x] Serialized output for all previously existing kinds is byte-identical to today, apart from the trailing `.0` for whole-number doubles. - [x] `CreateMetadataValue` routes the ten BCL types of the vocabulary table to the typed factories; the `problem+json` OpenAPI conformance test from `0052` passes unchanged, and an equivalent test covers a `DateTime` or `TimeSpan` boundary. -- [x] A `DateTime` of any `DateTimeKind` reaches metadata without throwing, keeps its kind through storage and reading, and renders through one encoding at every site. A validation comparison against a `DateTimeKind.Unspecified` boundary produces a validation error, never an exception. - [x] The core project builds for `netstandard2.0` and `net10.0` in Release with warnings as errors, and the only public API difference between the targets is the `DateOnly`/`TimeOnly` factories and accessors. - [x] Test code coverage stays above 95%. @@ -70,7 +39,7 @@ Members are named after their `System.*` type, as the existing ones are (`Int64` | `UInt64` = 6 | `ulong` | inline | decimal digits as JSON **string** | `string`, `uint64` | | `Single` = 7 | `float` | inline, widened `double` | shortest round-trippable number | `number`, `float` | | `Char` = 8 | `char` | inline | single-character string | `string`, `char` | -| `DateTime` = 9 | `DateTime` | inline `ToBinary()` | RFC 3339 date-time with `Z`, or ISO 8601 local time without a designator when the kind is `Unspecified` | `string`, `date-time` | +| `DateTime` = 9 | `DateTime` | inline UTC ticks | RFC 3339 date-time, `Z` suffix | `string`, `date-time` | | `DateTimeOffset` = 10 | `DateTimeOffset` | boxed | RFC 3339 date-time with offset | `string`, `date-time` | | `DateOnly` = 11 | `DateOnly` | inline day number | RFC 3339 full-date | `string`, `date` | | `TimeOnly` = 12 | `TimeOnly` | inline ticks | RFC 3339 full-time | `string`, `time` | @@ -84,9 +53,7 @@ The schema column is normative: `PortableOpenApiSchemaTypeMapper` and the source - `Uri` maps to `uri-reference` rather than `uri` because the schema is per CLR type while absoluteness is a per-value property. - `Single` is widened with a plain `(double)` cast at construction and narrowed back with `(float)` before formatting; the float → double → float round trip is lossless, so `0.1f` still emits `0.1` at no construction cost. The consequence is that `TryGetDouble` on a `Single` yields the widened value (`0.10000000149011612` for `0.1f`), not the double nearest `0.1`. - The existing `Double` kind gains a canonical rule shared with `Single`: when the shortest round-trippable text contains neither a fraction nor an exponent, `.0` is appended (via an integral check and `WriteRawValue`), so a bare `Double` token never reads back as `Int64`. `Decimal` is deliberately excluded from this rule: `5m` has scale 0 and must keep emitting `5`. -- `DateTime` and `DateTimeOffset` are separate kinds because the common UTC case then stores inline and avoids the boxing an offset requires. `FromDateTime` converts `Local` to UTC — a local wall clock is meaningless once it leaves the process — and accepts `DateTimeKind.Unspecified` as it is. Rejecting `Unspecified` is not an option: it is what every `new DateTime(...)` literal and every zone-less `DateTime.Parse` produces, so it is the common case for a validation boundary, and throwing turns a validation failure into an exception in `CreateMetadataValue`, `ValidationErrorMessageFormatting`, and OpenAPI example generation alike. -- Because the kind must survive, the payload stores `DateTime.ToBinary()` rather than raw ticks: it packs the kind into the two spare high bits of the tick count, in the same `Int64` slot and with no loss (for `Utc` and `Unspecified` it is a bit-exact copy of the internal state). Only `Utc` and `Unspecified` are ever stored, so decoding is deterministic; `FromBinary` resolves a `Local` payload against the reading machine's time zone, so a `Local` result is treated as a corrupt payload alongside the out-of-range case. -- An `Unspecified` value renders without a designator (`2026-07-26T13:45:30`). That is valid ISO 8601 local time but **not** RFC 3339, which makes the offset mandatory, so it does not satisfy the `format: date-time` that the schema mapper emits for `System.DateTime`. This is accepted deliberately: the alternative is inventing a `Z` the caller never asserted, which is exactly the silent-wrong-data failure mode this plan exists to remove. The one encoding is used everywhere — JSON bodies, headers, CloudEvents attributes, message text, OpenAPI examples — so the document never disagrees with the payload. Steering callers toward `Utc` at API boundaries belongs in an analyzer diagnostic, not a runtime throw. +- `DateTime` and `DateTimeOffset` are separate kinds because the common UTC case then stores inline ticks and avoids the boxing an offset requires. `FromDateTime` converts `Local` to UTC and throws for `DateTimeKind.Unspecified`. - `DateTimeOffset` boxes like `Decimal` — see `MetadataValue.FromDecimal` for the rationale. `FromUri(null)` returns `Null`, mirroring `FromString`. ### Payload storage @@ -101,9 +68,7 @@ While editing the struct, replace `record struct` with a plain `readonly struct` `0052` documented that adding a kind breaks nothing at compile time and corrupts silently. This plan removes that hazard structurally: -- **Derived shape:** `GetJsonShape()` is an extension method on `MetadataKind` next to `IsPrimitive`, implemented as an exhaustive switch expression. The shape is a function of the kind alone, and the callers that need it most — the schema mapper, the header conversion service — hold a kind rather than a value; `MetadataValue` exposes a forwarding property. A lookup table is deliberately not used: it would map an unlisted kind to shape `0` silently, which is the failure mode this section exists to remove, and the switch is what the JIT turns into a jump table anyway. The JSON writers switch on the six shapes. -- **Derived number encoding:** the `Number` arm needs to know which primitive overload to write a value with, which the shape alone does not say. `GetNumberEncoding()` sits next to `GetJsonShape()` as a second exhaustive switch expression over `MetadataKind`, returning `Int64`/`Double`/`Single`/`Decimal` or `None`. Re-deriving the shape inside the arm would be circular, and a `switch` *statement* over the kind in the writer would compile silently, so the classification has to be a value-returning switch to be checked at all — the enum is what buys the diagnostic. Both classifications then fail the Release build together when a kind is added, and a test pins them against each other so a `Number` shape without an encoding cannot ship. -- **Native number writing:** only `Double` and `Single` take the canonical-text `WriteRawValue` path, because only they carry the trailing `.0`. `Int64` and `Decimal` use `Utf8JsonWriter`'s own number formatting, which is byte-identical and allocation-free; routing them through the formatter costs a string allocation and a validation pass per value on the most common metadata kind in the library. The raw path skips input validation: the text comes from our own formatter and non-finite values are rejected at construction. +- **Derived shape:** `GetJsonShape()` is an extension method on `MetadataKind` next to `IsPrimitive`, implemented as an exhaustive switch expression. The shape is a function of the kind alone, and the callers that need it most — the schema mapper, the header conversion service — hold a kind rather than a value; `MetadataValue` exposes a forwarding property. A lookup table is deliberately not used: it would map an unlisted kind to shape `0` silently, which is the failure mode this section exists to remove, and the switch is what the JIT turns into a jump table anyway. The JSON writers switch on the six shapes; the `Number` arm dispatches over `Int64`/`Double`/`Single`/`Decimal` only. - **One formatter:** a single canonical-text method (with a `TryFormat`-style span overload) is the only place that knows how string-shaped kinds render. JSON string values, `DefaultHttpHeaderConversionService`'s fallback (currently `ToString()`, which wrongly quotes strings), and `GetStringAttribute` all call it, replacing the latter's special-cased decimal branch. Adding a kind touches this one site instead of eight. `ToString()` becomes debug-only output. - **Payload-preserving constructor:** `WithAnnotation`'s per-kind switch exists only because no constructor copies an existing payload. An internal `MetadataValue(Kind, payload, annotation)` path reduces the primitive case to one line; `Array`/`Object` still recurse to rewrite children and revalidate annotation constraints. - **Exhaustive switches:** the remaining kind switches (`Equals`, `GetHashCode`, `ToString`, the formatter) become switch expressions listing every member with no discard arm. @@ -118,8 +83,6 @@ Strictness is part of that contract, because the framework defaults are too perm - `Uri` accepts `UriKind.Absolute` only. `UriKind.RelativeOrAbsolute` succeeds for nearly any text, which would make `TryGetUri` return `true` for `"hello world"` and turn the accessor into a footgun for callers that probe kinds in sequence. - Date and time kinds parse the exact canonical format with the invariant culture and `DateTimeStyles.RoundtripKind` — never `DateTime.Parse`, which accepts `07/26/2026 13:45:30` and would resurrect inside the accessors the ambiguity this plan removes from the writers. -- `TryGetDateTime` accepts both `DateTime` encodings (with and without the `Z`) and rejects text carrying a numeric offset. That text is the `DateTimeOffset` encoding, and resolving it into a `DateTime` would make the result depend on the reading machine's time zone. -- `TryGetDateTimeOffset` accepts both zero-offset designators, `+00:00` and `Z`, although the writer only ever produces the former. Strictly matching the writer would defeat the purpose of the lenient path here: `Z` is what RFC 3339 emitters produce almost everywhere, including this library's own `DateTime` kind, so it is the form a degraded value most often arrives in. Text without any offset stays rejected — it is not a point in time, and resolving it against the reader's local offset would make the same text mean different instants on different hosts. Equality stays strict — kind plus payload, `Annotation` excluded. `FromGuid(g)` is not equal to `FromString(g.ToString())`; round-trip fidelity is `0054-1`'s job, not `Equals`'. Boxed kinds unbox and compare by value with the same defensive `is` pattern as the `Decimal` arm. `Uri` is a reference rather than a boxed value and must not use `Uri.Equals`, which ignores the fragment and compares hosts case-insensitively: two URIs that serialize differently would compare equal. It compares and hashes its `OriginalString` ordinally, which is exactly what is written to the wire. diff --git a/ai-plans/0055-plan-deviations.md b/ai-plans/0055-plan-deviations.md new file mode 100644 index 0000000..a341ee5 --- /dev/null +++ b/ai-plans/0055-plan-deviations.md @@ -0,0 +1,86 @@ +# 0055 Plan Deviations + +This document compares `ai-plans/0055-metadata-restructuring.md` with the implementation of the typed metadata vocabulary on branch `54-metadatakind-restructuring`. The plan text is left as it was written, so it stays the record of what was specified; the differences are recorded here. + +`ai-plans/0054-1-metadata-round-trip-envelope.md` is not part of this comparison. It is a follow-up plan that has not been implemented yet. + +## Summary + +The plan was implemented as specified. Every kind in the vocabulary table exists, the shape and canonical-text derivations are in place, the exhaustive switches fail the Release build when a kind is added, and the public API differs between `netstandard2.0` and `net10.0` only in the `DateOnly`/`TimeOnly` members. + +Four decisions in the plan were revised during the review that followed implementation, because they specified behavior that turned out to be wrong rather than merely inconvenient. Three of them (1, 3, 4) were reached through the plan's own reasoning and are corrections to it; the fourth was a pre-existing defect the plan would have frozen into a criterion. + +## Deviations From The Original Plan + +### 1. `DateTime` accepts `DateTimeKind.Unspecified` and stores `ToBinary()` instead of UTC ticks + +**Original plan:** +The vocabulary table specified the `DateTime` payload as `inline UTC ticks` and its encoding as "RFC 3339 date-time, `Z` suffix". The Vocabulary notes stated: "`FromDateTime` converts `Local` to UTC and throws for `DateTimeKind.Unspecified`." + +**Implemented:** +`FromDateTime` converts `Local` to UTC and accepts `Unspecified` as it is. The payload stores `DateTime.ToBinary()`, which packs the kind into the two spare high bits of the tick count — the same 8-byte `Int64` slot raw ticks occupied, with no loss of precision and no growth of the struct. Only `Utc` and `Unspecified` are ever stored, so decoding is deterministic; a `Local` result from `FromBinary` is treated as a corrupt payload, alongside the out-of-range case, because `FromBinary` would otherwise resolve it against the reading machine's time zone. + +An `Unspecified` value renders without a designator (`2026-07-26T13:45:30`). + +**Why:** +`Unspecified` is what every `new DateTime(...)` literal and every zone-less `DateTime.Parse` produces, so it is the common case for a validation boundary rather than an edge case. Combined with the criterion routing the ten BCL types to the typed factories, throwing turned the kind of an ordinary literal into an exception in `CreateMetadataValue`, `ValidationErrorMessageFormatting`, and OpenAPI example generation alike: a validation comparison against an `Unspecified` boundary threw instead of producing a validation error, and the failure surfaced as a 500 on the documentation endpoint with no compile-time signal to the author. + +**Impact:** +The suffix-less rendering is valid ISO 8601 local time but **not** RFC 3339, which makes the offset mandatory, so an `Unspecified` value does not satisfy the `format: date-time` the schema mapper emits for `System.DateTime`. This is accepted deliberately: the alternative is inventing a `Z` the caller never asserted, which is the silent-wrong-data failure mode the plan exists to remove. The one encoding is used at every site — JSON bodies, headers, CloudEvents attributes, message text, OpenAPI examples — so the document never disagrees with the payload. + +Two consequences worth knowing: an `Unspecified` and a `Utc` value with the same wall clock are not equal, because the kind is part of the payload; and steering callers toward `Utc` at API boundaries is left to a future analyzer diagnostic rather than a runtime throw. + +### 2. CloudEvents core string attributes treat `Null` as absent + +**Original plan:** +An acceptance criterion required that "a value of any primitive kind resolves as a core string attribute." + +**Implemented:** +`GetStringAttribute` resolves a value of any primitive kind **except** `Null`, which resolves to no attribute at all. + +**Why:** +`Null` is a primitive kind whose canonical text is `"null"`, so the letter of the criterion made an explicitly null attribute resolve to the four-character string. A `source` of `"null"` passed the required-attribute check and shipped an invalid event; a `time` of `"null"` failed RFC 3339 parsing instead of falling back to the current timestamp. + +**Impact:** +This is a defect fix, not a design change. The behavior before this plan was already to treat `Null` as absent — the pre-plan implementation walked `TryGetString → TryGetBoolean → TryGetInt64 → TryGetDouble → Kind == Decimal`, all of which miss `Null` — and the criterion would have frozen the regression that the unified canonical-text path introduced. A test asserting `(FromNull, "null")` was removed with it. + +Note that `DefaultHttpHeaderConversionService` still emits `"null"` for `MetadataValue.Null`. That is pre-existing behavior tracked separately in #51 and was deliberately left alone here. + +### 3. `TryGetDateTimeOffset` accepts both zero-offset designators + +**Original plan:** +The Accessors section specified that each `TryGet*` "converts from `MetadataKind.String` when the string holds the kind's canonical encoding and rejects any other text", where the canonical encoding for `DateTimeOffset` is the offset form its own writer produces (`+00:00` for a zero offset). + +**Implemented:** +`TryGetDateTimeOffset` additionally accepts the `Z` designator for a zero offset, although the writer never produces it. Text carrying no designator at all stays rejected. + +**Why:** +Strictly matching the writer defeats the purpose of the lenient string path. `Z` is what RFC 3339 emitters produce almost everywhere, including this library's own `DateTime` kind, so it is the form a wire-degraded value most often arrives in. Rejecting it meant the accessor failed on the majority of real-world timestamps. + +**Impact:** +The relaxation is bounded to the zero-offset case and does not admit ambiguity: the two accepted forms denote the same instant. Text without any designator remains rejected, because it is not a point in time — resolving it against the reader's local offset would make the same text mean different instants on different hosts. The symmetric rule on the other side is that `TryGetDateTime` rejects text carrying a numeric offset, for the same reason. + +### 4. `Int64` and `Decimal` are written with the JSON writer's own number formatting + +**Original plan:** +The Dispatch safety section specified: "The JSON writers switch on the six shapes; the `Number` arm dispatches over `Int64`/`Double`/`Single`/`Decimal` only." + +**Implemented:** +Only `Double` and `Single` take the canonical-text `WriteRawValue` path. `Int64` and `Decimal` use `Utf8JsonWriter`'s native `WriteNumberValue` overloads. The dispatch is driven by a new public `MetadataNumberEncoding` enum (`None`, `Int64`, `Double`, `Single`, `Decimal`) and a `GetNumberEncoding()` extension method sitting next to `GetJsonShape()`. + +**Why:** +Two reasons, one performance and one structural. + +Routing all four kinds through the canonical text cost a string allocation and a JSON validation pass per value on `Int64` and `Decimal`, where the writer's own formatting is byte-identical anyway. Only `Double` and `Single` need the raw path, because only they carry the trailing `.0` that keeps a whole-number token from reading back as an `Int64`. Measured: 48 B → 0 B per value for `Int64` and `Decimal`; byte-identity against the native overloads was verified across 19 edge cases. + +Structurally, the kind list guarding that arm was the last dispatch in the chain that was not compiler-checked, which contradicts the section it lives in. Re-deriving the shape inside the arm would be circular — the check is already inside the `Number` arm and can never fail — and a `switch` *statement* over the kind gets no exhaustiveness diagnostic, so the classification has to be a value-returning switch to be checked at all. That is what earns the enum rather than a private helper: both classifications now fail the Release build together when a kind is added, and a test pins them against each other so a `Number` shape without an encoding cannot ship. + +**Impact:** +`MetadataNumberEncoding` is public API that the plan did not anticipate. It is documented as the companion to `MetadataJsonShape` for serializers of protocols other than JSON, which need the same "which primitive is this written from" answer. + +## Known Gaps + +These were identified in the same review and deliberately not addressed on this branch: + +- **`TryFormatCanonical` still allocates.** It calls `ToCanonicalString()` and copies into the destination, so the span overload the plan describes does not yet deliver the allocation-free path its shape implies. An honest fix needs per-kind span formatting, a hand-rolled ISO 8601 duration formatter (`XmlConvert.ToString(TimeSpan)` has no span overload), and a `netstandard2.0` fork (`double.TryFormat` does not exist there). +- **Temporal validation boundaries produce degraded OpenAPI examples.** A `DateTime` boundary cannot be folded by `SemanticModel.GetConstantValue`, so the generated error example carries neither a message nor a `comparativeValue`. This is pre-existing and is tracked in #57. The `DateTime`/`DateTimeOffset`/`TimeSpan`/`Guid`/`Uri` arms added to `ValidatorOpenApiEmitter.ToLiteral` are unreachable through the analyzer until that issue is fixed, and are kept deliberately as its landing site. From 48cba056b445b10d9723c5fcbc6150c83497c6b7 Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Jul 2026 20:20:19 +0200 Subject: [PATCH 14/15] fix(metadata): return false from TryGetTimeSpan for out-of-range durations XmlConvert.ToTimeSpan throws OverflowException rather than FormatException for a well-formed XSD duration that exceeds the TimeSpan range, such as "P10675200D", so the exception escaped the accessor on wire-degraded string input. The catch now handles both exception types. Also corrects the exception contract of MetadataValueAnnotationHelper: the ArgumentOutOfRangeException arm was dead code because JsonShape already throws SwitchExpressionException for undeclared kinds, and the XML docs still promised the unreachable exception. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RtfndM4yhWgCtShY4trph5 --- .../MetadataValueAnnotationHelper.cs | 26 +++++++++++-------- .../Metadata/MetadataValue.cs | 4 ++- .../Metadata/TypedMetadataValueTests.cs | 3 +++ 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/Light.PortableResults/CloudEvents/MetadataValueAnnotationHelper.cs b/src/Light.PortableResults/CloudEvents/MetadataValueAnnotationHelper.cs index 1071828..f594c51 100644 --- a/src/Light.PortableResults/CloudEvents/MetadataValueAnnotationHelper.cs +++ b/src/Light.PortableResults/CloudEvents/MetadataValueAnnotationHelper.cs @@ -1,4 +1,3 @@ -using System; using Light.PortableResults.Metadata; namespace Light.PortableResults.CloudEvents; @@ -18,11 +17,17 @@ public static class MetadataValueAnnotationHelper /// A new containing the same payload as /// with applied. /// - /// - /// Thrown when has an unsupported . + /// + /// Thrown when cannot be applied to an array or object value. + /// + /// + /// Thrown as a SwitchExpressionException when has an undeclared + /// . /// public static MetadataValue WithAnnotation(MetadataValue value, MetadataValueAnnotation annotation) { + // JsonShape throws SwitchExpressionException for an undeclared kind, so the default arm only ever + // sees the four primitive shapes. switch (value.JsonShape) { case MetadataJsonShape.Array: @@ -31,14 +36,9 @@ public static MetadataValue WithAnnotation(MetadataValue value, MetadataValueAnn case MetadataJsonShape.Object: value.TryGetObject(out var objectValue); return MetadataValue.FromObject(WithAnnotation(objectValue, annotation), annotation); - case MetadataJsonShape.Null: - case MetadataJsonShape.Boolean: - case MetadataJsonShape.Number: - case MetadataJsonShape.String: + default: return value.WithAnnotation(annotation); } - - throw new ArgumentOutOfRangeException(nameof(value), value.Kind, "Unsupported metadata kind."); } /// @@ -51,8 +51,12 @@ public static MetadataValue WithAnnotation(MetadataValue value, MetadataValueAnn /// A rewritten with all contained values using /// . /// - /// - /// Thrown when one of the contained values has an unsupported . + /// + /// Thrown when cannot be applied to a contained array or object value. + /// + /// + /// Thrown as a SwitchExpressionException when one of the contained values has an undeclared + /// . /// public static MetadataObject WithAnnotation(MetadataObject metadataObject, MetadataValueAnnotation annotation) { diff --git a/src/Light.PortableResults/Metadata/MetadataValue.cs b/src/Light.PortableResults/Metadata/MetadataValue.cs index aa26f0a..35cad1f 100644 --- a/src/Light.PortableResults/Metadata/MetadataValue.cs +++ b/src/Light.PortableResults/Metadata/MetadataValue.cs @@ -683,7 +683,9 @@ public bool TryGetTimeSpan(out TimeSpan value) return true; } } - catch (FormatException) + // ToTimeSpan throws FormatException for malformed text, but a well-formed duration that exceeds + // the TimeSpan range (such as "P10675200D") surfaces as OverflowException instead. + catch (Exception exception) when (exception is FormatException or OverflowException) { // The common false path returns below. } diff --git a/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs b/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs index 5f62af6..1f9eec1 100644 --- a/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs +++ b/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs @@ -248,6 +248,9 @@ public void StringAccessorsShouldAcceptOnlyCanonicalEncodings() timeSpan.Should().Be(TimeSpan.FromSeconds(5)); MetadataValue.FromString("00:00:05").TryGetTimeSpan(out _).Should().BeFalse(); MetadataValue.FromString("not a duration").TryGetTimeSpan(out _).Should().BeFalse(); + // A well-formed duration exceeding the TimeSpan range makes XmlConvert.ToTimeSpan throw + // OverflowException rather than FormatException - it must not escape the accessor. + MetadataValue.FromString("P10675200D").TryGetTimeSpan(out _).Should().BeFalse(); MetadataValue.FromString(GuidValue.ToString("D")).TryGetGuid(out var guid).Should().BeTrue(); guid.Should().Be(GuidValue); From 8b3ec131076cab4ca10cd8fc157a407aa1798aff Mon Sep 17 00:00:00 2001 From: Kenny Pflug Date: Wed, 29 Jul 2026 20:44:25 +0200 Subject: [PATCH 15/15] fix!: make numeric conversions consistent and reject zone-less CloudEvents timestamps Applies the remaining findings of the 0055 review. CloudEvents 'time' attributes without a UTC designator are now rejected with an ArgumentException that points towards DateTimeKind.Utc or DateTimeOffset. Such text was previously parsed leniently and resolved against the local time zone of the serializing machine, so identical metadata produced different instants on different hosts. TryGetDecimal now converts from Single, narrowing back to float first so that 0.1f reads as 0.1m rather than the widened 0.10000000149011612m. Before, an in-process Single failed the accessor while the same value degraded to the string "0.1" on the wire succeeded. Validation messages render finite Double parameters with the canonical encoding, so a Double boundary no longer renders "0,5" next to a Single rendered "0.1" under a non-invariant culture. Non-finite values keep the culture path, which also removes a latent throw: the Single arm called FromSingle unconditionally, and that factory rejects NaN. FormatGuid drops a redundant ToLowerInvariant, since the "D" format is specified to produce lowercase digits. BREAKING CHANGE: A CloudEvents 'time' attribute whose text carries no UTC offset now throws instead of being resolved against the local time zone. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HeWvWAjKvEfavo9govBjNe --- AGENTS.md | 3 +- ai-plans/0055-plan-deviations.md | 41 ++++++++++++++++- .../ValidationErrorMessageFormatting.cs | 7 ++- .../Writing/CloudEventsResultExtensions.cs | 20 +++++++++ .../Metadata/MetadataValue.cs | 14 ++++-- .../CloudEventsResultExtensionsTests.cs | 27 ++++++++++++ .../Metadata/TypedMetadataValueTests.cs | 13 ++++++ .../TypedMetadataRoutingTests.cs | 44 +++++++++++++++++++ 8 files changed, 161 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3ff486b..48d1265 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,8 +11,7 @@ Plans typically have acceptance criteria with check boxes. Check each box when y In our Directory.Build.props files in this solution, the following rules are defined: - Implicit usings or global usings are not allowed - use explicit using statements for clarity. -- The Light.PortableResults project is built with .NET Standard 2.0, but you can use C# 14 features. -- All other projects use .NET 10, including the test projects. +- Use C# 14 across all projects. - The library is not published in a stable version yet, you can make breaking changes. - `` is enabled in Release builds, so your code changes must not generate warnings. - When a type or method is properly encapsulated, make it public. We don't know how callers would like to use this library. When some types are internal, this might make it hard for callers to access these in tests or when making configuration changes. Prefer public APIs over internal ones. diff --git a/ai-plans/0055-plan-deviations.md b/ai-plans/0055-plan-deviations.md index a341ee5..1961905 100644 --- a/ai-plans/0055-plan-deviations.md +++ b/ai-plans/0055-plan-deviations.md @@ -8,7 +8,7 @@ This document compares `ai-plans/0055-metadata-restructuring.md` with the implem The plan was implemented as specified. Every kind in the vocabulary table exists, the shape and canonical-text derivations are in place, the exhaustive switches fail the Release build when a kind is added, and the public API differs between `netstandard2.0` and `net10.0` only in the `DateOnly`/`TimeOnly` members. -Four decisions in the plan were revised during the review that followed implementation, because they specified behavior that turned out to be wrong rather than merely inconvenient. Three of them (1, 3, 4) were reached through the plan's own reasoning and are corrections to it; the fourth was a pre-existing defect the plan would have frozen into a criterion. +Four decisions in the plan were revised during the review that followed implementation, because they specified behavior that turned out to be wrong rather than merely inconvenient. Three of them (1, 3, 4) were reached through the plan's own reasoning and are corrections to it; the fourth was a pre-existing defect the plan would have frozen into a criterion. A second review added deviations 5 and 6: one records a build target the plan did not anticipate, the other extends the plan's own consistency rules to the pre-existing numeric kinds. ## Deviations From The Original Plan @@ -78,9 +78,48 @@ Structurally, the kind list guarding that arm was the last dispatch in the chain **Impact:** `MetadataNumberEncoding` is public API that the plan did not anticipate. It is documented as the companion to `MetadataJsonShape` for serializers of protocols other than JSON, which need the same "which primitive is this written from" answer. +### 5. The Validation project multi-targets alongside the core + +**Original plan:** +The Targets section stated: "The core project multi-targets `netstandard2.0;net10.0`." No other project was slated for a second target. + +**Implemented:** +`Light.PortableResults.Validation` also multi-targets `netstandard2.0;net10.0`. + +**Why:** +`CreateMetadataValue` and `ValidationErrorMessageFormatting` route `DateOnly`/`TimeOnly` validation boundaries to the typed factories, which exist only on the `net10.0` asset of the core package. Without a `net10.0` asset of its own, the Validation package would always bind to the `netstandard2.0` core surface and silently flatten those boundaries back to strings on modern runtimes — undoing the plan's central goal for two of the ten types. + +**Impact:** +The public API of the Validation package is identical across both targets; only the private routing inside `#if NET10_0_OR_GREATER` differs. The root `AGENTS.md` rule stating that only `Light.PortableResults` is built for .NET Standard 2.0 and "all other projects use .NET 10" is now outdated on both counts. + +### 6. Lenient numeric handling treats `Single` and `Double` canonically everywhere + +**Original plan:** +The Accessors section specified only "`TryGetDouble` converts from `Single`" as a cross-kind numeric conversion, and the canonical-message criterion was implemented for the ten new BCL types only, leaving `Double` message parameters on the culture-specific `IFormattable` path. + +**Implemented (post-review):** +`TryGetDecimal` also converts from `Single`, narrowing back to `float` before the decimal conversion so that `0.1f` reads as `0.1m` rather than the widened `0.10000000149011612m`. `ValidationErrorMessageFormatting.FormatParameter` routes finite `Double` values through the canonical encoding; non-finite `Single` and `Double` values fall back to the culture path, which also fixes a latent crash — the `Single` arm previously called `FromSingle` unconditionally, which throws for `NaN`. + +**Why:** +`FromSingle(0.1f)` failed `TryGetDecimal` while the same value degraded to the string `"0.1"` on the wire succeeded — the exact in-process/wire-degraded inversion the lenient accessors exist to prevent. And under a non-invariant culture, one validation message could render a `Double` boundary as `0,5` next to a `Single` rendered `0.1`. + +**Impact:** +`Int64` and `Decimal` message parameters keep the culture-specific path; extending the canonical rule to them is a separate decision. + +## Post-Review Fixes + +Smaller corrections applied on this branch after the review, none of which changes the plan's design: + +- **`TryGetTimeSpan` no longer throws on out-of-range durations.** `XmlConvert.ToTimeSpan` throws `OverflowException` rather than `FormatException` for a well-formed XSD duration exceeding the `TimeSpan` range (such as `"P10675200D"`), which escaped the accessor on wire-degraded input. +- **CloudEvents `time` resolution rejects designator-less timestamps.** The canonical text of a `DateTimeKind.Unspecified` value parsed leniently against the serializing machine's local time zone, so the same metadata produced different instants on different hosts. It now throws an `ArgumentException` pointing towards `DateTimeKind.Utc` or `DateTimeOffset`. +- **`MetadataValueAnnotationHelper.WithAnnotation` documents its real exception contract.** The `ArgumentOutOfRangeException` arm was dead code — `JsonShape` throws `SwitchExpressionException` for undeclared kinds first — and has been removed along with the stale docs. +- **`FormatGuid` dropped a redundant `ToLowerInvariant`.** The `"D"` format is specified to produce lowercase hexadecimal digits; no behavior change. + ## Known Gaps These were identified in the same review and deliberately not addressed on this branch: - **`TryFormatCanonical` still allocates.** It calls `ToCanonicalString()` and copies into the destination, so the span overload the plan describes does not yet deliver the allocation-free path its shape implies. An honest fix needs per-kind span formatting, a hand-rolled ISO 8601 duration formatter (`XmlConvert.ToString(TimeSpan)` has no span overload), and a `netstandard2.0` fork (`double.TryFormat` does not exist there). - **Temporal validation boundaries produce degraded OpenAPI examples.** A `DateTime` boundary cannot be folded by `SemanticModel.GetConstantValue`, so the generated error example carries neither a message nor a `comparativeValue`. This is pre-existing and is tracked in #57. The `DateTime`/`DateTimeOffset`/`TimeSpan`/`Guid`/`Uri` arms added to `ValidatorOpenApiEmitter.ToLiteral` are unreachable through the analyzer until that issue is fixed, and are kept deliberately as its landing site. +- **`TimeOnly` renders RFC 3339 partial-time, not full-time.** The OpenAPI registry's `format: time` refers to RFC 3339 *full-time*, which requires a UTC offset; a `TimeOnly` has none by nature, so its canonical `13:45:30` does not strictly satisfy the schema the mapper emits. This is the same accepted trade-off as the `Unspecified` `DateTime` rendering in deviation 1, but inherent to the type rather than to a caller's choice — inventing an offset would assert an instant the value does not carry. +- **Canonical floating-point text depends on the runtime's `"R"` implementation.** On .NET Core 3.0+, `"R"` produces the shortest round-trippable text the vocabulary specifies; on .NET Framework — a valid host for the `netstandard2.0` asset — it has the documented round-trip defect for `Double`, so canonical text there can differ between runtimes and fail to round-trip. This is runtime-dependent rather than TFM-dependent, so `#if` cannot address it. Tracked in #58. diff --git a/src/Light.PortableResults.Validation/Messaging/ValidationErrorMessageFormatting.cs b/src/Light.PortableResults.Validation/Messaging/ValidationErrorMessageFormatting.cs index 4ae98dc..b7dc546 100644 --- a/src/Light.PortableResults.Validation/Messaging/ValidationErrorMessageFormatting.cs +++ b/src/Light.PortableResults.Validation/Messaging/ValidationErrorMessageFormatting.cs @@ -41,9 +41,14 @@ private static bool TryFormatCanonical(T value, out string formattedValue) case ulong uint64: metadataValue = MetadataValue.FromUInt64(uint64); break; - case float single: + // NaN and Infinity have no metadata kind - the factories reject them - so they fall through to + // the culture-aware fallback path below. + case float single when !float.IsNaN(single) && !float.IsInfinity(single): metadataValue = MetadataValue.FromSingle(single); break; + case double @double when !double.IsNaN(@double) && !double.IsInfinity(@double): + metadataValue = MetadataValue.FromDouble(@double); + break; case char character: metadataValue = MetadataValue.FromChar(character); break; diff --git a/src/Light.PortableResults/CloudEvents/Writing/CloudEventsResultExtensions.cs b/src/Light.PortableResults/CloudEvents/Writing/CloudEventsResultExtensions.cs index 01ef593..cac1e2e 100644 --- a/src/Light.PortableResults/CloudEvents/Writing/CloudEventsResultExtensions.cs +++ b/src/Light.PortableResults/CloudEvents/Writing/CloudEventsResultExtensions.cs @@ -615,6 +615,26 @@ out var parsed ); } + // A timestamp without a UTC designator - the canonical text of a DateTimeKind.Unspecified value - + // would be resolved against the local time zone of whichever machine serializes the event, so the + // same metadata would produce different instants on different hosts. RFC 3339 makes the offset + // mandatory, thus such a value is rejected instead of being silently localized. + if (DateTime.TryParse( + stringValue, + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out var dateTime + ) && + dateTime.Kind == DateTimeKind.Unspecified) + { + throw new ArgumentException( + $"CloudEvents attribute '{attributeName}' has a timestamp without a UTC offset. " + + "Use a DateTimeOffset or a DateTime with DateTimeKind.Utc so that the event time does not " + + "depend on the time zone of the machine that serializes the event.", + attributeName + ); + } + return parsed; } diff --git a/src/Light.PortableResults/Metadata/MetadataValue.cs b/src/Light.PortableResults/Metadata/MetadataValue.cs index 35cad1f..597afdb 100644 --- a/src/Light.PortableResults/Metadata/MetadataValue.cs +++ b/src/Light.PortableResults/Metadata/MetadataValue.cs @@ -422,7 +422,8 @@ public bool TryGetString(out string? value) } /// - /// Attempts to get a decimal number. Signed integers, doubles, and invariant numeric strings are converted. + /// Attempts to get a decimal number. Signed integers, doubles, singles, and invariant numeric strings + /// are converted. /// public bool TryGetDecimal(out decimal value) { @@ -448,11 +449,15 @@ out value return true; } - if (Kind == MetadataKind.Double) + if (Kind == MetadataKind.Double || Kind == MetadataKind.Single) { try { - value = (decimal) _payload.Float64; + // A Single is narrowed back to float before the decimal conversion so that 0.1f reads as + // 0.1m instead of the widened double 0.10000000149011612m. + value = Kind == MetadataKind.Single ? + (decimal) (float) _payload.Float64 : + (decimal) _payload.Float64; return true; } catch (OverflowException) @@ -1062,7 +1067,8 @@ private static string FormatTimeOnly(long ticks) private static string FormatTimeSpan(TimeSpan value) => XmlConvert.ToString(value); - private static string FormatGuid(Guid value) => value.ToString("D", CultureInfo.InvariantCulture).ToLowerInvariant(); + // The "D" format is specified to produce lowercase hexadecimal digits, so no additional lowering is needed. + private static string FormatGuid(Guid value) => value.ToString("D", CultureInfo.InvariantCulture); private static string ThrowComplexCanonicalText(MetadataKind kind) => throw new InvalidOperationException($"Kind '{kind}' does not have a primitive canonical text encoding."); diff --git a/tests/Light.PortableResults.Tests/CloudEvents/Writing/CloudEventsResultExtensionsTests.cs b/tests/Light.PortableResults.Tests/CloudEvents/Writing/CloudEventsResultExtensionsTests.cs index 16705f5..6b7f81c 100644 --- a/tests/Light.PortableResults.Tests/CloudEvents/Writing/CloudEventsResultExtensionsTests.cs +++ b/tests/Light.PortableResults.Tests/CloudEvents/Writing/CloudEventsResultExtensionsTests.cs @@ -643,6 +643,33 @@ public void ToCloudEvent_ShouldThrow_WhenTimeMetadataIsInvalid() act.Should().Throw().Where(exception => exception.ParamName == "time"); } + [Fact] + public void ToCloudEvent_ShouldThrow_WhenTimeMetadataHasNoUtcOffset() + { + // The canonical text of a DateTimeKind.Unspecified value carries no designator. Resolving it against + // the serializing machine's time zone would make the same metadata produce different instants on + // different hosts, so it is rejected with a pointer towards UTC. + const MetadataValueAnnotation annotation = MetadataValueAnnotation.SerializeInCloudEventsExtensionAttributes; + var metadata = MetadataObject.Create( + ("type", MetadataValue.FromString("app.success", annotation)), + ("source", MetadataValue.FromString("urn:test:source", annotation)), + ( + "time", + MetadataValue.FromDateTime( + new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Unspecified), + annotation + ) + ) + ); + var result = Result.Ok(metadata); + + var act = () => result.ToCloudEvent(options: CreateWriteOptions(source: null)); + + act.Should().Throw() + .Where(exception => exception.ParamName == "time") + .WithMessage("*without a UTC offset*DateTimeKind.Utc*"); + } + [Fact] public void ToCloudEventsEnvelopeForWriting_ShouldCreateEnvelopeWithFrozenOptionsAndConvertedExtensionAttributes() { diff --git a/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs b/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs index 1f9eec1..633efc2 100644 --- a/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs +++ b/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs @@ -323,6 +323,19 @@ public void NumericKindsShouldSerializeIdenticallyAcrossTheirRange() } } + [Fact] + public void DecimalAccessorShouldConvertFromSingle() + { + // Without this conversion, an in-process Single would fail TryGetDecimal while the same value + // degraded to the string "0.1" on the wire would succeed - the exact inversion the lenient + // accessors exist to prevent. Narrowing back to float keeps the decimal at 0.1m instead of the + // widened double 0.10000000149011612m. + MetadataValue.FromSingle(0.1f).TryGetDecimal(out var value).Should().BeTrue(); + value.Should().Be(0.1m); + + MetadataValue.FromSingle(float.MaxValue).TryGetDecimal(out _).Should().BeFalse(); + } + [Fact] public void SingleShouldWidenToDoubleWithoutChangingItsOwnAccessor() { diff --git a/tests/Light.PortableResults.Validation.Tests/TypedMetadataRoutingTests.cs b/tests/Light.PortableResults.Validation.Tests/TypedMetadataRoutingTests.cs index 8bf93b3..58032a0 100644 --- a/tests/Light.PortableResults.Validation.Tests/TypedMetadataRoutingTests.cs +++ b/tests/Light.PortableResults.Validation.Tests/TypedMetadataRoutingTests.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using FluentAssertions; using Light.PortableResults.Metadata; using Light.PortableResults.Validation.Definitions; @@ -44,6 +45,49 @@ public void ValidationMessagesShouldUseCanonicalSingleEncoding(float value, stri ValidationErrorMessageFormatting.FormatParameter(value, context).Should().Be(expected); } + [Theory] + [InlineData(0.1, "0.1")] + [InlineData(5.0, "5.0")] + public void ValidationMessagesShouldUseCanonicalDoubleEncoding(double value, string expected) + { + var context = new ReadOnlyValidationContext( + new ValidationState(new ValidationContextOptions()), + string.Empty + ); + + ValidationErrorMessageFormatting.FormatParameter(value, context).Should().Be(expected); + } + + [Fact] + public void ValidationMessagesShouldNotApplyTheContextCultureToFloatingPointValues() + { + // Before this rule, a Double boundary rendered "0,5" under de-DE while a Single rendered "0.1" - + // the same message mixed culture-specific and canonical numeric text. + var context = new ReadOnlyValidationContext( + new ValidationState(new ValidationContextOptions { CultureInfo = new CultureInfo("de-DE") }), + string.Empty + ); + + ValidationErrorMessageFormatting.FormatParameter(0.5, context).Should().Be("0.5"); + ValidationErrorMessageFormatting.FormatParameter(0.5f, context).Should().Be("0.5"); + } + + [Fact] + public void NonFiniteFloatingPointValuesShouldFallBackToCultureFormatting() + { + // NaN and Infinity have no metadata kind - the factories reject them - so they must not reach the + // canonical path. + var context = new ReadOnlyValidationContext( + new ValidationState(new ValidationContextOptions()), + string.Empty + ); + + ValidationErrorMessageFormatting.FormatParameter(double.NaN, context).Should().Be("NaN"); + ValidationErrorMessageFormatting.FormatParameter(float.NaN, context).Should().Be("NaN"); + ValidationErrorMessageFormatting.FormatParameter(double.PositiveInfinity, context).Should().Be("Infinity"); + ValidationErrorMessageFormatting.FormatParameter(float.NegativeInfinity, context).Should().Be("-Infinity"); + } + [Fact] public void ValidationMessagesShouldUseCanonicalEncodingForTheNewStringShapedKinds() {