Skip to content

Give every validation failure one coded problem shape (string matching) - #8242

Draft
Hinton wants to merge 1 commit into
mainfrom
validation/coded-problem-responses-strings
Draft

Give every validation failure one coded problem shape (string matching)#8242
Hinton wants to merge 1 commit into
mainfrom
validation/coded-problem-responses-strings

Conversation

@Hinton

@Hinton Hinton commented Aug 21, 2026

Copy link
Copy Markdown
Member

🎟️ Tracking

No ticket — foundation extracted from the PAM error-codes work so it can land on main independently of it.

Alternative to #8237. Same wire contract, same feature flag, same behaviour for every case the repo actually has today — but no source generator and no reflection. 25 files / +1520 here against 39 files / +3135 there. Pick one; they are mutually exclusive.

📔 Objective

A request that failed its DataAnnotations answered with the ErrorResponseModel envelope while a request its handler rejected answered with an RFC 7807 problem document. Same endpoint, same field, two bodies and two key conventions — a client needed both parsers, and only one of them carried a code it could switch on.

One document now serves both:

{
  "type": "validation_error",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "reason": [{ "type": "required", "detail": "The Reason field is required." }],
    "name": [{
      "type": "too_long",
      "detail": "The field Name must be a string with a maximum length of 200.",
      "parameters": { "max": 200 }
    }]
  }
}

One vocabulary. Codes come from ValidationCodes and parameter keys from ValidationParameters. A client looking a substitution up by name is broken by a second spelling as surely as by a second code. A code names what went wrong and never the field it is keyed under — required, not name_required. Parameters carry the limit that was breached and nothing derived from the value that breached it.

How the code is recovered

DataAnnotations records a message and discards the constraint behind it — but the sentence it leaves still contains the limit. The 200 in [StringLength(200)] is right there in "must be a string with a maximum length of 200." ValidationMessageCodes is ~12 [GeneratedRegex] patterns that recognise each attribute's wording and lift the value back out.

Nothing validates anything; the framework still decides. Nothing reflects, either — so there is no generator, no registration step, and not one [RequiresUnreferencedCode]. HttpExtensions builds with IsAotCompatible and is analyzer-clean as it stands, publishable from a trimmed or AOT minimal API.

What this costs, and why it is written down

The wording is the contract. That is the trade, taken knowingly:

Behaviour
Framework rewords a message Loses its code, keeps its detail, reports as invalid
Explicit ErrorMessage (~5% of the repo's attributes) Matches nothing, reports as invalid
[JsonPropertyName] rename Keyed by camel-cased CLR name — same as the current envelope
Non-numeric bound The framework's rendering, e.g. "2020-01-01 00:00:00", not the declared literal

Nothing is ever dropped or mis-coded — the failure always reaches the client with its message. And ValidationMessageCodesTests asserts every pattern against the message its attribute actually produces, by calling FormatErrorMessage on the real attribute, so a framework reword fails the build on the next SDK bump rather than degrading silently in production.

All four rows are fixable only by reading the model's attributes rather than its messages — with reflection, which is not trim-safe, or a generator, which is #8237.

Notes for review

  • Behind FeatureFlagKeys.CodedValidationProblems; off means nothing changes. Each surface opts in separately — TryCodedProblem offers it and only the internal Api takes it. The public API keeps its published shape.
  • src/HttpExtensions/README.md documents the design and the four costs above.
  • ValidationProblemDocumentTests asserts the complete serialized body against a literal, so the wire format has a spec that fails on any shape change.
  • ValidationRoundTripTests drives a real MVC pipeline end to end and asserts on codes.

Known gaps, deliberately not in scope

  • ExceptionHandlerFilterAttribute still answers with ErrorResponseModel, so throw new BadRequestException(modelState) bypasses this. Same in both PRs; the natural follow-up.
  • .Produces<BitwardenValidationProblemDetails>(400) is not declared on endpoints, so OpenAPI does not yet describe the coded shape.

📸 Screenshots

N/A

A request that failed its DataAnnotations answered with the ErrorResponseModel
envelope while a request its handler rejected answered with an RFC 7807 problem
document. Same endpoint, same field, two bodies and two key conventions — a
client needed both parsers, and only one of them carried a code it could switch
on.

One document now serves both, keyed by the property, carrying a code that names
what went wrong and the substitutions a client needs to say so in its own
language. Codes come from ValidationCodes and parameter keys from
ValidationParameters, shared lists both sides draw on, because a client looking
a substitution up by name is broken by a second spelling as surely as by a
second code. Parameters carry the limit that was breached and nothing derived
from the value that breached it.

DataAnnotations records a message and discards the constraint behind it, but the
sentence it leaves still contains the limit: the 200 in StringLength(200) is in
"must be a string with a maximum length of 200". ValidationMessageCodes
recognises each attribute's wording and lifts the value back out.

That makes the wording the contract, which is the trade this takes knowingly.
Reading the constraint off the model instead would mean reflection, which is not
trim-safe, or a source generator, which is more machinery than the surface
currently justifies. Recognising a sentence is neither: it needs no registration
and no RequiresUnreferencedCode, so HttpExtensions builds with IsAotCompatible
and is clean, publishable from a trimmed or ahead-of-time minimal API as it
stands.

What it costs is written down. A reworded message loses its code and keeps its
detail, reported as invalid rather than as something wrong;
ValidationMessageCodesTests asserts every pattern against the message its
attribute actually produces, so a reword fails the build on the next SDK bump
instead of in production. An explicit ErrorMessage matches nothing and reports
the same way. A property renamed by JsonPropertyName is keyed by its camel-cased
CLR name, as the previous envelope also did, and a bound lifted out of prose is
the framework's rendering of it rather than the declared literal.

The document is behind CodedValidationProblems. It replaces a body clients are
already parsing, so each surface opts in on its own schedule and only the
internal API does today; the public API keeps its published shape, which is
versioned on its own terms. With the flag off, nothing answers differently.
await result.ExecuteAsync(context);

stream.Position = 0;
return Indent(await new StreamReader(stream).ReadToEndAsync());
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant