diff --git a/docs/design-principles/0020-api-framework.md b/docs/design-principles/0020-api-framework.md index 8297c506..3cf270fa 100644 --- a/docs/design-principles/0020-api-framework.md +++ b/docs/design-principles/0020-api-framework.md @@ -2,52 +2,194 @@ ## Design Drivers -1. We want to leverage core supported Microsoft ASP.NET facilities, rather than some other bespoke framework (like ServiceStack.net). -2. We are choosing Minimal API's over Controllers. -3. We want to model Requests and Responses that are related, easy to validate and in one place the code, We desire the [REPR design pattern](https://deviq.com/design-patterns/repr-design-pattern). -4. However, the standard Minimal API patterns are difficult to organize and maintain in larger codebases. -5. We want a design that is easier to define and organize the API into modules, but yet have them be realised as Minimal APIs. -6. We want to have automatic validation, as whole requests -7. We want to [MediatR](https://github.com/jbogard/MediatR) handlers, to make Minimal API registration and dependency injection easier +1. We want to leverage standard-supported Microsoft ASP.NET web infrastructure (that is well known across the developer community), rather than learning another web framework (like ServiceStack.net - as brilliant as it is). + - We are choosing ASP.NET Minimal API's over ASP.NET Controllers. +2. We want to deal with Request and Responses that are related and organized into one layer in the code. We favor the [REPR design pattern](https://deviq.com/design-patterns/repr-design-pattern). + - We are choosing to use MediatR to relate the requests and responses and endpoints into handlers +3. Minimal API examples (that you learn from) are simple to get started with, but difficult to organize and maintain in larger codebases especially when we are separating concerns into different layers. + - We are seeking patterns that allow us to separate concerns and slit them into de-coupled layers + +4. Web APIs are most often related to subdomains (and/or audiences) and typically grouped together for easier organization. We want a design that is easier to define and organize the API into pluggable modules. + - We are choosing to encapsulate all web host configurations into one place for reuse across one or more web hosts. + - We are choosing to implement a pluggable module pattern, (with host reusable host configuration) that makes moving and grouping multiple subdomains of APIs between web hosts easy + - We are choosing to support a bespoke pattern for aggregating related APIs into a single class, to simplify declarative syntaxes. We are choosing to use source generators to convert this code into Mediatr handlers +5. We want simple cross-cutting concerns like validation, authentication, rate-limiting, etc. to be easily applied at the module level, or at individual endpoint level. + - We are choosing to use FluentValidation + MediatR to wire-up and automatically validate all requests (where a validator is provided by the author) +6. We want all aspects of the web API to be testable. + - We are choosing to use MediatR to support dependency injection into handlers +7. We want to support `async` to offer the option to optimize IO-heavy request workloads further down the stack. + - All API declarations will be `async` by default +8. We are striving to establish simple-to-understand patterns for the API author while using essential 3rd party libraries, but at the same time, limit the number of dependencies on 3rd party libraries. + +## Implementation + +### Overview + +We are establishing our own authoring patterns built on top of ASP.NET Minimal API, using MediatR handlers, that make it easier to declare and organize endpoints into groups within subdomains. + +We are then leveraging FluentValidation for request validation. + +We are integrating standard ASP.NET services like Authentication and Authorization. + ### Modularity -One of the distinguishing design principles of a Modular Monolith over a Monolith is the ability to deploy any, all or some the API's in any number of deployment units. Taken to the extreme, you would end up with granular microservices. But smaller steps are very acceptable depending on the stage of the SaaS product. +One of the distinguishing design principles of a Modular Monolith over a Monolith is the ability to deploy any, all, or some of the subdomains/vertical slices (which includes its APIs) in any number of deployment units. + +Taking this to the extreme of one subdomain/vertical slice per web host, you would end up with granular microservices. However, smaller steps towards that full microservices implementation are also very necessary to balance cost with complexity in distributed systems as they expand, depending on the stage of the SaaS product. + +> We recommend starting with one deployment unit (a.k.a Monolith). Then, next, as load increases on the system, identify the "hot" subdomains and move them to their own web host, while grouping the remaining subdomains together into other hosts. Continue like this until you have a suitable balance of subdomains and hosts, that can be afforded. + +The ability to deploy any (vertical slice/subdomain) of the code to a separate web host, should be quick and easy to accomplish, without expensive re-engineering. This is the primary value of starting with a modular monolith. + +One of the essential things that has to be easy to do, is to group some endpoints (of a subdomain) with all the other components of the vertical slice and host it in any deployable unit. + +> Communications between subdomains will already be decoupled via adapters and buses/queues. + +This is how it is done. + +1. Each WebApi project (one per vertical slice/subdomain) will define one or more API classes derived from `IWebApiService`. (See next section for details) +2. The WebApi project will reference a custom Source Generator to convert the `IWebApiService` into one or more MediatR handlers and Minimal API registrations. + - Include the following XML in the `*.csproj` file of your WebApi project, + ```xml + + + + + ``` +4. These handlers and registrations will be automatically wired into the ASP.NET runtime in the web host `Program.cs`. +5. All dependencies of the WebApi project will be registered in the ASP.NET runtime automatically. + +To make all this happen, a module class derived from `ISubDomainModule` needs to be created in each WebApi project. + +In this class, you will need to declare the following things: + +1. The assembly containing the API classes derived from `IWebApiService` is usually the same assembly where this module is defined. +2. Make a call to the `app.RegisterRoutes()` method on the Source Generated class called `MinimalApiRegistration`. Which also usually exists in the same assembly as the where this module is defined. +3. Register any dependencies that your WebApi project has for the endpoints, and dependencies for the remaining components in the layers of the subdomain/vertical slice. + +Finally, this custom module class is then added to the list of other modules in `HostedModules` class, alongside the `Program.cs` of the web host project where this API is to be hosted. + +### Endpoints -The ability to deploy any (Subdomain) of the code to a separate web host, should be quick and easy to accomplish. +The design of Minimal APIs makes developing 10s or 100s of them in a single project quite unwieldy. And the examples being learned from do little to demonstrate how to separate concerns within them in more complex systems. Since they are registered as handlers, there is no concept of groups of APIs. Whereas many API endpoints are naturally grouped or categorized. This is certainly the case when exposing a whole vertical slice/subdomain. -One of the things that has to be easy to do, is to register who the endpoints of a subdomain in whatever host you like, as well as all its dependencies. +We have designed a bespoke pattern and grouping mechanism for related endpoints, that results in Minimal APIs. -With minimal API's there should be a modular way of registering both its endpoints and handlers, and then moving them to other hosts later. +1. There is typically one WebApi project per vertical slice/subdomain. (However multiple projects are possible for supporting separating audiences). -### Organisation + > For example, `CarsApi` -The design of Minimal API's makes developing 10s or 100s of them in a single project quite unwieldy. They certainly would not live in one file. +2. In the WebApi project, you will define one or more API classes derived from `IWebApiService`. + > For example, `CarsApi.cs` +3. In that class, you will define one or more endpoints (service operations) as instance methods. + > For example, + > ```c# + > public async Task Get(GetCarRequest request, CancellationToken cancellationToken) + > ``` +4. You will define the request and response types in separate files in the project: `Infrastructure.WebAPi.Interfaces` in a subfolder for the subdomain. + > For example, `Infrastructure.WebApi.Interfaces/Operations/Cars/GetCarRequest.cs` and `Infrastructure.WebApi.Interfaces/Operations/Cars/GetCarResponse.cs` + > The request class derives from `IWebRequest` and the response class derives from `IWebResponse` +6. You decorate the service operation/endpoint method with a `[WebApiRoute]` and define the route template and operation type: `Get`, `Search`, `Post`, `Put`, `Patch,` or `Delete`. + > For example: + > + > ```c# + > [WebApiRoute("/cars/{id}", WebApiOperation.Get)] + > ``` + > Note: the default API guidelines recommend using `PutPatch` in place of `Put` and `Patch`. +7. You inject any dependencies into a custom constructor. + > Note: The constructor with the most number of parameters will be used at runtime. +8. You would then create a request validator, for each endpoint/service operation. + > For example, named `GetCarRequestValidator`. -Since they are registered as handlers, there is no concept of groups of API's. Whereas many API endpoints are naturally grouped or categorized. This is certainly the case when exposing subdomains. +Normally, the endpoint code will simply delegate the HTTP request down to the next layer, which is the Application layer. There is not much else to do unless the API endpoint deals with streams, files, or other kinds of HTTP requests. -When using MediatR to register handlers for minimal API's, and with dependency injection, it becomes quite tedious and repetitive to write a handler class for every route, when many routes are grouped and will be sharing the same dependencies. +> All the other cross-cutting concerns, like exception handling, logging, validation, authentication etc are taken care of elsewhere in the web framework. -There are better ways to organize these groups of endpoints into classes, and test them more easily. +This method will simply map the request object into simple primitives (or DTOs) data types, and feed them to the next layer as function parameters. It will be returned a resource (as a DTO) from the Application layer, and this function will simply include that in the HTTP response. + +For example, + +```c# + [WebApiRoute("/cars/{id}", WebApiOperation.Get)] + public async Task Get(GetCarRequest request, CancellationToken cancellationToken) + { + var car = await _carsApplication.GetCarAsync(new CallerContext(), request.Id, cancellationToken); + return Results.Ok(new GetCarResponse { Car = car }); + } +``` + +> Note: you cannot pass request objects to the next layer, as that would mean a dependency in the wrong direction. +> +> Note: In most cases, there is no need for any mapping code in this method (apart from simple object deconstruction to function parameters) This saves the author from defining another layer of mapping code. + +Therefore, this layer is generally pretty simple, and, as such, does not usually warrant any unit testing. API Integration tests will fully cover and ensure that this layer is wired up correctly. + +> With the exception of the cases where the method is doing more than just delegating a simple call to the application layer. ### Validation -When you design endpoints you want the requests and responses to be coupled, and you want the requests to be validated automatically when requests come in. Writing wiring code for validation is lso very tedious and error prone, and so is writing code to response with errors in a consistent manner. +We are using [FluentValidation](https://docs.fluentvalidation.net/) to validate all API requests. + +FluentValidation validates the whole HTTP request as one document, and it is capable of providing detailed messages for one or more violations in the same request. + +> Validation messages (`HTTP 400 - BadRequest`) messages usually contain full details about what is wrong with the request for each of the violations + +As an author of an API endpoint, simply create a `AbstractValidator` class for each request, in your WebApi project, and it will be wired up automatically and will be executed automatically at runtime. + +1. Create a validator class for each request in an endpoint. + > For example, `public class GetCarRequestValidator : AbstractValidator` +2. Create a default constructor, and add one or more `RuleFor` statements to verify that the data in the request is valid for this request. + > For example, + > + > ```c# + > RuleFor(req => req.Id) + > .IsEntityId() + > .WithMessage(Properties.Resources.GetCarRequestValidator_InvalidId); + > ``` + > + > Note: You can inject dependencies as parameters to the constructor +3. Create a new resource string in the `Resources.resx` of the WebAPi project. +4. Write a unit test spec in the unit tests project of your WebApi project. + +### Async + +All API endpoints (service operations) will be declared as `async`, in the API layer. + +While this may not be necessary, this is to support async operations in lower layers of the vertical slice/subdomain. + +> Note: To support `async` properly anywhere within a single HTTP request, we need to `async` from the entry point down all the way to the response. + +### Authentication and Authorization + +TBD + +### Exception Handling + +TBD + +### Logging + +TBD -We want the codebase to make validation easier to do, and apply it automatically and have standard ways to report errors detected by it. +### Wire Formats -## Configuring API's +TBD -All APIs will be defined in a separate project that is initially part of a subdomain group of code. That project can then be registered as a module into a specific web host, and with it all the endpoints, handlers, dependencies needed for all layers of the subdomain. +- what are the options, how to ask for others, what is the default? +- Dates and other data type formats for JSON, and how to change? +- Casing for JSON? and how to change -The web host, will then code generate the endpoint declarations and handlers and register them with Minimal API, and other components can be registered with the IoC. +### Rate Limiting -### Reference the Source Generator +TBD -Every API project must reference the Source Generators in `Infrastructure.WebApi.Generators`. +### Swagger -EveryAPI must provide a plugin. +TBD -The plugin will then automatically call the source-generated registration code, update the runtime configuration of the web host and populate the IoC automatically. +# Credits -The configuration of the web host and its features will be encapsulated and provided by various extension methods, so that all API hosts are consistent. \ No newline at end of file +Many of the implementation patterns were inspired by content created by [Nick Chapsas](https://www.youtube.com/@nickchapsas) \ No newline at end of file diff --git a/src/ApiHost1/HostedModules.cs b/src/ApiHost1/HostedModules.cs new file mode 100644 index 00000000..1ac24df1 --- /dev/null +++ b/src/ApiHost1/HostedModules.cs @@ -0,0 +1,18 @@ +using CarsApi; +using Infrastructure.WebApi.Common; + +namespace ApiHost1; + +public static class HostedModules +{ + public static SubDomainModules Get() + { + // EXTEND: Add the sub domain of each API, to host in this project. + // NOTE: The order of these registrations will matter for some dependencies + var modules = new SubDomainModules(); + modules.Register(new CarsApiModule()); + modules.Register(new TestingOnlyApiModule()); + + return modules; + } +} \ No newline at end of file diff --git a/src/ApiHost1/Program.cs b/src/ApiHost1/Program.cs index 1d723913..d901e493 100644 --- a/src/ApiHost1/Program.cs +++ b/src/ApiHost1/Program.cs @@ -1,28 +1,25 @@ -using CarsApi; -using CarsApplication; +using ApiHost1; using Infrastructure.WebApi.Common; using JetBrains.Annotations; -//TODO: Add the modules of each API here -var modules = new SubDomainModules(); -modules.Register(new Module()); -modules.Register(new ApiHost1.Module()); +var modules = HostedModules.Get(); var builder = WebApplication.CreateBuilder(args); +builder.Services.RegisterValidators(modules.ApiAssemblies, out var validators); builder.Services.AddMediatR(configuration => { - configuration.RegisterServicesFromAssemblies(modules.HandlerAssemblies.ToArray()); + configuration.RegisterServicesFromAssemblies(modules.ApiAssemblies.ToArray()) + .AddValidatorBehaviors(validators, modules.ApiAssemblies); }); -builder.Services.AddScoped(); modules.RegisterServices(builder.Configuration, builder.Services); var app = builder.Build(); -//TODO: need to add validation (https://www.youtube.com/watch?v=XKN0084p7WQ) //TODO: need to add authentication/authorization (https://www.youtube.com/watch?v=XKN0084p7WQ) //TODO: need to add swaggerUI (https://www.youtube.com/watch?v=XKN0084p7WQ) +//TODO: Handle Result types, and not throwing exceptions to represent responses (Desriminating types) modules.ConfigureHost(app); diff --git a/src/ApiHost1/Module.cs b/src/ApiHost1/TestingOnlyApiModule.cs similarity index 79% rename from src/ApiHost1/Module.cs rename to src/ApiHost1/TestingOnlyApiModule.cs index 17387fac..95874c62 100644 --- a/src/ApiHost1/Module.cs +++ b/src/ApiHost1/TestingOnlyApiModule.cs @@ -3,7 +3,7 @@ namespace ApiHost1; -public class Module : ISubDomainModule +public class TestingOnlyApiModule : ISubDomainModule { public Action MinimalApiRegistrationFunction { @@ -12,7 +12,7 @@ public Action MinimalApiRegistrationFunction public Action RegisterServicesFunction { - get { return (configuration, services) => { }; } + get { return (_, _) => { }; } } public Assembly ApiAssembly => typeof(Program).Assembly; diff --git a/src/CarsApi/CarsApi.cs b/src/CarsApi/Cars/CarsApi.cs similarity index 96% rename from src/CarsApi/CarsApi.cs rename to src/CarsApi/Cars/CarsApi.cs index 4aa15fa0..aa340962 100644 --- a/src/CarsApi/CarsApi.cs +++ b/src/CarsApi/Cars/CarsApi.cs @@ -4,7 +4,7 @@ using Infrastructure.WebApi.Interfaces.Operations.Cars; using Microsoft.AspNetCore.Http; -namespace CarsApi; +namespace CarsApi.Cars; public class CarsApi : IWebApiService { diff --git a/src/CarsApi/Cars/GetCarRequestValidator.cs b/src/CarsApi/Cars/GetCarRequestValidator.cs new file mode 100644 index 00000000..f14a5ea0 --- /dev/null +++ b/src/CarsApi/Cars/GetCarRequestValidator.cs @@ -0,0 +1,12 @@ +using FluentValidation; +using Infrastructure.WebApi.Interfaces.Operations.Cars; + +namespace CarsApi.Cars; + +public class GetCarRequestValidator : AbstractValidator +{ + public GetCarRequestValidator() + { + RuleFor(req => req.Id).NotEmpty().Matches(@"[\d]{1,3}"); + } +} \ No newline at end of file diff --git a/src/CarsApi/Module.cs b/src/CarsApi/CarsApiModule.cs similarity index 63% rename from src/CarsApi/Module.cs rename to src/CarsApi/CarsApiModule.cs index 4c7fc991..dd1577dc 100644 --- a/src/CarsApi/Module.cs +++ b/src/CarsApi/CarsApiModule.cs @@ -1,4 +1,5 @@ using System.Reflection; +using CarsApplication; using Infrastructure.WebApi.Common; using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.Configuration; @@ -6,7 +7,7 @@ namespace CarsApi; -public class Module : ISubDomainModule +public class CarsApiModule : ISubDomainModule { public Action MinimalApiRegistrationFunction { @@ -15,8 +16,8 @@ public Action MinimalApiRegistrationFunction public Action RegisterServicesFunction { - get { return (configuration, services) => { }; } + get { return (_, services) => { services.AddScoped(); }; } } - public Assembly ApiAssembly => typeof(CarsApi).Assembly; + public Assembly ApiAssembly => typeof(Cars.CarsApi).Assembly; } \ No newline at end of file diff --git a/src/Common/Extensions/CollectionExtensions.cs b/src/Common/Extensions/CollectionExtensions.cs new file mode 100644 index 00000000..aac87068 --- /dev/null +++ b/src/Common/Extensions/CollectionExtensions.cs @@ -0,0 +1,14 @@ +namespace Common.Extensions; + +public static class CollectionExtensions +{ + public static bool HasAny(this IEnumerable collection) + { + return !collection.HasNone(); + } + + public static bool HasNone(this IEnumerable collection) + { + return !collection.Any(); + } +} \ No newline at end of file diff --git a/src/Infrastructure.WebApi.Common.UnitTests/ServiceCollectionExtensionsSpec.cs b/src/Infrastructure.WebApi.Common.UnitTests/ServiceCollectionExtensionsSpec.cs new file mode 100644 index 00000000..7aea48fe --- /dev/null +++ b/src/Infrastructure.WebApi.Common.UnitTests/ServiceCollectionExtensionsSpec.cs @@ -0,0 +1,98 @@ +using System.Reflection; +using FluentAssertions; +using FluentValidation; +using Infrastructure.WebApi.Interfaces; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Infrastructure.WebApi.Common.UnitTests; + +[Trait("Category", "Unit")] +public class ServiceCollectionExtensionsSpec +{ + [Fact] + public void WhenRegisterValidators_ThenRegistersInContainer() + { + var services = new ServiceCollection(); + + services.RegisterValidators(new[] { typeof(ServiceCollectionExtensionsSpec).Assembly }, out var validators); + + services.Should().ContainSingle(service => service.ImplementationType == typeof(TestRequestValidator)); + } + + [Fact] + public void WhenAddValidatorBehaviorsAndNoRegisteredValidators_ThenRegistersNoBehaviors() + { + var configuration = new MediatRServiceConfiguration(); + var assemblies = new[] { typeof(ServiceCollectionExtensionsSpec).Assembly }; + + configuration.AddValidatorBehaviors(Enumerable.Empty(), assemblies); + + configuration.BehaviorsToRegister.Should().BeEmpty(); + } + + + [Fact] + public void WhenAddValidatorBehaviorsAndNoRequestTypes_ThenRegistersNoBehaviors() + { + var configuration = new MediatRServiceConfiguration(); + var validators = new[] { typeof(TestRequestValidator) }; + + configuration.AddValidatorBehaviors(validators, Enumerable.Empty()); + + configuration.BehaviorsToRegister.Should().BeEmpty(); + } + + + [Fact] + public void WhenAddValidatorBehaviorsAndNoMatchingValidators_ThenRegistersNoBehaviors() + { + var configuration = new MediatRServiceConfiguration(); + var validators = new[] { typeof(TestRequestValidator2) }; + var assemblies = new[] { typeof(ServiceCollectionExtensionsSpec).Assembly }; + + configuration.AddValidatorBehaviors(validators, assemblies); + + configuration.BehaviorsToRegister.Should().BeEmpty(); + } + + [Fact] + public void WhenAddValidatorBehaviors_ThenRegistersBehavior() + { + var configuration = new MediatRServiceConfiguration(); + var validators = new[] { typeof(TestRequestValidator) }; + var assemblies = new[] { typeof(ServiceCollectionExtensionsSpec).Assembly }; + + configuration.AddValidatorBehaviors(validators, assemblies); + + var expectedBehaviorImplementationType = + typeof(ValidationBehavior<,>).MakeGenericType(typeof(TestRequest), typeof(TestResponse)); + configuration.BehaviorsToRegister.Should() + .ContainSingle(behavior => behavior.ImplementationType == expectedBehaviorImplementationType); + } + + public class TestRequestValidator : AbstractValidator + { + } + + public class TestRequestValidator2 : AbstractValidator + { + } + + public class TestApiWithoutMethods : IWebApiService + { + } + + public class TestApi : IWebApiService + { + [WebApiRoute("/aroute", WebApiOperation.Get)] + public Task Get(TestRequest request, CancellationToken cancellationToken) + { + return Task.FromResult(Results.Ok("amessage")); + } + } + + public class TestRequest2 : IWebRequest + { + } +} \ No newline at end of file diff --git a/src/Infrastructure.WebApi.Common.UnitTests/SubDomainModulesSpec.cs b/src/Infrastructure.WebApi.Common.UnitTests/SubDomainModulesSpec.cs index 820b275e..400cac91 100644 --- a/src/Infrastructure.WebApi.Common.UnitTests/SubDomainModulesSpec.cs +++ b/src/Infrastructure.WebApi.Common.UnitTests/SubDomainModulesSpec.cs @@ -50,7 +50,7 @@ public void WhenRegisterAndNullRegisterServicesFunction_ThenRegisters() RegisterServicesFunction = null! }); - _modules.HandlerAssemblies.Should().ContainSingle(); + _modules.ApiAssemblies.Should().ContainSingle(); } [Fact] diff --git a/src/Infrastructure.WebApi.Common.UnitTests/ValidationBehaviorSpec.cs b/src/Infrastructure.WebApi.Common.UnitTests/ValidationBehaviorSpec.cs new file mode 100644 index 00000000..d1ee8092 --- /dev/null +++ b/src/Infrastructure.WebApi.Common.UnitTests/ValidationBehaviorSpec.cs @@ -0,0 +1,69 @@ +using FluentAssertions; +using FluentValidation; +using FluentValidation.Results; +using Infrastructure.WebApi.Interfaces; +using Microsoft.AspNetCore.Http; +using Moq; + +namespace Infrastructure.WebApi.Common.UnitTests; + +[Trait("Category", "Unit")] +public class ValidationBehaviorSpec +{ + private readonly ValidationBehavior _behavior; + private readonly Mock> _validator; + + public ValidationBehaviorSpec() + { + _validator = new Mock>(); + _behavior = new ValidationBehavior(_validator.Object); + } + + [Fact] + public async Task WhenHandleAndValidatorPasses_ThenExecutesMiddleware() + { + var request = new TestRequest(); + var wasNextCalled = false; + _validator.Setup(val => val.ValidateAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(new ValidationResult())); + + var result = await _behavior.Handle(request, () => + { + wasNextCalled = true; + return Task.FromResult(Results.Ok()); + }, CancellationToken.None); + + wasNextCalled.Should().BeTrue(); + _validator.Verify(val => val.ValidateAsync(request, CancellationToken.None)); + result.Should().Be(Results.Ok()); + } + + [Fact] + public async Task WhenHandleAndValidatorFails_ThenReturnsBadRequest() + { + var request = new TestRequest(); + var wasNextCalled = false; + var errors = new ValidationResult(new List + { new("aproperty", "anerror") }); + _validator.Setup(val => val.ValidateAsync(It.IsAny(), It.IsAny())) + .Returns(Task.FromResult(errors)); + + var result = await _behavior.Handle(request, () => + { + wasNextCalled = true; + return Task.FromResult(Results.Ok()); + }, CancellationToken.None); + + wasNextCalled.Should().BeFalse(); + _validator.Verify(val => val.ValidateAsync(request, CancellationToken.None)); + result.Should().BeEquivalentTo(TypedResults.BadRequest(errors.Errors)); + } +} + +public class TestRequest : IWebRequest +{ +} + +public class TestResponse : IWebResponse +{ +} \ No newline at end of file diff --git a/src/Infrastructure.WebApi.Common/ISubDomainModule.cs b/src/Infrastructure.WebApi.Common/ISubDomainModule.cs new file mode 100644 index 00000000..d4a97207 --- /dev/null +++ b/src/Infrastructure.WebApi.Common/ISubDomainModule.cs @@ -0,0 +1,14 @@ +using System.Reflection; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Infrastructure.WebApi.Common; + +public interface ISubDomainModule +{ + public Assembly ApiAssembly { get; } + + public Action MinimalApiRegistrationFunction { get; } + public Action? RegisterServicesFunction { get; } +} \ No newline at end of file diff --git a/src/Infrastructure.WebApi.Common/Infrastructure.WebApi.Common.csproj b/src/Infrastructure.WebApi.Common/Infrastructure.WebApi.Common.csproj index cb7a8451..f8a7452f 100644 --- a/src/Infrastructure.WebApi.Common/Infrastructure.WebApi.Common.csproj +++ b/src/Infrastructure.WebApi.Common/Infrastructure.WebApi.Common.csproj @@ -13,4 +13,8 @@ + + + + diff --git a/src/Infrastructure.WebApi.Common/ServiceCollectionExtensions.cs b/src/Infrastructure.WebApi.Common/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..d7a41048 --- /dev/null +++ b/src/Infrastructure.WebApi.Common/ServiceCollectionExtensions.cs @@ -0,0 +1,147 @@ +using System.Reflection; +using Common.Extensions; +using FluentValidation; +using Infrastructure.WebApi.Interfaces; +using MediatR; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; + +namespace Infrastructure.WebApi.Common; + +public static class ServiceCollectionExtensions +{ + /// + /// Registers every found in the specified + /// + /// + // ReSharper disable once UnusedMethodReturnValue.Global + public static IServiceCollection RegisterValidators(this IServiceCollection services, + IEnumerable assembliesContainingValidators, out IEnumerable registeredValidators) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(assembliesContainingValidators); + + var validators = new List(); + AssemblyScanner.FindValidatorsInAssemblies(assembliesContainingValidators) + .ForEach(result => + { + services.AddScoped(result.InterfaceType, result.ValidatorType); + validators.Add(result.ValidatorType); + }); + + registeredValidators = validators; + return services; + } + + /// + /// Registers for every + /// found in the specified that has a corresponding registered + /// + /// + public static MediatRServiceConfiguration AddValidatorBehaviors(this MediatRServiceConfiguration configuration, + IEnumerable registeredValidators, + IEnumerable assembliesContainingApis) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentNullException.ThrowIfNull(registeredValidators); + ArgumentNullException.ThrowIfNull(assembliesContainingApis); + + var validators = registeredValidators.ToList(); + if (validators.HasNone()) + { + return configuration; + } + + var serviceClasses = assembliesContainingApis + .SelectMany(assembly => assembly.GetTypes()) + .Where(IsServiceClass); + + var requestTypes = serviceClasses + .SelectMany(type => type.GetMethods(BindingFlags.Instance | BindingFlags.Public)) + .Where(method => method is { IsAbstract: false }) + .SelectMany(method => + { + return method.GetParameters() + .Where(parameter => IsRequestType(parameter.ParameterType)) + .Select(parameter => parameter.ParameterType); + }) + .Distinct() + .ToList(); + + foreach (var requestType in requestTypes) + { + var requestValidator = FindValidatorOfRequestType(validators, requestType); + if (requestValidator is null) + { + continue; + } + + var responseType = GetResponseType(requestType); + if (responseType is null) + { + continue; + } + + var template1 = typeof(IPipelineBehavior<,>); + var behaviorType = template1.MakeGenericType(requestType, typeof(IResult)); + + var template2 = typeof(ValidationBehavior<,>); + var behaviorInstance = template2.MakeGenericType(requestType, responseType); + + configuration.AddBehavior(behaviorType, behaviorInstance, ServiceLifetime.Scoped); + } + + return configuration; + + static Type? FindValidatorOfRequestType(List validators, Type requestType) + { + return validators.Find(type => + { + var implementedInterfaces = type.GetInterfaces(); + var validatorInterface = implementedInterfaces + .FirstOrDefault(@interface => @interface.IsGenericType + && @interface.GetGenericTypeDefinition() == typeof(IValidator<>)); + if (validatorInterface is not null) + { + var validatorRequestType = validatorInterface.GenericTypeArguments.FirstOrDefault(); + if (validatorRequestType is not null) + { + return validatorRequestType == requestType; + } + } + + return false; + }); + } + + static bool IsServiceClass(Type type) + { + return type is { IsAbstract: false, IsGenericTypeDefinition: false } + && type.IsAssignableTo(typeof(IWebApiService)); + } + + static bool IsRequestType(Type type) + { + return GetRequestType(type) is not null; + } + + static Type? GetRequestType(Type type) + { + var interfaces = type.GetInterfaces(); + return interfaces + .FirstOrDefault(@interface => @interface.IsGenericType + && @interface.GetGenericTypeDefinition() == typeof(IWebRequest<>)); + } + + static Type? GetResponseType(Type type) + { + var requestType = GetRequestType(type); + if (requestType is not null) + { + return requestType.GenericTypeArguments.FirstOrDefault(); + } + + return null; + } + } +} \ No newline at end of file diff --git a/src/Infrastructure.WebApi.Common/SubDomainModules.cs b/src/Infrastructure.WebApi.Common/SubDomainModules.cs index 529c2946..30e75ed1 100644 --- a/src/Infrastructure.WebApi.Common/SubDomainModules.cs +++ b/src/Infrastructure.WebApi.Common/SubDomainModules.cs @@ -7,18 +7,18 @@ namespace Infrastructure.WebApi.Common; public class SubDomainModules { - private readonly List _handlerAssemblies; + private readonly List _apiAssemblies; private readonly List> _minimalApiRegistrationFunctions; private readonly List> _serviceCollectionFunctions; public SubDomainModules() { - _handlerAssemblies = new List(); + _apiAssemblies = new List(); _minimalApiRegistrationFunctions = new List>(); _serviceCollectionFunctions = new List>(); } - public IReadOnlyList HandlerAssemblies => _handlerAssemblies; + public IReadOnlyList ApiAssemblies => _apiAssemblies; public void Register(ISubDomainModule module) { @@ -27,7 +27,7 @@ public void Register(ISubDomainModule module) ArgumentNullException.ThrowIfNull(module.MinimalApiRegistrationFunction, nameof(module.MinimalApiRegistrationFunction)); - _handlerAssemblies.Add(module.ApiAssembly); + _apiAssemblies.Add(module.ApiAssembly); _minimalApiRegistrationFunctions.Add(module.MinimalApiRegistrationFunction); if (module.RegisterServicesFunction is not null) { @@ -44,12 +44,4 @@ public void ConfigureHost(WebApplication app) { _minimalApiRegistrationFunctions.ForEach(func => func(app)); } -} - -public interface ISubDomainModule -{ - public Assembly ApiAssembly { get; } - - public Action MinimalApiRegistrationFunction { get; } - public Action? RegisterServicesFunction { get; } } \ No newline at end of file diff --git a/src/Infrastructure.WebApi.Common/ValidationBehavior.cs b/src/Infrastructure.WebApi.Common/ValidationBehavior.cs new file mode 100644 index 00000000..dcc1964a --- /dev/null +++ b/src/Infrastructure.WebApi.Common/ValidationBehavior.cs @@ -0,0 +1,33 @@ +using FluentValidation; +using Infrastructure.WebApi.Interfaces; +using JetBrains.Annotations; +using MediatR; +using Microsoft.AspNetCore.Http; + +namespace Infrastructure.WebApi.Common; + +[UsedImplicitly] +public class ValidationBehavior : IPipelineBehavior + where TRequest : IWebRequest + where TResponse : IWebResponse +{ + private readonly IValidator _validator; + + + public ValidationBehavior(IValidator validator) + { + _validator = validator; + } + + public async Task Handle(TRequest request, RequestHandlerDelegate next, + CancellationToken cancellationToken) + { + var validationResult = await _validator.ValidateAsync(request, cancellationToken); + if (!validationResult.IsValid) + { + return Results.BadRequest(validationResult.Errors); + } + + return await next(); + } +} \ No newline at end of file diff --git a/src/SaaStack.sln.DotSettings b/src/SaaStack.sln.DotSettings index 8976fe59..6d477230 100644 --- a/src/SaaStack.sln.DotSettings +++ b/src/SaaStack.sln.DotSettings @@ -3,6 +3,39 @@ Required Required Required + True + True + A new test class (xUnit) + False + + + True + 1 + True + getAlphaNumericFileNameWithoutExtension() + 0 + + True + 2 + True + True + 2.0 + InCSharpFile + testc + True + [Trait("Category", "Unit")] +public class $filename$ +{ + public $filename$() + { + } + + [Fact] + public void When$condition$_Then$outcome$() + { + $END$ + } +} True True True @@ -21,6 +54,24 @@ #if TESTINGONLY $SELECTION$$END$ #endif + True + True + Test Method (xUnit) + True + 0 + True + 1 + True + True + 2.0 + InCSharpFile + testm + True + [Fact] +public void When$condition$_Then$outcome$() +{ + $END$ +} True True True \ No newline at end of file