diff --git a/AGENTS.md b/AGENTS.md index 3ff486b4..48d12653 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/0054-1-metadata-round-trip-envelope.md b/ai-plans/0054-1-metadata-round-trip-envelope.md new file mode 100644 index 00000000..6d3a2608 --- /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/0055-metadata-restructuring.md b/ai-plans/0055-metadata-restructuring.md new file mode 100644 index 00000000..98ef42bf --- /dev/null +++ b/ai-plans/0055-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 + +- [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 + +### 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/0055-plan-deviations.md b/ai-plans/0055-plan-deviations.md new file mode 100644 index 00000000..19619056 --- /dev/null +++ b/ai-plans/0055-plan-deviations.md @@ -0,0 +1,125 @@ +# 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. 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 + +### 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. + +### 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.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs b/src/Light.PortableResults.AspNetCore.OpenApi/Generation/PortableResultsOpenApiDocumentTransformer.cs index 9c7a8019..bf60c2e1 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 4edf7980..285db5d0 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 a3929549..c56ffc59 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 0a56dd80..8aa3ef27 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 3c053f85..5d3400ad 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 3cf8d285..13b83cc1 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 1f5bde07..b7dc5460 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,65 @@ 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; + // 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; + 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 5d7c93d6..192a7c80 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 2e69fc43..f594c51a 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,38 +17,27 @@ 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) { - switch (value.Kind) + // JsonShape throws SwitchExpressionException for an undeclared kind, so the default arm only ever + // sees the four primitive shapes. + 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."); + return value.WithAnnotation(annotation); } } @@ -63,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/CloudEvents/Writing/CloudEventsResultExtensions.cs b/src/Light.PortableResults/CloudEvents/Writing/CloudEventsResultExtensions.cs index b4597ae0..cac1e2e8 100644 --- a/src/Light.PortableResults/CloudEvents/Writing/CloudEventsResultExtensions.cs +++ b/src/Light.PortableResults/CloudEvents/Writing/CloudEventsResultExtensions.cs @@ -585,35 +585,13 @@ 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; + // 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) @@ -637,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/Http/Writing/Headers/DefaultHttpHeaderConversionService.cs b/src/Light.PortableResults/Http/Writing/Headers/DefaultHttpHeaderConversionService.cs index fcef76e7..01f88850 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 b983be85..79ebe262 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 00000000..b333fa29 --- /dev/null +++ b/src/Light.PortableResults/Metadata/MetadataJsonShape.cs @@ -0,0 +1,25 @@ +namespace Light.PortableResults.Metadata; + +/// +/// 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 14e445fb..35e13aac 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 00000000..0c6bc7e4 --- /dev/null +++ b/src/Light.PortableResults/Metadata/MetadataKindExtensions.cs @@ -0,0 +1,78 @@ +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 + }; + + /// + /// 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 00000000..876492f7 --- /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/Metadata/MetadataPayload.cs b/src/Light.PortableResults/Metadata/MetadataPayload.cs index b99c6676..b426fb0c 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 e6fc4a91..597afdb0 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,69 +80,162 @@ 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 metadata value from a decimal number. + /// + /// + /// 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 + ) => + new (MetadataKind.Decimal, new MetadataPayload((object) value), annotation); + + /// + /// 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 from a decimal value. + /// Creates a metadata value from a date and time. /// /// - /// 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. + /// 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 + /// . /// /// - /// The decimal value. - /// The serialization annotation. - /// The metadata value. - public static MetadataValue FromDecimal( - decimal value, + public static MetadataValue FromDateTime( + DateTime value, + MetadataValueAnnotation annotation = DefaultAnnotation + ) + { + // 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 + ); + } + + /// + /// Creates a metadata value from a date and time with an offset. + /// + public static MetadataValue FromDateTimeOffset( + DateTimeOffset value, MetadataValueAnnotation annotation = DefaultAnnotation ) => - new (MetadataKind.Decimal, new MetadataPayload((object) value), annotation); + new (MetadataKind.DateTimeOffset, new MetadataPayload((object) value), annotation); +#if NET10_0_OR_GREATER /// - /// Creates a from a . + /// Creates a metadata value from a date without a time. /// - /// 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. - /// + 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 +246,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 +283,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 +293,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 +380,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 +394,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 +408,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 +422,327 @@ 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, singles, 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) { - case MetadataKind.Decimal when _payload.Reference is decimal @decimal: - value = @decimal; + return decimal.TryParse( + stringValue, + NumberStyles.Number, + CultureInfo.InvariantCulture, + out value + ); + } + + if (Kind == MetadataKind.Int64) + { + value = _payload.Int64; + return true; + } + + if (Kind == MetadataKind.Double || Kind == MetadataKind.Single) + { + try + { + // 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; - case MetadataKind.String when _payload.Reference is string @string: - return decimal.TryParse(@string, NumberStyles.Number, CultureInfo.InvariantCulture, out value); - case MetadataKind.Double: - try + } + 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 = '\0'; + return false; + } + + /// + /// 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) + { + return TryReadStoredDateTime(_payload.Int64, out value); + } + + if (TryGetRawString(out var text) && + DateTime.TryParse( + text, + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out value + ) && + value.Kind != DateTimeKind.Local && + string.Equals(FormatDateTime(value), text, StringComparison.Ordinal)) + { + return true; + } + + value = default; + return false; + } + + /// + /// 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) + { + value = dateTimeOffset; + return true; + } + + if (TryGetRawString(out var text) && + DateTimeOffset.TryParse( + text, + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out value + ) && + IsCanonicalDateTimeOffsetText(value, text)) + { + return true; + } + + value = default; + 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) + { + 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; - } + } + // 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. + } + } - case MetadataKind.Int64: - value = _payload.Int64; - return true; - default: - value = 0; - return false; + value = TimeSpan.Zero; + 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 = Guid.Empty; + 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 +755,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 +768,79 @@ 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, . + /// + /// 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. + /// + 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 +851,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 +877,201 @@ 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 bool TryReadStoredDateTime(long binaryValue, out DateTime value) + { + try + { + 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 (ArgumentException) + { + 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); + + 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); + + // 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."); } + +#pragma warning restore CS8524 diff --git a/src/Light.PortableResults/SharedJsonSerialization/Writing/MetadataExtensions.cs b/src/Light.PortableResults/SharedJsonSerialization/Writing/MetadataExtensions.cs index c0002369..f7ee2355 100644 --- a/src/Light.PortableResults/SharedJsonSerialization/Writing/MetadataExtensions.cs +++ b/src/Light.PortableResults/SharedJsonSerialization/Writing/MetadataExtensions.cs @@ -53,42 +53,57 @@ 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); - break; - case MetadataKind.Double: - value.TryGetDouble(out var doubleMetadataValue); - writer.WriteNumberValue(doubleMetadataValue); + case MetadataJsonShape.Number: + WriteNumberValue(writer, value); break; - case MetadataKind.String: - value.TryGetString(out var stringMetadataValue); - writer.WriteStringValue(stringMetadataValue); + case MetadataJsonShape.String: + writer.WriteStringValue(value.ToCanonicalString()); 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) + { + switch (value.Kind.GetNumberEncoding()) + { + 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." + ); } } diff --git a/src/Light.PortableResults/packages.lock.json b/src/Light.PortableResults/packages.lock.json index f58a9493..84ec8ac6 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 72732524..bbd2d51a 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 82aa6f95..cb9954f6 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 71111b8d..fecc3e6e 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 72732524..bbd2d51a 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 82aa6f95..cb9954f6 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 71111b8d..fecc3e6e 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 00000000..ee28a937 --- /dev/null +++ b/tests/Light.PortableResults.AspNetCore.OpenApi.Tests/PortableOpenApiSchemaTypeMapperTests.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using FluentAssertions; +using Light.PortableResults.Metadata; +using Microsoft.OpenApi; +using Xunit; + +namespace Light.PortableResults.AspNetCore.OpenApi.Tests; + +public sealed class PortableOpenApiSchemaTypeMapperTests +{ + // 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()) + { + (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(IntegerTypesWideningToInt64))] + public void MapShouldGiveTheInt64SchemaToTypesThatWidenIntoIt(Type type) + { + var (_, expectedType, expectedFormat) = VocabularySchemas[MetadataKind.Int64]; + + var schema = PortableOpenApiSchemaTypeMapper.Map(type); + + schema.Type.Should().Be(expectedType); + schema.Format.Should().Be(expectedFormat); + } + + [Fact] + 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 underlyingType = Nullable.GetUnderlyingType(nullableType)!; + + var schema = PortableOpenApiSchemaTypeMapper.Map(nullableType); + var underlyingSchema = PortableOpenApiSchemaTypeMapper.Map(underlyingType); + + schema.Type.Should().Be(underlyingSchema.Type); + schema.Format.Should().Be(underlyingSchema.Format); + } + + [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 e7f50043..ae719c27 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" + ) ); } ); @@ -611,6 +615,93 @@ 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 + ), + ["dateTimeUnspecified"] = new DateTime( + 2026, + 7, + 26, + 13, + 45, + 30, + DateTimeKind.Unspecified + ), + ["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"); + // 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"); + 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 3591e7eb..d7d0be53 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 7209288b..6b7f81c0 100644 --- a/tests/Light.PortableResults.Tests/CloudEvents/Writing/CloudEventsResultExtensionsTests.cs +++ b/tests/Light.PortableResults.Tests/CloudEvents/Writing/CloudEventsResultExtensionsTests.cs @@ -416,6 +416,124 @@ public void ToCloudEvent_ShouldResolveCoreStringAttributes_FromDecimalMetadataVa document.RootElement.GetProperty("subject").GetString().Should().Be("19.50"); } + [Fact] + public void ToCloudEvent_ShouldResolveCoreStringAttributeFromEveryPrimitiveKind() + { + 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.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_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() { @@ -525,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/Http/Writing/Headers/DefaultHttpHeaderConversionServiceTests.cs b/tests/Light.PortableResults.Tests/Http/Writing/Headers/DefaultHttpHeaderConversionServiceTests.cs index f8a7dd01..69264d63 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 df0955bd..263efacd 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,63 @@ 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); + } + + // 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/MetadataValueTestFactory.cs b/tests/Light.PortableResults.Tests/Metadata/MetadataValueTestFactory.cs index 05f65869..2c650994 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 9b959443..5a2f7133 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 00000000..633efc28 --- /dev/null +++ b/tests/Light.PortableResults.Tests/Metadata/TypedMetadataValueTests.cs @@ -0,0 +1,491 @@ +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 DateTimeFactoryShouldNormalizeLocalValuesToUtc() + { + 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); + 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\""); + } + + [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); + } + + [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() + { + // 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] + 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-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(); + // 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); + 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); + } + } + + // 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 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() + { + 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(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); + // 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(); + } + + [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), + ["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)), + ["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 308c8653..77a154cc 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 66d851ce..bc4b66f1 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 00000000..98203b75 --- /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 00000000..58032a0b --- /dev/null +++ b/tests/Light.PortableResults.Validation.Tests/TypedMetadataRoutingTests.cs @@ -0,0 +1,170 @@ +using System; +using System.Globalization; +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); + // 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 + ); + 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); + } + + [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() + { + 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"); + } + + [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); + + 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 31ceff88..17694686 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))) ); }