Tutorial: Create a minimal API with ASP.NET Core

Note

This isn't the latest version of this article. For the current release, see the .NET 8 version of this article.

Warning

This version of ASP.NET Core is no longer supported. For more information, see .NET and .NET Core Support Policy. For the current release, see the .NET 8 version of this article.

Important

This information relates to a pre-release product that may be substantially modified before it's commercially released. Microsoft makes no warranties, express or implied, with respect to the information provided here.

For the current release, see the .NET 8 version of this article.

By Rick Anderson and Tom Dykstra

Minimal APIs are architected to create HTTP APIs with minimal dependencies. They're ideal for microservices and apps that want to include only the minimum files, features, and dependencies in ASP.NET Core.

This tutorial teaches the basics of building a minimal API with ASP.NET Core. Another approach to creating APIs in ASP.NET Core is to use controllers. For help with choosing between minimal APIs and controller-based APIs, see APIs overview. For a tutorial on creating an API project based on controllers that contains more features, see Create a web API.

Overview

This tutorial creates the following API:

API Description Request body Response body
GET /todoitems Get all to-do items None Array of to-do items
GET /todoitems/complete Get completed to-do items None Array of to-do items
GET /todoitems/{id} Get an item by ID None To-do item
POST /todoitems Add a new item To-do item To-do item
PUT /todoitems/{id} Update an existing item   To-do item None
DELETE /todoitems/{id}     Delete an item     None None

Prerequisites

Create an API project

  • Start Visual Studio 2022 and select Create a new project.

  • In the Create a new project dialog:

    • Enter Empty in the Search for templates search box.
    • Select the ASP.NET Core Empty template and select Next.

    Visual Studio Create a new project

  • Name the project TodoApi and select Next.

  • In the Additional information dialog:

    • Select .NET 9.0 (Preview)
    • Uncheck Do not use top-level statements
    • Select Create

    Additional information

Examine the code

The Program.cs file contains the following code:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.Run();

The preceding code:

Run the app

Press Ctrl+F5 to run without the debugger.

Visual Studio displays the following dialog:

This project is configured to use SSL. To avoid SSL warnings in the browser you can choose to trust the self-signed certificate that IIS Express has generated. Would you like to trust the IIS Express SSL certificate?

Select Yes if you trust the IIS Express SSL certificate.

The following dialog is displayed:

Security warning dialog

Select Yes if you agree to trust the development certificate.

For information on trusting the Firefox browser, see Firefox SEC_ERROR_INADEQUATE_KEY_USAGE certificate error.

Visual Studio launches the Kestrel web server and opens a browser window.

Hello World! is displayed in the browser. The Program.cs file contains a minimal but complete app.

Close the browser window.

Add NuGet packages

NuGet packages must be added to support the database and diagnostics used in this tutorial.

  • From the Tools menu, select NuGet Package Manager > Manage NuGet Packages for Solution.
  • Select the Browse tab.
  • Select Include Prelease.
  • Enter Microsoft.EntityFrameworkCore.InMemory in the search box, and then select Microsoft.EntityFrameworkCore.InMemory.
  • Select the Project checkbox in the right pane and then select Install.
  • Follow the preceding instructions to add the Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore package.

The model and database context classes

  • In the project folder, create a file named Todo.cs with the following code:
public class Todo
{
    public int Id { get; set; }
    public string? Name { get; set; }
    public bool IsComplete { get; set; }
}

The preceding code creates the model for this app. A model is a class that represents data that the app manages.

  • Create a file named TodoDb.cs with the following code:
using Microsoft.EntityFrameworkCore;

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

    public DbSet<Todo> Todos => Set<Todo>();
}

The preceding code defines the database context, which is the main class that coordinates Entity Framework functionality for a data model. This class derives from the Microsoft.EntityFrameworkCore.DbContext class.

Add the API code

  • Replace the contents of the Program.cs file with the following code:
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
var app = builder.Build();

app.MapGet("/todoitems", async (TodoDb db) =>
    await db.Todos.ToListAsync());

app.MapGet("/todoitems/complete", async (TodoDb db) =>
    await db.Todos.Where(t => t.IsComplete).ToListAsync());

app.MapGet("/todoitems/{id}", async (int id, TodoDb db) =>
    await db.Todos.FindAsync(id)
        is Todo todo
            ? Results.Ok(todo)
            : Results.NotFound());

app.MapPost("/todoitems", async (Todo todo, TodoDb db) =>
{
    db.Todos.Add(todo);
    await db.SaveChangesAsync();

    return Results.Created($"/todoitems/{todo.Id}", todo);
});

app.MapPut("/todoitems/{id}", async (int id, Todo inputTodo, TodoDb db) =>
{
    var todo = await db.Todos.FindAsync(id);

    if (todo is null) return Results.NotFound();

    todo.Name = inputTodo.Name;
    todo.IsComplete = inputTodo.IsComplete;

    await db.SaveChangesAsync();

    return Results.NoContent();
});

app.MapDelete("/todoitems/{id}", async (int id, TodoDb db) =>
{
    if (await db.Todos.FindAsync(id) is Todo todo)
    {
        db.Todos.Remove(todo);
        await db.SaveChangesAsync();
        return Results.NoContent();
    }

    return Results.NotFound();
});

app.Run();

The following highlighted code adds the database context to the dependency injection (DI) container and enables displaying database-related exceptions:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
var app = builder.Build();

The DI container provides access to the database context and other services.

This tutorial uses Endpoints Explorer and .http files to test the API.

Test posting data

The following code in Program.cs creates an HTTP POST endpoint /todoitems that adds data to the in-memory database:

app.MapPost("/todoitems", async (Todo todo, TodoDb db) =>
{
    db.Todos.Add(todo);
    await db.SaveChangesAsync();

    return Results.Created($"/todoitems/{todo.Id}", todo);
});

Run the app. The browser displays a 404 error because there's no longer a / endpoint.

