Tutorials / Build a REST API with C# and .NET · Lesson 30 of 34
Backend
On this page
More Validation Rules
You have learned the basics of validation: [Required], [Range], and the 400 that comes back. Real apps need a few more rules. This lesson shows the ones you are most likely to meet, and ends with how to write a rule of your own. Each part stands on its own, so read the ones you need.
Our goal: know the validation rules that are available, so you are not surprised when a real app needs one.
This lesson leaves out validation libraries (we name one and do not use it) and rules that need the database, such as "no two books with the same title". It shows the rules and what they answer, and does not ask you to type and run each one.
Where we are
This is the solution from the end of DTOs for the books endpoints. One file changes and one is new:
BookShop/
src/
BookShop.Api/
Dtos/
BookRequest.cs <- changes (new rules)
MaxDecimalPlacesAttribute.cs <- NEW (a rule of our own)
A length limit
A book with a title of 300 letters is accepted today. A title like that does not fit a shelf label, a receipt or a page of the shop's website. The value is a text, and nobody said how long it may be. [StringLength] says it:
using System.ComponentModel.DataAnnotations;
public record BookRequest(
[Required, StringLength(200)] string Title, // new
[Required, StringLength(100)] string Author, // new
[Range(0.01, 10000)] decimal Price,
[Range(0, 100000)] int Stock,
[Required, StringLength(50)] string Category); // new
[StringLength(200)] means the text may have at most 200 characters. The limits are the shop's choice: 200 for a title, 100 for an author's name, 50 for a category. A value can carry several rules, and all of them run. A title of 201 letters is now refused with a 400 and this message:
The field Title must be a string with a maximum length of 200.
A second attribute does nearly the same, [MaxLength(200)]. We use [StringLength] because its message reads better for a text, and because it can also set a minimum (MinimumLength = 3). The limit also shows in the API's own description, at /openapi/v1.json, where title now says maxLength: 200.
A gotcha: [Required] is still needed
With Nullable switched on, ASP.NET already treats a text that is not marked as possibly missing as required. So is [Required] on Title redundant? Not quite. We took it off for a moment and sent four requests, in a throwaway API with the same record:
| Missing | null |
"" |
" " |
|
|---|---|---|---|---|
| Only the automatic rule | 400 |
400 |
passes (!) | passes (!) |
With [Required] |
400 |
400 |
400 |
400 |
The automatic rule sees whether the value is there, so it catches a title that is left out or null. An empty text or spaces are values that are present, so they pass, and a book with a blank title is saved. [Required] is the rule that says "empty is not acceptable". That is why it stays.
The other built-in rules
The bookshop has no form that needs them, so here is a stand-in, only to show the rules on a model. A customer asks for the shop's catalogue by e-mail, and gives a few details. We put it behind a throwaway endpoint to get real answers, and you do not need to build it:
public class CatalogueRequest
{
[Required, EmailAddress]
public string Email { get; set; } = "";
[Url]
public string? Website { get; set; }
[Phone]
public string? Phone { get; set; }
[RegularExpression(@"^\d{5}$")]
public string? PostalCode { get; set; }
}
Each rule is one attribute on the box it checks. We sent each box one value that passes and one that fails, and the table shows what came back:
| Rule | Checks | Passes | Fails | The message |
|---|---|---|---|---|
[EmailAddress] |
the general shape of an e-mail | ana@example.com |
ana.example.com |
The Email field is not a valid e-mail address. |
[Url] |
a web address | https://example.com/books |
example.com |
The Website field is not a valid fully-qualified http, https, or ftp URL. |
[Phone] |
something that looks like a phone number | +1 555 010 4477 |
call me |
The Phone field is not a valid phone number. |
[RegularExpression] |
text against a pattern | 90210 |
9021 |
The field PostalCode must match the regular expression '^\d{5}$'. |
Three things to know about them:
- They are loose on purpose.
ana@examplealso passes[EmailAddress]. Only sending a message proves an address works.[Phone]accepts many formats, because phone numbers differ by country. - A missing value passes.
Websiteis optional, so leaving it out is fine. A box that must be filled needs[Required]next to the rule, asEmailhas. - An empty text is not missing.
""is rejected by[Url]and[Phone], but[RegularExpression]accepts it.
A regular expression is a short pattern that describes text (here, ^\d{5}$ means "exactly five digits"). It is easy to get wrong, so use a built-in rule such as [EmailAddress] when one exists, and a pattern only for a simple, fixed format.
Two boxes that must agree
Some rules need two boxes at once. The catalogue form asks for the e-mail twice, to catch typing mistakes. [Compare] does that, on the second box:
[Compare(nameof(Email))]
public string ConfirmEmail { get; set; } = "";
It says: this box must hold the same as the Email box. (nameof(Email) is the name "Email" written so that the compiler checks the spelling.) If the two e-mails differ, the answer is a 400 with this message under ConfirmEmail:
'ConfirmEmail' and 'Email' do not match.
Now a rule no single attribute can say: if the customer asks for a printed copy, a postal address is needed. For that the whole request checks itself. Add : IValidatableObject to the class, with two more boxes and one method:
public class CatalogueRequest : IValidatableObject
{
// ... the boxes from before ...
public bool WantsPrintedCopy { get; set; }
public string? PostalAddress { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
var errors = new List<ValidationResult>();
if (WantsPrintedCopy && string.IsNullOrWhiteSpace(PostalAddress))
{
errors.Add(new ValidationResult("A printed copy needs a postal address.", [nameof(PostalAddress)]));
}
return errors;
}
}
IValidatableObjectis an interface (a list of what a class can do, as in dependency injection basics) with one method,Validate. The method returns the list of problems it found, and an empty list means fine.new ValidationResult(message, [nameof(PostalAddress)])names the box the message belongs to, so the key in the400isPostalAddress.
A printed copy with no address is refused with A printed copy needs a postal address. under PostalAddress.
One small trap: Validate runs only when every attribute passed. If a customer makes both mistakes at once, a mismatched e-mail and a missing address, only the e-mail one is listed, and the address shows up after that is fixed. That is acceptable, and now you know why. If you need the whole list at once, put the check in an attribute instead.
When the built-in rules and IValidatableObject stop being enough, teams often reach for a library such as FluentValidation, which writes the rules in a class of their own. It is a real and popular choice. We do not use it: attributes cover what the shop needs, and they keep the rules visible on the request.
A 400 that is not from your rules
If the JSON cannot be read at all, for example a price of "abc", our rules never run, because there is no request to check. The answer has the same shape, with different keys:
"errors": {
"request": [
"The request field is required."
],
"$.price": [
"The JSON value could not be converted to BookRequest. Path: $.price | LineNumber: 0 | BytePositionInLine: 63."
]
}
$.price is the place in the JSON, and request means there was no usable request. The messages talk about .NET and are not written for a customer. A frontend should show the keys that are names of boxes (Price, Title) next to their boxes, and for the other two say something simple, such as "something in the form could not be read".
A rule of your own: a price in cents
A book with a price of 9.999 is accepted too. The till has cents, and no customer can pay a thousandth of a cent. No built-in rule counts the decimals of a number, so we write one. First, how we want to use it, on the model:
[Range(0.01, 10000), MaxDecimalPlaces(2)] decimal Price, // new
MaxDecimalPlaces(2) reads like the built-in rules. A price with more than two decimals is refused with a 400 and our own message:
The field Price must have at most 2 decimal places.
Now the class behind that line. A validation rule is a small class that inherits from ValidationAttribute, the base class of every rule in this lesson. Create MaxDecimalPlacesAttribute.cs in src/BookShop.Api/Dtos/. The whole file is:
using System.ComponentModel.DataAnnotations;
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)]
public class MaxDecimalPlacesAttribute : ValidationAttribute
{
private readonly int _places;
public MaxDecimalPlacesAttribute(int places)
: base("The field {0} must have at most " + places + " decimal places.")
{
_places = places;
}
public override bool IsValid(object? value)
{
if (value is not decimal number)
{
return true;
}
return decimal.Round(number, _places) == number;
}
}
: ValidationAttributemakes the class a rule. ASP.NET finds every rule by this base class.[AttributeUsage(...)]says where the rule may be put: on a property or on a parameter. Our request is a record, and its rules sit on the parameters of its one line, soParameteris needed.- The constructor takes the number of places,
[MaxDecimalPlaces(2)], and hands the message to the base class. The{0}is replaced by the name of the field when the message is shown. IsValidis the rule. It answerstruefor "fine" andfalsefor "not fine".value is not decimal numbermeans: if the value is not a number, or is missing, saytrue. This rule only judges numbers. A missing value is the job of[Required], and one rule has one job. The built-in rules behave the same way.decimal.Round(number, _places) == numberrounds the number to the allowed places and compares it with the original. 9.999 rounds to 10.00, which differs, so it fails. 9.50 stays 9.50, so it passes.
The controller does not change. The rules are checked before its method runs. Our own rule does not show in the API's description at /openapi/v1.json, because the document does not know what MaxDecimalPlaces means. So a rule of your own has to be told to the frontend in words.
Recap
- This lesson shows the validation rules you may need beyond
[Required]and[Range]. Each part stands on its own. [StringLength]limits the length of a text. The limits are the shop's choice.[Required]stays next to the automatic rule, because the automatic rule lets empty text and spaces through.[EmailAddress],[Url],[Phone]and[RegularExpression]check the shape of a text. They are loose, and they skip a missing value.[Compare]checks that two boxes match, andIValidatableObjectchecks a rule across several boxes. Its method runs only after every attribute passed.- A
400from a request that cannot be read has other keys ($.price,request) and .NET wording. - When no built-in rule fits, write a rule of your own: a class that inherits
ValidationAttributeand overridesIsValid. It judges only its own job, and leaves a missing value to[Required].
Questions to think about
Each question has its answer folded away underneath. Guess first, then open it to check.
- Why does
[Required]stay whenNullablealready makes a text required?
Show answer
The automatic rule sees only whether the value is there. An empty text or spaces are present, so they pass. [Required] rejects them.
- How does a rule that looks at two boxes differ from a rule on one box?
Show answer
A rule on one box is an attribute on that box. A rule across boxes needs the other box too, so it uses [Compare] or the Validate method of the whole request.
- When do you write a validation attribute yourself?
Show answer
When no built-in rule says what you need. A small class with one job (IsValid) makes the rule reusable, and it reads like the built-in ones.
Next lesson: rules the API cannot see, what happens when a book reaches the table by a door that is not the API.