Tutorials / Build a REST API with C# and .NET · Lesson 19 of 19
On this page
Read Books: GET All and GET One
Lesson 16 made the table. Lesson 17 wrote a service that can read it, and tested it. Yet the API still returns the three books typed into the controller. This lesson connects the last wire.
Our goal: two endpoints that read the real table. GET /books lists every book. GET /books/{id} returns one book, or a 404 when there is none. We test the controller, and check both endpoints by hand.
Here is the route:
- Put three books in the table, so there is something to read.
- Give the controller the service, and see the path of a request through the layers.
- Rewrite GET all to read from the service.
- Add GET one, with a 200 and a 404.
- Run it and try it, including two requests that go wrong.
- Tell the API's description about the 404.
- Test the controller with a hand-written fake.
This lesson leaves out saving, changing and deleting books (lessons 20 and 21), a nicer error format (lesson 19), and searching or paging a long list.
The map
The solution map from lesson 14 once more. This lesson adds the second test project, BookShop.Api.Tests, marked NEW. After it only the planned integration project is left.
Where we are
This is the solution from lesson 17. We touch these files:
BookShop/
BookShop.slnx <- changes (a new test project)
Directory.Build.props
Directory.Packages.props
src/
BookShop.Api/
Program.cs
Controllers/
BooksController.cs <- changes (uses the service, a new endpoint)
bookshop.db <- changes (three rows added by hand)
BookShop.Data/
Book.cs
BookShopDbContext.cs
IBookService.cs
BookService.cs
Migrations/
tests/
BookShop.Data.Tests/
BookShop.Api.Tests/ <- NEW project
BookShop.Api.Tests.csproj <- NEW
MSTestSettings.cs <- NEW (made by the template)
FakeBookService.cs <- NEW
BooksControllerTests.cs <- NEW
Run every command from the solution folder, BookShop/.
Put some books in the table
The table is empty, and an empty list proves little. Add three books by hand, the way lesson 10 did. Later lessons add books through the API, but that endpoint does not exist yet.
Open src/BookShop.Api/bookshop.db in DB Browser for SQLite (lesson 10). Use the file that your app really uses. Lesson 16 warned that the file can land in another folder when the app starts another way. On the Execute SQL tab, run:
INSERT INTO Books (Title, Author, Price, Stock, Category) VALUES
('The Pragmatic Programmer', 'Andrew Hunt', 39.90, 12, 'Programming'),
('Clean Code', 'Robert C. Martin', 34.50, 7, 'Programming'),
('Refactoring', 'Martin Fowler', 44.00, 3, 'Programming');
Then press Write Changes (Ctrl+S). Without it the rows exist only inside the tool, and the app will not see them. We left out Id on purpose. It is the primary key, and the database counts up by itself: 1, 2 and 3.
Open Browse Data and choose Books to check that you can see the three rows.
The path of a request
Before the code, the picture. A request for GET /books will pass through four of our own classes:
Who creates all these objects? Nobody writes new. The controller asks for an IBookService in its constructor. DI creates a BookService, which asks for a BookShopDbContext, which DI creates too. All three live inside the scope of one request (lesson 17). When the request ends, they are thrown away.
GET all
Open src/BookShop.Api/Controllers/BooksController.cs. The typed-in books go, the service comes in. The whole file is:
using BookShop.Data;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("books")]
public class BooksController : ControllerBase
{
private readonly IBookService _bookService; // new
public BooksController(IBookService bookService) // new
{ // new
_bookService = bookService; // new
} // new
[HttpGet]
public async Task<ActionResult<List<Book>>> GetAll() // changed
{
// Replaced in this lesson: the typed-in books. Temporary, delete after reading.
// var books = new List<Book>
// {
// new Book { Id = 1, Title = "The Pragmatic Programmer", Author = "Andrew Hunt", Price = 39.90m },
// new Book { Id = 2, Title = "Clean Code", Author = "Robert C. Martin", Price = 34.50m },
// new Book { Id = 3, Title = "Refactoring", Author = "Martin Fowler", Price = 44.00m }
// };
var books = await _bookService.GetAllAsync(); // new
return books;
}
}
The commented-out block is temporary and only shows where the code went. In a real project you would delete it, because git remembers the old version. Later lessons show the file clean.
What changed:
- The constructor asks for an
IBookService. It is the pattern of lessons 03 and 17: the controller says what it needs, and DI brings it. The controller names the interface, and onlyProgram.csknows thatBookServicestands behind it. async Task<...>andawaitbecause the service is async (lesson 09). ASP.NET waits for the answer and keeps serving other requests in the meantime.ActionResult<List<Book>>is new. Last lesson the method returned a plainList<Book>, which can only say one thing: "200 OK, here is the list". An endpoint often needs more than one possible answer.ActionResult<T>means "either aT, or some other result such as a 404". ForGetAllthere is only the list, soreturn books;is turned into "200 OK with this list" for us. We switch now so both endpoints read the same.
⚠️ Look at what the endpoints return:
Book, the very class that is stored in the table. That is a shortcut for now. If a column is added to the table next month, it appears in the JSON at once, and the API shows the shape of the database to everyone. A professional API returns classes made for the answer instead. Lesson 24 fixes this, once all four endpoints exist.
GET one
A client that wants one book names it in the address: /books/2. In the controller, the changing part of an address goes in braces. Add the second method. 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}")] // new
public async Task<ActionResult<Book>> GetById(int id) // new
{ // new
var book = await _bookService.GetByIdAsync(id); // new
if (book == null) // new
{ // new
return NotFound(); // new
} // new
return book; // new
} // new
}
(The commented-out books are gone, as promised.)
[HttpGet("{id}")]is the verbGETplus the end of the address. With the class's[Route("books")], the address is/books/{id}. The braces mark a part that changes:/books/1,/books/2, and so on.int idis the match. ASP.NET reads the2from the address, turns it into the number 2 and passes it in. That is called model binding: it fills the method's parameters from the request. The parameter name must match the name in the braces.if (book == null)is where lesson 17'snullbecomes HTTP. The service said "no such book". The controller decides what that means to the client: 404 Not Found.NotFound()is one of the helper methods thatControllerBasegives us (lesson 15 promised them). It builds a "404" result.return book;is again "200 OK with the book".
This is why ActionResult<Book> is needed. A method that returns a plain Book has no way to say "404". A method that returns ActionResult<Book> can return a book or a NotFound().
Run it, and try it
Start the app as in lesson 15 (dotnet run --project src/BookShop.Api, or the green button in Visual Studio) and use curl. Your port may differ, as before. First the list:
curl http://localhost:5101/books
[{"id":2,"title":"Clean Code","author":"Robert C. Martin","price":34.5,"stock":7,"category":"Programming"},{"id":3,"title":"Refactoring","author":"Martin Fowler","price":44.0,"stock":3,"category":"Programming"},{"id":1,"title":"The Pragmatic Programmer","author":"Andrew Hunt","price":39.9,"stock":12,"category":"Programming"}]
These are the rows of the table, not the code. They come in title order: Clean Code, Refactoring, The Pragmatic Programmer. That is the OrderBy of lesson 17. Prices print as 34.5 and 39.9 because a JSON number has no trailing zero (lesson 08). It is the same value.
One book:
curl http://localhost:5101/books/2
{"id":2,"title":"Clean Code","author":"Robert C. Martin","price":34.5,"stock":7,"category":"Programming"}
A book that does not exist. -i makes curl show the status line and headers too, which is where a 404 lives. A few header lines are left out here:
curl -i http://localhost:5101/books/99
HTTP/1.1 404 Not Found
Content-Type: application/problem+json; charset=utf-8
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.5","title":"Not Found","status":404,"traceId":"00-3133751a93eba87e2cb6fb795f3edb18-3cde9ee78c1c033f-00"}
The status is 404, as planned. Look at the body, though: we wrote nothing but NotFound(), and a small JSON message arrived. [ApiController] (lesson 15) adds it. The format is called Problem Details, and it is the standard way to describe an error in an API. Lesson 19 looks at it properly.
And a request that is not a number:
curl -i http://localhost:5101/books/abc
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":{"id":["The value 'abc' is not valid."]},"traceId":"00-561e57c428225f1635692d86f7e578fd-74ea7086c0f7b52f-00"}
We wrote no code for this either. Model binding could not turn abc into an int, so [ApiController] stopped the request before our method ran and answered 400 Bad Request, which means "your request is wrong". Both failures use Problem Details, the same format. That is a gift to the frontend (lesson 12): one shape to read for every error.
(The traceId is a number that identifies this request in the logs. It differs on every request.)
In the test page
curl is fine, and so is the test page from lesson 15. Open /scalar on your app's address. The menu now lists GET /books and GET /books/{id}. Click the second one, type 2 into the id box, and press Test Request. Try 99 and abc too. This is the quick check from lesson 12, and it is how a backend developer would finish the work on an endpoint.
Tell the description about the 404
Look at the GET /books/{id} page in Scalar. Its list of answers shows only 200. There is no 404, yet our code can send one. The frontend developer in lesson 12 reads this page as the contract, so a missing answer here is a real problem.
Why? The test page does not read your code. It reads the OpenAPI document (lesson 15) that ASP.NET builds from your method signatures. ASP.NET can see the return type, so it lists a 200 with a Book. It cannot see NotFound() hiding inside the method. We have to say it.
Two attributes do that. Add them above GetById. 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)] // new
[ProducesResponseType(StatusCodes.Status404NotFound)] // new
public async Task<ActionResult<Book>> GetById(int id)
{
var book = await _bookService.GetByIdAsync(id);
if (book == null)
{
return NotFound();
}
return book;
}
}
[ProducesResponseType(...)]declares one answer the method can give: a status code, and optionally a type for the body.- Both lines are needed. We first added only the 404. The 200 then vanished from the page. Once you list any answer yourself, ASP.NET stops guessing and lists exactly what you wrote. So the 200 gets its own line, and
<Book>says what its body is.StatusCodes.Status200OKis just a readable name for the number 200.
Run the app again and reload the page. GET /books/{id} now lists 200 and 404. You can check the document too: it is at /openapi/v1.json (lesson 15).
💡 You may have noticed that each answer on the page offers three content types:
text/plain,application/jsonandtext/json. ASP.NET is able to write an answer in more than one format, so the document says so. Real clients ask for JSON and get JSON. There is an attribute,[Produces("application/json")], that reduces the list to one. We tried it, and it has a catch: it also changes theContent-Typeof the 404 fromapplication/problem+jsonto plainapplication/json, which is not the error format we want. We leave the three types alone. Lesson 19 looks at the error format.
Test the controller
The service has tests (lesson 17). The controller has its own job and needs its own: for a book that exists it returns the book, and for one that does not it returns a 404. Those are the two decisions we wrote in this lesson.
A fake service
In lesson 17 we left a question open: how do you test a class that depends on something you cannot easily control? There it was a database. Here it is the service, and the service is a small interface. That is the case for a fake: a class you write yourself that keeps the same promise as the real one, but holds a plain list in memory. No database, no web server, nothing to clean up.
The controller only knows IBookService. That is what the interface of lesson 17 is for. It is also the answer to the question at the end of lesson 11: to test a class that needs a database, give it a stand-in that behaves the way the test needs.
The test project
The controller lives in the API project, so its tests get a project of their own, BookShop.Api.Tests. It is the same steps as lesson 17, now pointing at the API:
dotnet new mstest -n BookShop.Api.Tests -o tests/BookShop.Api.Tests --no-restore
dotnet add tests/BookShop.Api.Tests reference src/BookShop.Api
dotnet sln add tests/BookShop.Api.Tests
Delete Test1.cs. Open the new BookShop.Api.Tests.csproj and make it the same shape as in lesson 17: no settings group, no version. The whole file is:
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<Using Include="Microsoft.VisualStudio.TestTools.UnitTesting" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\BookShop.Api\BookShop.Api.csproj" />
</ItemGroup>
</Project>
A test project may reference a web project. It does not start the web server. It only borrows the classes, here BooksController. The MSTest version is already in Directory.Packages.props from lesson 17. In Visual Studio, add the project as in lesson 17 and point Add Project Reference at BookShop.Api.
Create FakeBookService.cs in tests/BookShop.Api.Tests/. 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);
}
}
: IBookServiceis the same promise asBookService. The compiler checks that both methods exist.Booksis the "database": a list the test fills before it starts.Task.FromResult(...)wraps a value that is ready now in aTask, because the interface says the methods return aTask. Our fake has no waiting to do, so it hands back an already finished one.FirstOrDefault(b => b.Id == id)gives the first book with that id, ornullif none matches. That is the same answer the real service gives for an unknown id.
The tests
Create BooksControllerTests.cs in the same folder. 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 BooksController(_fakeService)is lesson 03 in one line. A test does not need DI. It creates the controller itself and hands it the fake, which is possible because the constructor takes an interface.result.Valueandresult.Result. AnActionResult<T>holds one of two things. When the method returned a book (or a list), that is inValue. When it returned something likeNotFound(), that is inResult. The tests look in the right place:Valuefor the 200 cases, andResultfor the 404.Assert.IsInstanceOfType<NotFoundResult>(...)checks that the result is a 404. The test says what it means: the answer for an unknown id is "not found".- What these tests do not check. The controller's tests do not prove the query is right. That is the job of the service tests from lesson 17. Each project tests its own class.
Run all tests from the solution folder:
dotnet test
Passed! - Failed: 0, Passed: 3, Skipped: 0, Total: 3, Duration: 57 ms - BookShop.Api.Tests.dll (net10.0)
Passed! - Failed: 0, Passed: 4, Skipped: 0, Total: 4, Duration: 1 s - BookShop.Data.Tests.dll (net10.0)
Seven tests, two projects. (The order of the two lines and the durations may differ.)
The loop
You have now done what lesson 12 describes, from the backend side:
- Write the code: a service, then two endpoints.
- Write its tests with it: four for the service, three for the controller.
- Spot-check it by hand:
curland the test page, including the 404 and the 400.
Every endpoint in the coming lessons follows these three steps.
Where we ended
Compare with your solution. The controller changed, and the second test project is new:
BookShop/
BookShop.slnx both test projects are listed
Directory.Build.props
Directory.Packages.props
src/
BookShop.Api/
Program.cs
Controllers/
BooksController.cs GetAll and GetById, both through IBookService
bookshop.db the table now holds three books
BookShop.Data/
Book.cs
BookShopDbContext.cs
IBookService.cs
BookService.cs
Migrations/
tests/
BookShop.Data.Tests/
BookServiceTests.cs
BookShop.Api.Tests/
BookShop.Api.Tests.csproj
MSTestSettings.cs template settings, left as made
FakeBookService.cs a list in memory in place of the database
BooksControllerTests.cs three tests on the controller
Recap
- A request passes controller, service, DbContext, database. DI creates the objects for each request, and only the controller speaks HTTP.
- The controller asks for
IBookServicein its constructor. It never knows which class stands behind it. ActionResult<T>lets one method return either data (200) or another result such asNotFound()(404).{id}in[HttpGet("{id}")]is part of the address. Model binding turns it into theint idparameter. A value that does not fit is answered with a 400 before our code runs.- The service says
nullfor "no such book". The controller turns that into a 404. - Errors from
[ApiController]and fromNotFound()use the Problem Details format. - The test page and the OpenAPI document only know what ASP.NET can see.
[ProducesResponseType]adds an answer it cannot see. Once you list any, you must list all. - A fake is a hand-written stand-in for a small interface. It lets a test control what a class depends on, with no database and no web server.
Questions to think about
These have no answers here, on purpose. Try them from memory first.
- Why does the controller turn
nullinto a 404, and not the service? - What can
ActionResult<Book>do that a plainBookreturn type cannot? - Why can the controller test run with no database at all?
- The 404 and the 400 both come back in the same shape, and we wrote almost no code for it. Why is one shape for all errors useful to the frontend? (Lesson 19 answers how to take control of it.)
Next lesson: errors the professional way. We look at Problem Details closely, make a global handler for the errors nobody planned for, and make sure no stack trace ever reaches a user.