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:

    1. Put three books in the table, so there is something to read.
    2. Give the controller the service, and see the path of a request through the layers.
    3. Rewrite GET all to read from the service.
    4. Add GET one, with a 200 and a 404.
    5. Run it and try it, including two requests that go wrong.
    6. Tell the API's description about the 404.
    7. 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.

    The projects of the BookShop solution src/ tests/ Api lesson 14 Data lesson 16 Api.Tests lesson 18 NEW Api. IntegrationTests lesson 33, planned Data.Tests lesson 17 wwwroot: the screens uses Every name starts with BookShop. An arrow means "uses". Dashed: not built yet, or still only a plan.

    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:

    The path of a request Client Controller Service DbContext .db speaks HTTP the rules talks to the db The answer comes back the same way, and only the controller turns it into a status code.

    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 only Program.cs knows that BookService stands behind it.
    • async Task<...> and await because 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 plain List<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 a T, or some other result such as a 404". For GetAll there is only the list, so return 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 verb GET plus 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 id is the match. ASP.NET reads the 2 from 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's null becomes 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 that ControllerBase gives us (lesson 15 promised them). It builds a "404" result.
    • return book; is again "200 OK with the book".
    GetById: two possible answers GetById(id) asks the service a book came back 200 OK + the book null came back 404 Not Found

    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.Status200OK is 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/json and text/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 the Content-Type of the 404 from application/problem+json to plain application/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 same controller, two different services In the app Controller BookService database In a test Controller FakeBookService a list

    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);
        }
    }
    
    • : IBookService is the same promise as BookService. The compiler checks that both methods exist.
    • Books is the "database": a list the test fills before it starts.
    • Task.FromResult(...) wraps a value that is ready now in a Task, because the interface says the methods return a Task. 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, or null if 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.Value and result.Result. An ActionResult<T> holds one of two things. When the method returned a book (or a list), that is in Value. When it returned something like NotFound(), that is in Result. The tests look in the right place: Value for the 200 cases, and Result for 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.
    Which tests cover which class Controller Service database BookShop.Api.Tests with a fake service BookShop.Data.Tests with an in-memory SQLite database

    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:

    1. Write the code: a service, then two endpoints.
    2. Write its tests with it: four for the service, three for the controller.
    3. Spot-check it by hand: curl and 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 IBookService in its constructor. It never knows which class stands behind it.
    • ActionResult<T> lets one method return either data (200) or another result such as NotFound() (404).
    • {id} in [HttpGet("{id}")] is part of the address. Model binding turns it into the int id parameter. A value that does not fit is answered with a 400 before our code runs.
    • The service says null for "no such book". The controller turns that into a 404.
    • Errors from [ApiController] and from NotFound() 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.

    1. Why does the controller turn null into a 404, and not the service?
    2. What can ActionResult<Book> do that a plain Book return type cannot?
    3. Why can the controller test run with no database at all?
    4. 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.

    ← The Books Service