From faa85ae447f163440e2c352416eb9224edf8dde1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Thu, 21 May 2026 16:01:01 +0400 Subject: [PATCH 1/3] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D0=BB?= =?UTF-8?q?=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=BD=D0=BE=D0=B9=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4=20=D0=B8=20=D1=82=D0=B5=D1=81=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Directory.Packages.props | 5 + ...nx => PANiXiDA.Core.Presentation.Http.slnx | 4 +- README.md | 372 ++++++++---------- .../ApiVersioningConfiguration.cs | 26 ++ .../ForwardedHeadersConfiguration.cs | 36 ++ .../Configurations/OpenApiConfiguration.cs | 31 ++ .../ProblemDetailsConfiguration.cs | 45 +++ .../ServiceCollectionExtensions.cs | 59 +++ .../Endpoints/EndpointConstants.cs | 12 + .../Endpoints/EndpointGroupMapper.cs | 50 +++ .../Endpoints/EndpointMapper.cs | 83 ++++ .../Endpoints/IEndpoint.cs | 30 ++ .../Endpoints/IEndpointGroup.cs | 15 + .../Helpers/ResultHttpMapper.cs | 137 +++++++ .../Middlewares/ExceptionHandler.cs | 73 ++++ .../Middlewares/LoggingMiddleware.cs | 72 ++++ .../PANiXiDA.Core.Presentation.Http.csproj | 33 ++ .../PANiXiDA.Core.Template.csproj | 20 - .../ApiVersioningConfigurationTests.cs | 40 ++ .../ForwardedHeadersConfigurationTests.cs | 85 ++++ .../OpenApiConfigurationTests.cs | 63 +++ .../ProblemDetailsConfigurationTests.cs | 82 ++++ .../ServiceCollectionExtensionsTests.cs | 76 ++++ .../Endpoints/EndpointConstantsTests.cs | 12 + .../Endpoints/EndpointGroupMapperTests.cs | 26 ++ .../Endpoints/EndpointMapperTests.cs | 31 ++ .../Endpoints/EndpointMappingRecorder.cs | 24 ++ .../ComparableOnlyEndpointCandidate.cs | 9 + .../Endpoints/AbstractOrderedEndpoint.cs | 14 + .../EndpointWithComparableInterface.cs | 23 ++ .../Endpoints/FirstOrderedEndpoint.cs | 16 + .../Endpoints/IInterfaceOrderedEndpoint.cs | 8 + .../Fixtures/Endpoints/NonGenericEndpoint.cs | 13 + .../Fixtures/Endpoints/OtherGroupEndpoint.cs | 14 + .../Endpoints/SecondOrderedEndpoint.cs | 16 + .../Groups/ADiscoveredEndpointGroup.cs | 13 + .../Groups/AbstractDiscoveredEndpointGroup.cs | 13 + .../Groups/BDiscoveredEndpointGroup.cs | 13 + .../Groups/IDiscoveredEndpointGroup.cs | 7 + .../Fixtures/Groups/OrderedEndpointGroup.cs | 12 + .../Fixtures/Groups/OtherEndpointGroup.cs | 12 + .../Helpers/ResultHttpMapperTests.cs | 187 +++++++++ .../Middlewares/ExceptionHandlerTests.cs | 93 +++++ .../Middlewares/LoggingMiddlewareTests.cs | 104 +++++ ...A.Core.Presentation.Http.UnitTests.csproj} | 4 +- .../Support/TestHostEnvironment.cs | 15 + .../Support/TestHttpContextFactory.cs | 58 +++ .../Support/TestLogger.cs | 62 +++ version.json | 6 +- 49 files changed, 2030 insertions(+), 224 deletions(-) rename PANiXiDA.Core.Template.slnx => PANiXiDA.Core.Presentation.Http.slnx (70%) create mode 100644 src/PANiXiDA.Core.Presentation.Http/Configurations/ApiVersioningConfiguration.cs create mode 100644 src/PANiXiDA.Core.Presentation.Http/Configurations/ForwardedHeadersConfiguration.cs create mode 100644 src/PANiXiDA.Core.Presentation.Http/Configurations/OpenApiConfiguration.cs create mode 100644 src/PANiXiDA.Core.Presentation.Http/Configurations/ProblemDetailsConfiguration.cs create mode 100644 src/PANiXiDA.Core.Presentation.Http/DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/PANiXiDA.Core.Presentation.Http/Endpoints/EndpointConstants.cs create mode 100644 src/PANiXiDA.Core.Presentation.Http/Endpoints/EndpointGroupMapper.cs create mode 100644 src/PANiXiDA.Core.Presentation.Http/Endpoints/EndpointMapper.cs create mode 100644 src/PANiXiDA.Core.Presentation.Http/Endpoints/IEndpoint.cs create mode 100644 src/PANiXiDA.Core.Presentation.Http/Endpoints/IEndpointGroup.cs create mode 100644 src/PANiXiDA.Core.Presentation.Http/Helpers/ResultHttpMapper.cs create mode 100644 src/PANiXiDA.Core.Presentation.Http/Middlewares/ExceptionHandler.cs create mode 100644 src/PANiXiDA.Core.Presentation.Http/Middlewares/LoggingMiddleware.cs create mode 100644 src/PANiXiDA.Core.Presentation.Http/PANiXiDA.Core.Presentation.Http.csproj delete mode 100644 src/PANiXiDA.Core.Template/PANiXiDA.Core.Template.csproj create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Configurations/ApiVersioningConfigurationTests.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Configurations/ForwardedHeadersConfigurationTests.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Configurations/OpenApiConfigurationTests.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Configurations/ProblemDetailsConfigurationTests.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/DependencyInjection/ServiceCollectionExtensionsTests.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/EndpointConstantsTests.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/EndpointGroupMapperTests.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/EndpointMapperTests.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/EndpointMappingRecorder.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Candidates/ComparableOnlyEndpointCandidate.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/AbstractOrderedEndpoint.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/EndpointWithComparableInterface.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/FirstOrderedEndpoint.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/IInterfaceOrderedEndpoint.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/NonGenericEndpoint.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/OtherGroupEndpoint.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Endpoints/SecondOrderedEndpoint.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Groups/ADiscoveredEndpointGroup.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Groups/AbstractDiscoveredEndpointGroup.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Groups/BDiscoveredEndpointGroup.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Groups/IDiscoveredEndpointGroup.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Groups/OrderedEndpointGroup.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/Fixtures/Groups/OtherEndpointGroup.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Helpers/ResultHttpMapperTests.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Middlewares/ExceptionHandlerTests.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Middlewares/LoggingMiddlewareTests.cs rename tests/{PANiXiDA.Core.Template.UnitTests/PANiXiDA.Core.Template.UnitTests.csproj => PANiXiDA.Core.Presentation.Http.UnitTests/PANiXiDA.Core.Presentation.Http.UnitTests.csproj} (77%) create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Support/TestHostEnvironment.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Support/TestHttpContextFactory.cs create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/Support/TestLogger.cs 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..43a16b8 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 -[![CI](https://github.com///actions/workflows/ci.yml/badge.svg)](https://github.com///actions/workflows/ci.yml) -[![NuGet](https://img.shields.io/nuget/v/.svg)](https://www.nuget.org/packages/) -[![NuGet downloads](https://img.shields.io/nuget/dt/.svg)](https://www.nuget.org/packages/) +[![CI](https://github.com/panixida-dotnet-core/presentation-http/actions/workflows/ci.yml/badge.svg)](https://github.com/panixida-dotnet-core/presentation-http/actions/workflows/ci.yml) +[![NuGet](https://img.shields.io/nuget/v/PANiXiDA.Core.Presentation.Http.svg)](https://www.nuget.org/packages/PANiXiDA.Core.Presentation.Http) +[![NuGet downloads](https://img.shields.io/nuget/dt/PANiXiDA.Core.Presentation.Http.svg)](https://www.nuget.org/packages/PANiXiDA.Core.Presentation.Http) [![Target Framework](https://img.shields.io/badge/target-net10.0-512BD4)](https://dotnet.microsoft.com/) -[![License](https://img.shields.io/github/license//.svg)](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](https://img.shields.io/github/license/panixida-dotnet-core/presentation-http.svg)](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 reports may also include 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/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..d60c27c --- /dev/null +++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/EndpointMappingRecorder.cs @@ -0,0 +1,24 @@ +namespace PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints; + +internal static class EndpointMappingRecorder +{ + private static readonly List entries = []; + + internal static IReadOnlyList Entries + { + get + { + return entries; + } + } + + internal static void Add(string entry) + { + entries.Add(entry); + } + + internal static void Clear() + { + entries.Clear(); + } +} 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 77% 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..61654a6 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,4 +1,4 @@ - + Exe @@ -11,7 +11,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 scopes = []; + + public IReadOnlyList Entries + { + get + { + return entries; + } + } + + public IReadOnlyList Scopes + { + get + { + return scopes; + } + } + + public IDisposable BeginScope(TState state) + where TState : notnull + { + scopes.Add(state); + + return new TestScope(); + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + entries.Add(new LogEntry(logLevel, eventId, formatter(state, exception), exception, state)); + } + + internal sealed record LogEntry( + LogLevel LogLevel, + EventId EventId, + string Message, + Exception? Exception, + object? State); + + private sealed class TestScope : IDisposable + { + public void Dispose() + { + } + } +} diff --git a/version.json b/version.json index 9ed2b25..3b04b87 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json", - "version": "1.0", + "version": "1.0-preview", "publicReleaseRefSpec": [ "^refs/heads/main$", "^refs/heads/master$" @@ -11,7 +11,7 @@ } }, "pathFilters": [ - ":/src/PANiXiDA.Core.Template/", + ":/src/PANiXiDA.Core.Presentation.Http/", ":/Directory.Build.props", ":/Directory.Build.targets", ":/Directory.Packages.props", @@ -19,5 +19,5 @@ ":/version.json", ":/README.md", ":/icon.png" - ] + ] } From 09e6c94ba0bc55eab0bb42e18f9ebedd99bdb10f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Thu, 21 May 2026 16:11:16 +0400 Subject: [PATCH 2/3] test: isolate endpoint mapping recorder --- .../Endpoints/EndpointMappingRecorder.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/EndpointMappingRecorder.cs b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/EndpointMappingRecorder.cs index d60c27c..94ac2b6 100644 --- a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/EndpointMappingRecorder.cs +++ b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/Endpoints/EndpointMappingRecorder.cs @@ -2,23 +2,24 @@ namespace PANiXiDA.Core.Presentation.Http.UnitTests.Endpoints; internal static class EndpointMappingRecorder { - private static readonly List entries = []; + private static readonly AsyncLocal?> entries = new(); internal static IReadOnlyList Entries { get { - return entries; + return entries.Value ?? []; } } internal static void Add(string entry) { - entries.Add(entry); + entries.Value ??= []; + entries.Value.Add(entry); } internal static void Clear() { - entries.Clear(); + entries.Value = []; } } From 9ef963603c25b252129639662a14142b7b026cae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9C=D0=B8=D1=85=D0=B0=D0=B8=D0=BB?= Date: Thu, 21 May 2026 16:26:45 +0400 Subject: [PATCH 3/3] test: exclude generated files from coverage --- README.md | 2 +- .../CodeCoverage.config.xml | 9 +++++++++ .../PANiXiDA.Core.Presentation.Http.UnitTests.csproj | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 tests/PANiXiDA.Core.Presentation.Http.UnitTests/CodeCoverage.config.xml diff --git a/README.md b/README.md index 43a16b8..4943884 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ Run coverage: 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 reports may also include generated files under `obj/` from ASP.NET Core and validation source generators. +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. ## Package Contents 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/PANiXiDA.Core.Presentation.Http.UnitTests.csproj b/tests/PANiXiDA.Core.Presentation.Http.UnitTests/PANiXiDA.Core.Presentation.Http.UnitTests.csproj index 61654a6..08e4ba2 100644 --- a/tests/PANiXiDA.Core.Presentation.Http.UnitTests/PANiXiDA.Core.Presentation.Http.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"