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
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
using System.ComponentModel.DataAnnotations;

namespace EventTicketing.Api.Contracts;

public sealed record ReserveTicketsRequest([property: Range(1, 20)] int Quantity);
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using EventTicketing.Api.Contracts;
using EventTicketing.Api.Extensions;
using EventTicketing.Application.Events;

namespace EventTicketing.Api.Endpoints;

public static class EventEndpoints
{
public static IEndpointRouteBuilder MapEventEndpoints(this IEndpointRouteBuilder app)
{
var events = app.MapGroup("/api/events");

events.MapGet("/{eventId:int}", async (
int eventId,
GetEventAvailabilityHandler handler,
CancellationToken cancellationToken) =>
{
var result = await handler.HandleAsync(new GetEventAvailabilityQuery(eventId), cancellationToken);

return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblem();
});

events.MapPost("/{eventId:int}/reservations", async (
int eventId,
ReserveTicketsRequest request,
ReserveTicketsHandler handler,
CancellationToken cancellationToken) =>
{
var command = new ReserveTicketsCommand(eventId, request.Quantity);
var result = await handler.HandleAsync(command, cancellationToken);

return result.IsSuccess ? Results.Ok(result.Value) : result.ToProblem();
});

return app;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<ItemGroup>
<ProjectReference Include="..\EventTicketing.Application\EventTicketing.Application.csproj" />
<ProjectReference Include="..\EventTicketing.Infrastructure\EventTicketing.Infrastructure.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
</ItemGroup>

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using EventTicketing.Domain.Common;

namespace EventTicketing.Api.Extensions;

public static class ResultExtensions
{
public static IResult ToProblem(this Result result)
{
if (result.IsSuccess)
throw new InvalidOperationException("A successful result is not a problem.");

var statusCode = result.Error.Type switch
{
ErrorType.Validation => StatusCodes.Status400BadRequest,
ErrorType.NotFound => StatusCodes.Status404NotFound,
ErrorType.Conflict => StatusCodes.Status409Conflict,
_ => StatusCodes.Status500InternalServerError
};

return TypedResults.Problem(
statusCode: statusCode,
title: result.Error.Code,
detail: result.Error.Description);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using EventTicketing.Api.Endpoints;
using EventTicketing.Application;
using EventTicketing.Infrastructure;
using EventTicketing.Infrastructure.Persistence;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration.GetConnectionString("Ticketing")!);

builder.Services.AddProblemDetails();
builder.Services.AddValidation();
builder.Services.AddOpenApi();

var app = builder.Build();

app.UseExceptionHandler();

if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
await app.Services.SeedDatabaseAsync();
}

app.MapEventEndpoints();

app.Run();
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5000",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,5 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"ConnectionStrings": {
"Ticketing": "Data Source=tickets.db"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace EventTicketing.Application.Abstractions;

public interface IUnitOfWork
{
Task SaveChangesAsync(CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using EventTicketing.Application.Events;
using Microsoft.Extensions.DependencyInjection;

namespace EventTicketing.Application;

public static class DependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services)
{
services.AddScoped<ReserveTicketsHandler>();
services.AddScoped<GetEventAvailabilityHandler>();

return services;
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
<ProjectReference Include="..\EventTicketing.Domain\EventTicketing.Domain.csproj" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\ToDoApp.Domain\ToDoApp.Domain.csproj" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.12" />
</ItemGroup>

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
using EventTicketing.Domain.Common;
using EventTicketing.Domain.Events;

namespace EventTicketing.Application.Events;

public sealed record GetEventAvailabilityQuery(int EventId);

public sealed record EventAvailabilityResponse(int EventId, string Name, int Capacity, int TicketsLeft);

public sealed class GetEventAvailabilityHandler(IEventReadRepository reads)
{
public async Task<Result<EventAvailabilityResponse>> HandleAsync(
GetEventAvailabilityQuery query, CancellationToken cancellationToken = default)
{
var availability = await reads.GetAvailabilityAsync(query.EventId, cancellationToken);
if (availability is null)
return EventErrors.NotFound(query.EventId);

return availability;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace EventTicketing.Application.Events;

public interface IEventReadRepository
{
Task<EventAvailabilityResponse?> GetAvailabilityAsync(
int eventId, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using EventTicketing.Domain.Events;

namespace EventTicketing.Application.Events;

public interface IEventRepository
{
Task<Event?> GetByIdAsync(int eventId, CancellationToken cancellationToken = default);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
using EventTicketing.Application.Abstractions;
using EventTicketing.Domain.Common;
using EventTicketing.Domain.Events;

namespace EventTicketing.Application.Events;

public sealed record ReserveTicketsCommand(int EventId, int Quantity);

public sealed record ReservationResponse(int EventId, int TicketsReserved, int TicketsLeft);

public sealed class ReserveTicketsHandler(IEventRepository events, IUnitOfWork unitOfWork)
{
public async Task<Result<ReservationResponse>> HandleAsync(
ReserveTicketsCommand command, CancellationToken cancellationToken = default)
{
var ev = await events.GetByIdAsync(command.EventId, cancellationToken);
if (ev is null)
return EventErrors.NotFound(command.EventId);

var reservation = ev.Reserve(command.Quantity);
if (reservation.IsFailure)
return reservation.Error;

await unitOfWork.SaveChangesAsync(cancellationToken);

return new ReservationResponse(ev.Id, command.Quantity, ev.TicketsLeft);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
using EventTicketing.Application.Events;
using EventTicketing.Domain.Events;
using EventTicketing.Infrastructure.Persistence;
using NetArchTest.Rules;
using TestResult = NetArchTest.Rules.TestResult;

namespace EventTicketing.ArchitectureTests;

public class DependencyRuleTests
{
private const string Application = "EventTicketing.Application";
private const string Infrastructure = "EventTicketing.Infrastructure";
private const string Api = "EventTicketing.Api";

[Fact]
public void Domain_DependsOnNoOtherLayer()
{
var result = Types.InAssembly(typeof(Event).Assembly)
.ShouldNot()
.HaveDependencyOnAny(Application, Infrastructure, Api)
.GetResult();

Assert.True(result.IsSuccessful, Describe(result));
}

[Fact]
public void Application_DoesNotDependOnInfrastructureOrTheWeb()
{
var result = Types.InAssembly(typeof(ReserveTicketsHandler).Assembly)
.ShouldNot()
.HaveDependencyOnAny(Infrastructure, Api, "Microsoft.EntityFrameworkCore", "Microsoft.AspNetCore")
.GetResult();

Assert.True(result.IsSuccessful, Describe(result));
}

[Fact]
public void Infrastructure_DoesNotDependOnTheApi()
{
var result = Types.InAssembly(typeof(TicketingDbContext).Assembly)
.ShouldNot()
.HaveDependencyOn(Api)
.GetResult();

Assert.True(result.IsSuccessful, Describe(result));
}

private static string Describe(TestResult result) =>
"Offending types: " + string.Join(", ", result.FailingTypeNames ?? []);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageReference Include="NetArchTest.Rules" Version="1.3.2" />
<PackageReference Include="xunit.v3.mtp-off" Version="4.0.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="4.0.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\EventTicketing.Api\EventTicketing.Api.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
namespace EventTicketing.Domain.Common;

public sealed class DomainException(string message) : Exception(message);
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace EventTicketing.Domain.Common;

public enum ErrorType
{
Failure,
Validation,
NotFound,
Conflict
}

public sealed record Error(string Code, string Description, ErrorType Type)
{
public static readonly Error None = new(string.Empty, string.Empty, ErrorType.Failure);

public static Error Validation(string code, string description) =>
new(code, description, ErrorType.Validation);

public static Error NotFound(string code, string description) =>
new(code, description, ErrorType.NotFound);

public static Error Conflict(string code, string description) =>
new(code, description, ErrorType.Conflict);
}
Loading
Loading