The POST endpoint will be used to add data to the app.

  • Select View > Other Windows > Endpoints Explorer.

  • Right-click the POST endpoint and select Generate request.

    Endpoints Explorer context menu highlighting Generate Request menu item.

    A new file is created in the project folder named TodoApi.http, with contents similar to the following example:

    @TodoApi_HostAddress = https://localhost:7031
    
    Post {{TodoApi_HostAddress}}/todoitems
    
    ###
    
    • The first line creates a variable that is used for all of the endpoints.
    • The next line defines a POST request.
    • The triple hashtag (###) line is a request delimiter: what comes after it is for a different request.
  • The POST request needs headers and a body. To define those parts of the request, add the following lines immediately after the POST request line:

    Content-Type: application/json
    
    {
      "name":"walk dog",
      "isComplete":true
    }
    

    The preceding code adds a Content-Type header and a JSON request body. The TodoApi.http file should now look like the following example, but with your port number:

    @TodoApi_HostAddress = https://localhost:7057
    
    Post {{TodoApi_HostAddress}}/todoitems
    Content-Type: application/json
    
    {
      "name":"walk dog",
      "isComplete":true
    }
    
    ###
    
  • Run the app.

  • Select the Send request link that is above the POST request line.

    .http file window with run link highlighted.

    The POST request is sent to the app and the response is displayed in the Response pane.

    .http file window with response from the POST request.

Examine the GET endpoints

The sample app implements several GET endpoints by calling MapGet:

API Description Request body Response body
GET /todoitems Get all to-do items None Array of to-do items
GET /todoitems/complete Get all completed to-do items None Array of to-do items
GET /todoitems/{id} Get an item by ID None To-do item
app.MapGet("/todoitems", async (TodoDb db) =>
    await db.Todos.ToListAsync());

app.MapGet("/todoitems/complete", async (TodoDb db) =>
    await db.Todos.Where(t => t.IsComplete).ToListAsync());

app.MapGet("/todoitems/{id}", async (int id, TodoDb db) =>
    await db.Todos.FindAsync(id)
        is Todo todo
            ? Results.Ok(todo)
            : Results.NotFound());

Test the GET endpoints

Test the app by calling the GET endpoints from a browser or by using Endpoints Explorer. The following steps are for Endpoints Explorer.

  • In Endpoints Explorer, right-click the first GET endpoint, and select Generate request.

    The following content is added to the TodoApi.http file:

    Get {{TodoApi_HostAddress}}/todoitems
    
    ###
    
  • Select the Send request link that is above the new GET request line.

    The GET request is sent to the app and the response is displayed in the Response pane.

  • The response body is similar to the following JSON:

    [
      {
        "id": 1,
        "name": "walk dog",
        "isComplete": true
      }
    ]
    
  • In Endpoints Explorer, right-click the /todoitems/{id} GET endpoint and select Generate request. The following content is added to the TodoApi.http file:

    GET {{TodoApi_HostAddress}}/todoitems/{id}
    
    ###
    
  • Replace {id} with 1.

  • Select the Send request link that is above the new GET request line.

    The GET request is sent to the app and the response is displayed in the Response pane.

  • The response body is similar to the following JSON:

    {
      "id": 1,
      "name": "walk dog",
      "isComplete": true
    }
    

This app uses an in-memory database. If the app is restarted, the GET request doesn't return any data. If no data is returned, POST data to the app and try the GET request again.

Return values

ASP.NET Core automatically serializes the object to JSON and writes the JSON into the body of the response message. The response code for this return type is 200 OK, assuming there are no unhandled exceptions. Unhandled exceptions are translated into 5xx errors.

The return types can represent a wide range of HTTP status codes. For example, GET /todoitems/{id} can return two different status values:

  • If no item matches the requested ID, the method returns a 404 status NotFound error code.
  • Otherwise, the method returns 200 with a JSON response body. Returning item results in an HTTP 200 response.

Examine the PUT endpoint

The sample app implements a single PUT endpoint using MapPut:

app.MapPut("/todoitems/{id}", async (int id, Todo inputTodo, TodoDb db) =>
{
    var todo = await db.Todos.FindAsync(id);

    if (todo is null) return Results.NotFound();

    todo.Name = inputTodo.Name;
    todo.IsComplete = inputTodo.IsComplete;

    await db.SaveChangesAsync();

    return Results.NoContent();
});

This method is similar to the MapPost method, except it uses HTTP PUT. A successful response returns 204 (No Content). According to the HTTP specification, a PUT request requires the client to send the entire updated entity, not just the changes. To support partial updates, use HTTP PATCH.

Test the PUT endpoint

This sample uses an in-memory database that must be initialized each time the app is started. There must be an item in the database before you make a PUT call. Call GET to ensure there's an item in the database before making a PUT call.

Update the to-do item that has Id = 1 and set its name to "feed fish".

  • In Endpoints Explorer, right-click the PUT endpoint, and select Generate request.

    The following content is added to the TodoApi.http file:

    Put {{TodoApi_HostAddress}}/todoitems/{id}
    
    ###
    
  • In the PUT request line, replace {id} with 1.

  • Add the following lines immediately after the PUT request line:

    Content-Type: application/json
    
    {
      "name": "feed fish",
      "isComplete": false
    }
    

    The preceding code adds a Content-Type header and a JSON request body.

  • Select the Send request link that is above the new PUT request line.

    The PUT request is sent to the app and the response is displayed in the Response pane. The response body is empty, and the status code is 204.

Examine and test the DELETE endpoint

The sample app implements a single DELETE endpoint using MapDelete:

app.MapDelete("/todoitems/{id}", async (int id, TodoDb db) =>
{
    if (await db.Todos.FindAsync(id) is Todo todo)
    {
        db.Todos.Remove(todo);
        await db.SaveChangesAsync();
        return Results.NoContent();
    }

    return Results.NotFound();
});
  • In Endpoints Explorer, right-click the DELETE endpoint and select Generate request.

    A DELETE request is added to TodoApi.http.

  • Replace {id} in the DELETE request line with 1. The DELETE request should look like the following example:

    DELETE {{TodoApi_HostAddress}}/todoitems/1
    
    ###
    
  • Select the Send request link for the DELETE request.

    The DELETE request is sent to the app and the response is displayed in the Response pane. The response body is empty, and the status code is 204.

Use the MapGroup API

The sample app code repeats the todoitems URL prefix each time it sets up an endpoint. APIs often have groups of endpoints with a common URL prefix, and the MapGroup method is available to help organize such groups. It reduces repetitive code and allows for customizing entire groups of endpoints with a single call to methods like RequireAuthorization and WithMetadata.

Replace the contents of Program.cs with the following code:

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
var app = builder.Build();

var todoItems = app.MapGroup("/todoitems");

todoItems.MapGet("/", async (TodoDb db) =>
    await db.Todos.ToListAsync());

todoItems.MapGet("/complete", async (TodoDb db) =>
    await db.Todos.Where(t => t.IsComplete).ToListAsync());

todoItems.MapGet("/{id}", async (int id, TodoDb db) =>
    await db.Todos.FindAsync(id)
        is Todo todo
            ? Results.Ok(todo)
            : Results.NotFound());

todoItems.MapPost("/", async (Todo todo, TodoDb db) =>
{
    db.Todos.Add(todo);
    await db.SaveChangesAsync();

    return Results.Created($"/todoitems/{todo.Id}", todo);
});

todoItems.MapPut("/{id}", async (int id, Todo inputTodo, TodoDb db) =>
{
    var todo = await db.Todos.FindAsync(id);

    if (todo is null) return Results.NotFound();

    todo.Name = inputTodo.Name;
    todo.IsComplete = inputTodo.IsComplete;

    await db.SaveChangesAsync();

    return Results.NoContent();
});

todoItems.MapDelete("/{id}", async (int id, TodoDb db) =>
{
    if (await db.Todos.FindAsync(id) is Todo todo)
    {
        db.Todos.Remove(todo);
        await db.SaveChangesAsync();
        return Results.NoContent();
    }

    return Results.NotFound();
});

app.Run();

The preceding code has the following changes:

  • Adds var todoItems = app.MapGroup("/todoitems"); to set up the group using the URL prefix /todoitems.
  • Changes all the app.Map<HttpVerb> methods to todoItems.Map<HttpVerb>.
  • Removes the URL prefix /todoitems from the Map<HttpVerb> method calls.

Test the endpoints to verify that they work the same.

Use the TypedResults API

Returning TypedResults rather than Results has several advantages, including testability and automatically returning the response type metadata for OpenAPI to describe the endpoint. For more information, see TypedResults vs Results.

The Map<HttpVerb> methods can call route handler methods instead of using lambdas. To see an example, update Program.cs with the following code:

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
var app = builder.Build();

var todoItems = app.MapGroup("/todoitems");

todoItems.MapGet("/", GetAllTodos);
todoItems.MapGet("/complete", GetCompleteTodos);
todoItems.MapGet("/{id}", GetTodo);
todoItems.MapPost("/", CreateTodo);
todoItems.MapPut("/{id}", UpdateTodo);
todoItems.MapDelete("/{id}", DeleteTodo);

app.Run();

static async Task<IResult> GetAllTodos(TodoDb db)
{
    return TypedResults.Ok(await db.Todos.ToArrayAsync());
}

static async Task<IResult> GetCompleteTodos(TodoDb db)
{
    return TypedResults.Ok(await db.Todos.Where(t => t.IsComplete).ToListAsync());
}

static async Task<IResult> GetTodo(int id, TodoDb db)
{
    return await db.Todos.FindAsync(id)
        is Todo todo
            ? TypedResults.Ok(todo)
            : TypedResults.NotFound();
}

static async Task<IResult> CreateTodo(Todo todo, TodoDb db)
{
    db.Todos.Add(todo);
    await db.SaveChangesAsync();

    return TypedResults.Created($"/todoitems/{todo.Id}", todo);
}

static async Task<IResult> UpdateTodo(int id, Todo inputTodo, TodoDb db)
{
    var todo = await db.Todos.FindAsync(id);

    if (todo is null) return TypedResults.NotFound();

    todo.Name = inputTodo.Name;
    todo.IsComplete = inputTodo.IsComplete;

    await db.SaveChangesAsync();

    return TypedResults.NoContent();
}

static async Task<IResult> DeleteTodo(int id, TodoDb db)
{
    if (await db.Todos.FindAsync(id) is Todo todo)
    {
        db.Todos.Remove(todo);
        await db.SaveChangesAsync();
        return TypedResults.NoContent();
    }

    return TypedResults.NotFound();
}

The Map<HttpVerb> code now calls methods instead of lambdas:

var todoItems = app.MapGroup("/todoitems");

todoItems.MapGet("/", GetAllTodos);
todoItems.MapGet("/complete", GetCompleteTodos);
todoItems.MapGet("/{id}", GetTodo);
todoItems.MapPost("/", CreateTodo);
todoItems.MapPut("/{id}", UpdateTodo);
todoItems.MapDelete("/{id}", DeleteTodo);

These methods return objects that implement IResult and are defined by TypedResults:

static async Task<IResult> GetAllTodos(TodoDb db)
{
    return TypedResults.Ok(await db.Todos.ToArrayAsync());
}

static async Task<IResult> GetCompleteTodos(TodoDb db)
{
    return TypedResults.Ok(await db.Todos.Where(t => t.IsComplete).ToListAsync());
}

static async Task<IResult> GetTodo(int id, TodoDb db)
{
    return await db.Todos.FindAsync(id)
        is Todo todo
            ? TypedResults.Ok(todo)
            : TypedResults.NotFound();
}

static async Task<IResult> CreateTodo(Todo todo, TodoDb db)
{
    db.Todos.Add(todo);
    await db.SaveChangesAsync();

    return TypedResults.Created($"/todoitems/{todo.Id}", todo);
}

static async Task<IResult> UpdateTodo(int id, Todo inputTodo, TodoDb db)
{
    var todo = await db.Todos.FindAsync(id);

    if (todo is null) return TypedResults.NotFound();

    todo.Name = inputTodo.Name;
    todo.IsComplete = inputTodo.IsComplete;

    await db.SaveChangesAsync();

    return TypedResults.NoContent();
}

static async Task<IResult> DeleteTodo(int id, TodoDb db)
{
    if (await db.Todos.FindAsync(id) is Todo todo)
    {
        db.Todos.Remove(todo);
        await db.SaveChangesAsync();
        return TypedResults.NoContent();
    }

    return TypedResults.NotFound();
}

Unit tests can call these methods and test that they return the correct type. For example, if the method is GetAllTodos:

static async Task<IResult> GetAllTodos(TodoDb db)
{
    return TypedResults.Ok(await db.Todos.ToArrayAsync());
}

Unit test code can verify that an object of type Ok<Todo[]> is returned from the handler method. For example:

public async Task GetAllTodos_ReturnsOkOfTodosResult()
{
    // Arrange
    var db = CreateDbContext();

    // Act
    var result = await TodosApi.GetAllTodos(db);

    // Assert: Check for the correct returned type
    Assert.IsType<Ok<Todo[]>>(result);
}

Prevent over-posting

Currently the sample app exposes the entire Todo object. Production apps In production applications, a subset of the model is often used to restrict the data that can be input and returned. 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 article.

A DTO can be used to:

  • Prevent over-posting.
  • Hide properties that clients aren't supposed to view.
  • Omit some properties 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 Todo class to include a secret field:

public class Todo
{
    public int 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 file named TodoItemDTO.cs with the following code:

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

    public TodoItemDTO() { }
    public TodoItemDTO(Todo todoItem) =>
    (Id, Name, IsComplete) = (todoItem.Id, todoItem.Name, todoItem.IsComplete);
}

Replace the contents of the Program.cs file with the following code to use this DTO model:

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
var app = builder.Build();

RouteGroupBuilder todoItems = app.MapGroup("/todoitems");

todoItems.MapGet("/", GetAllTodos);
todoItems.MapGet("/complete", GetCompleteTodos);
todoItems.MapGet("/{id}", GetTodo);
todoItems.MapPost("/", CreateTodo);
todoItems.MapPut("/{id}", UpdateTodo);
todoItems.MapDelete("/{id}", DeleteTodo);

app.Run();

static async Task<IResult> GetAllTodos(TodoDb db)
{
    return TypedResults.Ok(await db.Todos.Select(x => new TodoItemDTO(x)).ToArrayAsync());
}

static async Task<IResult> GetCompleteTodos(TodoDb db) {
    return TypedResults.Ok(await db.Todos.Where(t => t.IsComplete).Select(x => new TodoItemDTO(x)).ToListAsync());
}

static async Task<IResult> GetTodo(int id, TodoDb db)
{
    return await db.Todos.FindAsync(id)
        is Todo todo
            ? TypedResults.Ok(new TodoItemDTO(todo))
            : TypedResults.NotFound();
}

static async Task<IResult> CreateTodo(TodoItemDTO todoItemDTO, TodoDb db)
{
    var todoItem = new Todo
    {
        IsComplete = todoItemDTO.IsComplete,
        Name = todoItemDTO.Name
    };

    db.Todos.Add(todoItem);
    await db.SaveChangesAsync();

    todoItemDTO = new TodoItemDTO(todoItem);

    return TypedResults.Created($"/todoitems/{todoItem.Id}", todoItemDTO);
}

static async Task<IResult> UpdateTodo(int id, TodoItemDTO todoItemDTO, TodoDb db)
{
    var todo = await db.Todos.FindAsync(id);

    if (todo is null) return TypedResults.NotFound();

    todo.Name = todoItemDTO.Name;
    todo.IsComplete = todoItemDTO.IsComplete;

    await db.SaveChangesAsync();

    return TypedResults.NoContent();
}

static async Task<IResult> DeleteTodo(int id, TodoDb db)
{
    if (await db.Todos.FindAsync(id) is Todo todo)
    {
        db.Todos.Remove(todo);
        await db.SaveChangesAsync();
        return TypedResults.NoContent();
    }

    return TypedResults.NotFound();
}

Verify you can post and get all fields except the secret field.

Troubleshooting with the completed sample

If you run into a problem you can't resolve, compare your code to the completed project. View or download completed project (how to download).

Next steps

Learn more

See Minimal APIs quick reference

Minimal APIs are architected to create HTTP APIs with minimal dependencies. They are ideal for microservices and apps that want to include only the minimum files, features, and dependencies in ASP.NET Core.

This tutorial teaches the basics of building a minimal API with ASP.NET Core. Another approach to creating APIs in ASP.NET Core is to use controllers. For help in choosing between minimal APIs and controller-based APIs, see APIs overview. For a tutorial on creating an API project based on controllers that contains more features, see Create a web API.

Overview

This tutorial creates the following API:

API Description Request body Response body
GET /todoitems Get all to-do items None Array of to-do items
GET /todoitems/complete Get completed to-do items None Array of to-do items
GET /todoitems/{id} Get an item by ID None To-do item
POST /todoitems Add a new item To-do item To-do item
PUT /todoitems/{id} Update an existing item   To-do item None
DELETE /todoitems/{id}     Delete an item     None None

Prerequisites

Create an API project

  • Start Visual Studio 2022 and select Create a new project.

  • In the Create a new project dialog:

    • Enter Empty in the Search for templates search box.
    • Select the ASP.NET Core Empty template and select Next.

    Visual Studio Create a new project

  • Name the project TodoApi and select Next.

  • In the Additional information dialog:

    • Select .NET 7.0
    • Uncheck Do not use top-level statements
    • Select Create

    Additional information

Examine the code

The Program.cs file contains the following code:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.Run();

The preceding code:

Run the app

Press Ctrl+F5 to run without the debugger.

Visual Studio displays the following dialog:

This project is configured to use SSL. To avoid SSL warnings in the browser you can choose to trust the self-signed certificate that IIS Express has generated. Would you like to trust the IIS Express SSL certificate?

Select Yes if you trust the IIS Express SSL certificate.

The following dialog is displayed:

Security warning dialog

Select Yes if you agree to trust the development certificate.

For information on trusting the Firefox browser, see Firefox SEC_ERROR_INADEQUATE_KEY_USAGE certificate error.

Visual Studio launches the Kestrel web server and opens a browser window.

Hello World! is displayed in the browser. The Program.cs file contains a minimal but complete app.

Add NuGet packages

NuGet packages must be added to support the database and diagnostics used in this tutorial.

  • From the Tools menu, select NuGet Package Manager > Manage NuGet Packages for Solution.
  • Select the Browse tab.
  • Enter Microsoft.EntityFrameworkCore.InMemory in the search box, and then select Microsoft.EntityFrameworkCore.InMemory.
  • Select the Project checkbox in the right pane.
  • In the Version drop down select the latest version 7 available, for example 7.0.17, and then select Install.
  • Follow the preceding instructions to add the Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore package with the latest version 7 available.

The model and database context classes

In the project folder, create a file named Todo.cs with the following code:

public class Todo
{
    public int Id { get; set; }
    public string? Name { get; set; }
    public bool IsComplete { get; set; }
}

The preceding code creates the model for this app. A model is a class that represents data that the app manages.

Create a file named TodoDb.cs with the following code:

using Microsoft.EntityFrameworkCore;

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

    public DbSet<Todo> Todos => Set<Todo>();
}

The preceding code defines the database context, which is the main class that coordinates Entity Framework functionality for a data model. This class derives from the Microsoft.EntityFrameworkCore.DbContext class.

Add the API code

Replace the contents of the Program.cs file with the following code:

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
var app = builder.Build();

app.MapGet("/todoitems", async (TodoDb db) =>
    await db.Todos.ToListAsync());

app.MapGet("/todoitems/complete", async (TodoDb db) =>
    await db.Todos.Where(t => t.IsComplete).ToListAsync());

app.MapGet("/todoitems/{id}", async (int id, TodoDb db) =>
    await db.Todos.FindAsync(id)
        is Todo todo
            ? Results.Ok(todo)
            : Results.NotFound());

app.MapPost("/todoitems", async (Todo todo, TodoDb db) =>
{
    db.Todos.Add(todo);
    await db.SaveChangesAsync();

    return Results.Created($"/todoitems/{todo.Id}", todo);
});

app.MapPut("/todoitems/{id}", async (int id, Todo inputTodo, TodoDb db) =>
{
    var todo = await db.Todos.FindAsync(id);

    if (todo is null) return Results.NotFound();

    todo.Name = inputTodo.Name;
    todo.IsComplete = inputTodo.IsComplete;

    await db.SaveChangesAsync();

    return Results.NoContent();
});

app.MapDelete("/todoitems/{id}", async (int id, TodoDb db) =>
{
    if (await db.Todos.FindAsync(id) is Todo todo)
    {
        db.Todos.Remove(todo);
        await db.SaveChangesAsync();
        return Results.NoContent();
    }

    return Results.NotFound();
});

app.Run();

The following highlighted code adds the database context to the dependency injection (DI) container and enables displaying database-related exceptions:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
var app = builder.Build();

The DI container provides access to the database context and other services.

Create API testing UI with Swagger

There are many available web API testing tools to choose from, and you can follow this tutorial's introductory API test steps with your own preferred tool.

This tutorial 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.

For more information on using OpenAPI and NSwag with ASP.NET, see ASP.NET Core web API documentation with Swagger / OpenAPI.

Install Swagger tooling

  • Run the following command:

    dotnet add package NSwag.AspNetCore
    

The previous command adds the NSwag.AspNetCore package, which contains tools to generate Swagger documents and UI.

Configure Swagger middleware

  • Add the following highlighted code before app is defined in line var app = builder.Build();

    using Microsoft.EntityFrameworkCore;
    
    var builder = WebApplication.CreateBuilder(args);
    builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
    builder.Services.AddDatabaseDeveloperPageExceptionFilter();
    
    builder.Services.AddEndpointsApiExplorer();
    builder.Services.AddOpenApiDocument(config =>
    {
        config.DocumentName = "TodoAPI";
        config.Title = "TodoAPI v1";
        config.Version = "v1";
    });
    var app = builder.Build();
    

In the previous code:

  • builder.Services.AddEndpointsApiExplorer();: Enables the API Explorer, which is a service that provides metadata about the HTTP API. The API Explorer is used by Swagger to generate the Swagger document.

  • builder.Services.AddOpenApiDocument(config => {...});: Adds the Swagger OpenAPI document generator to the application services and configures it to provide more information about the API, such as its title and version. For information on providing more robust API details, see Get started with NSwag and ASP.NET Core

  • Add the following highlighted code to the next line after app is defined in line var app = builder.Build();

    var app = builder.Build();
    if (app.Environment.IsDevelopment())
    {
        app.UseOpenApi();
        app.UseSwaggerUi(config =>
        {
            config.DocumentTitle = "TodoAPI";
            config.Path = "/swagger";
            config.DocumentPath = "/swagger/{documentName}/swagger.json";
            config.DocExpansion = "list";
        });
    }
    

    The previous code enables the Swagger middleware for serving the generated JSON document and the Swagger UI. Swagger is only enabled in a development environment. Enabling Swagger in a production environment could expose potentially sensitive details about the API's structure and implementation.

Test posting data

The following code in Program.cs creates an HTTP POST endpoint /todoitems that adds data to the in-memory database:

app.MapPost("/todoitems", async (Todo todo, TodoDb db) =>
{
    db.Todos.Add(todo);
    await db.SaveChangesAsync();

    return Results.Created($"/todoitems/{todo.Id}", todo);
});

Run the app. The browser displays a 404 error because there's no longer a / endpoint.

The POST endpoint will be used to add data to the app.

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

    Swagger generated API testing page

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

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

  • In the request body enter JSON for a to-do item, without specifying the optional id:

    {
      "name":"walk dog",
      "isComplete":true
    }
    
  • Select Execute.

    Swagger with Post

Swagger provides a Responses pane below the Execute button.

Swagger with Post response

Note a few of the useful details:

  • cURL: Swagger provides an example cURL command in Unix/Linux syntax, which can be run at the command line with any bash shell that uses Unix/Linux syntax, including Git Bash from Git for Windows.
  • Request URL: A simplified representation of the HTTP request made by Swagger UI's JavaScript code for the API call. Actual requests can include details such as headers and query parameters and a request body.
  • Server response: Includes the response body and headers. The response body shows the id was set to 1.
  • Response Code: A 201 HTTP status code was returned, indicating that the request was successfully processed and resulted in the creation of a new resource.

Examine the GET endpoints

The sample app implements several GET endpoints by calling MapGet:

