diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 0000000..3ec55e0 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,19 @@ +changelog: + exclude: + labels: + - notes:ignore + authors: + - dependabot + categories: + - title: 💥 Breaking Changes + labels: + - notes:breaking-change + - title: 🎉 New Features + labels: + - notes:new-feature + - title: 🐞 Bug Fixes + labels: + - notes:bug-fix + - title: 💪 Other Changes + labels: + - "*" \ No newline at end of file diff --git a/.github/workflows/ci_build.yml b/.github/workflows/ci_build.yml index e8ba4d8..6bae9ce 100644 --- a/.github/workflows/ci_build.yml +++ b/.github/workflows/ci_build.yml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json + name: build on: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..ee96bcf --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,87 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json + +name: publish + +on: + workflow_dispatch: # Allow running the workflow manually from the GitHub UI + push: + branches: + - main # Run the workflow when pushing to the main branch + - 'releases/**' # Run the workflow when pushing to a release branch + pull_request: + branches: + - '*' # Run the workflow for all pull requests + release: + types: + - published # Run the workflow when a new GitHub release is published + # Publish to nuget.org will only occur when a new release is published + +env: + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + DOTNET_NOLOGO: true + NuGetDirectory: ${{ github.workspace}}/nuget + +jobs: + create_nuget: + runs-on: ubuntu-latest + defaults: + run: + working-directory: src + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 # Get all history to allow automatic versioning using MinVer + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + + - run: dotnet pack --configuration Release --output ${{ env.NuGetDirectory }} + + # Publish the NuGet packages as an artifact, so they can be used in the following jobs + - uses: actions/upload-artifact@v4 + with: + name: nuget + if-no-files-found: error + retention-days: 7 + path: ${{ env.NuGetDirectory }}/*.nupkg + + run_test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: src + steps: + - uses: actions/checkout@v3 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + - name: Run tests + run: dotnet test --configuration Release + + deploy: + # Publish only when creating a GitHub Release + # https://docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository + # You can update this logic if you want to manage releases differently + if: github.event_name == 'release' + runs-on: ubuntu-latest + defaults: + run: + working-directory: src + needs: [ create_nuget, run_test ] + steps: + # Download the NuGet package created in the previous job + - uses: actions/download-artifact@v4 + with: + name: nuget + path: ${{ env.NuGetDirectory }} + + - name: Setup .NET Core + uses: actions/setup-dotnet@v4 + + # Publish all NuGet packages to NuGet.org + # Use --skip-duplicate to prevent errors if a package with the same version already exists. + # If you retry a failed workflow, already published packages will be skipped without error. + - name: Publish NuGet package + run: | + foreach($file in (Get-ChildItem "${{ env.NuGetDirectory }}" -Recurse -Include *.nupkg)) { + dotnet nuget push $file --api-key "${{ secrets.NUGET_APIKEY }}" --source https://api.nuget.org/v3/index.json --skip-duplicate + } \ No newline at end of file diff --git a/README.md b/README.md index e893a47..886ee6c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,85 @@ -# kernel +# feature[23] Shared Kernel [![build](https://github.com/feature23/kernel/actions/workflows/ci_build.yml/badge.svg)](https://github.com/feature23/kernel/actions/workflows/ci_build.yml) + +A library of reusable types for implementing Clean Architecture in .NET and ASP.NET Core applications, based heavily on the work of [Steve Smith](https://github.com/ardalis) in the following open-sourcee projects: +- [ASP.NET Core Template](https://github.com/ardalis/CleanArchitecture) +- [Shared Kernel](https://github.com/ardalis/Ardalis.SharedKernel) + +For the core functionality, only the `F23.Kernel` library is needed. This library provides types for events, results, query and command handlers, validation, and messaging. For smoother integration with ASP.NET Core, the `F23.Kernel.AspNetCore` library can be used for easily mapping between core result types and ASP.NET Core `IActionResult` and model state. + +> **WARNING:** This library is currently in a pre-release state, and breaking changes may occur before reaching version 1.0. + +## NuGet Installation +### Core Package +```powershell +dotnet add package F23.Kernel +``` + +### ASP.NET Core Helper Package +```powershell +dotnet add package F23.Kernel.AspNetCore +``` + +## Examples + +### Query Handler +```csharp +class GetWeatherForecastQueryResult +{ + public required IReadOnlyList Forecast { get; init; } +} + +class GetWeatherForecastQuery : IQuery +{ + public int DaysIntoTheFuture { get; init; } = 5; +} + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} + +class GetWeatherForecastQueryHandler(IValidator validator, IWeatherForecastRepository repository) + : IQueryHandler +{ + public async Task> Handle(GetWeatherForecastQuery query, CancellationToken cancellationToken = default) + { + if (await validator.Validate(query, cancellationToken) is ValidationFailedResult failed) + { + return Result.ValidationFailed(failed.Errors); + } + + var forecast = await repository.GetForecast(query.DaysIntoTheFuture, cancellationToken); + + var result = new GetWeatherForecastQueryResult + { + Forecast = forecast + }; + + return Result.Success(result); + } +} +``` + +#### Program.cs +```csharp +builder.Services.RegisterQueryHandler(); + +// Other code omitted for brevity + +app.MapGet("/weatherforecast", async (IQueryHandler queryHandler) => + { + var result = await queryHandler.Handle(new GetWeatherForecastQuery()); + + return result.ToMinimalApiResult(); + }) + .WithName("GetWeatherForecast") + .WithOpenApi(); +``` + +### Command Handler +> TODO + +### Event Sourcing +> TODO diff --git a/src/.idea/.idea.F23.Kernel/.idea/vcs.xml b/src/.idea/.idea.F23.Kernel/.idea/vcs.xml index 64713b8..6c0b863 100644 --- a/src/.idea/.idea.F23.Kernel/.idea/vcs.xml +++ b/src/.idea/.idea.F23.Kernel/.idea/vcs.xml @@ -1,7 +1,6 @@ - \ No newline at end of file diff --git a/src/F23.Kernel.AspNetCore/F23.Kernel.AspNetCore.csproj b/src/F23.Kernel.AspNetCore/F23.Kernel.AspNetCore.csproj index 9f0a6b2..d6a6073 100644 --- a/src/F23.Kernel.AspNetCore/F23.Kernel.AspNetCore.csproj +++ b/src/F23.Kernel.AspNetCore/F23.Kernel.AspNetCore.csproj @@ -4,19 +4,37 @@ net8.0 enable enable + 0.1.0 + feature[23] + feature[23] + https://github.com/feature23/kernel + https://github.com/feature23/kernel + https://github.com/feature23/kernel/blob/main/LICENSE + + + + bin\Debug\net8.0\F23.Kernel.AspNetCore.xml + + + + bin\Release\net8.0\F23.Kernel.AspNetCore.xml + + + + - + diff --git a/src/F23.Kernel.AspNetCore/ModelStateExtensions.cs b/src/F23.Kernel.AspNetCore/ModelStateExtensions.cs index a22dd06..de706c4 100644 --- a/src/F23.Kernel.AspNetCore/ModelStateExtensions.cs +++ b/src/F23.Kernel.AspNetCore/ModelStateExtensions.cs @@ -2,9 +2,20 @@ namespace F23.Kernel.AspNetCore; +/// +/// Provides extension methods for working with the class +/// to map to the appropriate structure for HTTP response. +/// public static class ModelStateExtensions { - public static void AddModelErrors(this ModelStateDictionary modelState, string key, IEnumerable errors) + /// + /// Adds multiple validation errors to the for a specified key. + /// + /// The instance where errors will be added. + /// The key to associate with each of the validation errors. + /// The collection of objects to add to the model state. + public static void AddModelErrors(this ModelStateDictionary modelState, string key, + IEnumerable errors) { foreach (var error in errors) { @@ -12,6 +23,11 @@ public static void AddModelErrors(this ModelStateDictionary modelState, string k } } + /// + /// Adds multiple validation errors to the using the keys specified by each error. + /// + /// The instance where errors will be added. + /// The collection of objects to add to the model state. public static void AddModelErrors(this ModelStateDictionary modelState, IEnumerable errors) { foreach (var error in errors) @@ -20,6 +36,11 @@ public static void AddModelErrors(this ModelStateDictionary modelState, IEnumera } } + /// + /// Converts a collection of objects to a populated . + /// + /// The collection of objects to add to the . + /// A instance containing the specified validation errors. public static ModelStateDictionary ToModelState(this IEnumerable errors) { var modelState = new ModelStateDictionary(); diff --git a/src/F23.Kernel.AspNetCore/ResultExtensions.cs b/src/F23.Kernel.AspNetCore/ResultExtensions.cs index 955fdf3..a3ae881 100644 --- a/src/F23.Kernel.AspNetCore/ResultExtensions.cs +++ b/src/F23.Kernel.AspNetCore/ResultExtensions.cs @@ -1,13 +1,37 @@ using System.Net; using F23.Hateoas; using F23.Kernel.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.AspNetCore.Mvc; using UnauthorizedResult = Microsoft.AspNetCore.Mvc.UnauthorizedResult; namespace F23.Kernel.AspNetCore; +/// +/// Provides extension methods for converting objects to objects. +/// public static class ResultExtensions { + /// + /// Converts a into an appropriate + /// that represents the result to be sent in an HTTP response. + /// + /// The to be converted. + /// + /// An representing the HTTP response: + /// + /// A for successful results. + /// A for a indicating . + /// A with HTTP status code 412 for a indicating . + /// A for a indicating . + /// A with model state populated for a . + /// An in case of an . + /// + /// + /// + /// Thrown when the does not match any known result types. + /// public static IActionResult ToActionResult(this Result result) => result switch { @@ -22,6 +46,66 @@ public static IActionResult ToActionResult(this Result result) _ => throw new ArgumentOutOfRangeException(nameof(result)) }; + /// + /// Converts a into an appropriate to be used in a minimal API context. + /// + /// The to be converted. + /// + /// An that represents the appropriate response: + /// - An for a . + /// - A for a with a reason of . + /// - A with status code 412 for a with a reason of . + /// - A for a with a reason of . + /// - A with model state populated for a . + /// - An for an . + /// + /// + /// Thrown when the does not match any known result types. + /// + public static IResult ToMinimalApiResult(this Result result) + => result switch + { + SuccessResult => Microsoft.AspNetCore.Http.Results.NoContent(), + AggregateResult { IsSuccess: true } => Microsoft.AspNetCore.Http.Results.NoContent(), + AggregateResult { IsSuccess: false, Results.Count: > 0 } aggregateResult => + aggregateResult.Results.First(i => !i.IsSuccess).ToMinimalApiResult(), + PreconditionFailedResult { Reason: PreconditionFailedReason.NotFound } => + Microsoft.AspNetCore.Http.Results.NotFound(), + PreconditionFailedResult { Reason: PreconditionFailedReason.ConcurrencyMismatch } => + Microsoft.AspNetCore.Http.Results.StatusCode((int) HttpStatusCode.PreconditionFailed), + PreconditionFailedResult { Reason: PreconditionFailedReason.Conflict } => + Microsoft.AspNetCore.Http.Results.Conflict(), + ValidationFailedResult validationFailed => + Microsoft.AspNetCore.Http.Results.BadRequest(validationFailed.Errors.ToModelState()), + F23.Kernel.Results.UnauthorizedResult => Microsoft.AspNetCore.Http.Results.Unauthorized(), + _ => throw new ArgumentOutOfRangeException(nameof(result)) + }; + + /// + /// Converts a into an appropriate + /// that represents the result to be sent in an HTTP response. + /// + /// The type of the value contained in the result, if successful. + /// The result instance to convert. + /// + /// An optional function to map a successful result to a custom . + /// If not provided, a default mapping is applied. + /// + /// + /// An representing the HTTP response: + /// + /// The result of , if specified, for a . + /// An for a , when is not specified. + /// A for a indicating . + /// A with HTTP status code 412 for a indicating . + /// A for a indicating . + /// A with model state populated for a . + /// An in case of an . + /// + /// + /// + /// Thrown when the does not match any known result types. + /// public static IActionResult ToActionResult(this Result result, Func? successMap = null) => result switch { @@ -34,4 +118,43 @@ public static IActionResult ToActionResult(this Result result, Func => new UnauthorizedResult(), _ => throw new ArgumentOutOfRangeException(nameof(result)) }; + + /// + /// Converts a into an appropriate to be used in a minimal API context. + /// + /// The instance to be converted. + /// + /// An optional function to map the value of a successful result into a user-defined . + /// If not provided, successful results will default to an HTTP 200 response with the value serialized as the body. + /// + /// The type of the result's value. + /// + /// An that represents the appropriate response: + /// - An HTTP 200 (OK) response for a if is not provided. + /// - The result of if provided and the result is a . + /// - An HTTP 404 (NotFound) response for a with a reason of . + /// - An HTTP 412 (PreconditionFailed) response for a with a reason of . + /// - An HTTP 409 (Conflict) response for a with a reason of . + /// - An HTTP 400 (BadRequest) response with model state populated for a . + /// - An HTTP 401 (Unauthorized) response for an . + /// + /// + /// Thrown when the does not match any known result types. + /// + public static IResult ToMinimalApiResult(this Result result, Func? successMap = null) + => result switch + { + SuccessResult success when successMap != null => successMap(success.Value), + SuccessResult success => Microsoft.AspNetCore.Http.Results.Ok(new HypermediaResponse(success.Value)), + PreconditionFailedResult { Reason: PreconditionFailedReason.NotFound } => + Microsoft.AspNetCore.Http.Results.NotFound(), + PreconditionFailedResult { Reason: PreconditionFailedReason.ConcurrencyMismatch } => + Microsoft.AspNetCore.Http.Results.StatusCode((int)HttpStatusCode.PreconditionFailed), + PreconditionFailedResult { Reason: PreconditionFailedReason.Conflict } => + Microsoft.AspNetCore.Http.Results.Conflict(), + ValidationFailedResult validationFailed => + Microsoft.AspNetCore.Http.Results.BadRequest(validationFailed.Errors.ToModelState()), + UnauthorizedResult => Microsoft.AspNetCore.Http.Results.Unauthorized(), + _ => throw new ArgumentOutOfRangeException(nameof(result)), + }; } diff --git a/src/F23.Kernel.Examples.AspNetCore/Core/IWeatherForecastRepository.cs b/src/F23.Kernel.Examples.AspNetCore/Core/IWeatherForecastRepository.cs new file mode 100644 index 0000000..52d94d2 --- /dev/null +++ b/src/F23.Kernel.Examples.AspNetCore/Core/IWeatherForecastRepository.cs @@ -0,0 +1,8 @@ +using F23.Kernel.Examples.AspNetCore.UseCases.GetWeatherForecast; + +namespace F23.Kernel.Examples.AspNetCore.Core; + +internal interface IWeatherForecastRepository +{ + Task> GetForecast(int daysIntoTheFuture, CancellationToken cancellationToken = default); +} diff --git a/src/F23.Kernel.Examples.AspNetCore/F23.Kernel.Examples.AspNetCore.csproj b/src/F23.Kernel.Examples.AspNetCore/F23.Kernel.Examples.AspNetCore.csproj new file mode 100644 index 0000000..c9353ea --- /dev/null +++ b/src/F23.Kernel.Examples.AspNetCore/F23.Kernel.Examples.AspNetCore.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/src/F23.Kernel.Examples.AspNetCore/F23.Kernel.Examples.AspNetCore.http b/src/F23.Kernel.Examples.AspNetCore/F23.Kernel.Examples.AspNetCore.http new file mode 100644 index 0000000..902afa0 --- /dev/null +++ b/src/F23.Kernel.Examples.AspNetCore/F23.Kernel.Examples.AspNetCore.http @@ -0,0 +1,6 @@ +@F23.Kernel.Examples.AspNetCore_HostAddress = http://localhost:5002 + +GET {{F23.Kernel.Examples.AspNetCore_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/src/F23.Kernel.Examples.AspNetCore/Infrastructure/MockWeatherForecastRepository.cs b/src/F23.Kernel.Examples.AspNetCore/Infrastructure/MockWeatherForecastRepository.cs new file mode 100644 index 0000000..9914492 --- /dev/null +++ b/src/F23.Kernel.Examples.AspNetCore/Infrastructure/MockWeatherForecastRepository.cs @@ -0,0 +1,26 @@ +using F23.Kernel.Examples.AspNetCore.Core; +using F23.Kernel.Examples.AspNetCore.UseCases.GetWeatherForecast; + +namespace F23.Kernel.Examples.AspNetCore.Infrastructure; + +internal class MockWeatherForecastRepository : IWeatherForecastRepository +{ + private static readonly string[] Summaries = + [ + "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" + ]; + + public Task> GetForecast(int daysIntoTheFuture, CancellationToken cancellationToken = default) + { + var forecast = Enumerable.Range(1, daysIntoTheFuture).Select(index => + new WeatherForecast + ( + DateOnly.FromDateTime(DateTime.Now.AddDays(index)), + Random.Shared.Next(-20, 55), + Summaries[Random.Shared.Next(Summaries.Length)] + )) + .ToArray(); + + return Task.FromResult>(forecast); + } +} diff --git a/src/F23.Kernel.Examples.AspNetCore/Program.cs b/src/F23.Kernel.Examples.AspNetCore/Program.cs new file mode 100644 index 0000000..e96eccf --- /dev/null +++ b/src/F23.Kernel.Examples.AspNetCore/Program.cs @@ -0,0 +1,37 @@ +using F23.Kernel; +using F23.Kernel.AspNetCore; +using F23.Kernel.Examples.AspNetCore.Core; +using F23.Kernel.Examples.AspNetCore.Infrastructure; +using F23.Kernel.Examples.AspNetCore.UseCases.GetWeatherForecast; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddSingleton(); +builder.Services.RegisterQueryHandler(); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +app.UseHttpsRedirection(); + +app.MapGet("/weatherforecast", async (IQueryHandler queryHandler) => + { + var result = await queryHandler.Handle(new GetWeatherForecastQuery()); + + return result.ToMinimalApiResult(); + }) + .WithName("GetWeatherForecast") + .WithOpenApi(); + +app.Run(); diff --git a/src/F23.Kernel.Examples.AspNetCore/Properties/launchSettings.json b/src/F23.Kernel.Examples.AspNetCore/Properties/launchSettings.json new file mode 100644 index 0000000..111d5cd --- /dev/null +++ b/src/F23.Kernel.Examples.AspNetCore/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:42649", + "sslPort": 44329 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5002", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7009;http://localhost:5002", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/F23.Kernel.Examples.AspNetCore/UseCases/GetWeatherForecast/GetWeatherForecastQuery.cs b/src/F23.Kernel.Examples.AspNetCore/UseCases/GetWeatherForecast/GetWeatherForecastQuery.cs new file mode 100644 index 0000000..f7f4f6d --- /dev/null +++ b/src/F23.Kernel.Examples.AspNetCore/UseCases/GetWeatherForecast/GetWeatherForecastQuery.cs @@ -0,0 +1,16 @@ +namespace F23.Kernel.Examples.AspNetCore.UseCases.GetWeatherForecast; + +class GetWeatherForecastQueryResult +{ + public required IReadOnlyList Forecast { get; init; } +} + +class GetWeatherForecastQuery : IQuery +{ + public int DaysIntoTheFuture { get; init; } = 5; +} + +record WeatherForecast(DateOnly Date, int TemperatureC, string? Summary) +{ + public int TemperatureF => 32 + (int)(TemperatureC / 0.5556); +} diff --git a/src/F23.Kernel.Examples.AspNetCore/UseCases/GetWeatherForecast/GetWeatherForecastQueryHandler.cs b/src/F23.Kernel.Examples.AspNetCore/UseCases/GetWeatherForecast/GetWeatherForecastQueryHandler.cs new file mode 100644 index 0000000..13156fb --- /dev/null +++ b/src/F23.Kernel.Examples.AspNetCore/UseCases/GetWeatherForecast/GetWeatherForecastQueryHandler.cs @@ -0,0 +1,25 @@ +using F23.Kernel.Examples.AspNetCore.Core; +using F23.Kernel.Results; + +namespace F23.Kernel.Examples.AspNetCore.UseCases.GetWeatherForecast; + +internal class GetWeatherForecastQueryHandler(IValidator validator, IWeatherForecastRepository repository) + : IQueryHandler +{ + public async Task> Handle(GetWeatherForecastQuery query, CancellationToken cancellationToken = default) + { + if (await validator.Validate(query, cancellationToken) is ValidationFailedResult failed) + { + return Result.ValidationFailed(failed.Errors); + } + + var forecast = await repository.GetForecast(query.DaysIntoTheFuture, cancellationToken); + + var result = new GetWeatherForecastQueryResult + { + Forecast = forecast + }; + + return Result.Success(result); + } +} diff --git a/src/F23.Kernel.Examples.AspNetCore/appsettings.Development.json b/src/F23.Kernel.Examples.AspNetCore/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/F23.Kernel.Examples.AspNetCore/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/F23.Kernel.Examples.AspNetCore/appsettings.json b/src/F23.Kernel.Examples.AspNetCore/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/src/F23.Kernel.Examples.AspNetCore/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/F23.Kernel.Tests/AspNetCore/ModelStateExtensionsTests.cs b/src/F23.Kernel.Tests/AspNetCore/ModelStateExtensionsTests.cs index 8f5bd1c..549c451 100644 --- a/src/F23.Kernel.Tests/AspNetCore/ModelStateExtensionsTests.cs +++ b/src/F23.Kernel.Tests/AspNetCore/ModelStateExtensionsTests.cs @@ -21,8 +21,10 @@ public void AddModelErrors_AddsAllValidationErrors() // Assert Assert.Equal(2, modelState.ErrorCount); - Assert.Equal("error1", modelState["key1"].Errors.First().ErrorMessage); - Assert.Equal("error2", modelState["key2"].Errors.First().ErrorMessage); + Assert.True(modelState.TryGetValue("key1", out var error1)); + Assert.Equal("error1", error1.Errors.First().ErrorMessage); + Assert.True(modelState.TryGetValue("key2", out var error2)); + Assert.Equal("error2", error2.Errors.First().ErrorMessage); } [Fact] @@ -63,7 +65,9 @@ public void ToModelState_ReturnsModelStateWithAllValidationErrors() // Assert Assert.Equal(2, modelState.ErrorCount); - Assert.Equal("error1", modelState["key1"].Errors.First().ErrorMessage); - Assert.Equal("error2", modelState["key2"].Errors.First().ErrorMessage); + Assert.True(modelState.TryGetValue("key1", out var error1)); + Assert.Equal("error1", error1.Errors.First().ErrorMessage); + Assert.True(modelState.TryGetValue("key2", out var error2)); + Assert.Equal("error2", error2.Errors.First().ErrorMessage); } } diff --git a/src/F23.Kernel.Tests/AspNetCore/ResultExtensionsMinimalApiTests.cs b/src/F23.Kernel.Tests/AspNetCore/ResultExtensionsMinimalApiTests.cs new file mode 100644 index 0000000..18dcb88 --- /dev/null +++ b/src/F23.Kernel.Tests/AspNetCore/ResultExtensionsMinimalApiTests.cs @@ -0,0 +1,267 @@ +using System.Net; +using F23.Hateoas; +using F23.Kernel.AspNetCore; +using F23.Kernel.Results; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace F23.Kernel.Tests.AspNetCore; + +public class ResultExtensionsMinimalApiTests +{ + [Fact] + public void SuccessResult_Returns_NoContentResult() + { + // Arrange + var result = new SuccessResult(); + + // Act + var actionResult = result.ToMinimalApiResult(); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void AggregateResult_Success_Returns_NoContentResult() + { + // Arrange + var result = new AggregateResult([new SuccessResult()]); + + // Act + var actionResult = result.ToMinimalApiResult(); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void AggregateResult_Failure_Returns_First_Failed_Result() + { + // Arrange + var result = new AggregateResult([ + new SuccessResult(), + new PreconditionFailedResult(PreconditionFailedReason.NotFound, null) + ]); + + // Act + var actionResult = result.ToMinimalApiResult(); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void PreconditionFailedResult_NotFound_Returns_NotFoundResult() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.NotFound, null); + + // Act + var actionResult = result.ToMinimalApiResult(); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void PreconditionFailedResult_ConcurrencyMismatch_Returns_StatusCodeResult() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.ConcurrencyMismatch, null); + + // Act + var actionResult = result.ToMinimalApiResult(); + + // Assert + var statusCodeResult = Assert.IsType(actionResult); + Assert.Equal((int) HttpStatusCode.PreconditionFailed, statusCodeResult.StatusCode); + } + + [Fact] + public void PreconditionFailedResult_Conflict_Returns_ConflictResult() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.Conflict, null); + + // Act + var actionResult = result.ToMinimalApiResult(); + + // Assert + Assert.IsType(actionResult); + } + + [Fact(Skip = "Need better implementation of BadRequest with ModelState for minimal APIs")] + public void ValidationFailedResult_Returns_BadRequestObjectResult() + { + // Arrange + var result = new ValidationFailedResult(new List + { + new("Key", "Message") + }); + + // Act + var actionResult = result.ToMinimalApiResult(); + + // Assert + var badRequestObjectResult = Assert.IsType>(actionResult); + var modelState = Assert.IsType(badRequestObjectResult.Value); + Assert.True(modelState.TryGetValue("Key", out var value)); + var errors = Assert.IsType(value); + var message = Assert.Single(errors); + Assert.Equal("Message", message); + } + + [Fact] + public void UnauthorizedResult_Returns_UnauthorizedResult() + { + // Arrange + var result = new F23.Kernel.Results.UnauthorizedResult("NONE SHALL PASS"); + + // Act + var actionResult = result.ToMinimalApiResult(); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void SuccessResultT_Returns_OkObjectResult() + { + // Arrange + var result = new SuccessResult("Hello, World!"); + + // Act + var actionResult = result.ToMinimalApiResult(); + + // Assert + var okObjectResult = Assert.IsType>(actionResult); + var hypermediaResponse = Assert.IsType(okObjectResult.Value); + Assert.Equal("Hello, World!", hypermediaResponse.Content); + } + + [Fact] + public void SuccessResultT_With_SuccessMap_Returns_SuccessMapResult() + { + // Arrange + var result = new SuccessResult("Hello, World!"); + + // Act + var actionResult = result.ToMinimalApiResult(Microsoft.AspNetCore.Http.Results.Ok); + + // Assert + var okObjectResult = Assert.IsType>(actionResult); + Assert.Equal("Hello, World!", okObjectResult.Value); + } + + [Fact] + public void PreconditionFailedResultT_NotFound_Returns_NotFoundResult() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.NotFound, null); + + // Act + var actionResult = result.ToMinimalApiResult(); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void PreconditionFailedResultT_ConcurrencyMismatch_Returns_StatusCodeResult() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.ConcurrencyMismatch, null); + + // Act + var actionResult = result.ToMinimalApiResult(); + + // Assert + var statusCodeResult = Assert.IsType(actionResult); + Assert.Equal((int) HttpStatusCode.PreconditionFailed, statusCodeResult.StatusCode); + } + + [Fact] + public void PreconditionFailedResultT_Conflict_Returns_ConflictResult() + { + // Arrange + var result = new PreconditionFailedResult(PreconditionFailedReason.Conflict, null); + + // Act + var actionResult = result.ToMinimalApiResult(); + + // Assert + Assert.IsType(actionResult); + } + + [Fact(Skip = "Need better implementation of BadRequest with ModelState for minimal APIs")] + public void ValidationFailedResultT_Returns_BadRequestObjectResult() + { + // Arrange + var result = new ValidationFailedResult(new List + { + new("Key", "Message") + }); + + // Act + var actionResult = result.ToMinimalApiResult(); + + // Assert + var badRequestObjectResult = Assert.IsType>(actionResult); + var modelState = Assert.IsType(badRequestObjectResult.Value); + Assert.True(modelState.TryGetValue("Key", out var value)); + var errors = Assert.IsType(value); + var message = Assert.Single(errors); + Assert.Equal("Message", message); + } + + [Fact] + public void UnauthorizedResultT_Returns_UnauthorizedResult() + { + // Arrange + var result = new UnauthorizedResult("NONE SHALL PASS"); + + // Act + var actionResult = result.ToMinimalApiResult(); + + // Assert + Assert.IsType(actionResult); + } + + [Fact] + public void ToMinimalApiResult_Throws_ArgumentOutOfRangeException_For_Unhandled_Result() + { + // Arrange + var result = new TestUnhandledResult(); + + // Act + void Act() => result.ToMinimalApiResult(); + + // Assert + Assert.Throws(Act); + } + + [Fact] + public void ToMinimalApiResultT_Throws_ArgumentOutOfRangeException_For_Unhandled_Result() + { + // Arrange + var result = new TestUnhandledResult(); + + // Act + void Act() => result.ToMinimalApiResult(); + + // Assert + Assert.Throws(Act); + } + + private class TestUnhandledResult() : Result(true) + { + public override string Message => "whoopsie"; + } + + private class TestUnhandledResult() : Result(true) + { + public override string Message => "whoopsie"; + } +} diff --git a/src/F23.Kernel.Tests/DependencyInjectionDomainEventDispatcherTests.cs b/src/F23.Kernel.Tests/DependencyInjectionDomainEventDispatcherTests.cs new file mode 100644 index 0000000..4257b6a --- /dev/null +++ b/src/F23.Kernel.Tests/DependencyInjectionDomainEventDispatcherTests.cs @@ -0,0 +1,38 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace F23.Kernel.Tests; + +public class DependencyInjectionDomainEventDispatcherTests +{ + [Fact] + public async Task Dispatch_WithHandlers_DispatchesToHandlers() + { + // Arrange + var handler = new TestDomainEventHandler(); + var serviceProvider = new ServiceCollection() + .AddTransient>(sp => handler) + .BuildServiceProvider(); + + var dispatcher = new DependencyInjectionDomainEventDispatcher(serviceProvider); + var domainEvent = new TestDomainEvent(); + + // Act + await dispatcher.Dispatch(domainEvent); + + // Assert + Assert.Equal(1, handler.HandleCount); + } + + private class TestDomainEvent : IDomainEvent; + + private class TestDomainEventHandler : IEventHandler + { + public int HandleCount { get; private set; } + + public Task Handle(TestDomainEvent domainEvent, CancellationToken cancellationToken = default) + { + HandleCount++; + return Task.CompletedTask; + } + } +} diff --git a/src/F23.Kernel.Tests/Mocks/TestResultContent.cs b/src/F23.Kernel.Tests/Mocks/TestResultContent.cs new file mode 100644 index 0000000..3b89d69 --- /dev/null +++ b/src/F23.Kernel.Tests/Mocks/TestResultContent.cs @@ -0,0 +1,3 @@ +namespace F23.Kernel.Tests.Mocks; + +public class TestResultContent; diff --git a/src/F23.Kernel.Tests/Mocks/UnknownResult.cs b/src/F23.Kernel.Tests/Mocks/UnknownResult.cs new file mode 100644 index 0000000..0b0b2c3 --- /dev/null +++ b/src/F23.Kernel.Tests/Mocks/UnknownResult.cs @@ -0,0 +1,6 @@ +namespace F23.Kernel.Tests.Mocks; + +public class UnknownResult() : Result(false) +{ + public override string Message => "Unknown result"; +} diff --git a/src/F23.Kernel.Tests/ResultFactoryMethodTests.cs b/src/F23.Kernel.Tests/ResultFactoryMethodTests.cs index b90b80e..2377e75 100644 --- a/src/F23.Kernel.Tests/ResultFactoryMethodTests.cs +++ b/src/F23.Kernel.Tests/ResultFactoryMethodTests.cs @@ -1,4 +1,5 @@ using F23.Kernel.Results; +using F23.Kernel.Tests.Mocks; namespace F23.Kernel.Tests; @@ -115,6 +116,22 @@ public void ValidationFailed_SingleError_ReturnsValidationFailedResult() Assert.Equal(message, error.Message); } + [Fact] + public void ValidationFailedT_SharedKey_ReturnsValidationFailedResult() + { + // Arrange + var errors = new List { new("key", "message") }; + + // Act + var result = Result.ValidationFailed("shared_key", errors); + + // Assert + var validationFailedResult = Assert.IsType>(result); + var error = Assert.Single(validationFailedResult.Errors); + Assert.Equal("shared_key", error.Key); + Assert.Equal("message", error.Message); + } + [Fact] public void ValidationFailedT_SingleError_ReturnsValidationFailedResult() { @@ -163,8 +180,4 @@ public void PreconditionFailedT_ReturnsPreconditionFailedResult() Assert.Equal(reason, preconditionFailedResult.Reason); Assert.Equal(message, preconditionFailedResult.Message); } - - // TODO.JB - test mapping methods - - private class TestResultContent; } diff --git a/src/F23.Kernel.Tests/ResultLoggingTests.cs b/src/F23.Kernel.Tests/ResultLoggingTests.cs new file mode 100644 index 0000000..2d3b224 --- /dev/null +++ b/src/F23.Kernel.Tests/ResultLoggingTests.cs @@ -0,0 +1,95 @@ +using F23.Kernel.Results; +using F23.Kernel.Tests.Mocks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace F23.Kernel.Tests; + +public class ResultLoggingTests +{ + [Fact] + public void LogFailure_SuccessResult_Throws() + { + // Arrange + var result = Result.Success(new TestResultContent()); + + // Act + void Act() => result.LogFailure(NullLogger.Instance); + + // Assert + Assert.Throws(Act); + } + + [Fact] + public void LogFailure_ValidationFailedResult_Logs() + { + // Arrange + var logger = new TestLogger(); + var result = Result.ValidationFailed("key", "message"); + + // Act + result.LogFailure(logger); + + // Assert + var message = Assert.Single(logger.Messages); + Assert.Equal("Validation failed: ValidationError { Key = key, Message = message }", message); + } + + [Fact] + public void LogFailure_UnauthorizedResult_Logs() + { + // Arrange + var logger = new TestLogger(); + var result = Result.Unauthorized("YOU SHALL NOT PASS"); + + // Act + result.LogFailure(logger); + + // Assert + var message = Assert.Single(logger.Messages); + Assert.Equal("Unauthorized: YOU SHALL NOT PASS", message); + } + + [Fact] + public void LogFailure_PreconditionFailedResult_Logs() + { + // Arrange + var logger = new TestLogger(); + var result = Result.PreconditionFailed(PreconditionFailedReason.NotFound, "Not found"); + + // Act + result.LogFailure(logger); + + // Assert + var message = Assert.Single(logger.Messages); + Assert.Equal("Precondition failed: NotFound", message); + } + + [Fact] + public void LogFailure_UnknownResultType_Throws() + { + // Arrange + var logger = new TestLogger(); + var result = new UnknownResult(); + + // Act + void Act() => result.LogFailure(logger); + + // Assert + Assert.Throws(Act); + } + + private class TestLogger : ILogger + { + public List Messages { get; } = []; + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + Messages.Add(formatter(state, exception)); + } + } +} diff --git a/src/F23.Kernel.Tests/ResultMappingTests.cs b/src/F23.Kernel.Tests/ResultMappingTests.cs new file mode 100644 index 0000000..9973569 --- /dev/null +++ b/src/F23.Kernel.Tests/ResultMappingTests.cs @@ -0,0 +1,91 @@ +using F23.Kernel.Results; +using F23.Kernel.Tests.Mocks; + +namespace F23.Kernel.Tests; + +public class ResultMappingTests +{ + [Fact] + public void Map_Success_ReturnsSuccessResult() + { + // Arrange + var result = Result.Success(new TestResultContent()); + + // Act + var mappedResult = result.Map(r => 42); + + // Assert + var typedResult = Assert.IsType>(mappedResult); + Assert.Equal(42, typedResult.Value); + } + + [Fact] + public void MapFailure_SuccessResult_Throws() + { + // Arrange + var result = Result.Success(new TestResultContent()); + + // Act + void Act() => result.MapFailure(); + + // Assert + Assert.Throws(Act); + } + + [Fact] + public void MapFailure_ValidationFailedResult_MapsCorrectly() + { + // Arrange + var result = Result.ValidationFailed("key", "message"); + + // Act + var mappedResult = result.Map(r => 42); + + // Assert + var typedResult = Assert.IsType>(mappedResult); + var error = Assert.Single(typedResult.Errors); + Assert.Equal("key", error.Key); + Assert.Equal("message", error.Message); + } + + [Fact] + public void MapFailure_UnauthorizedResult_MapsCorrectly() + { + // Arrange + var result = Result.Unauthorized("YOU SHALL NOT PASS"); + + // Act + var mappedResult = result.Map(r => 42); + + // Assert + var typedResult = Assert.IsType>(mappedResult); + Assert.Equal("YOU SHALL NOT PASS", typedResult.Message); + } + + [Fact] + public void MapFailure_PreconditionFailedResult_MapsCorrectly() + { + // Arrange + var result = Result.PreconditionFailed(PreconditionFailedReason.NotFound, "message"); + + // Act + var mappedResult = result.Map(r => 42); + + // Assert + var typedResult = Assert.IsType>(mappedResult); + Assert.Equal(PreconditionFailedReason.NotFound, typedResult.Reason); + } + + [Fact] + public void MapFailure_UnknownType_Throws() + { + // Arrange + var result = new UnknownResult(); + + // Act + void Act() => result.MapFailure(); + + // Assert + Assert.Throws(Act); + } +} diff --git a/src/F23.Kernel.sln b/src/F23.Kernel.sln index 1ffdb69..5cc7143 100644 --- a/src/F23.Kernel.sln +++ b/src/F23.Kernel.sln @@ -12,6 +12,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution ..\README.md = ..\README.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "F23.Kernel.Examples.AspNetCore", "F23.Kernel.Examples.AspNetCore\F23.Kernel.Examples.AspNetCore.csproj", "{7750AC72-F374-4151-B920-58AB7BC200F6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -30,5 +32,9 @@ Global {4C2F53E9-5CA4-47E9-AB1D-5148F4C4A6C3}.Debug|Any CPU.Build.0 = Debug|Any CPU {4C2F53E9-5CA4-47E9-AB1D-5148F4C4A6C3}.Release|Any CPU.ActiveCfg = Release|Any CPU {4C2F53E9-5CA4-47E9-AB1D-5148F4C4A6C3}.Release|Any CPU.Build.0 = Release|Any CPU + {7750AC72-F374-4151-B920-58AB7BC200F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7750AC72-F374-4151-B920-58AB7BC200F6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7750AC72-F374-4151-B920-58AB7BC200F6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7750AC72-F374-4151-B920-58AB7BC200F6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/src/F23.Kernel/DataAnnotationsValidator.cs b/src/F23.Kernel/DataAnnotationsValidator.cs index f61bf6a..8fa4b73 100644 --- a/src/F23.Kernel/DataAnnotationsValidator.cs +++ b/src/F23.Kernel/DataAnnotationsValidator.cs @@ -4,9 +4,19 @@ namespace F23.Kernel; +/// +/// A validator that uses attributes to validate an object. +/// +/// The type of the object to be validated. public class DataAnnotationsValidator : IValidator where T : notnull { + /// + /// Validates the specified object. + /// + /// The object to validate. + /// A token to monitor for cancellation requests. + /// A task that represents the asynchronous validation operation. The task result contains the validation result. public Task Validate(T value, CancellationToken cancellationToken = default) { var context = new ValidationContext(value); @@ -19,4 +29,4 @@ public Task Validate(T value, CancellationToken cancellationTo .Select(i => new ValidationError(i.MemberName, i.ErrorMessage ?? "Invalid value")) .ToList())); } -} \ No newline at end of file +} diff --git a/src/F23.Kernel/DependencyInjectionDomainEventDispatcher.cs b/src/F23.Kernel/DependencyInjectionDomainEventDispatcher.cs index 8eca374..dd0d785 100644 --- a/src/F23.Kernel/DependencyInjectionDomainEventDispatcher.cs +++ b/src/F23.Kernel/DependencyInjectionDomainEventDispatcher.cs @@ -1,7 +1,17 @@ namespace F23.Kernel; +/// +/// A dispatcher that uses dependency injection to dispatch domain events to handlers resolved from an . +/// +/// The service provider used to resolve event handlers. public class DependencyInjectionDomainEventDispatcher(IServiceProvider serviceProvider) : IDomainEventDispatcher { + /// + /// Dispatches the specified domain event to all registered handlers. + /// + /// The type of the domain event. + /// The domain event to dispatch. + /// A task that represents the asynchronous dispatch operation. public async Task Dispatch(T domainEvent) where T : IDomainEvent { var handlers = serviceProvider.GetServices>(); diff --git a/src/F23.Kernel/EventSourcing/EventBase.cs b/src/F23.Kernel/EventSourcing/EventBase.cs index c48dca4..3c2c47e 100644 --- a/src/F23.Kernel/EventSourcing/EventBase.cs +++ b/src/F23.Kernel/EventSourcing/EventBase.cs @@ -2,13 +2,24 @@ namespace F23.Kernel.EventSourcing; +/// +/// Represents the base class for all events in the event sourcing system. +/// public abstract record EventBase : IEvent { + /// + /// Gets the type of the event. + /// public string EventType => GetType().Name; - public string? UserProfileId { get; set; } - + /// + /// Gets or sets the date and time when the event occurred. Defaults to the current UTC date and time. + /// public DateTime OccurredAt { get; set; } = DateTime.UtcNow; + /// + /// Validates the event. + /// + /// A representing the result of the validation. public abstract ValidationResult Validate(); } diff --git a/src/F23.Kernel/EventSourcing/EventStream.cs b/src/F23.Kernel/EventSourcing/EventStream.cs index 1e7cd6a..b789559 100644 --- a/src/F23.Kernel/EventSourcing/EventStream.cs +++ b/src/F23.Kernel/EventSourcing/EventStream.cs @@ -5,6 +5,10 @@ namespace F23.Kernel.EventSourcing; /// public static class EventStream; +/// +/// Represents an event stream for an aggregate root, managing both committed and uncommitted events. +/// +/// The type of the aggregate root. public class EventStream : ISnapshotContainer where T : IAggregateRoot { @@ -34,16 +38,35 @@ public EventStream(T snapshot, IEnumerable events) _lastCommittedSnapshot = snapshot; } + /// + /// Gets the ID of the snapshot. + /// public string Id => Snapshot.Id; + /// + /// Gets the list of committed events. + /// public IReadOnlyList CommittedEvents => _committedEvents; + /// + /// Gets the list of uncommitted events. + /// public IReadOnlyList UncommittedEvents => _uncommittedEvents; + /// + /// Gets all events, both committed and uncommitted. + /// public IEnumerable AllEvents => _committedEvents.Concat(_uncommittedEvents); + /// + /// Gets the current snapshot of the aggregate root. + /// public T Snapshot { get; private set; } + /// + /// Applies an event to the aggregate root. + /// + /// The event to apply. public void Apply(IEvent e) { if (e is ICreationEvent) @@ -64,6 +87,9 @@ public void Apply(IEvent e) _uncommittedEvents.Add(e); } + /// + /// Commits all uncommitted events. + /// public void Commit() { _committedEvents.AddRange(_uncommittedEvents); @@ -71,11 +97,19 @@ public void Commit() _lastCommittedSnapshot = Snapshot; } + /// + /// Rolls back all uncommitted events. + /// public void Rollback() { Snapshot = _lastCommittedSnapshot; _uncommittedEvents.Clear(); } + /// + /// Gets the last committed snapshot for unit testing purposes. + /// + /// SHOULD NOT BE USED IN PRODUCTION CODE. + /// internal T LastCommittedSnapshot_FOR_UNIT_TESTING => _lastCommittedSnapshot; } diff --git a/src/F23.Kernel/EventSourcing/EventValidatorSwitcher.cs b/src/F23.Kernel/EventSourcing/EventValidatorSwitcher.cs index 8760c6e..7176fb5 100644 --- a/src/F23.Kernel/EventSourcing/EventValidatorSwitcher.cs +++ b/src/F23.Kernel/EventSourcing/EventValidatorSwitcher.cs @@ -2,8 +2,31 @@ namespace F23.Kernel.EventSourcing; +/// +/// The EventValidatorSwitcher class handles the validation of events +/// within an event stream using dependency injection to find and invoke +/// the appropriate validators based on the event type. +/// +/// An for resolving validators. +/// +/// This class resolves event-specific validators via dependency injection, +/// validates the given event, and aggregates validation errors, if any, +/// from multiple applicable validators. +/// public class EventValidatorSwitcher(IServiceProvider serviceProvider) { + /// + /// Validates the provided event within the context of an event stream, utilizing all applicable validators. + /// + /// The type of the aggregate root associated with the event stream. + /// The event stream containing the aggregate root and its associated events. + /// The event to validate. + /// A token to monitor for cancellation requests. + /// + /// A task that represents the asynchronous operation. The task result contains a + /// indicating whether the validation passed or failed. + /// If any errors occur during validation, they will be returned as part of a failed result. + /// public async Task Validate(EventStream eventStream, IEvent e, CancellationToken cancellationToken = default) diff --git a/src/F23.Kernel/EventSourcing/IAggregateRoot.cs b/src/F23.Kernel/EventSourcing/IAggregateRoot.cs index 038a3e6..786a666 100644 --- a/src/F23.Kernel/EventSourcing/IAggregateRoot.cs +++ b/src/F23.Kernel/EventSourcing/IAggregateRoot.cs @@ -1,6 +1,13 @@ namespace F23.Kernel.EventSourcing; +/// +/// Represents the root interface for a data model aggregate. +/// Aggregates are clusters of domain objects that are treated as a single unit for data changes. +/// public interface IAggregateRoot : IValidatable { + /// + /// Gets the unique identifier of the aggregate root. + /// string Id { get; } } diff --git a/src/F23.Kernel/EventSourcing/IAggregateRootFactory.cs b/src/F23.Kernel/EventSourcing/IAggregateRootFactory.cs index 44cda7f..9378035 100644 --- a/src/F23.Kernel/EventSourcing/IAggregateRootFactory.cs +++ b/src/F23.Kernel/EventSourcing/IAggregateRootFactory.cs @@ -1,8 +1,30 @@ namespace F23.Kernel.EventSourcing; +/// +/// Represents a factory interface for creating instances of aggregate roots. +/// Aggregate roots are the primary entry points for interactions with aggregates, +/// which are clusters of domain objects treated as a single unit according to +/// business logic. +/// +/// +/// The type of the aggregate root to be created. Must implement . +/// +/// +/// The type of the creation event used for initializing the aggregate root. Must +/// implement . +/// public interface IAggregateRootFactory where T : IAggregateRoot where TCreationEvent : ICreationEvent { + /// + /// Creates an aggregate root instance of type based on the specified creation event. + /// + /// The event that contains the information needed to create the aggregate root. + /// An optional cancellation token to observe during the asynchronous creation process. + /// + /// A task that represents the asynchronous operation. The task result contains a + /// which is either a successful result containing the created aggregate root instance or a failure result. + /// Task> Create(TCreationEvent creationEvent, CancellationToken cancellationToken = default); } diff --git a/src/F23.Kernel/EventSourcing/IAggregateRootValidatableEvent.cs b/src/F23.Kernel/EventSourcing/IAggregateRootValidatableEvent.cs index 30f5e64..1b8728f 100644 --- a/src/F23.Kernel/EventSourcing/IAggregateRootValidatableEvent.cs +++ b/src/F23.Kernel/EventSourcing/IAggregateRootValidatableEvent.cs @@ -2,8 +2,17 @@ namespace F23.Kernel.EventSourcing; +/// +/// Represents an event that can be validated against a specific state of an aggregate root. +/// +/// The type of the aggregate root against which the event is validated. It must implement . public interface IAggregateRootValidatableEvent where TAggregateRoot : IAggregateRoot { + /// + /// Validates the provided aggregate root and returns a validation result. + /// + /// The aggregate root instance to validate. + /// A indicating whether the validation passed or failed. ValidationResult Validate(TAggregateRoot aggregateRoot); } diff --git a/src/F23.Kernel/EventSourcing/IApplyEvent.cs b/src/F23.Kernel/EventSourcing/IApplyEvent.cs index 2a09d51..985f0e0 100644 --- a/src/F23.Kernel/EventSourcing/IApplyEvent.cs +++ b/src/F23.Kernel/EventSourcing/IApplyEvent.cs @@ -1,9 +1,26 @@ namespace F23.Kernel.EventSourcing; +/// +/// Defines a contract for a type that can apply a specific event and produce a new aggregate root state. +/// +/// +/// The type of the aggregate root to which the event is applied. +/// +/// +/// The type of the event to be applied to the aggregate root. +/// +/// +/// While not enforceable by the type system, this interface is intended to be implemented by +/// aggregate root types, so that they can transform their current state into new state based on the event. +/// public interface IApplyEvent where TEvent : IEvent where TSnapshot : IAggregateRoot { + /// + /// Applies the specified event to the aggregate root and updates its state accordingly. + /// + /// The event to be applied to the aggregate root. [UsedImplicitly] TSnapshot Apply(TEvent e); } diff --git a/src/F23.Kernel/EventSourcing/ICreationEvent.cs b/src/F23.Kernel/EventSourcing/ICreationEvent.cs index ee51a63..1c8f0e8 100644 --- a/src/F23.Kernel/EventSourcing/ICreationEvent.cs +++ b/src/F23.Kernel/EventSourcing/ICreationEvent.cs @@ -1,3 +1,10 @@ namespace F23.Kernel.EventSourcing; +/// +/// Represents an event that is used to create an aggregate root. +/// +/// +/// This interface is a marker for creation events within the event-sourcing pattern. +/// It should be used for defining events that establish the initial state of an aggregate root. +/// public interface ICreationEvent : IEvent; diff --git a/src/F23.Kernel/EventSourcing/ICreationEventValidator.cs b/src/F23.Kernel/EventSourcing/ICreationEventValidator.cs index 48bcc1a..598add3 100644 --- a/src/F23.Kernel/EventSourcing/ICreationEventValidator.cs +++ b/src/F23.Kernel/EventSourcing/ICreationEventValidator.cs @@ -1,4 +1,8 @@ namespace F23.Kernel.EventSourcing; +/// +/// Defines the contract for a validator that validates events used to create aggregate roots. +/// +/// The type of the creation event to be validated. Must implement . public interface ICreationEventValidator : IValidator where TEvent : ICreationEvent; diff --git a/src/F23.Kernel/EventSourcing/IEvent.cs b/src/F23.Kernel/EventSourcing/IEvent.cs index 44a045f..47fd3c2 100644 --- a/src/F23.Kernel/EventSourcing/IEvent.cs +++ b/src/F23.Kernel/EventSourcing/IEvent.cs @@ -1,10 +1,31 @@ namespace F23.Kernel.EventSourcing; +/// +/// Represents a domain event that is part of an event-sourced system. +/// +/// +/// This interface combines the functionality of a domain event marker with the ability to validate itself. +/// It also provides metadata about the event. +/// Implementors should define specific event types and their associated data. +/// public interface IEvent : IDomainEvent, IValidatable { + /// + /// Gets the type of the event as a string representation. + /// + /// + /// This property is used to identify the specific event type associated with the implementation. + /// The value is generally set as the name of the implementing class or a predefined string. + /// string EventType { get; } - string? UserProfileId { get; set; } - + /// + /// Gets or sets the date and time at which the event occurred. + /// + /// + /// This property is typically used to indicate when the event was raised + /// or recorded within an event sourcing system. The value is expected + /// to be in UTC format. + /// DateTime OccurredAt { get; set; } } diff --git a/src/F23.Kernel/EventSourcing/IEventStreamRepo.cs b/src/F23.Kernel/EventSourcing/IEventStreamRepo.cs index 1c04e67..d577e30 100644 --- a/src/F23.Kernel/EventSourcing/IEventStreamRepo.cs +++ b/src/F23.Kernel/EventSourcing/IEventStreamRepo.cs @@ -2,22 +2,83 @@ namespace F23.Kernel.EventSourcing; +/// +/// Represents a repository for managing event streams associated with aggregate roots. +/// Provides read/write operations to manage event streams and query capabilities for snapshots. +/// Requires a generic type parameter that implements . +/// +/// The type of the aggregate root associated with the event streams. public interface IEventStreamRepo where T : IAggregateRoot { + /// + /// Adds a new event stream to the repository. + /// + /// The event stream to be added, containing the aggregate root's snapshot and its events. + /// The identifier of the user performing the operation. Optional parameter. + /// A cancellation token that can be used to cancel the request. Defaults to . + /// A task representing the asynchronous operation. Task AddNewEventStream(EventStream stream, string? userId, CancellationToken cancellationToken = default); + /// + /// Commits an event stream by marking all uncommitted events in the stream as committed and persisting them to the data store. + /// + /// The event stream containing the uncommitted events to be committed. + /// The identifier of the user performing the operation. This parameter is optional. + /// A cancellation token that can be used to cancel the asynchronous operation. Defaults to . + /// A task representing the asynchronous operation. Task CommitEventStream(EventStream stream, string? userId, CancellationToken cancellationToken = default); + /// + /// Retrieves only the snapshot of an event stream identified by the given id. + /// + /// The unique identifier of the event stream to retrieve. + /// A cancellation token that can be used to cancel the asynchronous operation. Defaults to . + /// + /// A task that represents the asynchronous operation. The task result contains an + /// representing the snapshot of the event stream, or null if the event stream does not exist. + /// Task?> GetSnapshotOnly(string id, CancellationToken cancellationToken = default); + /// + /// Retrieves the full event stream, including committed events, for a specified aggregate root by its identifier. + /// + /// The unique identifier of the aggregate root for which the event stream is being retrieved. + /// A cancellation token that can be used to cancel the asynchronous operation. Defaults to . + /// + /// A task that represents the asynchronous operation. The task result contains the event stream associated with the specified identifier, + /// or null if the event stream does not exist. + /// Task?> GetFullEventStream(string id, CancellationToken cancellationToken = default); + /// + /// Queries snapshots of the aggregate root type based on the given predicate and optional pagination parameters. + /// + /// An expression that defines the condition to filter the snapshots. + /// The optional number of the page to retrieve. If null, no pagination is applied. + /// The optional number of items per page. If null, no pagination is applied. + /// A cancellation token that can be used to cancel the asynchronous operation. Defaults to . + /// + /// A task that represents the asynchronous operation. The task result contains a read-only list of snapshots + /// that match the provided predicate and pagination criteria. + /// Task> QuerySnapshots(Expression> predicate, int? pageNumber = null, int? pageSize = null, CancellationToken cancellationToken = default); + /// + /// Queries a collection of snapshots of the aggregate root type, applying the specified filter, + /// sorting, and pagination parameters. + /// + /// The type used for ordering the query result. + /// An expression defining the filter to apply to the query. + /// An expression specifying the property to order the results by. + /// A boolean value determining the sort order. If true, results will be ordered descending; otherwise, ascending. + /// The page number to retrieve, used for pagination. Null indicates no pagination. + /// The size of the page to retrieve, used for pagination. Null indicates no pagination. + /// A cancellation token that can be used to cancel the asynchronous operation. Defaults to . + /// A task that represents the asynchronous operation. The task result contains a read-only list of snapshots matching the filter, sorted, and paginated as specified. Task> QuerySnapshots(Expression> predicate, Expression> orderBy, bool descending = false, @@ -25,8 +86,22 @@ Task> QuerySnapshots(Expression> predic int? pageSize = null, CancellationToken cancellationToken = default); + /// + /// Counts the number of snapshots that satisfy the specified predicate. + /// + /// A query expression used to filter snapshots based on specified criteria. + /// A cancellation token that can be used to cancel the asynchronous operation. Defaults to . + /// + /// A task that represents the asynchronous operation. The task result contains the total count of snapshots matching the specified predicate. + /// Task CountSnapshots(Expression> predicate, CancellationToken cancellationToken = default); + /// + /// Retrieves all events associated with a specific event stream by its identifier. + /// + /// The unique identifier of the event stream. + /// A cancellation token that can be used to cancel the asynchronous operation. Defaults to . + /// A task that represents the asynchronous operation. The task result contains a read-only list of events associated with the specified event stream. Task> GetEventsForStream(string id, CancellationToken cancellationToken = default); } diff --git a/src/F23.Kernel/EventSourcing/IEventValidator.cs b/src/F23.Kernel/EventSourcing/IEventValidator.cs index d8690f1..5fa957b 100644 --- a/src/F23.Kernel/EventSourcing/IEventValidator.cs +++ b/src/F23.Kernel/EventSourcing/IEventValidator.cs @@ -2,9 +2,21 @@ namespace F23.Kernel.EventSourcing; +/// +/// A validator used to perform validation on an event within the context of an event stream. +/// +/// The type of the aggregate root associated with the event stream. +/// The type of the event being validated. public interface IEventValidator where T : IAggregateRoot where TEvent : IEvent { + /// + /// Validates the given event within the context of the specified event stream. + /// + /// The event stream containing the aggregate and its events. + /// The event to validate. + /// A cancellation token that can be used to cancel the asynchronous operation. Defaults to . + /// A task that represents the asynchronous operation. The task result contains a representing the outcome of the validation. Task Validate(EventStream eventStream, TEvent e, CancellationToken cancellationToken = default); } diff --git a/src/F23.Kernel/EventSourcing/ISnapshotContainer.cs b/src/F23.Kernel/EventSourcing/ISnapshotContainer.cs index 3242c67..f85e9b1 100644 --- a/src/F23.Kernel/EventSourcing/ISnapshotContainer.cs +++ b/src/F23.Kernel/EventSourcing/ISnapshotContainer.cs @@ -1,7 +1,16 @@ namespace F23.Kernel.EventSourcing; +/// +/// Defines a container holding a snapshot of an aggregate root in an event-sourced system. +/// +/// The type of the aggregate root. Must implement . public interface ISnapshotContainer where T : IAggregateRoot { + /// + /// Gets the current snapshot of the aggregate root. + /// A snapshot represents a specific state of the aggregate, used + /// to optimize queries. + /// T Snapshot { get; } } diff --git a/src/F23.Kernel/EventSourcing/SnapshotUpdatedEvent.cs b/src/F23.Kernel/EventSourcing/SnapshotUpdatedEvent.cs index dd116b7..9dd5e14 100644 --- a/src/F23.Kernel/EventSourcing/SnapshotUpdatedEvent.cs +++ b/src/F23.Kernel/EventSourcing/SnapshotUpdatedEvent.cs @@ -1,9 +1,25 @@ namespace F23.Kernel.EventSourcing; +/// +/// Represents an event that indicates a snapshot has been updated in an event-sourced system. +/// +/// +/// The type of the aggregate root associated with the snapshot. Must implement . +/// +/// +/// This event is triggered when a snapshot of the state of an aggregate root is updated, +/// typically during the process of event sourcing to optimize data retrieval or system performance. +/// public class SnapshotUpdatedEvent : IDomainEvent where T : IAggregateRoot { + /// + /// Represents the updated snapshot of the aggregate after one or more events was applied. + /// public required T NewSnapshot { get; init; } + /// + /// A collection of events associated with a snapshot update. + /// public required IReadOnlyList Events { get; init; } } diff --git a/src/F23.Kernel/F23.Kernel.csproj b/src/F23.Kernel/F23.Kernel.csproj index fb1b888..36f791b 100644 --- a/src/F23.Kernel/F23.Kernel.csproj +++ b/src/F23.Kernel/F23.Kernel.csproj @@ -4,6 +4,20 @@ net8.0 enable enable + 0.1.0 + feature[23] + feature[23] + https://github.com/feature23/kernel + https://github.com/feature23/kernel + https://github.com/feature23/kernel/blob/main/LICENSE + + + + bin\Debug\net8.0\F23.Kernel.xml + + + + bin\Release\net8.0\F23.Kernel.xml diff --git a/src/F23.Kernel/ICommand.cs b/src/F23.Kernel/ICommand.cs index 4221a07..55a5aed 100644 --- a/src/F23.Kernel/ICommand.cs +++ b/src/F23.Kernel/ICommand.cs @@ -1,5 +1,12 @@ namespace F23.Kernel; +/// +/// Represents a command that does not return a result. +/// public interface ICommand; +/// +/// Represents a command that returns a result. +/// +/// The type of the result. public interface ICommand : ICommand; diff --git a/src/F23.Kernel/ICommandHandler.cs b/src/F23.Kernel/ICommandHandler.cs index 77e5c1f..59270d9 100644 --- a/src/F23.Kernel/ICommandHandler.cs +++ b/src/F23.Kernel/ICommandHandler.cs @@ -1,15 +1,34 @@ -using F23.Kernel.Results; - namespace F23.Kernel; +/// +/// Defines a handler for a command that does not return a result. +/// +/// The type of the command. public interface ICommandHandler where TCommand : ICommand { + /// + /// Handles the specified command. + /// + /// The command to handle. + /// A token to monitor for cancellation requests. + /// A task that represents the asynchronous handle operation. Task Handle(TCommand command, CancellationToken cancellationToken = default); } +/// +/// Defines a handler for a command that returns a result. +/// +/// The type of the command. +/// The type of the result. public interface ICommandHandler where TCommand : ICommand { + /// + /// Handles the specified command. + /// + /// The command to handle. + /// A token to monitor for cancellation requests. + /// A task that represents the asynchronous handle operation. The task result contains the result of the command. Task> Handle(TCommand command, CancellationToken cancellationToken = default); -} \ No newline at end of file +} diff --git a/src/F23.Kernel/IDeepCloneable.cs b/src/F23.Kernel/IDeepCloneable.cs index 676901a..b175b10 100644 --- a/src/F23.Kernel/IDeepCloneable.cs +++ b/src/F23.Kernel/IDeepCloneable.cs @@ -1,6 +1,14 @@ namespace F23.Kernel; +/// +/// Represents an object that can create a deep clone of itself. +/// +/// The type of the object to clone. public interface IDeepCloneable { + /// + /// Creates a deep clone of the current object. + /// + /// A deep clone of the current object. T DeepClone(); } diff --git a/src/F23.Kernel/IDomainEvent.cs b/src/F23.Kernel/IDomainEvent.cs index f14409c..9b02e2c 100644 --- a/src/F23.Kernel/IDomainEvent.cs +++ b/src/F23.Kernel/IDomainEvent.cs @@ -1,3 +1,6 @@ namespace F23.Kernel; +/// +/// Marker interface for representing a domain event. +/// public interface IDomainEvent; diff --git a/src/F23.Kernel/IDomainEventDispatcher.cs b/src/F23.Kernel/IDomainEventDispatcher.cs index 28554e8..c374eb8 100644 --- a/src/F23.Kernel/IDomainEventDispatcher.cs +++ b/src/F23.Kernel/IDomainEventDispatcher.cs @@ -1,6 +1,15 @@ namespace F23.Kernel; +/// +/// Defines a dispatcher for domain events. +/// public interface IDomainEventDispatcher { + /// + /// Dispatches the specified domain event to its handlers. + /// + /// The type of the domain event. + /// The domain event to dispatch. + /// A task that represents the asynchronous dispatch operation. Task Dispatch(T domainEvent) where T : IDomainEvent; } diff --git a/src/F23.Kernel/IDomainEventHandler.cs b/src/F23.Kernel/IDomainEventHandler.cs index 54a114a..cdab032 100644 --- a/src/F23.Kernel/IDomainEventHandler.cs +++ b/src/F23.Kernel/IDomainEventHandler.cs @@ -1,7 +1,17 @@ namespace F23.Kernel; +/// +/// Defines a handler for a domain event. +/// +/// The type of the domain event. public interface IEventHandler where T : IDomainEvent { + /// + /// Handles the specified domain event. + /// + /// The domain event to handle. + /// A token to monitor for cancellation requests. + /// A task that represents the asynchronous handle operation. Task Handle(T domainEvent, CancellationToken cancellationToken = default); } diff --git a/src/F23.Kernel/IMessage.cs b/src/F23.Kernel/IMessage.cs index e05dbbb..c458ac8 100644 --- a/src/F23.Kernel/IMessage.cs +++ b/src/F23.Kernel/IMessage.cs @@ -1,3 +1,6 @@ namespace F23.Kernel; +/// +/// Marker interface representing a message that can be queued by +/// public interface IMessage; diff --git a/src/F23.Kernel/IMessageQueuer.cs b/src/F23.Kernel/IMessageQueuer.cs index 1027109..54d3a15 100644 --- a/src/F23.Kernel/IMessageQueuer.cs +++ b/src/F23.Kernel/IMessageQueuer.cs @@ -1,7 +1,17 @@ namespace F23.Kernel; +/// +/// Defines a queuer for messages without regard for underlying transport mechanisms. +/// +/// The type of the message. public interface IMessageQueuer where TMessage : IMessage { + /// + /// Enqueues the specified message. + /// + /// The message to enqueue. + /// A token to monitor for cancellation requests. + /// A task that represents the asynchronous enqueue operation. Task Enqueue(TMessage message, CancellationToken cancellationToken = default); } diff --git a/src/F23.Kernel/IQuery.cs b/src/F23.Kernel/IQuery.cs index d7411a7..7d2591d 100644 --- a/src/F23.Kernel/IQuery.cs +++ b/src/F23.Kernel/IQuery.cs @@ -1,3 +1,7 @@ namespace F23.Kernel; +/// +/// Represents a query that returns a result of type . +/// +/// The type of the result. public interface IQuery; diff --git a/src/F23.Kernel/IQueryHandler.cs b/src/F23.Kernel/IQueryHandler.cs index a04b537..1665847 100644 --- a/src/F23.Kernel/IQueryHandler.cs +++ b/src/F23.Kernel/IQueryHandler.cs @@ -1,9 +1,18 @@ -using F23.Kernel.Results; - namespace F23.Kernel; +/// +/// Defines a handler for a query that returns a result of type . +/// +/// The type of the query. +/// The type of the result. public interface IQueryHandler where TQuery : IQuery { + /// + /// Handles the specified query. + /// + /// The query to handle. + /// A token to monitor for cancellation requests. + /// A task that represents the asynchronous handle operation, containing the result of type . Task> Handle(TQuery query, CancellationToken cancellationToken = default); } diff --git a/src/F23.Kernel/IValidatable.cs b/src/F23.Kernel/IValidatable.cs index 8191a16..03611f8 100644 --- a/src/F23.Kernel/IValidatable.cs +++ b/src/F23.Kernel/IValidatable.cs @@ -2,7 +2,14 @@ namespace F23.Kernel; +/// +/// Represents an entity that can validate itself. +/// public interface IValidatable { + /// + /// Validates the entity. + /// + /// A that contains the validation results. ValidationResult Validate(); } diff --git a/src/F23.Kernel/IValidator.cs b/src/F23.Kernel/IValidator.cs index e371e53..0cf0b18 100644 --- a/src/F23.Kernel/IValidator.cs +++ b/src/F23.Kernel/IValidator.cs @@ -2,7 +2,17 @@ namespace F23.Kernel; +/// +/// A validator that validates an object. +/// +/// The type of the object to be validated. public interface IValidator { + /// + /// Validates the specified object. + /// + /// The object to validate. + /// A token to monitor for cancellation requests. + /// A task that represents the asynchronous validation operation. The task result contains the validation result. Task Validate(T value, CancellationToken cancellationToken = default); -} \ No newline at end of file +} diff --git a/src/F23.Kernel/KernelServiceCollectionExtensions.cs b/src/F23.Kernel/KernelServiceCollectionExtensions.cs index 378d215..caae0f5 100644 --- a/src/F23.Kernel/KernelServiceCollectionExtensions.cs +++ b/src/F23.Kernel/KernelServiceCollectionExtensions.cs @@ -1,7 +1,20 @@ namespace F23.Kernel; +/// +/// Provides extension methods for registering handlers in the service collection. +/// public static class KernelServiceCollectionExtensions { + /// + /// Registers a query handler in the service collection. + /// + /// The type of the query. + /// The type of the result. + /// The type of the query handler. + /// The service collection. + /// + /// A is also registered for the query. + /// public static void RegisterQueryHandler(this IServiceCollection services) where TQuery : IQuery where TQueryHandler : class, IQueryHandler @@ -10,6 +23,16 @@ public static void RegisterQueryHandler(this ISe services.AddTransient, TQueryHandler>(); } + /// + /// Registers a command handler in the service collection. + /// + /// The type of the command. + /// The type of the result. + /// The type of the command handler. + /// The service collection. + /// + /// A is also registered for the command. + /// public static void RegisterCommandHandler(this IServiceCollection services) where TCommand : ICommand where TCommandHandler : class, ICommandHandler @@ -18,6 +41,15 @@ public static void RegisterCommandHandler(th services.AddTransient, TCommandHandler>(); } + /// + /// Registers a command handler in the service collection. + /// + /// The type of the command. + /// The type of the command handler. + /// The service collection. + /// + /// A is also registered for the command. + /// public static void RegisterCommandHandler(this IServiceCollection services) where TCommand : ICommand where TCommandHandler : class, ICommandHandler @@ -26,6 +58,12 @@ public static void RegisterCommandHandler(this IServi services.AddTransient, TCommandHandler>(); } + /// + /// Registers an event handler in the service collection. + /// + /// The type of the event. + /// The type of the event handler. + /// The service collection. public static void RegisterEventHandler(this IServiceCollection services) where TEvent : IDomainEvent where TEventHandler : class, IEventHandler diff --git a/src/F23.Kernel/Result.cs b/src/F23.Kernel/Result.cs index 4da7859..6fb0dc4 100644 --- a/src/F23.Kernel/Result.cs +++ b/src/F23.Kernel/Result.cs @@ -3,51 +3,135 @@ namespace F23.Kernel; +/// +/// Abstract representation of the result of an operation that does not have an associated value. +/// public abstract class Result(bool isSuccess) { + /// + /// Gets a value indicating whether the operation was successful. + /// public bool IsSuccess { get; } = isSuccess; + /// + /// Gets a message describing the outcome of the operation. + /// public abstract string Message { get; } + /// + /// Creates a successful result. + /// + /// A . public static Result Success() => new SuccessResult(); + /// + /// Aggregates multiple results into a single result. + /// + /// The results to aggregate. + /// An . public static Result Aggregate(IReadOnlyList results) => new AggregateResult(results); + /// + /// Creates an unauthorized result. + /// + /// The unauthorized message. + /// An . public static Result Unauthorized(string message) => new UnauthorizedResult(message); + /// + /// Creates a validation failed result for a collection of objects. + /// + /// The validation errors. + /// A . public static Result ValidationFailed(IReadOnlyCollection errors) => new ValidationFailedResult(errors); + /// + /// Creates a validation failed result for a single key and message. + /// + /// The key associated with the validation error. + /// The validation error message. + /// A . public static Result ValidationFailed(string key, string message) => new ValidationFailedResult(new[] { new ValidationError(key, message) }); + /// + /// Creates a precondition failed result for the specified . + /// + /// The reason for the precondition failure. + /// The optional message associated with the precondition failure. + /// A . public static Result PreconditionFailed(PreconditionFailedReason reason, string? message = null) => new PreconditionFailedResult(reason, message); } +/// +/// Abstract representation of the result of an operation that has an associated value. +/// +/// The type of the value. public abstract class Result(bool isSuccess) : Result(isSuccess) { + /// + /// Creates a successful result with a value. + /// + /// The value. + /// A . public static Result Success(T value) => new SuccessResult(value); + /// + /// Creates an unauthorized result. + /// + /// The unauthorized message. + /// An . public new static Result Unauthorized(string message) => new UnauthorizedResult(message); + /// + /// Creates a validation failed result for a collection of objects. + /// + /// The validation errors. + /// A . public new static Result ValidationFailed(IReadOnlyCollection errors) => new ValidationFailedResult(errors); + /// + /// Creates a validation failed result for a single key with a collection of objects. + /// + /// The key associated with the validation error. + /// The validation errors. + /// A . public static Result ValidationFailed(string key, IEnumerable errors) => new ValidationFailedResult(errors.Select(e => e with { Key = key }).ToList()); + /// + /// Creates a validation failed result for a single key and message. + /// + /// The key associated with the validation error. + /// The validation error message. + /// A . public new static Result ValidationFailed(string key, string message) - => new ValidationFailedResult(new[] { new ValidationError(key, message) }); - + => new ValidationFailedResult([new ValidationError(key, message)]); + + /// + /// Creates a precondition failed result. + /// + /// The reason for the precondition failure. + /// The optional message associated with the precondition failure. + /// A . public new static Result PreconditionFailed(PreconditionFailedReason reason, string? message = null) => new PreconditionFailedResult(reason, message); + /// + /// Maps the success result to another type. + /// + /// The type to map to. + /// The function to map the success value. + /// A . + /// Thrown if the result type is not known. public Result Map(Func successMapper) => this switch { @@ -55,6 +139,12 @@ public Result Map(Func successMapper) => _ => MapFailure() }; + /// + /// Maps the failure result to another type. + /// + /// The type to map to. + /// A . + /// Thrown if the result type is not known. public Result MapFailure() => this switch { @@ -65,6 +155,12 @@ public Result MapFailure() => _ => throw new InvalidOperationException("Unknown result type") }; + /// + /// Logs the failure result to the specified . + /// + /// The logger. + /// The log level. + /// Thrown if the result is a success or an unknown type. public void LogFailure(ILogger logger, LogLevel logLevel = LogLevel.Warning) { switch (this) diff --git a/src/F23.Kernel/Results/AggregateResult.cs b/src/F23.Kernel/Results/AggregateResult.cs index 3f0ac7d..1c0defe 100644 --- a/src/F23.Kernel/Results/AggregateResult.cs +++ b/src/F23.Kernel/Results/AggregateResult.cs @@ -1,9 +1,19 @@ namespace F23.Kernel.Results; +/// +/// Represents the aggregation of multiple instances into a single result. +/// The overall success of the aggregate result is determined by the success state of all the underlying results. +/// public class AggregateResult(IReadOnlyList results) : Result(results.All(r => r.IsSuccess)) { + /// + /// Gets the list of individual instances that are part of the aggregate result. + /// public IReadOnlyList Results { get; } = results; + /// + /// Gets a message describing the outcome of the operation. + /// public override string Message => "Aggregate result of multiple results"; } diff --git a/src/F23.Kernel/Results/AuthorizationResult.cs b/src/F23.Kernel/Results/AuthorizationResult.cs index abf46de..95f530c 100644 --- a/src/F23.Kernel/Results/AuthorizationResult.cs +++ b/src/F23.Kernel/Results/AuthorizationResult.cs @@ -1,12 +1,37 @@ namespace F23.Kernel.Results; +/// +/// Represents the result of an authorization attempt. +/// public abstract record AuthorizationResult(bool IsAuthorized) { + /// Creates and returns an instance of SuccessfulAuthorizationResult, + /// representing a successful authorization result. + /// An AuthorizationResult indicating success. public static AuthorizationResult Success() => new SuccessfulAuthorizationResult(); + /// Creates a failed authorization result with the specified message. + /// The failure message associated with the authorization result. + /// An instance of FailedAuthorizationResult representing the unsuccessful authorization. public static AuthorizationResult Fail(string message) => new FailedAuthorizationResult(message); } +/// +/// Represents a successful authorization result. +/// +/// +/// This class is derived from the base class and +/// signifies that authorization was successful. The IsAuthorized property +/// is always set to true for instances of this class. +/// public record SuccessfulAuthorizationResult() : AuthorizationResult(true); -public record FailedAuthorizationResult(string Message) : AuthorizationResult(false); \ No newline at end of file +/// +/// Represents the result of a failed authorization attempt. +/// +/// +/// This class is derived from the base class and +/// signifies that authorization was successful. The IsAuthorized property +/// is always set to false for instances of this class. +/// +public record FailedAuthorizationResult(string Message) : AuthorizationResult(false); diff --git a/src/F23.Kernel/Results/PreconditionFailedReason.cs b/src/F23.Kernel/Results/PreconditionFailedReason.cs index 1dc4b0d..2273ef0 100644 --- a/src/F23.Kernel/Results/PreconditionFailedReason.cs +++ b/src/F23.Kernel/Results/PreconditionFailedReason.cs @@ -1,14 +1,39 @@ namespace F23.Kernel.Results; +/// +/// Defines reasons for a precondition failure in an operation. +/// public enum PreconditionFailedReason { + /// + /// Indicates that a requested resource was not found, leading to the failure of a precondition. + /// NotFound = 1, + + /// + /// Represents a failure due to a concurrency mismatch, typically when the current state of a resource does not align with the expected state. + /// ConcurrencyMismatch = 2, + + /// + /// Indicates that a conflict with the current state of the resource caused the failure of a precondition. + /// Conflict = 3, } +/// +/// Provides extension methods for the enumeration. +/// internal static class PreconditionFailedReasonExtensions { + /// + /// Converts a to its corresponding descriptive message. + /// + /// The value to convert. + /// A string describing the specified . + /// + /// Thrown when the provided is not a valid value. + /// public static string ToMessage(this PreconditionFailedReason reason) => reason switch { PreconditionFailedReason.NotFound => "A requested resource was not found.", diff --git a/src/F23.Kernel/Results/PreconditionFailedResult.cs b/src/F23.Kernel/Results/PreconditionFailedResult.cs index 5a45b8d..791b443 100644 --- a/src/F23.Kernel/Results/PreconditionFailedResult.cs +++ b/src/F23.Kernel/Results/PreconditionFailedResult.cs @@ -1,15 +1,50 @@ namespace F23.Kernel.Results; +/// +/// Represents a result where a precondition has failed. This is used to indicate that some condition for proceeding with an +/// operation has not been met, often due to the state of the resource. +/// public class PreconditionFailedResult(PreconditionFailedReason reason, string? message) : Result(false) { + /// + /// Gets the reason why the precondition has failed in a given operation. + /// + /// + /// The value is of type and provides additional context + /// about the specific reason for the failure, such as NotFound, ConcurrencyMismatch, or Conflict. + /// public PreconditionFailedReason Reason { get; } = reason; + /// + /// Gets the message associated with the precondition failure. + /// If a specific message is provided, it will return that message. + /// Otherwise, it will return the default message associated with the + /// value. + /// public override string Message => message ?? Reason.ToMessage(); } +/// +/// Represents a result where a precondition has failed. This is used to indicate that some condition for proceeding with an +/// operation has not been met, often due to the state of the resource. +/// +/// The type of the result's associated value, if the operation had succeeded. public class PreconditionFailedResult(PreconditionFailedReason reason, string? message) : Result(false) { + /// + /// Gets the reason why the precondition has failed in a given operation. + /// + /// + /// The value is of type and provides additional context + /// about the specific reason for the failure, such as NotFound, ConcurrencyMismatch, or Conflict. + /// public PreconditionFailedReason Reason { get; } = reason; + /// + /// Gets the message associated with the precondition failure. + /// If a specific message is provided, it will return that message. + /// Otherwise, it will return the default message associated with the + /// value. + /// public override string Message => message ?? Reason.ToMessage(); } diff --git a/src/F23.Kernel/Results/SuccessResult.cs b/src/F23.Kernel/Results/SuccessResult.cs index 39fde0a..dcfb779 100644 --- a/src/F23.Kernel/Results/SuccessResult.cs +++ b/src/F23.Kernel/Results/SuccessResult.cs @@ -1,13 +1,33 @@ namespace F23.Kernel.Results; +/// +/// Represents a successful result of an operation. +/// This class is a specialized implementation of the type +/// with a predefined success state and message. +/// public class SuccessResult() : Result(true) { + /// + /// Gets a message describing the outcome of the operation. + /// public override string Message => "The operation was successful."; } +/// +/// Represents a successful result of an operation with an associated value. +/// Inherits from the base . +/// +/// The type of the associated value. public class SuccessResult(T value) : Result(true) { + /// + /// Gets a message describing the outcome of the operation. + /// public override string Message => "The operation was successful."; + /// + /// Gets the value of the result. + /// This represents the successful outcome of the operation when the result is a . + /// public T Value => value; } diff --git a/src/F23.Kernel/Results/UnauthorizedResult.cs b/src/F23.Kernel/Results/UnauthorizedResult.cs index e4843ca..0540c1b 100644 --- a/src/F23.Kernel/Results/UnauthorizedResult.cs +++ b/src/F23.Kernel/Results/UnauthorizedResult.cs @@ -1,11 +1,24 @@ namespace F23.Kernel.Results; +/// +/// Represents a result indicating an unauthorized operation. +/// public class UnauthorizedResult(string message) : Result(false) { + /// + /// Gets a message describing the outcome of the operation. + /// public override string Message => message; } +/// +/// Represents a result indicating an unauthorized operation. +/// +/// The type of the result's associated value, if the operation had succeeded. public class UnauthorizedResult(string message) : Result(false) { + /// + /// Gets a message describing the outcome of the operation. + /// public override string Message => message; } diff --git a/src/F23.Kernel/Results/ValidationFailedResult.cs b/src/F23.Kernel/Results/ValidationFailedResult.cs index a4529bd..31f67de 100644 --- a/src/F23.Kernel/Results/ValidationFailedResult.cs +++ b/src/F23.Kernel/Results/ValidationFailedResult.cs @@ -1,15 +1,42 @@ namespace F23.Kernel.Results; +/// +/// Represents a validation failure result containing validation errors. +/// public class ValidationFailedResult(IReadOnlyCollection errors) : ValidationResult(false) { + /// + /// Gets a message describing the outcome of the operation. + /// public override string Message => "The operation failed due to validation errors."; + /// + /// Gets a collection of instances associated with the validation failure. + /// + /// + /// This property provides detailed information about the errors that occurred during validation, + /// including the key associated with each error and its corresponding message. + /// public IReadOnlyCollection Errors => errors; } +/// +/// Represents a validation failure result that includes validation errors. +/// +/// The type of the result's associated value, if the operation had succeeded. public class ValidationFailedResult(IReadOnlyCollection errors) : ValidationResult(false) { + /// + /// Gets a message describing the outcome of the operation. + /// public override string Message => "The operation failed due to validation errors."; + /// + /// Gets a collection of instances associated with the validation failure. + /// + /// + /// This property provides detailed information about the errors that occurred during validation, + /// including the key associated with each error and its corresponding message. + /// public IReadOnlyCollection Errors => errors; } diff --git a/src/F23.Kernel/Results/ValidationPassedResult.cs b/src/F23.Kernel/Results/ValidationPassedResult.cs index 8930572..ef7b9f4 100644 --- a/src/F23.Kernel/Results/ValidationPassedResult.cs +++ b/src/F23.Kernel/Results/ValidationPassedResult.cs @@ -1,11 +1,24 @@ namespace F23.Kernel.Results; +/// +/// Represents a successful validation. +/// public class ValidationPassedResult() : ValidationResult(true) { + /// + /// Gets a message describing the outcome of the operation. + /// public override string Message => "Validation passed"; } +/// +/// Represents a successful validation. +/// +/// The type of the associated value. public class ValidationPassedResult() : ValidationResult(true) { + /// + /// Gets a message describing the outcome of the operation. + /// public override string Message => "Validation passed"; } diff --git a/src/F23.Kernel/Results/ValidationResult.cs b/src/F23.Kernel/Results/ValidationResult.cs index c605261..9b98158 100644 --- a/src/F23.Kernel/Results/ValidationResult.cs +++ b/src/F23.Kernel/Results/ValidationResult.cs @@ -1,19 +1,55 @@ namespace F23.Kernel.Results; +/// +/// Represents the result of a validation operation. +/// public abstract class ValidationResult(bool isSuccess) : Result(isSuccess) { + /// + /// Creates a validation result that represents a failure with a single validation error. + /// + /// The validation error to include in the result. + /// A indicating failure with the provided error. public static ValidationResult Failed(ValidationError error) => new ValidationFailedResult([error]); + /// + /// Creates a failed validation result with a key and message describing the validation error. + /// + /// The key associated with the validation error. + /// The validation error message. + /// A representing the failure with the specified validation error details. public static ValidationResult Failed(string key, string message) => new ValidationFailedResult([new ValidationError(key, message)]); + /// + /// Creates a validation result with the specified collection of validation errors. + /// + /// A collection of that caused the validation failure. + /// A indicating failure with the provided errors. public static ValidationResult Failed(IReadOnlyCollection errors) => new ValidationFailedResult(errors); + /// + /// Creates a that represents a successful validation outcome. + /// + /// A indicating that validation passed. public static ValidationResult Passed() => new ValidationPassedResult(); } +/// +/// Represents the result of a validation operation. +/// +/// The type of the associated value. public abstract class ValidationResult(bool isSuccess) : Result(isSuccess) { + /// + /// Creates a validation result with the specified collection of validation errors. + /// + /// A collection of that caused the validation failure. + /// A indicating failure with the provided errors. public static ValidationResult Failed(IReadOnlyCollection errors) => new ValidationFailedResult(errors); + /// + /// Creates a that represents a successful validation outcome. + /// + /// A indicating that validation passed. public static ValidationResult Passed() => new ValidationPassedResult(); } diff --git a/src/F23.Kernel/ValidationError.cs b/src/F23.Kernel/ValidationError.cs index 3e85f75..193f706 100644 --- a/src/F23.Kernel/ValidationError.cs +++ b/src/F23.Kernel/ValidationError.cs @@ -1,3 +1,8 @@ namespace F23.Kernel; -public record ValidationError(string Key, string Message); \ No newline at end of file +/// +/// Represents a validation error with a key and a message. +/// +/// The key associated with the validation error. +/// The validation error message. +public record ValidationError(string Key, string Message);