From 2710c714662da44f7faeddd05405732ab5d2af89 Mon Sep 17 00:00:00 2001 From: Fernando Tona <105774270+fernandotonacoder@users.noreply.github.com> Date: Thu, 6 Aug 2026 01:00:44 +0100 Subject: [PATCH 1/8] overall validation enhancement and first readme --- README.md | 77 +++++++++++++++++++ .../Contracts/CreateMedicationRequest.cs | 5 +- .../Data/MedicationsDbContext.cs | 7 ++ .../Endpoints/MedicationEndpoints.cs | 26 +++---- src/Medications.Api/Entities/Medication.cs | 2 + src/Medications.Api/Program.cs | 4 +- .../CreateMedicationRequestUnitTests.cs | 45 ++++++++++- .../DeleteEndpointUnitTests.cs | 17 ---- .../EndpointIdValidationUnitTests.cs | 26 +++++++ .../GetByIdEndpointUnitTests.cs | 15 ---- 10 files changed, 171 insertions(+), 53 deletions(-) create mode 100644 README.md create mode 100644 tests/Medications.Unit.Tests/EndpointIdValidationUnitTests.cs diff --git a/README.md b/README.md new file mode 100644 index 0000000..fe17c91 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# 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] +- **[.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 + +## Getting started + +Requires the [.NET 10 SDK]. + +```bash +# Run via the Aspire AppHost (dashboard + Scalar link): +dotnet run --project src/Medications.AppHost + +# Or run the API on its own: +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`. + +### 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` | + +## Tests + +```bash +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). + +## 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. +- 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. + +[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 diff --git a/src/Medications.Api/Contracts/CreateMedicationRequest.cs b/src/Medications.Api/Contracts/CreateMedicationRequest.cs index 3b05870..2be285d 100644 --- a/src/Medications.Api/Contracts/CreateMedicationRequest.cs +++ b/src/Medications.Api/Contracts/CreateMedicationRequest.cs @@ -1,12 +1,13 @@ using System.ComponentModel.DataAnnotations; +using Medications.Api.Entities; namespace Medications.Api.Contracts; public class CreateMedicationRequest { - [Required(AllowEmptyStrings = false)] - public required string Name { get; set; } + [StringLength(Medication.NameMaxLength)] + public string Name { get; set; } = string.Empty; [Range(1, int.MaxValue, ErrorMessage = "Quantity must be greater than 0")] public int Quantity { get; set; } diff --git a/src/Medications.Api/Data/MedicationsDbContext.cs b/src/Medications.Api/Data/MedicationsDbContext.cs index 610fa68..0338bc2 100644 --- a/src/Medications.Api/Data/MedicationsDbContext.cs +++ b/src/Medications.Api/Data/MedicationsDbContext.cs @@ -7,4 +7,11 @@ public class MedicationsDbContext( DbContextOptions options) : DbContext(options) { public DbSet Medications => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity() + .Property(medication => medication.Name) + .HasMaxLength(Medication.NameMaxLength); + } } diff --git a/src/Medications.Api/Endpoints/MedicationEndpoints.cs b/src/Medications.Api/Endpoints/MedicationEndpoints.cs index 0c91d00..fb7babc 100644 --- a/src/Medications.Api/Endpoints/MedicationEndpoints.cs +++ b/src/Medications.Api/Endpoints/MedicationEndpoints.cs @@ -1,3 +1,4 @@ +using System.ComponentModel.DataAnnotations; using Medications.Api.Contracts; using Medications.Api.Data; using Medications.Api.Mapping; @@ -27,7 +28,8 @@ public static IEndpointRouteBuilder MapMedicationEndpoints(this IEndpointRouteBu group.MapGet("/{id}", GetMedication) .WithName(GetMedicationEndpointName) .WithDescription("Retrieve a specific medication by its ID.") - .ProducesProblem(StatusCodes.Status400BadRequest); + .ProducesValidationProblem() + .ProducesProblem(StatusCodes.Status404NotFound); group.MapPost("/", CreateMedication) .WithName(CreateMedicationEndpointName) @@ -37,7 +39,8 @@ public static IEndpointRouteBuilder MapMedicationEndpoints(this IEndpointRouteBu group.MapDelete("/{id}", DeleteMedication) .WithName(DeleteMedicationEndpointName) .WithDescription("Delete a medication") - .ProducesProblem(StatusCodes.Status400BadRequest); + .ProducesValidationProblem() + .ProducesProblem(StatusCodes.Status404NotFound); return endpoints; } @@ -54,11 +57,10 @@ internal static async Task>> GetMedications( return TypedResults.Ok(medicationResponseList); } - internal static async Task, NotFound, ProblemHttpResult>> GetMedication( - int id, MedicationsDbContext dbContext, CancellationToken cancellationToken) + internal static async Task, NotFound>> GetMedication( + [Range(1, int.MaxValue, ErrorMessage = "Id must be greater than zero.")] int id, + MedicationsDbContext dbContext, CancellationToken cancellationToken) { - if (id <= 0) return InvalidId(); - var medication = await dbContext.Medications.FindAsync([id], cancellationToken); if (medication is null) return TypedResults.NotFound(); @@ -83,11 +85,10 @@ internal static async Task> CreateMedication( return TypedResults.CreatedAtRoute(medicationResponse, GetMedicationEndpointName, new { id = medicationResponse.Id }); } - internal static async Task> DeleteMedication( - int id, MedicationsDbContext dbContext, CancellationToken cancellationToken) + internal static async Task> DeleteMedication( + [Range(1, int.MaxValue, ErrorMessage = "Id must be greater than zero.")] int id, + MedicationsDbContext dbContext, CancellationToken cancellationToken) { - if (id <= 0) return InvalidId(); - var medication = await dbContext.Medications.FindAsync([id], cancellationToken); if (medication is null) return TypedResults.NotFound(); @@ -96,9 +97,4 @@ internal static async Task> Dele await dbContext.SaveChangesAsync(cancellationToken); return TypedResults.NoContent(); } - - private static ProblemHttpResult InvalidId() => - TypedResults.Problem( - detail: "Id must be greater than zero.", - statusCode: StatusCodes.Status400BadRequest); } diff --git a/src/Medications.Api/Entities/Medication.cs b/src/Medications.Api/Entities/Medication.cs index 26b1b86..79dcf74 100644 --- a/src/Medications.Api/Entities/Medication.cs +++ b/src/Medications.Api/Entities/Medication.cs @@ -2,6 +2,8 @@ namespace Medications.Api.Entities; public class Medication { + public const int NameMaxLength = 200; + public int Id { get; set; } public required string Name { get; set; } diff --git a/src/Medications.Api/Program.cs b/src/Medications.Api/Program.cs index 1f6c836..9cb2ca1 100644 --- a/src/Medications.Api/Program.cs +++ b/src/Medications.Api/Program.cs @@ -7,6 +7,7 @@ builder.Services.AddDbContext(opt => opt.UseInMemoryDatabase("Medications")); builder.Services.AddOpenApi(); builder.Services.AddValidation(); +builder.Services.Configure(options => options.ThrowOnBadRequest = true); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddProblemDetails(options => { @@ -31,7 +32,8 @@ { StatusCodeSelector = ex => ex is BadHttpRequestException badRequest ? badRequest.StatusCode - : StatusCodes.Status500InternalServerError + : StatusCodes.Status500InternalServerError, + SuppressDiagnosticsCallback = context => context.Exception is BadHttpRequestException }); app.UseStatusCodePages(); diff --git a/tests/Medications.Unit.Tests/CreateMedicationRequestUnitTests.cs b/tests/Medications.Unit.Tests/CreateMedicationRequestUnitTests.cs index 336bb79..2859a10 100644 --- a/tests/Medications.Unit.Tests/CreateMedicationRequestUnitTests.cs +++ b/tests/Medications.Unit.Tests/CreateMedicationRequestUnitTests.cs @@ -1,14 +1,18 @@ using Medications.Api.Contracts; +using Medications.Api.Entities; using System.ComponentModel.DataAnnotations; namespace Medications.Unit.Tests { public class CreateMedicationRequestUnitTests { - [Fact] - public void Name_WhenEmptyString_IsInvalid() + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + public void Name_WhenEmptyOrWhitespace_IsInvalid(string name) { - var results = Validate(new CreateMedicationRequest { Name = "", Quantity = 10 }); + var results = Validate(new CreateMedicationRequest { Name = name, Quantity = 10 }); var error = Assert.Single(results); Assert.Equal("The Name field is required.", error.ErrorMessage); @@ -16,6 +20,41 @@ public void Name_WhenEmptyString_IsInvalid() Assert.DoesNotContain(nameof(CreateMedicationRequest.Quantity), error.MemberNames); } + [Fact] + public void Name_WhenNotSet_IsInvalid() + { + var results = Validate(new CreateMedicationRequest { Quantity = 10 }); + + var error = Assert.Single(results); + Assert.Equal("The Name field is required.", error.ErrorMessage); + Assert.Contains(nameof(CreateMedicationRequest.Name), error.MemberNames); + } + + [Fact] + public void Name_WhenLongerThanMaxLength_IsInvalid() + { + var results = Validate(new CreateMedicationRequest + { + Name = new string('a', Medication.NameMaxLength + 1), + Quantity = 10 + }); + + var error = Assert.Single(results); + Assert.Contains(nameof(CreateMedicationRequest.Name), error.MemberNames); + } + + [Fact] + public void Name_AtMaxLength_IsValid() + { + var results = Validate(new CreateMedicationRequest + { + Name = new string('a', Medication.NameMaxLength), + Quantity = 10 + }); + + Assert.Empty(results); + } + [Theory] [InlineData(0)] [InlineData(-1)] diff --git a/tests/Medications.Unit.Tests/DeleteEndpointUnitTests.cs b/tests/Medications.Unit.Tests/DeleteEndpointUnitTests.cs index 42ea574..b19b2e2 100644 --- a/tests/Medications.Unit.Tests/DeleteEndpointUnitTests.cs +++ b/tests/Medications.Unit.Tests/DeleteEndpointUnitTests.cs @@ -1,5 +1,4 @@ using Medications.Api.Endpoints; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; using static Medications.Unit.Tests.TestDbContextFactory; @@ -30,21 +29,5 @@ public async Task DeleteMedication_WhenMedicationDoesNotExist_ReturnsNotFound() Assert.NotNull(dbContext.Medications.Find(1)); Assert.NotNull(dbContext.Medications.Find(2)); } - - [Theory] - [InlineData(0)] - [InlineData(-1)] - public async Task DeleteMedication_WhenIdNotGreaterThanZero_ReturnsBadRequest(int id) - { - await using var dbContext = CreateContext(NewMedication(1), NewMedication(2)); - - var result = await MedicationEndpoints.DeleteMedication(id, dbContext, CancellationToken.None); - - var problem = Assert.IsType(result.Result); - Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode); - Assert.Equal("Id must be greater than zero.", problem.ProblemDetails.Detail); - Assert.NotNull(dbContext.Medications.Find(1)); - Assert.NotNull(dbContext.Medications.Find(2)); - } } } diff --git a/tests/Medications.Unit.Tests/EndpointIdValidationUnitTests.cs b/tests/Medications.Unit.Tests/EndpointIdValidationUnitTests.cs new file mode 100644 index 0000000..afb4a8b --- /dev/null +++ b/tests/Medications.Unit.Tests/EndpointIdValidationUnitTests.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using Medications.Api.Endpoints; + +namespace Medications.Unit.Tests +{ + public class EndpointIdValidationUnitTests + { + [Theory] + [InlineData(nameof(MedicationEndpoints.GetMedication))] + [InlineData(nameof(MedicationEndpoints.DeleteMedication))] + public void IdParameter_DeclaresRangeStartingAtOne(string methodName) + { + var method = typeof(MedicationEndpoints).GetMethod( + methodName, BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(method); + var idParameter = Assert.Single(method.GetParameters(), parameter => parameter.Name == "id"); + + var range = Assert.IsType( + Assert.Single(idParameter.GetCustomAttributes(typeof(RangeAttribute), inherit: false))); + Assert.Equal(1, range.Minimum); + Assert.Equal("Id must be greater than zero.", range.ErrorMessage); + } + } +} diff --git a/tests/Medications.Unit.Tests/GetByIdEndpointUnitTests.cs b/tests/Medications.Unit.Tests/GetByIdEndpointUnitTests.cs index 3d6f5fc..5395183 100644 --- a/tests/Medications.Unit.Tests/GetByIdEndpointUnitTests.cs +++ b/tests/Medications.Unit.Tests/GetByIdEndpointUnitTests.cs @@ -1,6 +1,5 @@ using Medications.Api.Contracts; using Medications.Api.Endpoints; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.HttpResults; using static Medications.Unit.Tests.TestDbContextFactory; @@ -35,19 +34,5 @@ public async Task GetMedicationById_WhenMedicationDoesNotExist_ReturnsNotFound() Assert.IsType(result.Result); } - - [Theory] - [InlineData(0)] - [InlineData(-1)] - public async Task GetMedicationById_WhenNotGreaterThanZero_ReturnsBadRequest(int id) - { - await using var dbContext = CreateContext(NewMedication(1), NewMedication(2)); - - var result = await MedicationEndpoints.GetMedication(id, dbContext, CancellationToken.None); - - var problem = Assert.IsType(result.Result); - Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode); - Assert.Equal("Id must be greater than zero.", problem.ProblemDetails.Detail); - } } } From 56759763a008e746f646816ef86fa697fc9d6041 Mon Sep 17 00:00:00 2001 From: Fernando Tona <105774270+fernandotonacoder@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:34:52 +0100 Subject: [PATCH 2/8] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fe17c91..4e48023 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A small REST API to list, create and delete medications, built with .NET 10 Mini ## Tech stack - **.NET 10 / C#** — ASP.NET Core [Minimal APIs] -- **[EF Core]** — [InMemory provider] +- **[EF Core]** — [InMemory provider] for Unit Tests, SQL Server for the real app - **[.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 From c698d546d754cdd73857e2b3519bb264bdcc753d Mon Sep 17 00:00:00 2001 From: Fernando Tona <105774270+fernandotonacoder@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:47:42 +0100 Subject: [PATCH 3/8] dummy commit From 559b1c71d2ce4ef21a7f8115ffd227a7c2c61e13 Mon Sep 17 00:00:00 2001 From: Fernando Tona <105774270+fernandotonacoder@users.noreply.github.com> Date: Fri, 7 Aug 2026 03:27:27 +0100 Subject: [PATCH 4/8] add db and migrations --- .config/dotnet-tools.json | 13 +++ Directory.Packages.props | 5 ++ README.md | 89 +++++++++++-------- src/Medications.Api/Medications.Api.csproj | 7 +- .../20260806230417_InitialCreate.Designer.cs | 54 +++++++++++ .../20260806230417_InitialCreate.cs | 37 ++++++++ .../MedicationsDbContextModelSnapshot.cs | 51 +++++++++++ src/Medications.Api/Program.cs | 8 +- src/Medications.Api/appsettings.json | 5 +- src/Medications.AppHost/AppHost.cs | 12 +++ .../Medications.AppHost.csproj | 4 + .../Medications.Unit.Tests.csproj | 1 + 12 files changed, 248 insertions(+), 38 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 src/Medications.Api/Migrations/20260806230417_InitialCreate.Designer.cs create mode 100644 src/Medications.Api/Migrations/20260806230417_InitialCreate.cs create mode 100644 src/Medications.Api/Migrations/MedicationsDbContextModelSnapshot.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..cce89d8 --- /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 + } + } +} \ No newline at end of file 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..d33135c 100644 --- a/README.md +++ b/README.md @@ -5,24 +5,45 @@ A small REST API to list, create and delete medications, built with .NET 10 Mini ## 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 in CI ## 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 — 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 @@ -35,43 +56,41 @@ Standalone, the API listens on `http://localhost:5122`. In Development, interact ## 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` — no database 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) — validation errors, malformed JSON, invalid route values and 404s — wired once in Program.cs rather than 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. [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 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/20260806230417_InitialCreate.Designer.cs b/src/Medications.Api/Migrations/20260806230417_InitialCreate.Designer.cs new file mode 100644 index 0000000..61c62de --- /dev/null +++ b/src/Medications.Api/Migrations/20260806230417_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("20260806230417_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/20260806230417_InitialCreate.cs b/src/Medications.Api/Migrations/20260806230417_InitialCreate.cs new file mode 100644 index 0000000..3adb0d7 --- /dev/null +++ b/src/Medications.Api/Migrations/20260806230417_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..68fd817 --- /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", (string)null); + }); +#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..e66a5dc 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 = builder.Configuration.GetConnectionString("medications") is not null + ? 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") + .WithReference(medicationsDb) + .WaitFor(medicationsDb) .WithUrlForEndpoint("https", 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..55783b3 100644 --- a/tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj +++ b/tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj @@ -15,6 +15,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive + From f06accb98d2d7e7e9a58b4f6a7db93b0de4ad110 Mon Sep 17 00:00:00 2001 From: Fernando Tona <105774270+fernandotonacoder@users.noreply.github.com> Date: Fri, 7 Aug 2026 04:20:42 +0100 Subject: [PATCH 5/8] final cleanup --- .config/dotnet-tools.json | 2 +- README.md | 35 ++++++++++++++----- ... 20260807025640_InitialCreate.Designer.cs} | 2 +- ...ate.cs => 20260807025640_InitialCreate.cs} | 0 .../MedicationsDbContextModelSnapshot.cs | 2 +- src/Medications.AppHost/AppHost.cs | 4 +-- .../Medications.Unit.Tests.csproj | 3 -- 7 files changed, 31 insertions(+), 17 deletions(-) rename src/Medications.Api/Migrations/{20260806230417_InitialCreate.Designer.cs => 20260807025640_InitialCreate.Designer.cs} (97%) rename src/Medications.Api/Migrations/{20260806230417_InitialCreate.cs => 20260807025640_InitialCreate.cs} (100%) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index cce89d8..f3cc8ba 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -10,4 +10,4 @@ "rollForward": false } } -} \ No newline at end of file +} diff --git a/README.md b/README.md index d33135c..790f363 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,13 @@ # 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) +[![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) + +[![.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/) + A small REST API to list, create and delete medications, built with .NET 10 Minimal APIs. ## Tech stack @@ -22,7 +30,7 @@ dotnet run --project src/Medications.AppHost 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 — nothing to configure. The container and its data are kept between runs. +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. @@ -47,12 +55,12 @@ Standalone, the API listens on `http://localhost:5122`; interactive docs at `/sc ### 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 @@ -60,7 +68,7 @@ Standalone, the API listens on `http://localhost:5122`; interactive docs at `/sc dotnet test ``` -Unit tests cover the endpoint handlers, the mapping layer and the request contract's validation attributes, using the EF InMemory provider and a `FakeTimeProvider` — no database needed. +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 @@ -78,7 +86,7 @@ dotnet ef database update --project src/Medications.Api # apply without - 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) — validation errors, malformed JSON, invalid route values and 404s — wired once in Program.cs rather than per endpoint. +- 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 (injected `TimeProvider`, faked in tests); `Id` and `CreationDate` are never accepted as input. @@ -87,6 +95,15 @@ dotnet ef database update --project src/Medications.Api # apply without - 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 [.NET Aspire]: https://learn.microsoft.com/en-us/dotnet/aspire/ [.NET 10 SDK]: https://dotnet.microsoft.com/download/dotnet/10.0 diff --git a/src/Medications.Api/Migrations/20260806230417_InitialCreate.Designer.cs b/src/Medications.Api/Migrations/20260807025640_InitialCreate.Designer.cs similarity index 97% rename from src/Medications.Api/Migrations/20260806230417_InitialCreate.Designer.cs rename to src/Medications.Api/Migrations/20260807025640_InitialCreate.Designer.cs index 61c62de..47867f1 100644 --- a/src/Medications.Api/Migrations/20260806230417_InitialCreate.Designer.cs +++ b/src/Medications.Api/Migrations/20260807025640_InitialCreate.Designer.cs @@ -12,7 +12,7 @@ namespace Medications.Api.Migrations { [DbContext(typeof(MedicationsDbContext))] - [Migration("20260806230417_InitialCreate")] + [Migration("20260807025640_InitialCreate")] partial class InitialCreate { /// diff --git a/src/Medications.Api/Migrations/20260806230417_InitialCreate.cs b/src/Medications.Api/Migrations/20260807025640_InitialCreate.cs similarity index 100% rename from src/Medications.Api/Migrations/20260806230417_InitialCreate.cs rename to src/Medications.Api/Migrations/20260807025640_InitialCreate.cs diff --git a/src/Medications.Api/Migrations/MedicationsDbContextModelSnapshot.cs b/src/Medications.Api/Migrations/MedicationsDbContextModelSnapshot.cs index 68fd817..1f9e894 100644 --- a/src/Medications.Api/Migrations/MedicationsDbContextModelSnapshot.cs +++ b/src/Medications.Api/Migrations/MedicationsDbContextModelSnapshot.cs @@ -43,7 +43,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id"); - b.ToTable("Medications", (string)null); + b.ToTable("Medications"); }); #pragma warning restore 612, 618 } diff --git a/src/Medications.AppHost/AppHost.cs b/src/Medications.AppHost/AppHost.cs index e66a5dc..d15f6c1 100644 --- a/src/Medications.AppHost/AppHost.cs +++ b/src/Medications.AppHost/AppHost.cs @@ -3,7 +3,7 @@ var builder = DistributedApplication.CreateBuilder(args); -var medicationsDb = builder.Configuration.GetConnectionString("medications") is not null +var medicationsDb = !string.IsNullOrWhiteSpace(builder.Configuration.GetConnectionString("medications")) ? builder.AddConnectionString("medications") : builder.AddSqlServer("sql") .WithImageTag("2022-CU26-ubuntu-22.04") @@ -15,6 +15,6 @@ builder.AddProject("medications-api") .WithReference(medicationsDb) .WaitFor(medicationsDb) - .WithUrlForEndpoint("https", url => new() { Url = "/scalar", DisplayText = "Scalar UI", DisplayOrder = 100 }); + .WithUrlForEndpoint("http", url => new() { Url = "/scalar", DisplayText = "Scalar UI", DisplayOrder = 100 }); await builder.Build().RunAsync(); diff --git a/tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj b/tests/Medications.Unit.Tests/Medications.Unit.Tests.csproj index 55783b3..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 From bd858f9ce8cad11f2d7be9eb40e32e3092b1218d Mon Sep 17 00:00:00 2001 From: Fernando Tona <105774270+fernandotonacoder@users.noreply.github.com> Date: Fri, 7 Aug 2026 04:33:37 +0100 Subject: [PATCH 6/8] adjust readme sonarcloud badge --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 790f363..e0dd28f 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@ # 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) -[![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) [![.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 From 43bd8061f4639088555c31f64125e91e90607486 Mon Sep 17 00:00:00 2001 From: Fernando Tona <105774270+fernandotonacoder@users.noreply.github.com> Date: Fri, 7 Aug 2026 04:34:10 +0100 Subject: [PATCH 7/8] alignment --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index e0dd28f..05c27be 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # 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) From 51047a398a759dc9c567dd5bd47ea3fdb0b0b234 Mon Sep 17 00:00:00 2001 From: Fernando Tona <105774270+fernandotonacoder@users.noreply.github.com> Date: Fri, 7 Aug 2026 04:47:34 +0100 Subject: [PATCH 8/8] remove migrations from sonarscan coverage --- .github/workflows/ci.yml | 5 ++++- README.md | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) 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/README.md b/README.md index 05c27be..b23e8eb 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ A small REST API to list, create and delete medications, built with .NET 10 Mini - **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 in CI +- **xunit v3** — unit tests, with Coverlet coverage feeding SonarQube Cloud +- **[GitHub Actions]** — builds and tests every push and pull request to `main` ## Getting started @@ -111,3 +112,4 @@ Made with ❤️ by Fernando Tona [`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