API Description Request body Response body
GET /todoitems Get all to-do items None Array of to-do items
GET /todoitems/complete Get all completed to-do items None Array of to-do items
GET /todoitems/{id} Get an item by ID None To-do item
app.MapGet("/todoitems", async (TodoDb db) =>
    await db.Todos.ToListAsync());

app.MapGet("/todoitems/complete", async (TodoDb db) =>
    await db.Todos.Where(t => t.IsComplete).ToListAsync());

app.MapGet("/todoitems/{id}", async (int id, TodoDb db) =>
    await db.Todos.FindAsync(id)
        is Todo todo
            ? Results.Ok(todo)
            : Results.NotFound());

Test the GET endpoints

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

  • In Swagger select GET /todoitems > Try it out > Execute.

  • Alternatively, call GET /todoitems from a browser by entering the URI http://localhost:<port>/todoitems. For example, http://localhost:5001/todoitems

The call to GET /todoitems produces a response similar to the following:

[
  {
    "id": 1,
    "name": "walk dog",
    "isComplete": true
  }
]
  • Call GET /todoitems/{id} in Swagger to return data from a specific id:

    • Select GET /todoitems > Try it out.
    • Set the id field to 1 and select Execute.
  • Alternatively, call GET /todoitems from a browser by entering the URI https://localhost:<port>/todoitems/1. For example, https://localhost:5001/todoitems/1

  • The response is similar to the following:

    {
      "id": 1,
      "name": "walk dog",
      "isComplete": true
    }
    

This app uses an in-memory database. If the app is restarted, the GET request doesn't return any data. If no data is returned, POST data to the app and try the GET request again.

Return values

ASP.NET Core automatically serializes the object to JSON and writes the JSON into the body of the response message. The response code for this return type is 200 OK, assuming there are no unhandled exceptions. Unhandled exceptions are translated into 5xx errors.

The return types can represent a wide range of HTTP status codes. For example, GET /todoitems/{id} can return two different status values:

  • If no item matches the requested ID, the method returns a 404 status NotFound error code.
  • Otherwise, the method returns 200 with a JSON response body. Returning item results in an HTTP 200 response.

Examine the PUT endpoint

The sample app implements a single PUT endpoint using MapPut:

app.MapPut("/todoitems/{id}", async (int id, Todo inputTodo, TodoDb db) =>
{
    var todo = await db.Todos.FindAsync(id);

    if (todo is null) return Results.NotFound();

    todo.Name = inputTodo.Name;
    todo.IsComplete = inputTodo.IsComplete;

    await db.SaveChangesAsync();

    return Results.NoContent();
});

This method is similar to the MapPost method, except it uses HTTP PUT. A successful response returns 204 (No Content). According to the HTTP specification, a PUT request requires the client to send the entire updated entity, not just the changes. To support partial updates, use HTTP PATCH.

Test the PUT endpoint

This sample uses an in-memory database that must be initialized each time the app is started. There must be an item in the database before you make a PUT call. Call GET to ensure there's an item in the database before making a PUT call.

Update the to-do item that has Id = 1 and set its name to "feed fish".

Use Swagger to send a PUT request:

  • Select Put /todoitems/{id} > Try it out.

  • Set the id field to 1.

  • Set the request body to the following JSON:

    {
      "name": "feed fish",
      "isComplete": false
    }
    
  • Select Execute.

Examine and test the DELETE endpoint

The sample app implements a single DELETE endpoint using MapDelete:

app.MapDelete("/todoitems/{id}", async (int id, TodoDb db) =>
{
    if (await db.Todos.FindAsync(id) is Todo todo)
    {
        db.Todos.Remove(todo);
        await db.SaveChangesAsync();
        return Results.NoContent();
    }

    return Results.NotFound();
});

Use Swagger to send a DELETE request:

  • Select DELETE /todoitems/{id} > Try it out.

  • Set the ID field to 1 and select Execute.

    The DELETE request is sent to the app and the response is displayed in the Responses pane. The response body is empty, and the Server response status code is 204.

Use the MapGroup API

The sample app code repeats the todoitems URL prefix each time it sets up an endpoint. APIs often have groups of endpoints with a common URL prefix, and the MapGroup method is available to help organize such groups. It reduces repetitive code and allows for customizing entire groups of endpoints with a single call to methods like RequireAuthorization and WithMetadata.

Replace the contents of Program.cs with the following code:

using NSwag.AspNetCore;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApiDocument(config =>
{
    config.DocumentName = "TodoAPI";
    config.Title = "TodoAPI v1";
    config.Version = "v1";
});

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseOpenApi();
    app.UseSwaggerUi(config =>
    {
        config.DocumentTitle = "TodoAPI";
        config.Path = "/swagger";
        config.DocumentPath = "/swagger/{documentName}/swagger.json";
        config.DocExpansion = "list";
    });
}

var todoItems = app.MapGroup("/todoitems");

todoItems.MapGet("/", async (TodoDb db) =>
    await db.Todos.ToListAsync());

todoItems.MapGet("/complete", async (TodoDb db) =>
    await db.Todos.Where(t => t.IsComplete).ToListAsync());

todoItems.MapGet("/{id}", async (int id, TodoDb db) =>
    await db.Todos.FindAsync(id)
        is Todo todo
            ? Results.Ok(todo)
            : Results.NotFound());

todoItems.MapPost("/", async (Todo todo, TodoDb db) =>
{
    db.Todos.Add(todo);
    await db.SaveChangesAsync();

    return Results.Created($"/todoitems/{todo.Id}", todo);
});

todoItems.MapPut("/{id}", async (int id, Todo inputTodo, TodoDb db) =>
{
    var todo = await db.Todos.FindAsync(id);

    if (todo is null) return Results.NotFound();

    todo.Name = inputTodo.Name;
    todo.IsComplete = inputTodo.IsComplete;

    await db.SaveChangesAsync();

    return Results.NoContent();
});

todoItems.MapDelete("/{id}", async (int id, TodoDb db) =>
{
    if (await db.Todos.FindAsync(id) is Todo todo)
    {
        db.Todos.Remove(todo);
        await db.SaveChangesAsync();
        return Results.NoContent();
    }

    return Results.NotFound();
});

app.Run();

The preceding code has the following changes:

  • Adds var todoItems = app.MapGroup("/todoitems"); to set up the group using the URL prefix /todoitems.
  • Changes all the app.Map<HttpVerb> methods to todoItems.Map<HttpVerb>.
  • Removes the URL prefix /todoitems from the Map<HttpVerb> method calls.

Test the endpoints to verify that they work the same.

Use the TypedResults API

Returning TypedResults rather than Results has several advantages, including testability and automatically returning the response type metadata for OpenAPI to describe the endpoint. For more information, see TypedResults vs Results.

The Map<HttpVerb> methods can call route handler methods instead of using lambdas. To see an example, update Program.cs with the following code:

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
var app = builder.Build();

var todoItems = app.MapGroup("/todoitems");

todoItems.MapGet("/", GetAllTodos);
todoItems.MapGet("/complete", GetCompleteTodos);
todoItems.MapGet("/{id}", GetTodo);
todoItems.MapPost("/", CreateTodo);
todoItems.MapPut("/{id}", UpdateTodo);
todoItems.MapDelete("/{id}", DeleteTodo);

app.Run();

static async Task<IResult> GetAllTodos(TodoDb db)
{
    return TypedResults.Ok(await db.Todos.ToArrayAsync());
}

static async Task<IResult> GetCompleteTodos(TodoDb db)
{
    return TypedResults.Ok(await db.Todos.Where(t => t.IsComplete).ToListAsync());
}

static async Task<IResult> GetTodo(int id, TodoDb db)
{
    return await db.Todos.FindAsync(id)
        is Todo todo
            ? TypedResults.Ok(todo)
            : TypedResults.NotFound();
}

static async Task<IResult> CreateTodo(Todo todo, TodoDb db)
{
    db.Todos.Add(todo);
    await db.SaveChangesAsync();

    return TypedResults.Created($"/todoitems/{todo.Id}", todo);
}

static async Task<IResult> UpdateTodo(int id, Todo inputTodo, TodoDb db)
{
    var todo = await db.Todos.FindAsync(id);

    if (todo is null) return TypedResults.NotFound();

    todo.Name = inputTodo.Name;
    todo.IsComplete = inputTodo.IsComplete;

    await db.SaveChangesAsync();

    return TypedResults.NoContent();
}

static async Task<IResult> DeleteTodo(int id, TodoDb db)
{
    if (await db.Todos.FindAsync(id) is Todo todo)
    {
        db.Todos.Remove(todo);
        await db.SaveChangesAsync();
        return TypedResults.NoContent();
    }

    return TypedResults.NotFound();
}

The Map<HttpVerb> code now calls methods instead of lambdas:

var todoItems = app.MapGroup("/todoitems");

todoItems.MapGet("/", GetAllTodos);
todoItems.MapGet("/complete", GetCompleteTodos);
todoItems.MapGet("/{id}", GetTodo);
todoItems.MapPost("/", CreateTodo);
todoItems.MapPut("/{id}", UpdateTodo);
todoItems.MapDelete("/{id}", DeleteTodo);

These methods return objects that implement IResult and are defined by TypedResults:

static async Task<IResult> GetAllTodos(TodoDb db)
{
    return TypedResults.Ok(await db.Todos.ToArrayAsync());
}

static async Task<IResult> GetCompleteTodos(TodoDb db)
{
    return TypedResults.Ok(await db.Todos.Where(t => t.IsComplete).ToListAsync());
}

static async Task<IResult> GetTodo(int id, TodoDb db)
{
    return await db.Todos.FindAsync(id)
        is Todo todo
            ? TypedResults.Ok(todo)
            : TypedResults.NotFound();
}

static async Task<IResult> CreateTodo(Todo todo, TodoDb db)
{
    db.Todos.Add(todo);
    await db.SaveChangesAsync();

    return TypedResults.Created($"/todoitems/{todo.Id}", todo);
}

static async Task<IResult> UpdateTodo(int id, Todo inputTodo, TodoDb db)
{
    var todo = await db.Todos.FindAsync(id);

    if (todo is null) return TypedResults.NotFound();

    todo.Name = inputTodo.Name;
    todo.IsComplete = inputTodo.IsComplete;

    await db.SaveChangesAsync();

    return TypedResults.NoContent();
}

