Updated on

A controller action in an ASP.NET Core Web API does three things: it matches a route, it asks a repository for data, and it returns that data with a status code. Everything else belongs somewhere other than the controller.

In this part we build the three GET endpoints of our OwnerController on the repository from part 4, all owners, one owner by id, and one owner with the accounts belonging to it, and return each one as a DTO rather than as the entity that came out of the database.


VIDEO: How to Handle GET Requests video.


The embedded video writes these actions synchronously and wraps each one in a try/catch. The code below is the current version: the same actions, async, with error handling moved out of the controller.

If you want to see all the basic instructions and complete navigation for this series, please follow the following link: Introduction page for this tutorial.

For the previous part check out: Repository Pattern in .NET Core Web API

To download the source code for this article, you can visit the UsingRepositoryForGetRequests folder in our GitHub repository. The source code for the whole series is here.

How Do Controllers and Routing Work in an ASP.NET Core Web API?

A controller is a class deriving from ControllerBase whose public methods handle HTTP requests. ControllerBase brings the helpers that build responses, Ok(), NotFound(), CreatedAtRoute(), without the view machinery an MVC controller carries.

Routing decides which method runs. ASP.NET Core offers two ways to decide, and a Web API uses one of them.

Convention-based routing derives the route from a pattern: a controller segment, an action segment, and an optional parameter. It suits an MVC application where URLs mirror the folder structure.

Attribute routing puts the route on the class and the method. [Route("api/owner")] on the controller sets the prefix, [HttpGet] on a method claims the prefix itself, and [HttpGet("{id}")] claims one segment below it. What the URL is, is visible where the code is.

Web APIs use attribute routing, and [ApiController] makes it required rather than optional.

That last sentence is not a style preference. An action on a controller marked [ApiController] that carries no route attribute is a startup failure: the application throws InvalidOperationException with the message “Action methods on controllers annotated with ApiControllerAttribute must be attribute routed” before it serves a single request. Convention-based routing is worth knowing about, but it is not something a Web API can fall back on.

To create a controller, right-click on the Controllers folder inside the main project and Add/Controller. Then from the menu choose API Controller - Empty and name it OwnerController.cs:

using Microsoft.AspNetCore.Mvc;

namespace AccountOwnerServer.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class OwnerController : ControllerBase
    {
    }
}

Every Web API controller class inherits from the ControllerBase abstract class that provides all the necessary behavior for the derived class.

Above the controller class we can see the routing attribute:

[Route("api/[controller]")]

The token form derives the segment from the class name, so OwnerController answers on api/owner. Renaming the class would silently change the URL, which is why we replace it with a literal route in the next section.

For completeness, this is the convention-based form the MVC template registers, configured on the application rather than on the controller. The first part maps the controller name, the second maps the action method, and the third is an optional parameter:

app.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}");

How Do We Return All Records From a Repository?

Returning a collection takes one method in three places, and the shape is the same every time.

The entity’s repository interface declares it. Task<IEnumerable<Owner>> GetAllOwnersAsync() says what the caller gets, and says nothing about how.

The repository class implements it on top of the generic base. FindAll() hands back an IQueryable<Owner>, OrderBy adds the sort, and ToListAsync() is the call that finally reaches the database.

The controller action asks for it and returns it. [HttpGet] on a method with no route template claims the controller’s own route, so this action answers GET /api/owner.

Two details decide how good the result is. Sorting belongs in the repository, where it becomes part of the SQL, rather than in the controller, where it sorts a list already in memory. And an action that returns every row is fine with three owners and wrong with thirty thousand.

That second detail has its own article: when the row count grows, add paging to this action and return only the part of the data the caller asked for.

First, let’s change the base route from [Route("api/[controller]")] to [Route("api/owner")]. Even though the first route works just fine, the second is explicit about which controller the route points to.

Now it is time to create the first action method to return all the owners from the database. In the IOwnerRepository interface, let’s create a definition for the GetAllOwnersAsync method:

public interface IOwnerRepository
{
    Task<IEnumerable<Owner>> GetAllOwnersAsync();
}

Then implement that interface inside the OwnerRepository class:

namespace Repository
{
    public class OwnerRepository : RepositoryBase<Owner>, IOwnerRepository
    {
        public OwnerRepository(RepositoryContext repositoryContext)
            : base(repositoryContext)
        {
        }

        public async Task<IEnumerable<Owner>> GetAllOwnersAsync() =>
            await FindAll()
                .OrderBy(ow => ow.Name)
                .ToListAsync();
    }
}

FindAll() and FindByCondition() stay synchronous and keep returning IQueryable<T>. They compose a query, they do not execute one, and only the executing call needs awaiting. ToListAsync() lives in Microsoft.EntityFrameworkCore, which the repository file already imports.

Finally, we need to return all the owners by using the GetAllOwnersAsync method inside the Web API action. The purpose of the action methods inside Web API controllers is not only to return results. We have to pay attention to the status codes of our responses as well, and we have to decorate the actions with the HTTP attributes that mark which kind of request reaches them.

Let’s modify the OwnerController:

using Contracts;
using Microsoft.AspNetCore.Mvc;

namespace AccountOwnerServer.Controllers
{
    [Route("api/owner")]
    [ApiController]
    public class OwnerController : ControllerBase
    {
        private readonly ILoggerManager _logger;
        private readonly IRepositoryWrapper _repository;

        public OwnerController(ILoggerManager logger, IRepositoryWrapper repository)
        {
            _logger = logger;
            _repository = repository;
        }

        [HttpGet]
        public async Task<IActionResult> GetAllOwners()
        {
            var owners = await _repository.Owner.GetAllOwnersAsync();

            _logger.LogInfo("Returned all owners from database.");

            return Ok(owners);
        }
    }
}

Let us explain this code a bit. First of all, we inject the logger and repository services inside the constructor. Then, by decorating the GetAllOwners action with the [HttpGet] attribute, we map this action to the GET request. Finally, we use both injected parameters to log the message and to get the data from the repository class.

There is no try/catch here, and that is deliberate. Every action in this series used to wrap its two lines of work in eleven lines of scaffolding that ended in StatusCode(500, "Internal server error"). Since .NET 8 that job belongs to a single IExceptionHandler registered once in Program.cs, so an unhandled exception in any action produces the same logged error and the same 500 response without the controller mentioning it:

builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();

var app = builder.Build();

app.UseExceptionHandler();

The handler itself is one class implementing TryHandleAsync, and returning true from it is what stops the exception propagating. AddProblemDetails() supplies the default RFC 9457 body when no handler writes one of its own. The full walkthrough is in our article on handling exceptions with IExceptionHandler.

The IActionResult interface supports a variety of methods that return the result and the status code together. Here, the Ok method returns all the owners and the status code 200. A more precise alternative is ActionResult<T>, which documents the success type in the signature and shows up in the generated OpenAPI document; we keep IActionResult in this series and cover the choice in ActionResult<T> and the other Web API return types.

One more thing. In this series, we are not using the service layer because we didn’t want to make things more complicated for this small project. But if you want to use it in your projects, which we strongly recommend, please read our Onion Architecture article to see how it should be done.

Because there is no route attribute right above the action, the route for the GetAllOwners action is api/owner (http://localhost:5000/api/owner).

How Do We Restrict What a Controller Can Call?

We would like to point out one more thing inside the GetAllOwners action. Right now, if we look at the repository structure, its classes inherit from the abstract RepositoryBase<T> class and also from their own interface, which then inherits from the IRepositoryBase<T> interface. With that hierarchy in place, typing _repository.Owner. lets us call the custom methods from the OwnerRepository class and also every method from the abstract RepositoryBase<T> class.

If we want to avoid that and allow the actions inside the controller to call only the methods from the repository user classes, all we have to do is remove the IRepositoryBase<T> inheritance from IOwnerRepository. Consequently, only the repository user classes are able to call the generic methods from the RepositoryBase<T> class, and the action methods communicate only with the repository user classes. That is the form the source code for this series uses.

It is up to us how we want to organize our code and permissions.

To check the result, we are going to use the Postman tool to send requests towards the application. We can also learn more about how to consume a Web API programmatically using C# by reading A few great ways to consume a RESTful API in C#.

Let’s start the application, start Postman and create a request:

get all owners http get requests

As we can see, this action returns all the data from the database, straight out of the Owner entity. The property names in that response are the entity’s own, which is the first thing the DTO section changes.

Before we continue, there is one more thing worth showing. If we look at the model classes, all the properties have the same name as the columns they map to. But a property can carry a different name from its column and still map to it, using the [Column] attribute.

So let’s do that. We are going to change the property names from AccountId and OwnerId to just Id in the Owner and Account classes, and add the [Column] attribute that maps each Id property to the right column in the database:

[Table("Account")]
public class Account
{
    [Column("AccountId")]
    public Guid Id { get; set; }

    [Required(ErrorMessage = "Date created is required")]
    public DateTime DateCreated { get; set; }

    [Required(ErrorMessage = "Account type is required")]
    public string? AccountType { get; set; }

    [ForeignKey(nameof(Owner))]
    public Guid OwnerId { get; set; }
    public Owner? Owner { get; set; }
}
[Table("Owner")]
public class Owner
{
    [Column("OwnerId")]
    public Guid Id { get; set; }

    [Required(ErrorMessage = "Name is required")]
    [StringLength(60, ErrorMessage = "Name can't be longer than 60 characters")]
    public string? Name { get; set; }

    [Required(ErrorMessage = "Date of birth is required")]
    public DateTime DateOfBirth { get; set; }

    [Required(ErrorMessage = "Address is required")]
    [StringLength(100, ErrorMessage = "Address cannot be longer than 100 characters")]
    public string? Address { get; set; }

    public ICollection<Account>? Accounts { get; set; }
}

Now let’s continue.

Why Do We Return a DTO Instead of the Entity?

A DTO is a class that exists to be the shape of a response. The entity is the shape of a table; the DTO is the shape of the contract we publish to a client.

Returning the entity ties the two together. Add a column and every client sees a new field. Add a navigation property and a serializer may follow it into a loop, or quietly ship data nobody was meant to see.

A DTO breaks that link. OwnerDto omits Accounts, so listing owners returns owners and nothing else, and the database is free to change underneath without the response changing.

The cost is a mapping step, and mapping is boring to write by hand for twenty properties. That is the job a mapping library does: describe once how Owner becomes OwnerDto, then ask for the conversion wherever it is needed.

A DTO is not the same thing as a POCO, and the distinction is worth reading once: what a DTO is and how it differs from a POCO.

Let’s create a new folder DataTransferObjects in the Entities project, and inside it the OwnerDto class:

public class OwnerDto
{
    public Guid Id { get; set; }
    public string? Name { get; set; }
    public DateTime DateOfBirth { get; set; }
    public string? Address { get; set; }
}

As we can see, there is no Accounts property, because we don’t want to show that information to the client right now.

Now all we have to do is map a returned list of owners from the database to a list of OwnerDto. Doing that by hand is a boring job, and with twenty or more properties it is a slow one as well. This is what a mapping library is for, and the one this series uses is AutoMapper.

Working with AutoMapper

AutoMapper is a library that helps us map one object onto another. To install it, we run one command in the project folder:

dotnet add package AutoMapper --version 14.0.0

The version is pinned on purpose. AutoMapper was MIT-licensed up to and including 14.0.0. From 15.0.0 the package moved to a new repository and a new licence: the Reciprocal Public License 1.5, or a commercial agreement described at luckypennysoftware.com/license, with requireLicenseAcceptance set on the package. The change is not only legal: on 16.x the registration line below does not compile, because every AddAutoMapper overload there takes a configuration action first, so moving up the versions means a licence decision and a code change together.

Version 14.0.0 also carries a high-severity advisory, GHSA-rvv3-g6hj-g44x, a denial of service through uncontrolled recursion that affects every release below 15.1.1, and since the fixed versions all sit above the licence change, a reader on 14.0.0 faces the same trade-off from the other side rather than escaping it.

If that trade-off matters on a commercial project, the alternatives are worth a look before the dependency is baked in: how AutoMapper and Mapster compare.

After the installation, we have to register it in the Program class:

builder.Services.AddAutoMapper(typeof(Program));

Now we have to create a mapping profile class to tell AutoMapper how to execute the mapping actions. So let’s create a new class MappingProfile in the main project and modify it:

public class MappingProfile : Profile
{
    public MappingProfile()
    {
        CreateMap<Owner, OwnerDto>();
    }
}

Finally, we can modify the OwnerController:

public class OwnerController : ControllerBase
{
    private readonly ILoggerManager _logger;
    private readonly IRepositoryWrapper _repository;
    private readonly IMapper _mapper;

    public OwnerController(ILoggerManager logger, IRepositoryWrapper repository, IMapper mapper)
    {
        _logger = logger;
        _repository = repository;
        _mapper = mapper;
    }

    [HttpGet]
    public async Task<IActionResult> GetAllOwners()
    {
        var owners = await _repository.Owner.GetAllOwnersAsync();

        _logger.LogInfo("Returned all owners from database.");

        var ownersResult = _mapper.Map<IEnumerable<OwnerDto>>(owners);

        return Ok(ownersResult);
    }
}

We can send the same request from Postman and get the same data without the accounts, but with a much better implementation. AutoMapper has plenty more to it, and we cover it properly in Getting Started With AutoMapper in ASP.NET Core.

DTOs are where a Web API stops being a database with a URL. The Ultimate ASP.NET Core Web API course takes this further than a series can: request and response DTOs with validation, a service layer between controller and repository, and the tests that keep the contract honest.

How Do We Return a Single Record by Id?

To continue, let’s modify the IOwnerRepository interface:

public interface IOwnerRepository
{
    Task<IEnumerable<Owner>> GetAllOwnersAsync();
    Task<Owner?> GetOwnerByIdAsync(Guid ownerId);
}

The return type is Owner?, not Owner. FirstOrDefaultAsync() returns null when nothing matches, and with nullable reference types enabled a method that promises a non-null Owner while returning that call is a CS8603 warning on every build. Saying it in the signature is what lets the controller check for null without arguing with the compiler.

Then, let’s implement the interface in OwnerRepository.cs:

public async Task<Owner?> GetOwnerByIdAsync(Guid ownerId) =>
    await FindByCondition(owner => owner.Id.Equals(ownerId))
        .FirstOrDefaultAsync();

Finally, let’s change the OwnerController:

[HttpGet("{id}")]
public async Task<IActionResult> GetOwnerById(Guid id)
{
    var owner = await _repository.Owner.GetOwnerByIdAsync(id);

    if (owner is null)
    {
        _logger.LogError($"Owner with id: {id}, hasn't been found in db.");
        return NotFound();
    }

    _logger.LogInfo($"Returned owner with id: {id}");

    var ownerResult = _mapper.Map<OwnerDto>(owner);

    return Ok(ownerResult);
}

Since the id comes from the route rather than the query string, this is route-template binding. If we need to accept values that are not part of the path, passing parameters with a GET request covers the other binding sources.

We are going to use Postman to send a valid request and check the result:

valid postman request http get requests

An id that matches no owner takes the other branch and returns 404 with a problem-details body, which [ApiController] produces for us:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
  "title": "Not Found",
  "status": 404,
  "traceId": "00-a35d4cf9c41b010ef81bc906dc19df8e-497448d691bab7cb-00"
}

How Do We Return an Entity With Its Related Data?

Related data does not arrive on its own. EF Core loads the entity we asked for and leaves its navigation properties empty unless we say otherwise, so an owner fetched by id comes back with Accounts set to null.

Include() is how we say otherwise. Chained onto the query before it executes, it tells EF Core to fetch the accounts belonging to that owner in the same round trip.

The DTO has to have somewhere to put them. OwnerDto gains an IEnumerable<AccountDto> property, and the mapping configuration gains a rule for Account to AccountDto, or AutoMapper throws when it tries to map the collection and names the missing rule.

The route says what is different. [HttpGet("{id}/account")] gives this action its own URL, so a client asks for an owner with accounts deliberately rather than receiving them on every request.

First, we need to create the AccountDto class:

public class AccountDto
{
    public Guid Id { get; set; }
    public DateTime DateCreated { get; set; }
    public string? AccountType { get; set; }
}

Then we modify our OwnerDto class so it can carry the accounts belonging to the owner. We could create an additional DTO class named OwnerWithAccountsDto, but for the sake of simplicity we are going to modify the existing one:

public class OwnerDto
{
    public Guid Id { get; set; }
    public string? Name { get; set; }
    public DateTime DateOfBirth { get; set; }
    public string? Address { get; set; }

    public IEnumerable<AccountDto>? Accounts { get; set; }
}

