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
63 changes: 63 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
name: CI

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
workflow_dispatch:

jobs:
build-and-test:

runs-on: ubuntu-latest

env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

steps:
- uses: actions/[email protected]
with:
fetch-depth: 0

- name: Setup .NET
uses: actions/[email protected]
with:
dotnet-version: 10.0.x

- name: Set up JDK 21 for SonarQube analysis
if: env.SONAR_TOKEN != ''
uses: actions/[email protected]
with:
distribution: temurin
java-version: 21

- name: Cache SonarQube packages
if: env.SONAR_TOKEN != ''
uses: actions/[email protected]
with:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar
restore-keys: ${{ runner.os }}-sonar

- name: Begin Sonar analysis
if: env.SONAR_TOKEN != ''
run: |
dotnet tool install --global dotnet-sonarscanner
dotnet-sonarscanner begin \
/k:"fernandotonacoder_medications-rest-api" \
/o:"fernandotonacoder" \
/d:sonar.token="$SONAR_TOKEN" \
/d:sonar.host.url="https://sonarcloud.io" \
/d:sonar.cs.cobertura.reportsPaths="**/coverage.cobertura.xml"

- name: Build
run: dotnet build --no-incremental

- name: Test
# RunSettingsFilePath points at tests/test.runsettings in cobertura format
run: dotnet test --no-build

- name: End Sonar analysis
if: env.SONAR_TOKEN != ''
run: dotnet-sonarscanner end /d:sonar.token="$SONAR_TOKEN"
28 changes: 0 additions & 28 deletions .github/workflows/dotnet.yml

This file was deleted.

8 changes: 6 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@
<ItemGroup>
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.8.0" />
<PackageVersion Include="Microsoft.OpenApi" Version="2.7.5" />
<PackageVersion Include="Scalar.Aspire" Version="0.11.11" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.16" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.16.17" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
</ItemGroup>
</Project>
8 changes: 6 additions & 2 deletions Medications.slnx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
<Solution>
<Folder Name="/src/">
<Project Path="src\Medications.Api\Medications.Api.csproj"/>
<Project Path="src\Medications.AppHost\Medications.AppHost.csproj"/>
<Project Path="src\Medications.Api\Medications.Api.csproj" />
<Project Path="src\Medications.AppHost\Medications.AppHost.csproj" />
</Folder>
<Folder Name="/tests/">
<File Path="test.runsettings" />
<Project Path="tests\Medications.Unit.Tests\Medications.Unit.Tests.csproj" />
</Folder>
</Solution>
66 changes: 52 additions & 14 deletions src/Medications.Api/Endpoints/MedicationEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,46 +8,65 @@ namespace Medications.Api.Endpoints;

public static class MedicationEndpoints
{
private const string RouteGroupPrefix = "/api/medications";

private const string GetMedicationsEndpointName = "GetMedications";
private const string GetMedicationEndpointName = "GetMedication";
private const string CreateMedicationEndpointName = "CreateMedication";
private const string DeleteMedicationEndpointName = "DeleteMedication";

public static IEndpointRouteBuilder MapMedicationEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/medications")
var group = endpoints.MapGroup(RouteGroupPrefix)
.WithTags("Medications");

group.MapGet("/", GetAllMedications)
.WithName("GetMedications")
group.MapGet("/", GetMedications)
.WithName(GetMedicationsEndpointName)
.WithDescription("Retrieve all medications.");

group.MapGet("/{id}", GetMedication)
.WithName("GetMedication")
.WithDescription("Retrieve a specific medication by its ID.");
.WithName(GetMedicationEndpointName)
.WithDescription("Retrieve a specific medication by its ID.")
.ProducesProblem(StatusCodes.Status400BadRequest);

group.MapPost("/", CreateMedication)
.WithName("CreateMedication")
.WithDescription("Create a new medication");
.WithName(CreateMedicationEndpointName)
.WithDescription("Create a new medication")
.ProducesValidationProblem();

group.MapDelete("/{id}", DeleteMedication)
.WithName(DeleteMedicationEndpointName)
.WithDescription("Delete a medication")
.ProducesProblem(StatusCodes.Status400BadRequest);

return endpoints;
}

private static async Task<Ok<List<MedicationResponse>>> GetAllMedications(MedicationsDbContext dbContext)
internal static async Task<Ok<List<MedicationResponse>>> GetMedications(
MedicationsDbContext dbContext, CancellationToken cancellationToken)
{
var medications = await dbContext.Medications.ToListAsync();
var medications = await dbContext.Medications
.OrderBy(medication => medication.Id)
.ToListAsync(cancellationToken);

var medicationResponseList = medications.Select(medication => medication.ToResponse()).ToList();

return TypedResults.Ok(medicationResponseList);
}

private static async Task<Results<Ok<MedicationResponse>, NotFound>> GetMedication(
int id, MedicationsDbContext dbContext)
internal static async Task<Results<Ok<MedicationResponse>, NotFound, ProblemHttpResult>> GetMedication(
int id, MedicationsDbContext dbContext, CancellationToken cancellationToken)
{
var medication = await dbContext.Medications.FindAsync(id);
if (id <= 0) return InvalidId();

var medication = await dbContext.Medications.FindAsync([id], cancellationToken);

if (medication is null) return TypedResults.NotFound();

return TypedResults.Ok(medication.ToResponse());
}

