From b91df2931f5c1945227575e4ffe073c111fe8bc5 Mon Sep 17 00:00:00 2001 From: Eduardo Cabral Date: Sun, 24 Sep 2023 23:31:23 -0300 Subject: [PATCH] chore: unify validation package --- .github/workflows/ci.yml | 2 +- .github/workflows/pre-release.yml | 8 +- .github/workflows/release.yml | 8 +- src/PipelineRD.Tests/PipelineRD.Tests.csproj | 2 +- .../PipelineRD.Validation.Tests.csproj | 28 +++++ .../PipelineRDBuilderExtensionsTests.cs | 38 +++++++ .../PipelineRDExtensionsTests.cs | 106 ++++++++++++++++++ src/PipelineRD.Validation.Tests/Startup.cs | 22 ++++ .../PipelineExtensions.cs | 63 +++++++++++ .../PipelineRD.Validation.csproj | 38 +++++++ .../PipelinesServicesBuilderExtensions.cs | 27 +++++ src/PipelineRD.sln | 12 ++ .../Builders/PipelineServicesBuilder.cs | 2 + src/PipelineRD/IPipeline.cs | 1 + src/PipelineRD/Pipeline.cs | 12 +- 15 files changed, 359 insertions(+), 10 deletions(-) create mode 100644 src/PipelineRD.Validation.Tests/PipelineRD.Validation.Tests.csproj create mode 100644 src/PipelineRD.Validation.Tests/PipelineRDBuilderExtensionsTests.cs create mode 100644 src/PipelineRD.Validation.Tests/PipelineRDExtensionsTests.cs create mode 100644 src/PipelineRD.Validation.Tests/Startup.cs create mode 100644 src/PipelineRD.Validation/PipelineExtensions.cs create mode 100644 src/PipelineRD.Validation/PipelineRD.Validation.csproj create mode 100644 src/PipelineRD.Validation/PipelinesServicesBuilderExtensions.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1afe81..760d30d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,4 +29,4 @@ jobs: run: dotnet build --configuration Release --no-restore src/PipelineRD.sln - name: Run Unit Tests - run: dotnet test --configuration Release --no-restore --no-build src/PipelineRD.Tests \ No newline at end of file + run: dotnet test --configuration Release --no-restore --no-build src/PipelineRD.sln \ No newline at end of file diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 193f7e0..b059964 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -24,5 +24,11 @@ jobs: - name: Create the PipelineRD package run: dotnet pack -c Release --no-build -p:Version="${{github.ref_name}}-alpha.${{ env.NOW }}" -o src/PipelineRD/bin/Release src/PipelineRD/PipelineRD.csproj + - name: Create the PipelineRD.Validation package + run: dotnet pack -c Release --no-build -p:Version="${{github.ref_name}}-alpha.${{ env.NOW }}" -o src/PipelineRD.Validation/bin/Release src/PipelineRD.Validation/PipelineRD.Validation.csproj + - name: Publish the PipelineRD package - run: dotnet nuget push src/PipelineRD/bin/Release/*.nupkg --api-key ${{secrets.NUGET_API_KEY}} --source https://api.nuget.org/v3/index.json \ No newline at end of file + run: dotnet nuget push src/PipelineRD/bin/Release/*.nupkg --api-key ${{secrets.NUGET_API_KEY}} --source https://api.nuget.org/v3/index.json + + - name: Publish the PipelineRD.Validation package + run: dotnet nuget push src/PipelineRD.Validation/bin/Release/*.nupkg --api-key ${{secrets.NUGET_API_KEY}} --source https://api.nuget.org/v3/index.json \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 550e18e..02e0652 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,5 +19,11 @@ jobs: - name: Create the PipelineRD package run: dotnet pack -c Release --no-build -p:Version=${{github.ref_name}} -o src/PipelineRD/bin/Release src/PipelineRD/PipelineRD.csproj + - name: Create the PipelineRD.Validation package + run: dotnet pack -c Release --no-build -p:Version=${{github.ref_name}} -o src/PipelineRD.Validation/bin/Release src/PipelineRD.Validation/PipelineRD.Validation.csproj + - name: Publish the PipelineRD package - run: dotnet nuget push src/PipelineRD/bin/Release/*.nupkg --api-key ${{secrets.NUGET_API_KEY}} --source https://api.nuget.org/v3/index.json \ No newline at end of file + run: dotnet nuget push src/PipelineRD/bin/Release/*.nupkg --api-key ${{secrets.NUGET_API_KEY}} --source https://api.nuget.org/v3/index.json + + - name: Publish the PipelineRD.Validation package + run: dotnet nuget push src/PipelineRD.Validation/bin/Release/*.nupkg --api-key ${{secrets.NUGET_API_KEY}} --source https://api.nuget.org/v3/index.json \ No newline at end of file diff --git a/src/PipelineRD.Tests/PipelineRD.Tests.csproj b/src/PipelineRD.Tests/PipelineRD.Tests.csproj index 4050f5a..b1415f1 100644 --- a/src/PipelineRD.Tests/PipelineRD.Tests.csproj +++ b/src/PipelineRD.Tests/PipelineRD.Tests.csproj @@ -11,7 +11,7 @@ - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/src/PipelineRD.Validation.Tests/PipelineRD.Validation.Tests.csproj b/src/PipelineRD.Validation.Tests/PipelineRD.Validation.Tests.csproj new file mode 100644 index 0000000..d1d7083 --- /dev/null +++ b/src/PipelineRD.Validation.Tests/PipelineRD.Validation.Tests.csproj @@ -0,0 +1,28 @@ + + + + net6 + + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/src/PipelineRD.Validation.Tests/PipelineRDBuilderExtensionsTests.cs b/src/PipelineRD.Validation.Tests/PipelineRDBuilderExtensionsTests.cs new file mode 100644 index 0000000..36fe2a9 --- /dev/null +++ b/src/PipelineRD.Validation.Tests/PipelineRDBuilderExtensionsTests.cs @@ -0,0 +1,38 @@ +using FluentValidation; + +using Microsoft.Extensions.DependencyInjection; +using PipelineRD.Cache; +using PipelineRD.Extensions; + +using System.Linq; + +using Xunit; + +namespace PipelineRD.Validation.Tests +{ + public class PipelineRDBuilderExtensionsTests + { + [Fact] + public void Should_UsePipelineRD_AddPipelineServices_And_Check_If_IValidatorRequest_Is_Singleton() + { + var services = new ServiceCollection(); + + services.UsePipelineRD(x => + { + x.SetupCache(new PipelineRDCacheSettings()); + x.SetupPipelineServices(x => x.InjectRequestValidators()); + }); + + var provider = services.BuildServiceProvider(); + + var service = services.FirstOrDefault(x => x.ServiceType == typeof(IValidator)); + + Assert.NotNull(service); + Assert.Equal(ServiceLifetime.Singleton, service.Lifetime); + } + + class PipelineRDRequestTest { } + + class PipelineRDRequestTestValidator : AbstractValidator { } + } +} diff --git a/src/PipelineRD.Validation.Tests/PipelineRDExtensionsTests.cs b/src/PipelineRD.Validation.Tests/PipelineRDExtensionsTests.cs new file mode 100644 index 0000000..cd7fe77 --- /dev/null +++ b/src/PipelineRD.Validation.Tests/PipelineRDExtensionsTests.cs @@ -0,0 +1,106 @@ +using FluentValidation; + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +using Microsoft.Extensions.DependencyInjection; + +using Xunit; +using System.Net; + +namespace PipelineRD.Validation.Tests +{ + public class PipelineRDExtensionsTests + { + private readonly IServiceProvider _serviceProvider; + + public PipelineRDExtensionsTests(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + [Fact] + public async Task Should_Pipeline_Validate_Request() + { + var request = new SampleRequest() { ValidModel = false }; + var pipeline = _serviceProvider.GetService>(); + pipeline.WithHandler(); + pipeline.WithHandler(); + pipeline.WithHandler(); + + var result = await pipeline.ExecuteWithValidation(request); + + Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); + Assert.Single(result.Errors); + } + + [Fact] + public async Task Should_Pipeline_Validate_Request_Using_Validator_Implementation() + { + var request = new SampleRequest() { ValidModel = false }; + var pipeline = _serviceProvider.GetService>(); + var validator = new SampleRequestValidator(); + pipeline.WithHandler(); + pipeline.WithHandler(); + pipeline.WithHandler(); + + var result = await pipeline.ExecuteWithValidation(request, validator); + + Assert.Equal(HttpStatusCode.BadRequest, result.StatusCode); + Assert.Single(result.Errors); + } + } + + public class SampleRequest + { + public Guid Guid { get; set; } = Guid.NewGuid(); + + public bool ValidFirst { get; set; } = true; + public bool ValidSecond { get; set; } = true; + + public bool ValidModel { get; set; } + } + + public class ContextSample : BaseContext + { + public bool ValidFirst { get; set; } = true; + + public ContextSample() + { + } + } + + public class SampleRequestValidator : AbstractValidator + { + public SampleRequestValidator() + { + RuleFor(x => x.ValidModel) + .Equal(true); + } + } + + public class FirstSampleStep : Handler + { + public override Task Handle(SampleRequest request) + { + return Proceed(); + } + } + + public class SecondSampleStep : Handler + { + public override Task Handle(SampleRequest request) + { + return Proceed(); + } + } + + public class ThirdSampleStep : Handler + { + public override Task Handle(SampleRequest request) + { + return this.Finish(200); + } + } +} diff --git a/src/PipelineRD.Validation.Tests/Startup.cs b/src/PipelineRD.Validation.Tests/Startup.cs new file mode 100644 index 0000000..c9ba0b0 --- /dev/null +++ b/src/PipelineRD.Validation.Tests/Startup.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.DependencyInjection; +using PipelineRD.Cache; +using PipelineRD.Extensions; + +namespace PipelineRD.Validation.Tests +{ + class Startup + { + public void ConfigureServices(IServiceCollection services) + { + services.UsePipelineRD(x => + { + x.SetupCache(new PipelineRDCacheSettings()); + x.SetupPipelineServices(x => + { + x.InjectAll(); + x.InjectRequestValidators(); + }); + }); + } + } +} diff --git a/src/PipelineRD.Validation/PipelineExtensions.cs b/src/PipelineRD.Validation/PipelineExtensions.cs new file mode 100644 index 0000000..a417861 --- /dev/null +++ b/src/PipelineRD.Validation/PipelineExtensions.cs @@ -0,0 +1,63 @@ +using FluentValidation; + +using Microsoft.Extensions.DependencyInjection; + +using System.Linq; +using System.Net; +using System.Threading.Tasks; + +namespace PipelineRD.Validation +{ + + public static class PipelineExtensions + { + /// + /// Execute the pipeline with a fail-fast validation for the request model using the fluent validation package. + /// The second parameter is an optional validator that will be used if passed it. If not, it will try to get one from the DI container. + /// + /// A model that holds the data from the request. + /// An optional validator that will be used if passed it. If not, it will try to get one from the DI container. + /// The result from the pipeline. + public static async Task ExecuteWithValidation( + this IPipeline pipeline, + TRequest request, + IValidator validator = null, + HttpStatusCode defaultValidationFailStatus = HttpStatusCode.BadRequest) + where TContext : BaseContext + { + // Make sure that we execute the validation when + // it is not the PipelineDiagram + if (pipeline.GetType() == typeof(Pipeline)) + { + if (validator == null) + { + var injectedValidator = pipeline.ServiceProvider.GetService>(); + validator = injectedValidator ?? throw new PipelineException($"There is no validator injected in DI for this request type({request.GetType().Name}). Please pass a validator to the method 'ExecuteWithValidation' or inject it."); + } + + if (validator != null) + { + var validationContext = new ValidationContext(request); + var validateResult = validator.Validate(validationContext); + + if (!validateResult.IsValid) + { + var errors = validateResult.Errors + .Select(p => new HandlerError() + { + Message = p.ErrorMessage, + Source = p.PropertyName + }) + .ToList(); + + return HandlerResultBuilder.CreateDefault() + .WithErrors(errors) + .WithStatusCode(defaultValidationFailStatus); + } + } + } + + return await pipeline.Execute(request); + } + } +} diff --git a/src/PipelineRD.Validation/PipelineRD.Validation.csproj b/src/PipelineRD.Validation/PipelineRD.Validation.csproj new file mode 100644 index 0000000..9b132ce --- /dev/null +++ b/src/PipelineRD.Validation/PipelineRD.Validation.csproj @@ -0,0 +1,38 @@ + + + + Library + net6 + PipelineRD.Validation + Eduardo Cabral + The complementary validation package for PipelineRD. It uses Fluent Validation to do it. + https://github.com/eduardosbcabral/pipelineRD-validation + latest + https://user-images.githubusercontent.com/29133996/134798452-de38b1d7-4a8a-4410-b60b-1814b7339a18.png + + The complementary validation package for PipelineRD. It uses Fluent Validation to do it. + The complementary validation package for PipelineRD. It uses Fluent Validation to do it. + PipelineRD.Validation + icon.png + README.md + Eduardo Cabral + https://github.com/eduardosbcabral/pipelineRD-validation + MIT + true + enable + latest + + + + + + + + + + + + + + + diff --git a/src/PipelineRD.Validation/PipelinesServicesBuilderExtensions.cs b/src/PipelineRD.Validation/PipelinesServicesBuilderExtensions.cs new file mode 100644 index 0000000..64892b3 --- /dev/null +++ b/src/PipelineRD.Validation/PipelinesServicesBuilderExtensions.cs @@ -0,0 +1,27 @@ +using FluentValidation; + +using Microsoft.Extensions.DependencyInjection; +using PipelineRD.Extensions.Builders; +using System.Linq; + +namespace PipelineRD.Validation +{ + public static class PipelinesServicesBuilderExtensions + { + public static void InjectRequestValidators(this IPipelineServicesBuilder builder) + { + var validators = from type in builder.Types + where !type.IsAbstract && !type.IsGenericTypeDefinition + let interfaces = type.GetInterfaces() + let genericInterfaces = interfaces.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IValidator<>)) + let matchingInterface = genericInterfaces.FirstOrDefault() + where matchingInterface != null + select new { Interface = matchingInterface, Type = type }; + + foreach (var validator in validators) + { + builder.Services.AddSingleton(validator.Interface, validator.Type); + } + } + } +} diff --git a/src/PipelineRD.sln b/src/PipelineRD.sln index 21b3bd6..47ce4b5 100644 --- a/src/PipelineRD.sln +++ b/src/PipelineRD.sln @@ -9,6 +9,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PipelineRD", "PipelineRD\Pi EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PipelineRD.Sample", "PipelineRD.Sample\PipelineRD.Sample.csproj", "{43206434-F5E6-4E74-A1E8-FE69EEBC0093}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PipelineRD.Validation", "PipelineRD.Validation\PipelineRD.Validation.csproj", "{4611FF49-11E4-4684-BC50-77035281208E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PipelineRD.Validation.Tests", "PipelineRD.Validation.Tests\PipelineRD.Validation.Tests.csproj", "{4BD57D55-87D5-4D73-8015-DB940095B9AD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -27,6 +31,14 @@ Global {43206434-F5E6-4E74-A1E8-FE69EEBC0093}.Debug|Any CPU.Build.0 = Debug|Any CPU {43206434-F5E6-4E74-A1E8-FE69EEBC0093}.Release|Any CPU.ActiveCfg = Release|Any CPU {43206434-F5E6-4E74-A1E8-FE69EEBC0093}.Release|Any CPU.Build.0 = Release|Any CPU + {4611FF49-11E4-4684-BC50-77035281208E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4611FF49-11E4-4684-BC50-77035281208E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4611FF49-11E4-4684-BC50-77035281208E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4611FF49-11E4-4684-BC50-77035281208E}.Release|Any CPU.Build.0 = Release|Any CPU + {4BD57D55-87D5-4D73-8015-DB940095B9AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4BD57D55-87D5-4D73-8015-DB940095B9AD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4BD57D55-87D5-4D73-8015-DB940095B9AD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4BD57D55-87D5-4D73-8015-DB940095B9AD}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/PipelineRD/Extensions/Builders/PipelineServicesBuilder.cs b/src/PipelineRD/Extensions/Builders/PipelineServicesBuilder.cs index 7ebe6ca..6786f39 100644 --- a/src/PipelineRD/Extensions/Builders/PipelineServicesBuilder.cs +++ b/src/PipelineRD/Extensions/Builders/PipelineServicesBuilder.cs @@ -115,6 +115,8 @@ public static IEnumerable GetInterfaces(Type type, bool includeInherited) public interface IPipelineServicesBuilder { + IEnumerable Types { get; } + IServiceCollection Services { get; } void InjectContexts(ServiceLifetime contextsLifetime = ServiceLifetime.Scoped); void InjectHandlers(ServiceLifetime handlersLifetime = ServiceLifetime.Scoped); void InjectPipelines(ServiceLifetime pipelinesLifetime = ServiceLifetime.Scoped); diff --git a/src/PipelineRD/IPipeline.cs b/src/PipelineRD/IPipeline.cs index 0841bec..1749663 100644 --- a/src/PipelineRD/IPipeline.cs +++ b/src/PipelineRD/IPipeline.cs @@ -10,6 +10,7 @@ public interface IPipeline where TContext : BaseContext { Queue> Handlers { get; } TContext Context { get; } + IServiceProvider ServiceProvider { get; } IPipeline EnableCache(ICacheProvider cacheProvider = null); IPipeline WithRequestKey(string requestKey); diff --git a/src/PipelineRD/Pipeline.cs b/src/PipelineRD/Pipeline.cs index fb1bcce..0cd7f1c 100644 --- a/src/PipelineRD/Pipeline.cs +++ b/src/PipelineRD/Pipeline.cs @@ -18,9 +18,9 @@ public class Pipeline : IPipeline where { public Queue> Handlers { get; private set; } public TContext Context { get; private set; } + public IServiceProvider ServiceProvider { get; private set; } private static string Identifier => $"Pipeline<{typeof(TContext).Name}, {typeof(TRequest).Name}>"; - private readonly IServiceProvider _serviceProvider; private string _requestKey; private ICacheProvider _cacheProvider; private bool _useCache; @@ -28,7 +28,7 @@ public class Pipeline : IPipeline where public Pipeline(IServiceProvider serviceProvider, TContext context = null, string requestKey = null) : this() { - _serviceProvider = serviceProvider; + ServiceProvider = serviceProvider; _requestKey = requestKey; Context = context ?? serviceProvider.GetService() ?? throw new PipelineException($"{typeof(TContext).Name} is not configured."); } @@ -40,12 +40,12 @@ protected Pipeline() public IPipeline EnableCache(ICacheProvider cacheProvider = null) { - if (_serviceProvider.GetService() == null) + if (ServiceProvider.GetService() == null) { throw new PipelineException("IDistributedCache interface is not injected."); } - _cacheProvider = (cacheProvider ?? _serviceProvider.GetService()) ?? throw new PipelineException($"Interface ICacheProvider is not configured."); + _cacheProvider = (cacheProvider ?? ServiceProvider.GetService()) ?? throw new PipelineException($"Interface ICacheProvider is not configured."); _useCache = true; return this; } @@ -219,7 +219,7 @@ public IPipeline When(Expression WithHandler() where THandler : Handler { - var handler = _serviceProvider.GetService() ?? throw new PipelineException($"{typeof(THandler).Name} not found in the dependency container."); + var handler = ServiceProvider.GetService() ?? throw new PipelineException($"{typeof(THandler).Name} not found in the dependency container."); return WithHandler(handler); } @@ -243,7 +243,7 @@ public IPipeline WithPolicy(AsyncPolicy polic public IPipeline WithRecovery() where TRecoveryHandler : RecoveryHandler { - var handler = _serviceProvider.GetService() ?? throw new PipelineException($"Recovery {typeof(TRecoveryHandler).Name} not found in the dependency container."); + var handler = ServiceProvider.GetService() ?? throw new PipelineException($"Recovery {typeof(TRecoveryHandler).Name} not found in the dependency container."); return WithRecovery(handler); }