static async Task<IResult> DeleteTodo(int id, TodoDb db)
{
    if (await db.Todos.FindAsync(id) is Todo todo)
    {
        db.Todos.Remove(todo);
        await db.SaveChangesAsync();
        return TypedResults.NoContent();
    }

    return TypedResults.NotFound();
}

Unit tests can call these methods and test that they return the correct type. For example, if the method is GetAllTodos:

static async Task<IResult> GetAllTodos(TodoDb db)
{
    return TypedResults.Ok(await db.Todos.ToArrayAsync());
}

Unit test code can verify that an object of type Ok<Todo[]> is returned from the handler method. For example:

public async Task GetAllTodos_ReturnsOkOfTodosResult()
{
    // Arrange
    var db = CreateDbContext();

    // Act
    var result = await TodosApi.GetAllTodos(db);

    // Assert: Check for the correct returned type
    Assert.IsType<Ok<Todo[]>>(result);
}

Prevent over-posting

Currently the sample app exposes the entire Todo object. Production apps In production applications, a subset of the model is often used to restrict the data that can be input and returned. 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 article.

A DTO can be used to:

  • Prevent over-posting.
  • Hide properties that clients aren't supposed to view.
  • Omit some properties 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 Todo class to include a secret field:

public class Todo
{
    public int 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 file named TodoItemDTO.cs with the following code:

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

    public TodoItemDTO() { }
    public TodoItemDTO(Todo todoItem) =>
    (Id, Name, IsComplete) = (todoItem.Id, todoItem.Name, todoItem.IsComplete);
}

Replace the contents of the Program.cs file with the following code to use this DTO model:

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
var app = builder.Build();

app.MapGet("/todoitems", async (TodoDb db) =>
    await db.Todos.Select(x => new TodoItemDTO(x)).ToListAsync());

app.MapGet("/todoitems/{id}", async (int id, TodoDb db) =>
    await db.Todos.FindAsync(id)
        is Todo todo
            ? Results.Ok(new TodoItemDTO(todo))
            : Results.NotFound());

app.MapPost("/todoitems", async (TodoItemDTO todoItemDTO, TodoDb db) =>
{
    var todoItem = new Todo
    {
        IsComplete = todoItemDTO.IsComplete,
        Name = todoItemDTO.Name
    };

    db.Todos.Add(todoItem);
    await db.SaveChangesAsync();

    return Results.Created($"/todoitems/{todoItem.Id}", new TodoItemDTO(todoItem));
});

app.MapPut("/todoitems/{id}", async (int id, TodoItemDTO todoItemDTO, TodoDb db) =>
{
    var todo = await db.Todos.FindAsync(id);

    if (todo is null) return Results.NotFound();

    todo.Name = todoItemDTO.Name;
    todo.IsComplete = todoItemDTO.IsComplete;

    await db.SaveChangesAsync();

    return Results.NoContent();
});

app.MapDelete("/todoitems/{id}", async (int id, TodoDb db) =>
{
    if (await db.Todos.FindAsync(id) is Todo todo)
    {
        db.Todos.Remove(todo);
        await db.SaveChangesAsync();
        return Results.NoContent();
    }

    return Results.NotFound();
});

app.Run();

public class Todo
{
    public int Id { get; set; }
    public string? Name { get; set; }
    public bool IsComplete { get; set; }
    public string? Secret { get; set; }
}

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

    public TodoItemDTO() { }
    public TodoItemDTO(Todo todoItem) =>
    (Id, Name, IsComplete) = (todoItem.Id, todoItem.Name, todoItem.IsComplete);
}


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

    public DbSet<Todo> Todos => Set<Todo>();
}

Verify you can post and get all fields except the secret field.

Troubleshooting with the completed sample

If you run into a problem you can't resolve, compare your code to the completed project. View or download completed project (how to download).

Next steps

Learn more

See Minimal APIs quick reference

Minimal APIs are architected to create HTTP APIs with minimal dependencies. They are ideal for microservices and apps that want to include only the minimum files, features, and dependencies in ASP.NET Core.

This tutorial teaches the basics of building a minimal API with ASP.NET Core. Another approach to creating APIs in ASP.NET Core is to use controllers. For help in choosing between minimal APIs and controller-based APIs, see APIs overview. For a tutorial on creating an API project based on controllers that contains more features, see Create a web API.

Overview

This tutorial creates the following API:

API Description Request body Response body
GET /todoitems Get all to-do items None Array of to-do items
GET /todoitems/complete Get completed to-do items None Array of to-do items
GET /todoitems/{id} Get an item by ID None To-do item
POST /todoitems Add a new item To-do item To-do item
PUT /todoitems/{id} Update an existing item   To-do item None
DELETE /todoitems/{id}     Delete an item     None None

Prerequisites

Create an API project

  • Start Visual Studio 2022 and select Create a new project.

  • In the Create a new project dialog:

    • Enter Empty in the Search for templates search box.
    • Select the ASP.NET Core Empty template and select Next.

    Visual Studio Create a new project

  • Name the project TodoApi and select Next.

  • In the Additional information dialog:

    • Select .NET 6.0
    • Uncheck Do not use top-level statements
    • Select Create

Examine the code

The Program.cs file contains the following code:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.Run();

The preceding code:

Run the app

Press Ctrl+F5 to run without the debugger.

Visual Studio displays the following dialog:

This project is configured to use SSL. To avoid SSL warnings in the browser you can choose to trust the self-signed certificate that IIS Express has generated. Would you like to trust the IIS Express SSL certificate?

Select Yes if you trust the IIS Express SSL certificate.

The following dialog is displayed:

Security warning dialog

Select Yes if you agree to trust the development certificate.

For information on trusting the Firefox browser, see Firefox SEC_ERROR_INADEQUATE_KEY_USAGE certificate error.

Visual Studio launches the Kestrel web server and opens a browser window.

Hello World! is displayed in the browser. The Program.cs file contains a minimal but complete app.

Add NuGet packages

NuGet packages must be added to support the database and diagnostics used in this tutorial.

  • From the Tools menu, select NuGet Package Manager > Manage NuGet Packages for Solution.
  • Select the Browse tab.
  • Enter Microsoft.EntityFrameworkCore.InMemory in the search box, and then select Microsoft.EntityFrameworkCore.InMemory.
  • Select the Project checkbox in the right pane.
  • In the Version drop down select the latest version 7 available, for example 6.0.28, and then select Install.
  • Follow the preceding instructions to add the Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore package with the latest version 7 available.

The model and database context classes

In the project folder, create a file named Todo.cs with the following code:

public class Todo
{
    public int Id { get; set; }
    public string? Name { get; set; }
    public bool IsComplete { get; set; }
}

The preceding code creates the model for this app. A model is a class that represents data that the app manages.

Create a file named TodoDb.cs with the following code:

using Microsoft.EntityFrameworkCore;

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

    public DbSet<Todo> Todos => Set<Todo>();
}

The preceding code defines the database context, which is the main class that coordinates Entity Framework functionality for a data model. This class derives from the Microsoft.EntityFrameworkCore.DbContext class.

Add the API code

Replace the contents of the Program.cs file with the following code:

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.MapGet("/todoitems", async (TodoDb db) =>
    await db.Todos.ToListAsync());

app.MapGet("/todoitems/complete", async (TodoDb db) =>
    await db.Todos.Where(t => t.IsComplete).ToListAsync());

app.MapGet("/todoitems/{id}", async (int id, TodoDb db) =>
    await db.Todos.FindAsync(id)
        is Todo todo
            ? Results.Ok(todo)
            : Results.NotFound());

app.MapPost("/todoitems", async (Todo todo, TodoDb db) =>
{
    db.Todos.Add(todo);
    await db.SaveChangesAsync();

    return Results.Created($"/todoitems/{todo.Id}", todo);
});

app.MapPut("/todoitems/{id}", async (int id, Todo inputTodo, TodoDb db) =>
{
    var todo = await db.Todos.FindAsync(id);

    if (todo is null) return Results.NotFound();

    todo.Name = inputTodo.Name;
    todo.IsComplete = inputTodo.IsComplete;

    await db.SaveChangesAsync();

    return Results.NoContent();
});

app.MapDelete("/todoitems/{id}", async (int id, TodoDb db) =>
{
    if (await db.Todos.FindAsync(id) is Todo todo)
    {
        db.Todos.Remove(todo);
        await db.SaveChangesAsync();
        return Results.NoContent();
    }

    return Results.NotFound();
});

app.Run();

The following highlighted code adds the database context to the dependency injection (DI) container and enables displaying database-related exceptions:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
var app = builder.Build();

The DI container provides access to the database context and other services.

Create API testing UI with Swagger

There are many available web API testing tools to choose from, and you can follow this tutorial's introductory API test steps with your own preferred tool.

This tutorial 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.

For more information on using OpenAPI and NSwag with ASP.NET, see ASP.NET Core web API documentation with Swagger / OpenAPI.

Install Swagger tooling

  • Run the following command:

    dotnet add package NSwag.AspNetCore
    

The previous command adds the NSwag.AspNetCore package, which contains tools to generate Swagger documents and UI.

Configure Swagger middleware

  • In Program.cs add the following using statements at the top:

    using NSwag.AspNetCore;
    
  • Add the following highlighted code before app is defined in line var app = builder.Build();

    using NSwag.AspNetCore;
    using Microsoft.EntityFrameworkCore;
    
    var builder = WebApplication.CreateBuilder(args);
    builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
    builder.Services.AddDatabaseDeveloperPageExceptionFilter();
    
    builder.Services.AddEndpointsApiExplorer();
    builder.Services.AddOpenApiDocument(config =>
    {
        config.DocumentName = "TodoAPI";
        config.Title = "TodoAPI v1";
        config.Version = "v1";
    });
    
    var app = builder.Build();
    

