diff --git a/Directory.Packages.props b/Directory.Packages.props
index 1272c60..bc7d881 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,9 +4,14 @@
true
+
+
+
+
+
diff --git a/PANiXiDA.Core.Template.slnx b/PANiXiDA.Core.Presentation.Http.slnx
similarity index 70%
rename from PANiXiDA.Core.Template.slnx
rename to PANiXiDA.Core.Presentation.Http.slnx
index 736a666..99531a8 100644
--- a/PANiXiDA.Core.Template.slnx
+++ b/PANiXiDA.Core.Presentation.Http.slnx
@@ -16,9 +16,9 @@
-
+
-
+
diff --git a/README.md b/README.md
index 22cff3e..4943884 100644
--- a/README.md
+++ b/README.md
@@ -1,286 +1,264 @@
-## What to do after creating a repository from this template
+# PANiXiDA.Core.Presentation.Http
-### 1. Rename repository metadata
-- change repository name
-- change solution / project names
-- change package ID
-- change assembly name
-- change repository URLs
-- change ProjectReference in test project
+`PANiXiDA.Core.Presentation.Http` is a reusable ASP.NET Core HTTP presentation package for PANiXiDA applications.
-### 2. Update package metadata
-- description
-- tags
-
-### 3. Update documentation
-- replace this template README with the project README
-- fill all placeholder sections
-- update badges
-- update installation instructions
-- add real usage examples
-
-### 4. Configure GitHub repository
-- check repository visibility
-- configure default branch
-- configure branch protection rules
-- configure Issues / Discussions if needed
-- configure repository description, topics and website
-
-### 5. Prepare the first release
-- update versioning configuration pathFilters in version.json
-- verify NuGet metadata
-- verify README and icon inside the package
-- publish the first package version
-- the version is updated automatically based on the commit history
-
----
-
-# Universal README template for the NuGet package
-
-#
-
-`` is a .NET library for .
-
-It is designed for who need .
+It provides common Minimal API endpoint conventions, API versioning, OpenAPI setup, Problem Details handling, request logging, exception handling, forwarded headers configuration, and helpers for mapping `PANiXiDA.Core.ResultPattern` results to HTTP responses.
## Status
-[](https://github.com///actions/workflows/ci.yml)
-[](https://www.nuget.org/packages/)
-[](https://www.nuget.org/packages/)
+[](https://github.com/panixida-dotnet-core/presentation-http/actions/workflows/ci.yml)
+[](https://www.nuget.org/packages/PANiXiDA.Core.Presentation.Http)
+[](https://www.nuget.org/packages/PANiXiDA.Core.Presentation.Http)
[](https://dotnet.microsoft.com/)
-[](LICENSE)
-
-## Overview
-
-Describe:
-
-- what problem this package solves;
-- why it exists;
-- where it fits in the system or ecosystem;
-- how it differs from alternatives, if that matters.
-
-Keep this section short and practical.
+[](LICENSE)
## Features
-- Feature 1
-- Feature 2
-- Feature 3
-- Feature 4
-- Feature 5
+- `AddHttp` registers the default HTTP presentation services.
+- `MapHttp` adds the default middleware pipeline and maps discovered endpoint groups.
+- `IEndpointGroup` defines route groups for Minimal API endpoints.
+- `IEndpoint` defines endpoints that belong to a specific group.
+- `EndpointMapper` discovers and maps endpoints in a deterministic type-name order.
+- `EndpointConstants.EndpointPrefix` defines `/api/v{version:apiVersion}`.
+- `ResultHttpMapper` maps `Result` and `Result` to `IResult`.
-## Quick Start
-
-### Requirements
+## Requirements
-- .NET 10 SDK
+- .NET 10 SDK.
+- ASP.NET Core Minimal API application.
-### Installation
+## Installation
```xml
-
+
-````
+```
-### Minimal import
+## Quick Start
```csharp
-using ;
-```
+using PANiXiDA.Core.Presentation.Http.DependencyInjection;
-### First example
+var builder = WebApplication.CreateBuilder(args);
-```csharp
-// Add a minimal example here
-```
+builder.Services.AddHttp(builder.Configuration.GetSection("ForwardedHeaders"));
-## Usage
+var app = builder.Build();
-### Basic usage
+app.MapHttp(typeof(Program).Assembly);
-```csharp
-// Add a basic example here
+app.Run();
```
-### Typical scenario
+Pass `null` to `AddHttp` if forwarded headers should use the package defaults.
```csharp
-// Add a realistic example here
+builder.Services.AddHttp(configuration: null);
```
-### Advanced scenario
+## Forwarded Headers
+
+The package configures these forwarded headers by default:
```csharp
-// Add an advanced example here if needed
+ForwardedHeaders.XForwardedFor |
+ForwardedHeaders.XForwardedHost |
+ForwardedHeaders.XForwardedProto
```
-## Configuration
-
-Describe configuration only if the package actually requires it.
-
-Possible topics:
+Additional values can be bound from the standard ASP.NET Core `ForwardedHeadersOptions` model by passing a configuration section to `AddHttp`.
+
+```json
+{
+ "ForwardedHeaders": {
+ "ForwardedHeaders": "XForwardedFor, XForwardedHost, XForwardedProto",
+ "ForwardLimit": 2,
+ "RequireHeaderSymmetry": true,
+ "AllowedHosts": [
+ "api.example.com"
+ ]
+ }
+}
+```
-* environment variables;
-* `appsettings.json`;
-* feature flags;
-* external services;
-* secrets;
-* runtime prerequisites.
+For advanced scenarios, configure `ForwardedHeadersOptions` directly after `AddHttp`.
-If the package does not require runtime configuration, say so explicitly.
+```csharp
+using Microsoft.AspNetCore.HttpOverrides;
-## Project Structure
+builder.Services.AddHttp(builder.Configuration.GetSection("ForwardedHeaders"));
-```text
-.
-├── src/
-│ └── /
-├── tests/
-│ └── .UnitTests/
-├── .editorconfig
-├── .gitattributes
-├── .gitignore
-├── Directory.Build.props
-├── Directory.Build.targets
-├── Directory.Packages.props
-├── global.json
-├── version.json
-├── LICENSE
-└── README.md
+builder.Services.Configure(options =>
+{
+ options.KnownProxies.Clear();
+ options.KnownIPNetworks.Clear();
+});
```
-### Main repository files
+## Endpoint Groups
-* `src/` — source code
-* `tests/` — automated tests
-* `Directory.Build.props` — shared MSBuild settings
-* `Directory.Build.targets` — shared build / packaging settings
-* `Directory.Packages.props` — centralized package versions
-* `global.json` — SDK and tooling configuration
-* `version.json` — versioning configuration
-* `README.md` — package overview and usage documentation
+An endpoint group owns a route prefix, tags, and the call to map endpoints that belong to the group.
-## Development
+```csharp
+using Microsoft.AspNetCore.Routing;
-### Build
+using PANiXiDA.Core.Presentation.Http.Endpoints;
-```bash
-dotnet restore
-dotnet build --configuration Release
+public sealed class OrdersEndpointGroup : IEndpointGroup
+{
+ public void Map(IEndpointRouteBuilder endpoints)
+ {
+ var group = endpoints.MapGroup(EndpointConstants.EndpointPrefix)
+ .MapGroup("/orders")
+ .WithTags("Orders");
+
+ EndpointMapper.MapGroupEndpoints(group, endpoints.ServiceProvider);
+ }
+}
```
-### Format
+The final route prefix is `/api/v{version}/orders`.
-```bash
-dotnet format
-```
+## Endpoints
-### Test
+An endpoint implements `IEndpoint`, where `TGroup` is the endpoint group it belongs to.
-```bash
-dotnet test --configuration Release
+```csharp
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Routing;
+
+using PANiXiDA.Core.Presentation.Http.Endpoints;
+
+public sealed class GetOrderEndpoint : IEndpoint
+{
+ public void Map(RouteGroupBuilder group)
+ {
+ group.MapGet("/{id:guid}", (Guid id) =>
+ {
+ return TypedResults.Ok(new OrderResponse(id));
+ });
+ }
+}
+
+public sealed record OrderResponse(Guid Id);
```
-### Pack
+## Result Mapping
-```bash
-dotnet pack --configuration Release
-```
+Successful results are mapped through the provided success factory.
-### Full local validation
+```csharp
+using Microsoft.AspNetCore.Http;
-```bash
-dotnet restore
-dotnet format
-dotnet build --configuration Release
-dotnet test --configuration Release
-dotnet pack --configuration Release
-```
+using PANiXiDA.Core.Presentation.Http.Helpers;
+using PANiXiDA.Core.ResultPattern;
-### Tooling and conventions
+public static IResult GetOrder(Guid id)
+{
+ Result result = Result.Success(new OrderResponse(id));
-This repository uses:
+ return result.ToHttpResult(value =>
+ {
+ return TypedResults.Ok(value);
+ });
+}
+```
-* .NET 10
-* Nullable enabled
-* Implicit usings enabled
-* Central package management
-* GitHub Actions
-* Nerdbank.GitVersioning
+Failed results are mapped to `ProblemDetails` or `ValidationProblem`.
-Add more items only if they are actually relevant for the repository.
+```csharp
+using Microsoft.AspNetCore.Http;
-## API / Contracts / Examples
+using PANiXiDA.Core.Presentation.Http.Helpers;
+using PANiXiDA.Core.ResultPattern;
-Describe the public API surface here.
+public static IResult CreateOrder()
+{
+ Result result = Result.Failure(Error.Validation("Email is required").WithField("Email"));
-Suggested structure:
+ return result.ToHttpProblem();
+}
+```
-* core abstractions;
-* main entry points;
-* key extension methods;
-* important behavioral notes;
-* typical integration examples.
+## HTTP Error Mapping
-## Roadmap / TODO
+| Error type | HTTP status | Title |
+| --- | ---: | --- |
+| `Validation` | 400 | `One or more validation errors occurred.` |
+| `NotFound` | 404 | `Resource not found` |
+| `Conflict` | 409 | `Conflict` |
+| `Unauthorized` | 401 | `Unauthorized` |
+| `Forbidden` | 403 | `Forbidden` |
+| `Failure` | 400 | `Request failed` |
+| `Unexpected` | 500 | `Server error` |
-Potential future improvements:
+Validation error fields are used as `ValidationProblem` keys. If a validation error has no field, the key is `general`.
-* item 1;
-* item 2;
-* item 3.
+## OpenAPI
-Remove this section if it does not provide value.
+In `Development`, `MapHttp` exposes:
-## Contributing
+- OpenAPI document at `/openapi/v1.json`;
+- Swagger UI at `/swagger`.
-Contributions are welcome.
+OpenAPI is not mapped automatically outside `Development`.
-### General rules
+## API Versioning
-* keep the public API intentional;
-* avoid unnecessary dependencies;
-* preserve repository conventions;
-* do not introduce breaking changes without review;
-* keep documentation updated.
+The package configures URL segment API versioning:
-### Code style
+```text
+/api/v1/orders
+```
-* follow the repository `.editorconfig`;
-* prefer readable and explicit code;
-* keep naming consistent with the existing codebase.
+The default API version is `1.0`, and the version must be present in the route.
-### Tests
+## Project Structure
-* add or update tests for meaningful behavior changes;
-* cover both success and failure scenarios where applicable;
-* add regression tests for bug fixes.
+```text
+src/
+ PANiXiDA.Core.Presentation.Http/
+ Configurations/
+ DependencyInjection/
+ Endpoints/
+ Helpers/
+ Middlewares/
+tests/
+ PANiXiDA.Core.Presentation.Http.UnitTests/
+```
-### Validation before completion
+## Development
-Run:
+Run the standard validation before publishing:
-```bash
+```powershell
dotnet restore
dotnet format
dotnet build --configuration Release
dotnet test --configuration Release
+dotnet pack --configuration Release
```
-## License
+Run coverage:
-This project is licensed under the license.
+```powershell
+dotnet test --configuration Release -- --coverage --coverage-output coverage.xml --coverage-output-format xml
+```
+
+The source files under `src/PANiXiDA.Core.Presentation.Http` are covered by unit tests. Coverage excludes generated files under `obj/` from ASP.NET Core and validation source generators.
-See the [LICENSE](LICENSE) file for details.
+## Package Contents
-## Maintainers / Contacts
+The NuGet package includes:
-Maintained by .
+- compiled library for `net10.0`;
+- XML documentation;
+- README;
+- package icon;
+- Source Link metadata;
+- symbols package when packed with repository settings.
-For questions or improvements, use:
+## License
-* GitHub Issues
-* Pull Requests
-* GitHub Discussions, if enabled
+This project is licensed under the Apache-2.0 license. See [LICENSE](LICENSE) for details.
diff --git a/src/PANiXiDA.Core.Presentation.Http/Configurations/ApiVersioningConfiguration.cs b/src/PANiXiDA.Core.Presentation.Http/Configurations/ApiVersioningConfiguration.cs
new file mode 100644
index 0000000..80f5e1d
--- /dev/null
+++ b/src/PANiXiDA.Core.Presentation.Http/Configurations/ApiVersioningConfiguration.cs
@@ -0,0 +1,26 @@
+using Asp.Versioning;
+
+using Microsoft.Extensions.DependencyInjection;
+
+namespace PANiXiDA.Core.Presentation.Http.Configurations;
+
+internal static class ApiVersioningConfiguration
+{
+ internal static IServiceCollection AddApiVersioningConfiguration(this IServiceCollection services)
+ {
+ services.AddApiVersioning(options =>
+ {
+ options.DefaultApiVersion = new ApiVersion(1, 0);
+ options.AssumeDefaultVersionWhenUnspecified = false;
+ options.ReportApiVersions = true;
+ options.ApiVersionReader = new UrlSegmentApiVersionReader();
+ })
+ .AddApiExplorer(options =>
+ {
+ options.GroupNameFormat = "'v'V";
+ options.SubstituteApiVersionInUrl = true;
+ });
+
+ return services;
+ }
+}
diff --git a/src/PANiXiDA.Core.Presentation.Http/Configurations/ForwardedHeadersConfiguration.cs b/src/PANiXiDA.Core.Presentation.Http/Configurations/ForwardedHeadersConfiguration.cs
new file mode 100644
index 0000000..987110f
--- /dev/null
+++ b/src/PANiXiDA.Core.Presentation.Http/Configurations/ForwardedHeadersConfiguration.cs
@@ -0,0 +1,36 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.HttpOverrides;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace PANiXiDA.Core.Presentation.Http.Configurations;
+
+internal static class ForwardedHeadersConfiguration
+{
+ internal static IServiceCollection AddForwardedHeadersConfiguration(
+ this IServiceCollection services,
+ IConfiguration? configuration)
+ {
+ services.Configure(options =>
+ {
+ options.ForwardedHeaders =
+ ForwardedHeaders.XForwardedFor |
+ ForwardedHeaders.XForwardedHost |
+ ForwardedHeaders.XForwardedProto;
+ });
+
+ if (configuration is not null)
+ {
+ services.Configure(configuration);
+ }
+
+ return services;
+ }
+
+ internal static WebApplication UseForwardedHeadersConfiguration(this WebApplication app)
+ {
+ app.UseForwardedHeaders();
+
+ return app;
+ }
+}
diff --git a/src/PANiXiDA.Core.Presentation.Http/Configurations/OpenApiConfiguration.cs b/src/PANiXiDA.Core.Presentation.Http/Configurations/OpenApiConfiguration.cs
new file mode 100644
index 0000000..d0f9b4e
--- /dev/null
+++ b/src/PANiXiDA.Core.Presentation.Http/Configurations/OpenApiConfiguration.cs
@@ -0,0 +1,31 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+namespace PANiXiDA.Core.Presentation.Http.Configurations;
+
+internal static class OpenApiConfiguration
+{
+ internal static IServiceCollection AddOpenApiConfiguration(this IServiceCollection services)
+ {
+ services.AddOpenApi();
+
+ return services;
+ }
+
+ internal static WebApplication UseOpenApiConfiguration(this WebApplication app)
+ {
+ if (app.Environment.IsDevelopment())
+ {
+ app.MapOpenApi();
+
+ app.UseSwaggerUI(options =>
+ {
+ options.SwaggerEndpoint("/openapi/v1.json", "v1");
+ options.RoutePrefix = "swagger";
+ });
+ }
+
+ return app;
+ }
+}
diff --git a/src/PANiXiDA.Core.Presentation.Http/Configurations/ProblemDetailsConfiguration.cs b/src/PANiXiDA.Core.Presentation.Http/Configurations/ProblemDetailsConfiguration.cs
new file mode 100644
index 0000000..1449121
--- /dev/null
+++ b/src/PANiXiDA.Core.Presentation.Http/Configurations/ProblemDetailsConfiguration.cs
@@ -0,0 +1,45 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+
+using System.Text.Json;
+
+namespace PANiXiDA.Core.Presentation.Http.Configurations;
+
+internal static class ProblemDetailsConfiguration
+{
+ internal static IServiceCollection AddProblemDetailsConfiguration(this IServiceCollection services)
+ {
+ services.AddProblemDetails(options =>
+ {
+ options.CustomizeProblemDetails = context =>
+ {
+ if (context.ProblemDetails is not HttpValidationProblemDetails validationProblem)
+ {
+ return;
+ }
+
+ var normalizedErrors = validationProblem.Errors.ToDictionary(
+ item => JsonNamingPolicy.CamelCase.ConvertName(item.Key),
+ item => item.Value);
+
+ var normalizedProblem = new HttpValidationProblemDetails(normalizedErrors)
+ {
+ Title = validationProblem.Title,
+ Type = validationProblem.Type,
+ Status = validationProblem.Status,
+ Detail = validationProblem.Detail,
+ Instance = validationProblem.Instance
+ };
+
+ foreach (var extension in validationProblem.Extensions)
+ {
+ normalizedProblem.Extensions[extension.Key] = extension.Value;
+ }
+
+ context.ProblemDetails = normalizedProblem;
+ };
+ });
+
+ return services;
+ }
+}
diff --git a/src/PANiXiDA.Core.Presentation.Http/DependencyInjection/ServiceCollectionExtensions.cs b/src/PANiXiDA.Core.Presentation.Http/DependencyInjection/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..8962420
--- /dev/null
+++ b/src/PANiXiDA.Core.Presentation.Http/DependencyInjection/ServiceCollectionExtensions.cs
@@ -0,0 +1,59 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+
+using PANiXiDA.Core.Presentation.Http.Configurations;
+using PANiXiDA.Core.Presentation.Http.Endpoints;
+using PANiXiDA.Core.Presentation.Http.Middlewares;
+
+using System.Reflection;
+
+namespace PANiXiDA.Core.Presentation.Http.DependencyInjection;
+
+///
+/// Provides extension methods for registering and mapping application HTTP infrastructure.
+///
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Registers the default HTTP presentation services, including API versioning, OpenAPI, Problem Details, exception handling, validation, and forwarded headers.
+ ///
+ /// The application service collection.
+ /// The forwarded headers configuration section, or to use defaults.
+ /// The original service collection for further configuration.
+ public static IServiceCollection AddHttp(
+ this IServiceCollection services,
+ IConfiguration? configuration)
+ {
+ services.AddForwardedHeadersConfiguration(configuration);
+ services.AddApiVersioningConfiguration();
+ services.AddOpenApiConfiguration();
+ services.AddProblemDetailsConfiguration();
+ services.AddExceptionHandler();
+ services.AddValidation();
+
+ return services;
+ }
+
+ ///
+ /// Adds the HTTP presentation middleware and maps endpoint groups from the specified assemblies.
+ ///
+ /// The ASP.NET Core application instance.
+ /// The assemblies used to discover endpoint groups.
+ /// The original application instance for further configuration.
+ public static WebApplication MapHttp(this WebApplication app, params Assembly[] assemblies)
+ {
+ app.UseForwardedHeadersConfiguration();
+ app.UseExceptionHandler();
+ app.UseHttpsRedirection();
+ app.UseMiddleware();
+ app.UseOpenApiConfiguration();
+
+ foreach (var assembly in assemblies)
+ {
+ EndpointGroupMapper.MapDiscoveredGroups(app, assembly);
+ }
+
+ return app;
+ }
+}
diff --git a/src/PANiXiDA.Core.Presentation.Http/Endpoints/EndpointConstants.cs b/src/PANiXiDA.Core.Presentation.Http/Endpoints/EndpointConstants.cs
new file mode 100644
index 0000000..15a1f99
--- /dev/null
+++ b/src/PANiXiDA.Core.Presentation.Http/Endpoints/EndpointConstants.cs
@@ -0,0 +1,12 @@
+namespace PANiXiDA.Core.Presentation.Http.Endpoints;
+
+///
+/// Provides common constants for HTTP API endpoint configuration.
+///
+public static class EndpointConstants
+{
+ ///
+ /// The versioned API route prefix.
+ ///
+ public const string EndpointPrefix = "/api/v{version:apiVersion}";
+}
diff --git a/src/PANiXiDA.Core.Presentation.Http/Endpoints/EndpointGroupMapper.cs b/src/PANiXiDA.Core.Presentation.Http/Endpoints/EndpointGroupMapper.cs
new file mode 100644
index 0000000..9e895c4
--- /dev/null
+++ b/src/PANiXiDA.Core.Presentation.Http/Endpoints/EndpointGroupMapper.cs
@@ -0,0 +1,50 @@
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+
+using System.Reflection;
+
+namespace PANiXiDA.Core.Presentation.Http.Endpoints;
+
+internal static class EndpointGroupMapper
+{
+ internal static void MapDiscoveredGroups(IEndpointRouteBuilder endpoints, Assembly assembly)
+ {
+ var groupTypes = GetGroupTypes(assembly);
+
+ foreach (var groupType in groupTypes)
+ {
+ var group = (IEndpointGroup)ActivatorUtilities.CreateInstance(
+ endpoints.ServiceProvider,
+ groupType);
+
+ group.Map(endpoints);
+ }
+ }
+
+ private static List GetGroupTypes(Assembly assembly)
+ {
+ var result = new List();
+
+ foreach (var type in assembly.GetTypes())
+ {
+ if (type.IsAbstract || type.IsInterface)
+ {
+ continue;
+ }
+
+ if (!typeof(IEndpointGroup).IsAssignableFrom(type))
+ {
+ continue;
+ }
+
+ result.Add(type);
+ }
+
+ result.Sort(static (left, right) =>
+ {
+ return StringComparer.Ordinal.Compare(left.FullName, right.FullName);
+ });
+
+ return result;
+ }
+}
diff --git a/src/PANiXiDA.Core.Presentation.Http/Endpoints/EndpointMapper.cs b/src/PANiXiDA.Core.Presentation.Http/Endpoints/EndpointMapper.cs
new file mode 100644
index 0000000..b9f9cdd
--- /dev/null
+++ b/src/PANiXiDA.Core.Presentation.Http/Endpoints/EndpointMapper.cs
@@ -0,0 +1,83 @@
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+
+using System.Reflection;
+
+namespace PANiXiDA.Core.Presentation.Http.Endpoints;
+
+///
+/// Discovers and maps endpoints that belong to the specified endpoint group.
+///
+public static class EndpointMapper
+{
+ ///
+ /// Finds endpoints for , creates them through the service provider, and maps them to the specified route group.
+ ///
+ /// The endpoint group type.
+ /// The route group to map endpoints to.
+ /// The service provider used to create endpoint instances.
+ public static void MapGroupEndpoints(
+ RouteGroupBuilder group,
+ IServiceProvider serviceProvider)
+ where TGroup : IEndpointGroup
+ {
+ var endpointTypes = GetEndpointTypes(typeof(TGroup).Assembly, typeof(TGroup));
+ var endpoints = new List();
+
+ foreach (var endpointType in endpointTypes)
+ {
+ var endpoint = (IEndpoint)ActivatorUtilities.CreateInstance(serviceProvider, endpointType);
+ endpoints.Add(endpoint);
+ }
+
+ foreach (var endpoint in endpoints)
+ {
+ endpoint.Map(group);
+ }
+ }
+
+ private static List GetEndpointTypes(Assembly assembly, Type groupType)
+ {
+ var result = new List();
+
+ foreach (var type in assembly.GetTypes())
+ {
+ if (type.IsAbstract || type.IsInterface)
+ {
+ continue;
+ }
+
+ var interfaces = type.GetInterfaces();
+
+ foreach (var interfaceType in interfaces)
+ {
+ if (!interfaceType.IsGenericType)
+ {
+ continue;
+ }
+
+ if (interfaceType.GetGenericTypeDefinition() != typeof(IEndpoint<>))
+ {
+ continue;
+ }
+
+ var genericArguments = interfaceType.GetGenericArguments();
+
+ if (genericArguments[0] != groupType)
+ {
+ continue;
+ }
+
+ result.Add(type);
+ break;
+ }
+ }
+
+ result.Sort(static (left, right) =>
+ {
+ return StringComparer.Ordinal.Compare(left.FullName, right.FullName);
+ });
+
+ return result;
+ }
+}
diff --git a/src/PANiXiDA.Core.Presentation.Http/Endpoints/IEndpoint.cs b/src/PANiXiDA.Core.Presentation.Http/Endpoints/IEndpoint.cs
new file mode 100644
index 0000000..bb075e5
--- /dev/null
+++ b/src/PANiXiDA.Core.Presentation.Http/Endpoints/IEndpoint.cs
@@ -0,0 +1,30 @@
+using Microsoft.AspNetCore.Routing;
+
+using System.Diagnostics.CodeAnalysis;
+
+namespace PANiXiDA.Core.Presentation.Http.Endpoints;
+
+///
+/// Represents an endpoint that can be mapped to an ASP.NET Core route group.
+///
+public interface IEndpoint
+{
+ ///
+ /// Maps the endpoint to the specified route group.
+ ///
+ /// The route group to map the endpoint to.
+ void Map(RouteGroupBuilder group);
+}
+
+///
+/// Represents an endpoint that belongs to a specific endpoint group.
+///
+/// The endpoint group type.
+[SuppressMessage(
+ "Major Code Smell",
+ "S2326:Unused type parameters should be removed",
+ Justification = "TGroup intentionally acts as a marker type for endpoint grouping.")]
+public interface IEndpoint : IEndpoint
+ where TGroup : IEndpointGroup
+{
+}
diff --git a/src/PANiXiDA.Core.Presentation.Http/Endpoints/IEndpointGroup.cs b/src/PANiXiDA.Core.Presentation.Http/Endpoints/IEndpointGroup.cs
new file mode 100644
index 0000000..32fc242
--- /dev/null
+++ b/src/PANiXiDA.Core.Presentation.Http/Endpoints/IEndpointGroup.cs
@@ -0,0 +1,15 @@
+using Microsoft.AspNetCore.Routing;
+
+namespace PANiXiDA.Core.Presentation.Http.Endpoints;
+
+///
+/// Represents an endpoint group that can be mapped to ASP.NET Core routes.
+///
+public interface IEndpointGroup
+{
+ ///
+ /// Maps the endpoint group to the specified route builder.
+ ///
+ /// The application route builder.
+ void Map(IEndpointRouteBuilder endpoints);
+}
diff --git a/src/PANiXiDA.Core.Presentation.Http/Helpers/ResultHttpMapper.cs b/src/PANiXiDA.Core.Presentation.Http/Helpers/ResultHttpMapper.cs
new file mode 100644
index 0000000..c99c9bd
--- /dev/null
+++ b/src/PANiXiDA.Core.Presentation.Http/Helpers/ResultHttpMapper.cs
@@ -0,0 +1,137 @@
+using Microsoft.AspNetCore.Http;
+
+using PANiXiDA.Core.ResultPattern;
+
+namespace PANiXiDA.Core.Presentation.Http.Helpers;
+
+///
+/// Provides methods for mapping operation results to Minimal API HTTP responses.
+///
+public static class ResultHttpMapper
+{
+ ///
+ /// Maps a non-generic result to an HTTP response.
+ ///
+ /// The operation result.
+ /// The HTTP response factory for a successful result.
+ /// The success HTTP response or a Problem Details response for an error.
+ public static IResult ToHttpResult(
+ this Result result,
+ Func onSuccess)
+ {
+ if (result.IsSuccess)
+ {
+ return onSuccess();
+ }
+
+ return CreateProblem(result.Errors);
+ }
+
+ ///
+ /// Maps a generic result to an HTTP response.
+ ///
+ /// The successful result value type.
+ /// The operation result.
+ /// The HTTP response factory for a successful result.
+ /// The success HTTP response or a Problem Details response for an error.
+ public static IResult ToHttpResult(
+ this Result result,
+ Func onSuccess)
+ {
+ if (result.IsSuccess)
+ {
+ return onSuccess(result.Value);
+ }
+
+ return CreateProblem(result.Errors);
+ }
+
+ ///
+ /// Maps a failed non-generic result to a Problem Details HTTP response.
+ ///
+ /// The operation result.
+ /// A Problem Details HTTP response built from the result errors.
+ public static IResult ToHttpProblem(this Result result)
+ {
+ return CreateProblem(result.Errors);
+ }
+
+ ///
+ /// Maps a failed generic result to a Problem Details HTTP response.
+ ///
+ /// The result value type.
+ /// The operation result.
+ /// A Problem Details HTTP response built from the result errors.
+ public static IResult ToHttpProblem(this Result result)
+ {
+ return CreateProblem(result.Errors);
+ }
+
+ private static IResult CreateProblem(IReadOnlyList errors)
+ {
+ var firstError = errors[0];
+ var statusCode = GetStatusCode(firstError.Type);
+
+ if (firstError.Type == ErrorType.Validation)
+ {
+ var validationErrors = errors
+ .GroupBy(GetFieldName)
+ .ToDictionary(
+ group => group.Key,
+ group => group
+ .Select(item => item.Message)
+ .Distinct()
+ .ToArray());
+
+ return TypedResults.ValidationProblem(
+ errors: validationErrors,
+ title: "One or more validation errors occurred.");
+ }
+
+ return TypedResults.Problem(
+ statusCode: statusCode,
+ title: GetTitle(firstError.Type),
+ detail: firstError.Message);
+ }
+
+ private static int GetStatusCode(ErrorType errorType)
+ {
+ return errorType switch
+ {
+ ErrorType.Validation => StatusCodes.Status400BadRequest,
+ ErrorType.NotFound => StatusCodes.Status404NotFound,
+ ErrorType.Conflict => StatusCodes.Status409Conflict,
+ ErrorType.Unauthorized => StatusCodes.Status401Unauthorized,
+ ErrorType.Forbidden => StatusCodes.Status403Forbidden,
+ ErrorType.Failure => StatusCodes.Status400BadRequest,
+ ErrorType.Unexpected => StatusCodes.Status500InternalServerError,
+ _ => StatusCodes.Status500InternalServerError
+ };
+ }
+
+ private static string GetTitle(ErrorType errorType)
+ {
+ return errorType switch
+ {
+ ErrorType.NotFound => "Resource not found",
+ ErrorType.Conflict => "Conflict",
+ ErrorType.Unauthorized => "Unauthorized",
+ ErrorType.Forbidden => "Forbidden",
+ ErrorType.Failure => "Request failed",
+ ErrorType.Unexpected => "Server error",
+ _ => "Server error"
+ };
+ }
+
+ private static string GetFieldName(Error error)
+ {
+ if (error.Metadata.TryGetValue(Error.FieldMetadataKey, out var field) &&
+ field is string fieldName &&
+ !string.IsNullOrWhiteSpace(fieldName))
+ {
+ return fieldName;
+ }
+
+ return "general";
+ }
+}
diff --git a/src/PANiXiDA.Core.Presentation.Http/Middlewares/ExceptionHandler.cs b/src/PANiXiDA.Core.Presentation.Http/Middlewares/ExceptionHandler.cs
new file mode 100644
index 0000000..397ce91
--- /dev/null
+++ b/src/PANiXiDA.Core.Presentation.Http/Middlewares/ExceptionHandler.cs
@@ -0,0 +1,73 @@
+using Microsoft.AspNetCore.Diagnostics;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+using System.Diagnostics;
+using System.Security.Claims;
+
+namespace PANiXiDA.Core.Presentation.Http.Middlewares;
+
+internal sealed class ExceptionHandler(
+ ILogger logger,
+ IHostEnvironment hostEnvironment) : IExceptionHandler
+{
+ public async ValueTask TryHandleAsync(
+ HttpContext httpContext,
+ Exception exception,
+ CancellationToken cancellationToken)
+ {
+ var activity = Activity.Current;
+ var endpoint = httpContext.GetEndpoint();
+
+ var userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
+ var userName = httpContext.User.Identity?.Name;
+
+ logger.LogError(
+ exception,
+ """
+ Unhandled HTTP exception.
+ TraceIdentifier: {TraceIdentifier}
+ TraceId: {TraceId}
+ SpanId: {SpanId}
+ Method: {Method}
+ Path: {Path}
+ QueryString: {QueryString}
+ Endpoint: {Endpoint}
+ UserId: {UserId}
+ UserName: {UserName}
+ RemoteIp: {RemoteIp}
+ UserAgent: {UserAgent}
+ """,
+ httpContext.TraceIdentifier,
+ activity?.TraceId.ToString(),
+ activity?.SpanId.ToString(),
+ httpContext.Request.Method,
+ httpContext.Request.Path.Value,
+ httpContext.Request.QueryString.Value,
+ endpoint?.DisplayName,
+ userId,
+ userName,
+ httpContext.Connection.RemoteIpAddress?.ToString(),
+ httpContext.Request.Headers.UserAgent.ToString());
+
+ var problemDetails = new ProblemDetails
+ {
+ Status = StatusCodes.Status500InternalServerError,
+ Title = "Internal server error",
+ Detail = hostEnvironment.IsDevelopment() ? exception.Message : null,
+ Extensions =
+ {
+ ["traceId"] = httpContext.TraceIdentifier,
+ ["activityTraceId"] = activity?.TraceId.ToString()
+ }
+ };
+
+ httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
+
+ await Results.Problem(problemDetails).ExecuteAsync(httpContext);
+
+ return true;
+ }
+}
diff --git a/src/PANiXiDA.Core.Presentation.Http/Middlewares/LoggingMiddleware.cs b/src/PANiXiDA.Core.Presentation.Http/Middlewares/LoggingMiddleware.cs
new file mode 100644
index 0000000..af76c0a
--- /dev/null
+++ b/src/PANiXiDA.Core.Presentation.Http/Middlewares/LoggingMiddleware.cs
@@ -0,0 +1,72 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+
+using System.Diagnostics;
+using System.Security.Claims;
+
+namespace PANiXiDA.Core.Presentation.Http.Middlewares;
+
+internal sealed class LoggingMiddleware(
+ RequestDelegate next,
+ ILogger logger)
+{
+ public async Task InvokeAsync(HttpContext httpContext)
+ {
+ var startedAt = Stopwatch.GetTimestamp();
+ var activity = Activity.Current;
+ var endpoint = httpContext.GetEndpoint();
+
+ var userId = httpContext.User.FindFirstValue(ClaimTypes.NameIdentifier);
+ var userName = httpContext.User.Identity?.Name;
+
+ using (logger.BeginScope(new Dictionary
+ {
+ ["Transport"] = "http",
+ ["TraceIdentifier"] = httpContext.TraceIdentifier,
+ ["TraceId"] = activity?.TraceId.ToString(),
+ ["SpanId"] = activity?.SpanId.ToString(),
+ ["Method"] = httpContext.Request.Method,
+ ["Path"] = httpContext.Request.Path.Value,
+ ["Endpoint"] = endpoint?.DisplayName,
+ ["UserId"] = userId,
+ ["UserName"] = userName,
+ ["RemoteIp"] = httpContext.Connection.RemoteIpAddress?.ToString(),
+ ["UserAgent"] = httpContext.Request.Headers.UserAgent.ToString(),
+ }))
+ {
+ try
+ {
+ await next(httpContext);
+ }
+ finally
+ {
+ var elapsed = Stopwatch.GetElapsedTime(startedAt);
+ var logLevel = GetLogLevel(httpContext.Response.StatusCode);
+
+ if (logger.IsEnabled(logLevel))
+ {
+ logger.Log(
+ logLevel,
+ "HTTP request finished with status code {StatusCode} in {ElapsedMs} ms",
+ httpContext.Response.StatusCode,
+ elapsed.TotalMilliseconds);
+ }
+ }
+ }
+ }
+
+ private static LogLevel GetLogLevel(int statusCode)
+ {
+ if (statusCode >= StatusCodes.Status500InternalServerError)
+ {
+ return LogLevel.Error;
+ }
+
+ if (statusCode >= StatusCodes.Status400BadRequest)
+ {
+ return LogLevel.Warning;
+ }
+
+ return LogLevel.Information;
+ }
+}
diff --git a/src/PANiXiDA.Core.Presentation.Http/PANiXiDA.Core.Presentation.Http.csproj b/src/PANiXiDA.Core.Presentation.Http/PANiXiDA.Core.Presentation.Http.csproj
new file mode 100644
index 0000000..8cce2f8
--- /dev/null
+++ b/src/PANiXiDA.Core.Presentation.Http/PANiXiDA.Core.Presentation.Http.csproj
@@ -0,0 +1,33 @@
+
+
+ true
+
+ PANiXiDA.Core.Presentation.Http
+
+ PANiXiDA.Core.Presentation.Http
+ Reusable HTTP presentation layer abstractions, endpoint conventions, and ASP.NET Core integration utilities for PANiXiDA applications.
+ PANiXiDA;Core;Presentation;Http;AspNetCore;WebApi;Endpoints;MinimalApi;ApiVersioning;Swagger;OpenApi;ProblemDetails
+
+ https://github.com/panixida-dotnet-core/presentation-http
+ https://github.com/panixida-dotnet-core/presentation-http
+
+
+
+
+
+
+
+
+
+
+
+ all
+
+
+
+
+
+
+
+
+
diff --git a/src/PANiXiDA.Core.Template/PANiXiDA.Core.Template.csproj b/src/PANiXiDA.Core.Template/PANiXiDA.Core.Template.csproj
deleted file mode 100644
index 03bc1a3..0000000
--- a/src/PANiXiDA.Core.Template/PANiXiDA.Core.Template.csproj
+++ /dev/null
@@ -1,20 +0,0 @@
-
-
- true
-
- PANiXiDA.Core.Template
-
- PANiXiDA.Core.Template
- Change me.
- Template;ChangeMe
-
- https://github.com/panixida-dotnet-core/template
- https://github.com/panixida-dotnet-core/template
-
-
-
-
- all
-
-
-
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/CodeCoverage.config.xml b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/CodeCoverage.config.xml
new file mode 100644
index 0000000..ac206bc
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/CodeCoverage.config.xml
@@ -0,0 +1,9 @@
+
+
+
+
+ .*[\\/]obj[\\/].*
+
+
+
+
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Configurations/ApiVersioningConfigurationTests.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Configurations/ApiVersioningConfigurationTests.cs
new file mode 100644
index 0000000..7c809d0
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Configurations/ApiVersioningConfigurationTests.cs
@@ -0,0 +1,40 @@
+using Asp.Versioning;
+using Asp.Versioning.ApiExplorer;
+
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+using PANiXiDA.Core.Presentation.Http.Configurations;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Configurations;
+
+public sealed class ApiVersioningConfigurationTests
+{
+ [Fact(DisplayName = "API versioning configuration registers expected options")]
+ public void AddApiVersioningConfiguration_ShouldRegisterExpectedOptions()
+ {
+ var services = new ServiceCollection();
+
+ var result = services.AddApiVersioningConfiguration();
+
+ result.Should().BeSameAs(services);
+
+ using var serviceProvider = services.BuildServiceProvider();
+
+ var apiVersioningOptions = serviceProvider
+ .GetRequiredService>()
+ .Value;
+
+ apiVersioningOptions.DefaultApiVersion.Should().Be(new ApiVersion(1, 0));
+ apiVersioningOptions.AssumeDefaultVersionWhenUnspecified.Should().BeFalse();
+ apiVersioningOptions.ReportApiVersions.Should().BeTrue();
+ apiVersioningOptions.ApiVersionReader.Should().BeOfType();
+
+ var apiExplorerOptions = serviceProvider
+ .GetRequiredService>()
+ .Value;
+
+ apiExplorerOptions.GroupNameFormat.Should().Be("'v'V");
+ apiExplorerOptions.SubstituteApiVersionInUrl.Should().BeTrue();
+ }
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Configurations/ForwardedHeadersConfigurationTests.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Configurations/ForwardedHeadersConfigurationTests.cs
new file mode 100644
index 0000000..63b2956
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Configurations/ForwardedHeadersConfigurationTests.cs
@@ -0,0 +1,85 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.HttpOverrides;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+using PANiXiDA.Core.Presentation.Http.Configurations;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Configurations;
+
+public sealed class ForwardedHeadersConfigurationTests
+{
+ [Fact(DisplayName = "ForwardedHeaders configuration enables default forwarded headers")]
+ public void AddForwardedHeadersConfiguration_ShouldUseDefaultHeaders()
+ {
+ var services = new ServiceCollection();
+ var defaultOptions = new ForwardedHeadersOptions();
+
+ var result = services.AddForwardedHeadersConfiguration(configuration: null);
+
+ var options = CreateOptions(services);
+
+ result.Should().BeSameAs(services);
+ options.ForwardedHeaders.Should().Be(
+ ForwardedHeaders.XForwardedFor |
+ ForwardedHeaders.XForwardedHost |
+ ForwardedHeaders.XForwardedProto);
+ options.KnownIPNetworks.Should().HaveCount(defaultOptions.KnownIPNetworks.Count);
+ options.KnownProxies.Should().HaveCount(defaultOptions.KnownProxies.Count);
+ }
+
+ [Fact(DisplayName = "ForwardedHeaders configuration binds standard options from configuration")]
+ public void AddForwardedHeadersConfiguration_ShouldBindStandardOptionsFromConfiguration()
+ {
+ var services = new ServiceCollection();
+ var configuration = CreateConfiguration(new Dictionary
+ {
+ [nameof(ForwardedHeadersOptions.ForwardedHeaders)] = nameof(ForwardedHeaders.XForwardedFor),
+ [nameof(ForwardedHeadersOptions.ForwardLimit)] = "2",
+ [nameof(ForwardedHeadersOptions.RequireHeaderSymmetry)] = "true",
+ [nameof(ForwardedHeadersOptions.AllowedHosts) + ":0"] = "api.example.test"
+ });
+
+ services.AddForwardedHeadersConfiguration(configuration);
+
+ var options = CreateOptions(services);
+
+ options.ForwardedHeaders.Should().Be(ForwardedHeaders.XForwardedFor);
+ options.ForwardLimit.Should().Be(2);
+ options.RequireHeaderSymmetry.Should().BeTrue();
+ options.AllowedHosts.Should().Equal("api.example.test");
+ }
+
+ [Fact(DisplayName = "ForwardedHeaders configuration accepts a custom section")]
+ public void AddForwardedHeadersConfiguration_ShouldBindStandardOptionsFromCustomSection()
+ {
+ var services = new ServiceCollection();
+ var configuration = CreateConfiguration(new Dictionary
+ {
+ ["Http:ForwardedHeaders:ForwardedHeaders"] = nameof(ForwardedHeaders.XForwardedHost),
+ ["Http:ForwardedHeaders:ForwardLimit"] = "5"
+ });
+
+ services.AddForwardedHeadersConfiguration(configuration.GetSection("Http:ForwardedHeaders"));
+
+ var options = CreateOptions(services);
+
+ options.ForwardedHeaders.Should().Be(ForwardedHeaders.XForwardedHost);
+ options.ForwardLimit.Should().Be(5);
+ }
+
+ private static ForwardedHeadersOptions CreateOptions(IServiceCollection services)
+ {
+ using var serviceProvider = services.BuildServiceProvider();
+
+ return serviceProvider.GetRequiredService>().Value;
+ }
+
+ private static IConfiguration CreateConfiguration(Dictionary values)
+ {
+ return new ConfigurationBuilder()
+ .AddInMemoryCollection(values)
+ .Build();
+ }
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Configurations/OpenApiConfigurationTests.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Configurations/OpenApiConfigurationTests.cs
new file mode 100644
index 0000000..11f2c65
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Configurations/OpenApiConfigurationTests.cs
@@ -0,0 +1,63 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+using PANiXiDA.Core.Presentation.Http.Configurations;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Configurations;
+
+public sealed class OpenApiConfigurationTests
+{
+ [Fact(DisplayName = "OpenAPI configuration registers services and returns the same collection")]
+ public void AddOpenApiConfiguration_ShouldReturnSameServiceCollection()
+ {
+ var services = new ServiceCollection();
+
+ var result = services.AddOpenApiConfiguration();
+
+ result.Should().BeSameAs(services);
+ }
+
+ [Fact(DisplayName = "OpenAPI configuration maps the specification endpoint in Development")]
+ public void UseOpenApiConfiguration_ShouldMapOpenApiEndpointInDevelopment()
+ {
+ var builder = WebApplication.CreateBuilder(new WebApplicationOptions
+ {
+ EnvironmentName = Environments.Development
+ });
+
+ builder.Services.AddOpenApiConfiguration();
+
+ using var app = builder.Build();
+
+ var result = app.UseOpenApiConfiguration();
+
+ result.Should().BeSameAs(app);
+ GetRoutePatterns(app).Should().Contain("/openapi/{documentName}.json");
+ }
+
+ [Fact(DisplayName = "OpenAPI configuration does not map the specification endpoint outside Development")]
+ public void UseOpenApiConfiguration_ShouldNotMapOpenApiEndpointOutsideDevelopment()
+ {
+ var builder = WebApplication.CreateBuilder(new WebApplicationOptions
+ {
+ EnvironmentName = Environments.Production
+ });
+
+ using var app = builder.Build();
+
+ var result = app.UseOpenApiConfiguration();
+
+ result.Should().BeSameAs(app);
+ GetRoutePatterns(app).Should().NotContain("/openapi/{documentName}.json");
+ }
+
+ private static List GetRoutePatterns(WebApplication app)
+ {
+ return [.. ((IEndpointRouteBuilder)app).DataSources
+ .SelectMany(static dataSource => dataSource.Endpoints)
+ .OfType()
+ .Select(static endpoint => endpoint.RoutePattern.RawText)];
+ }
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Configurations/ProblemDetailsConfigurationTests.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Configurations/ProblemDetailsConfigurationTests.cs
new file mode 100644
index 0000000..934cff7
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Configurations/ProblemDetailsConfigurationTests.cs
@@ -0,0 +1,82 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+
+using PANiXiDA.Core.Presentation.Http.Configurations;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Configurations;
+
+public sealed class ProblemDetailsConfigurationTests
+{
+ [Fact(DisplayName = "ProblemDetails configuration leaves regular ProblemDetails unchanged")]
+ public void AddProblemDetailsConfiguration_ShouldLeaveRegularProblemDetailsUnchanged()
+ {
+ var options = CreateOptions();
+ var problemDetails = new ProblemDetails
+ {
+ Title = "Validation failed"
+ };
+
+ var context = new ProblemDetailsContext
+ {
+ HttpContext = new DefaultHttpContext(),
+ ProblemDetails = problemDetails
+ };
+
+ options.CustomizeProblemDetails!(context);
+
+ context.ProblemDetails.Should().BeSameAs(problemDetails);
+ }
+
+ [Fact(DisplayName = "ProblemDetails configuration normalizes validation error keys to camelCase")]
+ public void AddProblemDetailsConfiguration_ShouldNormalizeValidationErrorKeys()
+ {
+ var options = CreateOptions();
+ var validationProblem = new HttpValidationProblemDetails(new Dictionary
+ {
+ ["UserName"] = ["Required"],
+ ["Address.ZipCode"] = ["Invalid"]
+ })
+ {
+ Title = "Validation failed",
+ Type = "https://example.test/validation",
+ Status = StatusCodes.Status400BadRequest,
+ Detail = "Invalid request",
+ Instance = "/users"
+ };
+
+ validationProblem.Extensions["code"] = "validation_failed";
+
+ var context = new ProblemDetailsContext
+ {
+ HttpContext = new DefaultHttpContext(),
+ ProblemDetails = validationProblem
+ };
+
+ options.CustomizeProblemDetails!(context);
+
+ var normalizedProblem = context.ProblemDetails.Should().BeOfType().Subject;
+
+ normalizedProblem.Errors.Should().ContainKey("userName");
+ normalizedProblem.Errors.Should().ContainKey("address.ZipCode");
+ normalizedProblem.Errors["userName"].Should().Equal("Required");
+ normalizedProblem.Errors["address.ZipCode"].Should().Equal("Invalid");
+ normalizedProblem.Title.Should().Be(validationProblem.Title);
+ normalizedProblem.Type.Should().Be(validationProblem.Type);
+ normalizedProblem.Status.Should().Be(validationProblem.Status);
+ normalizedProblem.Detail.Should().Be(validationProblem.Detail);
+ normalizedProblem.Instance.Should().Be(validationProblem.Instance);
+ normalizedProblem.Extensions.Should().ContainKey("code").WhoseValue.Should().Be("validation_failed");
+ }
+
+ private static ProblemDetailsOptions CreateOptions()
+ {
+ var services = new ServiceCollection();
+ services.AddProblemDetailsConfiguration();
+
+ using var serviceProvider = services.BuildServiceProvider();
+
+ return serviceProvider.GetRequiredService>().Value;
+ }
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/DependencyInjection/ServiceCollectionExtensionsTests.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/DependencyInjection/ServiceCollectionExtensionsTests.cs
new file mode 100644
index 0000000..6e0a709
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/DependencyInjection/ServiceCollectionExtensionsTests.cs
@@ -0,0 +1,76 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Options;
+
+using PANiXiDA.Core.Presentation.Http.DependencyInjection;
+using PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints;
+using PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints.Fixtures.Groups;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.DependencyInjection;
+
+public sealed class ServiceCollectionExtensionsTests
+{
+ [Fact(DisplayName = "AddHttp registers HTTP infrastructure and returns the same service collection")]
+ public void AddHttp_ShouldReturnSameServiceCollection()
+ {
+ var services = new ServiceCollection();
+ var configuration = new ConfigurationBuilder().Build();
+
+ var result = services.AddHttp(configuration);
+
+ result.Should().BeSameAs(services);
+ }
+
+ [Fact(DisplayName = "AddHttp applies ForwardedHeaders configuration")]
+ public void AddHttp_ShouldApplyForwardedHeadersConfiguration()
+ {
+ var services = new ServiceCollection();
+ var configuration = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary
+ {
+ [nameof(ForwardedHeadersOptions.ForwardLimit)] = "4"
+ })
+ .Build();
+
+ services.AddHttp(configuration);
+
+ using var serviceProvider = services.BuildServiceProvider();
+ var options = serviceProvider.GetRequiredService>().Value;
+
+ options.ForwardLimit.Should().Be(4);
+ }
+
+ [Fact(DisplayName = "AddHttp uses default settings when configuration is null")]
+ public void AddHttp_ShouldUseDefaultForwardedHeadersConfigurationIfConfigurationIsNull()
+ {
+ var services = new ServiceCollection();
+ IConfiguration? configuration = null;
+
+ var result = services.AddHttp(configuration);
+
+ result.Should().BeSameAs(services);
+ }
+
+ [Fact(DisplayName = "MapHttp adds middleware and maps groups from the provided assemblies")]
+ public void MapHttp_ShouldReturnSameApplicationAndMapEndpointGroups()
+ {
+ EndpointMappingRecorder.Clear();
+
+ var builder = WebApplication.CreateBuilder(new WebApplicationOptions
+ {
+ EnvironmentName = Environments.Production
+ });
+
+ builder.Services.AddHttp(builder.Configuration);
+
+ using var app = builder.Build();
+
+ var result = app.MapHttp(typeof(ADiscoveredEndpointGroup).Assembly);
+
+ result.Should().BeSameAs(app);
+ EndpointMappingRecorder.Entries.Should().Contain(nameof(ADiscoveredEndpointGroup));
+ EndpointMappingRecorder.Entries.Should().Contain(nameof(BDiscoveredEndpointGroup));
+ }
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/EndpointConstantsTests.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/EndpointConstantsTests.cs
new file mode 100644
index 0000000..b042235
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/EndpointConstantsTests.cs
@@ -0,0 +1,12 @@
+using PANiXiDA.Core.Presentation.Http.Endpoints;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints;
+
+public sealed class EndpointConstantsTests
+{
+ [Fact(DisplayName = "EndpointPrefix contains the versioned API prefix")]
+ public void EndpointPrefix_ShouldContainVersionedApiPrefix()
+ {
+ EndpointConstants.EndpointPrefix.Should().Be("/api/v{version:apiVersion}");
+ }
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/EndpointGroupMapperTests.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/EndpointGroupMapperTests.cs
new file mode 100644
index 0000000..089bba6
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/EndpointGroupMapperTests.cs
@@ -0,0 +1,26 @@
+using Microsoft.AspNetCore.Builder;
+
+using PANiXiDA.Core.Presentation.Http.Endpoints;
+using PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints.Fixtures.Groups;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints;
+
+public sealed class EndpointGroupMapperTests
+{
+ [Fact(DisplayName = "MapDiscoveredGroups maps discovered endpoint groups")]
+ public void MapDiscoveredGroups_ShouldMapDiscoveredConcreteGroups()
+ {
+ EndpointMappingRecorder.Clear();
+
+ var builder = WebApplication.CreateBuilder();
+ using var app = builder.Build();
+
+ EndpointGroupMapper.MapDiscoveredGroups(app, typeof(ADiscoveredEndpointGroup).Assembly);
+
+ EndpointMappingRecorder.Entries.Should().ContainInOrder(
+ nameof(ADiscoveredEndpointGroup),
+ nameof(BDiscoveredEndpointGroup));
+ EndpointMappingRecorder.Entries.Should().NotContain(nameof(AbstractDiscoveredEndpointGroup));
+ EndpointMappingRecorder.Entries.Should().NotContain(nameof(IDiscoveredEndpointGroup));
+ }
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/EndpointMapperTests.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/EndpointMapperTests.cs
new file mode 100644
index 0000000..4e35d07
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/EndpointMapperTests.cs
@@ -0,0 +1,31 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Extensions.DependencyInjection;
+
+using PANiXiDA.Core.Presentation.Http.Endpoints;
+using PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints.Fixtures.Endpoints;
+using PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints.Fixtures.Groups;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints;
+
+public sealed class EndpointMapperTests
+{
+ [Fact(DisplayName = "MapGroupEndpoints maps selected group endpoints by type name")]
+ public void MapGroupEndpoints_ShouldMapSelectedGroupEndpointsByTypeName()
+ {
+ EndpointMappingRecorder.Clear();
+
+ var services = new ServiceCollection();
+ using var serviceProvider = services.BuildServiceProvider();
+
+ var builder = WebApplication.CreateBuilder();
+ using var app = builder.Build();
+ var group = app.MapGroup("/test");
+
+ EndpointMapper.MapGroupEndpoints(group, serviceProvider);
+
+ EndpointMappingRecorder.Entries.Should().Equal(
+ nameof(EndpointWithComparableInterface),
+ nameof(FirstOrderedEndpoint),
+ nameof(SecondOrderedEndpoint));
+ }
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/EndpointMappingRecorder.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/EndpointMappingRecorder.cs
new file mode 100644
index 0000000..94ac2b6
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/EndpointMappingRecorder.cs
@@ -0,0 +1,25 @@
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints;
+
+internal static class EndpointMappingRecorder
+{
+ private static readonly AsyncLocal?> entries = new();
+
+ internal static IReadOnlyList Entries
+ {
+ get
+ {
+ return entries.Value ?? [];
+ }
+ }
+
+ internal static void Add(string entry)
+ {
+ entries.Value ??= [];
+ entries.Value.Add(entry);
+ }
+
+ internal static void Clear()
+ {
+ entries.Value = [];
+ }
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Candidates/ComparableOnlyEndpointCandidate.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Candidates/ComparableOnlyEndpointCandidate.cs
new file mode 100644
index 0000000..7cbe5bd
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Candidates/ComparableOnlyEndpointCandidate.cs
@@ -0,0 +1,9 @@
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints.Fixtures.Candidates;
+
+public sealed class ComparableOnlyEndpointCandidate : IComparable
+{
+ public int CompareTo(ComparableOnlyEndpointCandidate? other)
+ {
+ return 0;
+ }
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/AbstractOrderedEndpoint.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/AbstractOrderedEndpoint.cs
new file mode 100644
index 0000000..7a1e171
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/AbstractOrderedEndpoint.cs
@@ -0,0 +1,14 @@
+using Microsoft.AspNetCore.Routing;
+
+using PANiXiDA.Core.Presentation.Http.Endpoints;
+using PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints.Fixtures.Groups;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints.Fixtures.Endpoints;
+
+public abstract class AbstractOrderedEndpoint : IEndpoint
+{
+ public void Map(RouteGroupBuilder group)
+ {
+ EndpointMappingRecorder.Add(nameof(AbstractOrderedEndpoint));
+ }
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/EndpointWithComparableInterface.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/EndpointWithComparableInterface.cs
new file mode 100644
index 0000000..9270a52
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/EndpointWithComparableInterface.cs
@@ -0,0 +1,23 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Routing;
+
+using PANiXiDA.Core.Presentation.Http.Endpoints;
+using PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints.Fixtures.Groups;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints.Fixtures.Endpoints;
+
+public sealed class EndpointWithComparableInterface :
+ IEndpoint,
+ IComparable
+{
+ public int CompareTo(EndpointWithComparableInterface? other)
+ {
+ return 0;
+ }
+
+ public void Map(RouteGroupBuilder group)
+ {
+ EndpointMappingRecorder.Add(nameof(EndpointWithComparableInterface));
+ group.MapGet("/comparable", static () => "comparable");
+ }
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/FirstOrderedEndpoint.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/FirstOrderedEndpoint.cs
new file mode 100644
index 0000000..5bca9ac
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/FirstOrderedEndpoint.cs
@@ -0,0 +1,16 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Routing;
+
+using PANiXiDA.Core.Presentation.Http.Endpoints;
+using PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints.Fixtures.Groups;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints.Fixtures.Endpoints;
+
+public sealed class FirstOrderedEndpoint : IEndpoint
+{
+ public void Map(RouteGroupBuilder group)
+ {
+ EndpointMappingRecorder.Add(nameof(FirstOrderedEndpoint));
+ group.MapGet("/first", static () => "first");
+ }
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/IInterfaceOrderedEndpoint.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/IInterfaceOrderedEndpoint.cs
new file mode 100644
index 0000000..79f373d
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/IInterfaceOrderedEndpoint.cs
@@ -0,0 +1,8 @@
+using PANiXiDA.Core.Presentation.Http.Endpoints;
+using PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints.Fixtures.Groups;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints.Fixtures.Endpoints;
+
+public interface IInterfaceOrderedEndpoint : IEndpoint
+{
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/NonGenericEndpoint.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/NonGenericEndpoint.cs
new file mode 100644
index 0000000..4fc049a
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/NonGenericEndpoint.cs
@@ -0,0 +1,13 @@
+using Microsoft.AspNetCore.Routing;
+
+using PANiXiDA.Core.Presentation.Http.Endpoints;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints.Fixtures.Endpoints;
+
+public sealed class NonGenericEndpoint : IEndpoint
+{
+ public void Map(RouteGroupBuilder group)
+ {
+ EndpointMappingRecorder.Add(nameof(NonGenericEndpoint));
+ }
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/OtherGroupEndpoint.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/OtherGroupEndpoint.cs
new file mode 100644
index 0000000..56d1cf5
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/OtherGroupEndpoint.cs
@@ -0,0 +1,14 @@
+using Microsoft.AspNetCore.Routing;
+
+using PANiXiDA.Core.Presentation.Http.Endpoints;
+using PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints.Fixtures.Groups;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints.Fixtures.Endpoints;
+
+public sealed class OtherGroupEndpoint : IEndpoint
+{
+ public void Map(RouteGroupBuilder group)
+ {
+ EndpointMappingRecorder.Add(nameof(OtherGroupEndpoint));
+ }
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/SecondOrderedEndpoint.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/SecondOrderedEndpoint.cs
new file mode 100644
index 0000000..78dd9bb
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/SecondOrderedEndpoint.cs
@@ -0,0 +1,16 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Routing;
+
+using PANiXiDA.Core.Presentation.Http.Endpoints;
+using PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints.Fixtures.Groups;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints.Fixtures.Endpoints;
+
+public sealed class SecondOrderedEndpoint : IEndpoint
+{
+ public void Map(RouteGroupBuilder group)
+ {
+ EndpointMappingRecorder.Add(nameof(SecondOrderedEndpoint));
+ group.MapGet("/second", static () => "second");
+ }
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Groups/ADiscoveredEndpointGroup.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Groups/ADiscoveredEndpointGroup.cs
new file mode 100644
index 0000000..d157df0
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Groups/ADiscoveredEndpointGroup.cs
@@ -0,0 +1,13 @@
+using Microsoft.AspNetCore.Routing;
+
+using PANiXiDA.Core.Presentation.Http.Endpoints;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints.Fixtures.Groups;
+
+public sealed class ADiscoveredEndpointGroup : IEndpointGroup
+{
+ public void Map(IEndpointRouteBuilder endpoints)
+ {
+ EndpointMappingRecorder.Add(nameof(ADiscoveredEndpointGroup));
+ }
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Groups/AbstractDiscoveredEndpointGroup.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Groups/AbstractDiscoveredEndpointGroup.cs
new file mode 100644
index 0000000..5fdd1f6
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Groups/AbstractDiscoveredEndpointGroup.cs
@@ -0,0 +1,13 @@
+using Microsoft.AspNetCore.Routing;
+
+using PANiXiDA.Core.Presentation.Http.Endpoints;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints.Fixtures.Groups;
+
+public abstract class AbstractDiscoveredEndpointGroup : IEndpointGroup
+{
+ public void Map(IEndpointRouteBuilder endpoints)
+ {
+ EndpointMappingRecorder.Add(nameof(AbstractDiscoveredEndpointGroup));
+ }
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Groups/BDiscoveredEndpointGroup.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Groups/BDiscoveredEndpointGroup.cs
new file mode 100644
index 0000000..8b52a17
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Groups/BDiscoveredEndpointGroup.cs
@@ -0,0 +1,13 @@
+using Microsoft.AspNetCore.Routing;
+
+using PANiXiDA.Core.Presentation.Http.Endpoints;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints.Fixtures.Groups;
+
+public sealed class BDiscoveredEndpointGroup : IEndpointGroup
+{
+ public void Map(IEndpointRouteBuilder endpoints)
+ {
+ EndpointMappingRecorder.Add(nameof(BDiscoveredEndpointGroup));
+ }
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Groups/IDiscoveredEndpointGroup.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Groups/IDiscoveredEndpointGroup.cs
new file mode 100644
index 0000000..a1acba9
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Groups/IDiscoveredEndpointGroup.cs
@@ -0,0 +1,7 @@
+using PANiXiDA.Core.Presentation.Http.Endpoints;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints.Fixtures.Groups;
+
+public interface IDiscoveredEndpointGroup : IEndpointGroup
+{
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Groups/OrderedEndpointGroup.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Groups/OrderedEndpointGroup.cs
new file mode 100644
index 0000000..c1ce096
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Groups/OrderedEndpointGroup.cs
@@ -0,0 +1,12 @@
+using Microsoft.AspNetCore.Routing;
+
+using PANiXiDA.Core.Presentation.Http.Endpoints;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints.Fixtures.Groups;
+
+public sealed class OrderedEndpointGroup : IEndpointGroup
+{
+ public void Map(IEndpointRouteBuilder endpoints)
+ {
+ }
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Groups/OtherEndpointGroup.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Groups/OtherEndpointGroup.cs
new file mode 100644
index 0000000..1c6e30b
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Groups/OtherEndpointGroup.cs
@@ -0,0 +1,12 @@
+using Microsoft.AspNetCore.Routing;
+
+using PANiXiDA.Core.Presentation.Http.Endpoints;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints.Fixtures.Groups;
+
+public sealed class OtherEndpointGroup : IEndpointGroup
+{
+ public void Map(IEndpointRouteBuilder endpoints)
+ {
+ }
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Helpers/ResultHttpMapperTests.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Helpers/ResultHttpMapperTests.cs
new file mode 100644
index 0000000..e60d00e
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Helpers/ResultHttpMapperTests.cs
@@ -0,0 +1,187 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+
+using PANiXiDA.Core.Presentation.Http.Helpers;
+using PANiXiDA.Core.ResultPattern;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Helpers;
+
+public sealed class ResultHttpMapperTests
+{
+ [Fact(DisplayName = "ToHttpResult returns the factory response for a successful Result")]
+ public void ToHttpResult_ShouldReturnSuccessResultForSuccessfulResult()
+ {
+ var expected = TypedResults.Ok("created");
+ var result = Result.Success();
+
+ var httpResult = result.ToHttpResult(() => expected);
+
+ httpResult.Should().BeSameAs(expected);
+ }
+
+ [Fact(DisplayName = "ToHttpResult returns ProblemDetails for a failed Result")]
+ public void ToHttpResult_ShouldReturnProblemForFailedResult()
+ {
+ var result = Result.Failure(Error.NotFound("Order not found"));
+ var successInvoked = false;
+
+ var httpResult = result.ToHttpResult(() =>
+ {
+ successInvoked = true;
+
+ return TypedResults.Ok();
+ });
+
+ successInvoked.Should().BeFalse();
+
+ var problemDetails = AssertProblem(httpResult, StatusCodes.Status404NotFound);
+ problemDetails.Title.Should().Be("Resource not found");
+ problemDetails.Detail.Should().Be("Order not found");
+ }
+
+ [Fact(DisplayName = "ToHttpResult passes the value to the factory for a successful generic Result")]
+ public void ToHttpResult_ShouldPassValueToSuccessFactoryForSuccessfulGenericResult()
+ {
+ var expected = TypedResults.Ok("mapped");
+ var result = Result.Success("source");
+ string? receivedValue = null;
+
+ var httpResult = result.ToHttpResult(value =>
+ {
+ receivedValue = value;
+
+ return expected;
+ });
+
+ httpResult.Should().BeSameAs(expected);
+ receivedValue.Should().Be("source");
+ }
+
+ [Fact(DisplayName = "ToHttpResult returns ProblemDetails for a failed generic Result")]
+ public void ToHttpResult_ShouldReturnProblemForFailedGenericResult()
+ {
+ var result = Result.Failure(Error.Conflict("Order already exists"));
+ var successInvoked = false;
+
+ var httpResult = result.ToHttpResult(value =>
+ {
+ successInvoked = true;
+
+ return TypedResults.Ok(value);
+ });
+
+ successInvoked.Should().BeFalse();
+
+ var problemDetails = AssertProblem(httpResult, StatusCodes.Status409Conflict);
+ problemDetails.Title.Should().Be("Conflict");
+ problemDetails.Detail.Should().Be("Order already exists");
+ }
+
+ [Theory(DisplayName = "ToHttpProblem returns the expected status code and title")]
+ [MemberData(nameof(GetProblemCases))]
+ public void ToHttpProblem_ShouldReturnExpectedStatusCodeAndTitle(
+ ErrorType errorType,
+ string message,
+ int expectedStatusCode,
+ string expectedTitle)
+ {
+ var error = CreateError(errorType, message);
+ var result = Result.Failure(error);
+
+ var httpResult = result.ToHttpProblem();
+
+ var problemDetails = AssertProblem(httpResult, expectedStatusCode);
+ problemDetails.Title.Should().Be(expectedTitle);
+ problemDetails.Detail.Should().Be(message);
+ }
+
+ [Fact(DisplayName = "ToHttpProblem returns ProblemDetails for a generic Result")]
+ public void ToHttpProblem_ShouldReturnProblemForGenericResult()
+ {
+ var result = Result.Failure(Error.Unauthorized("Authentication required"));
+
+ var httpResult = result.ToHttpProblem();
+
+ var problemDetails = AssertProblem(httpResult, StatusCodes.Status401Unauthorized);
+ problemDetails.Title.Should().Be("Unauthorized");
+ problemDetails.Detail.Should().Be("Authentication required");
+ }
+
+ [Fact(DisplayName = "ToHttpProblem groups validation errors by field")]
+ public void ToHttpProblem_ShouldGroupValidationErrorsByField()
+ {
+ var result = Result.Failure(
+ [
+ Error.Validation("Required").WithField("email"),
+ Error.Validation("Required").WithField("email"),
+ Error.Validation("Too short").WithField("password"),
+ Error.Validation("General error"),
+ Error.Validation("Blank field").WithMetadata(Error.FieldMetadataKey, " "),
+ Error.Validation("Wrong metadata").WithMetadata(Error.FieldMetadataKey, 123)
+ ]);
+
+ var httpResult = result.ToHttpProblem();
+
+ var problemDetails = AssertValidationProblem(httpResult);
+ problemDetails.Title.Should().Be("One or more validation errors occurred.");
+ problemDetails.Errors["email"].Should().Equal("Required");
+ problemDetails.Errors["password"].Should().Equal("Too short");
+ problemDetails.Errors["general"].Should().Equal("General error", "Blank field", "Wrong metadata");
+ }
+
+ public static TheoryData GetProblemCases()
+ {
+ return new TheoryData
+ {
+ { ErrorType.NotFound, "Not found", StatusCodes.Status404NotFound, "Resource not found" },
+ { ErrorType.Conflict, "Conflict", StatusCodes.Status409Conflict, "Conflict" },
+ { ErrorType.Unauthorized, "Unauthorized", StatusCodes.Status401Unauthorized, "Unauthorized" },
+ { ErrorType.Forbidden, "Forbidden", StatusCodes.Status403Forbidden, "Forbidden" },
+ { ErrorType.Failure, "Failure", StatusCodes.Status400BadRequest, "Request failed" },
+ { ErrorType.Unexpected, "Unexpected", StatusCodes.Status500InternalServerError, "Server error" },
+ { (ErrorType)999, "Unknown", StatusCodes.Status500InternalServerError, "Server error" }
+ };
+ }
+
+ private static Error CreateError(ErrorType errorType, string message)
+ {
+ return errorType switch
+ {
+ ErrorType.NotFound => Error.NotFound(message),
+ ErrorType.Conflict => Error.Conflict(message),
+ ErrorType.Unauthorized => Error.Unauthorized(message),
+ ErrorType.Forbidden => Error.Forbidden(message),
+ ErrorType.Failure => Error.Failure(message),
+ ErrorType.Unexpected => Error.Unexpected(message),
+ _ => new Error(message, errorType, new Dictionary())
+ };
+ }
+
+ private static ProblemDetails AssertProblem(IResult httpResult, int expectedStatusCode)
+ {
+ httpResult.Should()
+ .BeAssignableTo()
+ .Which.StatusCode.Should()
+ .Be(expectedStatusCode);
+
+ var valueResult = httpResult.Should()
+ .BeAssignableTo>()
+ .Subject;
+
+ return valueResult.Value.Should().NotBeNull().And.BeOfType().Subject;
+ }
+
+ private static HttpValidationProblemDetails AssertValidationProblem(IResult httpResult)
+ {
+ httpResult.Should()
+ .BeAssignableTo()
+ .Which.StatusCode.Should()
+ .Be(StatusCodes.Status400BadRequest);
+
+ var valueResult = httpResult.Should()
+ .BeAssignableTo>()
+ .Subject;
+
+ return valueResult.Value.Should().NotBeNull().And.BeOfType().Subject;
+ }
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Middlewares/ExceptionHandlerTests.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Middlewares/ExceptionHandlerTests.cs
new file mode 100644
index 0000000..1649aee
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Middlewares/ExceptionHandlerTests.cs
@@ -0,0 +1,93 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+using PANiXiDA.Core.Presentation.Http.Middlewares;
+using PANiXiDA.Core.Presentation.Http.UnitTests.Support;
+
+using System.Diagnostics;
+using System.Text.Json;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Middlewares;
+
+public sealed class ExceptionHandlerTests
+{
+ [Fact(DisplayName = "TryHandleAsync returns ProblemDetails with exception details in Development")]
+ public async Task TryHandleAsync_ShouldWriteProblemDetailsWithExceptionMessageInDevelopment()
+ {
+ using var activity = new Activity("http-exception").Start();
+
+ var logger = new TestLogger();
+ var environment = new TestHostEnvironment
+ {
+ EnvironmentName = Environments.Development
+ };
+
+ using var serviceProvider = CreateRequestServices();
+ var httpContext = TestHttpContextFactory.CreateHttpContext(serviceProvider);
+ httpContext.Request.Method = HttpMethods.Get;
+ httpContext.Request.QueryString = new QueryString("?status=active");
+
+ var exception = new InvalidOperationException("Development failure");
+ var handler = new ExceptionHandler(logger, environment);
+
+ var handled = await handler.TryHandleAsync(httpContext, exception, CancellationToken.None);
+
+ handled.Should().BeTrue();
+ httpContext.Response.StatusCode.Should().Be(StatusCodes.Status500InternalServerError);
+
+ using var document = ReadResponseBody(httpContext);
+ var root = document.RootElement;
+ root.GetProperty("title").GetString().Should().Be("Internal server error");
+ root.GetProperty("status").GetInt32().Should().Be(StatusCodes.Status500InternalServerError);
+ root.GetProperty("detail").GetString().Should().Be("Development failure");
+ root.GetProperty("traceId").GetString().Should().Be(activity.Id);
+ root.GetProperty("activityTraceId").GetString().Should().Be(activity.TraceId.ToString());
+
+ var logEntry = logger.Entries.Should().ContainSingle().Subject;
+ logEntry.LogLevel.Should().Be(Microsoft.Extensions.Logging.LogLevel.Error);
+ logEntry.Exception.Should().BeSameAs(exception);
+ }
+
+ [Fact(DisplayName = "TryHandleAsync hides exception details outside Development")]
+ public async Task TryHandleAsync_ShouldHideExceptionMessageOutsideDevelopment()
+ {
+ Activity.Current = null;
+
+ var logger = new TestLogger();
+ var environment = new TestHostEnvironment
+ {
+ EnvironmentName = Environments.Production
+ };
+
+ using var serviceProvider = CreateRequestServices();
+ var httpContext = TestHttpContextFactory.CreateMinimalHttpContext(serviceProvider);
+ var handler = new ExceptionHandler(logger, environment);
+
+ var handled = await handler.TryHandleAsync(
+ httpContext,
+ new InvalidOperationException("Production failure"),
+ CancellationToken.None);
+
+ handled.Should().BeTrue();
+
+ using var document = ReadResponseBody(httpContext);
+ document.RootElement.TryGetProperty("detail", out _).Should().BeFalse();
+ }
+
+ private static ServiceProvider CreateRequestServices()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddProblemDetails();
+
+ return services.BuildServiceProvider();
+ }
+
+ private static JsonDocument ReadResponseBody(HttpContext httpContext)
+ {
+ httpContext.Response.Body.Position = 0;
+
+ return JsonDocument.Parse(httpContext.Response.Body);
+ }
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Middlewares/LoggingMiddlewareTests.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Middlewares/LoggingMiddlewareTests.cs
new file mode 100644
index 0000000..de58c82
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Middlewares/LoggingMiddlewareTests.cs
@@ -0,0 +1,104 @@
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Logging;
+
+using PANiXiDA.Core.Presentation.Http.Middlewares;
+using PANiXiDA.Core.Presentation.Http.UnitTests.Support;
+
+using System.Diagnostics;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Middlewares;
+
+public sealed class LoggingMiddlewareTests
+{
+ [Theory(DisplayName = "InvokeAsync writes the expected log level by response status")]
+ [InlineData(StatusCodes.Status204NoContent, LogLevel.Information)]
+ [InlineData(StatusCodes.Status404NotFound, LogLevel.Warning)]
+ [InlineData(StatusCodes.Status500InternalServerError, LogLevel.Error)]
+ public async Task InvokeAsync_ShouldWriteExpectedLogLevelByStatusCode(
+ int statusCode,
+ LogLevel expectedLogLevel)
+ {
+ using var activity = new Activity("http-request").Start();
+
+ var logger = new TestLogger();
+ var httpContext = TestHttpContextFactory.CreateHttpContext();
+ Task next(HttpContext context)
+ {
+ context.Response.StatusCode = statusCode;
+
+ return Task.CompletedTask;
+ }
+
+ var middleware = new LoggingMiddleware(next, logger);
+
+ await middleware.InvokeAsync(httpContext);
+
+ var logEntry = logger.Entries.Should().ContainSingle().Subject;
+ logEntry.LogLevel.Should().Be(expectedLogLevel);
+ logEntry.Message.Should().StartWith($"HTTP request finished with status code {statusCode}");
+
+ var scope = logger.Scopes.Should().ContainSingle().Subject;
+ var scopeValues = scope.Should().BeAssignableTo>().Subject;
+ scopeValues["Transport"].Should().Be("http");
+ scopeValues["TraceIdentifier"].Should().Be(httpContext.TraceIdentifier);
+ scopeValues["TraceId"].Should().Be(activity.TraceId.ToString());
+ scopeValues["SpanId"].Should().Be(activity.SpanId.ToString());
+ scopeValues["Method"].Should().Be(HttpMethods.Post);
+ scopeValues["Path"].Should().Be("/orders");
+ scopeValues["Endpoint"].Should().Be("Test endpoint");
+ scopeValues["UserId"].Should().Be("user-id");
+ scopeValues["UserName"].Should().Be("user-name");
+ scopeValues["RemoteIp"].Should().Be("127.0.0.1");
+ scopeValues["UserAgent"].Should().Be("UnitTest");
+ }
+
+ [Fact(DisplayName = "InvokeAsync logs request completion when the next middleware throws")]
+ public async Task InvokeAsync_ShouldLogRequestCompletionWhenNextMiddlewareThrows()
+ {
+ var logger = new TestLogger();
+ var httpContext = TestHttpContextFactory.CreateHttpContext();
+ var exception = new InvalidOperationException("Request failed");
+
+ Task next(HttpContext _)
+ {
+ throw exception;
+ }
+
+ var middleware = new LoggingMiddleware(next, logger);
+
+ var act = async () => await middleware.InvokeAsync(httpContext);
+
+ await act.Should().ThrowAsync().WithMessage("Request failed");
+ logger.Entries.Should().ContainSingle().Which.LogLevel.Should().Be(LogLevel.Information);
+ }
+
+ [Fact(DisplayName = "InvokeAsync supports requests without optional context")]
+ public async Task InvokeAsync_ShouldSupportRequestWithoutOptionalContext()
+ {
+ Activity.Current = null;
+
+ var logger = new TestLogger();
+ var httpContext = TestHttpContextFactory.CreateMinimalHttpContext();
+
+ static Task next(HttpContext context)
+ {
+ context.Response.StatusCode = StatusCodes.Status200OK;
+
+ return Task.CompletedTask;
+ }
+
+ var middleware = new LoggingMiddleware(next, logger);
+
+ await middleware.InvokeAsync(httpContext);
+
+ var scope = logger.Scopes.Should().ContainSingle().Subject;
+ var scopeValues = scope.Should().BeAssignableTo>().Subject;
+ scopeValues["TraceId"].Should().BeNull();
+ scopeValues["SpanId"].Should().BeNull();
+ scopeValues["Endpoint"].Should().BeNull();
+ scopeValues["UserId"].Should().BeNull();
+ scopeValues["UserName"].Should().BeNull();
+ scopeValues["RemoteIp"].Should().BeNull();
+ scopeValues["UserAgent"].Should().Be(string.Empty);
+ }
+}
diff --git a/tests/PANiXiDA.Core.Template.UnitTests/PANiXiDA.Core.Template.UnitTests.csproj b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/PANiXiDA.Core.Presentation.Http.UnitTests.csproj
similarity index 60%
rename from tests/PANiXiDA.Core.Template.UnitTests/PANiXiDA.Core.Template.UnitTests.csproj
rename to tests/PANiXiDA.Core.Presentation.Http.UnitTests/PANiXiDA.Core.Presentation.Http.UnitTests.csproj
index 7e03d53..08e4ba2 100644
--- a/tests/PANiXiDA.Core.Template.UnitTests/PANiXiDA.Core.Template.UnitTests.csproj
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/PANiXiDA.Core.Presentation.Http.UnitTests.csproj
@@ -1,6 +1,7 @@
-
+
Exe
+ $(TestingPlatformCommandLineArguments) --coverage-settings "$(MSBuildProjectDirectory)/CodeCoverage.config.xml"
@@ -11,7 +12,7 @@
-
+
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Support/TestHostEnvironment.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Support/TestHostEnvironment.cs
new file mode 100644
index 0000000..dda0d0f
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Support/TestHostEnvironment.cs
@@ -0,0 +1,15 @@
+using Microsoft.Extensions.FileProviders;
+using Microsoft.Extensions.Hosting;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Support;
+
+internal sealed class TestHostEnvironment : IHostEnvironment
+{
+ public string EnvironmentName { get; set; } = Environments.Production;
+
+ public string ApplicationName { get; set; } = "TestApplication";
+
+ public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
+
+ public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Support/TestHttpContextFactory.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Support/TestHttpContextFactory.cs
new file mode 100644
index 0000000..3624eac
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Support/TestHttpContextFactory.cs
@@ -0,0 +1,58 @@
+using Microsoft.AspNetCore.Http;
+
+using System.Net;
+using System.Security.Claims;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Support;
+
+internal static class TestHttpContextFactory
+{
+ internal static DefaultHttpContext CreateHttpContext(IServiceProvider? requestServices = null)
+ {
+ var httpContext = new DefaultHttpContext
+ {
+ TraceIdentifier = "trace-id",
+ User = new ClaimsPrincipal(new ClaimsIdentity(
+ [
+ new Claim(ClaimTypes.NameIdentifier, "user-id"),
+ new Claim(ClaimTypes.Name, "user-name")
+ ], "TestAuthentication"))
+ };
+
+ if (requestServices is not null)
+ {
+ httpContext.RequestServices = requestServices;
+ }
+
+ httpContext.Response.Body = new MemoryStream();
+ httpContext.Request.Method = HttpMethods.Post;
+ httpContext.Request.Path = "/orders";
+ httpContext.Request.Headers.UserAgent = "UnitTest";
+ httpContext.Connection.RemoteIpAddress = IPAddress.Parse("127.0.0.1");
+ httpContext.SetEndpoint(new Endpoint(
+ static context => Task.CompletedTask,
+ new EndpointMetadataCollection(),
+ "Test endpoint"));
+
+ return httpContext;
+ }
+
+ internal static DefaultHttpContext CreateMinimalHttpContext(IServiceProvider? requestServices = null)
+ {
+ var httpContext = new DefaultHttpContext
+ {
+ Response =
+ {
+ Body = new MemoryStream()
+ },
+ User = new ClaimsPrincipal()
+ };
+
+ if (requestServices is not null)
+ {
+ httpContext.RequestServices = requestServices;
+ }
+
+ return httpContext;
+ }
+}
diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Support/TestLogger.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Support/TestLogger.cs
new file mode 100644
index 0000000..1d8939f
--- /dev/null
+++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Support/TestLogger.cs
@@ -0,0 +1,62 @@
+using Microsoft.Extensions.Logging;
+
+namespace PANiXiDA.Core.Presentation.Http.UnitTests.Support;
+
+internal sealed class TestLogger : ILogger
+{
+ private readonly List entries = [];
+ private readonly List