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/3] 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/3] 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/3] dummy commit