Skip to content

Validation ​

Constraint attributes on the members of a request body's type, or on a handler's parameters, are checked after the request is bound and before the handler runs. So are the rules in a rules class for a body's type. ValidationModules.SourceGenerator compiles both into that check.

The body type goes in src/Todos/NewList.cs:

csharp
using ValidationModules.Constraints;

namespace Todos;

public class NewList
{
    [Required]
    [StringLength(Min = 3, Max = 40)]
    public string? Name { get; set; }

    [Range(1, 50)]
    public int Capacity { get; set; }
}

The handler goes in src/Todos/ListController.cs:

csharp
using Hardened.Web.Runtime.Attributes;

namespace Todos;

[BasePath("/lists")]
public class ListController
{
    [Post("/")]
    public NewList Create(NewList list) => list;
}

The examples on this page add files to src/Todos in an application made with dotnet new hardened-web -n Todos. Their routes answer under /todos, which the template's library module sets with [BasePath("/todos")].

A request that fails a constraint answers 400. The body names each field that failed, with a code and a message:

http
POST /todos/lists
Content-Type: application/json

{"name":"ab","capacity":0}

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{"errors":[{"field":"list.name","code":"string_length","message":"name must be between 3 and 40 characters."},{"field":"list.capacity","code":"range","message":"capacity must be between 1 and 50."}],"detail":"One or more validation errors occurred.","type":"urn:hardened:problem:validation-failed","title":"Request Validation Failed","status":400}

A body that passes reaches the handler:

http
POST /todos/lists
Content-Type: application/json

{"name":"Groceries","capacity":5}

HTTP/1.1 200 OK
Content-Type: application/json

{"name":"Groceries","capacity":5}

The handler declares no filter and registers nothing.

Packages ​

The constraint attributes are in ValidationModules.Constraints. That namespace is in the ValidationModules.Runtime package, which Hardened.Web.Runtime brings.

ValidationModules.SourceGenerator goes in the project that holds the constrained handlers. The hardened-web template references it in src/Todos. It generates a validator for each constrained type and each type a rules class describes. It registers them into the project's [HardenedModule] class.

Its version is the ValidationModules.Runtime version that Hardened brings, which is 1.2.0 for this release. A generator newer than that runtime fails the build with VM5001. A project that sets versions in its project file references it with this line:

xml
<PackageReference Include="ValidationModules.SourceGenerator" Version="1.2.0" />

Without it, no constraint is checked. The build reports warning HRDV006 once for the project, naming one handler.

A project with two [HardenedModule] classes has each validator registered into both. The build reports warning VM6001.

Constraint attributes ​

Each attribute reports the code in the table when it fails. The last column is what the OpenAPI document publishes for it.

AttributeArgumentsCodeIn the OpenAPI document
[Required]AllowEmptyStringsrequiredThe member is listed in the schema's required
[StringLength](max), or Min and Maxstring_lengthminLength, maxLength
[Range](min, max), or Min and Max. ExclusiveMin and ExclusiveMaxrangeminimum, maximum, or exclusiveMinimum, exclusiveMaximum
[Pattern]("regex"), or (typeof(T), nameof(T.Member)) for a [GeneratedRegex] methodpatternpattern, for the string form only
[ItemCount](min, max), or Min and Maxarray_boundsminItems, maxItems
[AllowedValues]The permitted valuesenumenum
[DeniedValues]The refused valuesenumNothing
[MultipleOf]The divisormultiple_ofmultipleOf
[UniqueItems]Noneunique_itemsuniqueItems
[EmailAddress]NoneemailNothing
[Phone]NonephoneNothing
[Url]NoneurlNothing
[CreditCard]Nonecredit_cardNothing
[Base64String]Nonebase64Nothing
[FileExtensions]Extensions, by default png,jpg,jpeg,giffile_extensionNothing
[ValidateNested]Optionally a Polymorphism, described in Nested modelsThe codes of the member type's constraintsNothing

