Tutorials / Build a REST API with C# and .NET · Lesson 27 of 27
On this page
DTOs for the Books Endpoints
A customer fills in an order form at the bookshop counter. The form has boxes for the title, the author, the price, the stock and the category. It has no box for the book's number, because the shop gives each book a number itself. Imagine a form that did have a box for it, and a customer who wrote "9" in that box. That is what our API allows today.
Our goal: give the books endpoints classes made for what comes in and what goes out, so a client can no longer choose a book's id, and the table can change without changing the API. The classes are records, from records and small C# shortcuts.
Here is the route:
- Send the attack from create a book again, and see it still works.
- Picture three shapes around one table: what comes in, what is stored, what goes out.
- Write the three small classes, and meet one trap on the way.
- Copy between them by hand, in one small class of its own.
- Change the controller, and take the rules off
Book. - Send the attack again, and test the change.
This lesson leaves out libraries that copy between classes for you (we name them and do not use them), changes to the services, and the screens that will use these endpoints.
Where we are
This is the solution from the end of records and small C# shortcuts. The classes go in a new folder in the API project, and the rules move from Book to the request:
BookShop/
BookShop.slnx
Directory.Build.props
Directory.Packages.props
src/
BookShop.Api/
Program.cs
GlobalExceptionHandler.cs
Controllers/
BooksController.cs <- changes (maps, takes and returns the new classes)
ReportsController.cs
Dtos/ <- NEW folder
BookRequest.cs <- NEW
BookResponse.cs <- NEW
BookMapping.cs <- NEW
BookShop.Data/
Book.cs <- changes (the rules leave)
BookShopDbContext.cs
IBookService.cs
BookService.cs
SellResult.cs
CategoryStock.cs
IStockReportService.cs
StockReportService.cs
Migrations/
tests/
BookShop.Data.Tests/
BookServiceTests.cs
BookValidationTests.cs <- DELETED (moves to the API tests, renamed)
StockReportServiceTests.cs
BookShop.Api.Tests/
FakeBookService.cs
FakeLogger.cs
BooksControllerTests.cs <- changes (builds requests, reads responses)
GlobalExceptionHandlerTests.cs
FakeStockReportService.cs
ReportsControllerTests.cs
BookRequestValidationTests.cs <- NEW (the rules' tests, moved)
BookMappingTests.cs <- NEW
Run every command from the solution folder, BookShop/. No project is added, so there is no solution map. The services and their tests do not change at all.
The attack, again
Create a book showed over-posting: the request carries more than a client should be allowed to set. Send the same two requests to the API as it is today (on a copy of the database, so your data stays as it is). First, a client who chooses the id and the stock:
curl -i -X POST http://localhost:5101/books -H "Content-Type: application/json" -d '{"id":9,"title":"Sneaky","author":"Anon","price":9.50,"stock":9999,"category":"Programming"}'
HTTP/1.1 201 Created
{"id":9,"title":"Sneaky","author":"Anon","price":9.50,"stock":9999,"category":"Programming"}
The client chose the id, and the API obeyed. Second, a client who picks an id that is already taken:
curl -i -X POST http://localhost:5101/books -H "Content-Type: application/json" -d '{"id":1,"title":"Sneaky","author":"Anon","price":9.50,"stock":1,"category":"Programming"}'
HTTP/1.1 500 Internal Server Error
The database said no (the key exists), and the client's own mistake came back as a failure of the server.
There is a second problem, the other way round. The answers are Book objects, the very class of the table. Suppose next month the owner adds a column for what the shop paid for each book, the cost price. The day it is added, it appears in every JSON the API sends, including to customers. (This is a picture of what would happen. We did not add the column.) The shape of the table has leaked into the API, and no one chose that.
Three shapes around one table
The fix is the form at the counter. The customer fills in a form made for filling in, the shop keeps a ledger with its own columns, and what goes back to the customer is made for reading.
These classes are DTOs (data transfer objects), as the report was in the stock report. Until now the report was the only one. From here the books use them too: a request class for what a client sends, a response class for what the API sends back, and the Book class for the table only.
Why bother, and where do we stop?
The honest question: it worked, so why add three classes? Because the two problems above are real, and the cost is small today and larger later. Every new column is a decision about whether to show it, and now that decision is a line of code and not an accident. Every new field a client may send is a decision too. We stop at the books, because that is where the problems are. We do not add classes for things nobody sends or reads.
The three classes
All three go in a new folder, Dtos/, in src/BookShop.Api/. They belong to the API, since they describe the API's conversation with its clients. The services still speak Book, so the data project never needs to know them. Like the controllers, they are in the global namespace.
The response
Create BookResponse.cs. The whole file is:
public record BookResponse(int Id, string Title, string Author, decimal Price, int Stock, string Category);
It is a record of the kind the last lesson showed: built once, never changed. It lists the six values a client gets, and today they are the same six as Book. Later, a new column of Book does not appear here until someone adds it on purpose.
The request
The client sends five values. It does not send an id. Create BookRequest.cs. The whole file is:
using System.ComponentModel.DataAnnotations;
public record BookRequest
{
[Required]
public string Title { get; init; } = "";
[Required]
public string Author { get; init; } = "";
[Range(0.01, 10000)]
public decimal Price { get; init; }
[Range(0, 100000)]
public int Stock { get; init; }
[Required]
public string Category { get; init; } = "";
}
The rules are the ones from Book, moved here, because they are about what a client may send and not about the table. The text properties start as "" for the reason given when creating a book: the empty text is exactly what [Required] turns away.
This record is written with its properties inside, and not on one line like the response. That is a deliberate choice, and we learned it the hard way. The first try was the short form:
public record BookRequest(
[property: Required] string Title,
[property: Required] string Author,
[property: Range(0.01, 10000)] decimal Price,
[property: Range(0, 100000)] int Stock,
[property: Required] string Category);
It built, and every POST answered with a 500. The log said why:
System.InvalidOperationException: Record type 'BookRequest' has validation metadata defined on property 'Category' that will be ignored. 'Category' is a parameter in the record primary constructor and validation metadata must be associated with the constructor parameter.
The web framework wants the rules on the constructor's parameters in the short form. That works too, but the rule-checking class that our unit tests use only looks at properties. Putting the properties inside the record keeps both happy, and it reads like the Book class the reader already knows. (The init after each name means "set once, when the object is built", as for every record.)
One request class, not two
Many teams write two: CreateBookRequest and UpdateBookRequest. For our books, POST and PUT take exactly the same five values, because PUT replaces the whole book. Two identical classes would be copied code. So we use one, BookRequest, for both. The day the two need to differ (say, an update may no longer change the stock), split it then. It is a choice, and both are normal.
Copying between them
Something has to turn a Book into a BookResponse, and a BookRequest into a Book for the service. We write it by hand, in one small class. Create BookMapping.cs in Dtos/. The whole file is:
using BookShop.Data;
public static class BookMapping
{
public static BookResponse ToResponse(this Book book)
{
return new BookResponse(book.Id, book.Title, book.Author, book.Price, book.Stock, book.Category);
}
public static Book ToBook(this BookRequest request)
{
return new Book
{
Title = request.Title,
Author = request.Author,
Price = request.Price,
Stock = request.Stock,
Category = request.Category
};
}
}
ToResponsecopies the six values of a book into a response.ToBookcopies the five values of a request into a newBook. It does not setId, so the id stays 0, and the database gives the real one when the book is saved. That one omission is the whole fix for the attack.thisbefore the first parameter makes each method an extension method: a method you call as if it belonged to the type,book.ToResponse()andrequest.ToBook(). The.Whereand.Selectof LINQ work the same way. The class must bestaticfor that.
Why by hand? Libraries such as AutoMapper and Mapster copy between classes by matching names, and plenty of bigger projects use them. We do not. Two short methods cost nothing, and the copying stays visible: when a column is added to Book, someone must come here and decide whether it goes out. That is the point of this lesson, and a library that copies everything with a matching name would quietly undo it.
The controller
Open src/BookShop.Api/Controllers/BooksController.cs. The whole file is:
using System.ComponentModel.DataAnnotations;
using BookShop.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
[ApiController]
[Route("books")]
public class BooksController : ControllerBase
{
private readonly IBookService _bookService;
public BooksController(IBookService bookService)
{
_bookService = bookService;
}
[HttpGet]
public async Task<ActionResult<List<BookResponse>>> GetAll() // new
{
var books = await _bookService.GetAllAsync();
return books.Select(b => b.ToResponse()).ToList(); // new
// return books; // replaced
}
[HttpGet("{id}")]
[ProducesResponseType<BookResponse>(StatusCodes.Status200OK)] // new
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<BookResponse>> GetById(int id) // new
{
var book = await _bookService.GetByIdAsync(id);
if (book == null)
{
return NotFound();
}
return book.ToResponse(); // new
// return book; // replaced
}
[HttpPost]
[ProducesResponseType<BookResponse>(StatusCodes.Status201Created)] // new
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<BookResponse>> Create(BookRequest request) // new
{
var created = await _bookService.CreateAsync(request.ToBook()); // new
return CreatedAtAction(nameof(GetById), new { id = created.Id }, created.ToResponse()); // new
// var created = await _bookService.CreateAsync(book); // replaced
// return CreatedAtAction(nameof(GetById), new { id = created.Id }, created); // replaced
}
[HttpPut("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Update(int id, BookRequest request) // new
{
var found = await _bookService.UpdateAsync(id, request.ToBook()); // new
// var found = await _bookService.UpdateAsync(id, book); // replaced
if (!found)
{
return NotFound();
}
return NoContent();
}
[HttpDelete("{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Delete(int id)
{
var found = await _bookService.DeleteAsync(id);
if (!found)
{
return NotFound();
}
return NoContent();
}
[HttpPost("{id}/sell")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Sell(int id, [BindRequired, Range(1, 1000)] int quantity)
{
var result = await _bookService.SellAsync(id, quantity);
if (result.Book == null)
{
return NotFound();
}
if (!result.Sold)
{
return Problem(
statusCode: StatusCodes.Status409Conflict,
title: "Not enough copies in stock.",
detail: $"Copies left of {result.Book.Title}: {result.Book.Stock}. You asked for {quantity}.",
type: "https://bookshop.example/errors/insufficient-stock");
}
return NoContent();
}
}
The commented-out lines are temporary, as before: delete them once you have seen where the code went. What changed:
- The signatures now name the new classes:
CreateandUpdatetake aBookRequest, and the three read and create methods answer with aBookResponse. The[ProducesResponseType]lines say the same, so the OpenAPI document describes the right shape. - The controller does the copying. It sits at the edge, where HTTP meets our code, so it is the one place that knows both shapes.
request.ToBook()goes in,book.ToResponse()comes out. GetAllhas to copy every book:books.Select(b => b.ToResponse()).ToList().Selectis the same one as in the report query, now run on the list in memory: for each book, make a response.- The services did not change.
CreateAsync(Book)andUpdateAsync(int, Book)still take aBook. The sale did not change either, since it never took a book from the client. DeleteandSelldid not change, because they never carried a book in or out.
The table class, clean
The rules left Book. It is a plain description of the table again. Open src/BookShop.Data/Book.cs. The whole file is:
// using System.ComponentModel.DataAnnotations; // moved to BookRequest
namespace BookShop.Data;
public class Book
{
public int Id { get; set; }
// [Required] // moved to BookRequest
public string Title { get; set; } = "";
// [Required] // moved to BookRequest
public string Author { get; set; } = "";
// [Range(0.01, 10000)] // moved to BookRequest
public decimal Price { get; set; }
// [Range(0, 100000)] // moved to BookRequest
public int Stock { get; set; }
// [Required] // moved to BookRequest
public string Category { get; set; } = "";
}
The comments show what went where, and they are temporary. The clean file is the same without them. (We checked that the table did not notice: dotnet ef migrations has-pending-model-changes answered "No changes have been made to the model since the last migration.")
⚠️ A thing to remember from now on.
Bookno longer protects anyone: a book built in code, such as in a nightly job, is not checked by the rules. The rules sit where data enters from outside, on the request. That is where a stranger's data should be checked, and it is also why the rules live in the API project and not in the data project.
Run it
Start the app and send the attacks again. Each case on its own, on a copy of the database with five books, so the new id follows the five (your ids may differ).
Case 1. The client chooses the id and the stock:
curl -i -X POST http://localhost:5101/books -H "Content-Type: application/json" -d '{"id":9,"title":"Sneaky","author":"Anon","price":9.50,"stock":9999,"category":"Programming"}'
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: http://localhost:5101/books/6
{"id":6,"title":"Sneaky","author":"Anon","price":9.50,"stock":9999,"category":"Programming"}
The id of 9 was ignored. The request class has no Id, so the 9 had nowhere to go, and the database chose the next number. The stock of 9999 was accepted. That is not a leak: the order form has a stock box, on purpose, because a shop must say how many copies it has. Whether a client may set it is a decision for the shop, and the request class is now the place where it is written down.
Case 2. The client picks an id that is already taken. Before, this was a 500:
curl -i -X POST http://localhost:5101/books -H "Content-Type: application/json" -d '{"id":1,"title":"Sneaky","author":"Anon","price":9.50,"stock":1,"category":"Programming"}'
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: http://localhost:5101/books/7
{"id":7,"title":"Sneaky","author":"Anon","price":9.50,"stock":1,"category":"Programming"}
A new book with the next id. The client's id is ignored again, and so there is nothing for the database to refuse.
Case 3. A body with nothing in it. The rules are the same as before, now on the request:
curl -i -X POST http://localhost:5101/books -H "Content-Type: application/json" -d '{}'
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."
],
"Title": [
"The Title field is required."
],
"Author": [
"The Author field is required."
],
"Category": [
"The Category field is required."
]
},
"traceId": "00-0ddaf12b06d80d4fad5716e0b88dd7ee-c7cc7da9ef077dca-00"
}
(The real body is one long line.) Same names, same messages as before the change: the names in errors are the C# names of the request class, Title and Price. Stock is not listed, because a missing stock is 0, and 0 is allowed.
Case 4. A PUT with an id in the body that does not match the address:
curl -i -X PUT http://localhost:5101/books/2 -H "Content-Type: application/json" -d '{"id":99,"title":"Clean Code","author":"Robert C. Martin","price":29.90,"stock":20,"category":"Programming"}'
HTTP/1.1 204 No Content
curl http://localhost:5101/books/2
{"id":2,"title":"Clean Code","author":"Robert C. Martin","price":29.9,"stock":20,"category":"Programming"}
Book 2 was updated, and its id is still 2. The 99 was ignored here too, and not only by a line in the service as before: the request cannot even carry it.
Case 5. Reading did not change. One book, and then all of them: the JSON is the same as before the lesson, so a frontend written against the old API would not notice.
curl http://localhost:5101/books/1
{"id":1,"title":"The Pragmatic Programmer","author":"Andrew Hunt","price":39.9,"stock":12,"category":"Programming"}
(A PUT with only {"price":10} is turned away with a 400 for Title, Author and Category, as before.)
Put the books back for later lessons. In DB Browser, on the Execute SQL tab, run DELETE FROM Books WHERE Title = 'Sneaky'; and then UPDATE Books SET Price = 34.50, Stock = 7 WHERE Title = 'Clean Code';, and press Write Changes.
Test it
The rules move house
The rules now sit on BookRequest, so their tests move to the API tests. In tests/BookShop.Data.Tests/, delete BookValidationTests.cs. Create BookRequestValidationTests.cs in tests/BookShop.Api.Tests/. The whole file is:
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);
}
}
The four tests are the old ones. One difference is a gift of records: to break one value, they use with from the last lesson, which copies the valid request and changes one thing. The old tests had to set a property on a shared object.
The mapping
Create BookMappingTests.cs in tests/BookShop.Api.Tests/. The whole file is:
using BookShop.Data;
namespace BookShop.Api.Tests;
[TestClass]
public class BookMappingTests
{
[TestMethod]
public void ToResponse_Book_CopiesEveryField()
{
var book = new Book { Id = 7, Title = "Clean Code", Author = "Robert C. Martin", Price = 34.50m, Stock = 7, Category = "Programming" };
var response = book.ToResponse();
Assert.AreEqual(new BookResponse(7, "Clean Code", "Robert C. Martin", 34.50m, 7, "Programming"), response);
}
[TestMethod]
public void ToBook_Request_CopiesTheFieldsAndLeavesTheIdUnset()
{
var request = new BookRequest { Title = "Clean Code", Author = "Robert C. Martin", Price = 34.50m, Stock = 7, Category = "Programming" };
var book = request.ToBook();
Assert.AreEqual(0, book.Id);
Assert.AreEqual("Clean Code", book.Title);
Assert.AreEqual("Robert C. Martin", book.Author);
Assert.AreEqual(34.50m, book.Price);
Assert.AreEqual(7, book.Stock);
Assert.AreEqual("Programming", book.Category);
}
}
The first test compares two records as a whole: a record is equal to another when every value matches, as the last lesson showed. The second test is the over-posting fix in a unit test: the id of a book built from a request is 0, whatever a client wanted.
The controller
The controller's tests build requests and read responses now. Open tests/BookShop.Api.Tests/BooksControllerTests.cs. The whole file is:
using BookShop.Data;
using Microsoft.AspNetCore.Mvc;
namespace BookShop.Api.Tests;
[TestClass]
public class BooksControllerTests
{
private FakeBookService _fakeService = null!;
private BooksController _controller = null!;
[TestInitialize]
public void Setup()
{
_fakeService = new FakeBookService();
_controller = new BooksController(_fakeService);
}
[TestMethod]
public async Task GetAll_TwoBooks_ReturnsBoth()
{
_fakeService.Books.Add(new Book { Id = 1, Title = "Clean Code" });
_fakeService.Books.Add(new Book { Id = 2, Title = "Refactoring" });
var result = await _controller.GetAll();
Assert.IsNotNull(result.Value);
Assert.HasCount(2, result.Value);
}
[TestMethod]
public async Task GetById_ExistingBook_ReturnsIt()
{
_fakeService.Books.Add(new Book { Id = 7, Title = "Clean Code" });
var result = await _controller.GetById(7);
Assert.IsNotNull(result.Value);
Assert.AreEqual("Clean Code", result.Value.Title);
}
[TestMethod]
public async Task GetById_UnknownId_ReturnsNotFound()
{
var result = await _controller.GetById(999);
Assert.IsInstanceOfType<NotFoundResult>(result.Result);
}
[TestMethod]
public async Task Create_ValidBook_ReturnsCreatedPointingAtTheNewBook()
{
var result = await _controller.Create(new BookRequest { Title = "Clean Code" }); // new
// var result = await _controller.Create(new Book { Title = "Clean Code" }); // replaced
var created = result.Result as CreatedAtActionResult;
Assert.IsNotNull(created);
Assert.AreEqual("GetById", created.ActionName);
Assert.AreEqual(1, created.RouteValues!["id"]);
var book = created.Value as BookResponse; // new
// var book = created.Value as Book; // replaced
Assert.IsNotNull(book);
Assert.AreEqual("Clean Code", book.Title);
}
[TestMethod]
public async Task Create_ValidBook_PassesItToTheService()
{
await _controller.Create(new BookRequest { Title = "Clean Code" }); // new
// await _controller.Create(new Book { Title = "Clean Code" }); // replaced
Assert.HasCount(1, _fakeService.Books);
Assert.AreEqual("Clean Code", _fakeService.Books[0].Title);
}
[TestMethod]
public async Task Update_ExistingBook_ReturnsNoContent()
{
_fakeService.Books.Add(new Book { Id = 3, Title = "Clean Code" });
var result = await _controller.Update(3, new BookRequest { Title = "Clean Code, 2nd edition" }); // new
// var result = await _controller.Update(3, new Book { Title = "Clean Code, 2nd edition" }); // replaced
Assert.IsInstanceOfType<NoContentResult>(result);
}
[TestMethod]
public async Task Update_ExistingBook_PassesTheNewValuesToTheService()
{
_fakeService.Books.Add(new Book { Id = 3, Title = "Clean Code" });
await _controller.Update(3, new BookRequest { Title = "Clean Code, 2nd edition" }); // new
// await _controller.Update(3, new Book { Title = "Clean Code, 2nd edition" }); // replaced
Assert.AreEqual("Clean Code, 2nd edition", _fakeService.Books[0].Title);
}
[TestMethod]
public async Task Update_UnknownId_ReturnsNotFound()
{
var result = await _controller.Update(999, new BookRequest { Title = "Clean Code" }); // new
// var result = await _controller.Update(999, new Book { Title = "Clean Code" }); // replaced
Assert.IsInstanceOfType<NotFoundResult>(result);
}
[TestMethod]
public async Task Delete_ExistingBook_ReturnsNoContentAndRemovesIt()
{
_fakeService.Books.Add(new Book { Id = 3, Title = "Clean Code" });
var result = await _controller.Delete(3);
Assert.IsInstanceOfType<NoContentResult>(result);
Assert.IsEmpty(_fakeService.Books);
}
[TestMethod]
public async Task Delete_UnknownId_ReturnsNotFound()
{
var result = await _controller.Delete(999);
Assert.IsInstanceOfType<NotFoundResult>(result);
}
[TestMethod]
public async Task Sell_EnoughCopies_ReturnsNoContent()
{
_fakeService.SellResult = new SellResult { Book = new Book { Title = "Refactoring", Stock = 1 }, Sold = true };
var result = await _controller.Sell(3, 2);
Assert.IsInstanceOfType<NoContentResult>(result);
}
[TestMethod]
public async Task Sell_NotEnoughCopies_ReturnsConflictWithAMeaningfulProblem()
{
_fakeService.SellResult = new SellResult { Book = new Book { Title = "Refactoring", Stock = 3 } };
var result = await _controller.Sell(3, 5);
var answer = result as ObjectResult;
Assert.IsNotNull(answer);
Assert.AreEqual(409, answer.StatusCode);
var problem = answer.Value as ProblemDetails;
Assert.IsNotNull(problem);
Assert.AreEqual("https://bookshop.example/errors/insufficient-stock", problem.Type);
Assert.Contains("Refactoring", problem.Detail!);
Assert.Contains("3", problem.Detail!);
Assert.Contains("5", problem.Detail!);
}
[TestMethod]
public async Task Sell_UnknownBook_ReturnsNotFound()
{
_fakeService.SellResult = new SellResult();
var result = await _controller.Sell(999, 1);
Assert.IsInstanceOfType<NotFoundResult>(result);
}
}
The fake service did not change: it still holds Book objects, because the service still speaks Book. The test still reads result.Value.Title of a BookResponse with no change, because the names are the same.
Run all tests from the solution folder:
dotnet test
Passed! - Failed: 0, Passed: 24, Skipped: 0, Total: 24, Duration: 204 ms - BookShop.Api.Tests.dll (net10.0)
Passed! - Failed: 0, Passed: 19, Skipped: 0, Total: 19, Duration: 1 s - BookShop.Data.Tests.dll (net10.0)
Forty-three tests. (The four rules tests moved from the second line to the first, and two mapping tests are new. The durations may differ.) The over-posting itself, the id in a JSON body, cannot be seen by a unit test, since the test builds the C# object directly. It was checked by hand above, and a later lesson adds a test that sends real HTTP.
In the test page
Open /scalar. POST /books now shows a body made of five boxes, with title, author and category marked required, and no id. The answers of GET /books list BookResponse. And the page no longer mentions Book at all: the table class has left the contract.
Where we ended
Compare with your solution. The books have their own request and response, and the table class is only a table:
BookShop/
BookShop.slnx
Directory.Build.props
Directory.Packages.props
src/
BookShop.Api/
Program.cs
GlobalExceptionHandler.cs
Controllers/
BooksController.cs takes BookRequest, answers BookResponse
ReportsController.cs
Dtos/
BookRequest.cs what a client may send, with the rules
BookResponse.cs what the API sends back
BookMapping.cs the copying, in two methods
BookShop.Data/
Book.cs only the table, no rules
BookShopDbContext.cs
IBookService.cs
BookService.cs
SellResult.cs
CategoryStock.cs
IStockReportService.cs
StockReportService.cs
Migrations/
tests/
BookShop.Data.Tests/
BookServiceTests.cs
StockReportServiceTests.cs
BookShop.Api.Tests/
FakeBookService.cs
FakeLogger.cs
BooksControllerTests.cs
GlobalExceptionHandlerTests.cs
FakeStockReportService.cs
ReportsControllerTests.cs
BookRequestValidationTests.cs the rules' tests, moved
BookMappingTests.cs the copying, tested
Recap
- Over-posting is a client setting what it should not, because the request class has every column. Leaking is the other side: every new column appears in the answers at once.
- The fix is three shapes: a request (what a client may send), the entity (the table) and a response (what is sent back). Each is its own small class.
BookResponseis a record.BookRequestis a record with properties inside, so the rules and their tests keep working. The short form with rules on its parameters made everyPOSTfail with a500.- The request has no
Id, so a client's id has nowhere to go. The same request that gave a201withid 9, and a500forid 1, now gives a normal201with the next id. - One request class serves
POSTandPUT, since both carry the same five values. Split it when they differ. - The mapping is two short methods written by hand. A new column on the table is a visible decision, and not an accident.
- The controller does the copying, at the edge. The services still take and return
Book. - The rules left
Book, and sit on the request, where outside data comes in.
Questions to think about
These have no answers here, on purpose. Try them from memory first.
- What goes wrong when the request class is the same class as the table?
- Why does the request have no
Id? - Why does the controller do the copying, and not the service?
- Why is it a good thing that adding a column to
Bookno longer changes the API by itself? - The API is finished. What would a person need to see it work in a browser? (A later lesson answers this.)
Next lesson: the first web page, which calls the books endpoint and shows the list.