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.
GET /api/todos · GET /api/todos/{id} · POST /api/todos · PUT /api/todos/{id} · DELETE /api/todos/{id} · POST /api/auth/login
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
| Part | Meaning | Todo example |
|---|---|---|
| Method | The requested operation | GET, POST, PUT, DELETE |
| URL / route | The resource to target | /api/todos/3 |
| Request body | Data sent to the API | { "title": "Learn API" } |
| Status code | The result of the request | 200, 201, 404 |
| Response body | Data 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.
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();
buildercollects services and configuration.appis the web application that handles requests.MapGetconnects a GET route to a small C# function.Runstarts 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
| Concept | Example | Why it matters |
|---|---|---|
| Type and variable | int id = 3; | Stores a typed value. |
| String | string title = "Learn API"; | Stores text. |
| Record / DTO | public record TodoCreateDto(string Title); | Defines API input or output data. |
| Conditional | if (todo is null) return Results.NotFound(); | Handles missing data safely. |
| Collection | new List<TodoGetDto>() | Holds multiple Todos. |
| Async | await 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
Helpful official references: Minimal APIs, Web API overview, and C# tour.
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, notTodoItems. -
Use plural route resources:
/api/todos, not/api/todo. -
Match file names to type names:
TodoCreateDto.cscontainsTodoCreateDto. -
Avoid unclear abbreviations such as
td,res, orusr; prefertodo,result, andusername.
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 |
Prepare your development environment
Required tools
- .NET 10 SDK — Microsoft
- Visual Studio Code — Microsoft
- C# Dev Kit — Microsoft
- Optional: REST Client for VS Code
Verify .NET
dotnet --version
Expected result:
10.0.xxx
dotnet commands in this lab are the same
on both platforms.
dotnet --version shows .NET 10.
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
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?
-
CreateBuilderprepares application services and configuration. Buildcreates the web application.MapGetmaps an HTTP GET request to code.Runstarts the web server.
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 |
GET /api/todos instead of action-style URLs such
as GET /api/getTodos.
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.
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.
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
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.
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")
));
Create the database with EF Core Migration
dotnet ef migrations add InitialCreate
dotnet ef database update
You should now see:
Migrations/
todo.db
todo.db exists and migration commands
complete successfully.
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();
});
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.
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"
}
}
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"
}
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.
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 |
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
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 →