Web Technology Development Basic Workshop

Instructor: Suriya Sonphu

Build a Todo API with .NET 10 Minimal API

A beginner-friendly, self-paced hands-on lab using VS Code, REST, DTOs, SQLite, Entity Framework Core, and JWT Bearer authentication.

LevelBeginner
PlatformWindows / macOS
IDEVisual Studio Code
Backend.NET 10 Minimal API

Learning outcomes

By the end of this lab you should be able to explain and build the complete request flow:

Client
  ↓ HTTP + JSON
Minimal API Endpoint
  ↓ DTO
Entity Framework Core
  ↓
SQLite Database

You will also add authentication so protected Todo endpoints require a valid Bearer token.

Target API
GET /api/todos · GET /api/todos/{id} · POST /api/todos · PUT /api/todos/{id} · DELETE /api/todos/{id} · POST /api/auth/login
BEFORE STEP 0

Backend foundations: start here

Read this 45–60 minute tutorial before installing tools or writing the Todo API. It explains the ideas you will use in every later step; you do not need prior C# or web-backend experience.

1. What are we building?

A backend is a program that receives requests from a client, applies rules, and sends a response. In this lab, the browser or REST Client is the client; the Todo API is the backend.

Client → HTTP request → Minimal API endpoint → response (usually JSON)

A resource is the thing an API manages. Here it is a Todo. An endpoint is one address and HTTP method that operates on that resource, such as GET /api/todos.

2. HTTP and JSON essentials

PartMeaningTodo example
MethodThe requested operationGET, POST, PUT, DELETE
URL / routeThe resource to target/api/todos/3
Request bodyData sent to the API{ "title": "Learn API" }
Status codeThe result of the request200, 201, 404
Response bodyData returned by the API{ "id": 3, "title": "Learn API" }

JSON is text used to exchange structured data. Property names are wrapped in double quotes, strings use double quotes, and objects use curly braces. Read the examples rather than memorizing JSON; the API reference will show the exact shapes later.

Quick check: GET /api/todos/3 asks to read Todo 3; POST /api/todos sends data to create a new Todo.

3. REST means resource-oriented URLs

REST is a convention for designing APIs around resources. Prefer nouns in routes and let the HTTP method describe the action.

Good: GET /api/todos
Good: DELETE /api/todos/3
Avoid: GET /api/getTodos
Avoid: POST /api/deleteTodo?id=3

The lab will use 200 OK for successful reads, 201 Created for a successful create, 204 No Content for a successful delete, 404 Not Found when an item does not exist, and 401 Unauthorized when a protected route has no valid token.

4. Read a Minimal API in four lines

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

app.MapGet("/", () => "Hello Todo API");
app.Run();
  • builder collects services and configuration.
  • app is the web application that handles requests.
  • MapGet connects a GET route to a small C# function.
  • Run starts the local web server.

The expression () => "Hello Todo API" is a lambda: a short function that returns the text on the right. Later, a route parameter such as (int id) is automatically read from /api/todos/{id}.

5. C# you need for this lab

ConceptExampleWhy it matters
Type and variableint id = 3;Stores a typed value.
Stringstring title = "Learn API";Stores text.
Record / DTOpublic record TodoCreateDto(string Title);Defines API input or output data.
Conditionalif (todo is null) return Results.NotFound();Handles missing data safely.
Collectionnew List<TodoGetDto>()Holds multiple Todos.
Asyncawait db.SaveChangesAsync();Waits for database work without blocking the server.

Do not worry about every keyword yet. Follow the code in sequence, keep names consistent, and use compiler errors as precise hints about what C# expects.

6. Services, dependency injection, and configuration

A service is a reusable capability, such as AppDbContext for database access. We register services before building the app, then Minimal API supplies them as endpoint parameters:

builder.Services.AddDbContext<AppDbContext>(...);

app.MapGet("/api/todos", async (AppDbContext db) =>
{
    return Results.Ok(await db.Todos.ToListAsync());
});

This is dependency injection: the endpoint declares what it needs, rather than creating it itself. Settings belong in appsettings.json; real secrets must not be committed to Git and should use environment variables or Secret Manager.

7. Your mental model for the rest of the lab

Route + HTTP method
  → endpoint receives route values and JSON DTO
  → validates/uses services
  → returns a status code and JSON response
