Table of Contents
- Module 1 — Getting Started with ASP.NET Core 10 Minimal APIs
- Module 2 — Dependency Injection, Routing, and Parameter Binding
- Module 3 — Crafting Correct Responses
- Module 4 — Creating, Updating, and Deleting Resources
- Module 5 — Validation, Structure, Logging, and Exception Handling
- Module 6 — Securing Your Minimal API
- Module 7 — Documenting Your Minimal API
- Architecture Diagrams
- Reference Tables
Module 1 — Getting Started with ASP.NET Core 10 Minimal APIs
Introduction
Minimal APIs are a relatively new approach for building Web APIs with ASP.NET Core. More direct than the classic MVC approach with less “ceremony”. This course starts from scratch to build a complete, secure, and documented API.
Recommended prerequisites:
- Basic ASP.NET Core concepts (DI container, request pipeline, middleware)
- Experience with ASP.NET Core MVC and Entity Framework Core (recommended but not required)
Tools used: Visual Studio 2026 (or any IDE supporting ASP.NET Core)
ASP.NET Core API Building Approaches
Two approaches coexist for building Web APIs with ASP.NET Core:
| Approach | Description | Characteristics |
|---|---|---|
| ASP.NET Core MVC | Model-View-Controller pattern | Controllers, Action methods, Attributes, Full-featured |
| ASP.NET Core Minimal APIs | Lightweight and direct approach | Lambdas or static methods, fewer classes, faster to get started |
Comparison: Minimal APIs vs Controller-based
graph LR
subgraph MVC["ASP.NET Core MVC"]
direction TB
M["Model\n(data, business rules)"]
V["View\n(returned JSON)"]
C["Controller\n(logic, routing)"]
C --> M
C --> V
M --> V
end
subgraph MinAPI["ASP.NET Core Minimal APIs"]
direction TB
PCS["Program.cs"]
HH["Handler (Lambda or static method)"]
RT["Route Template"]
PCS --> RT
RT --> HH
end
Client["HTTP Client"] -->|"Request"| MVC
Client -->|"Request"| MinAPI
Detailed comparison table:
| Criterion | Minimal APIs | MVC Controllers |
|---|---|---|
| Boilerplate | Minimal | More verbose |
| Performance | Slightly superior | Very good |
| Features | Essential | Complete (Views, Areas, complex Filters) |
| Testability | Good (with Handler classes) | Excellent |
| Content Negotiation | Not natively supported | Supported |
| Use cases | Microservices, lightweight APIs | Complex applications, enterprise APIs |
Creating a Minimal API Project
In Visual Studio:
- FILE > New > Project → Template: ASP.NET Core Web API
- Uncheck “Use controllers” (to use Minimal APIs)
- Uncheck “Enable OpenAPI support” (we’ll configure it manually)
- Authentication → None (configured manually afterward)
Initial Program.cs structure:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.UseHttpsRedirection();
app.MapGet("/recipes", () =>
{
return "Hello from Minimal API!";
});
app.Run();
Request pipeline — overview:
flowchart LR
Client["HTTP Client"]
Client -->|"HTTP Request"| MW1["UseHttpsRedirection"]
MW1 --> MW2["UseStatusCodePages"]
MW2 --> MW3["UseAuthentication"]
MW3 --> MW4["UseAuthorization"]
MW4 --> Router["Route Matching\n(MapGet, MapPost...)"]
Router --> Handler["Handler\n(Lambda / Static Method)"]
Handler -->|"HTTP Response"| Client
The .http File — Sending Requests
The .http file lets you send requests directly from Visual Studio:
@baseUrl = https://localhost:7290
### Get all dishes
GET {{baseUrl}}/dishes
Authorization: Bearer {{token}}
###
### Get a specific dish
GET {{baseUrl}}/dishes/fd630a57-2352-4731-b25c-db9cc7601b16
Authorization: Bearer {{token}}
###
### Create a dish
POST {{baseUrl}}/dishes
Content-Type: application/json
Authorization: Bearer {{token}}
{
"name": "Grilled Salmon with Vegetables"
}
Data Layer with Entity Framework Core
Required NuGet packages:
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0" />
Entities:
// Entities/Dish.cs
public class Dish
{
public Guid Id { get; set; }
public required string Name { get; set; }
public ICollection<Ingredient> Ingredients { get; set; }
= new List<Ingredient>();
}
// Entities/Ingredient.cs
public class Ingredient
{
public Guid Id { get; set; }
public required string Name { get; set; }
public ICollection<Dish> Dishes { get; set; }
= new List<Dish>();
}
DbContext:
// DbContexts/DishesDbContext.cs
public class DishesDbContext : DbContext
{
public DbSet<Dish> Dishes { get; set; }
public DbSet<Ingredient> Ingredients { get; set; }
public DishesDbContext(DbContextOptions<DishesDbContext> options)
: base(options) { }
// Seed data + model configuration in OnModelCreating
}
Registration in Program.cs:
builder.Services.AddDbContext<DishesDbContext>(o => o.UseSqlite(
builder.Configuration["ConnectionStrings:DishesDBConnectionString"]));
appsettings.json:
{
"ConnectionStrings": {
"DishesDBConnectionString": "Data Source=Dishes.db"
}
}
Module 2 — Dependency Injection, Routing, and Parameter Binding
Dependency Injection in Minimal APIs
The Inversion of Control pattern delegates the selection of dependencies to an external component. ASP.NET Core includes a built-in DI container.
Available lifetimes:
| Lifetime | Description | Typical usage |
|---|---|---|
AddSingleton | Single instance for the whole application | Configuration, caches |
AddScoped | One instance per HTTP request | DbContext, business services |
AddTransient | New instance on each injection | Lightweight, stateless services |
Injection in a Minimal API handler:
// The DI container automatically injects DishesDbContext
app.MapGet("/dishes", async (DishesDbContext dishesDbContext) =>
{
return await dishesDbContext.Dishes.ToListAsync();
});
// With multiple dependencies
app.MapGet("/dishes", async (
DishesDbContext dishesDbContext,
ILogger<Dish> logger,
ClaimsPrincipal claimsPrincipal,
string? name) =>
{
logger.LogInformation("Fetching dishes...");
return await dishesDbContext.Dishes
.Where(d => name == null || d.Name.Contains(name))
.ToListAsync();
});
Note: Special types (
HttpContext,ClaimsPrincipal,CancellationToken) are automatically injected without annotation.
Routing in Minimal APIs
Routing consists of associating an HTTP method + URI to a specific handler.
flowchart TD
Request["HTTP Request\nGET /dishes/abc-123"] --> RM["Route Matching"]
RM --> EP1["MapGet /dishes → GetDishes"]
RM --> EP2["MapGet /dishes/{dishId:guid} → GetDishById"]
RM --> EP3["MapPost /dishes → CreateDish"]
RM --> EP4["MapPut /dishes/{dishId:guid} → UpdateDish"]
RM --> EP5["MapDelete /dishes/{dishId:guid} → DeleteDish"]
EP2 -->|"dishId = abc-123"| Handler["Handler executed"]
Available mapping methods:
| HTTP Method | Map Method | Usage |
|---|---|---|
| GET | MapGet | Read data |
| POST | MapPost | Create a resource |
| PUT | MapPut | Update a resource (full replacement) |
| PATCH | MapPatch | Partial update |
| DELETE | MapDelete | Delete a resource |
Route template examples:
// Simple route
app.MapGet("/dishes", handler);
// Route with parameter
app.MapGet("/dishes/{dishId}", handler);
// Route with type constraint
app.MapGet("/dishes/{dishId:guid}", handler);
// Route with int constraint
app.MapGet("/items/{id:int}", handler);
// Route with optional parameter
app.MapGet("/dishes/{dishName?}", handler);
Route Templates and Constraints
// Automatic binding of a route parameter
app.MapGet("/dishes/{dishId:guid}", async (
DishesDbContext dishesDbContext,
Guid dishId) =>
{
return await dishesDbContext.Dishes
.FirstOrDefaultAsync(d => d.Id == dishId);
});
// Route with multiple parameters
app.MapGet("/dishes/{dishId:guid}/ingredients", async (
DishesDbContext dishesDbContext,
Guid dishId) =>
{
var dish = await dishesDbContext.Dishes
.Include(d => d.Ingredients)
.FirstOrDefaultAsync(d => d.Id == dishId);
return dish?.Ingredients;
});
Common route constraints:
| Constraint | Example | Description |
|---|---|---|
:guid | {id:guid} | Valid GUID |
:int | {id:int} | Integer |
:long | {id:long} | Long integer |
:double | {price:double} | Double |
:bool | {active:bool} | Boolean |
:datetime | {date:datetime} | DateTime |
:minlength(n) | {name:minlength(2)} | Minimum length |
:maxlength(n) | {name:maxlength(100)} | Maximum length |
:range(min,max) | {age:range(0,120)} | Value range |
Why Not Expose the Entity Model
Directly exposing entities via endpoints is a bad practice:
- Separation of concerns: Entities represent database tables; DTOs represent what is sent over the network.
- Computed data: A
DishDtocan containIngredientCount(computed) that doesn’t exist in the entity. - Security: Avoids exposing sensitive or internal fields.
- Evolvability: Entities can change without impacting the API contract.
DTOs and Mapping
DTO classes:
// Models/DishDto.cs
namespace DishesAPI.Models;
public class DishDto
{
public Guid Id { get; set; }
public required string Name { get; set; }
}
// Models/DishForCreationDto.cs
using System.ComponentModel.DataAnnotations;
namespace DishesAPI.Models;
public class DishForCreationDto
{
[Required]
[StringLength(200, MinimumLength = 2)]
public required string Name { get; set; }
}
// Models/DishForUpdateDto.cs
using System.ComponentModel.DataAnnotations;
namespace DishesAPI.Models;
public class DishForUpdateDto
{
[Required]
[StringLength(200, MinimumLength = 2)]
public required string Name { get; set; }
}
// Models/IngredientDto.cs
namespace DishesAPI.Models;
public class IngredientDto
{
public Guid Id { get; set; }
public required string Name { get; set; }
public Guid DishId { get; set; }
}
Manual mapping extensions:
// Extensions/MappingExtensions.cs
using DishesAPI.Entities;
using DishesAPI.Models;
namespace DishesAPI.Extensions;
public static class MappingExtensions
{
public static DishDto ToDishDto(this Dish dish)
{
return new DishDto
{
Id = dish.Id,
Name = dish.Name
};
}
public static IEnumerable<DishDto> ToDishDtoList(this IEnumerable<Dish> dishes)
{
return dishes.Select(d => d.ToDishDto());
}
public static IngredientDto ToIngredientDto(this Ingredient ingredient, Guid dishId)
{
return new IngredientDto
{
Id = ingredient.Id,
Name = ingredient.Name,
DishId = dishId
};
}
public static IEnumerable<IngredientDto> ToIngredientDtoList(
this IEnumerable<Ingredient> ingredients, Guid dishId)
{
return ingredients.Select(i => i.ToIngredientDto(dishId));
}
public static Dish ToDish(this DishForCreationDto dto)
{
return new Dish { Name = dto.Name };
}
public static void UpdateFromDto(this Dish dish, DishForUpdateDto dto)
{
dish.Name = dto.Name;
}
}
Tip: For complex models, prefer a source-generator mapper like Mapperly (zero runtime overhead).
Parameter Binding
Parameter binding is the process of converting request data into strongly typed parameters.
flowchart LR
Request["HTTP Request"] --> PB["Parameter Binding Engine"]
PB --> RT["[FromRoute]\nRoute values"]
PB --> QS["[FromQuery]\nQuery string"]
PB --> HD["[FromHeader]\nHeaders"]
PB --> BD["[FromBody]\nRequest body (JSON)"]
PB --> FV["[FromForm]\nForm values"]
PB --> DI["[FromServices]\nDependency Injection"]
PB --> SP["Special Types\nHttpContext, ClaimsPrincipal,\nCancellationToken, etc."]
Inference rules (without attributes):
| Parameter type | Inferred source |
|---|---|
Simple types (string, Guid, int) matching name in route | Route value |
| Simple types NOT matching the route | Query string |
| Complex types (classes) | Request body (JSON) |
| Types registered in the DI container | Dependency Injection |
HttpContext, ClaimsPrincipal, CancellationToken | Special injection |
Binding examples:
// Binding from query string (explicit)
app.MapGet("/dishes", async (
DishesDbContext db,
[FromQuery] string? name) =>
{
return await db.Dishes
.Where(d => name == null || d.Name.Contains(name))
.ToListAsync();
});
// Automatic binding (inferred) - same result
app.MapGet("/dishes", async (DishesDbContext db, string? name) =>
{
return await db.Dishes
.Where(d => name == null || d.Name.Contains(name))
.ToListAsync();
});
// Accessing the ClaimsPrincipal (current user)
app.MapGet("/dishes", async (
DishesDbContext db,
ClaimsPrincipal claimsPrincipal) =>
{
var userId = claimsPrincipal.FindFirstValue(ClaimTypes.NameIdentifier);
// ...
});
// Different name between parameter and query string
app.MapGet("/dishes", async (
DishesDbContext db,
[FromQuery(Name = "filter")] string? nameFilter) =>
{
// ?filter=salmon → nameFilter = "salmon"
});
Module 3 — Crafting Correct Responses
Common API Features
| Feature | Description | Implementation |
|---|---|---|
| Filtering | Limit a collection based on a predicate | Query string ?name=salmon |
| Searching | Search across multiple fields | Query string ?searchQuery=stew |
| Sorting | Sort the collection | Query string ?orderBy=name |
| Paging | Paginate results | Query string ?pageNumber=1&pageSize=10 |
Important: For complex filters, always pass parameters via query string — never put filter parameters in the body of a GET (often ignored by web servers).
Status Codes and Their Importance
HTTP status codes communicate the result of a request to the non-human consumers of the API.
flowchart TD
Request["Client Request"] --> API["API Handler"]
API -->|"Resource found"| S200["200 OK\n(GET/PUT with body)"]
API -->|"Resource created"| S201["201 Created\n+ Location header"]
API -->|"Success without body"| S204["204 No Content\n(DELETE/PUT)"]
API -->|"Resource not found"| S404["404 Not Found"]
API -->|"Invalid data"| S400["400 Bad Request\n(validation)"]
API -->|"Not authenticated"| S401["401 Unauthorized"]
API -->|"Access forbidden"| S403["403 Forbidden"]
API -->|"Server error"| S500["500 Internal Server Error"]
Common status codes table:
| Code | Name | Usage |
|---|---|---|
| 200 | OK | General success (GET, PUT with return) |
| 201 | Created | Resource successfully created |
| 204 | No Content | Success without returned content (DELETE, PUT) |
| 400 | Bad Request | Invalid data sent by the client |
| 401 | Unauthorized | Not authenticated |
| 403 | Forbidden | Authenticated but not authorized |
| 404 | Not Found | Non-existent resource |
| 409 | Conflict | Conflict (e.g., duplicate) |
| 422 | Unprocessable Entity | Unprocessable entity (semantic validation) |
| 500 | Internal Server Error | Unhandled server-side error |
TypedResults — Correct Responses
TypedResults provides strongly-typed helpers matching HTTP status codes. They allow the framework to generate precise OpenAPI documentation.
// Results<T1, T2> lets you declare possible return types
app.MapGet("/dishes/{dishId:guid}", async (
DishesDbContext dishesDbContext,
Guid dishId) =>
{
var dish = await dishesDbContext.Dishes
.FirstOrDefaultAsync(d => d.Id == dishId);
if (dish == null)
return TypedResults.NotFound(); // 404
return TypedResults.Ok(dish.ToDishDto()); // 200 + DishDto
});
// Explicit return type declaration (required when multiple types)
app.MapGet("/dishes/{dishId:guid}",
async Task<Results<NotFound, Ok<DishDto>>> (
DishesDbContext dishesDbContext,
Guid dishId) =>
{
var dish = await dishesDbContext.Dishes
.FirstOrDefaultAsync(d => d.Id == dishId);
if (dish == null)
return TypedResults.NotFound();
return TypedResults.Ok(dish.ToDishDto());
});
Available TypedResults:
| TypedResult | Status Code | Description |
|---|---|---|
TypedResults.Ok(value) | 200 | Success with body |
TypedResults.Created(uri, value) | 201 | Resource created |
TypedResults.CreatedAtRoute(value, routeName, routeValues) | 201 | Created with link |
TypedResults.NoContent() | 204 | Success without body |
TypedResults.BadRequest(detail) | 400 | Invalid request |
TypedResults.NotFound() | 404 | Not found |
TypedResults.Conflict() | 409 | Conflict |
TypedResults.Forbid() | 403 | Access forbidden |
TypedResults.Unauthorized() | 401 | Not authenticated |
TypedResults.Problem(...) | 500 | Server error |
TypedResults.ValidationProblem(errors) | 400 | Validation errors |
Problem Details — Standardized Error Responses
RFC 9457 defines a standardized format for error responses.
Example Problem Details response:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Not Found",
"status": 404,
"detail": "The dish with id 'xxx' was not found.",
"instance": "/dishes/xxx"
}
Configuration in Program.cs:
var builder = WebApplication.CreateBuilder(args);
// 1. Register the Problem Details service
builder.Services.AddProblemDetails();
var app = builder.Build();
// 2. Middleware for unhandled errors (prod)
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler(); // returns a Problem Details in production
}
// 3. Middleware for status codes without body (404, 401, etc.)
app.UseStatusCodePages(); // automatically produces a Problem Details body
app.MapGet("/dishes", ...);
app.Run();
Note:
UseStatusCodePages+AddProblemDetails= all error responses automatically return a Problem Details body.
Module 4 — Creating, Updating, and Deleting Resources
Routing Revisited — Conventions for Manipulation
URI design principles:
- Use only nouns (not verbs) in URIs
- Consistent singular/plural (
/dishesalways, never/dish) - Do NOT create
/create-dish— thePOST /dishesalready implies creation - The URI for
UPDATEandDELETEis the same as forGETof an individual resource
POST /dishes → Create a dish
GET /dishes → Read all dishes
GET /dishes/{dishId} → Read a specific dish
PUT /dishes/{dishId} → Update a dish
DELETE /dishes/{dishId} → Delete a dish
GET /dishes/{dishId}/ingredients → Read ingredients of a dish
Creating a Resource (POST)
app.MapPost("/dishes", async Task<CreatedAtRoute<DishDto>> (
DishesDbContext dishesDbContext,
DishForCreationDto dishForCreationDto) =>
{
// Mapping DTO → Entity
var dishEntity = dishForCreationDto.ToDish();
// EF Core starts tracking the entity
dishesDbContext.Add(dishEntity);
// Persist to database
await dishesDbContext.SaveChangesAsync();
var dishToReturn = dishEntity.ToDishDto();
// 201 Created + Location header pointing to the new resource
return TypedResults.CreatedAtRoute(
dishToReturn,
"GetDish", // Route name to generate the link
new { dishId = dishToReturn.Id });
});
// The GET route must have a name for CreatedAtRoute to work
app.MapGet("/dishes/{dishId:guid}", handler)
.WithName("GetDish");
Generating Links (CreatedAtRoute / LinkGenerator)
Approach 1: CreatedAtRoute (in a handler)
return TypedResults.CreatedAtRoute(
dishToReturn,
"GetDish",
new { dishId = dishToReturn.Id });
Approach 2: LinkGenerator (usable anywhere)
app.MapPost("/dishes", async (
DishesDbContext db,
LinkGenerator linkGenerator,
HttpContext httpContext,
DishForCreationDto dto) =>
{
var dishEntity = dto.ToDish();
db.Add(dishEntity);
await db.SaveChangesAsync();
var dishToReturn = dishEntity.ToDishDto();
var uri = linkGenerator.GetUriByName(httpContext, "GetDish",
new { dishId = dishToReturn.Id });
return TypedResults.Created(uri, dishToReturn);
});
Recommendation: Use
CreatedAtRoutein handlers, andLinkGeneratoronly when links need to be generated in other classes (services, etc.).
Updating a Resource (PUT)
app.MapPut("/dishes/{dishId:guid}",
async Task<Results<NotFound, NoContent>> (
DishesDbContext dishesDbContext,
Guid dishId,
DishForUpdateDto dishForUpdateDto) =>
{
var dishEntity = await dishesDbContext.Dishes
.FirstOrDefaultAsync(d => d.Id == dishId);
if (dishEntity == null)
return TypedResults.NotFound();
// EF Core tracked entity — just update the properties
dishEntity.UpdateFromDto(dishForUpdateDto);
await dishesDbContext.SaveChangesAsync();
return TypedResults.NoContent(); // 204
});
Deleting a Resource (DELETE)
app.MapDelete("/dishes/{dishId:guid}",
async Task<Results<NotFound, NoContent>> (
DishesDbContext dishesDbContext,
Guid dishId) =>
{
var dishEntity = await dishesDbContext.Dishes
.FirstOrDefaultAsync(d => d.Id == dishId);
if (dishEntity == null)
return TypedResults.NotFound();
dishesDbContext.Dishes.Remove(dishEntity);
await dishesDbContext.SaveChangesAsync();
return TypedResults.NoContent(); // 204
});
Grouping Endpoints with MapGroup
MapGroup allows grouping endpoints that share a common prefix or common metadata.
graph TD
App["app (IEndpointRouteBuilder)"]
App --> DG["dishesEndpoints\nMapGroup('/dishes')"]
App --> IG["ingredientsEndpoints\nMapGroup('/dishes/{dishId:guid}/ingredients')"]
DG --> DG2["dishWithGuidIdEndpoints\nMapGroup('/{dishId:guid}')"]
DG --> GET_ALL["MapGet('') → GetDishes\n→ GET /dishes"]
DG --> GET_NAME["MapGet('/{dishName}') → GetDishByName\n→ GET /dishes/{name}"]
DG --> POST["MapPost('') → CreateDish\n→ POST /dishes"]
DG2 --> GET_ID["MapGet('') → GetDishById\n→ GET /dishes/{dishId}"]
DG2 --> PUT["MapPut('') → UpdateDish\n→ PUT /dishes/{dishId}"]
DG2 --> DELETE["MapDelete('') → DeleteDish\n→ DELETE /dishes/{dishId}"]
IG --> GET_ING["MapGet('') → GetIngredients\n→ GET /dishes/{dishId}/ingredients"]
Implementation in Program.cs:
// Main group for dishes
var dishesEndpoints = app.MapGroup("/dishes")
.RequireAuthorization()
.WithTags("Dishes");
// Sub-group for endpoints with dishId
var dishWithGuidIdEndpoints = dishesEndpoints
.MapGroup("/{dishId:guid}");
// Group for ingredients
var ingredientsEndpoints = app.MapGroup("/dishes/{dishId:guid}/ingredients")
.RequireAuthorization()
.WithTags("Ingredients");
// Mapping endpoints
dishesEndpoints.MapGet("", GetDishesAsync);
dishesEndpoints.MapGet("/{dishName}", GetDishByNameAsync);
dishesEndpoints.MapPost("", CreateDishAsync);
dishWithGuidIdEndpoints.MapGet("", GetDishByIdAsync).WithName("GetDish");
dishWithGuidIdEndpoints.MapPut("", UpdateDishAsync);
dishWithGuidIdEndpoints.MapDelete("", DeleteDishAsync);
ingredientsEndpoints.MapGet("", GetIngredientsAsync);
Content Negotiation in Minimal APIs
Minimal APIs do not support content negotiation natively.
| Aspect | Minimal APIs | MVC Controllers |
|---|---|---|
| Output format | JSON only | JSON, XML, and more |
Accept header | Ignored | Respected |
Response Content-Type | application/json | Based on Accept header |
For XML support or content negotiation, use the Carter library which adds a thin layer over Minimal APIs.
Module 5 — Validation, Structure, Logging, and Exception Handling
Validation in Minimal APIs
Since .NET 9+, validation with Data Annotations is natively supported in Minimal APIs (no more need for MiniValidation or FluentValidation for simple cases).
Step 1 — Annotate the DTOs:
// Models/DishForCreationDto.cs
using System.ComponentModel.DataAnnotations;
public class DishForCreationDto
{
// The 'required' keyword = compile-time check (C# feature)
// The [Required] attribute = runtime validation (Data Annotations)
[Required]
[StringLength(200, MinimumLength = 2)]
public required string Name { get; set; }
}
Step 2 — Register the validation service:
// Program.cs
builder.Services.AddValidation(); // Registers automatic validation
That’s it! The framework automatically validates the request body before executing the handler. On failure, it automatically returns a 400 Bad Request with a ValidationProblemDetails body.
Automatic validation response:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Name": [
"The field Name must be a string with a minimum length of 2 and a maximum length of 200."
]
}
}
Common Data Annotations validation attributes:
| Attribute | Description | Example |
|---|---|---|
[Required] | Required field | [Required] |
[StringLength(max, MinimumLength=min)] | String length | [StringLength(200, MinimumLength = 2)] |
[Range(min, max)] | Value range | [Range(0, 100)] |
[RegularExpression(pattern)] | Regular expression | [RegularExpression(@"^\d{5}$")] |
[EmailAddress] | Email format | [EmailAddress] |
[Url] | URL format | [Url] |
[MinLength(n)] | Minimum length (collections) | [MinLength(1)] |
[MaxLength(n)] | Maximum length (collections) | [MaxLength(10)] |
[Compare("OtherProp")] | Comparison with another property | [Compare("Password")] |
Structuring Options
Progressive structure evolution:
graph LR
A["Approach 1\nInline lambdas\nin Program.cs"] -->|"Extract"| B["Approach 2\nLocal functions\nin Program.cs"]
B -->|"Extract"| C["Approach 3\nStatic Handler Classes\n(DishesHandlers.cs)"]
C -->|"Extract"| D["Approach 4\nHandler Classes\n+ Extension Methods\n(RegisterDishesEndpoints)"]
D -->|"Third-party libs"| E["Carter\nModule pattern"]
| Approach | Advantages | Disadvantages |
|---|---|---|
| Inline lambdas | Simple, fast | Doesn’t scale, everything in Program.cs |
| Local functions | Improved readability | Still everything in Program.cs |
| Static Handler Classes | Testable, organized by feature | Map calls still in Program.cs |
| Handler Classes + Extension Methods | Complete, maintainable, testable | A few more files |
| Carter | Popular for large codebases | External dependency |
Handler Classes and Extension Methods
Recommended structure:
DishesAPI/
├── Program.cs
├── EndpointHandlers/
│ ├── DishesHandlers.cs
│ └── IngredientsHandlers.cs
├── Extensions/
│ ├── EndpointRouterBuilderExtensions.cs
│ └── MappingExtensions.cs
├── Models/
│ ├── DishDto.cs
│ ├── DishForCreationDto.cs
│ ├── DishForUpdateDto.cs
│ └── IngredientDto.cs
├── Entities/
│ ├── Dish.cs
│ └── Ingredient.cs
└── DbContexts/
└── DishesDbContext.cs
EndpointHandlers/DishesHandlers.cs:
using DishesAPI.DbContexts;
using DishesAPI.Entities;
using DishesAPI.Extensions;
using DishesAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
using System.Security.Claims;
namespace DishesAPI.EndpointHandlers;
public static class DishesHandlers
{
public static async Task<Ok<IEnumerable<DishDto>>> GetDishesAsync(
DishesDbContext dishesDbContext,
ILogger<DishDto> logger,
ClaimsPrincipal claimsPrincipal,
string? name)
{
logger.LogInformation(
"Getting dishes, authenticated: {IsAuthenticated}",
claimsPrincipal.Identity?.IsAuthenticated);
return TypedResults.Ok(
(await dishesDbContext.Dishes
.Where(d => name == null || d.Name.Contains(name))
.ToListAsync())
.ToDishDtoList());
}
public static async Task<Results<NotFound, Ok<DishDto>>> GetDishByIdAsync(
DishesDbContext dishesDbContext,
Guid dishId)
{
var dishEntity = await dishesDbContext.Dishes
.FirstOrDefaultAsync(d => d.Id == dishId);
if (dishEntity == null)
return TypedResults.NotFound();
return TypedResults.Ok(dishEntity.ToDishDto());
}
public static async Task<Results<NotFound, Ok<DishDto>>> GetDishByNameAsync(
DishesDbContext dishesDbContext,
string dishName)
{
var dishEntity = await dishesDbContext.Dishes
.FirstOrDefaultAsync(d => d.Name == dishName);
if (dishEntity == null)
return TypedResults.NotFound();
return TypedResults.Ok(dishEntity.ToDishDto());
}
public static async Task<CreatedAtRoute<DishDto>> CreateDishAsync(
DishesDbContext dishesDbContext,
DishForCreationDto dishForCreationDto)
{
var dishEntity = dishForCreationDto.ToDish();
dishesDbContext.Add(dishEntity);
await dishesDbContext.SaveChangesAsync();
var dishToReturn = dishEntity.ToDishDto();
return TypedResults.CreatedAtRoute(
dishToReturn,
"GetDish",
new { dishId = dishToReturn.Id });
}
public static async Task<Results<NotFound, NoContent>> UpdateDishAsync(
DishesDbContext dishesDbContext,
Guid dishId,
DishForUpdateDto dishForUpdateDto)
{
var dishEntity = await dishesDbContext.Dishes
.FirstOrDefaultAsync(d => d.Id == dishId);
if (dishEntity == null)
return TypedResults.NotFound();
dishEntity.UpdateFromDto(dishForUpdateDto);
await dishesDbContext.SaveChangesAsync();
return TypedResults.NoContent();
}
public static async Task<Results<NotFound, NoContent>> DeleteDishAsync(
DishesDbContext dishesDbContext,
Guid dishId)
{
var dishEntity = await dishesDbContext.Dishes
.FirstOrDefaultAsync(d => d.Id == dishId);
if (dishEntity == null)
return TypedResults.NotFound();
dishesDbContext.Dishes.Remove(dishEntity);
await dishesDbContext.SaveChangesAsync();
return TypedResults.NoContent();
}
}
EndpointHandlers/IngredientsHandlers.cs:
using DishesAPI.DbContexts;
using DishesAPI.Extensions;
using DishesAPI.Models;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
namespace DishesAPI.EndpointHandlers;
public static class IngredientsHandlers
{
public static async Task<Results<NotFound, Ok<IEnumerable<IngredientDto>>>> GetIngredientsAsync(
DishesDbContext dishesDbContext,
Guid dishId)
{
var dishEntity = await dishesDbContext.Dishes
.Include(d => d.Ingredients)
.FirstOrDefaultAsync(d => d.Id == dishId);
if (dishEntity == null)
return TypedResults.NotFound();
return TypedResults.Ok(dishEntity.Ingredients.ToIngredientDtoList(dishId));
}
}
Extensions/EndpointRouterBuilderExtensions.cs:
using DishesAPI.EndpointHandlers;
namespace DishesAPI.Extensions;
public static class EndpointRouterBuilderExtensions
{
public static void RegisterDishesEndpoints(
this IEndpointRouteBuilder endpointRouteBuilder)
{
var dishesEndpoints = endpointRouteBuilder
.MapGroup("/dishes")
.RequireAuthorization()
.WithTags("Dishes");
var dishWithGuidIdEndpoints = dishesEndpoints
.MapGroup("/{dishId:guid}");
dishesEndpoints.MapGet("", DishesHandlers.GetDishesAsync)
.WithSummary("Get all dishes")
.WithDescription("Returns all dishes, optionally filtered by name.");
dishWithGuidIdEndpoints.MapGet("", DishesHandlers.GetDishByIdAsync)
.WithName("GetDish");
dishesEndpoints.MapGet("/{dishName}", DishesHandlers.GetDishByNameAsync)
.AllowAnonymous()
.WithSummary("Get a dish by name")
.WithDescription("Returns a single dish identified by its name. Allows anonymous access.");
dishesEndpoints.MapPost("", DishesHandlers.CreateDishAsync)
.RequireAuthorization("RequireAdminFromBelgium")
.WithSummary("Create a dish")
.WithDescription("Creates a new dish. Requires admin role and country Belgium.")
.ProducesValidationProblem(400);
dishWithGuidIdEndpoints.MapPut("", DishesHandlers.UpdateDishAsync)
.RequireAuthorization("RequireAdminFromBelgium");
dishWithGuidIdEndpoints.MapDelete("", DishesHandlers.DeleteDishAsync)
.RequireAuthorization("RequireAdminFromBelgium");
}
public static void RegisterIngredientsEndpoints(
this IEndpointRouteBuilder endpointRouteBuilder)
{
var ingredientsEndpoints = endpointRouteBuilder
.MapGroup("/dishes/{dishId:guid}/ingredients")
.RequireAuthorization()
.WithTags("Ingredients");
ingredientsEndpoints.MapGet("", IngredientsHandlers.GetIngredientsAsync);
}
}
Final Program.cs (clean):
using DishesAPI.DbContexts;
using DishesAPI.Extensions;
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails();
builder.Services.AddValidation();
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
builder.Services.AddDbContext<DishesDbContext>(o => o.UseSqlite(
builder.Configuration["ConnectionStrings:DishesDBConnectionString"]));
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler();
}
app.UseHttpsRedirection();
app.UseStatusCodePages();
app.UseAuthentication();
app.UseAuthorization();
app.RegisterDishesEndpoints();
app.RegisterIngredientsEndpoints();
// Recreate the DB on each run (for demos)
using (var scope = app.Services.GetRequiredService<IServiceScopeFactory>().CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<DishesDbContext>();
context.Database.EnsureDeleted();
context.Database.Migrate();
}
app.Run();
Exception Handling
Two middleware components for exception handling:
flowchart LR
Ex["Unhandled exception"] --> Env{"Environment?"}
Env -->|"Development"| DEP["Developer Exception Page\n(full stack trace)"]
Env -->|"Production"| EHM["Exception Handler Middleware\n(app.UseExceptionHandler)\n→ Problem Details without stack trace"]
DEP --> Response["HTTP Response"]
EHM --> Response
| Middleware | Environment | Behavior |
|---|---|---|
Developer Exception Page | Development (enabled by default) | Full stack trace in the response |
UseExceptionHandler | Production (configure manually) | 500 error with Problem Details, without stack trace |
Security: Exposing stack traces in production is a security risk. Never enable the Developer Exception Page in production.
// Configuration in Program.cs
if (!app.Environment.IsDevelopment())
{
// Intercepts unhandled exceptions → returns Problem Details
app.UseExceptionHandler();
}
// UseStatusCodePages ensures all error responses have a body
app.UseStatusCodePages();
Logging in Minimal APIs
ASP.NET Core logging is natively integrated. WebApplication.CreateBuilder automatically configures Console and Debug providers.
// In a static handler — inject ILogger<T>
public static async Task<Ok<IEnumerable<DishDto>>> GetDishesAsync(
DishesDbContext dishesDbContext,
ILogger<DishDto> logger, // ILogger injection
ClaimsPrincipal claimsPrincipal,
string? name)
{
// Structured logging: use message templates, not string interpolation
logger.LogInformation(
"Getting dishes, authenticated: {IsAuthenticated}",
claimsPrincipal.Identity?.IsAuthenticated);
// DO NOT do: logger.LogInformation($"Getting dishes, authenticated: {isAuth}");
// → loses structured logging (values are no longer separated)
return TypedResults.Ok(...);
}
Log levels (from most verbose to most critical):
| Level | Method | Usage |
|---|---|---|
| Trace | LogTrace | Very detailed information (fine-grained debugging) |
| Debug | LogDebug | Debugging information |
| Information | LogInformation | Normal application flow events |
| Warning | LogWarning | Unexpected but recoverable events |
| Error | LogError | Errors requiring attention |
| Critical | LogCritical | Catastrophic failures |
Benefit of structured logging:
- Values are preserved as separate fields
- Aggregation tools (Seq, Elasticsearch, etc.) can filter and query them
{IsAuthenticated}→ becomes a structured field, not a baked-in string
Module 6 — Securing Your Minimal API
API Security Overview
sequenceDiagram
participant U as User / Client App
participant IDP as Identity Provider<br/>(Entra ID, Auth0, etc.)
participant API as Minimal API
U->>IDP: Token request (credentials)
IDP-->>U: JWT Bearer Token
U->>API: GET /dishes<br/>Authorization: Bearer {token}
API->>API: Validates token (signature, issuer, audience, expiry)
API-->>U: 200 OK + data (if valid)<br/>401 Unauthorized (if invalid)
Security types:
| Approach | Use case | Complexity |
|---|---|---|
| Cookie-based | Client and API on same domain | Low |
| JWT Bearer Token | Multi-client APIs, mobile, SPA | Moderate |
| OAuth2 + OpenID Connect | SSO, centralized identity | High |
| API Keys | Simple APIs / B2B | Low |
Token-based Security — JWT Bearer
Structure of a JWT (JSON Web Token):
Header.Payload.Signature
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyMTIzIiwiYXVkIjoibWVudS1hcGkiLCJpc3MiOiJteS1pZHAiLCJyb2xlIjoiYWRtaW4iLCJjb3VudHJ5IjoiQmVsZ2l1bSJ9.xxx
Typical claims in a JWT:
| Claim | Full name | Description |
|---|---|---|
sub | Subject | Unique user identifier |
iss | Issuer | Who created the token |
aud | Audience | For whom the token is intended |
exp | Expiration | When the token expires |
iat | Issued At | When the token was created |
role | Role | User’s role |
country | Country | Custom claim (country) |
Authentication and Authorization in the Pipeline
flowchart LR
Request["HTTP Request\n+ Bearer Token"] --> UseAuth["UseAuthentication\n→ Validates token\n→ Creates ClaimsPrincipal"]
UseAuth --> UseAuthz["UseAuthorization\n→ Checks policies\n→ RequireAuthorization"]
UseAuthz --> Handler["Handler executed"]
UseAuth -->|"Invalid token"| U401["401 Unauthorized"]
UseAuthz -->|"Policy not satisfied"| U403["403 Forbidden"]
Configuration in Program.cs:
// 1. Register authentication and authorization services
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
// 2. Configure in appsettings.json (JWT Bearer middleware reads it automatically)
/*
{
"Authentication": {
"DefaultScheme": "Bearer",
"Schemes": {
"Bearer": {
"Audience": "menu-api",
"Issuer": "dotnet-user-jwts"
}
}
}
}
*/
// 3. Add middlewares to the pipeline (order matters!)
app.UseAuthentication(); // Before UseAuthorization
app.UseAuthorization();
// 4. Protect endpoints
app.MapGet("/dishes", handler).RequireAuthorization();
app.MapPost("/dishes", handler).RequireAuthorization("RequireAdminFromBelgium");
// or at group level:
app.MapGroup("/dishes").RequireAuthorization();
// Allow anonymous access on an endpoint within a protected group:
app.MapGet("/dishes/{dishName}", handler).AllowAnonymous();
Authorization Policies
Policies allow fine-grained access control based on claims, roles, or custom logic.
// Define a policy in Program.cs
builder.Services.AddAuthorizationBuilder()
.AddPolicy("RequireAdminFromBelgium", policy =>
policy
.RequireAuthenticatedUser() // Must be authenticated
.RequireRole("admin") // Must have "admin" role
.RequireClaim("country", "Belgium")); // Must have country=Belgium
// Apply the policy to an endpoint
dishesEndpoints.MapPost("", CreateDishAsync)
.RequireAuthorization("RequireAdminFromBelgium");
dishWithGuidIdEndpoints.MapPut("", UpdateDishAsync)
.RequireAuthorization("RequireAdminFromBelgium");
dishWithGuidIdEndpoints.MapDelete("", DeleteDishAsync)
.RequireAuthorization("RequireAdminFromBelgium");
Results based on the token:
| Token | GET /dishes endpoint | POST /dishes endpoint |
|---|---|---|
| No token | 401 Unauthorized | 401 Unauthorized |
Valid token, user role | 200 OK | 403 Forbidden |
Valid token, admin role, country Belgium | 200 OK | 201 Created |
Valid token, admin role, country France | 200 OK | 403 Forbidden |
Generating a Test Token with dotnet user-jwts
For development, dotnet user-jwts generates JWT tokens locally without an external Identity Provider.
# See available options
dotnet user-jwts create --help
# Create a simple token with audience
dotnet user-jwts create --audience menu-api
# Create a token with role and claim
dotnet user-jwts create \
--audience menu-api \
--role admin \
--claim country=Belgium
Example generated token payload:
{
"sub": "kevin",
"aud": "menu-api",
"iss": "dotnet-user-jwts",
"role": "admin",
"country": "Belgium",
"exp": 1234567890
}
Corresponding appsettings.json configuration:
{
"Authentication": {
"DefaultScheme": "Bearer",
"Schemes": {
"Bearer": {
"Audience": "menu-api",
"Issuer": "dotnet-user-jwts"
}
}
}
}
Note:
dotnet user-jwtsis for development only. In production, use an Identity Provider (Microsoft Entra ID, Auth0, Okta, IdentityServer, etc.) with OAuth2 / OpenID Connect.
Module 7 — Documenting Your Minimal API
OpenAPI in ASP.NET Core
OpenAPI (formerly Swagger) is the de facto standard for documenting HTTP APIs.
flowchart LR
Code["Code\n(Endpoints, TypedResults,\nData Annotations)"] -->|"AddOpenApi\nMapOpenApi"| Spec["OpenAPI Spec\n/openapi/v1.json\n(JSON/YAML)"]
Spec -->|"MapScalarApiReference"| UI["Documentation UI\n(Scalar)\n/scalar/v1"]
Spec -->|"Import"| Clients["Generated Clients\n(Kiota, NSwag, etc.)"]
Pieces of the puzzle:
- OpenAPI Specification — JSON/YAML document describing the API (endpoints, parameters, responses, security)
- Spec generation —
Microsoft.AspNetCore.OpenAPIautomatically generates from endpoints - Documentation UI — Scalar (or Swagger UI) generates a visual interface from the spec
Advantage of TypedResults: since we use TypedResults.Ok<DishDto>, TypedResults.NotFound, etc., the generated OpenAPI spec automatically contains the precise return types for each endpoint.
Scalar — Documentation UI
Required NuGet packages:
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="Scalar.AspNetCore" Version="2.x.x" />
Configuration in Program.cs:
using Microsoft.OpenApi;
using Scalar.AspNetCore;
// Services
builder.Services.AddOpenApi(options =>
{
options.AddDocumentTransformer((document, context, cancellationToken) =>
{
document.Info = new OpenApiInfo
{
Title = "DishesAPI",
Version = "v1",
Description = "An API for managing dishes and their ingredients."
};
// Configure Bearer security in the OpenAPI spec
document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
document.Components.SecuritySchemes["Bearer"] = new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
Description = "Enter a valid JWT bearer token."
};
document.Security ??= [];
document.Security.Add(new OpenApiSecurityRequirement
{
[new OpenApiSecuritySchemeReference("Bearer")] = new List<string>()
});
return Task.CompletedTask;
});
});
// Pipeline
app.MapOpenApi(); // → GET /openapi/v1.json
if (app.Environment.IsDevelopment())
{
app.MapScalarApiReference(); // → GET /scalar/v1
}
Improving Endpoint Metadata
// Groups with Tags (to group in the UI)
var dishesEndpoints = endpointRouteBuilder
.MapGroup("/dishes")
.RequireAuthorization()
.WithTags("Dishes"); // ← Tag for the group
// Endpoints with Summary and Description
dishesEndpoints.MapGet("", DishesHandlers.GetDishesAsync)
.WithSummary("Get all dishes")
.WithDescription("Returns all dishes, optionally filtered by name.");
dishesEndpoints.MapGet("/{dishName}", DishesHandlers.GetDishByNameAsync)
.AllowAnonymous()
.WithSummary("Get a dish by name")
.WithDescription("Returns a single dish identified by its name. Allows anonymous access.");
// Document validation responses
dishesEndpoints.MapPost("", DishesHandlers.CreateDishAsync)
.RequireAuthorization("RequireAdminFromBelgium")
.WithSummary("Create a dish")
.WithDescription("Creates a new dish. Requires admin role and country Belgium.")
.ProducesValidationProblem(400); // ← Documents the validation 400
Available metadata methods:
| Method | Description |
|---|---|
.WithName("routeName") | Route name (used by CreatedAtRoute) |
.WithTags("TagName") | Group in the documentation UI |
.WithSummary("...") | Short title in the docs |
.WithDescription("...") | Detailed description |
.Produces<T>(statusCode) | Documents a return type and its status code |
.ProducesProblem(statusCode) | Documents a Problem Details response |
.ProducesValidationProblem(400) | Documents a ValidationProblemDetails response |
.AllowAnonymous() | Excludes the endpoint from required authorization |
.RequireAuthorization() | Requires authentication |
.RequireAuthorization("PolicyName") | Requires a specific policy |
.ExcludeFromDescription() | Excludes from OpenAPI documentation |
Architecture Diagrams
Complete Minimal APIs Architecture
flowchart TD
subgraph Client["Client (Browser, Mobile App, SPA)"]
Request["HTTP Request\n+ Bearer Token"]
end
subgraph Pipeline["ASP.NET Core Request Pipeline"]
HTTPS["UseHttpsRedirection"]
SCP["UseStatusCodePages"]
AUTH["UseAuthentication\n(JWT Bearer Middleware)"]
AUTHZ["UseAuthorization"]
ROUTER["Route Matching\n(MapGroup, MapGet/Post/Put/Delete)"]
end
subgraph Handlers["Endpoint Handlers"]
DH["DishesHandlers\n(static methods)"]
IH["IngredientsHandlers\n(static methods)"]
end
subgraph Services["Services / Infrastructure"]
DI["DI Container\n(AddDbContext, AddAuthentication, etc.)"]
VAL["Validation\n(AddValidation + DataAnnotations)"]
PD["ProblemDetails\n(AddProblemDetails)"]
end
subgraph Data["Data Layer"]
EF["Entity Framework Core"]
DB["SQLite Database\nDishes.db"]
end
subgraph Doc["Documentation"]
OAS["OpenAPI Spec\n/openapi/v1.json"]
SCALAR["Scalar UI\n/scalar/v1"]
end
Request --> HTTPS --> SCP --> AUTH --> AUTHZ --> ROUTER
ROUTER --> DH
ROUTER --> IH
DH --> EF --> DB
IH --> EF
DI --> DH
DI --> IH
VAL --> ROUTER
PD --> AUTH
PD --> AUTHZ
OAS --> SCALAR
ROUTER --> OAS
Complete Middleware Pipeline
sequenceDiagram
participant C as Client
participant HTTPS as UseHttpsRedirection
participant SCP as UseStatusCodePages
participant AUTH as UseAuthentication
participant AUTHZ as UseAuthorization
participant H as Handler
C->>HTTPS: HTTPS Request
HTTPS->>SCP: Forward
SCP->>AUTH: Forward
AUTH->>AUTH: Validates Bearer Token → ClaimsPrincipal
AUTH->>AUTHZ: Forward (with ClaimsPrincipal)
AUTHZ->>AUTHZ: Checks RequireAuthorization / Policies
AUTHZ->>H: Forward (if authorized)
H-->>C: 200 OK / 201 Created / 204 No Content
AUTH-->>C: 401 Unauthorized (invalid token)
AUTHZ-->>C: 403 Forbidden (policy not satisfied)
SCP-->>C: Problem Details body (for responses without body)
Route Grouping — MapGroup
graph TD
APP["app\nIEndpointRouteBuilder"]
APP --> DG["MapGroup('/dishes')\n.RequireAuthorization()\n.WithTags('Dishes')"]
APP --> IG["MapGroup('/dishes/{dishId:guid}/ingredients')\n.RequireAuthorization()\n.WithTags('Ingredients')"]
DG --> SG["MapGroup('/{dishId:guid}')"]
DG --> G1["MapGet('') → GetDishesAsync\n.WithSummary('Get all dishes')"]
DG --> G2["MapGet('/{dishName}') → GetDishByNameAsync\n.AllowAnonymous()"]
DG --> G3["MapPost('') → CreateDishAsync\n.RequireAuthorization('RequireAdminFromBelgium')"]
SG --> S1["MapGet('') → GetDishByIdAsync\n.WithName('GetDish')"]
SG --> S2["MapPut('') → UpdateDishAsync\n.RequireAuthorization('RequireAdminFromBelgium')"]
SG --> S3["MapDelete('') → DeleteDishAsync\n.RequireAuthorization('RequireAdminFromBelgium')"]
IG --> I1["MapGet('') → GetIngredientsAsync"]
Reference Tables
Summary Table — DishesAPI Endpoints
| Method | URI | Handler | Auth | Description |
|---|---|---|---|---|
| GET | /dishes | GetDishesAsync | Required | All dishes (filterable by name) |
| GET | /dishes/{dishId:guid} | GetDishByIdAsync | Required | A dish by ID |
| GET | /dishes/{dishName} | GetDishByNameAsync | Anonymous | A dish by name |
| POST | /dishes | CreateDishAsync | RequireAdminFromBelgium | Create a dish |
| PUT | /dishes/{dishId:guid} | UpdateDishAsync | RequireAdminFromBelgium | Update a dish |
| DELETE | /dishes/{dishId:guid} | DeleteDishAsync | RequireAdminFromBelgium | Delete a dish |
| GET | /dishes/{dishId:guid}/ingredients | GetIngredientsAsync | Required | Ingredients of a dish |
Summary Table — Registered Services
| Service | Method | Description |
|---|---|---|
| Problem Details | AddProblemDetails() | Standardized error responses RFC 9457 |
| Validation | AddValidation() | Automatic Data Annotations validation |
| Authentication | AddAuthentication().AddJwtBearer() | JWT Bearer Token authentication |
| Authorization | AddAuthorization() + AddAuthorizationBuilder() | Authorization policies |
| DbContext | AddDbContext<T>() | Entity Framework Core with SQLite |
| OpenAPI | AddOpenApi() | OpenAPI spec generation |
Summary Table — Middleware Pipeline
| Middleware | Method | Order | Description |
|---|---|---|---|
| HTTPS Redirection | UseHttpsRedirection() | 1 | Redirects HTTP → HTTPS |
| Exception Handler | UseExceptionHandler() | 2 (prod) | Centralized exception handling |
| Status Code Pages | UseStatusCodePages() | 3 | Adds Problem Details body to errors without body |
| Authentication | UseAuthentication() | 4 | Validates the Bearer Token |
| Authorization | UseAuthorization() | 5 | Checks policies (AFTER Authentication) |
| OpenAPI | MapOpenApi() | - | Exposes /openapi/v1.json |
| Scalar UI | MapScalarApiReference() | - | Exposes /scalar/v1 (dev only) |
Comparison — Results vs TypedResults
| Aspect | Results<T1, T2> | TypedResults.X() |
|---|---|---|
| Type safety | Strong | Strong |
| OpenAPI documentation | Automatic | Automatic |
| Usage | Declared return type | Inside the handler body |
| Declaration example | Task<Results<NotFound, Ok<DishDto>>> | Return TypedResults.Ok(...) |
DishesAPI.csproj — Key Packages
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<!-- Entity Framework Core + SQLite -->
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0" />
<!-- JWT Bearer Authentication -->
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
<!-- OpenAPI Documentation -->
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<!-- Scalar UI -->
<PackageReference Include="Scalar.AspNetCore" Version="2.x.x" />
</ItemGroup>
</Project>
Additional resources:
Search Terms
asp.net · minimal · apis · core · web · c# · .net · development · api · pipeline · resource · responses · routing · architecture · authorization · binding · comparison · correct · deleting · dependency · endpoints · entity · exception · generating