[Required] fails on null, on an empty string and on a string of white space. With AllowEmptyStrings = true, it fails on null only. When [Required] fails, the member's other constraints are not checked. When two other constraints fail on one member, both are reported.

Every constraint except [Required] passes a null value.

WARNING

A member without [Required] accepts null, whatever else constrains it. The template's record NewTodo([property: StringLength(64, Min = 1)] string Title) accepts {"title":null}. POST /todos answers 201 with {"id":3,"title":null,"done":false}.

[Range] takes int, long, double or string bounds. A decimal bound is written as a string, such as [Range("0.5", "30")]. The OpenAPI document publishes it as a number. An exclusive bound is published under exclusiveMinimum or exclusiveMaximum, with the bound as its value. The OpenAPI document publishes [Range(0.0, 1.0, ExclusiveMax = true)] as "minimum": 0, "exclusiveMaximum": 1.

Every constraint takes Code and Message, which replace the error's code and message. {field} in Message becomes the member's name.

On a model's member, When names a bool member of the same model. The constraint is then checked only when that member is true. Unless checks the constraint only when the member it names is false.

The OpenAPI document publishes the constraints on the model's schema. This is the schema for the first example's NewList:

json
"NewList": {
  "type": "object",
  "required": [
    "name",
    "capacity"
  ],
  "properties": {
    "name": {
      "type": [
        "string",
        "null"
      ],
      "minLength": 3,
      "maxLength": 40
    },
    "capacity": {
      "type": "integer",
      "format": "int32",
      "minimum": 1,
      "maximum": 50
    }
  }
}

capacity is in required because its type is int. The OpenAPI document covers the rest of the schema.

A timeout on a pattern ​

[Pattern(typeof(T), nameof(T.Member))] uses the [GeneratedRegex] method you declare, with the timeout you give it. Without a timeout, a pattern that backtracks catastrophically, such as ^(a+)+$, runs on the request's thread for as long as a crafted value makes it. Give the method a timeout:

csharp
using System.Text.RegularExpressions;

namespace Todos;

public static partial class TodoPatterns
{
    [GeneratedRegex("^[a-z0-9-]+$", RegexOptions.None, matchTimeoutMilliseconds: 2000)]
    public static partial Regex Slug();
}

A match that runs past its timeout counts as a failed one, and the request answers 400 with the pattern code. A retry of the same value would time out again, so the answer is not a 500. A pattern from an OpenAPI document or a Smithy model gets a timeout of 2,000 milliseconds. That is also the default of .NET's RegularExpressionAttribute.

Constraints on parameters ​

A constraint on a path, query or header parameter is checked like one on a model's member. The handler in src/Todos/SearchController.cs constrains three parameters:

csharp
using Hardened.Web.Runtime.Attributes;
using ValidationModules.Constraints;

namespace Todos;

[BasePath("/search")]
public class SearchController
{
    [Get("/")]
    public async Task<IReadOnlyList<Todo>> Search(
        ITodoStore store,
        [FromQueryString("q")] [StringLength(Min = 2)] string text,
        [FromQueryString] [Range(1, 100)] int? limit,
        [FromHeader("X-Region")] [StringLength(2, Min = 2)] string? region
    )
    {
        var todos = await store.All();

        return todos
            .Where(todo => todo.Title.Contains(text, StringComparison.OrdinalIgnoreCase))
            .Take(limit ?? 10)
            .ToList();
    }
}

This request fails all three:

http
GET /todos/search?q=a&limit=0
X-Region: USA

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{"errors":[{"field":"q","code":"string_length","message":"q must be at least 2 characters."},{"field":"limit","code":"range","message":"limit must be between 1 and 100."},{"field":"X-Region","code":"string_length","message":"X-Region must be between 2 and 2 characters."}],"detail":"One or more validation errors occurred.","type":"urn:hardened:problem:validation-failed","title":"Request Validation Failed","status":400}