Ready for Step 0? You should be able to identify the method, route, request body, response and status code in a simple API request. Then continue to install and verify the tools.

Helpful official references: Minimal APIs, Web API overview, and C# tour.

BEFORE STEP 0

Backend naming conventions

Naming conventions make a codebase easier to read before anyone explains it. In this lab, use names that reveal responsibility: API contracts live in Dtos, database objects live in Models, and infrastructure such as EF Core lives in Data.

C# and project names

Item Convention Example
Project / namespace PascalCase TodoApi, TodoApi.Dtos
Class / record / enum PascalCase noun TodoItem, TodoStatus
DTO Resource + action + Dto TodoGetDto, TodoCreateDto
Method / endpoint handler PascalCase verb phrase GetTodos, CreateTodo
Local variable / parameter camelCase todo, todoId, jwtKey
Private field underscore + camelCase _logger, _dbContext
Boolean property Readable yes/no meaning IsCompleted, IsActive

Folders and files for this lab

TodoApi/
  Program.cs
  Data/
    AppDbContext.cs
  Dtos/
    LoginDto.cs
    LoginResponseDto.cs
    TodoCreateDto.cs
    TodoGetDto.cs
    TodoUpdateDto.cs
  Models/
    TodoItem.cs
  • Use singular names for one model or DTO: TodoItem, not TodoItems.
  • Use plural route resources: /api/todos, not /api/todo.
  • Match file names to type names: TodoCreateDto.cs contains TodoCreateDto.
  • Avoid unclear abbreviations such as td, res, or usr; prefer todo, result, and username.

API route and JSON names

Area Convention Example
Route path lowercase plural nouns /api/todos
Route parameter short, meaningful camelCase /api/todos/{id}
JSON property camelCase in API payloads { "isCompleted": true }
Endpoint name verb + resource GetTodos, UpdateTodo
Checkpoint: before adding a new file, ask “what responsibility does this name communicate?” A good name should help another student find the correct file without reading the whole project.
STEP 0

Prepare your development environment

Using purple Visual Studio on Windows? Complete the Visual Studio API setup guide first, then return to this unchanged lab step.

Required tools

Verify .NET

dotnet --version

Expected result:

10.0.xxx
Windows: use PowerShell or Windows Terminal. macOS: use Terminal or the integrated VS Code terminal. All dotnet commands in this lab are the same on both platforms.
Checkpoint: dotnet --version shows .NET 10.
STEP 1

Create the Todo API project

mkdir todo-workshop
cd todo-workshop
dotnet new webapi -n TodoApi
cd TodoApi
code .

If code . is not available, open VS Code manually and choose File → Open Folder.

Build and run

dotnet build
dotnet run

Look for:

Now listening on: http://localhost:xxxx
Checkpoint: the project builds without errors and the local web server starts.
STEP 2

Create your first endpoint

Replace the contents of Program.cs with:

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.MapGet("/", () => "Hello Todo API");

app.Run();

Run the project again:

dotnet run

Open the localhost URL shown in your terminal. You should see:

Hello Todo API

What happened?

  • CreateBuilder prepares application services and configuration.
  • Build creates the web application.
  • MapGet maps an HTTP GET request to code.
  • Run starts the web server.
STEP 3

Understand REST and design the API first

Our main resource is a Todo. We design endpoints around the resource, not action names.

CRUD HTTP Endpoint Purpose
Create POST /api/todos Create a Todo
Read GET /api/todos Get all Todos
Read GET /api/todos/{id} Get one Todo
Update PUT /api/todos/{id} Update a Todo
Delete DELETE /api/todos/{id} Delete a Todo
Prefer GET /api/todos instead of action-style URLs such as GET /api/getTodos.
STEP 4

Create the GET DTO

Create a folder named Dtos, then create Dtos/TodoGetDto.cs:

namespace TodoApi.Dtos;

public record TodoGetDto(
    int Id,
    string Title,
    bool IsCompleted
);

Why use a DTO?

DTO means Data Transfer Object. It defines the shape of data that crosses the API boundary. Database entities and API contracts do not need to expose exactly the same fields.

STEP 5

Return data with GET DTO

Update Program.cs:

using TodoApi.Dtos;

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

var todos = new List<TodoGetDto>
{
    new(1, "Learn Minimal API", false),
    new(2, "Learn Vue", false)
};