One consequence is worth knowing before it surprises us: the two actions we already wrote now return "accounts": [] as well, because they never call Include and AutoMapper maps a null source collection to an empty one. An empty array there is the honest answer, since those endpoints did not ask for accounts.

Let’s modify the interface accordingly:

public interface IOwnerRepository
{
    Task<IEnumerable<Owner>> GetAllOwnersAsync();
    Task<Owner?> GetOwnerByIdAsync(Guid ownerId);
    Task<Owner?> GetOwnerWithDetailsAsync(Guid ownerId);
}

Also, let’s modify the repository class:

public async Task<Owner?> GetOwnerWithDetailsAsync(Guid ownerId) =>
    await FindByCondition(owner => owner.Id.Equals(ownerId))
        .Include(ac => ac.Accounts)
        .FirstOrDefaultAsync();

We are using the Include method to include all the accounts related to the current owner.

We have to add a new mapping rule in the MappingProfile class:

public MappingProfile()
{
    CreateMap<Owner, OwnerDto>();

    CreateMap<Account, AccountDto>();
}

Leaving that second rule out is not a quiet failure. AutoMapper throws AutoMapperMappingException with the message “Error mapping types.” and names Owner -> OwnerDto along with the member it could not map, the request returns 500, and the collection never arrives empty.

Finally, let’s modify the controller:

[HttpGet("{id}/account")]
public async Task<IActionResult> GetOwnerWithDetails(Guid id)
{
    var owner = await _repository.Owner.GetOwnerWithDetailsAsync(id);

    if (owner is null)
    {
        _logger.LogError($"Owner with id: {id}, hasn't been found in db.");
        return NotFound();
    }

    _logger.LogInfo($"Returned owner with details for id: {id}");

    var ownerResult = _mapper.Map<OwnerDto>(owner);

    return Ok(ownerResult);
}

Result:

ownerdetails http get request

These actions use the repository asynchronously, which is how the series builds it. If you want to see a repository written async from the ground up in a standalone project, visit Implementing Async Repository in .NET Core. We still recommend finishing all the parts of this series first, to gain an easier understanding of the project’s business logic.

Which Routes Do Our GET Actions Expose?

Three actions, three routes, and they differ only in what follows the controller’s prefix.

[Route("api/owner")] on the class sets that prefix. The article changes it from [Route("api/[controller]")] early on, and the difference is worth knowing: the token form derives the segment from the class name, so renaming OwnerController silently changes every URL. The literal form does not.

[HttpGet] with no template claims the prefix itself. [HttpGet("{id}")] claims one segment below it and binds that segment to the action’s id parameter by name. [HttpGet("{id}/account")] claims a segment below that.

Status codes carry the rest of the answer. A collection that is empty is still 200 OK with an empty array, because the request itself succeeded and the answer happens to be nothing. A single record that does not exist is 404 Not Found, because the thing addressed by that URL is not there at all.

ActionRequestReturnsOn successOn failure
GetAllOwnersGET /api/ownerevery owner as OwnerDto, ordered by name200 OKno failure path
GetOwnerByIdGET /api/owner/{id}one OwnerDto200 OK404 Not Found
GetOwnerWithDetailsGET /api/owner/{id}/accountone OwnerDto with its AccountDto collection200 OK404 Not Found

Conclusion

Requests using GET should only retrieve data from the database, and every action inside the OwnerController class is written that way.

By reading this post we have learned:

  • How to work with a controller class
  • What routing is and how a Web API uses it
  • How to handle GET requests in a Web API
  • How to use DTOs while handling requests, and what a mapping library costs

In the next article, POST, PUT, and DELETE in ASP.NET Core Web API, we apply the same principles to the requests that change data. The series hub lists every part.

Tested with .NET 10 (SDK 10.0.302), EF Core 10.0.11 and AutoMapper 14.0.0.