Tutorials / Build a REST API with C# and .NET · Lesson 22 of 27
On this page
Create a Book: POST
So far the API only reads. This lesson lets a client send a new book and have it saved. It is the first endpoint that receives data, and anything that receives data from outside has to be treated with suspicion.
Our goal: POST /books saves a book from a JSON body and answers 201 Created. A bad body is turned away with a 400 that lists what was wrong. We write the service method, the endpoint and the validation rules, then test them and try them by hand.
Here is the route:
- Add
CreateAsyncto the service, and to the hand-written fake. - Add the endpoint, and send our first book with
curl. - See what the bare endpoint lets in (a blank book), and why a plain version is not enough.
- Add validation with attributes, and see the
400with the list of errors. - Tell the API's description about the new answers.
- See a weakness that remains, over-posting, and why we leave it for a later lesson.
- Test the service, the rules and the controller, and check by hand.
This lesson leaves out changing and deleting books (update and delete), the clean fix for over-posting (a later lesson), and rules that need the database, such as "no two books with the same title".
Where we are
This is the solution from the end of errors the professional way. We touch these files:
BookShop/
BookShop.slnx
Directory.Build.props
Directory.Packages.props
src/
BookShop.Api/
Program.cs
GlobalExceptionHandler.cs
Controllers/
BooksController.cs <- changes (a new endpoint)
BookShop.Data/
Book.cs <- changes (validation rules)
BookShopDbContext.cs
IBookService.cs <- changes (a new method)
BookService.cs <- changes (the new method)
Migrations/
tests/
BookShop.Data.Tests/
BookServiceTests.cs <- changes (two new tests)
BookValidationTests.cs <- NEW
BookShop.Api.Tests/
FakeBookService.cs <- changes (the new method)
FakeLogger.cs
BooksControllerTests.cs <- changes (two new tests)
GlobalExceptionHandlerTests.cs
Run every command from the solution folder, BookShop/. No project is added, so there is no solution map.
What a POST looks like
HTTP and REST concepts gave each verb a meaning: GET reads, POST creates. A client that wants to add a book sends a POST to the address of the collection, /books, and puts the new book in the body as JSON. The API saves it and answers with three things:
201 Created, not plain200 OK. Both mean success, and201is more exact: "something new now exists".- A
Locationheader: the address of the new thing, for example/books/4. The client does not have to guess where the book lives. It can follow the address. - The new book in the body, as the database now has it. The client sent no
Id. The answer shows the one the database chose.
The service method
The pattern is the one from the books service: the work goes in the service, the HTTP decisions go in the controller. Open src/BookShop.Data/IBookService.cs. The whole file is:
namespace BookShop.Data;
public interface IBookService
{
Task<List<Book>> GetAllAsync();
Task<Book?> GetByIdAsync(int id);
Task<Book> CreateAsync(Book book); // new
}
CreateAsync takes the book to save and gives back the saved book. There is no ? on Book, because creating never answers "no such book". Why give the book back? The database fills in the Id while saving, and the caller needs it. Open src/BookShop.Data/BookService.cs. The whole file is:
using Microsoft.EntityFrameworkCore;
namespace BookShop.Data;
public class BookService : IBookService
{
private readonly BookShopDbContext _context;
public BookService(BookShopDbContext context)
{
_context = context;
}
public async Task<List<Book>> GetAllAsync()
{
return await _context.Books.OrderBy(b => b.Title).ToListAsync();
}
public async Task<Book?> GetByIdAsync(int id)
{
return await _context.Books.FindAsync(id);
}
public async Task<Book> CreateAsync(Book book) // new
{ // new
_context.Books.Add(book); // new
await _context.SaveChangesAsync(); // new
return book; // new
} // new
}
_context.Books.Add(book)tells EF Core "this object is a new row". Nothing has gone to the database yet. It is like writing a line on a pad.SaveChangesAsync()is the moment EF Core sends theINSERT(it waits without blocking, as in async and await). If it succeeds, EF Core copies the newIdback into the samebookobject.return bookgives the caller that same object, now with itsId.
The build now fails, in the test project. The fake from reading books promised to be an IBookService, and the promise has grown:
error CS0535: 'FakeBookService' does not implement interface member 'IBookService.CreateAsync(Book)'
That is the interface doing its job: the compiler will not let a stand-in drift away from the real thing. Open tests/BookShop.Api.Tests/FakeBookService.cs. The whole file is:
using BookShop.Data;
namespace BookShop.Api.Tests;
public class FakeBookService : IBookService
{
public List<Book> Books { get; } = new List<Book>();
public Task<List<Book>> GetAllAsync()
{
return Task.FromResult(Books);
}
public Task<Book?> GetByIdAsync(int id)
{
var book = Books.FirstOrDefault(b => b.Id == id);
return Task.FromResult(book);
}
public Task<Book> CreateAsync(Book book) // new
{ // new
book.Id = Books.Count + 1; // new
Books.Add(book); // new
return Task.FromResult(book); // new
} // new
}
The fake does what the real service does, in miniature: it gives the book an Id and keeps it in the list.
The endpoint
Open src/BookShop.Api/Controllers/BooksController.cs. The whole file is:
using BookShop.Data;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("books")]
public class BooksController : ControllerBase
{
private readonly IBookService _bookService;
public BooksController(IBookService bookService)
{
_bookService = bookService;
}
[HttpGet]
public async Task<ActionResult<List<Book>>> GetAll()
{
var books = await _bookService.GetAllAsync();
return books;
}
[HttpGet("{id}")]
[ProducesResponseType<Book>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Book>> GetById(int id)
{
var book = await _bookService.GetByIdAsync(id);
if (book == null)
{
return NotFound();
}
return book;
}
[HttpPost] // new
public async Task<ActionResult<Book>> Create(Book book) // new
{ // new
var created = await _bookService.CreateAsync(book); // new
return CreatedAtAction(nameof(GetById), new { id = created.Id }, created); // new
} // new
}
[HttpPost]says this method answersPOST /books. It has no address of its own, so it uses the class's[Route("books")], likeGetAll.Create(Book book): the parameter is a wholeBook. When reading books,int idcame from the address. A parameter that is a class comes from the body, and[ApiController]decides that on its own. This is model binding again: ASP.NET reads the JSON and builds aBookfrom it, matching property names (not caring about upper or lower case).CreatedAtAction(...)builds the201. It takes three things: the name of the action that finds one book (nameof(GetById)is the text"GetById", with the compiler checking the spelling), the values to put in its address (id), and the body to send back. From the first two it works outLocation: /books/4. Writing the address by hand as text would break the day someone changes a route. This way theLocationalways followsGetById.
Send the first book
Start the app (dotnet run --project src/BookShop.Api, or the green button in Visual Studio). For a POST, curl needs three more things than a GET:
curl -i -X POST http://localhost:5101/books -H "Content-Type: application/json" -d '{"title":"Domain-Driven Design","author":"Eric Evans","price":52.00,"stock":4,"category":"Architecture"}'
-X POSTchooses the verb. Without itcurlsends aGET.-H "Content-Type: application/json"adds a header that tells the API what the body is. We come back to it in a moment.-d '...'is the body, here a JSON object. The single quotes keep the double quotes inside safe in bash. (In Windows PowerShell, quoting JSON is awkward. The test page below avoids the problem, or use a.httpfile as in the first-endpoint lesson.)
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: http://localhost:5101/books/4
{"id":4,"title":"Domain-Driven Design","author":"Eric Evans","price":52.00,"stock":4,"category":"Architecture"}
(A few header lines are left out. Your id is 4 if the table still holds the three books from reading books.) There are all three parts: the 201, the Location, and the book with its new id. Follow the address:
curl http://localhost:5101/books/4
{"id":4,"title":"Domain-Driven Design","author":"Eric Evans","price":52.0,"stock":4,"category":"Architecture"}
The book is really in the table. (52.00 became 52.0 on the way back: the same number, written the way JSON usually writes it, as we saw when reading books.)
The header matters
What if the client forgets the Content-Type header? curl -d then sends the body as if it were an HTML form:
curl -i -X POST http://localhost:5101/books -d '{"title":"x"}'
HTTP/1.1 415 Unsupported Media Type
Content-Type: application/problem+json; charset=utf-8
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.16",
"title": "Unsupported Media Type",
"status": 415,
"traceId": "00-b59b9f0ec8f71ead829f5ec522a07f5c-5e3a36530783393b-00"
}
(The real body is one long line. It is shown here with line breaks, so it is easier to read.)
The API refuses to guess what the body is. The answer is 415 Unsupported Media Type, and it is another planned error in the Problem Details shape of the errors lesson. When you meet a 415 from a POST, check the Content-Type header first.
What the bare endpoint lets in
The endpoint works for a good book. How does it treat a bad one? Send the emptiest JSON there is, an object with nothing in it:
curl -i -X POST http://localhost:5101/books -H "Content-Type: application/json" -d '{}'
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: http://localhost:5101/books/5
{"id":5,"title":"","author":"","price":0,"stock":0,"category":""}
A book with no title, no author and a price of nothing has been saved, and the API called it a success. Why? Book gives each text property the value "" when no one sets it, so nothing is ever missing: model binding is happy with a book full of blanks.
Nobody wants those rows in a bookshop. A client can make this mistake by accident (a form with an empty box) or on purpose. The endpoint has to say no, and say why, before anything reaches the table. That is validation.
(Delete the blank book before going on, in DB Browser: DELETE FROM Books WHERE Id = 5; and Write Changes. Later id values on your machine may then be higher than the ones shown here. That is only the counter in the table, and it is harmless.)
Validation
Validation means checking that the data is acceptable before using it. In a controller there is a convenient way to say what "acceptable" means: data annotations, attributes placed on the properties of the class. Each one is a small rule. The rules live on Book, since the rules are about what a book is. Open src/BookShop.Data/Book.cs. The whole file is:
using System.ComponentModel.DataAnnotations; // new
namespace BookShop.Data;
public class Book
{
public int Id { get; set; }
[Required] // new
public string Title { get; set; } = "";
[Required] // new
public string Author { get; set; } = "";
[Range(0.01, 10000)] // new
public decimal Price { get; set; }
[Range(0, 100000)] // new
public int Stock { get; set; }
[Required] // new
public string Category { get; set; } = "";
}
[Required]means the value must be present and, for text, not empty. This is why it is needed even though the property can never benull: the empty text""that we saw above is exactly what it rejects.[Range(0.01, 10000)]onPricemeans the number must be between those two values. A free or negative book is a mistake, and so is a book at a price nobody meant. The numbers are our choice: they are the shop's rules, in one visible line.[Range(0, 100000)]onStockallows zero (out of stock) but not a negative number of copies.using System.ComponentModel.DataAnnotations;is where these attributes live. It is part of .NET itself, so there is no package to install.
We did nothing to the controller. [ApiController] checks the rules for us. After model binding builds the Book, it runs every rule. If one fails, the request is stopped, our Create method never runs, and the answer is a 400:
Restart the app, and send the empty object again:
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-0b607455a2cd630f16cb5139f62d3842-d4eb757f6d57938f-00"
}
This is the 400 we saw when reading books, and now the errors field from the Problem Details table has something to hold: one entry for each property that broke a rule, each with a list of messages. A frontend can show every message beside the right box. (The names in errors are the C# names, Title, while the JSON of a book uses title. Remember that for a later lesson.)
A number that is out of range gets its own message:
curl -s -X POST http://localhost:5101/books -H "Content-Type: application/json" -d '{"title":"A","author":"B","price":-5,"stock":-1,"category":"C"}'
{
"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."
],
"Stock": [
"The field Stock must be between 0 and 100000."
]
},
"traceId": "00-b07aec00fd2adafd64a7b205598faf29-fd874f4bb2e9a37d-00"
}
Both mistakes are reported together, so the client can fix them in one go. JSON that is broken, or text where a number belongs ("price":"cheap"), is also turned away with a 400 in this same shape.
⚠️ Two jobs, one class.
Bookis the class EF Core reads to build the table, and now it carries the rules of the API as well. EF Core reads attributes too, and some of them describe the column (a maximum length, for example). We kept to two that leave the table alone: after adding them,dotnet ef migrations addfinds nothing to change and writes an empty migration. This is part of the shortcut from reading books: the same class is the table, the request and the answer. A later lesson gives each job its own class, and the rules move to the class that receives the data.
Tell the description about the answers
Open /openapi/v1.json, or the test page. The POST /books entry lists one answer, 200. The real answers are 201 and 400. As when reading books, ASP.NET cannot see them inside the method, so we say it. Add two attributes above Create. The whole file is:
using BookShop.Data;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("books")]
public class BooksController : ControllerBase
{
private readonly IBookService _bookService;
public BooksController(IBookService bookService)
{
_bookService = bookService;
}
[HttpGet]
public async Task<ActionResult<List<Book>>> GetAll()
{
var books = await _bookService.GetAllAsync();
return books;
}
[HttpGet("{id}")]
[ProducesResponseType<Book>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Book>> GetById(int id)
{
var book = await _bookService.GetByIdAsync(id);
if (book == null)
{
return NotFound();
}
return book;
}
[HttpPost]
[ProducesResponseType<Book>(StatusCodes.Status201Created)] // new
[ProducesResponseType(StatusCodes.Status400BadRequest)] // new
public async Task<ActionResult<Book>> Create(Book book)
{
var created = await _bookService.CreateAsync(book);
return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
}
}
Reload the page: POST /books now lists 201 and 400. This is the contract the frontend developer reads, so it has to be true. (As before, once you list any answer you must list them all.)
A weakness that remains: over-posting
Look at what the client may send. Book has six properties, and model binding fills every one that the JSON names. That includes two that a client should never choose: Id, which is the database's job, and Stock, which should change when copies arrive or sell. A customer-facing form would not offer those boxes. A client that ignores the form can still send them:
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/9
{"id":9,"title":"Sneaky","author":"Anon","price":9.50,"stock":9999,"category":"Programming"}
The API created a book with the id and the stock the sender chose. This is called over-posting: the client posts more than it was meant to be able to set. It is not about wrong data, since every value passed the rules. It is about fields that were never offered.
There is a second, quieter harm. Ask for an id that already exists:
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
Content-Type: application/problem+json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "Something went wrong on our side.",
"status": 500,
"detail": "The error has been logged. Quote the trace id if you contact support.",
"traceId": "00-72a8e3c3d1910c8ac46890123468ef70-fe06161b9110c257-00"
}
The client made the mistake, and the API reports a failure of its own, a 500. This is the errors lesson's handler working as intended: it hid the details from the client. In the terminal, the log tells the real story:
fail: GlobalExceptionHandler[0]
Unhandled exception on POST /books, trace 00-72a8e3c3d1910c8ac46890123468ef70-fe06161b9110c257-00
Microsoft.EntityFrameworkCore.DbUpdateException: An error occurred while saving the entity changes. See the inner exception for details.
---> Microsoft.Data.Sqlite.SqliteException (0x80004005): SQLite Error 19: 'UNIQUE constraint failed: Books.Id'.
The row already has that key, so the database said no.
⚠️ This is a shortcut, and we leave it on purpose. The endpoint takes the
Bookentity, the very class of the table, as its request. That is why over-posting is possible: the request has every column, whether the client should set it or not. The same shortcut sits behind the endpoints for reading books, which return the entity. It is quick to write and fine while we learn the flow of a request. A later lesson fixes it for all of them: a small request class that holds only the fields a client may send, soidcannot get in. We will send this same request again then, and watch theidbe ignored. (stockis a field a shop needs to set, so it stays. Whether a client may set it is a decision for the shop.)
Tidy the table before going on. In DB Browser, run DELETE FROM Books WHERE Id > 3; and press Write Changes, so only the first three books remain.
Test it
Three things deserve a test: the service saves, the rules reject, the controller answers correctly.
The service
Add two tests to tests/BookShop.Data.Tests/BookServiceTests.cs, using the in-memory SQLite database of the books service. The whole file is below. A // new line above a method marks the whole method:
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace BookShop.Data.Tests;
[TestClass]
public class BookServiceTests
{
private SqliteConnection _connection = null!;
private BookShopDbContext _context = null!;
private BookService _service = null!;
[TestInitialize]
public void Setup()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<BookShopDbContext>()
.UseSqlite(_connection)
.Options;
_context = new BookShopDbContext(options);
_context.Database.EnsureCreated();
_service = new BookService(_context);
}
[TestCleanup]
public void Cleanup()
{
_context.Dispose();
_connection.Dispose();
}
[TestMethod]
public async Task GetAllAsync_NoBooks_ReturnsEmptyList()
{
var books = await _service.GetAllAsync();
Assert.IsEmpty(books);
}
[TestMethod]
public async Task GetAllAsync_SomeBooks_ReturnsThemOrderedByTitle()
{
_context.Books.Add(new Book { Title = "Refactoring", Author = "Martin Fowler", Price = 44.00m });
_context.Books.Add(new Book { Title = "Clean Code", Author = "Robert C. Martin", Price = 34.50m });
await _context.SaveChangesAsync();
var books = await _service.GetAllAsync();
Assert.HasCount(2, books);
Assert.AreEqual("Clean Code", books[0].Title);
Assert.AreEqual("Refactoring", books[1].Title);
}
[TestMethod]
public async Task GetByIdAsync_ExistingBook_ReturnsIt()
{
var saved = new Book { Title = "Clean Code", Author = "Robert C. Martin", Price = 34.50m };
_context.Books.Add(saved);
await _context.SaveChangesAsync();
var book = await _service.GetByIdAsync(saved.Id);
Assert.IsNotNull(book);
Assert.AreEqual("Clean Code", book.Title);
}
[TestMethod]
public async Task GetByIdAsync_UnknownId_ReturnsNull()
{
var book = await _service.GetByIdAsync(999);
Assert.IsNull(book);
}
// new
[TestMethod]
public async Task CreateAsync_ValidBook_SavesItAndGivesItAnId()
{
var book = new Book { Title = "Clean Code", Author = "Robert C. Martin", Price = 34.50m };
var created = await _service.CreateAsync(book);
Assert.IsGreaterThan(0, created.Id);
var found = await _service.GetByIdAsync(created.Id);
Assert.IsNotNull(found);
Assert.AreEqual("Clean Code", found.Title);
}
// new
[TestMethod]
public async Task CreateAsync_TwoBooks_GetDifferentIds()
{
var first = await _service.CreateAsync(new Book { Title = "Clean Code" });
var second = await _service.CreateAsync(new Book { Title = "Refactoring" });
Assert.AreNotEqual(first.Id, second.Id);
}
}
The first test saves a book and then reads it back through the other service method, which proves the row is really in the table, and that the Id was filled in. The second makes sure the database hands out a new number each time. (The service does not check the validation rules. That is the controller layer's gate, as the picture showed, so these tests save books that would not pass it.)
The rules
The rules sit on Book, so their tests sit with the data. .NET has a class that runs the attributes on any object, Validator, so a test can check the rules with no web server at all. Create BookValidationTests.cs in tests/BookShop.Data.Tests/. The whole file is:
using System.ComponentModel.DataAnnotations;
namespace BookShop.Data.Tests;
[TestClass]
public class BookValidationTests
{
private static Book ValidBook()
{
return new Book
{
Title = "Clean Code",
Author = "Robert C. Martin",
Price = 34.50m,
Stock = 7,
Category = "Programming"
};
}
private static List<ValidationResult> Validate(Book book)
{
var results = new List<ValidationResult>();
Validator.TryValidateObject(book, new ValidationContext(book), results, validateAllProperties: true);
return results;
}
[TestMethod]
public void Validate_ValidBook_HasNoErrors()
{
var results = Validate(ValidBook());
Assert.IsEmpty(results);
}
[TestMethod]
public void Validate_EmptyTitle_HasAnErrorOnTitle()
{
var book = ValidBook();
book.Title = "";
var results = Validate(book);
Assert.HasCount(1, results);
Assert.Contains("Title", results[0].MemberNames);
}
[TestMethod]
public void Validate_PriceZero_HasAnErrorOnPrice()
{
var book = ValidBook();
book.Price = 0m;
var results = Validate(book);
Assert.HasCount(1, results);
Assert.Contains("Price", results[0].MemberNames);
}
[TestMethod]
public void Validate_NegativeStock_HasAnErrorOnStock()
{
var book = ValidBook();
book.Stock = -1;
var results = Validate(book);
Assert.HasCount(1, results);
Assert.Contains("Stock", results[0].MemberNames);
}
}
ValidBook()builds a book that passes every rule. Each test spoils exactly one thing, so a failure points to one rule. A helper method like this keeps the tests short.Validator.TryValidateObject(...)is .NET's own way to run the attributes. The last argument,validateAllProperties: true, matters: without it,[Range]rules are skipped. This is also what[ApiController]does for us in a real request.MemberNamessays which property each error is about, so the test checks the right rule broke, not just that something did.
The controller
Add two tests to 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);
}
// new
[TestMethod]
public async Task Create_ValidBook_ReturnsCreatedPointingAtTheNewBook()
{
var result = await _controller.Create(new Book { Title = "Clean Code" });
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 Book;
Assert.IsNotNull(book);
Assert.AreEqual("Clean Code", book.Title);
}
// new
[TestMethod]
public async Task Create_ValidBook_PassesItToTheService()
{
await _controller.Create(new Book { Title = "Clean Code" });
Assert.HasCount(1, _fakeService.Books);
Assert.AreEqual("Clean Code", _fakeService.Books[0].Title);
}
}
result.Result as CreatedAtActionResultlooks inside theActionResult<Book>(Resultholds the non-data answers).asgivesnullif it is some other kind of answer, and the next line fails the test if so.ActionNameandRouteValues["id"]are the two things that make up theLocation. The test checks that the new book points atGetByIdand at the id the fake gave it. (!tells the compiler we knowRouteValuesis notnullhere.)- The second test checks the controller really handed the book to the service, and did not just invent an answer.
What is not tested here: the 400. The gate of the picture stands in front of Create, and a test that calls Create directly walks around it. That is why the rules have their own tests above, and why the 400 is checked by hand. A test that sends a real request through the whole pipeline is an integration test, which comes later.
Run all tests from the solution folder:
dotnet test
Passed! - Failed: 0, Passed: 8, Skipped: 0, Total: 8, Duration: 133 ms - BookShop.Api.Tests.dll (net10.0)
Passed! - Failed: 0, Passed: 10, Skipped: 0, Total: 10, Duration: 1 s - BookShop.Data.Tests.dll (net10.0)
Eighteen tests, two projects. (The order and the durations may differ.)
In the test page
The last step of the loop. Open /scalar and find POST /books. It offers a body editor, where you can type the JSON of a book, and a Test Request button, which saves you the quoting problems of curl. Send a good book and look for the 201 and the Location. Send an empty {} and look for the 400 and its list. Check that the page now lists the answers 201 and 400.
Where we ended
Compare with your solution. The service, the endpoint and the rules grew. One test file is new:
BookShop/
BookShop.slnx
Directory.Build.props
Directory.Packages.props
src/
BookShop.Api/
Program.cs
GlobalExceptionHandler.cs
Controllers/
BooksController.cs GetAll, GetById and Create (201, 400)
BookShop.Data/
Book.cs now carries the validation rules
BookShopDbContext.cs
IBookService.cs plus CreateAsync
BookService.cs plus CreateAsync
Migrations/
tests/
BookShop.Data.Tests/
BookServiceTests.cs six tests, two of them new
BookValidationTests.cs four tests on the rules
BookShop.Api.Tests/
FakeBookService.cs plus CreateAsync
FakeLogger.cs
BooksControllerTests.cs five tests, two of them new
GlobalExceptionHandlerTests.cs
Recap
- A POST to the collection creates a thing. The answer is 201 Created, with a
Locationheader that names the new address and the new thing in the body. CreatedAtAction(nameof(GetById), ...)builds the201and theLocationfrom the name of the action that reads one book, so the two stay in step.- A parameter that is a class comes from the body. Model binding builds it from the JSON. The client must send
Content-Type: application/json, or the answer is 415. - Validation checks data before it is used. Data annotations such as
[Required]and[Range]put the rules on the properties. [ApiController]runs the rules before our method. A failure is a 400 with anerrorslist, and the method never runs.- Without rules, a
{}saves a blank book.[Required]is needed on text, because the default""is never missing. - Over-posting: because the request is the
Bookentity, a client can setIdandStock. It is a known shortcut. - Rules are tested with
Validator.TryValidateObject. The400itself is checked by hand, since a direct call toCreateskips the gate.
Questions to think about
These have no answers here, on purpose. Try them from memory first.
- Why is the answer to a successful POST
201and not200? - What is the
Locationheader for, and who uses it? - Why is the body checked before
Createruns, and not inside it? - What is over-posting, and why does it happen with this endpoint?
Next lesson: update and delete. PUT replaces a book, DELETE removes one, and the service decides when a book is missing.