A controller-based web API with ASP.NET Core

Shivam SainiShivam Saini
11 min read

This tutorial teaches the basics of building a controller-based web API that uses a database.
You will embark on a journey to create a fully functional API for managing "to-do" items, featuring GET, POST, PUT, and DELETE endpoints. This practical guide will lead you through the process of setting up controllers, routing, models, and database connectivity, establishing a strong foundation for further exploration.

overview


This tutorial builds the following API:

APIDescriptionRequest bodyResponse body
GET /api/todoitemsGet all to-do itemsNoneArray of to-do items
GET /api/todoitems/{id}Get an item by IDNoneTo-do item
POST /api/todoitemsAdd a new itemTo-do itemTo-do item
PUT /api/todoitems/{id}Update an existing itemTo-do itemNone
DELETE /api/todoitems/{id}Delete an itemNoneNone

The following diagram shows the design of the app.

Prerequisites


  • Visual Studio Code

  • C# Dev Kit for Visual Studio Code

  • .NET 9 SDK

Create a Web API project


  • Change directories to the folder that will contain the project folder in the terminal & run the following command.

      dotnet new webapi --use-controllers -o TodoApi
      cd TodoApi
      dotnet add package Microsoft.EntityFrameworkCore.InMemory
    

these commands:

◦ Create a new web API project. ◦ Add a NuGet package that is needed.

  • If Visual Studio Code doesn't offer to add build and debug assets, do it from Command Palette.

Run the project


The project template creates a WeatherForecast API

  • Trust the HTTPS development certificate by running the following command:
dotnet dev-certs https --trust
  • Run the following command to start the app on the https profile:
dotnet run --launch-profile https
  • The default browser is launched to https://localhost:<port>, where <port> is the randomly chosen port number displayed in the output. There's no endpoint at https://localhost:<port>, so the browser returns HTTP 404 Not Found.

  • Append /weatherforecast to the URL to test the WeatherForecast API.

Test the project


Create API testing UI with Swagger

We will utilizes the .NET package NSwag.AspNetCore, which integrates Swagger tools for generating a testing UI adhering to the OpenAPI specification:

  • NSwag: A .NET library that integrates Swagger directly into ASP.NET Core applications, providing middleware and configuration.

  • Swagger: A set of open-source tools such as OpenAPIGenerator and SwaggerUI that generate API testing pages that follow the OpenAPI specification.

  • OpenAPI specification: A document that describes the capabilities of the API, based on the XML and attribute annotations within the controllers and models.

Install Swagger tooling

  • Run the following command:
dotnet add package NSwag.AspNetCore

This command adds the NSwag.AspNetCore package, which contains tools to generate Swagger documents and UI. Because our project is using OpenAPI, we only use the NSwag package to generate the Swagger UI.

Configure Swagger middleware

  • In Program.cs, add the following highlighted code:
var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.UseSwaggerUi(options =>
    {
        options.DocumentPath = "/openapi/v1.json";
    });
}

The code provided activates the Swagger middleware, allowing the generated JSON document to be served via the Swagger UI. It is important to note that Swagger is exclusively enabled in a development environment; enabling it in a production environment may risk exposing sensitive information regarding the API’s structure and implementation.

The application utilizes the OpenAPI document, which is generated by OpenApi and can be found at /openapi/v1.json, to create the user interface. To view the generated OpenAPI specification for the WeatherForecast API while the project is operational, navigate to https://localhost:<port>/openapi/v1.json in your web browser.

The OpenAPI specification is a JSON formatted document that outlines the structure and capabilities of your API, detailing endpoints, request and response formats, parameters, and more. It serves as a comprehensive blueprint for your API, facilitating interaction with various tools.

Add a model class


A model is a set of classes that represent the data that the app manages. The model for this app is the TodoItem class.

  • Add a folder named Models.

  • Add a TodoItem.cs file to the Models folder with the following code:

namespace TodoApi.Models;

public class TodoItem
{
    public long Id { get; set; }
    public string? Name { get; set; }
    public bool IsComplete { get; set; }
}

The Id property functions as the unique key in a relational database.

Model classes can go anywhere in the project, but the Models folder is used by convention.

Add a database context


The database context is the main class that coordinates Entity Framework functionality for a data model. This class is created by deriving from the Microsoft.EntityFrameworkCore.DbContext class.

  • Add a TodoContext.cs file to the Models folder.

  • Enter the following code:

using Microsoft.EntityFrameworkCore;

namespace TodoApi.Models;

public class TodoContext : DbContext
{
    public TodoContext(DbContextOptions<TodoContext> options)
        : base(options)
    {
    }

    public DbSet<TodoItem> TodoItems { get; set; } = null!;
}

Register the database context


In ASP.NET Core, services such as the DB context must be registered with the dependency injection (DI) container. The container provides the service to controllers.Update Program.cs with the following code:

using Microsoft.EntityFrameworkCore;
using TodoApi.Models;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();
builder.Services.AddOpenApi();
builder.Services.AddDbContext<TodoContext>(opt =>
    opt.UseInMemoryDatabase("TodoList"));

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