A value that does not convert to the parameter's type answers 400 with code invalid:

http
GET /todos/search?q=read&limit=abc

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{"errors":[{"field":"limit","code":"invalid","message":"limit is not a valid Int32."}],"detail":"One or more validation errors occurred.","type":"urn:hardened:problem:validation-failed","title":"Request Validation Failed","status":400}

A non-nullable query or header parameter that the request leaves out answers required. An omitted nullable parameter is not checked.

The OpenAPI document publishes a parameter's constraints on the parameter's schema.

A route constraint is checked before any value constraint. With a route /page/{count:int} and [Range(Min = 1, Max = 100)] int count, /page/abc answers 404. /page/0 answers 400. Routing covers route constraints.

When or Unless on a parameter's constraint fails the build with HRDV005.

Nested models ​

[ValidateNested] on a member checks the constraints of the member's type. It checks an object, each element of a collection, or each value of a dictionary. This src/Todos/NewList.cs replaces the first one and adds a list of items:

csharp
using ValidationModules.Constraints;

namespace Todos;

public sealed class ListItem
{
    [Required]
    [StringLength(64, Min = 1)]
    public string? Title { get; set; }
}

public class NewList
{
    [Required]
    [StringLength(Min = 3, Max = 40)]
    public string? Name { get; set; }

    [Range(1, 50)]
    public int Capacity { get; set; }

    [ItemCount(Max = 3)]
    [ValidateNested]
    public List<ListItem>? Items { get; set; }
}

The second item fails [Required]:

http
POST /todos/lists
Content-Type: application/json

{"name":"Groceries","capacity":5,"items":[{"title":"Milk"},{"title":""}]}

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{"errors":[{"field":"list.items[1].title","code":"required","message":"title is required."}],"detail":"One or more validation errors occurred.","type":"urn:hardened:problem:validation-failed","title":"Request Validation Failed","status":400}

Without [ValidateNested], the member type's constraints are not checked. A member that is null is not checked.

[ValidateNested] on a member whose type is not sealed reports warning VM1503. Seal the type, or write [ValidateNested(Polymorphism.DeclaredOnly)], which checks the declared type's constraints only. Polymorphism is in ValidationModules.Constraints.

The failure response ​

The body of a validation refusal is RequestValidationError, a problem details document sent as application/problem+json. It has five members, in this order:

MemberValue
errorsThe failures. Each entry has field, code and message
detailOne or more validation errors occurred.
typeurn:hardened:problem:validation-failed, whatever the status
titleRequest Validation Failed
status400, or the status the operation declares for validation, such as 422

Rules the attributes cannot state describes the one exception to that detail.

errors lists every failed constraint, not only the first. The constraints after a failed [Required] are the exception. A handler under [ValidationMode(ValidationStopMode.StopOnFirstError)] lists only the first, as Stop at the first failure describes.

The same body answers each of these refusals:

The requestfieldcodemessage
Fails a constraintThe field, named as in Field namesThe constraint's codeThe constraint's message
Has a path or query value that does not convert, or a member of a query-string or form model that does notThe parameter's or the field's nameinvalidlimit is not a valid Int32.
Leaves out a non-nullable query or header value, or a required member of a query-string or form modelThe parameter's or the field's namerequiredq is required.
Has a body that is not JSONThe body parameter's nameinvalidThe parser's message, such as Expected depth to be zero at the end of the JSON payload. There is an open JSON object or array that should be closed.
Has a body member of the wrong JSON typeThe member's fieldinvalidThe kind of value that goes there, such as The value is not an integer this field can hold. or The value is not a date in the form YYYY-MM-DD. A member bound through a record's constructor reads the same as a property.
Has a body member of type DateTimeOffset whose value states no offset, such as 2030-01-01T00:00:00The member's fieldinvalidThe value is not an RFC 3339 date-time: it states no offset, such as Z or -05:00.
Has an empty body, or the body nullThe body parameter's namerequiredrequest is required.

