Skip to content

Validation boundaries of non-constant types produce OpenAPI error examples without a message or comparativeValue #57

Description

@feO2x

Rationale

The validation source generator learns a rule's comparative value by asking Roslyn to fold the argument expression:

https://github.com/feO2x/Light.PortableResults/blob/main/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs#L808

SemanticModel.GetConstantValue only succeeds for C# constant expressions, and the C# specification restricts those to string, char, bool, the integral types, float, double, decimal, enums, and null. DateTime, DateTimeOffset, TimeSpan, Guid, DateOnly, TimeOnly, and Uri cannot be constants in C# at all, so a temporal or identifier boundary can never be folded — not by writing it differently, not by hoisting it into a field. static readonly fields of otherwise-constant types are affected for the same reason.

When folding fails, HasConstantValue stays false and two things happen downstream. The message template loses its {comparativeValue} replacement, so the whole message is abandoned:

https://github.com/feO2x/Light.PortableResults/blob/main/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiAnalyzer.cs#L454

and the emitter drops the metadata dictionary for that rule — all of it, not just the entry it could not fold:

https://github.com/feO2x/Light.PortableResults/blob/main/src/Light.PortableResults.Validation.OpenApi.SourceGeneration/ValidatorOpenApiEmitter.cs#L236

Reproduction

public sealed class BookingDto
{
    public DateTime StartsAt { get; init; }
    public int Seats { get; init; }
}

[GeneratePortableValidationOpenApi]
public sealed partial class BookingValidator : Validator<BookingDto>
{
    public BookingValidator(IValidationContextFactory validationContextFactory)
        : base(validationContextFactory) { }

    protected override ValidatedValue<BookingDto> PerformValidation(
        ValidationContext context,
        ValidationCheckpoint checkpoint,
        BookingDto dto
    )
    {
        context.Check(dto.StartsAt).IsGreaterThan(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
        context.Check(dto.Seats).IsGreaterThan(0);
        return checkpoint.ToValidatedValue(dto);
    }
}

Generated output, verbatim:

builder.WithGreaterThanError<global::System.DateTime>();
builder.WithGreaterThanError<global::System.Int32>();

builder.WithErrorExample("GreaterThan", "startsAt", null);
builder.WithErrorExample("GreaterThan", "seats", "seats must be greater than 0", new Dictionary<string, object?>(StringComparer.Ordinal) { ["comparativeValue"] = 0 });

The two checks are written identically in the validator, and only the int one produces a usable example. No diagnostic is reported for the DateTime check.

Impact

  • Every temporal validation boundary — IsGreaterThan, IsLessThan, IsInRange, IsEqualTo, and their siblings over DateTime, DateTimeOffset, DateOnly, TimeOnly, TimeSpan — publishes an error example with no message and no comparativeValue. Guid and Uri comparisons are affected the same way.
  • The degradation is silent. Nothing in the generated output, the diagnostics, or the published document distinguishes "this rule has no message" from "this rule's message could not be reconstructed", so it reads as an authoring omission rather than a generator limitation.
  • Date and time boundaries are among the most common cases where a client actually needs the boundary value in the example, which is what makes this worth fixing rather than documenting.
  • The all in canEmitMetadata widens the blast radius: one unfoldable entry drops every other metadata entry on the same rule, including ones that folded fine.

The schema side is unaffected — WithGreaterThanError<global::System.DateTime>() is emitted correctly and PortableOpenApiSchemaTypeMapper gives it type: string, format: date-time. This is purely about the example.

Proposed direction (open for discussion)

Stop asking Roslyn to evaluate the expression and reconstruct it from syntax instead. When GetConstantValue fails, inspect the argument expression and recognise the shapes that can be re-emitted into the generated file:

  • new DateTime(...) / new DateTimeOffset(...) / new TimeOnly(...) and friends whose arguments are themselves constants
  • DateOnly.FromDayNumber(...), TimeSpan.FromTicks(...), Guid.ParseExact("..."), DateTime.Parse("...")-style factory calls with constant arguments
  • a reference to a static readonly field whose initializer is one of the above

The emitter already knows how to write these: ValidatorOpenApiEmitter.ToLiteral has arms for DateTime, DateTimeOffset, TimeSpan, Guid, and Uri, plus TryCreateDateOnlyOrTimeOnlyLiteral. Those arms are currently unreachable through the analyzer for exactly the reason described above, and are being kept on purpose as the landing site for this fix.

Reconstructing the value also needs a decision on how it is rendered into the message text, which is a string template. FormatMessageValue would have to agree with the canonical text MetadataValue produces for the same kind, or the message and the comparativeValue metadata entry disagree in the same example.

Two narrower alternatives, if the syntax walk turns out to be too broad:

  1. Handle only new DateTime(...)/new DateTimeOffset(...) with constant arguments, which covers the common authoring style and leaves the rest degraded.
  2. Leave the behaviour as is and report an informational diagnostic when a rule's metadata cannot be folded, so the degradation is at least visible at build time. This is worth doing regardless of whether the reconstruction lands.

Acceptance Criteria

  • A validation boundary written as new DateTime(...) with constant arguments produces an error example carrying both the message and the comparativeValue metadata entry.
  • The same holds for DateTimeOffset, DateOnly, TimeOnly, TimeSpan, and Guid boundaries, or the unsupported ones are named explicitly in the decision.
  • A rule with one unfoldable metadata entry no longer discards the entries that did fold, or the all-or-nothing behaviour is recorded as deliberate.
  • The value rendered into the message text matches the canonical text MetadataValue produces for the same kind, so message and metadata cannot disagree.
  • A boundary the generator still cannot reconstruct is surfaced by a diagnostic rather than degrading silently.
  • The generator test suite covers a temporal boundary end to end, asserting the emitted WithErrorExample call rather than calling ToLiteral directly.
  • Test code coverage stays above 95%.

Out of scope

  • The schema emitted for temporal types, which is already correct.
  • Runtime validation behaviour: the check itself works, this is only about what the generator can say about it at compile time.
  • Boundaries computed at runtime (DateTime.UtcNow.AddDays(30)), which have no compile-time value by definition and should keep degrading, ideally with the diagnostic from the last alternative above.

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions