Tutorials / Build a REST API with C# and .NET · Lesson 29 of 29
On this page
Validation in Depth
At the order desk of the bookshop, a clerk looks at every order form before it goes to the back office. Is every box filled in? Does the title fit in its box, or is it a whole novel? Is the price a price, with cents and not thousandths of a cent? Do two boxes agree with each other, such as an e-mail address typed twice? A form that fails any of these goes back to the customer with a note. A form that passes is trusted from then on. Our API is that clerk, and so far it checks only three things.
Our goal: make the books endpoint turn away the bad requests that still get in, and know which checkpoint is responsible for which kind of mistake.
Here is the route:
- Picture the three places where a bad value can be stopped.
- Find the holes that are still open in the books endpoint.
- See why
[Required]is still needed, with a trap that is easy to miss. - Close the holes: a length limit, and one rule we write ourselves.
- Read the
400closely, including the answers that do not come from our rules. - Look at rules that compare two boxes, and at the other built-in rules.
- Test the rules.
This lesson leaves out validation libraries (we name one and do not use it), rules that need the database, such as "no two books with the same title", and the screens that show the messages.
Where we are
This is the solution from the end of DTOs for the books endpoints. One file changes, one is new, and two test files follow:
BookShop/
src/
BookShop.Api/
Dtos/
BookRequest.cs <- changes (new rules)
BookResponse.cs
BookMapping.cs
MaxDecimalPlacesAttribute.cs <- NEW (a rule of our own)
tests/
BookShop.Api.Tests/
BookRequestValidationTests.cs <- changes (four new tests)
MaxDecimalPlacesAttributeTests.cs <- NEW
Only the files that change are listed. Run every command from the solution folder, BookShop/. No project is added, so there is no solution map.
Three checkpoints
A customer, Ana, sends an order for a book. Between her and the shelf there are three places where a bad order can be stopped:
- The web page can look at the form before it sends anything, and say "the title is empty" at once, without a round trip. That is kind to Ana, and it is only a convenience. Anyone can send a request without our page, with curl or with a program, and the page's check never runs. So the server never trusts it.
- The API is where every request comes in, so this is the check that counts. The rules live on the request class, as DTOs for the books endpoints set up.
- The database is the last line. It stops what slipped past, such as a nightly job that writes to the table directly and never meets the API. The
Bookstable already has one guard, which EF Core and SQLite showed:"Title" TEXT NOT NULL. In DB Browser, on the Execute SQL tab, try to save a book with no title:
INSERT INTO Books (Title, Author, Price, Stock, Category) VALUES (NULL, 'Anon', 9.50, 1, 'Programming');
NOT NULL constraint failed: Books.Title
The table refuses it, whoever asks. But look at what that guard does not do. It does not say what a good title looks like, and it has no limit on length: SQLite ignores the number in a column such as VARCHAR(10) (we stored 44 letters in one and it did not mind), and EF Core made Title plain TEXT anyway. A server such as SQL Server would refuse a text that is too long for its column. Here, the API's check is the only length check.
Why have three, and not one good one? Each stops different mistakes, and the cost of each is small. We build the middle one in this lesson, because it is the one that is ours to get right.
Two holes
Start the app on a copy of your database and send a book with a price that has three decimals. This is the code as it stands at the end of the last lesson:
curl -i -X POST http://localhost:5101/books -H "Content-Type: application/json" -d '{"title":"Odd Price","author":"Anon","price":9.999,"stock":1,"category":"Programming"}'
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: http://localhost:5101/books/6
{"id":6,"title":"Odd Price","author":"Anon","price":9.999,"stock":1,"category":"Programming"}
The API saved it. A customer cannot pay 9.999: the till has cents. (Your id may differ from the 6 shown.) The second hole is length. We also sent a title of 300 letters, and the answer was a 201 as well. A title like that does not fit a shelf label, a receipt or a page of the shop's website.
Both are the same kind of hole: the value is the right type, and nobody said how big or how precise it may be.
Delete the odd book before you go on, in DB Browser: DELETE FROM Books WHERE Title = 'Odd Price';, then Write Changes.
A trap: what [Required] is still doing
A fair question before adding rules. The solution has Nullable switched on, so the compiler already treats string Title as "never missing". Does ASP.NET not check that for us? And why does the request still carry = "" after each text property? We can find out by experiment. Take [Required] off Title for a moment, and keep everything else. First, a request with no title at all:
curl -i -X POST http://localhost:5101/books -H "Content-Type: application/json" -d '{"author":"Anon","price":9.5,"stock":1,"category":"Programming"}'
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: http://localhost:5101/books/6
{"id":6,"title":"","author":"Anon","price":9.5,"stock":1,"category":"Programming"}
A book with an empty title was saved. The property starts as "" (that is what = "" does), nothing replaced it, and an empty text is a value that is present. The automatic check did not fire. Second, a request that says plainly that the title is nothing:
curl -i -X POST http://localhost:5101/books -H "Content-Type: application/json" -d '{"title":null,"author":"Anon","price":9.5,"stock":1,"category":"Programming"}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Title": [
"The Title field is required."
]
},
"traceId": "00-630c7688eb4535e74484d54b8dd4b923-c09e65c1f70646fb-00"
}
(The real body is one line.) So ASP.NET does have an automatic rule: with Nullable on, a text property that is not marked as possibly missing is required. But it catches only null, which is a title that is sent as nothing. A title that is left out becomes "" through our own default, and "" slips through. (In create a book we said the property can never be null. To be exact, it starts as "", but a client can still send null into it.)
We also tried the opposite, with no [Required] and no = "". A request without a title then answered 400, with the same message. So the automatic rule alone is enough to catch a missing title, at a price: the compiler warns that a non-nullable property is never given a value (warning CS8618), and an empty title "" or a title of only spaces still gets in.
The explicit [Required] is better, and it is why both stay:
| Missing | null |
"" |
" " |
|
|---|---|---|---|---|
Only the automatic rule, with = "" |
passes (!) | 400 |
passes | passes |
[Required] and = "" |
400 |
400 |
400 |
400 |
(We ran every cell. In the first row a title of three spaces passes, and in the second row it is turned away.) Two more reasons to keep it. The unit tests for the rules, later, use a checker that only reads attributes, so the automatic rule is invisible to them. And an attribute is a plain statement in the file: anyone reading BookRequest sees that a title is required.
💡 The lesson to take away: a default value can hide a missing value.
= ""keeps the compiler quiet, and[Required]is what says "empty is not acceptable".
Close the holes
Two changes. A limit on how long the text may be, and a rule that no built-in attribute offers: a price may have at most two decimal places.
Text length: [StringLength]
[StringLength(200)] says 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 second attribute does nearly the same, [MaxLength(200)]. We use [StringLength] because its message reads better for a text ("must be a string with a maximum length of 200"), and because it can also set a minimum (MinimumLength = 3).
A rule of our own: [MaxDecimalPlaces]
Which built-in rule counts the decimals of a price? None. The way out is to write the attribute ourselves: 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)]
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, and the checker our unit tests use, find every rule by this base class.[AttributeUsage(AttributeTargets.Property)]says this attribute may be put on properties and nowhere else.- The constructor takes the number of places,
[MaxDecimalPlaces(2)], and keeps it. It also 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: we checked that[EmailAddress]accepts a missing value, which is why a box that must be filled in needs[Required]as well.decimal.Round(number, _places) == numberrounds the number to the allowed places and compares it with the original. If rounding changed it, it had extra digits. 9.999 rounds to 10.00, which differs, so it fails. 9.50 stays 9.50, so it passes.
Now use both in BookRequest. Open src/BookShop.Api/Dtos/BookRequest.cs. The whole file is:
using System.ComponentModel.DataAnnotations;
public record BookRequest
{
[Required]
[StringLength(200)] // new
public string Title { get; init; } = "";
[Required]
[StringLength(100)] // new
public string Author { get; init; } = "";
[Range(0.01, 10000)]
[MaxDecimalPlaces(2)] // new
public decimal Price { get; init; }
[Range(0, 100000)]
public int Stock { get; init; }
[Required]
[StringLength(50)] // new
public string Category { get; init; } = "";
}
A property can carry several rules, and all of them run. We did not touch Book, the services or the controller. The controller never checks anything: the rules are checked before its method runs.
The built-in rules even show up in the contract. Open /openapi/v1.json (or the test page), and title now says maxLength: 200 while price says minimum: 0.01 and maximum: 10000. A frontend developer can read the limits from the API itself. Our own attribute is not there, because the document does not know what MaxDecimalPlaces means, so a rule of our own has to be told to the frontend in words.
Run it
Restart the app. Each case on its own, on a copy of the database with five books. Case 1. The price that got in before:
curl -i -X POST http://localhost:5101/books -H "Content-Type: application/json" -d '{"title":"Odd Price","author":"Anon","price":9.999,"stock":1,"category":"Programming"}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Price": [
"The field Price must have at most 2 decimal places."
]
},
"traceId": "00-ceb3f4905387e7053ba6778bcb747bab-ae7245777795b558-00"
}
Our message, with the field name filled in. A PUT with "price":34.555 got the same 400, since both use BookRequest.
Case 2. A price with the cents it is allowed:
curl -i -X POST http://localhost:5101/books -H "Content-Type: application/json" -d '{"title":"Odd Price","author":"Anon","price":9.5,"stock":1,"category":"Programming"}'
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: http://localhost:5101/books/6
{"id":6,"title":"Odd Price","author":"Anon","price":9.5,"stock":1,"category":"Programming"}
Case 3. A title one letter over the limit. The first line builds a text of 201 letters, so you do not have to type them (it works in a bash terminal):
TITLE=$(printf 'x%.0s' {1..201})
curl -i -X POST http://localhost:5101/books -H "Content-Type: application/json" -d "{\"title\":\"$TITLE\",\"author\":\"Anon\",\"price\":9.5,\"stock\":1,\"category\":\"Programming\"}"
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Title": [
"The field Title must be a string with a maximum length of 200."
]
},
"traceId": "00-b485e4103186e5a00e2b1f4f0a2b8688-cb7ff4b3dda2e2f3-00"
}
Delete the test book: DELETE FROM Books WHERE Title = 'Odd Price'; in DB Browser, then Write Changes.
Reading the 400
Everything wrong at once
The API does not stop at the first mistake. It checks every property, and the 400 lists every problem, so that a form can show all of them in one go and not make Ana fix one, send, and meet the next. Case 4. A request with something wrong in almost every box:
curl -i -X POST http://localhost:5101/books -H "Content-Type: application/json" -d '{"title":"Clean Code","author":" ","price":0.001,"stock":-5,"category":""}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Price": [
"The field Price must be between 0.01 and 10000.",
"The field Price must have at most 2 decimal places."
],
"Stock": [
"The field Stock must be between 0 and 100000."
],
"Author": [
"The Author field is required."
],
"Category": [
"The Category field is required."
]
},
"traceId": "00-ea59d401d883d90e07bd84ef5864fecb-527e021a43201537-00"
}
Read it against the request. Title is fine, so it is not listed. Author is a single space and Category is empty, so [Required] rejects both. Stock is -5. Price is 0.001, and it broke two rules at once: below the minimum, and too many decimals. That is why each name in errors holds a list of messages and not one message. Every message is a plain sentence, ready to show beside the box it belongs to.
The names (Price, Stock) are the C# names of the properties, while the book JSON uses price. We tried [JsonPropertyName("title")] on a property to change this, and the key stayed Title. So a frontend matches them by name, ignoring the case of the first letter.
A 400 that did not come from our rules
Case 5. The price is text, not a number:
curl -i -X POST http://localhost:5101/books -H "Content-Type: application/json" -d '{"title":"Clean Code","author":"Robert C. Martin","price":"abc","stock":7,"category":"Programming"}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"request": [
"The request field is required."
],
"$.price": [
"The JSON value could not be converted to System.Decimal. Path: $.price | LineNumber: 0 | BytePositionInLine: 63."
]
},
"traceId": "00-547df3e6a92ba5896797dc14a46ec47d-c7e3ffc16a9503ce-00"
}
Same shape, different cause. Our rules never ran here. Model binding (the first step in the gate from create a book) could not turn the JSON into a BookRequest, because "abc" is not a number, so there was no request object to check. There are now three kinds of key in errors:
| Key | What it means |
|---|---|
Price, Title |
A rule on that property failed. The message is written for people. |
$.price |
The JSON itself could not be read at that place. The path is the one in the body, in the same case as sent. The message talks about .NET (System.Decimal). |
request |
There was no usable request at all. This one comes along with the others. |
A frontend should show the first kind next to its box, and for the other two say something simple such as "something in the form could not be read", and not print the .NET text. Nothing in them is secret, but it is not written for a customer.
Rules across several boxes
So far each rule looks at one box. Some rules need two. The bookshop has no such form yet, so here is a stand-in, only to show the idea: a customer asks for the shop's catalogue by e-mail, and may also ask for a printed copy by post. We put it behind a throwaway endpoint to get real answers, and you do not need to build it. The request is:
using System.ComponentModel.DataAnnotations;
public class CatalogueRequest : IValidatableObject
{
[Required]
[EmailAddress]
public string Email { get; set; } = "";
[Compare(nameof(Email))]
public string ConfirmEmail { get; set; } = "";
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;
}
}
[EmailAddress]checks the general shape of an e-mail address.[Compare(nameof(Email))]onConfirmEmailsays: this box must hold the same as theEmailbox.nameof(Email)is the name"Email"written so that the compiler checks the spelling.IValidatableObjectis an interface (a list of what a class can do, as in dependency injection basics) with one method,Validate. Use it for a rule that no single attribute can say, such as "if the printed copy is ticked, the address is needed". 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 inerrorsisPostalAddress.
Case 6. The e-mail typed twice, but differently:
curl -i -X POST http://localhost:5101/catalogue -H "Content-Type: application/json" -d '{"email":"ana@example.com","confirmEmail":"anna@example.com"}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"ConfirmEmail": [
"'ConfirmEmail' and 'Email' do not match."
]
},
"traceId": "00-9ae83d9ae53a79696b2d2b8b20a7e161-705aa0684e113c86-00"
}
Case 7. A printed copy, with no address:
curl -i -X POST http://localhost:5101/catalogue -H "Content-Type: application/json" -d '{"email":"ana@example.com","confirmEmail":"ana@example.com","wantsPrintedCopy":true}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"PostalAddress": [
"A printed copy needs a postal address."
]
},
"traceId": "00-b63d76dc5203815918bedc72c5013a00-739561f20757053d-00"
}
Case 8. Both mistakes at once:
curl -i -X POST http://localhost:5101/catalogue -H "Content-Type: application/json" -d '{"email":"ana@example.com","confirmEmail":"anna@example.com","wantsPrintedCopy":true}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"ConfirmEmail": [
"'ConfirmEmail' and 'Email' do not match."
]
},
"traceId": "00-4762706911f1493c74316494db210a53-cb385e2cd2f3ddc3-00"
}
Only one problem is listed, and the missing address is not mentioned. This is a small trap. The checks run in an order:
The method of IValidatableObject runs only when every attribute passed. In case 8 the attribute failed, so Validate was skipped, and Ana learns about the second mistake only after fixing the first. That is acceptable, and now you know why it happens. If you need the whole list at once, put the check in an attribute instead.
💡 Both are real choices, not one right way. An attribute is short, reusable and shows in the file.
IValidatableObjectsuits one class with a rule that looks at several of its own properties.
The other built-in rules
Here are the rules you will meet most, each checked with one value that passes and one that fails:
| Rule | Checks | Passes | Fails | Watch out |
|---|---|---|---|---|
[EmailAddress] |
the general shape of an e-mail | ana@example.com |
ana.example.com |
ana@example also passes. Only sending a message proves an address works |
[Url] |
a web address | https://example.com/books |
example.com |
it needs the https:// part |
[Phone] |
something that looks like a phone number | +1 555 010 4477 |
call me |
formats differ by country, so it is loose |
[RegularExpression(@"^\d{5}$")] |
text against a pattern | 90210 |
9021 |
keep patterns short and readable |
[Compare] |
this box equals another | see case 6 | see case 6 | the message names both fields |
A regular expression is a short pattern that describes text (here, "exactly five digits"). It is powerful and 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. Remember too that these rules skip a missing value: an e-mail box that must be filled needs [Required] next to [EmailAddress]. (An empty text "" is not missing, and [EmailAddress] rejects it.)
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, in code instead of attributes. 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.
Test it
The rules
The rules sit on BookRequest, so their tests grow in tests/BookShop.Api.Tests/BookRequestValidationTests.cs. The whole file is below. A // new line above a method marks the whole method:
using System.ComponentModel.DataAnnotations;
namespace BookShop.Api.Tests;
[TestClass]
public class BookRequestValidationTests
{
private static BookRequest ValidRequest()
{
return new BookRequest
{
Title = "Clean Code",
Author = "Robert C. Martin",
Price = 34.50m,
Stock = 7,
Category = "Programming"
};
}
private static List<ValidationResult> Validate(BookRequest request)
{
var results = new List<ValidationResult>();
Validator.TryValidateObject(request, new ValidationContext(request), results, validateAllProperties: true);
return results;
}
[TestMethod]
public void Validate_ValidRequest_HasNoErrors()
{
var results = Validate(ValidRequest());
Assert.IsEmpty(results);
}
[TestMethod]
public void Validate_EmptyTitle_HasAnErrorOnTitle()
{
var request = ValidRequest() with { Title = "" };
var results = Validate(request);
Assert.HasCount(1, results);
Assert.Contains("Title", results[0].MemberNames);
}
[TestMethod]
public void Validate_PriceZero_HasAnErrorOnPrice()
{
var request = ValidRequest() with { Price = 0m };
var results = Validate(request);
Assert.HasCount(1, results);
Assert.Contains("Price", results[0].MemberNames);
}
[TestMethod]
public void Validate_NegativeStock_HasAnErrorOnStock()
{
var request = ValidRequest() with { Stock = -1 };
var results = Validate(request);
Assert.HasCount(1, results);
Assert.Contains("Stock", results[0].MemberNames);
}
// new
[TestMethod]
public void Validate_TitleOfExactlyTheLimit_HasNoErrors()
{
var request = ValidRequest() with { Title = new string('x', 200) };
var results = Validate(request);
Assert.IsEmpty(results);
}
// new
[TestMethod]
public void Validate_TitleOneOverTheLimit_HasAnErrorOnTitle()
{
var request = ValidRequest() with { Title = new string('x', 201) };
var results = Validate(request);
Assert.HasCount(1, results);
Assert.Contains("Title", results[0].MemberNames);
}
// new
[TestMethod]
public void Validate_TitleOfOnlySpaces_HasAnErrorOnTitle()
{
var request = ValidRequest() with { Title = " " };
var results = Validate(request);
Assert.HasCount(1, results);
Assert.Contains("Title", results[0].MemberNames);
}
// new
[TestMethod]
public void Validate_PriceWithThreeDecimals_HasAnErrorOnPrice()
{
var request = ValidRequest() with { Price = 9.999m };
var results = Validate(request);
Assert.HasCount(1, results);
Assert.Contains("Price", results[0].MemberNames);
}
}
Two of the new tests are about a boundary, the edge of a rule: 200 letters pass and 201 fail. Bugs hide at the edges, such as < where <= was meant. The test with spaces writes down the finding from the trap section, so a later change cannot quietly lose it. These tests are why [Required] must be an explicit attribute: the checker here, Validator, reads attributes only and cannot see the automatic rule.
The attribute itself
A rule we wrote is code we own, so it gets its own tests. Create MaxDecimalPlacesAttributeTests.cs in tests/BookShop.Api.Tests/. The whole file is:
namespace BookShop.Api.Tests;
[TestClass]
public class MaxDecimalPlacesAttributeTests
{
private readonly MaxDecimalPlacesAttribute _rule = new(2);
[TestMethod]
public void IsValid_TwoDecimals_ReturnsTrue()
{
Assert.IsTrue(_rule.IsValid(9.99m));
}
[TestMethod]
public void IsValid_WholeNumber_ReturnsTrue()
{
Assert.IsTrue(_rule.IsValid(10m));
}
[TestMethod]
public void IsValid_ThreeDecimals_ReturnsFalse()
{
Assert.IsFalse(_rule.IsValid(9.999m));
}
[TestMethod]
public void IsValid_NotANumber_ReturnsTrue()
{
Assert.IsTrue(_rule.IsValid(null));
Assert.IsTrue(_rule.IsValid("abc"));
}
[TestMethod]
public void FormatErrorMessage_NamesTheFieldAndTheLimit()
{
var message = _rule.FormatErrorMessage("Price");
Assert.AreEqual("The field Price must have at most 2 decimal places.", message);
}
}
The new(2) is the target-typed new() from records and small C# shortcuts: the type is known from the left, so it is not repeated. The fourth test says what the rule does not do: it leaves a missing value or a non-number to other rules.
Run all tests from the solution folder:
dotnet test
Passed! - Failed: 0, Passed: 33, Skipped: 0, Total: 33, Duration: 363 ms - BookShop.Api.Tests.dll (net10.0)
Passed! - Failed: 0, Passed: 19, Skipped: 0, Total: 19, Duration: 2 s - BookShop.Data.Tests.dll (net10.0)
Fifty-two tests. Nine are new in this lesson (four on the request, five on the attribute), so the API tests went from 24 to 33. The durations may differ.
Where we ended
Compare with your solution. Only the files that changed or were added are listed:
BookShop/
src/
BookShop.Api/
Dtos/
BookRequest.cs the rules, now with lengths and decimal places
MaxDecimalPlacesAttribute.cs a rule of our own
tests/
BookShop.Api.Tests/
BookRequestValidationTests.cs eight tests: required, range, length, spaces, decimals
MaxDecimalPlacesAttributeTests.cs five tests on the attribute
Recap
- A bad value can be stopped in three places: the web page (a kindness that anyone can skip), the API (the check we rely on) and the database (the last line, such as
NOT NULL). The API's rules live on the request. - A text or number of the right type can still be wrong in size or precision.
[StringLength]limits length, and[Range]limits a number. - A default value can hide a missing value. With
= "", a title that is left out becomes an empty text. The automatic check catches onlynull, so[Required]stays: it rejects missing, empty and spaces-only text, and the unit tests can see it. - 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]. - The
400lists every broken rule at once, each under the name of its property, with a list of messages. A request that cannot be read gives other keys ($.price,request). - A rule across boxes uses
[Compare]orIValidatableObject. The method ofIValidatableObjectruns only after every attribute passed. - Built-in rules skip a missing value. Pair them with
[Required]when the box is mandatory. - Test a rule on both sides of its edge (200 passes, 201 fails), and test a rule you wrote yourself.
Questions to think about
Each question has its answer folded away underneath. Guess first, then open it to check.
- The web page already checks the form. Why must the API check it again?
Show answer
Anyone can send a request without our page, so the page's check can be skipped. Only the API's check always runs.
- Why is
[Required]still onTitle, when the property starts as""and the compiler already knows it should not be missing?
Show answer
The starting value "" is a value, so a title that is left out becomes an empty text and passes the automatic check. [Required] is what rejects empty and spaces-only text.
- When do you write a validation attribute yourself?
Show answer
When the shop has a rule that no built-in attribute states, such as a price with at most two decimal places.
- Why does the
400list all the errors at once and not only the first?
Show answer
So the person can fix every mistake in one go and not meet them one by one.
- The API now refuses bad requests with clear messages. What would a person need to see all this working in a browser? (A later lesson answers this.)
Show answer
A web page that calls the books endpoint and shows the list, and later a form that shows these messages.
Next lesson: the first web page, which calls the books endpoint and shows the list.