app.UseHttpsRedirection();

app.UseAuthorization();

app.MapControllers();

app.Run();

The preceding code:

  • Adds using directives.

  • Adds the database context to the DI container.

  • Specifies that the database context will use an in-memory database.

Scaffold a controller


Make sure that all of your changes so far are saved.

  • In terminal opens at the TodoAPI project folder. Run the following commands:
dotnet add package Microsoft.VisualStudio.Web.CodeGeneration.Design
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Tools
dotnet tool uninstall -g dotnet-aspnet-codegenerator
dotnet tool install -g dotnet-aspnet-codegenerator
dotnet tool update -g dotnet-aspnet-codegenerator

The preceding commands:

  • Add NuGet packages required for scaffolding.

  • Install the scaffolding engine (dotnet-aspnet-codegenerator) after uninstalling any possible previous version.

Build the project.

Run the following command:

dotnet aspnet-codegenerator controller -name TodoItemsController -async -api -m TodoItem -dc TodoContext -outDir Controllers

The preceding command scaffolds the TodoItemsController.

The generated code performs several key functions:

1. ApiController Attribute: It marks the class with the [ApiController] attribute, which signifies that the controller is designed to handle web API requests. For detailed information about the specific behaviors this attribute enables, refer to the guide on creating web APIs with ASP.NET Core.

2. Dependency Injection (DI): The code utilizes dependency injection to incorporate the database context (`TodoContext`) into the controller. This context is essential for performing all CRUD operations within the controller methods.

3. Route Template Differences:

• For Controllers with Views, the route template includes the [action] token.

• For API Controllers, the route template does not include the [action] token.

When the [action] token is absent from the route template, the action name (method name) is excluded from the endpoint. This means that the method name associated with the action is not utilized in route matching.

Update the PostTodoItem create method


Refactor the PostTodoItem return statement to use the nameof operator for improved clarity and maintainability.

[HttpPost]
public async Task<ActionResult<TodoItem>> PostTodoItem(TodoItem todoItem)
{
    _context.TodoItems.Add(todoItem);
    await _context.SaveChangesAsync();

    //    return CreatedAtAction("GetTodoItem", new { id = todoItem.Id }, todoItem);
    return CreatedAtAction(nameof(GetTodoItem), new { id = todoItem.Id }, todoItem);
}

The code shown above represents an HTTP POST method, identified by the [HttpPost] attribute. This method reads the TodoItem object from the body of the incoming HTTP request.
(See: Attribute routing with Http[Verb] attributes for details.)

The CreatedAtAction method plays a key role here:

  • Returns an HTTP 201 (Created) status code when the operation succeeds — the standard response for creating a new resource.

  • Adds a Location header to the response, which points to the URI of the newly created to-do item.

  • Uses the GetTodoItem action to generate that URI. By applying the C# nameof operator, we avoid hard-coding the action name, improving maintainability and reducing errors.

Test PostTodoItem

  • With the app still running, in the browser, navigate to https://localhost:<port>/swagger to display the API testing page generated by Swagger. Click on TodoItems to expand the operations.

  • On the Swagger API testing page, select Post /api/todoitems > Try it out.

  • Note that the Request body field contains a generated example format reflecting the parameters for the API.

  • n the request body, provide the JSON for a to-do item, leaving out the optional id field.

{
  "name": "Sex on the beach",
  "isComplete": true
}
  • Select Execute.

  • Swagger provides a Responses pane below the Execute button.

A few useful details to note:

  • cURL: Swagger generates an example cURL command in Unix/Linux syntax. On macOS, you can run this directly in the Terminal (or any shell that supports Unix commands, such as Git Bash).

  • Request URL: Shows a simplified version of the HTTP request made by Swagger UI. Real requests may also include headers, query parameters, and a request body.

  • Server Response: Displays both the response body and headers. In this case, the body shows the id field set to 1.

  • Response Code: Returns 201 Created, which means the request succeeded and a new resource was created on the server.

Test the location header URI

Test the app by calling the endpoints from a browser or Swagger.

Exploring the GET methods

This API provides two GET endpoints:

  • GET /api/todoitems – retrieves all to-do items.

  • GET /api/todoitems/{id} – retrieves a single to-do item by its ID.

Earlier, we looked at an example of the GET /api/todoitems/{id} route.

Now, try adding another to-do item by following the POST instructions. Once it’s created, test the GET /api/todoitemsendpoint in Swagger to see the full list of items.

⚠️ Note: This project uses an in-memory database. That means whenever the app is stopped and restarted, the stored data is lost. If your GET request doesn’t return any items, simply use the POST endpoint again to add new data before testing.

Return values


The GetTodoItems and GetTodoItem methods return an ActionResult<T> type in ASP.NET Core. This means that the framework automatically converts the returned object into JSON and sends it as part of the response body.

When everything goes smoothly and there are no unhandled exceptions, the response will have a status code of 200 OK. However, if there are any unhandled exceptions, the response will instead show a 5xx error code.

