diff --git a/.github/workflows/build-samples.yml b/.github/workflows/build-samples.yml index fa11a6a..b379be1 100644 --- a/.github/workflows/build-samples.yml +++ b/.github/workflows/build-samples.yml @@ -11,6 +11,7 @@ on: - "distributed-systems/transactional-outbox-ef-core/**" - "aspnet-core/api-security-in-practice/**" - "aspnet-core/passkey-first-identity/**" + - "aspnet-core/build-web-api-dotnet-8/**" - ".github/workflows/build-samples.yml" pull_request: @@ -22,6 +23,7 @@ on: - "distributed-systems/transactional-outbox-ef-core/**" - "aspnet-core/api-security-in-practice/**" - "aspnet-core/passkey-first-identity/**" + - "aspnet-core/build-web-api-dotnet-8/**" - ".github/workflows/build-samples.yml" workflow_dispatch: @@ -251,3 +253,35 @@ jobs: aspnet-core/passkey-first-identity/tests/PasskeyIdentityMinimal.Tests/PasskeyIdentityMinimal.Tests.csproj --configuration Release --no-build + + test-web-api-fundamentals: + name: Test ASP.NET Core Todo API sample + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v5 + + - name: Install .NET 10 SDK + uses: actions/setup-dotnet@v5 + with: + dotnet-version: "10.0.x" + + - name: Restore + run: > + dotnet restore + aspnet-core/build-web-api-dotnet-8/TodoApiMinimal.slnx + + - name: Build + run: > + dotnet build + aspnet-core/build-web-api-dotnet-8/TodoApiMinimal.slnx + --configuration Release + --no-restore + + - name: Test + run: > + dotnet test + aspnet-core/build-web-api-dotnet-8/TodoApiMinimal.slnx + --configuration Release + --no-build diff --git a/README.md b/README.md index d6deea1..f0cc46a 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ Each sample folder contains a focused implementation of one tutorial topic. The | [`distributed-systems/transactional-outbox-ef-core`](distributed-systems/transactional-outbox-ef-core/) | Minimal EF Core and SQLite demonstration that saves business state and an outbox message atomically, then publishes it through a one-shot relay | [Transactional Outbox Pattern in .NET with EF Core (.NET 10): Fix the Dual-Write Problem](https://www.dotnet-guide.com/tutorials/distributed-systems/transactional-outbox-ef-core/) | | [`aspnet-core/api-security-in-practice`](aspnet-core/api-security-in-practice/) | Minimal JWT bearer authentication, note ownership enforcement, and rate limiting for an ASP.NET Core API | [ASP.NET Core 8 API Security: JWT Authentication, CSRF Protection & Rate Limiting](https://www.dotnet-guide.com/tutorials/aspnet-core/api-security-in-practice/) | | [`aspnet-core/passkey-first-identity`](aspnet-core/passkey-first-identity/) | Minimal .NET 10 Identity sample for passkey enrollment, username-first passkey sign-in, antiforgery protection, and stored credential listing | [Passkeys in ASP.NET Core Identity (.NET 10): Build a Passwordless-First Web App with WebAuthn](https://www.dotnet-guide.com/tutorials/aspnet-core/passkey-first-identity/) | -| -## Companion articles +| [`aspnet-core/build-web-api-dotnet-8`](aspnet-core/build-web-api-dotnet-8/) | Minimal .NET 10 Todo API demonstrating route groups, dependency injection, CRUD status codes, validation, middleware, and integration testing | [ASP.NET Core Fundamentals: Build Web APIs on .NET 8](https://www.dotnet-guide.com/tutorials/aspnet-core/build-web-api-dotnet-8/) | +## Companion articles - [Common Microsoft.Extensions.AI mistakes](https://www.dotnet-guide.com/articles/dotnet-ai/microsoft-extensions-ai-common-mistakes/) - [Vector search in .NET: Common mistakes](https://www.dotnet-guide.com/articles/dotnet-ai/vector-search-common-mistakes/) - [NetArchTest, ArchUnitNET, and Roslyn analyzers](https://www.dotnet-guide.com/articles/software-architecture/netarchtest-archunitnet-roslyn-analyzers/) @@ -121,43 +121,54 @@ tutorials/ | |-- TransactionalOutboxMinimal.Tests.csproj | `-- OutboxFlowTests.cs |-- aspnet-core/ -| `-- api-security-in-practice/ -| |-- ApiSecurityMinimal.slnx -| |-- README.md -| |-- src/ -| | `-- ApiSecurityMinimal/ -| | |-- ApiSecurityMinimal.csproj -| | |-- Program.cs -| | |-- Models.cs -| | |-- DemoStore.cs -| | |-- JwtTokenService.cs -| | `-- appsettings.json -| `-- tests/ -| `-- ApiSecurityMinimal.Tests/ -| |-- ApiSecurityMinimal.Tests.csproj -| `-- ApiSecurityTests.cs -| `-- passkey-first-identity/ -| |-- PasskeyIdentityMinimal.slnx +| |-- api-security-in-practice/ +| | |-- ApiSecurityMinimal.slnx +| | |-- README.md +| | |-- src/ +| | | `-- ApiSecurityMinimal/ +| | | |-- ApiSecurityMinimal.csproj +| | | |-- Program.cs +| | | |-- Models.cs +| | | |-- DemoStore.cs +| | | |-- JwtTokenService.cs +| | | `-- appsettings.json +| | `-- tests/ +| | `-- ApiSecurityMinimal.Tests/ +| | |-- ApiSecurityMinimal.Tests.csproj +| | `-- ApiSecurityTests.cs +| |-- passkey-first-identity/ +| | |-- PasskeyIdentityMinimal.slnx +| | |-- README.md +| | |-- src/ +| | | `-- PasskeyIdentityMinimal/ +| | | |-- PasskeyIdentityMinimal.csproj +| | | |-- Program.cs +| | | |-- appsettings.json +| | | |-- Data/ +| | | | |-- ApplicationDbContext.cs +| | | | `-- ApplicationUser.cs +| | | |-- Endpoints/ +| | | | |-- AntiforgeryEndpointExtensions.cs +| | | | `-- PasskeyEndpoints.cs +| | | `-- wwwroot/ +| | | |-- index.html +| | | `-- passkeys.js +| | `-- tests/ +| | `-- PasskeyIdentityMinimal.Tests/ +| | |-- PasskeyIdentityMinimal.Tests.csproj +| | |-- PasskeyEndpointTests.cs +| | `-- PasskeyIdentityFactory.cs +| `-- build-web-api-dotnet-8/ +| |-- TodoApiMinimal.slnx | |-- README.md | |-- src/ -| | `-- PasskeyIdentityMinimal/ -| | |-- PasskeyIdentityMinimal.csproj -| | |-- Program.cs -| | |-- appsettings.json -| | |-- Data/ -| | | |-- ApplicationDbContext.cs -| | | `-- ApplicationUser.cs -| | |-- Endpoints/ -| | | |-- AntiforgeryEndpointExtensions.cs -| | | `-- PasskeyEndpoints.cs -| | `-- wwwroot/ -| | |-- index.html -| | `-- passkeys.js +| | `-- TodoApiMinimal/ +| | |-- TodoApiMinimal.csproj +| | `-- Program.cs | `-- tests/ -| `-- PasskeyIdentityMinimal.Tests/ -| |-- PasskeyIdentityMinimal.Tests.csproj -| |-- PasskeyEndpointTests.cs -| `-- PasskeyIdentityFactory.cs +| `-- TodoApiMinimal.Tests/ +| |-- TodoApiMinimal.Tests.csproj +| `-- TodoApiTests.cs |-- .github/ | `-- workflows/ | `-- build-samples.yml diff --git a/aspnet-core/build-web-api-dotnet-8/README.md b/aspnet-core/build-web-api-dotnet-8/README.md new file mode 100644 index 0000000..b79d5ec --- /dev/null +++ b/aspnet-core/build-web-api-dotnet-8/README.md @@ -0,0 +1,166 @@ +# ASP.NET Core Todo API — Minimal Fundamentals Sample + +A focused .NET 10 companion showing how core ASP.NET Core Web API building +blocks work together in a small Todo API. + +## Full tutorial + +[ASP.NET Core Fundamentals: Build Web APIs on .NET 8](https://www.dotnet-guide.com/tutorials/aspnet-core/build-web-api-dotnet-8/) + +## Framework note + +The tutorial explains ASP.NET Core 8. + +This companion targets .NET 10 so it can use the DOTNET GUIDE repository's +current SDK and CI toolchain. The sample demonstrates the same foundational +routing, dependency-injection, request-binding, middleware, CRUD, and HTTP-result +patterns. + +## What this sample demonstrates + +- Minimal API route groups; +- route parameters and constraints; +- JSON request-body binding; +- dependency injection into route handlers; +- an in-memory repository; +- GET, POST, PUT, and DELETE operations; +- validation responses; +- `200`, `201`, `204`, `400`, and `404` semantics; +- a `Location` header after resource creation; +- a small request-ID middleware; +- integration testing with `WebApplicationFactory`. + +## Important boundary + +This repository sample intentionally does not reproduce the complete tutorial. + +The full DOTNET GUIDE tutorial also covers: + +- controllers and MVC patterns; +- configuration sources; +- EF Core and SQLite; +- migrations; +- JWT authentication and authorization; +- caching; +- rate limiting; +- health-check strategy; +- performance guidance; +- migration from ASP.NET Core 7; +- deployment options. + +The separate repository sample +[`aspnet-core/api-security-in-practice`](../api-security-in-practice/) +contains a focused security example. + +## Prerequisite + +- .NET 10 SDK + +Check: + +```powershell +dotnet --version +``` + +## Restore, build, and test + +```powershell +dotnet restore .\TodoApiMinimal.slnx + +dotnet build ` + .\TodoApiMinimal.slnx ` + --configuration Release ` + --no-restore + +dotnet test ` + .\TodoApiMinimal.slnx ` + --configuration Release ` + --no-build +``` + +## Run + +```powershell +dotnet run ` + --project .\src\TodoApiMinimal\TodoApiMinimal.csproj ` + --urls http://localhost:5128 +``` + +## Endpoints + +```text +GET / +GET /health +GET /api/todos +GET /api/todos/{id} +POST /api/todos +PUT /api/todos/{id} +DELETE /api/todos/{id} +``` + +## Example request + +```powershell +$body = @{ + title = "Review HTTP status codes" +} | ConvertTo-Json + +Invoke-RestMethod ` + -Method Post ` + -Uri "http://localhost:5128/api/todos" ` + -ContentType "application/json" ` + -Body $body +``` + +## Persistence limitation + +The repository is stored only in application memory. + +Restarting the process resets the sample to its two seeded Todo items. + +No production persistence is implied. + +## Project structure + +```text +TodoApiMinimal.slnx +README.md +src/ +└── TodoApiMinimal/ + ├── TodoApiMinimal.csproj + └── Program.cs +tests/ +└── TodoApiMinimal.Tests/ + ├── TodoApiMinimal.Tests.csproj + └── TodoApiTests.cs +``` + +## Deliberately omitted + +- controllers; +- EF Core; +- SQLite; +- migrations; +- authentication; +- authorization; +- CORS; +- caching; +- rate limiting; +- Swagger or OpenAPI UI; +- Docker; +- cloud deployment; +- production storage. + +These are covered by the full tutorial or another focused companion sample. + +## Verification + +- Companion target framework: .NET 10 +- Tutorial framework: .NET 8 +- External services required: none +- API keys required: none +- Database required: none +- Expected integration tests: 7 +- Last reviewed: 2026-07-31 + +This sample is educational and should be reviewed before production use. \ No newline at end of file diff --git a/aspnet-core/build-web-api-dotnet-8/TodoApiMinimal.slnx b/aspnet-core/build-web-api-dotnet-8/TodoApiMinimal.slnx new file mode 100644 index 0000000..6583c4c --- /dev/null +++ b/aspnet-core/build-web-api-dotnet-8/TodoApiMinimal.slnx @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/aspnet-core/build-web-api-dotnet-8/src/TodoApiMinimal/Program.cs b/aspnet-core/build-web-api-dotnet-8/src/TodoApiMinimal/Program.cs new file mode 100644 index 0000000..2db0e74 --- /dev/null +++ b/aspnet-core/build-web-api-dotnet-8/src/TodoApiMinimal/Program.cs @@ -0,0 +1,264 @@ +using System.Collections.Concurrent; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddSingleton< + ITodoRepository, + InMemoryTodoRepository>(); + +var app = builder.Build(); + +app.Use(async (context, next) => +{ + context.Response.Headers["X-Request-Id"] = + context.TraceIdentifier; + + await next(); +}); + +app.MapGet("/", () => Results.Ok(new +{ + name = "Todo API Minimal", + endpoints = new[] + { + "GET /health", + "GET /api/todos", + "GET /api/todos/{id}", + "POST /api/todos", + "PUT /api/todos/{id}", + "DELETE /api/todos/{id}" + } +})); + +app.MapGet("/health", () => + Results.Ok(new + { + status = "ok" + })); + +RouteGroupBuilder todos = + app.MapGroup("/api/todos"); + +todos.MapGet( + "/", + (ITodoRepository repository) => + Results.Ok(repository.GetAll())); + +todos.MapGet( + "/{id:int:min(1)}", + (int id, ITodoRepository repository) => + { + TodoItem? todo = + repository.GetById(id); + + return todo is null + ? Results.NotFound() + : Results.Ok(todo); + }); + +todos.MapPost( + "/", + ( + CreateTodoRequest request, + ITodoRepository repository) => + { + Dictionary? errors = + ValidateTitle(request.Title); + + if (errors is not null) + { + return Results.ValidationProblem(errors); + } + + TodoItem created = + repository.Create( + request.Title!.Trim()); + + return Results.Created( + $"/api/todos/{created.Id}", + created); + }); + +todos.MapPut( + "/{id:int:min(1)}", + ( + int id, + UpdateTodoRequest request, + ITodoRepository repository) => + { + Dictionary? errors = + ValidateTitle(request.Title); + + if (errors is not null) + { + return Results.ValidationProblem(errors); + } + + TodoItem? updated = + repository.Update( + id, + request.Title!.Trim(), + request.IsComplete); + + return updated is null + ? Results.NotFound() + : Results.NoContent(); + }); + +todos.MapDelete( + "/{id:int:min(1)}", + (int id, ITodoRepository repository) => + repository.Delete(id) + ? Results.NoContent() + : Results.NotFound()); + +app.Run(); + +static Dictionary? + ValidateTitle(string? title) +{ + if (string.IsNullOrWhiteSpace(title)) + { + return new Dictionary + { + ["title"] = + [ + "Title is required." + ] + }; + } + + if (title.Trim().Length > 100) + { + return new Dictionary + { + ["title"] = + [ + "Title must contain 100 characters or fewer." + ] + }; + } + + return null; +} + +public sealed record TodoItem( + int Id, + string Title, + bool IsComplete); + +public sealed record CreateTodoRequest( + string? Title); + +public sealed record UpdateTodoRequest( + string? Title, + bool IsComplete); + +public interface ITodoRepository +{ + IReadOnlyList GetAll(); + + TodoItem? GetById(int id); + + TodoItem Create(string title); + + TodoItem? Update( + int id, + string title, + bool isComplete); + + bool Delete(int id); +} + +public sealed class InMemoryTodoRepository : + ITodoRepository +{ + private readonly + ConcurrentDictionary _items = + new(); + + private int _nextId; + + public InMemoryTodoRepository() + { + _items[1] = + new TodoItem( + 1, + "Learn ASP.NET Core", + false); + + _items[2] = + new TodoItem( + 2, + "Build a Todo API", + false); + + _nextId = 2; + } + + public IReadOnlyList GetAll() => + _items.Values + .OrderBy(item => item.Id) + .ToArray(); + + public TodoItem? GetById(int id) => + _items.TryGetValue( + id, + out TodoItem? item) + ? item + : null; + + public TodoItem Create(string title) + { + int id = + Interlocked.Increment( + ref _nextId); + + var item = + new TodoItem( + id, + title, + false); + + _items[id] = item; + + return item; + } + + public TodoItem? Update( + int id, + string title, + bool isComplete) + { + while (_items.TryGetValue( + id, + out TodoItem? current)) + { + TodoItem updated = + current with + { + Title = title, + IsComplete = isComplete + }; + + if (_items.TryUpdate( + id, + updated, + current)) + { + return updated; + } + } + + return null; + } + + public bool Delete(int id) => + _items.TryRemove( + id, + out _); +} + +public partial class Program +{ +} \ No newline at end of file diff --git a/aspnet-core/build-web-api-dotnet-8/src/TodoApiMinimal/TodoApiMinimal.csproj b/aspnet-core/build-web-api-dotnet-8/src/TodoApiMinimal/TodoApiMinimal.csproj new file mode 100644 index 0000000..718ebd0 --- /dev/null +++ b/aspnet-core/build-web-api-dotnet-8/src/TodoApiMinimal/TodoApiMinimal.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/aspnet-core/build-web-api-dotnet-8/tests/TodoApiMinimal.Tests/TodoApiMinimal.Tests.csproj b/aspnet-core/build-web-api-dotnet-8/tests/TodoApiMinimal.Tests/TodoApiMinimal.Tests.csproj new file mode 100644 index 0000000..aaf9454 --- /dev/null +++ b/aspnet-core/build-web-api-dotnet-8/tests/TodoApiMinimal.Tests/TodoApiMinimal.Tests.csproj @@ -0,0 +1,49 @@ + + + + net10.0 + enable + enable + false + true + Exe + + + + + + + + + + + all + + runtime; + build; + native; + contentfiles; + analyzers; + buildtransitive + + + + + + + + + + + + + \ No newline at end of file diff --git a/aspnet-core/build-web-api-dotnet-8/tests/TodoApiMinimal.Tests/TodoApiTests.cs b/aspnet-core/build-web-api-dotnet-8/tests/TodoApiMinimal.Tests/TodoApiTests.cs new file mode 100644 index 0000000..8a6b619 --- /dev/null +++ b/aspnet-core/build-web-api-dotnet-8/tests/TodoApiMinimal.Tests/TodoApiTests.cs @@ -0,0 +1,219 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace TodoApiMinimal.Tests; + +public sealed class TodoApiTests +{ + [Fact] + public async Task + Get_all_returns_seeded_todos() + { + using var factory = + new WebApplicationFactory(); + + using HttpClient client = + factory.CreateClient(); + + CancellationToken ct = + TestContext.Current.CancellationToken; + + HttpResponseMessage response = + await client.GetAsync( + "/api/todos", + ct); + + Assert.Equal( + HttpStatusCode.OK, + response.StatusCode); + + TodoItem[]? todos = + await response.Content + .ReadFromJsonAsync( + ct); + + Assert.NotNull(todos); + Assert.Equal(2, todos.Length); + Assert.Equal(1, todos[0].Id); + Assert.Equal(2, todos[1].Id); + } + + [Fact] + public async Task + Get_missing_todo_returns_not_found() + { + using var factory = + new WebApplicationFactory(); + + using HttpClient client = + factory.CreateClient(); + + CancellationToken ct = + TestContext.Current.CancellationToken; + + HttpResponseMessage response = + await client.GetAsync( + "/api/todos/999", + ct); + + Assert.Equal( + HttpStatusCode.NotFound, + response.StatusCode); + } + + [Fact] + public async Task + Create_returns_created_resource_and_location() + { + using var factory = + new WebApplicationFactory(); + + using HttpClient client = + factory.CreateClient(); + + CancellationToken ct = + TestContext.Current.CancellationToken; + + HttpResponseMessage response = + await client.PostAsJsonAsync( + "/api/todos", + new CreateTodoRequest( + "Write integration tests"), + ct); + + Assert.Equal( + HttpStatusCode.Created, + response.StatusCode); + + TodoItem? created = + await response.Content + .ReadFromJsonAsync( + ct); + + Assert.NotNull(created); + Assert.Equal( + "Write integration tests", + created.Title); + + Assert.Equal( + $"/api/todos/{created.Id}", + response.Headers.Location?.ToString()); + } + + [Fact] + public async Task + Blank_title_returns_validation_problem() + { + using var factory = + new WebApplicationFactory(); + + using HttpClient client = + factory.CreateClient(); + + CancellationToken ct = + TestContext.Current.CancellationToken; + + HttpResponseMessage response = + await client.PostAsJsonAsync( + "/api/todos", + new CreateTodoRequest(" "), + ct); + + Assert.Equal( + HttpStatusCode.BadRequest, + response.StatusCode); + } + + [Fact] + public async Task + Update_changes_existing_todo() + { + using var factory = + new WebApplicationFactory(); + + using HttpClient client = + factory.CreateClient(); + + CancellationToken ct = + TestContext.Current.CancellationToken; + + HttpResponseMessage updateResponse = + await client.PutAsJsonAsync( + "/api/todos/1", + new UpdateTodoRequest( + "Learn route groups", + true), + ct); + + Assert.Equal( + HttpStatusCode.NoContent, + updateResponse.StatusCode); + + TodoItem? updated = + await client.GetFromJsonAsync( + "/api/todos/1", + ct); + + Assert.NotNull(updated); + Assert.Equal( + "Learn route groups", + updated.Title); + Assert.True(updated.IsComplete); + } + + [Fact] + public async Task + Delete_removes_existing_todo() + { + using var factory = + new WebApplicationFactory(); + + using HttpClient client = + factory.CreateClient(); + + CancellationToken ct = + TestContext.Current.CancellationToken; + + HttpResponseMessage deleteResponse = + await client.DeleteAsync( + "/api/todos/1", + ct); + + Assert.Equal( + HttpStatusCode.NoContent, + deleteResponse.StatusCode); + + HttpResponseMessage getResponse = + await client.GetAsync( + "/api/todos/1", + ct); + + Assert.Equal( + HttpStatusCode.NotFound, + getResponse.StatusCode); + } + + [Fact] + public async Task + Middleware_adds_request_id_header() + { + using var factory = + new WebApplicationFactory(); + + using HttpClient client = + factory.CreateClient(); + + CancellationToken ct = + TestContext.Current.CancellationToken; + + HttpResponseMessage response = + await client.GetAsync( + "/health", + ct); + + Assert.True( + response.Headers.Contains( + "X-Request-Id")); + } +} \ No newline at end of file