app.MapGet("/", () => "Hello Todo API");

app.MapGet("/api/todos", () =>
    Results.Ok(todos));

app.MapGet("/api/todos/{id}", (int id) =>
{
    var todo = todos.FirstOrDefault(x => x.Id == id);

    return todo is null
        ? Results.NotFound()
        : Results.Ok(todo);
});

app.Run();

Try it

GET /api/todos
GET /api/todos/1
GET /api/todos/99

The last request should return 404 Not Found.

Checkpoint: GET all and GET by ID both work with in-memory data.
STEP 6

Add POST, PUT, and DELETE

Create DTOs

Dtos/TodoCreateDto.cs

namespace TodoApi.Dtos;

public record TodoCreateDto(string Title);

Dtos/TodoUpdateDto.cs

namespace TodoApi.Dtos;

public record TodoUpdateDto(
    string Title,
    bool IsCompleted
);

POST

app.MapPost("/api/todos", (TodoCreateDto dto) =>
{
    var nextId = todos.Count == 0 ? 1 : todos.Max(x => x.Id) + 1;
    var todo = new TodoGetDto(nextId, dto.Title, false);
    todos.Add(todo);

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

Endpoint: http://localhost:[port]/api/todos>

Method: POST

Payload: { "title": "Learn API" }

PUT

app.MapPut("/api/todos/{id}", (int id, TodoUpdateDto dto) =>
{
    var index = todos.FindIndex(x => x.Id == id);
    if (index == -1) return Results.NotFound();

    todos[index] = todos[index] with
    {
        Title = dto.Title,
        IsCompleted = dto.IsCompleted
    };

    return Results.Ok(todos[index]);
});

Endpoint: http://localhost:[port]/api/todos/{id}>

Method: PUT

Parameter: id

Payload: { "title": "Learn API", "isComplete": true }

DELETE

app.MapDelete("/api/todos/{id}", (int id) =>
{
    var todo = todos.FirstOrDefault(x => x.Id == id);
    if (todo is null) return Results.NotFound();

    todos.Remove(todo);
    return Results.NoContent();
});

Endpoint: http://localhost:[port]/api/todos/{id}>

Method: DELETE

Parameter: id

Checkpoint: you can create, update, and delete Todos while the application is running.
STEP 7

Organize endpoints with MapGroup

Replace repeated /api/todos prefixes with a route group:

var todoGroup = app.MapGroup("/api/todos").WithTags("Todos");

todoGroup.MapGet("/", ...);
todoGroup.MapGet("/{id}", ...);
todoGroup.MapPost("/", ...);
todoGroup.MapPut("/{id}", ...);
todoGroup.MapDelete("/{id}", ...);

This makes endpoint organization cleaner and will later let us apply authorization to the whole group.

STEP 8

Replace in-memory storage with SQLite

Create the database entity

Create Models/TodoItem.cs:

namespace TodoApi.Models;

public class TodoItem
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public bool IsCompleted { get; set; }
    public DateTime CreatedAt { get; set; }
}

Install EF Core SQLite packages

dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.EntityFrameworkCore.Design

Install the EF CLI tool

dotnet tool install --global dotnet-ef
dotnet ef --version

Create DbContext

Create Data/AppDbContext.cs:

using Microsoft.EntityFrameworkCore;
using TodoApi.Models;

namespace TodoApi.Data;

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

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

Add the connection string

In appsettings.json:

{
  "ConnectionStrings": {
    "DefaultConnection": "Data Source=todo.db"
  }
}

Register EF Core

Add near the top of Program.cs:

using Microsoft.EntityFrameworkCore;
using TodoApi.Data;
using TodoApi.Models;

Before builder.Build():

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlite(
        builder.Configuration.GetConnectionString("DefaultConnection")
    ));
STEP 9

Create the database with EF Core Migration

dotnet ef migrations add InitialCreate
dotnet ef database update

You should now see:

Migrations/
todo.db
Checkpoint: todo.db exists and migration commands complete successfully.
STEP 10

CRUD using the database

Remove the in-memory List<TodoGetDto>. Replace the Todo route handlers with database-backed versions.

GET all

todoGroup.MapGet("/", async (AppDbContext db) =>
{
    var todos = await db.Todos
        .Select(x => new TodoGetDto(x.Id, x.Title, x.IsCompleted))
        .ToListAsync();

    return Results.Ok(todos);
});