The ActionResult type can represent different HTTP status codes, allowing for flexibility in response handling. For example, the GetTodoItem method can produce two possible outcomes:

• If there is no item that matches the requested ID, the method responds with a 404 Not Found error.

• If an item is successfully found, it returns a 200 status code along with a JSON response body containing the details of the item.

Prevent over-posting


Currently the sample app exposes the entire TodoItem object. Production apps typically limit the data that's input and returned using a subset of the model. There are multiple reasons behind this, and security is a major one. The subset of a model is usually referred to as a Data Transfer Object (DTO), input model, or view model. DTO is used in this tutorial.

A DTO may be used to:

  • Prevent over-posting.

  • Hide properties that clients are not supposed to view.

  • Omit some properties in order to reduce payload size.

  • Flatten object graphs that contain nested objects. Flattened object graphs can be more convenient for clients.

To demonstrate the DTO approach, update the TodoItem class to include a secret field:

namespace TodoApi.Models;

public class TodoItem
{
    public long Id { get; set; }
    public string? Name { get; set; }
    public bool IsComplete { get; set; }
    public string? Secret { get; set; }
}

The secret field needs to be hidden from this app, but an administrative app could choose to expose it.

Verify you can post and get the secret field.

Create a DTO model in a Models/TodoItemsDTO.cs file

namespace TodoApi.Models;

public class TodoItemDTO
{
    public long Id { get; set; }
    public string? Name { get; set; }
    public bool IsComplete { get; set; }
}

Update the TodoItemsController to use TodoItemDTO:

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using TodoApi.Models;

namespace TodoApi.Controllers;

[Route("api/[controller]")]
[ApiController]
public class TodoItemsController : ControllerBase
{
    private readonly TodoContext _context;

    public TodoItemsController(TodoContext context)
    {
        _context = context;
    }

    // GET: api/TodoItems
    [HttpGet]
    public async Task<ActionResult<IEnumerable<TodoItemDTO>>> GetTodoItems()
    {
        return await _context.TodoItems
            .Select(x => ItemToDTO(x))
            .ToListAsync();
    }

    // GET: api/TodoItems/5
    // <snippet_GetByID>
    [HttpGet("{id}")]
    public async Task<ActionResult<TodoItemDTO>> GetTodoItem(long id)
    {
        var todoItem = await _context.TodoItems.FindAsync(id);

        if (todoItem == null)
        {
            return NotFound();
        }

        return ItemToDTO(todoItem);
    }
    // </snippet_GetByID>

    // PUT: api/TodoItems/5
    // To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754
    // <snippet_Update>
    [HttpPut("{id}")]
    public async Task<IActionResult> PutTodoItem(long id, TodoItemDTO todoDTO)
    {
        if (id != todoDTO.Id)
        {
            return BadRequest();
        }

        var todoItem = await _context.TodoItems.FindAsync(id);
        if (todoItem == null)
        {
            return NotFound();
        }

        todoItem.Name = todoDTO.Name;
        todoItem.IsComplete = todoDTO.IsComplete;

        try
        {
            await _context.SaveChangesAsync();
        }
        catch (DbUpdateConcurrencyException) when (!TodoItemExists(id))
        {
            return NotFound();
        }

        return NoContent();
    }
    // </snippet_Update>

    // POST: api/TodoItems
    // To protect from overposting attacks, see https://go.microsoft.com/fwlink/?linkid=2123754
    // <snippet_Create>
    [HttpPost]
    public async Task<ActionResult<TodoItemDTO>> PostTodoItem(TodoItemDTO todoDTO)
    {
        var todoItem = new TodoItem
        {
            IsComplete = todoDTO.IsComplete,
            Name = todoDTO.Name
        };

        _context.TodoItems.Add(todoItem);
        await _context.SaveChangesAsync();

        return CreatedAtAction(
            nameof(GetTodoItem),
            new { id = todoItem.Id },
            ItemToDTO(todoItem));
    }
    // </snippet_Create>

    // DELETE: api/TodoItems/5
    [HttpDelete("{id}")]
    public async Task<IActionResult> DeleteTodoItem(long id)
    {
        var todoItem = await _context.TodoItems.FindAsync(id);
        if (todoItem == null)
        {
            return NotFound();
        }

        _context.TodoItems.Remove(todoItem);
        await _context.SaveChangesAsync();

        return NoContent();
    }

    private bool TodoItemExists(long id)
    {
        return _context.TodoItems.Any(e => e.Id == id);
    }

    private static TodoItemDTO ItemToDTO(TodoItem todoItem) =>
       new TodoItemDTO
       {
           Id = todoItem.Id,
           Name = todoItem.Name,
           IsComplete = todoItem.IsComplete
       };
}

Add authentication support to a web API


refer https://www.c-sharpcorner.com/article/basic-authentication-in-web-api/

Publish to Azure


refer https://learn.microsoft.com/en-us/azure/?product=popular

0
Subscribe to my newsletter

Read articles from Shivam Saini directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Shivam Saini
Shivam Saini