A handler whose body parameter is nullable, such as NewTodo? request, receives null for the body null. An empty body still answers required.

A value an enum does not declare is refused as invalid when it is read, before any constraint runs.

The body parameter of the template's POST /todos is NewTodo request. A body that is not JSON answers with the parser's message:

http
POST /todos
Content-Type: application/json

{"title":

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{"errors":[{"field":"request","code":"invalid","message":"Expected depth to be zero at the end of the JSON payload. There is an open JSON object or array that should be closed."}],"detail":"One or more validation errors occurred.","type":"urn:hardened:problem:validation-failed","title":"Request Validation Failed","status":400}

The OpenAPI document lists a 400 with the RequestValidationError schema for an operation that validates. The execution pipeline shows where the check runs among the filters.

An operation that declares a body of its own at the validation status answers validation failures with that body, and the document lists that body alone. That is a handler whose response set includes BadRequest, or a contract that declares a Problem for its 400. The body then carries no field errors. They are in the request log. Declare no body at the validation status to send RequestValidationError. Declared responses covers the rule.

Field names ​

Each entry's field depends on where the value is:

Where the value isfieldExample
A body memberThe handler's body parameter name, a dot, then the path to the memberlist.name for Create(NewList list)
A collection elementThe path with the element's indexlist.items[1].title
A dictionary valueThe path with the keybody.map[k1].title
A path, query or header valueThe name the request usesq for [FromQueryString("q")] string text, and X-Region for [FromHeader("X-Region")]
A member of a [FromQueryString] or [FromForm] model that fails a constraintThe model parameter's name, a dot, then the memberfilter.limit
A member of such a model that is missing or does not convertThe field's name alonelimit

A member's name is written with its first letter in lower case: DueDate reports as dueDate. The member's name in message is spelled the same way, as in name must be between 3 and 40 characters. ValidationModules_FieldNaming changes the spelling, as Build properties describes.

A model bound from the query string or a form is checked after it is bound, like a body model. Forms and files covers binding a model from fields. The handler in src/Todos/TodoFilterController.cs binds one from the query string:

csharp
using Hardened.Web.Runtime.Attributes;
using ValidationModules.Constraints;

namespace Todos;

public record TodoFilter(
    [property: Range(1, 100)] int Limit,
    [property: StringLength(Min = 2)] string Text
);

[BasePath("/filtered")]
public class TodoFilterController
{
    [Get("/")]
    public string Find([FromQueryString] TodoFilter filter) => $"{filter.Limit} {filter.Text}";
}

This request fails both constraints:

http
GET /todos/filtered?limit=0&text=a

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{"errors":[{"field":"filter.limit","code":"range","message":"limit must be between 1 and 100."},{"field":"filter.text","code":"string_length","message":"text must be at least 2 characters."}],"detail":"One or more validation errors occurred.","type":"urn:hardened:problem:validation-failed","title":"Request Validation Failed","status":400}

A member of such a model that is missing or does not convert is refused while the model is bound, before any constraint runs:

http
GET /todos/filtered?text=ab

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{"errors":[{"field":"limit","code":"required","message":"limit is required."}],"detail":"One or more validation errors occurred.","type":"urn:hardened:problem:validation-failed","title":"Request Validation Failed","status":400}

Only the first missing member is reported.

Stop at the first failure ​

[ValidationMode(ValidationStopMode.StopOnFirstError)] on a handler method, or on its class, answers a request that fails validation with its first failure only. errors then has one entry. ValidationStopMode.CollectAll answers with every failure. A handler that declares no mode gets CollectAll.

The class in src/Todos/QuickListController.cs declares StopOnFirstError. Its CreateFull method declares CollectAll. NewList is the version from Nested models:

csharp
using Hardened.Requests.Runtime.Validation;
using Hardened.Web.Runtime.Attributes;
using ValidationModules;

namespace Todos;

[BasePath("/quick-lists")]
[ValidationMode(ValidationStopMode.StopOnFirstError)]
public class QuickListController
{
    [Post("/")]
    public NewList Create(NewList list) => list;

    [Post("/full")]
    [ValidationMode(ValidationStopMode.CollectAll)]
    public NewList CreateFull(NewList list) => list;
}

Create answers the first example's body with one entry:

http
POST /todos/quick-lists
Content-Type: application/json

{"name":"ab","capacity":0}

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{"errors":[{"field":"list.name","code":"string_length","message":"name must be between 3 and 40 characters."}],"detail":"One or more validation errors occurred.","type":"urn:hardened:problem:validation-failed","title":"Request Validation Failed","status":400}

CreateFull answers the same body with both failures:

http
POST /todos/quick-lists/full
Content-Type: application/json

{"name":"ab","capacity":0}

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{"errors":[{"field":"list.name","code":"string_length","message":"name must be between 3 and 40 characters."},{"field":"list.capacity","code":"range","message":"capacity must be between 1 and 50."}],"detail":"One or more validation errors occurred.","type":"urn:hardened:problem:validation-failed","title":"Request Validation Failed","status":400}

ValidationModeAttribute is in Hardened.Requests.Runtime.Validation, in the Hardened.Requests.Runtime package. ValidationStopMode is ValidationModules' own enum, in the namespace ValidationModules of ValidationModules.Runtime. Hardened.Web.Runtime brings that package.

These declarations set the mode:

Declared onSets the mode of
A handler methodThe handler. The nearest declaration wins: a method's mode beats its class's
A handler classIts handlers
A [HardenedModule] classEvery handler compiled in the module's project that declares no mode. Only CollectAll compiles there, as Limits describes
An operation in a contract, with x-hardened-validation in an OpenAPI document or @validation in a Smithy modelThe operation. The contract's mode beats a [ValidationMode] on the module class and one on the implementation

Generating from OpenAPI and Generating from Smithy cover x-hardened-validation and @validation. The OpenAPI document publishes the mode as x-hardened-validation. The OpenAPI document covers the published extension.

The refusal keeps its body and its status: 400, or the 422 that Change the status to 422 sets.

The mode belongs to the handler, not to the model. NewList gets every failure at /todos/lists and only the first at /todos/quick-lists.

The first failure is the first in declaration order: the parameters in the order the handler declares them, a model's members in the order its type declares them, and one member's constraints in the order they are written. The checks after the first failure do not run: the member's other constraints, the other members and parameters, and the elements of a collection. At /todos/quick-lists, the body {"name":"Groceries","capacity":5,"items":[{"title":""},{"title":""}]} reports list.items[0].title alone.

A validator registered for the body type runs before the generated check for that type. Rules the attributes cannot state shows one. Under StopOnFirstError, when both fail, the refusal names the registered validator's failure alone. A registered validator that reports two failures has only its first reported.

A ValidationException the handler throws is answered with every error it holds, in either mode.

Skip validation ​

[ValidateNever] binds a parameter without checking its constraints. On a handler method, it covers every parameter the handler binds. ValidateNeverAttribute is in Hardened.Requests.Runtime.Validation.

A route that stores a draft takes the same NewList without its constraints. The handler is in src/Todos/DraftListController.cs:

csharp
using Hardened.Requests.Runtime.Validation;
using Hardened.Web.Runtime.Attributes;

namespace Todos;

[BasePath("/drafts")]
public class DraftListController
{
    [Post("/")]
    public NewList Save([ValidateNever] NewList list) => list;
}

POST /todos/drafts with the body {"name":"ab","capacity":0} answers 200 with the list as it was sent. POST /todos/quick-lists/full refuses the same body with two failures.

A parameter beside a marked one is still checked. Binding still refuses a value that does not convert to its type, and a body that does not deserialize. The OpenAPI document still publishes the model's constraints, because the schema belongs to the type. A specification-first operation ignores [ValidateNever] and checks what its contract declares.

Change the status to 422 ​

[Throws<RequestValidationError>(422)] on a handler answers every validation refusal of that handler with 422: a failed constraint, a body that cannot be read, and a ValidationException the handler throws. The handler in src/Todos/ImportController.cs declares it:

csharp
using Hardened.Requests.Runtime.Validation;
using Hardened.Web.Runtime.Attributes;
using Hardened.Web.Runtime.Responses;

namespace Todos;

[BasePath("/imports")]
public class ImportController
{
    [Post("/")]
    [Throws<RequestValidationError>(422)]
    public Todo Import(NewTodo request) => new Todo(0, request.Title, false);
}

A failed constraint answers 422:

http
POST /todos/imports
Content-Type: application/json

{"title":""}

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{"errors":[{"field":"request.title","code":"string_length","message":"title must be between 1 and 64 characters."}],"detail":"One or more validation errors occurred.","type":"urn:hardened:problem:validation-failed","title":"Request Validation Failed","status":422}

The OpenAPI document then lists the 422 with the RequestValidationError schema, and no 400.

RequestValidationError is in Hardened.Requests.Runtime.Validation. [Throws<T>] is in Hardened.Web.Runtime.Responses. Declared responses covers it.

Change the body ​

The registered IExceptionToModelConverter writes the body of every refusal that comes from an exception, a validation failure and the 406 included. A class with [SingletonService(Using = RegistrationType.Replace)] that implements it replaces the default. ExceptionToModelConverter is the default. A replacement can call it for the status and the body it would have sent.

The converter in src/Todos/ValidationProblemConverter.cs replaces the body of a validation refusal:

csharp
using DependencyModules.Runtime.Attributes;
using Hardened.Requests.Abstract.Errors;
using Hardened.Requests.Abstract.Execution;
using Hardened.Requests.Runtime.Errors;
using Hardened.Requests.Runtime.Validation;

namespace Todos;

public record ValidationProblem(
    string Title,
    int Status,
    IReadOnlyList<RequestValidationFieldError> Errors
);

[SingletonService(Using = RegistrationType.Replace)]
public class ValidationProblemConverter : IExceptionToModelConverter
{
    private readonly ExceptionToModelConverter _default = new();

    public (int, object) ConvertExceptionToModel(IExecutionContext context, Exception exp)
    {
        var (status, model) = _default.ConvertExceptionToModel(context, exp);

        if (model is RequestValidationError validation)
        {
            return (status, new ValidationProblem("The request is invalid.", status, validation.Errors));
        }

        return (status, model);
    }
}

GET /todos/0 then answers with the new body:

http
GET /todos/0

HTTP/1.1 400 Bad Request
Content-Type: application/json

{"title":"The request is invalid.","status":400,"errors":[{"field":"id","code":"range","message":"id must be at least 1."}]}

A response the handler returns, such as a NotFound, does not pass through the converter. The OpenAPI document still describes the 400 with the RequestValidationError schema.

The interface is in Hardened.Requests.Abstract.Errors. The default is in Hardened.Requests.Runtime.Errors. RegistrationType is in DependencyModules.Runtime.Attributes.

Rules classes ​

A rules class declares a type's rules in C# instead of in attributes on its members. It implements IValidationRulesFor<T> with a static Describe method. The generator compiles the rules into the type's validator, and a handler that binds the type runs it. The type itself carries no attribute.

This src/Todos/NewList.cs replaces the first one and moves its rules into a rules class:

csharp
using ValidationModules;

namespace Todos;

public class NewList
{
    public string? Name { get; set; }

    public int Capacity { get; set; }
}

public class NewListRules : IValidationRulesFor<NewList>
{
    public static void Describe(ValidationRules<NewList> rules, NewList x)
    {
        rules.Require(x.Name);
        rules.Length(x.Name, 3, 40);
        rules.Range(x.Capacity, 1, 50);
    }
}

POST /todos/lists answers as it did with the attributes:

http
POST /todos/lists
Content-Type: application/json

{"name":"ab","capacity":0}

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{"errors":[{"field":"list.name","code":"string_length","message":"name must be between 3 and 40 characters."},{"field":"list.capacity","code":"range","message":"capacity must be between 1 and 50."}],"detail":"One or more validation errors occurred.","type":"urn:hardened:problem:validation-failed","title":"Request Validation Failed","status":400}

A rules class can also compare two members, apply a rule only in some states of the object, and describe a type declared in another assembly. The ValidationModules guide to rules classes covers what ValidationRules<T> can state. IValidationRulesFor<T> and ValidationRules<T> are in ValidationModules.

The OpenAPI document does not publish the rules in a rules class.

Rules the attributes cannot state ​

A handler can throw ValidationException, from Hardened.Requests.Runtime.Validation, with a ValidationModules.ValidationResult. The request then answers with the same body and status as a failed constraint. ValidationModules also declares a ValidationException. The example names Hardened's through an alias.

The handler in src/Todos/DraftController.cs refuses the title inbox:

csharp
using Hardened.Web.Runtime.Attributes;
using ValidationModules;
using ValidationException = Hardened.Requests.Runtime.Validation.ValidationException;

namespace Todos;

[BasePath("/drafts")]
public class DraftController
{
    [Post("/")]
    public Todo Create(NewTodo request)
    {
        if (string.Equals(request.Title, "inbox", StringComparison.OrdinalIgnoreCase))
        {
            throw new ValidationException(
                ValidationResult.FromErrors([
                    new ValidationError("request.title", "reserved", "inbox is a reserved title."),
                ])
            );
        }

        return new Todo(0, request.Title, false);
    }
}

A draft titled Inbox is refused:

http
POST /todos/drafts
Content-Type: application/json

{"title":"Inbox"}

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{"errors":[{"field":"request.title","code":"reserved","message":"inbox is a reserved title."}],"detail":"One or more validation errors occurred.","type":"urn:hardened:problem:validation-failed","title":"Request Validation Failed","status":400}

ValidationModules.ValidationException, which ValidateAndThrow throws, answers the same way. The body's detail is then the exception's own message, such as Validation failed: request.title reserved.

A class that implements IValidatorFor<T> for a body type, registered as a service, runs beside the generated check for T. Its errors join the same list. The generated check for T still runs. The registered validator runs first, so its errors come before the generated check's in errors. The context it receives is positioned at the body parameter, so "title" is reported as request.title.

The validator in src/Todos/NoShoutingValidator.cs checks NewTodo:

csharp
using DependencyModules.Runtime.Attributes;
using ValidationModules;

namespace Todos;

[SingletonService]
public class NoShoutingValidator : IValidatorFor<NewTodo>
{
    public ValidationFlow Validate(ref ValidationContext context, NewTodo value)
    {
        if (IsValid(value))
        {
            return ValidationFlow.Continue;
        }

        return context.Report(
            "title",
            "shouting",
            "title must not be all capitals.",
            ValidationSeverity.Error
        );
    }

    public bool IsValid(NewTodo value) =>
        value.Title is not { Length: > 1 } title || title != title.ToUpperInvariant();
}

The template's POST /todos then answers:

http
POST /todos
Content-Type: application/json

{"title":"URGENT"}

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{"errors":[{"field":"request.title","code":"shouting","message":"title must not be all capitals."}],"detail":"One or more validation errors occurred.","type":"urn:hardened:problem:validation-failed","title":"Request Validation Failed","status":400}

IValidatorFor<T>, ValidationContext, ValidationFlow and ValidationSeverity are in ValidationModules.

DataAnnotations attributes ​

The generator also compiles the System.ComponentModel.DataAnnotations attributes. They report the same codes:

AttributeCode
[Required]required
[StringLength]string_length
[Range]range
[RegularExpression]pattern
[EmailAddress]email

A NoteController under [BasePath("/notes")] takes NewNote note in a [Post("/")] handler. The model in src/Todos/NewNote.cs uses three of the attributes:

csharp
using DataAnnotations = System.ComponentModel.DataAnnotations;

namespace Todos;

public class NewNote
{
    [DataAnnotations.Required]
    [DataAnnotations.StringLength(200, MinimumLength = 3)]
    public string? Text { get; set; }

    [DataAnnotations.EmailAddress]
    public string? Author { get; set; }
}

This body fails two of them:

http
POST /todos/notes
Content-Type: application/json

{"text":"hi","author":"nobody"}

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{"errors":[{"field":"note.text","code":"string_length","message":"text must be between 3 and 200 characters."},{"field":"note.author","code":"email","message":"author is not a valid email address."}],"detail":"One or more validation errors occurred.","type":"urn:hardened:problem:validation-failed","title":"Request Validation Failed","status":400}

The DataAnnotations attributes do not reach the OpenAPI document. ValidationModules_DataAnnotations set to Ignore stops the generator compiling them, as Build properties describes.

Build properties ​

The generator takes three build properties:

PropertyValuesDefaultEffect
ValidationModules_FieldNamingSnakeCase, PascalCase, AsDeclaredFirst letter in lower caseThe spelling of a member's name in field and message
ValidationModules_DataAnnotationsIgnoreThe DataAnnotations attributes are compiledIgnore stops compiling them
ValidationModules_PatternPolicyError, Warn, AllowError when PublishAot or IsAotCompatible is true, Allow otherwiseHow the build reports [Pattern("...")] with an inline expression: error or warning VM1301, or nothing

This excerpt of src/Todos/Todos.csproj sets SnakeCase:

xml
<PropertyGroup>
  <ValidationModules_FieldNaming>SnakeCase</ValidationModules_FieldNaming>
</PropertyGroup>

With SnakeCase, a DueDate member of a body parameter named reminder reports as reminder.due_date, with the message due_date is required. PascalCase and AsDeclared both write the member's name as it is declared: DueDate. The body parameter's name, and the names of path, query and header values, do not change.

[Pattern(typeof(T), nameof(T.Member))] is not reported under any policy.

The hardened-web template sets Warn in src/<Name>/<Name>.csproj when neither PublishAot nor IsAotCompatible is true. An inline expression then warns on every build, and a Native AOT publish still fails with the error.

Diagnostics ​

The build reports these diagnostics:

CodeSeverityReported when
HRDV005ErrorA parameter's constraint sets When or Unless
HRDV006WarningThe project declares constraints on a handler and does not reference ValidationModules.SourceGenerator
VM1201Warning[Required] is on a non-nullable value type, such as int, which is never null
VM1301Error or warning, by ValidationModules_PatternPolicy[Pattern("...")] has an inline expression
VM1503Warning[ValidateNested] is on a member whose type is not sealed

An omitted int member reads as 0, so [Required] on it never fails. JSON serialization covers the required modifier and [JsonRequired], which make the value's absence a refusal.

Limits ​

An IAsyncValidatorFor<T> registered for a body type is not run.

The OpenAPI document does not publish [DeniedValues], [EmailAddress], [Phone], [Url], [CreditCard], [Base64String], [FileExtensions], [Pattern] with a [GeneratedRegex], any DataAnnotations attribute, or the rules in a rules class. Each of them is still checked.

On a [HardenedModule] class, [ValidationMode(ValidationStopMode.StopOnFirstError)] fails the build with CS1503 in the generated <Module>.Module.g.cs:

text
Argument 1: cannot convert from 'int' to 'ValidationModules.ValidationStopMode'

It fails on the template's library module and on its application module. It also fails when written as (ValidationStopMode)1 or as stopMode: ValidationStopMode.StopOnFirstError.

[ValidationMode(ValidationStopMode.CollectAll)] compiles on a module class. It is the default, so it changes no refusal.

Next ​

Released under the MIT License.