In the previous code:

  • builder.Services.AddEndpointsApiExplorer();: Enables the API Explorer, which is a service that provides metadata about the HTTP API. The API Explorer is used by Swagger to generate the Swagger document.

  • builder.Services.AddOpenApiDocument(config => {...});: Adds the Swagger OpenAPI document generator to the application services and configures it to provide more information about the API, such as its title and version. For information on providing more robust API details, see Get started with NSwag and ASP.NET Core

  • Add the following highlighted code to the next line after app is defined in line var app = builder.Build();

    
    var app = builder.Build();
    
    if (app.Environment.IsDevelopment())
    {
        app.UseOpenApi();
        app.UseSwaggerUi(config =>
        {
            config.DocumentTitle = "TodoAPI";
            config.Path = "/swagger";
            config.DocumentPath = "/swagger/{documentName}/swagger.json";
            config.DocExpansion = "list";
        });
    }
    
    

    The previous code enables the Swagger middleware for serving the generated JSON document and the Swagger UI. Swagger is only enabled in a development environment. Enabling Swagger in a production environment could expose potentially sensitive details about the API's structure and implementation.

Test posting data

The following code in Program.cs creates an HTTP POST endpoint /todoitems that adds data to the in-memory database:

app.MapPost("/todoitems", async (Todo todo, TodoDb db) =>
{
    db.Todos.Add(todo);
    await db.SaveChangesAsync();

    return Results.Created($"/todoitems/{todo.Id}", todo);
});

Run the app. The browser displays a 404 error because there's no longer a / endpoint.

The POST endpoint will be used to add data to the app.

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

    Swagger generated API testing page

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

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

  • In the request body enter JSON for a to-do item, without specifying the optional id:

    {
      "name":"walk dog",
      "isComplete":true
    }
    
  • Select Execute.

    Swagger with Post data

Swagger provides a Responses pane below the Execute button.

Swagger with Post resonse pane

Note a few of the useful details:

  • cURL: Swagger provides an example cURL command in Unix/Linux syntax, which can be run at the command line with any bash shell that uses Unix/Linux syntax, including Git Bash from Git for Windows.
  • Request URL: A simplified representation of the HTTP request made by Swagger UI's JavaScript code for the API call. Actual requests can include details such as headers and query parameters and a request body.
  • Server response: Includes the response body and headers. The response body shows the id was set to 1.
  • Response Code: A 201 HTTP status code was returned, indicating that the request was successfully processed and resulted in the creation of a new resource.

Examine the GET endpoints

The sample app implements several GET endpoints by calling MapGet:

API Description Request body Response body
GET /todoitems Get all to-do items None Array of to-do items
GET /todoitems/complete Get all completed to-do items None Array of to-do items
GET /todoitems/{id} Get an item by ID None To-do item
app.MapGet("/", () => "Hello World!");

app.MapGet("/todoitems", async (TodoDb db) =>
    await db.Todos.ToListAsync());

app.MapGet("/todoitems/complete", async (TodoDb db) =>
    await db.Todos.Where(t => t.IsComplete).ToListAsync());

app.MapGet("/todoitems/{id}", async (int id, TodoDb db) =>
    await db.Todos.FindAsync(id)
        is Todo todo
            ? Results.Ok(todo)
            : Results.NotFound());

Test the GET endpoints

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

  • In Swagger select GET /todoitems > Try it out > Execute.

  • Alternatively, call GET /todoitems from a browser by entering the URI http://localhost:<port>/todoitems. For example, http://localhost:5001/todoitems

The call to GET /todoitems produces a response similar to the following:

[
  {
    "id": 1,
    "name": "walk dog",
    "isComplete": true
  }
]
  • Call GET /todoitems/{id} in Swagger to return data from a specific id:

    • Select GET /todoitems > Try it out.
    • Set the id field to 1 and select Execute.
  • Alternatively, call GET /todoitems from a browser by entering the URI https://localhost:<port>/todoitems/1. For example, For example, https://localhost:5001/todoitems/1

  • The response is similar to the following:

    {
      "id": 1,
      "name": "walk dog",
      "isComplete": true
    }
    

This app uses an in-memory database. If the app is restarted, the GET request doesn't return any data. If no data is returned, POST data to the app and try the GET request again.

Return values

ASP.NET Core automatically serializes the object to JSON and writes the JSON into the body of the response message. The response code for this return type is 200 OK, assuming there are no unhandled exceptions. Unhandled exceptions are translated into 5xx errors.

The return types can represent a wide range of HTTP status codes. For example, GET /todoitems/{id} can return two different status values:

  • If no item matches the requested ID, the method returns a 404 status NotFound error code.
  • Otherwise, the method returns 200 with a JSON response body. Returning item results in an HTTP 200 response.

Examine the PUT endpoint

The sample app implements a single PUT endpoint using MapPut:

app.MapPut("/todoitems/{id}", async (int id, Todo inputTodo, TodoDb db) =>
{
    var todo = await db.Todos.FindAsync(id);

    if (todo is null) return Results.NotFound();

    todo.Name = inputTodo.Name;
    todo.IsComplete = inputTodo.IsComplete;

    await db.SaveChangesAsync();

    return Results.NoContent();
});

This method is similar to the MapPost method, except it uses HTTP PUT. A successful response returns 204 (No Content). According to the HTTP specification, a PUT request requires the client to send the entire updated entity, not just the changes. To support partial updates, use HTTP PATCH.

Test the PUT endpoint

This sample uses an in-memory database that must be initialized each time the app is started. There must be an item in the database before you make a PUT call. Call GET to ensure there's an item in the database before making a PUT call.

Update the to-do item that has Id = 1 and set its name to "feed fish".

Use Swagger to send a PUT request:

  • Select Put /todoitems/{id} > Try it out.

  • Set the id field to 1.

  • Set the request body to the following JSON:

    {
      "name": "feed fish",
      "isComplete": false
    }
    
  • Select Execute.

Examine and test the DELETE endpoint

The sample app implements a single DELETE endpoint using MapDelete:

app.MapDelete("/todoitems/{id}", async (int id, TodoDb db) =>
{
    if (await db.Todos.FindAsync(id) is Todo todo)
    {
        db.Todos.Remove(todo);
        await db.SaveChangesAsync();
        return Results.NoContent();
    }

    return Results.NotFound();
});

Use Swagger to send a DELETE request:

  • Select DELETE /todoitems/{id} > Try it out.

  • Set the ID field to 1 and select Execute.

    The DELETE request is sent to the app and the response is displayed in the Responses pane. The response body is empty, and the Server response status code is 204.

Prevent over-posting

Currently the sample app exposes the entire Todo object. Production apps In production applications, a subset of the model is often used to restrict the data that can be input and returned. 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 article.

A DTO can be used to:

  • Prevent over-posting.
  • Hide properties that clients aren't supposed to view.
  • Omit some properties 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 Todo class to include a secret field:

public class Todo
{
    public int 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 file named TodoItemDTO.cs with the following code:

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

    public TodoItemDTO() { }
    public TodoItemDTO(Todo todoItem) =>
    (Id, Name, IsComplete) = (todoItem.Id, todoItem.Name, todoItem.IsComplete);
}

Replace the contents of the Program.cs file with the following code to use this DTO model:

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
var app = builder.Build();

app.MapGet("/todoitems", async (TodoDb db) =>
    await db.Todos.Select(x => new TodoItemDTO(x)).ToListAsync());

app.MapGet("/todoitems/{id}", async (int id, TodoDb db) =>
    await db.Todos.FindAsync(id)
        is Todo todo
            ? Results.Ok(new TodoItemDTO(todo))
            : Results.NotFound());

app.MapPost("/todoitems", async (TodoItemDTO todoItemDTO, TodoDb db) =>
{
    var todoItem = new Todo
    {
        IsComplete = todoItemDTO.IsComplete,
        Name = todoItemDTO.Name
    };

    db.Todos.Add(todoItem);
    await db.SaveChangesAsync();

    return Results.Created($"/todoitems/{todoItem.Id}", new TodoItemDTO(todoItem));
});

app.MapPut("/todoitems/{id}", async (int id, TodoItemDTO todoItemDTO, TodoDb db) =>
{
    var todo = await db.Todos.FindAsync(id);

    if (todo is null) return Results.NotFound();

    todo.Name = todoItemDTO.Name;
    todo.IsComplete = todoItemDTO.IsComplete;

    await db.SaveChangesAsync();

    return Results.NoContent();
});

app.MapDelete("/todoitems/{id}", async (int id, TodoDb db) =>
{
    if (await db.Todos.FindAsync(id) is Todo todo)
    {
        db.Todos.Remove(todo);
        await db.SaveChangesAsync();
        return Results.NoContent();
    }

    return Results.NotFound();
});

app.Run();

public class Todo
{
    public int Id { get; set; }
    public string? Name { get; set; }
    public bool IsComplete { get; set; }
    public string? Secret { get; set; }
}

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

    public TodoItemDTO() { }
    public TodoItemDTO(Todo todoItem) =>
    (Id, Name, IsComplete) = (todoItem.Id, todoItem.Name, todoItem.IsComplete);
}


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

    public DbSet<Todo> Todos => Set<Todo>();
}

Verify you can post and get all fields except the secret field.

Test minimal API

For an example of testing a minimal API app, see this GitHub sample.

Publish to Azure

For information on deploying to Azure, see Quickstart: Deploy an ASP.NET web app.

Additional resources

Minimal APIs are architected to create HTTP APIs with minimal dependencies. They're ideal for microservices and apps that want to include only the minimum files, features, and dependencies in ASP.NET Core.

This tutorial teaches the basics of building a minimal API with ASP.NET Core. Another approach to creating APIs in ASP.NET Core is to use controllers. For help with choosing between minimal APIs and controller-based APIs, see APIs overview. For a tutorial on creating an API project based on controllers that contains more features, see Create a web API.

Overview

This tutorial creates the following API:

API Description Request body Response body
GET /todoitems Get all to-do items None Array of to-do items
GET /todoitems/complete Get completed to-do items None Array of to-do items
GET /todoitems/{id} Get an item by ID None To-do item
POST /todoitems Add a new item To-do item To-do item
PUT /todoitems/{id} Update an existing item   To-do item None
DELETE /todoitems/{id}     Delete an item     None None

Prerequisites

Create an API project

  • Start Visual Studio 2022 and select Create a new project.

  • In the Create a new project dialog:

    • Enter Empty in the Search for templates search box.
    • Select the ASP.NET Core Empty template and select Next.

    Visual Studio Create a new project

  • Name the project TodoApi and select Next.

  • In the Additional information dialog:

    • Select .NET 8.0 (Long Term Support)
    • Uncheck Do not use top-level statements
    • Select Create

    Additional information