GET by ID

todoGroup.MapGet("/{id}", async (int id, AppDbContext db) =>
{
    var todo = await db.Todos.FindAsync(id);
    if (todo is null) return Results.NotFound();

    return Results.Ok(
        new TodoGetDto(todo.Id, todo.Title, todo.IsCompleted)
    );
});

POST

todoGroup.MapPost("/", async (TodoCreateDto dto, AppDbContext db) =>
{
    var todo = new TodoItem
    {
        Title = dto.Title,
        IsCompleted = false,
        CreatedAt = DateTime.UtcNow
    };

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

    var result = new TodoGetDto(todo.Id, todo.Title, todo.IsCompleted);
    return Results.Created($"/api/todos/{todo.Id}", result);
});

PUT

todoGroup.MapPut("/{id}", async (int id, TodoUpdateDto dto, AppDbContext db) =>
{
    var todo = await db.Todos.FindAsync(id);
    if (todo is null) return Results.NotFound();

    todo.Title = dto.Title;
    todo.IsCompleted = dto.IsCompleted;
    await db.SaveChangesAsync();

    return Results.Ok(
        new TodoGetDto(todo.Id, todo.Title, todo.IsCompleted)
    );
});

DELETE

todoGroup.MapDelete("/{id}", async (int id, AppDbContext db) =>
{
    var todo = await db.Todos.FindAsync(id);
    if (todo is null) return Results.NotFound();

    db.Todos.Remove(todo);
    await db.SaveChangesAsync();
    return Results.NoContent();
});
Checkpoint: restart the API. Previously created Todos should still exist. That proves data is persisted in SQLite.
STEP 11

Authentication vs Authorization

Authentication

Who are you?

The system verifies a user's identity.

Authorization

What are you allowed to do?

The system decides which resources/actions the authenticated user may access.

What is a Bearer token?

After login, the client receives an access token. Protected requests send the token in the HTTP Authorization header:

Authorization: Bearer eyJhbGciOi...

For this lab we use a JWT access token. The API validates its signature, issuer, audience, and expiry.

STEP 12

Add JWT Bearer login

Install package

dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

Add JWT configuration

Extend appsettings.json:

{
  "ConnectionStrings": {
    "DefaultConnection": "Data Source=todo.db"
  },
  "Jwt": {
    "Issuer": "TodoApi",
    "Audience": "TodoApp",
    "Key": "todo-workshop-development-key-change-in-production"
  }
}
Workshop only: do not commit production secrets. In real applications, use environment variables, Secret Manager, or another secure secret store.

Configure authentication

In Program.cs, add these using statements:

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;

Before builder.Build():

var jwtKey = builder.Configuration["Jwt:Key"]!;

builder.Services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidAudience = builder.Configuration["Jwt:Audience"],
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(jwtKey))
        };
    });

builder.Services.AddAuthorization();

After builder.Build():

app.UseAuthentication();
app.UseAuthorization();

Create login DTOs

Dtos/LoginDto.cs

namespace TodoApi.Dtos;

public record LoginDto(string Username, string Password);

Dtos/LoginResponseDto.cs

namespace TodoApi.Dtos;

public record LoginResponseDto(string AccessToken);

Create login endpoint

app.MapPost("/api/auth/login", (
    LoginDto login,
    IConfiguration configuration) =>
{
    if (login.Username != "student" || login.Password != "password")
        return Results.Unauthorized();

    var claims = new[]
    {
        new Claim(ClaimTypes.Name, login.Username)
    };

    var key = new SymmetricSecurityKey(
        Encoding.UTF8.GetBytes(configuration["Jwt:Key"]!));

    var credentials = new SigningCredentials(
        key,
        SecurityAlgorithms.HmacSha256);

    var token = new JwtSecurityToken(
        issuer: configuration["Jwt:Issuer"],
        audience: configuration["Jwt:Audience"],
        claims: claims,
        expires: DateTime.UtcNow.AddHours(1),
        signingCredentials: credentials);

    var tokenString = new JwtSecurityTokenHandler().WriteToken(token);
    return Results.Ok(new LoginResponseDto(tokenString));
});

Test the login endpoint:

