Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .config/dotnet-tools.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.10",
"commands": [
"dotnet-ef"
],
"rollForward": false
}
}
}
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,17 @@ jobs:

- name: Begin Sonar analysis
if: env.SONAR_TOKEN != ''
# Skips generated migrations, and composition roots until integration tests exist.
run: |
dotnet tool install --global dotnet-sonarscanner
dotnet-sonarscanner begin \
/k:"fernandotonacoder_medications-rest-api" \
/o:"fernandotonacoder" \
/d:sonar.token="$SONAR_TOKEN" \
/d:sonar.host.url="https://sonarcloud.io" \
/d:sonar.cs.cobertura.reportsPaths="**/coverage.cobertura.xml"
/d:sonar.cs.cobertura.reportsPaths="**/coverage.cobertura.xml" \
/d:sonar.exclusions="**/Migrations/**" \
/d:sonar.coverage.exclusions="**/Program.cs,**/AppHost.cs"

- name: Build
run: dotnet build --no-incremental
Expand Down
5 changes: 5 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,13 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Aspire.Hosting.SqlServer" Version="13.4.6" />
<PackageVersion Include="Aspire.Microsoft.EntityFrameworkCore.SqlServer" Version="13.4.6" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" />
<!-- Explicit so the EF graph lands on 10.0.10; the Aspire package still pins 10.0.8. -->
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.8.0" />
<PackageVersion Include="Microsoft.OpenApi" Version="2.7.5" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.17" />
Expand Down
120 changes: 79 additions & 41 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,77 +1,115 @@
# Medications REST API

[![CI](https://github.com/fernandotonacoder/medications-rest-api/actions/workflows/ci.yml/badge.svg)](https://github.com/fernandotonacoder/medications-rest-api/actions/workflows/ci.yml)
[![.NET](https://img.shields.io/badge/.NET-10-512BD4?logo=dotnet&logoColor=white)](https://dotnet.microsoft.com/)
[![Aspire](https://img.shields.io/badge/Aspire-512BD4?logo=dotnet&logoColor=white)](https://learn.microsoft.com/en-us/dotnet/aspire/)
[![SQL Server](https://img.shields.io/badge/SQL%20Server-2022-CC2927)](https://www.microsoft.com/sql-server)
[![Scalar](https://img.shields.io/badge/Scalar-API%20Reference-1F2937)](https://scalar.com/)

[![Quality gate](https://sonarcloud.io/api/project_badges/quality_gate?project=fernandotonacoder_medications-rest-api)](https://sonarcloud.io/summary/overall?id=fernandotonacoder_medications-rest-api)

A small REST API to list, create and delete medications, built with .NET 10 Minimal APIs.

## Tech stack

- **.NET 10 / C#** — ASP.NET Core [Minimal APIs]
- **[EF Core]** — [InMemory provider] for Unit Tests, SQL Server for the real app
- **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][xunit]** — unit tests, with Coverlet coverage feeding SonarQube Cloud in CI
- **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`

## Getting started

Requires the [.NET 10 SDK].
Requires the [.NET 10 SDK] and a container runtime (Docker or Podman).

```bash
```powershell
# Run via the Aspire AppHost (dashboard + Scalar link):
dotnet run --project src/Medications.AppHost

# Or run the API on its own:
# Or, with the Aspire CLI installed:
aspire run
```

The 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.

### Using your own SQL Server instead

No container runtime needed. If a `medications` connection string is configured, the AppHost uses
it and starts no container:

```powershell
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](src/Medications.Api/appsettings.json), already set to
`localhost` with Windows authentication):

```powershell
dotnet run --project src/Medications.Api
```

Standalone, the API listens on `http://localhost:5122`. In Development, interactive docs are at `/scalar` and the OpenAPI document at `/openapi/v1.json`.
Standalone, the API listens on `http://localhost:5122`; interactive docs at `/scalar` (Development).

### Endpoints

| Method | Route | Success | Errors |
| -------- | ----------------------- | ---------------- | ------ |
| `GET` | `/api/medications` | `200` list | — |
| `GET` | `/api/medications/{id}` | `200` | `400` invalid id, `404` |
| `POST` | `/api/medications` | `201` + Location | `400` validation |
| `DELETE` | `/api/medications/{id}` | `204` | `400` invalid id, `404` |
| 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` |

## Tests

```bash
```powershell
dotnet test
```

Unit tests cover the endpoint handlers (invoked directly, with a fresh InMemory context per test and a fake `TimeProvider`), the mapping layer, and the request contract's validation attributes. Coverage is configured in `tests/test.runsettings` (cobertura, consumed by SonarQube in CI).
Unit 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.

## Database

The schema is managed with EF Core migrations, applied when the API starts. To work with them,
use the [EF CLI][dotnet-ef]:

```powershell
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
```

## Some Design notes

- Validation uses [DataAnnotations] on the request DTO, enforced by .NET 10's [`AddValidation()`]. The route `id` is validated the same way, through a [`[Range]`][range] attribute on the handler parameter, so every validation error has the same [`HttpValidationProblemDetails`] shape.
- `Name` is capped at 200 characters, defined once in `Medication.NameMaxLength` and used by both [`[StringLength]`][stringlength] on the DTO and [`HasMaxLength`] in the EF model.
- The DTO doesn't use the C# [`required`][required-keyword] keyword for `Name`: a body without `name` would then fail deserialization with a generic 400, instead of a normal `errors.Name` validation error.
- Errors are [Problem Details (RFC 9457)][rfc9457] on every path: [`AddProblemDetails`] + [`UseExceptionHandler`] (with [`StatusCodeSelector`]) + [`UseStatusCodePages`]. [`ThrowOnBadRequest`] is enabled for all environments so binding failures keep their `detail` outside Development, and [`SuppressDiagnosticsCallback`] keeps those client errors out of Error-level logs.
- Validation uses DataAnnotations on the request DTO, enforced by .NET 10's built-in minimal API validation ([`AddValidation()`]). The route `id` is validated the same way, so every validation error has the same response shape.
- `Name` is capped at 200 characters, defined once in `Medication.NameMaxLength` and used by both the DTO and the EF model.
- The DTO doesn't use the C# `required` keyword for `Name`: a missing `name` then 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 `detail` message 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 (`0` or negative) return `400`; well-formed ids that don't exist return `404`.
- `CreationDate` is set by the server, using an injected [`TimeProvider`] ([`FakeTimeProvider`] in tests). The request and response DTOs are separate types, so `Id` and `CreationDate` are never accepted as input.
- `GET /api/medications/{id}` is not in the challenge spec; it exists as the target of the `Location` header returned by [`CreatedAtRoute`] on POST.
- `CreationDate` is set by the server (injected `TimeProvider`, faked in tests); `Id` and `CreationDate` are never accepted as input.
- `GET /api/medications/{id}` is not in the challenge spec; it exists as the target of POST's `Location` header.
- The DbContext is registered with Aspire's [`AddSqlServerDbContext`], which adds retries, health checks and telemetry on top of `AddDbContext`.
- 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.

---

<div align="center">

Made with ❤️ by Fernando Tona
[Website](https://fernandotonacoder.github.io) • [LinkedIn](https://www.linkedin.com/in/fernandotona/) • [GitHub](https://github.com/fernandotonacoder)

</div>

[Minimal APIs]: https://learn.microsoft.com/en-us/aspnet/core/fundamentals/minimal-apis/overview
[EF Core]: https://learn.microsoft.com/en-us/ef/core/
[InMemory provider]: https://learn.microsoft.com/en-us/ef/core/providers/in-memory/
[.NET Aspire]: https://learn.microsoft.com/en-us/dotnet/aspire/
[Scalar]: https://github.com/scalar/scalar
[xunit]: https://xunit.net/
[.NET 10 SDK]: https://dotnet.microsoft.com/download/dotnet/10.0
[DataAnnotations]: https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.dataannotations
[`AddValidation()`]: https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.dependencyinjection.validationservicecollectionextensions.addvalidation
[range]: https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.dataannotations.rangeattribute
[stringlength]: https://learn.microsoft.com/en-us/dotnet/api/system.componentmodel.dataannotations.stringlengthattribute
[`HttpValidationProblemDetails`]: https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.httpvalidationproblemdetails
[`HasMaxLength`]: https://learn.microsoft.com/en-us/dotnet/api/microsoft.entityframeworkcore.metadata.builders.propertybuilder.hasmaxlength
[required-keyword]: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/required
[rfc9457]: https://www.rfc-editor.org/rfc/rfc9457
[`AddProblemDetails`]: https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.dependencyinjection.problemdetailsservicecollectionextensions.addproblemdetails
[`UseExceptionHandler`]: https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.builder.exceptionhandlerextensions.useexceptionhandler
[`StatusCodeSelector`]: https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.builder.exceptionhandleroptions.statuscodeselector
[`UseStatusCodePages`]: https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.builder.statuscodepagesextensions.usestatuscodepages
[`ThrowOnBadRequest`]: https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.routing.routehandleroptions.throwonbadrequest
[`SuppressDiagnosticsCallback`]: https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.builder.exceptionhandleroptions.suppressdiagnosticscallback
[`TimeProvider`]: https://learn.microsoft.com/en-us/dotnet/api/system.timeprovider
[`FakeTimeProvider`]: https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.time.testing.faketimeprovider
[`CreatedAtRoute`]: https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.typedresults.createdatroute
[`AddSqlServerDbContext`]: https://aspire.dev/integrations/databases/efcore/sql-server/sql-server-connect/
[SQL Server container]: https://aspire.dev/integrations/databases/sql-server/sql-server-host/
[dotnet-ef]: https://learn.microsoft.com/en-us/ef/core/cli/dotnet
[GitHub Actions]: .github/workflows/ci.yml
7 changes: 6 additions & 1 deletion src/Medications.Api/Medications.Api.csproj
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<ItemGroup>
<PackageReference Include="Aspire.Microsoft.EntityFrameworkCore.SqlServer"/>
<PackageReference Include="Microsoft.AspNetCore.OpenApi"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer"/>
<PackageReference Include="Microsoft.OpenApi"/>
<PackageReference Include="Scalar.AspNetCore"/>
</ItemGroup>
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

37 changes: 37 additions & 0 deletions src/Medications.Api/Migrations/20260807025640_InitialCreate.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable

namespace Medications.Api.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Medications",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: false),
Quantity = table.Column<int>(type: "int", nullable: false),
CreationDate = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Medications", x => x.Id);
});
}

/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Medications");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// <auto-generated />
using System;
using Medications.Api.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;

#nullable disable

namespace Medications.Api.Migrations
{
[DbContext(typeof(MedicationsDbContext))]
partial class MedicationsDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.10")
.HasAnnotation("Relational:MaxIdentifierLength", 128);

SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);

modelBuilder.Entity("Medications.Api.Entities.Medication", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");

SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));

b.Property<DateTimeOffset>("CreationDate")
.HasColumnType("datetimeoffset");

b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");

b.Property<int>("Quantity")
.HasColumnType("int");

b.HasKey("Id");

b.ToTable("Medications");
});
#pragma warning restore 612, 618
}
}
}
8 changes: 7 additions & 1 deletion src/Medications.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<MedicationsDbContext>(opt => opt.UseInMemoryDatabase("Medications"));
builder.AddSqlServerDbContext<MedicationsDbContext>("medications");
builder.Services.AddOpenApi();
builder.Services.AddValidation();
builder.Services.Configure<RouteHandlerOptions>(options => options.ThrowOnBadRequest = true);
Expand All @@ -22,6 +22,12 @@

var app = builder.Build();

await using (var scope = app.Services.CreateAsyncScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<MedicationsDbContext>();
await dbContext.Database.MigrateAsync();
}

if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
Expand Down
5 changes: 4 additions & 1 deletion src/Medications.Api/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,8 @@
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
"AllowedHosts": "*",
"ConnectionStrings": {
"medications": "Server=localhost;Database=Medications;Trusted_Connection=True;TrustServerCertificate=True"
}
}
Loading