Examine the code

The Program.cs file contains the following code:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.Run();

The preceding code:

Run the app

Press Ctrl+F5 to run without the debugger.

Visual Studio displays the following dialog:

This project is configured to use SSL. To avoid SSL warnings in the browser you can choose to trust the self-signed certificate that IIS Express has generated. Would you like to trust the IIS Express SSL certificate?

Select Yes if you trust the IIS Express SSL certificate.

The following dialog is displayed:

Security warning dialog

Select Yes if you agree to trust the development certificate.

For information on trusting the Firefox browser, see Firefox SEC_ERROR_INADEQUATE_KEY_USAGE certificate error.

Visual Studio launches the Kestrel web server and opens a browser window.

Hello World! is displayed in the browser. The Program.cs file contains a minimal but complete app.

Close the browser window.

Add NuGet packages

NuGet packages must be added to support the database and diagnostics used in this tutorial.

  • From the Tools menu, select NuGet Package Manager > Manage NuGet Packages for Solution.
  • Select the Browse tab.
  • Enter Microsoft.EntityFrameworkCore.InMemory in the search box, and then select Microsoft.EntityFrameworkCore.InMemory.
  • Select the Project checkbox in the right pane and then select Install.
  • Follow the preceding instructions to add the Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore package.

The model and database context classes

  • In the project folder, create a file named Todo.cs with the following code:
public class Todo
{
    public int Id { get; set; }
    public string? Name { get; set; }
    public bool IsComplete { get; set; }
}

The preceding code creates the model for this app. A model is a class that represents data that the app manages.

  • Create a file named TodoDb.cs with the following code:
using Microsoft.EntityFrameworkCore;

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

    public DbSet<Todo> Todos => Set<Todo>();
}

The preceding code defines the database context, which is the main class that coordinates Entity Framework functionality for a data model. This class derives from the Microsoft.EntityFrameworkCore.DbContext class.

Add the API code

  • Replace the contents of the Program.cs file with the following code:
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
var app = builder.Build();

app.MapGet("/todoitems", async (TodoDb db) =>
    await db.Todos.ToListAsync());

app.MapGet("/todoitems/complete", async (TodoDb db) =>
    await db.Todos.Where(t => t.IsComplete).ToListAsync());

app.MapGet("/todoitems/{id}", async (int id, TodoDb db) =>
    await db.Todos.FindAsync(id)
        is Todo todo
            ? Results.Ok(todo)
            : Results.NotFound());

app.MapPost("/todoitems", async (Todo todo, TodoDb db) =>
{
    db.Todos.Add(todo);
    await db.SaveChangesAsync();

    return Results.Created($"/todoitems/{todo.Id}", todo);
});

app.MapPut("/todoitems/{id}", async (int id, Todo inputTodo, TodoDb db) =>
{
    var todo = await db.Todos.FindAsync(id);

    if (todo is null) return Results.NotFound();

    todo.Name = inputTodo.Name;
    todo.IsComplete = inputTodo.IsComplete;

    await db.SaveChangesAsync();

    return Results.NoContent();
});

app.MapDelete("/todoitems/{id}", async (int id, TodoDb db) =>
{
    if (await db.Todos.FindAsync(id) is Todo todo)
    {
        db.Todos.Remove(todo);
        await db.SaveChangesAsync();
        return Results.NoContent();
    }

    return Results.NotFound();
});

app.Run();

The following highlighted code adds the database context to the dependency injection (DI) container and enables displaying database-related exceptions:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
var app = builder.Build();

The DI container provides access to the database context and other services.

This tutorial uses Endpoints Explorer and .http files to test the API.

Test posting data

The following code in Program.cs creates an HTTP POST endpoint /todoitems that adds data to the in-memory database:

app.MapPost("/todoitems", async (Todo todo, TodoDb db) =>
{
    db.Todos.Add(todo);
    await db.SaveChangesAsync();

    return Results.Created($"/todoitems/{todo.Id}", todo);
});

Run the app. The browser displays a 404 error because there's no longer a / endpoint.