POST /api/auth/login
Content-Type: application/json
{
  "username": "student",
  "password": "password"
}
This lab intentionally uses one hard-coded user so you can focus on the JWT request flow. A production system must use a proper user store and password hashing.
STEP 13

Protect all Todo endpoints

Change your Todo route group to:

var todoGroup = app
    .MapGroup("/api/todos")
    .RequireAuthorization();

Now calling GET /api/todos without a token should return 401 Unauthorized.

Checkpoint: login endpoint is public, while every Todo endpoint requires authentication.
STEP 14

Test the complete workflow

Create TodoApi.http. Replace the host port with the one printed by dotnet run.

@host = http://localhost:5000

### Hello
GET {{host}}/

### Login
POST {{host}}/api/auth/login
Content-Type: application/json

{
  "username": "student",
  "password": "password"
}

### Get Todos without token — should be 401
GET {{host}}/api/todos

### Get Todos with token
GET {{host}}/api/todos
Authorization: Bearer YOUR_TOKEN

### Create Todo
POST {{host}}/api/todos
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "title": "Complete KU workshop"
}

### Update Todo
PUT {{host}}/api/todos/1
Authorization: Bearer YOUR_TOKEN
Content-Type: application/json

{
  "title": "Complete KU workshop",
  "isCompleted": true
}

### Delete Todo
DELETE {{host}}/api/todos/1
Authorization: Bearer YOUR_TOKEN

Status codes you should observe

Scenario Status
Successful GET 200 OK
Successful create 201 Created
Successful delete 204 No Content
Missing resource 404 Not Found
Missing/invalid token 401 Unauthorized
STEP 15

Add interactive endpoint documentation

OpenAPI describes the available routes, methods, parameters, request bodies, and responses in a standard format. Scalar renders that OpenAPI document as an interactive API reference.

Install OpenAPI and Scalar

dotnet add package Microsoft.AspNetCore.OpenApi
dotnet add package Scalar.AspNetCore

Add the namespaces and service registration in Program.cs:

using Scalar.AspNetCore;

builder.Services.AddOpenApi();

After var app = builder.Build();, map the OpenAPI document and visual reference:

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.MapScalarApiReference();
}

Add names and summaries to endpoints so the reference is easier to understand:

todoGroup.MapGet("/", async (AppDbContext db) => ...)
    .WithName("GetTodos")
    .WithSummary("Get all Todos")
    .Produces<List<TodoGetDto>>();

Restart the API, then open:

  • http://localhost:5000/scalar/v1 — interactive API reference
  • http://localhost:5000/openapi/v1.json — generated OpenAPI JSON
Checkpoint: the reference lists Login plus all five Todo CRUD operations.

Troubleshooting

dotnet command not found

Install the .NET 10 SDK, close and reopen your terminal, then run dotnet --version again.

code . command not found on macOS

In VS Code open the Command Palette and run Shell Command: Install 'code' command in PATH, or open the folder manually.

dotnet ef command not found

Run dotnet tool install --global dotnet-ef. If already installed, reopen the terminal and retry.

SQLite migration fails

Confirm Microsoft.EntityFrameworkCore.Sqlite and Microsoft.EntityFrameworkCore.Design are installed and that AppDbContext is registered before builder.Build().

GET /api/todos returns 401

That is expected after RequireAuthorization(). Login first and send the returned access token as Authorization: Bearer <token>.

Token is rejected immediately

Check that Issuer, Audience, Key, and token creation settings match the validation settings exactly.

Final checkpoint

You are finished when all of these are true:

  • Project builds with .NET 10.
  • GET, POST, PUT, DELETE endpoints work.
  • API input/output uses DTOs.
  • Todos persist in SQLite after application restart.
  • Login returns a JWT access token.
  • Todo endpoints return 401 without a valid Bearer token.
  • Authenticated CRUD requests succeed.
  • Scalar displays the generated OpenAPI endpoint reference.

Architecture you should now understand

Browser / Vue Client
        │
        │ HTTP + JSON
        ▼
ASP.NET Core 10 Minimal API
        │
        ├── JWT Authentication
        │
        ├── Todo Endpoints
        │       │
        │      DTO
        │       │
        └── Entity Framework Core
                │
                ▼
             SQLite

Next lab: build a Vue 3 + TypeScript frontend with Tailwind CSS and connect it to this API using Axios →