diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..f3cc8ba --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.10", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa63b4d..fffa26e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,7 @@ 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 \ @@ -49,7 +50,9 @@ jobs: /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 diff --git a/Directory.Packages.props b/Directory.Packages.props index 008f955..344fee6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,8 +3,13 @@ true + + + + + diff --git a/README.md b/README.md index 4e48023..b23e8eb 100644 --- a/README.md +++ b/README.md @@ -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 --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. + +--- + +
+ +Made with ❤️ by Fernando Tona +[Website](https://fernandotonacoder.github.io) • [LinkedIn](https://www.linkedin.com/in/fernandotona/) • [GitHub](https://github.com/fernandotonacoder) + +
[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 diff --git a/src/Medications.Api/Medications.Api.csproj b/src/Medications.Api/Medications.Api.csproj index 043f62a..aa3744e 100644 --- a/src/Medications.Api/Medications.Api.csproj +++ b/src/Medications.Api/Medications.Api.csproj @@ -1,8 +1,13 @@ + - + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/src/Medications.Api/Migrations/20260807025640_InitialCreate.Designer.cs b/src/Medications.Api/Migrations/20260807025640_InitialCreate.Designer.cs new file mode 100644 index 0000000..47867f1 --- /dev/null +++ b/src/Medications.Api/Migrations/20260807025640_InitialCreate.Designer.cs @@ -0,0 +1,54 @@ +// +using System; +using Medications.Api.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Medications.Api.Migrations +{ + [DbContext(typeof(MedicationsDbContext))] + [Migration("20260807025640_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Medications"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Medications.Api/Migrations/20260807025640_InitialCreate.cs b/src/Medications.Api/Migrations/20260807025640_InitialCreate.cs new file mode 100644 index 0000000..3adb0d7 --- /dev/null +++ b/src/Medications.Api/Migrations/20260807025640_InitialCreate.cs @@ -0,0 +1,37 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Medications.Api.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Medications", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(200)", maxLength: 200, nullable: false), + Quantity = table.Column(type: "int", nullable: false), + CreationDate = table.Column(type: "datetimeoffset", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Medications", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Medications"); + } + } +} diff --git a/src/Medications.Api/Migrations/MedicationsDbContextModelSnapshot.cs b/src/Medications.Api/Migrations/MedicationsDbContextModelSnapshot.cs new file mode 100644 index 0000000..1f9e894 --- /dev/null +++ b/src/Medications.Api/Migrations/MedicationsDbContextModelSnapshot.cs @@ -0,0 +1,51 @@ +// +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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreationDate") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Quantity") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Medications"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Medications.Api/Program.cs b/src/Medications.Api/Program.cs index 9cb2ca1..6982e82 100644 --- a/src/Medications.Api/Program.cs +++ b/src/Medications.Api/Program.cs @@ -4,7 +4,7 @@ using Scalar.AspNetCore; var builder = WebApplication.CreateBuilder(args); -builder.Services.AddDbContext(opt => opt.UseInMemoryDatabase("Medications")); +builder.AddSqlServerDbContext("medications"); builder.Services.AddOpenApi(); builder.Services.AddValidation(); builder.Services.Configure(options => options.ThrowOnBadRequest = true); @@ -22,6 +22,12 @@ var app = builder.Build(); +await using (var scope = app.Services.CreateAsyncScope()) +{ + var dbContext = scope.ServiceProvider.GetRequiredService(); + await dbContext.Database.MigrateAsync(); +} + if (app.Environment.IsDevelopment()) { app.MapOpenApi(); diff --git a/src/Medications.Api/appsettings.json b/src/Medications.Api/appsettings.json index 10f68b8..a5cbd90 100644 --- a/src/Medications.Api/appsettings.json +++ b/src/Medications.Api/appsettings.json @@ -5,5 +5,8 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*" + "AllowedHosts": "*", + "ConnectionStrings": { + "medications": "Server=localhost;Database=Medications;Trusted_Connection=True;TrustServerCertificate=True" + } } diff --git a/src/Medications.AppHost/AppHost.cs b/src/Medications.AppHost/AppHost.cs index e602bb6..d15f6c1 100644 --- a/src/Medications.AppHost/AppHost.cs +++ b/src/Medications.AppHost/AppHost.cs @@ -1,8 +1,20 @@ +using Microsoft.Extensions.Configuration; using Projects; var builder = DistributedApplication.CreateBuilder(args); +var medicationsDb = !string.IsNullOrWhiteSpace(builder.Configuration.GetConnectionString("medications")) + ? builder.AddConnectionString("medications") + : builder.AddSqlServer("sql") + .WithImageTag("2022-CU26-ubuntu-22.04") + .WithContainerName("medications-sql") + .WithLifetime(ContainerLifetime.Persistent) + .WithDataVolume() + .AddDatabase("medications"); + builder.AddProject("medications-api") - .WithUrlForEndpoint("https", url => new() { Url = "/scalar", DisplayText = "Scalar UI", DisplayOrder = 100 }); + .WithReference(medicationsDb) + .WaitFor(medicationsDb) + .WithUrlForEndpoint("http", url => new() { Url = "/scalar", DisplayText = "Scalar UI", DisplayOrder = 100 }); await builder.Build().RunAsync(); diff --git a/src/Medications.AppHost/Medications.AppHost.csproj b/src/Medications.AppHost/Medications.AppHost.csproj index 185a886..131a53f 100644 --- a/src/Medications.AppHost/Medications.AppHost.csproj +++ b/src/Medications.AppHost/Medications.AppHost.csproj @@ -5,6 +5,10 @@ dc8941e3-0794-4122-a9d7-f3c18746f746 + + + + diff --git a/tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj b/tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj index 3fd85d6..1234f5e 100644 --- a/tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj +++ b/tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj @@ -1,9 +1,6 @@  - net10.0 - enable - enable false @@ -15,6 +12,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive +