A small REST API to list, create and delete medications, built with .NET 10 Minimal APIs.
- .NET 10 / C# — ASP.NET Core Minimal APIs
- EF Core — SQL Server for the app, InMemory provider for unit tests
- .NET Aspire — AppHost for local orchestration
- OpenAPI + Scalar — generated document with interactive UI (Development only)
- xunit v3 — unit tests, with Coverlet coverage feeding SonarQube Cloud
- GitHub Actions — builds and tests every push and pull request to
main
Requires the .NET 10 SDK and a container runtime (Docker or Podman).
# Run via the Aspire AppHost (dashboard + Scalar link):
dotnet run --project src/Medications.AppHost
# Or, with the Aspire CLI installed:
aspire runThe dashboard URL is printed on the console; the API and its Scalar UI are listed there. The AppHost runs SQL Server in a container pulled by Aspire automatically if non-existent, and passes the connection string to the API, so there is nothing to configure. The container and its data are kept between runs.
If running the solution directly from Visual Studio, Rider or VS Code, the IDE launches the browser with the Aspire Dashboard.
No container runtime needed. If a medications connection string is configured, the AppHost uses
it and starts no container:
dotnet user-secrets --project src/Medications.AppHost set "ConnectionStrings:medications" "Server=localhost;Database=Medications;Trusted_Connection=True;TrustServerCertificate=True"The API can also run without the AppHost, reading the same connection string from its own
configuration (appsettings.json, already set to
localhost with Windows authentication):
dotnet run --project src/Medications.ApiStandalone, the API listens on http://localhost:5122; interactive docs at /scalar (Development).
| Method | Route | Success | Errors |
|---|---|---|---|
GET |
/api/medications |
200 |
— |
GET |
/api/medications/{id} |
200 |
400, 404 |
POST |
/api/medications |
201 |
400 |
DELETE |
/api/medications/{id} |
204 |
400, 404 |
dotnet testUnit tests cover the endpoint handlers, the mapping layer and the request contract's validation attributes, using the EF InMemory provider and a FakeTimeProvider, so no database is needed.
The schema is managed with EF Core migrations, applied when the API starts. To work with them, use the EF CLI:
dotnet tool restore
dotnet ef migrations add <Name> --project src/Medications.Api # after changing the model
dotnet ef database update --project src/Medications.Api # apply without running the API- Validation uses DataAnnotations on the request DTO, enforced by .NET 10's built-in minimal API validation (
AddValidation()). The routeidis validated the same way, so every validation error has the same response shape. Nameis capped at 200 characters, defined once inMedication.NameMaxLengthand used by both the DTO and the EF model.- The DTO doesn't use the C#
requiredkeyword forName: a missingnamethen returns a normal field-level validation error instead of a generic 400. - Every error response uses the same JSON shape (ASP.NET Core's Problem Details), whether it is a validation error, malformed JSON, an invalid route value or a 404. This is wired once in Program.cs, not per endpoint.
- Binding failures keep their explanatory
detailmessage in Production too (by default ASP.NET Core only includes it in Development), and client mistakes like malformed JSON are not logged as server errors. - Invalid ids (
0or negative) return400; well-formed ids that don't exist return404. CreationDateis set by the server (injectedTimeProvider, faked in tests);IdandCreationDateare never accepted as input.GET /api/medications/{id}is not in the challenge spec; it exists as the target of POST'sLocationheader.- The DbContext is registered with Aspire's
AddSqlServerDbContext, which adds retries, health checks and telemetry on top ofAddDbContext. - The SQL Server container is pinned to a specific image tag instead of
2022-latest(which changes over time), is kept between runs with its data in a volume, and its generated password lives in the AppHost's user secrets. - Migrations run at startup to keep the demo to a single command; a real deployment would apply them as a separate step.