The POST endpoint will be used to add data to the app.

  • Select View > Other Windows > Endpoints Explorer.

  • Right-click the POST endpoint and select Generate request.

    Endpoints Explorer context menu highlighting Generate Request menu item.

    A new file is created in the project folder named TodoApi.http, with contents similar to the following example:

    @TodoApi_HostAddress = https://localhost:7031
    
    Post {{TodoApi_HostAddress}}/todoitems
    
    ###
    
    • The first line creates a variable that is used for all of the endpoints.
    • The next line defines a POST request.
    • The triple hashtag (###) line is a request delimiter: what comes after it is for a different request.
  • The POST request needs headers and a body. To define those parts of the request, add the following lines immediately after the POST request line:

    Content-Type: application/json
    
    {
      "name":"walk dog",
      "isComplete":true
    }
    

    The preceding code adds a Content-Type header and a JSON request body. The TodoApi.http file should now look like the following example, but with your port number:

    @TodoApi_HostAddress = https://localhost:7057
    
    Post {{TodoApi_HostAddress}}/todoitems
    Content-Type: application/json
    
    {
      "name":"walk dog",
      "isComplete":true
    }
    
    ###
    
  • Run the app.

  • Select the Send request link that is above the POST request line.

    .http file window with run link highlighted.

    The POST request is sent to the app and the response is displayed in the Response pane.

    .http file window with response from the POST request.

Examine the GET endpoints

The sample app implements several GET endpoints by calling MapGet:

API Description Request body Response body
GET /todoitems Get all to-do items None Array of to-do items
GET /todoitems/complete Get all completed to-do items None Array of to-do items
GET /todoitems/{id} Get an item by ID None To-do item
app.MapGet("/todoitems", async (TodoDb db) =>
    await db.Todos.ToListAsync());

app.MapGet("/todoitems/complete", async (TodoDb db) =>
    await db.Todos.Where(t => t.IsComplete).ToListAsync());

app.MapGet("/todoitems/{id}", async (int id, TodoDb db) =>
    await db.Todos.FindAsync(id)
        is Todo todo
            ? Results.Ok(todo)
            : Results.NotFound());

Test the GET endpoints

Test the app by calling the GET endpoints from a browser or by using Endpoints Explorer. The following steps are for Endpoints Explorer.

  • In Endpoints Explorer, right-click the first GET endpoint, and select Generate request.

    The following content is added to the TodoApi.http file:

    Get {{TodoApi_HostAddress}}/todoitems
    
    ###
    
  • Select the Send request link that is above the new GET request line.

    The GET request is sent to the app and the response is displayed in the Response pane.

  • The response body is similar to the following JSON:

    [
      {
        "id": 1,
        "name": "walk dog",
        "isComplete": true
      }
    ]
    
  • In Endpoints Explorer, right-click the /todoitems/{id} GET endpoint and select Generate request. The following content is added to the TodoApi.http file:

    GET {{TodoApi_HostAddress}}/todoitems/{id}
    
    ###
    
  • Replace {id} with 1.

  • Select the Send request link that is above the new GET request line.

    The GET request is sent to the app and the response is displayed in the Response pane.

  • The response body is similar to the following JSON:

    {
      "id": 1,
      "name": "walk dog",
      "isComplete": true
    }
    

This app uses an in-memory database. If the app is restarted, the GET request doesn't return any data. If no data is returned, POST data to the app and try the GET request again.

Return values

ASP.NET Core automatically serializes the object to JSON and writes the JSON into the body of the response message. The response code for this return type is 200 OK, assuming there are no unhandled exceptions. Unhandled exceptions are translated into 5xx errors.

The return types can represent a wide range of HTTP status codes. For example, GET /todoitems/{id} can return two different status values:

  • If no item matches the requested ID, the method returns a 404 status NotFound error code.
  • Otherwise, the method returns 200 with a JSON response body. Returning item results in an HTTP 200 response.

Examine the PUT endpoint

The sample app implements a single PUT endpoint using MapPut:

app.MapPut("/todoitems/{id}", async (int id, Todo inputTodo, TodoDb db) =>
{
    var todo = await db.Todos.FindAsync(id);

    if (todo is null) return Results.NotFound();

    todo.Name = inputTodo.Name;
    todo.IsComplete = inputTodo.IsComplete;

    await db.SaveChangesAsync();

    return Results.NoContent();
});

This method is similar to the MapPost method, except it uses HTTP PUT. A successful response returns 204 (No Content). According to the HTTP specification, a PUT request requires the client to send the entire updated entity, not just the changes. To support partial updates, use HTTP PATCH.

Test the PUT endpoint

This sample uses an in-memory database that must be initialized each time the app is started. There must be an item in the database before you make a PUT call. Call GET to ensure there's an item in the database before making a PUT call.

Update the to-do item that has Id = 1 and set its name to "feed fish".

  • In Endpoints Explorer, right-click the PUT endpoint, and select Generate request.

    The following content is added to the TodoApi.http file:

    Put {{TodoApi_HostAddress}}/todoitems/{id}
    
    ###
    
  • In the PUT request line, replace {id} with 1.

  • Add the following lines immediately after the PUT request line:

    Content-Type: application/json
    
    {
      "name": "feed fish",
      "isComplete": false
    }
    

    The preceding code adds a Content-Type header and a JSON request body.

  • Select the Send request link that is above the new PUT request line.

    The PUT request is sent to the app and the response is displayed in the Response pane. The response body is empty, and the status code is 204.

Examine and test the DELETE endpoint

The sample app implements a single DELETE endpoint using MapDelete:

app.MapDelete("/todoitems/{id}", async (int id, TodoDb db) =>
{
    if (await db.Todos.FindAsync(id) is Todo todo)
    {
        db.Todos.Remove(todo);
        await db.SaveChangesAsync();
        return Results.NoContent();
    }

    return Results.NotFound();
});
  • In Endpoints Explorer, right-click the DELETE endpoint and select Generate request.

    A DELETE request is added to TodoApi.http.

  • Replace {id} in the DELETE request line with 1. The DELETE request should look like the following example:

    DELETE {{TodoApi_HostAddress}}/todoitems/1
    
    ###
    
  • Select the Send request link for the DELETE request.

    The DELETE request is sent to the app and the response is displayed in the Response pane. The response body is empty, and the status code is 204.

Use the MapGroup API

The sample app code repeats the todoitems URL prefix each time it sets up an endpoint. APIs often have groups of endpoints with a common URL prefix, and the MapGroup method is available to help organize such groups. It reduces repetitive code and allows for customizing entire groups of endpoints with a single call to methods like RequireAuthorization and WithMetadata.

Replace the contents of Program.cs with the following code:

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
var app = builder.Build();

var todoItems = app.MapGroup("/todoitems");

todoItems.MapGet("/", async (TodoDb db) =>
    await db.Todos.ToListAsync());

todoItems.MapGet("/complete", async (TodoDb db) =>
    await db.Todos.Where(t => t.IsComplete).ToListAsync());

todoItems.MapGet("/{id}", async (int id, TodoDb db) =>
    await db.Todos.FindAsync(id)
        is Todo todo
            ? Results.Ok(todo)
            : Results.NotFound());

todoItems.MapPost("/", async (Todo todo, TodoDb db) =>
{
    db.Todos.Add(todo);
    await db.SaveChangesAsync();

    return Results.Created($"/todoitems/{todo.Id}", todo);
});

todoItems.MapPut("/{id}", async (int id, Todo inputTodo, TodoDb db) =>
{
    var todo = await db.Todos.FindAsync(id);

    if (todo is null) return Results.NotFound();

    todo.Name = inputTodo.Name;
    todo.IsComplete = inputTodo.IsComplete;

    await db.SaveChangesAsync();

    return Results.NoContent();
});

todoItems.MapDelete("/{id}", async (int id, TodoDb db) =>
{
    if (await db.Todos.FindAsync(id) is Todo todo)
    {
        db.Todos.Remove(todo);
        await db.SaveChangesAsync();
        return Results.NoContent();
    }

    return Results.NotFound();
});

app.Run();

The preceding code has the following changes:

  • Adds var todoItems = app.MapGroup("/todoitems"); to set up the group using the URL prefix /todoitems.
  • Changes all the app.Map<HttpVerb> methods to todoItems.Map<HttpVerb>.
  • Removes the URL prefix /todoitems from the Map<HttpVerb> method calls.

Test the endpoints to verify that they work the same.

Use the TypedResults API

Returning TypedResults rather than Results has several advantages, including testability and automatically returning the response type metadata for OpenAPI to describe the endpoint. For more information, see TypedResults vs Results.

The Map<HttpVerb> methods can call route handler methods instead of using lambdas. To see an example, update Program.cs with the following code:

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
var app = builder.Build();

var todoItems = app.MapGroup("/todoitems");

todoItems.MapGet("/", GetAllTodos);
todoItems.MapGet("/complete", GetCompleteTodos);
todoItems.MapGet("/{id}", GetTodo);
todoItems.MapPost("/", CreateTodo);
todoItems.MapPut("/{id}", UpdateTodo);
todoItems.MapDelete("/{id}", DeleteTodo);

app.Run();

static async Task<IResult> GetAllTodos(TodoDb db)
{
    return TypedResults.Ok(await db.Todos.ToArrayAsync());
}

static async Task<IResult> GetCompleteTodos(TodoDb db)
{
    return TypedResults.Ok(await db.Todos.Where(t => t.IsComplete).ToListAsync());
}

static async Task<IResult> GetTodo(int id, TodoDb db)
{
    return await db.Todos.FindAsync(id)
        is Todo todo
            ? TypedResults.Ok(todo)
            : TypedResults.NotFound();
}

static async Task<IResult> CreateTodo(Todo todo, TodoDb db)
{
    db.Todos.Add(todo);
    await db.SaveChangesAsync();

    return TypedResults.Created($"/todoitems/{todo.Id}", todo);
}

static async Task<IResult> UpdateTodo(int id, Todo inputTodo, TodoDb db)
{
    var todo = await db.Todos.FindAsync(id);

    if (todo is null) return TypedResults.NotFound();

    todo.Name = inputTodo.Name;
    todo.IsComplete = inputTodo.IsComplete;

    await db.SaveChangesAsync();

    return TypedResults.NoContent();
}

static async Task<IResult> DeleteTodo(int id, TodoDb db)
{
    if (await db.Todos.FindAsync(id) is Todo todo)
    {
        db.Todos.Remove(todo);
        await db.SaveChangesAsync();
        return TypedResults.NoContent();
    }

    return TypedResults.NotFound();
}

The Map<HttpVerb> code now calls methods instead of lambdas:

var todoItems = app.MapGroup("/todoitems");

todoItems.MapGet("/", GetAllTodos);
todoItems.MapGet("/complete", GetCompleteTodos);
todoItems.MapGet("/{id}", GetTodo);
todoItems.MapPost("/", CreateTodo);
todoItems.MapPut("/{id}", UpdateTodo);
todoItems.MapDelete("/{id}", DeleteTodo);

These methods return objects that implement IResult and are defined by TypedResults:

static async Task<IResult> GetAllTodos(TodoDb db)
{
    return TypedResults.Ok(await db.Todos.ToArrayAsync());
}

static async Task<IResult> GetCompleteTodos(TodoDb db)
{
    return TypedResults.Ok(await db.Todos.Where(t => t.IsComplete).ToListAsync());
}

static async Task<IResult> GetTodo(int id, TodoDb db)
{
    return await db.Todos.FindAsync(id)
        is Todo todo
            ? TypedResults.Ok(todo)
            : TypedResults.NotFound();
}

static async Task<IResult> CreateTodo(Todo todo, TodoDb db)
{
    db.Todos.Add(todo);
    await db.SaveChangesAsync();

    return TypedResults.Created($"/todoitems/{todo.Id}", todo);
}

static async Task<IResult> UpdateTodo(int id, Todo inputTodo, TodoDb db)
{
    var todo = await db.Todos.FindAsync(id);

    if (todo is null) return TypedResults.NotFound();

    todo.Name = inputTodo.Name;
    todo.IsComplete = inputTodo.IsComplete;

    await db.SaveChangesAsync();

    return TypedResults.NoContent();
}

static async Task<IResult> DeleteTodo(int id, TodoDb db)
{
    if (await db.Todos.FindAsync(id) is Todo todo)
    {
        db.Todos.Remove(todo);
        await db.SaveChangesAsync();
        return TypedResults.NoContent();
    }

    return TypedResults.NotFound();
}

Unit tests can call these methods and test that they return the correct type. For example, if the method is GetAllTodos:

static async Task<IResult> GetAllTodos(TodoDb db)
{
    return TypedResults.Ok(await db.Todos.ToArrayAsync());
}

Unit test code can verify that an object of type Ok<Todo[]> is returned from the handler method. For example:

public async Task GetAllTodos_ReturnsOkOfTodosResult()
{
    // Arrange
    var db = CreateDbContext();

    // Act
    var result = await TodosApi.GetAllTodos(db);

    // Assert: Check for the correct returned type
    Assert.IsType<Ok<Todo[]>>(result);
}

Prevent over-posting

Currently the sample app exposes the entire Todo object. Production apps In production applications, a subset of the model is often used to restrict the data that can be input and returned. 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 article.

A DTO can be used to:

  • Prevent over-posting.
  • Hide properties that clients aren't supposed to view.
  • Omit some properties 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 Todo class to include a secret field:

public class Todo
{
    public int 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 file named TodoItemDTO.cs with the following code:

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

    public TodoItemDTO() { }
    public TodoItemDTO(Todo todoItem) =>
    (Id, Name, IsComplete) = (todoItem.Id, todoItem.Name, todoItem.IsComplete);
}

Replace the contents of the Program.cs file with the following code to use this DTO model:

using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<TodoDb>(opt => opt.UseInMemoryDatabase("TodoList"));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
var app = builder.Build();

RouteGroupBuilder todoItems = app.MapGroup("/todoitems");

todoItems.MapGet("/", GetAllTodos);
todoItems.MapGet("/complete", GetCompleteTodos);
todoItems.MapGet("/{id}", GetTodo);
todoItems.MapPost("/", CreateTodo);
todoItems.MapPut("/{id}", UpdateTodo);
todoItems.MapDelete("/{id}", DeleteTodo);

app.Run();

static async Task<IResult> GetAllTodos(TodoDb db)
{
    return TypedResults.Ok(await db.Todos.Select(x => new TodoItemDTO(x)).ToArrayAsync());
}

static async Task<IResult> GetCompleteTodos(TodoDb db) {
    return TypedResults.Ok(await db.Todos.Where(t => t.IsComplete).Select(x => new TodoItemDTO(x)).ToListAsync());
}

static async Task<IResult> GetTodo(int id, TodoDb db)
{
    return await db.Todos.FindAsync(id)
        is Todo todo
            ? TypedResults.Ok(new TodoItemDTO(todo))
            : TypedResults.NotFound();
}

static async Task<IResult> CreateTodo(TodoItemDTO todoItemDTO, TodoDb db)
{
    var todoItem = new Todo
    {
        IsComplete = todoItemDTO.IsComplete,
        Name = todoItemDTO.Name
    };

    db.Todos.Add(todoItem);
    await db.SaveChangesAsync();

    todoItemDTO = new TodoItemDTO(todoItem);

    return TypedResults.Created($"/todoitems/{todoItem.Id}", todoItemDTO);
}

static async Task<IResult> UpdateTodo(int id, TodoItemDTO todoItemDTO, TodoDb db)
{
    var todo = await db.Todos.FindAsync(id);

    if (todo is null) return TypedResults.NotFound();

    todo.Name = todoItemDTO.Name;
    todo.IsComplete = todoItemDTO.IsComplete;

    await db.SaveChangesAsync();

    return TypedResults.NoContent();
}

static async Task<IResult> DeleteTodo(int id, TodoDb db)
{
    if (await db.Todos.FindAsync(id) is Todo todo)
    {
        db.Todos.Remove(todo);
        await db.SaveChangesAsync();
        return TypedResults.NoContent();
    }

    return TypedResults.NotFound();
}

Verify you can post and get all fields except the secret field.

Troubleshooting with the completed sample

If you run into a problem you can't resolve, compare your code to the completed project. View or download completed project (how to download).

Next steps

Learn more

See Minimal APIs quick reference