private static async Task<CreatedAtRoute<MedicationResponse>> CreateMedication(
internal static async Task<CreatedAtRoute<MedicationResponse>> CreateMedication(
CreateMedicationRequest request,
MedicationsDbContext dbContext,
TimeProvider timeProvider,
Expand All @@ -61,6 +80,25 @@ private static async Task<CreatedAtRoute<MedicationResponse>> CreateMedication(

var medicationResponse = medication.ToResponse();

return TypedResults.CreatedAtRoute(medicationResponse, "GetMedication", new { id = medicationResponse.Id });
return TypedResults.CreatedAtRoute(medicationResponse, GetMedicationEndpointName, new { id = medicationResponse.Id });
}

internal static async Task<Results<NoContent, NotFound, ProblemHttpResult>> DeleteMedication(
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();

dbContext.Medications.Remove(medication);
await dbContext.SaveChangesAsync(cancellationToken);
return TypedResults.NoContent();
}

private static ProblemHttpResult InvalidId() =>
TypedResults.Problem(
detail: "Id must be greater than zero.",
statusCode: StatusCodes.Status400BadRequest);
}
5 changes: 5 additions & 0 deletions src/Medications.Api/Medications.Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
<PackageReference Include="Microsoft.AspNetCore.OpenApi"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory"/>
<PackageReference Include="Microsoft.OpenApi"/>
<PackageReference Include="Scalar.AspNetCore"/>
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Medications.Unit.Tests" />
</ItemGroup>

</Project>
20 changes: 20 additions & 0 deletions src/Medications.Api/Program.cs
Original file line number Diff line number Diff line change
@@ -1,20 +1,40 @@
using Medications.Api.Data;
using Medications.Api.Endpoints;
using Microsoft.EntityFrameworkCore;
using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<MedicationsDbContext>(opt => opt.UseInMemoryDatabase("Medications"));
builder.Services.AddOpenApi();
builder.Services.AddValidation();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
if (context.Exception is BadHttpRequestException badRequest)
{
context.ProblemDetails.Detail ??= badRequest.Message;
}
};
});

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}

app.UseExceptionHandler(new ExceptionHandlerOptions
{
StatusCodeSelector = ex => ex is BadHttpRequestException badRequest
? badRequest.StatusCode
: StatusCodes.Status500InternalServerError
});
app.UseStatusCodePages();

app.UseHttpsRedirection();

app.MapMedicationEndpoints();
Expand Down
15 changes: 2 additions & 13 deletions src/Medications.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
@@ -1,19 +1,8 @@
using Projects;
using Scalar.Aspire;

var builder = DistributedApplication.CreateBuilder(args);

var apiService = builder.AddProject<Medications_Api>("medications-api");

var scalar = builder.AddScalarApiReference("scalar", options =>
{
options
.WithTheme(ScalarTheme.Default)
.PreferHttpsEndpoint()
.AllowSelfSignedCertificates();
});

scalar
.WithApiReference(apiService);
builder.AddProject<Medications_Api>("medications-api")
.WithUrlForEndpoint("https", url => new() { Url = "/scalar", DisplayText = "Scalar UI", DisplayOrder = 100 });

await builder.Build().RunAsync();
4 changes: 0 additions & 4 deletions src/Medications.AppHost/Medications.AppHost.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,4 @@
<ProjectReference Include="..\Medications.Api\Medications.Api.csproj"/>
</ItemGroup>

<ItemGroup>
<PackageReference Include="Scalar.Aspire"/>
</ItemGroup>

</Project>
Empty file removed tests/.gitkeep
Empty file.
43 changes: 43 additions & 0 deletions tests/Medications.Unit.Tests/CreateMedicationRequestUnitTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using Medications.Api.Contracts;
using System.ComponentModel.DataAnnotations;

namespace Medications.Unit.Tests
{
public class CreateMedicationRequestUnitTests
{
[Fact]
public void Name_WhenEmptyString_IsInvalid()
{
var results = Validate(new CreateMedicationRequest { 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);
}

[Theory]
[InlineData(0)]
[InlineData(-1)]
public void Quantity_WhenNotGreaterThanZero_IsInvalid(int quantity)
{
var results = Validate(new CreateMedicationRequest { Name = "Aspirin", Quantity = quantity });

var error = Assert.Single(results);
Assert.Equal("Quantity must be greater than 0", error.ErrorMessage);
Assert.DoesNotContain(nameof(CreateMedicationRequest.Name), error.MemberNames);
Assert.Contains(nameof(CreateMedicationRequest.Quantity), error.MemberNames);
}

private static List<ValidationResult> Validate(CreateMedicationRequest request)
{
var results = new List<ValidationResult>();
Validator.TryValidateObject(
request,
new ValidationContext(request),
results,
validateAllProperties: true);
return results;
}
}
}
Loading