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
77 changes: 77 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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] 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

## 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
5 changes: 3 additions & 2 deletions src/Medications.Api/Contracts/CreateMedicationRequest.cs
Original file line number Diff line number Diff line change
@@ -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; }
Expand Down
7 changes: 7 additions & 0 deletions src/Medications.Api/Data/MedicationsDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,11 @@ public class MedicationsDbContext(
DbContextOptions<MedicationsDbContext> options) : DbContext(options)
{
public DbSet<Medication> Medications => Set<Medication>();

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Medication>()
.Property(medication => medication.Name)
.HasMaxLength(Medication.NameMaxLength);
}
}
26 changes: 11 additions & 15 deletions src/Medications.Api/Endpoints/MedicationEndpoints.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.ComponentModel.DataAnnotations;
using Medications.Api.Contracts;
using Medications.Api.Data;
using Medications.Api.Mapping;
Expand Down Expand Up @@ -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)
Expand All @@ -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;
}
Expand All @@ -54,11 +57,10 @@ internal static async Task<Ok<List<MedicationResponse>>> GetMedications(
return TypedResults.Ok(medicationResponseList);
}

internal static async Task<Results<Ok<MedicationResponse>, NotFound, ProblemHttpResult>> GetMedication(
int id, MedicationsDbContext dbContext, CancellationToken cancellationToken)
internal static async Task<Results<Ok<MedicationResponse>, 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();
Expand All @@ -83,11 +85,10 @@ internal static async Task<CreatedAtRoute<MedicationResponse>> CreateMedication(
return TypedResults.CreatedAtRoute(medicationResponse, GetMedicationEndpointName, new { id = medicationResponse.Id });
}

internal static async Task<Results<NoContent, NotFound, ProblemHttpResult>> DeleteMedication(
int id, MedicationsDbContext dbContext, CancellationToken cancellationToken)
internal static async Task<Results<NoContent, NotFound>> 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();
Expand All @@ -96,9 +97,4 @@ internal static async Task<Results<NoContent, NotFound, ProblemHttpResult>> Dele
await dbContext.SaveChangesAsync(cancellationToken);
return TypedResults.NoContent();
}

private static ProblemHttpResult InvalidId() =>
TypedResults.Problem(
detail: "Id must be greater than zero.",
statusCode: StatusCodes.Status400BadRequest);
}
2 changes: 2 additions & 0 deletions src/Medications.Api/Entities/Medication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
4 changes: 3 additions & 1 deletion src/Medications.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
builder.Services.AddDbContext<MedicationsDbContext>(opt => opt.UseInMemoryDatabase("Medications"));
builder.Services.AddOpenApi();
builder.Services.AddValidation();
builder.Services.Configure<RouteHandlerOptions>(options => options.ThrowOnBadRequest = true);
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddProblemDetails(options =>
{
Expand All @@ -31,7 +32,8 @@
{
StatusCodeSelector = ex => ex is BadHttpRequestException badRequest
? badRequest.StatusCode
: StatusCodes.Status500InternalServerError
: StatusCodes.Status500InternalServerError,
SuppressDiagnosticsCallback = context => context.Exception is BadHttpRequestException
});
app.UseStatusCodePages();

Expand Down
45 changes: 42 additions & 3 deletions tests/Medications.Unit.Tests/CreateMedicationRequestUnitTests.cs
Original file line number Diff line number Diff line change
@@ -1,21 +1,60 @@
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);
Assert.Contains(nameof(CreateMedicationRequest.Name), error.MemberNames);
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)]
Expand Down
17 changes: 0 additions & 17 deletions tests/Medications.Unit.Tests/DeleteEndpointUnitTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using Medications.Api.Endpoints;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using static Medications.Unit.Tests.TestDbContextFactory;

Expand Down Expand Up @@ -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<ProblemHttpResult>(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));
}
}
}
26 changes: 26 additions & 0 deletions tests/Medications.Unit.Tests/EndpointIdValidationUnitTests.cs
Original file line number Diff line number Diff line change
@@ -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<RangeAttribute>(
Assert.Single(idParameter.GetCustomAttributes(typeof(RangeAttribute), inherit: false)));
Assert.Equal(1, range.Minimum);
Assert.Equal("Id must be greater than zero.", range.ErrorMessage);
}
}
}
15 changes: 0 additions & 15 deletions tests/Medications.Unit.Tests/GetByIdEndpointUnitTests.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -35,19 +34,5 @@ public async Task GetMedicationById_WhenMedicationDoesNotExist_ReturnsNotFound()

Assert.IsType<NotFound>(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<ProblemHttpResult>(result.Result);
Assert.Equal(StatusCodes.Status400BadRequest, problem.StatusCode);
Assert.Equal("Id must be greater than zero.", problem.ProblemDetails.Detail);
}
}
}