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:

    1. Picture the three places where a bad value can be stopped.
    2. Find the holes that are still open in the books endpoint.
    3. See why [Required] is still needed, with a trap that is easy to miss.
    4. Close the holes: a length limit, and one rule we write ourselves.
    5. Read the 400 closely, including the answers that do not come from our rules.
    6. Look at rules that compare two boxes, and at the other built-in rules.
    7. 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:

    Three checkpoints for one request The web page 1. a kindness "title is empty" can be skipped The API 2. the rules on BookRequest cannot be skipped The database 3. the last line NOT NULL cannot be skipped Only the API and the database are ours to rely on.
    • 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 Books table 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;
        }
    }
    
    • : ValidationAttribute makes 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.
    • IsValid is the rule. It answers true for "fine" and false for "not fine".
    • value is not decimal number means: if the value is not a number, or is missing, say true. 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) == number rounds 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))] on ConfirmEmail 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.
    • IValidatableObject is 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 in errors is PostalAddress.

    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 order of the checks Rules on boxes attributes Rule on the form Validate() Method runs or a 400 all pass pass A failed box rule means Validate() never runs

    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. IValidatableObject suits 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 only null, 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 ValidationAttribute and overrides IsValid. It judges only its own job, and leaves a missing value to [Required].
    • The 400 lists 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] or IValidatableObject. The method of IValidatableObject runs 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.

    1. 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.

    1. Why is [Required] still on Title, 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.

    1. 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.

    1. Why does the 400 list 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.

    1. 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.

    ← DTOs for the Books Endpoints