diff --git a/src/PipeForge.Tests.Steps/IDisposablePipelineStep.cs b/src/PipeForge.Tests.Steps/IDisposablePipelineStep.cs new file mode 100644 index 0000000..2b29e5e --- /dev/null +++ b/src/PipeForge.Tests.Steps/IDisposablePipelineStep.cs @@ -0,0 +1,5 @@ +namespace PipeForge.Tests.Steps; + +public interface IDisposablePipelineStep : IPipelineStep, IDisposable +{ +} diff --git a/src/PipeForge.Tests.Steps/IGenericPipelineStep.cs b/src/PipeForge.Tests.Steps/IGenericPipelineStep.cs new file mode 100644 index 0000000..7f06865 --- /dev/null +++ b/src/PipeForge.Tests.Steps/IGenericPipelineStep.cs @@ -0,0 +1,31 @@ +namespace PipeForge.Tests.Steps; + +public interface IGenericPipelineStep : IPipelineStep where T : class +{ +} + +public class GenericPipelineStep : IGenericPipelineStep where T : class +{ + public string? Description => throw new NotImplementedException(); + + public bool MayShortCircuit => throw new NotImplementedException(); + + public string Name => throw new NotImplementedException(); + + public string? ShortCircuitCondition => throw new NotImplementedException(); + + public Task InvokeAsync(T context, PipelineDelegate next, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } +} + +[PipelineStep(0)] +public class ClosedGenericPipelineStep : GenericPipelineStep, IGenericPipelineStep +{ + // This class is a closed generic implementation of IGenericPipelineStep. +} + +public class OpenGenericPipelineStep : ClosedGenericPipelineStep +{ +} diff --git a/src/PipeForge.Tests.Steps/INotImplementedRunner.cs b/src/PipeForge.Tests.Steps/INotImplementedRunner.cs new file mode 100644 index 0000000..2badc01 --- /dev/null +++ b/src/PipeForge.Tests.Steps/INotImplementedRunner.cs @@ -0,0 +1,5 @@ +namespace PipeForge.Tests.Steps; + +public interface INotImplementedRunner : IPipelineRunner +{ } + diff --git a/src/PipeForge.Tests.Steps/ISampleContextRunner.cs b/src/PipeForge.Tests.Steps/ISampleContextRunner.cs new file mode 100644 index 0000000..4dc5d7a --- /dev/null +++ b/src/PipeForge.Tests.Steps/ISampleContextRunner.cs @@ -0,0 +1,11 @@ +namespace PipeForge.Tests.Steps; + +public interface ISampleContextRunner : IPipelineRunner +{ } + +public class SampleContextRunner : PipelineRunner, ISampleContextRunner +{ + public SampleContextRunner(IServiceProvider serviceProvider) : base(serviceProvider) + { + } +} diff --git a/src/PipeForge.Tests.Steps/SampleContextStepB.cs b/src/PipeForge.Tests.Steps/SampleContextStepB.cs new file mode 100644 index 0000000..632bb95 --- /dev/null +++ b/src/PipeForge.Tests.Steps/SampleContextStepB.cs @@ -0,0 +1,12 @@ +namespace PipeForge.Tests.Steps; + +[PipelineStep(3)] +public class SampleContextStepB : SampleContextStep +{ + public static readonly string StepName = "B"; + + public SampleContextStepB() + { + Name = StepName; + } +} diff --git a/src/PipeForge.Tests.Steps/SampleContextStepC.cs b/src/PipeForge.Tests.Steps/SampleContextStepC.cs new file mode 100644 index 0000000..389575f --- /dev/null +++ b/src/PipeForge.Tests.Steps/SampleContextStepC.cs @@ -0,0 +1,12 @@ +namespace PipeForge.Tests.Steps; + +[PipelineStep(2)] +public class SampleContextStepC : SampleContextStep +{ + public static readonly string StepName = "C"; + + public SampleContextStepC() + { + Name = StepName; + } +} diff --git a/src/PipeForge.Tests.Steps/SampleContextStepF.cs b/src/PipeForge.Tests.Steps/SampleContextStepF.cs new file mode 100644 index 0000000..64ba58d --- /dev/null +++ b/src/PipeForge.Tests.Steps/SampleContextStepF.cs @@ -0,0 +1,34 @@ +namespace PipeForge.Tests.Steps; + +[PipelineStep(4, TestConstants.Filter1)] +public class SampleContextStepF1 : SampleContextStep +{ + public static readonly string StepName = "F"; + + public SampleContextStepF1() + { + Name = StepName; + } + + public override Task InvokeAsync(SampleContext context, PipelineDelegate next, CancellationToken cancellationToken = default) + { + context.AddStep(Name); + return Task.CompletedTask; + } +} + +[PipelineStep(4, TestConstants.Filter2)] +public class SampleContextStepF2 : SampleContextStep +{ + public static readonly string StepName = "F"; + + public SampleContextStepF2() + { + Name = StepName; + } + + public override Task InvokeAsync(SampleContext context, PipelineDelegate next, CancellationToken cancellationToken = default) + { + throw new NotImplementedException("This step should not be executed in this test."); + } +} diff --git a/src/PipeForge.Tests.Steps/SampleContextStepM.cs b/src/PipeForge.Tests.Steps/SampleContextStepM.cs new file mode 100644 index 0000000..5e9fb67 --- /dev/null +++ b/src/PipeForge.Tests.Steps/SampleContextStepM.cs @@ -0,0 +1,12 @@ +namespace PipeForge.Tests.Steps; + +[PipelineStep(4, TestConstants.Filter1, TestConstants.Filter2)] +public class SampleContextStepM : SampleContextStep +{ + public static readonly string StepName = "M"; + + public SampleContextStepM() + { + Name = StepName; + } +} diff --git a/src/PipeForge.Tests.Steps/SampleContextStepZ.cs b/src/PipeForge.Tests.Steps/SampleContextStepZ.cs new file mode 100644 index 0000000..bc810d6 --- /dev/null +++ b/src/PipeForge.Tests.Steps/SampleContextStepZ.cs @@ -0,0 +1,12 @@ +namespace PipeForge.Tests.Steps; + +[PipelineStep(100)] +public class SampleContextStepZ : SampleContextStep +{ + public static readonly string StepName = "Z"; + + public SampleContextStepZ() + { + Name = StepName; + } +} diff --git a/src/PipeForge.Tests.Steps/TestConstants.cs b/src/PipeForge.Tests.Steps/TestConstants.cs new file mode 100644 index 0000000..a182e10 --- /dev/null +++ b/src/PipeForge.Tests.Steps/TestConstants.cs @@ -0,0 +1,7 @@ +namespace PipeForge.Tests.Steps; + +public static class TestConstants +{ + public const string Filter1 = "Filter1"; + public const string Filter2 = "Filter2"; +} diff --git a/src/PipeForge.Tests/DelegatePipelineStepTests.cs b/src/PipeForge.Tests/DelegatePipelineStepTests.cs new file mode 100644 index 0000000..12cde96 --- /dev/null +++ b/src/PipeForge.Tests/DelegatePipelineStepTests.cs @@ -0,0 +1,31 @@ +using PipeForge.Tests.Steps; + +namespace PipeForge.Tests; + +public class DelegatePipelineStepTests +{ + [Fact] + public void Constructor_ThrowsException_WhenActionIsNull() + { + var ex = Should.Throw(() => + { + _ = new DelegatePipelineStep(null!); + }); + } + + [Fact] + public async Task Execute_CallsAction_WhenInvoked() + { + var wasCalled = false; + PipelineDelegate next = (_, _) => Task.CompletedTask; + + var step = new DelegatePipelineStep(async (context, d, ct) => + { + wasCalled = true; + await Task.CompletedTask; + }); + + await step.InvokeAsync(new SampleContext(), next, CancellationToken.None); + wasCalled.ShouldBeTrue(); + } +} diff --git a/src/PipeForge.Tests/Extensions/AssemblyExtensionsTests.cs b/src/PipeForge.Tests/Extensions/AssemblyExtensionsTests.cs new file mode 100644 index 0000000..6cc0e90 --- /dev/null +++ b/src/PipeForge.Tests/Extensions/AssemblyExtensionsTests.cs @@ -0,0 +1,177 @@ +using System.Reflection; +using PipeForge.Extensions; +using PipeForge.Tests.Steps; + +namespace PipeForge.Tests.Extensions; + +public class AssemblyExtensionsTests +{ + private static readonly Assembly[] _assemblies = [typeof(SampleContext).Assembly]; + + [Fact] + public void FindClosedImplementationsOf_ThrowsException_WhenTypeIsNotAnInterface() + { + var ex = Should.Throw(() => + { + _assemblies.FindClosedImplementationsOf(); + }); + + ex.Message.ShouldStartWith(string.Format(PipeForge.Extensions.AssemblyExtensions.MessageNotAnInterface, typeof(string).FullName)); + } + + [Fact] + public void FindClosedImplementationsOf_ReturnsAllClosedImplementations_ForValidInterface() + { + var result = _assemblies.FindClosedImplementationsOf>(); + result.ShouldNotBeEmpty(); + + result.ShouldSatisfyAllConditions + ( + () => result.ShouldContain(t => t == typeof(SampleContextStepA)), + () => result.ShouldNotContain(t => t == typeof(SampleContextStep)), + () => result.ShouldNotContain(t => t == typeof(ISampleContextStep)), + () => result.ShouldNotContain(t => t == typeof(IDisposablePipelineStep)), + () => result.ShouldNotContain(t => t == typeof(OpenGenericPipelineStep<>)), + () => result.ShouldNotContain(t => t == typeof(GenericPipelineStep<>)), + () => result.ShouldNotContain(t => t == typeof(IGenericPipelineStep<>)) + ); + } + + [Fact] + public void FindClosedImplementationsOf_ReturnsAllClosedImplementations_ForValidSpecificInterface() + { + var result = _assemblies.FindClosedImplementationsOf(); + result.ShouldNotBeEmpty(); + + result.ShouldSatisfyAllConditions + ( + () => result.ShouldContain(t => t == typeof(SampleContextStepA)), + () => result.ShouldNotContain(t => t == typeof(SampleContextStep)), + () => result.ShouldNotContain(t => t == typeof(ISampleContextStep)), + () => result.ShouldNotContain(t => t == typeof(IDisposablePipelineStep)), + () => result.ShouldNotContain(t => t == typeof(OpenGenericPipelineStep<>)), + () => result.ShouldNotContain(t => t == typeof(ClosedGenericPipelineStep)), + () => result.ShouldNotContain(t => t == typeof(GenericPipelineStep<>)), + () => result.ShouldNotContain(t => t == typeof(IGenericPipelineStep<>)) + ); + } + + [Fact] + public void GetDescriptorsFor_ReturnsEmpty_WhenNoStepsFound() + { + var result = _assemblies.GetDescriptorsFor>(null); + result.ShouldBeEmpty(); + } + + [Fact] + public void GetDescriptorsFor_ReturnsIEnumerable_ForValidStepInterface() + { + var result = _assemblies.GetDescriptorsFor>(null).ToList(); + result.ShouldNotBeEmpty(); + + result.ShouldSatisfyAllConditions + ( + () => result.Count().ShouldBe(5), + () => result[0].ImplementationType.ShouldBe(typeof(ClosedGenericPipelineStep)), + () => result[1].ImplementationType.ShouldBe(typeof(SampleContextStepA)), + () => result[2].ImplementationType.ShouldBe(typeof(SampleContextStepC)), + () => result[3].ImplementationType.ShouldBe(typeof(SampleContextStepB)), + () => result[4].ImplementationType.ShouldBe(typeof(SampleContextStepZ)) + ); + } + + [Fact] + public void GetDescriptorsFor_ReturnsIEnumerable_ForValidCustomStepInterface_WithNoFilters() + { + var result = _assemblies.GetDescriptorsFor(null).ToList(); + result.ShouldNotBeEmpty(); + + result.ShouldSatisfyAllConditions + ( + () => result.Count().ShouldBe(4), + () => result[0].ImplementationType.ShouldBe(typeof(SampleContextStepA)), + () => result[1].ImplementationType.ShouldBe(typeof(SampleContextStepC)), + () => result[2].ImplementationType.ShouldBe(typeof(SampleContextStepB)), + () => result[3].ImplementationType.ShouldBe(typeof(SampleContextStepZ)) + ); + } + + [Fact] + public void GetDescriptorsFor_ReturnsIEnumerable_ForValidCustomStepInterface_WithSingleFilter1() + { + var result = _assemblies.GetDescriptorsFor([TestConstants.Filter1]).ToList(); + result.ShouldNotBeEmpty(); + + result.ShouldSatisfyAllConditions + ( + () => result.Count().ShouldBe(6), + () => result[0].ImplementationType.ShouldBe(typeof(SampleContextStepA)), + () => result[1].ImplementationType.ShouldBe(typeof(SampleContextStepC)), + () => result[2].ImplementationType.ShouldBe(typeof(SampleContextStepB)), + () => result[3].ImplementationType.ShouldBe(typeof(SampleContextStepF1)), + () => result[4].ImplementationType.ShouldBe(typeof(SampleContextStepM)), + () => result[5].ImplementationType.ShouldBe(typeof(SampleContextStepZ)) + ); + } + + [Fact] + public void GetDescriptorsFor_ReturnsIEnumerable_ForValidCustomStepInterface_WithSingleFilter2() + { + var result = _assemblies.GetDescriptorsFor([TestConstants.Filter2]).ToList(); + result.ShouldNotBeEmpty(); + + result.ShouldSatisfyAllConditions + ( + () => result.Count().ShouldBe(6), + () => result[0].ImplementationType.ShouldBe(typeof(SampleContextStepA)), + () => result[1].ImplementationType.ShouldBe(typeof(SampleContextStepC)), + () => result[2].ImplementationType.ShouldBe(typeof(SampleContextStepB)), + () => result[3].ImplementationType.ShouldBe(typeof(SampleContextStepF2)), + () => result[4].ImplementationType.ShouldBe(typeof(SampleContextStepM)), + () => result[5].ImplementationType.ShouldBe(typeof(SampleContextStepZ)) + ); + } + + [Fact] + public void GetDescriptorsFor_ReturnsIEnumerable_ForValidCustomStepInterface_WithMultipleFilters() + { + var result = _assemblies.GetDescriptorsFor([TestConstants.Filter2, TestConstants.Filter1]).ToList(); + result.ShouldNotBeEmpty(); + + result.ShouldSatisfyAllConditions + ( + () => result.Count().ShouldBe(7), + () => result[0].ImplementationType.ShouldBe(typeof(SampleContextStepA)), + () => result[1].ImplementationType.ShouldBe(typeof(SampleContextStepC)), + () => result[2].ImplementationType.ShouldBe(typeof(SampleContextStepB)), + () => result[3].ImplementationType.ShouldBe(typeof(SampleContextStepF1)), + () => result[4].ImplementationType.ShouldBe(typeof(SampleContextStepF2)), + () => result[5].ImplementationType.ShouldBe(typeof(SampleContextStepM)), + () => result[6].ImplementationType.ShouldBe(typeof(SampleContextStepZ)) + ); + } + + [Fact] + public void SafeGetTypes_ReturnsTypes_WhenDelegateSucceeds() + { + var expected = new[] { typeof(string), typeof(int) }; + var result = PipeForge.Extensions.AssemblyExtensions.SafeGetTypes(() => expected); + result.ShouldBe(expected); + } + + [Fact] + public void SafeGetTypes_ReturnsFilteredTypes_WhenReflectionTypeLoadExceptionThrown() + { + var types = new Type?[] { typeof(string), null, typeof(int) }; + var ex = new ReflectionTypeLoadException(types, []); + var result = PipeForge.Extensions.AssemblyExtensions.SafeGetTypes(() => throw ex); + result.ShouldBe([typeof(string), typeof(int)]); + } + + [Fact] + public void SafeGetTypes_ReturnsEmpty_WhenUnexpectedExceptionThrown() + { + var result = PipeForge.Extensions.AssemblyExtensions.SafeGetTypes(() => throw new InvalidOperationException()); + result.ShouldBeEmpty(); + } +} diff --git a/src/PipeForge.Tests/Extensions/FilterExtensionsTests.cs b/src/PipeForge.Tests/Extensions/FilterExtensionsTests.cs new file mode 100644 index 0000000..dd38c1b --- /dev/null +++ b/src/PipeForge.Tests/Extensions/FilterExtensionsTests.cs @@ -0,0 +1,72 @@ +using PipeForge.Extensions; + +namespace PipeForge.Tests.Extensions; + +public class FilterExtensionsTests +{ + [Fact] + public void MatchesAnyFilter_ReturnsTrue_WhenDescriptorFiltersIsEmpty() + { + var descriptorFilters = new string[0]; + var filters = new[] { "foo", "bar" }; + + var result = descriptorFilters.MatchesAnyFilter(filters); + + result.ShouldBeTrue(); + } + + [Fact] + public void MatchesAnyFilter_ReturnsFalse_WhenFiltersIsNullAndDescriptorFiltersIsNotEmpty() + { + var descriptorFilters = new[] { "foo", "bar" }; + string[]? filters = null; + + var result = descriptorFilters.MatchesAnyFilter(filters); + + result.ShouldBeFalse(); + } + + [Fact] + public void MatchesAnyFilter_ReturnsFalse_WhenFiltersIsEmptyAndDescriptorFiltersIsNotEmpty() + { + var descriptorFilters = new[] { "foo", "bar" }; + var filters = new string[0]; + + var result = descriptorFilters.MatchesAnyFilter(filters); + + result.ShouldBeFalse(); + } + + [Fact] + public void MatchesAnyFilter_ReturnsTrue_WhenAtLeastOneFilterMatches() + { + var descriptorFilters = new[] { "foo", "bar" }; + var filters = new[] { "bar", "baz" }; + + var result = descriptorFilters.MatchesAnyFilter(filters); + + result.ShouldBeTrue(); + } + + [Fact] + public void MatchesAnyFilter_ReturnsTrue_WhenMatchIsCaseInsensitive() + { + var descriptorFilters = new[] { "FoO", "BaR" }; + var filters = new[] { "foo" }; + + var result = descriptorFilters.MatchesAnyFilter(filters); + + result.ShouldBeTrue(); + } + + [Fact] + public void MatchesAnyFilter_ReturnsFalse_WhenNoFiltersMatch() + { + var descriptorFilters = new[] { "foo", "bar" }; + var filters = new[] { "baz", "qux" }; + + var result = descriptorFilters.MatchesAnyFilter(filters); + + result.ShouldBeFalse(); + } +} diff --git a/src/PipeForge.Tests/Extensions/TypeExtensionsTests.cs b/src/PipeForge.Tests/Extensions/TypeExtensionsTests.cs new file mode 100644 index 0000000..20e9e7a --- /dev/null +++ b/src/PipeForge.Tests/Extensions/TypeExtensionsTests.cs @@ -0,0 +1,87 @@ +using PipeForge.Extensions; +using PipeForge.Tests.Steps; + +namespace PipeForge.Tests.Extensions; + +public class TypeExtensionsTests +{ + private interface ICustom { } + + private class DerivedWithoutInterface : ImplementsDirectly { } + + private class ImplementsDirectly : ICustom { } + + [Fact] + public void ImplementsPipelineStep_ReturnsFalse_WhenTypeIsNull() + { + Type? type = null; + + var result = type!.ImplementsPipelineStep(); + + result.ShouldBeFalse(); + } + + [Fact] + public void ImplementsPipelineStep_ReturnsTrue_WhenTypeImplementsInterfaceDirectly() + { + var type = typeof(ISampleContextStep); + + var result = type.ImplementsPipelineStep(); + + result.ShouldBeTrue(); + } + + [Fact] + public void ImplementsPipelineStep_ReturnsTrue_WhenTypeImplementsOpenGenericVariant() + { + var type = typeof(IGenericPipelineStep<>); + + var result = type.ImplementsPipelineStep(); + + result.ShouldBeTrue(); + } + + [Fact] + public void ImplementsPipelineStep_ReturnsTrue_WhenTypeInheritsFromTypeThatImplementsInterface() + { + var type = typeof(SampleContextStepA); + + var result = type.ImplementsPipelineStep(); + + result.ShouldBeTrue(); + } + + [Fact] + public void ImplementsPipelineStep_ReturnsFalse_WhenTypeDoesNotImplement() + { + var type = typeof(string); + + var result = type.ImplementsPipelineStep(); + + result.ShouldBeFalse(); + } + + [Fact] + public void DirectlyImplements_ReturnsTrue_WhenTypeImplementsInterfaceAndBaseDoesNot() + { + var type = typeof(ImplementsDirectly); + var result = type.DirectlyImplements(typeof(ICustom)); + result.ShouldBeTrue(); + } + + [Fact] + public void DirectlyImplements_ReturnsFalse_WhenInterfaceIsImplementedByBase() + { + var type = typeof(DerivedWithoutInterface); + var result = type.DirectlyImplements(typeof(ICustom)); + result.ShouldBeFalse(); + } + + [Fact] + public void DirectlyImplements_ReturnsFalse_WhenInterfaceIsNotImplementedAtAll() + { + var type = typeof(string); + var result = type.DirectlyImplements(typeof(ICustom)); + result.ShouldBeFalse(); + } +} diff --git a/src/PipeForge.Tests/Metadata/PipelineStepDescriptorTests.cs b/src/PipeForge.Tests/Metadata/PipelineStepDescriptorTests.cs new file mode 100644 index 0000000..2e8fe90 --- /dev/null +++ b/src/PipeForge.Tests/Metadata/PipelineStepDescriptorTests.cs @@ -0,0 +1,72 @@ +using PipeForge.Metadata; +using PipeForge.Tests.Steps; + +namespace PipeForge.Tests.Metadata; + +public class PipelineStepDescriptorTests +{ + private class HasNoAttributeStep : SampleContextStep + { } + + [PipelineStep(1)] + private class HasNoFilterStep : SampleContextStep + { + } + + [PipelineStep(2, TestConstants.Filter1)] + private class HasOneFilterStep : SampleContextStep + { + } + + [PipelineStep(3, TestConstants.Filter1, TestConstants.Filter2)] + private class HasTwoFiltersStep : SampleContextStep + { + } + + [Fact] + public void Constructor_SetsProperties_WhenAttributeIsPresent() + { + var descriptor = new PipelineStepDescriptor(typeof(HasNoFilterStep)); + + descriptor.ImplementationType.ShouldBe(typeof(HasNoFilterStep)); + descriptor.Order.ShouldBe(1); + descriptor.Filters.ShouldBeEmpty(); + } + + [Fact] + public void Constructor_SetsProperties_WhenAttributeHasOneFilter() + { + var descriptor = new PipelineStepDescriptor(typeof(HasOneFilterStep)); + + descriptor.ImplementationType.ShouldBe(typeof(HasOneFilterStep)); + descriptor.Order.ShouldBe(2); + descriptor.Filters.ShouldNotBeEmpty(); + descriptor.Filters.Count().ShouldBe(1); + descriptor.Filters.ShouldContain(TestConstants.Filter1); + } + + [Fact] + public void Constructor_SetsProperties_WhenAttributeHasMultipleFilters() + { + var descriptor = new PipelineStepDescriptor(typeof(HasTwoFiltersStep)); + + descriptor.ImplementationType.ShouldBe(typeof(HasTwoFiltersStep)); + descriptor.Order.ShouldBe(3); + descriptor.Filters.ShouldNotBeEmpty(); + descriptor.Filters.Count().ShouldBe(2); + descriptor.Filters.ShouldContain(TestConstants.Filter1); + descriptor.Filters.ShouldContain(TestConstants.Filter2); + } + + [Fact] + public void Constructor_ThrowsInvalidOperationException_WhenAttributeIsMissing() + { + var ex = Should.Throw(() => + { + _ = new PipelineStepDescriptor(typeof(HasNoAttributeStep)); + }); + + ex.Message.ShouldContain(nameof(PipelineStepAttribute)); + ex.Message.ShouldContain(nameof(HasNoAttributeStep)); + } +} diff --git a/src/PipeForge.Tests/Metadata/StepInterfaceDescriptorTests.cs b/src/PipeForge.Tests/Metadata/StepInterfaceDescriptorTests.cs new file mode 100644 index 0000000..692f93d --- /dev/null +++ b/src/PipeForge.Tests/Metadata/StepInterfaceDescriptorTests.cs @@ -0,0 +1,42 @@ +using PipeForge.Metadata; +using PipeForge.Tests.Steps; + +namespace PipeForge.Tests.Metadata; + +public class StepInterfaceDescriptorTests +{ + private class NotAnInterface { } + + private interface INotAStep { } + + [Fact] + public void StepInterfaceDescriptor_ThrowsArgumentException_WhenTypeIsNotAnInterface() + { + var ex = Should.Throw(() => + { + _ = StepInterfaceDescriptor.InterfaceType; + }); + + ex.InnerException.ShouldBeOfType(); + ex.InnerException!.Message.ShouldContain("is not an interface"); + } + + [Fact] + public void StepInterfaceDescriptor_ThrowsArgumentException_WhenTypeIsNotAPipelineStep() + { + var ex = Should.Throw(() => + { + _ = StepInterfaceDescriptor.InterfaceType; + }); + + ex.InnerException.ShouldBeOfType(); + ex.InnerException!.Message.ShouldContain("does not implement the IPipelineStep interface"); + } + + [Fact] + public void StepInterfaceDescriptor_InitializesSuccessfully_WhenValidPipelineStepInterfaceIsProvided() + { + StepInterfaceDescriptor.InterfaceType.ShouldBe(typeof(ISampleContextStep)); + StepInterfaceDescriptor.LazyType.ShouldBe(typeof(Lazy)); + } +} diff --git a/src/PipeForge.Tests/Metadata/StepTypeDescriptorTests.cs b/src/PipeForge.Tests/Metadata/StepTypeDescriptorTests.cs new file mode 100644 index 0000000..5120e5b --- /dev/null +++ b/src/PipeForge.Tests/Metadata/StepTypeDescriptorTests.cs @@ -0,0 +1,67 @@ +using PipeForge.Metadata; +using PipeForge.Tests.Steps; + +namespace PipeForge.Tests.Metadata; + +public class StepTypeDescriptorTests +{ + private interface INotAStep { } + + private class ImplementsNothing { } + + private class ImplementsINotAStep : INotAStep { } + + [Fact] + public void CreateGeneric_ThrowsArgumentException_WhenTypeDoesNotImplementInterface() + { + var ex = Should.Throw(() => + { + StepTypeDescriptor.Create(typeof(ImplementsNothing)); + }); + + ex.Message.ShouldContain("does not implement the interface"); + ex.ParamName.ShouldBeNull(); // because we used string.Format, not constructor with paramName + } + + [Fact] + public void CreateGeneric_ReturnsDescriptor_WhenTypeImplementsInterface() + { + var result = StepTypeDescriptor.Create(typeof(SampleContextStepA)); + + result.ConcreteType.ShouldBe(typeof(SampleContextStepA)); + result.InterfaceType.ShouldBe(typeof(ISampleContextStep)); + result.LazyType.ShouldBe(typeof(Lazy)); + result.TypeName.ShouldBe(typeof(SampleContextStepA).FullName); + } + + [Fact] + public void CreateNonGeneric_ThrowsArgumentException_WhenNoPipelineStepInterfaceFound() + { + var ex = Should.Throw(() => + { + StepTypeDescriptor.Create(typeof(ImplementsINotAStep)); + }); + + ex.Message.ShouldContain("does not implement the interface"); + } + + [Fact] + public void CreateNonGeneric_ReturnsDescriptor_WhenPipelineStepInterfaceIsImplemented() + { + var result = StepTypeDescriptor.Create(typeof(SampleContextStepA)); + + result.ConcreteType.ShouldBe(typeof(SampleContextStepA)); + result.InterfaceType.ShouldBe(typeof(IPipelineStep)); + result.LazyType.ShouldBe(typeof(Lazy>)); + result.TypeName.ShouldBe(typeof(SampleContextStepA).FullName); + } + + [Fact] + public void CreateNonGeneric_ReturnsCachedLazyType_WhenCalledMultipleTimes() + { + var first = StepTypeDescriptor.Create(typeof(SampleContextStepA)); + var second = StepTypeDescriptor.Create(typeof(SampleContextStepA)); + + first.LazyType.ShouldBeSameAs(second.LazyType); + } +} diff --git a/src/PipeForge.Tests/PipelineBuilderTests.cs b/src/PipeForge.Tests/PipelineBuilderTests.cs new file mode 100644 index 0000000..73efc75 --- /dev/null +++ b/src/PipeForge.Tests/PipelineBuilderTests.cs @@ -0,0 +1,111 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using PipeForge.Tests.Steps; + +namespace PipeForge.Tests; + +public class PipelineBuilderTests +{ + // This sample interface and implementation are used to validate + // that services can be added and resolved using the pipeline builder. + private interface IRandomNumberService + { + int GetRandomNumber(); + } + + private class RandomNumberService : IRandomNumberService + { + private static readonly Random _random = new(); + + public int GetRandomNumber() + { + return _random.Next(); + } + } + + private class BuilderStep1 : PipelineStep + { + public override Task InvokeAsync(SampleContext context, PipelineDelegate next, CancellationToken cancellationToken = default) + { + context.AddStep("BuilderStep1"); + return next(context, cancellationToken); + } + } + + private class BuilderStep2 : PipelineStep + { + private readonly IRandomNumberService _randomNumberService; + + public BuilderStep2(IRandomNumberService randomNumberService) + { + _randomNumberService = randomNumberService; + } + + public override async Task InvokeAsync(SampleContext context, PipelineDelegate next, CancellationToken cancellationToken = default) + { + context.AddStep("BuilderStep2"); + await next(context, cancellationToken); + context.AddStep(_randomNumberService.GetRandomNumber().ToString()); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task PipelineBuilder_CreatesAndRunsPipeline(bool provideLogger) + { + ILoggerFactory? loggerFactory = provideLogger ? new LoggerFactory() : null; + + var context = new SampleContext(); + var builder = Pipeline.CreateFor(loggerFactory); + + // Test adding a service to the pipeline + builder.ConfigureServices(services => + { + services.AddSingleton(); + }); + + builder.WithStep(); + + // Test adding a step with a delegate + builder.WithStep((ctx, next, cancellationToken) => + { + ctx.AddStep("BuilderStep0"); + return next(ctx, cancellationToken); + }); + + // Test adding a step using a class + builder.WithStep(); + + // Test adding a step using a class with dependencies + builder.WithStep(); + + // Test adding a step with a delegate + builder.WithStep((ctx, next, cancellationToken) => + { + ctx.AddStep("BuilderStep3"); + return next(ctx, cancellationToken); + }); + + var pipeline = builder.Build(); + + await pipeline.ExecuteAsync(context); + + context.Steps.Count.ShouldBe(6); + context.Steps[0].ShouldBe(SampleContextStepA.StepName); + context.Steps[1].ShouldBe("BuilderStep0"); + context.Steps[2].ShouldBe("BuilderStep1"); + context.Steps[3].ShouldBe("BuilderStep2"); + context.Steps[4].ShouldBe("BuilderStep3"); + int.TryParse(context.Steps[5], out _).ShouldBeTrue(); + } + + [Fact] + public void PipelineBuilder_ThrowsException_WhenTypeImplementsIPipelineStep() + { + Should.Throw(() => + { + _ = Pipeline.CreateFor(); + }); + } +} diff --git a/src/PipeForge.Tests/PipelineRegistration/RegisterPipelineTests.cs b/src/PipeForge.Tests/PipelineRegistration/RegisterPipelineTests.cs new file mode 100644 index 0000000..07683dc --- /dev/null +++ b/src/PipeForge.Tests/PipelineRegistration/RegisterPipelineTests.cs @@ -0,0 +1,53 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using PipeForge.Tests.Steps; + +namespace PipeForge.Tests.PipelineRegistration; + +public class RegisterPipelineTests +{ + private static readonly Assembly[] _assemblies = [typeof(SampleContext).Assembly]; + + [Fact] + public void RegisterPipeline_ThrowsException_WhenContextImplementsIPipelineStep() + { + var name = typeof(SampleContextStepA).FullName ?? typeof(SampleContextStepA).Name; + var services = new ServiceCollection(); + + var ex = Should.Throw(() => + { + services.RegisterPipeline< + SampleContextStepA, + IPipelineStep, + IPipelineRunner>>(_assemblies, ServiceLifetime.Transient, null); + }); + + ex.Message.ShouldStartWith(string.Format(PipeForge.PipelineRegistration.MessageInvalidContextType, name)); + } + + [Fact] + public void RegisterPipeline_ReturnsEarly_WhenRunnerAlreadyRegistered() + { + var services = new ServiceCollection(); + services.AddTransient(); + + services.RegisterPipeline(_assemblies, ServiceLifetime.Transient, null); + + services.Any(d => d.ServiceType == typeof(ISampleContextStep)).ShouldBeFalse(); + } + + [Theory] + [InlineData(4)] + [InlineData(6, TestConstants.Filter1)] + [InlineData(6, TestConstants.Filter2)] + [InlineData(7, TestConstants.Filter1, TestConstants.Filter2)] + public void RegisterPipeline_RegistersEverything(int expected, params string[]? filters) + { + var services = new ServiceCollection(); + + services.RegisterPipeline(_assemblies, ServiceLifetime.Transient, filters); + + services.Count(d => d.ServiceType == typeof(ISampleContextStep)).ShouldBe(expected); + services.Any(d => d.ServiceType == typeof(ISampleContextRunner)).ShouldBeTrue(); + } +} diff --git a/src/PipeForge.Tests/PipelineRegistration/RegisterRunnerTests.cs b/src/PipeForge.Tests/PipelineRegistration/RegisterRunnerTests.cs new file mode 100644 index 0000000..18dcf3b --- /dev/null +++ b/src/PipeForge.Tests/PipelineRegistration/RegisterRunnerTests.cs @@ -0,0 +1,113 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using PipeForge.Tests.Steps; + +namespace PipeForge.Tests.PipelineRegistration; + +public class RegisterRunnerTests +{ + private static readonly Assembly[] Assemblies = [typeof(ISampleContextStep).Assembly]; + + [Fact] + public void RegisterRunner_ReturnsTrue_WhenRegistering_DefaultInterfaces() + { + var services = new ServiceCollection(); + + var result = services.RegisterRunner< + SampleContext, + IPipelineStep, + IPipelineRunner>>(Assemblies, ServiceLifetime.Transient, null); + + result.ShouldBeTrue(); + + var provider = services.BuildServiceProvider(); + var runner = provider.GetRequiredService>>(); + runner.ShouldNotBeNull(); + runner.ShouldBeOfType>>(); + } + + [Fact] + public void RegisterRunner_ReturnsTrue_WhenRegistering_DefaultSimpleInterfaces() + { + var services = new ServiceCollection(); + + var result = services.RegisterRunner< + SampleContext, + IPipelineStep, + IPipelineRunner>(Assemblies, ServiceLifetime.Transient, null); + + result.ShouldBeTrue(); + + var provider = services.BuildServiceProvider(); + var runner = provider.GetRequiredService>(); + runner.ShouldNotBeNull(); + runner.ShouldBeOfType>(); + } + + [Fact] + public void RegisterRunner_ReturnsTrue_WhenRegistering_CustomStepInterface() + { + var services = new ServiceCollection(); + + var result = services.RegisterRunner< + SampleContext, + ISampleContextStep, + IPipelineRunner>(Assemblies, ServiceLifetime.Transient, null); + + result.ShouldBeTrue(); + + var provider = services.BuildServiceProvider(); + var runner = provider.GetRequiredService>(); + runner.ShouldNotBeNull(); + runner.ShouldBeOfType>(); + } + + [Fact] + public void RegisterRunner_ReturnsTrue_WhenRegistering_CustomRunnerInterface() + { + var services = new ServiceCollection(); + + var result = services.RegisterRunner< + SampleContext, + ISampleContextStep, + ISampleContextRunner>(Assemblies, ServiceLifetime.Transient, null); + + result.ShouldBeTrue(); + + var provider = services.BuildServiceProvider(); + var runner = provider.GetRequiredService(); + runner.ShouldNotBeNull(); + runner.ShouldBeOfType(); + } + + [Fact] + public void RegisterRunner_ReturnsFalse_WhenRunnerIsAlreadyRegisters() + { + var services = new ServiceCollection(); + services.AddTransient>, PipelineRunner>>(); + + var result = services.RegisterRunner< + SampleContext, + IPipelineStep, + IPipelineRunner>>(Assemblies, ServiceLifetime.Transient, null); + + result.ShouldBeFalse(); + } + + [Fact] + public void RegisterRunner_Throws_WhenNoRunnerImplementationFound() + { + var runnerTypeName = typeof(INotImplementedRunner).FullName ?? typeof(INotImplementedRunner).Name; + var services = new ServiceCollection(); + + var ex = Should.Throw(() => + { + services.RegisterRunner< + SampleContext, + ISampleContextStep, + INotImplementedRunner>(Assemblies, ServiceLifetime.Transient, null); + }); + + ex.Message.ShouldBe(string.Format(PipeForge.PipelineRegistration.MessageRunnerImplementationNotFound, runnerTypeName)); + } +} diff --git a/src/PipeForge.Tests/PipelineRegistration/RegisterStepTests.cs b/src/PipeForge.Tests/PipelineRegistration/RegisterStepTests.cs new file mode 100644 index 0000000..4d918de --- /dev/null +++ b/src/PipeForge.Tests/PipelineRegistration/RegisterStepTests.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.DependencyInjection; +using PipeForge.Metadata; +using PipeForge.Tests.Steps; + +namespace PipeForge.Tests.PipelineRegistration; + +public class RegisterStepTests +{ + [Fact] + public void RegisterStep_RegistersInterfaceAndLazyType() + { + var services = new ServiceCollection(); + var descriptor = StepTypeDescriptor.Create(typeof(SampleContextStepA)); + + services.RegisterStep(descriptor, ServiceLifetime.Transient, null); + + var provider = services.BuildServiceProvider(); + var step = provider.GetRequiredService(); + var intr = provider.GetRequiredService(); + var lazy = provider.GetRequiredService>(); + } + + [Fact] + public void RegisterStep_Throws_WhenStepIsAlreadyRegistered() + { + var descriptor = StepTypeDescriptor.Create(typeof(SampleContextStepA)); + + var services = new ServiceCollection(); + services.AddTransient(); +#if DEBUG + var ex = Should.Throw(() => + { + services.RegisterStep(descriptor, ServiceLifetime.Transient, null); + }); + + ex.Message.ShouldBe(string.Format(PipeForge.PipelineRegistration.MessageStepAlreadyRegistered, descriptor.TypeName)); +#endif + } +} diff --git a/src/PipeForge.Tests/PipelineRunner/DescribeSchemaTests.cs b/src/PipeForge.Tests/PipelineRunner/DescribeSchemaTests.cs new file mode 100644 index 0000000..9a854b7 --- /dev/null +++ b/src/PipeForge.Tests/PipelineRunner/DescribeSchemaTests.cs @@ -0,0 +1,32 @@ +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using PipeForge.Tests.Steps; + +namespace PipeForge.Tests.PipelineRunner; + +public class DescribeSchemaTests +{ + [Fact] + public void DescribeSchema_ReturnsValidJsonSchema() + { + var provider = new ServiceCollection().BuildServiceProvider(); + var runner = new PipelineRunner(provider); + + var schema = runner.DescribeSchema(); + + var document = JsonDocument.Parse(schema); + var root = document.RootElement; + + root.GetProperty("$schema").GetString().ShouldBe("http://json-schema.org/draft-07/schema#"); + root.GetProperty("title").GetString().ShouldBe("PipelineStep"); + root.GetProperty("type").GetString().ShouldBe("object"); + + var properties = root.GetProperty("properties"); + properties.TryGetProperty("Order", out _).ShouldBeTrue(); + properties.TryGetProperty("Name", out _).ShouldBeTrue(); + properties.TryGetProperty("MayShortCircuit", out _).ShouldBeTrue(); + + var required = root.GetProperty("required").EnumerateArray().Select(e => e.GetString()).ToArray(); + required.ShouldContain("Order"); + } +} diff --git a/src/PipeForge.Tests/PipelineRunner/DescribeTests.cs b/src/PipeForge.Tests/PipelineRunner/DescribeTests.cs new file mode 100644 index 0000000..c160d7b --- /dev/null +++ b/src/PipeForge.Tests/PipelineRunner/DescribeTests.cs @@ -0,0 +1,92 @@ +using Microsoft.Extensions.DependencyInjection; +using PipeForge.Tests.Steps; + +namespace PipeForge.Tests.PipelineRunner; + +public class DescribeTests +{ + private interface INamedStep : IPipelineStep { } + + private class NamedStep : INamedStep + { + public string? Description { get; } + + public bool MayShortCircuit { get; } + + public string Name { get; } + + public string? ShortCircuitCondition { get; } + + public NamedStep(string name, string? description = null, bool shortCircuiting = false, string? condition = null) + { + Name = name; + Description = description; + MayShortCircuit = shortCircuiting; + ShortCircuitCondition = condition; + } + + public Task InvokeAsync(SampleContext context, PipelineDelegate next, CancellationToken cancellationToken = default) + { + throw new NotImplementedException(); + } + } + + private class NamedStep1 : NamedStep + { + public static readonly string StepName = Guid.NewGuid().ToString(); + public static readonly string StepDescription = Guid.NewGuid().ToString(); + public static readonly string Condition = Guid.NewGuid().ToString(); + public static bool ShortCircuiting = true; + + public NamedStep1() : base(StepName, StepDescription, ShortCircuiting, Condition) + { + } + } + + private class NamedStep2 : NamedStep + { + public static readonly string StepName = Guid.NewGuid().ToString(); + public static readonly string StepDescription = Guid.NewGuid().ToString(); + public static bool ShortCircuiting = false; + + public NamedStep2() : base(StepName, StepDescription, ShortCircuiting, null) + { + } + } + + private class NamedStep3 : NamedStep + { + public static readonly string StepName = Guid.NewGuid().ToString(); + public static readonly string StepDescription = Guid.NewGuid().ToString(); + public static bool ShortCircuiting = false; + + public NamedStep3() : base(StepName, StepDescription, ShortCircuiting, null) + { + } + } + + [Fact] + public void Describe_ReturnsStepDescriptions_InOrderAndWithMetadata() + { + var services = new ServiceCollection(); + services.AddPipelineStep(); + services.AddPipelineStep(); + services.AddPipelineStep(); + services.RegisterRunner>([typeof(INamedStep).Assembly], ServiceLifetime.Transient, null); + + var provider = services.BuildServiceProvider(); + var runner = provider.GetRequiredService>(); + + var json = runner.Describe(); + + json.ShouldContain($"\"Name\": \"{NamedStep1.StepName}\""); + json.ShouldContain($"\"Name\": \"{NamedStep2.StepName}\""); + json.ShouldContain($"\"Name\": \"{NamedStep3.StepName}\""); + + json.ShouldContain("\"MayShortCircuit\": true"); + json.ShouldContain($"\"ShortCircuitCondition\": \"{NamedStep1.Condition}\""); + + json.IndexOf(NamedStep1.StepName).ShouldBeLessThan(json.IndexOf(NamedStep2.StepName)); + json.IndexOf(NamedStep2.StepName).ShouldBeLessThan(json.IndexOf(NamedStep3.StepName)); + } +} diff --git a/src/PipeForge.Tests/PipelineRunner/ExecuteAsyncTests.cs b/src/PipeForge.Tests/PipelineRunner/ExecuteAsyncTests.cs new file mode 100644 index 0000000..1e9db0d --- /dev/null +++ b/src/PipeForge.Tests/PipelineRunner/ExecuteAsyncTests.cs @@ -0,0 +1,80 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using PipeForge.Tests.Steps; + +namespace PipeForge.Tests.PipelineRunner; + +public class ExecuteAsyncTests +{ + private static readonly Assembly[] _assemblies = [typeof(SampleContext).Assembly]; + + [Fact] + public async Task ExecuteAsync_ThrowsException_WhenContextIsNull() + { + var services = new ServiceCollection(); + services.AddPipeline(_assemblies, [TestConstants.Filter1]); + + var provider = services.BuildServiceProvider(); + var runner = provider.GetRequiredService(); + runner.ShouldNotBeNull(); + + await Should.ThrowAsync(() => runner.ExecuteAsync(null!)); + } + + [Fact] + public async Task ExecuteAsync_ShouldRunPipelineSuccessfully() + { + var services = new ServiceCollection(); + services.AddPipeline(_assemblies); + + var provider = services.BuildServiceProvider(); + var runner = provider.GetRequiredService(); + runner.ShouldNotBeNull(); + + var context = new SampleContext(); + await runner.ExecuteAsync(context); + + var result = context.ToString(); + result.ShouldBe("A,C,B,Z"); + } + + [Fact] + public async Task ExecuteAsync_ShouldRunPipelineSuccessfully_WithShortCircuit() + { + var services = new ServiceCollection(); + services.AddPipeline(_assemblies, [TestConstants.Filter1]); + + var provider = services.BuildServiceProvider(); + var runner = provider.GetRequiredService(); + runner.ShouldNotBeNull(); + + var context = new SampleContext(); + await runner.ExecuteAsync(context); + + var result = context.ToString(); + result.ShouldBe("A,C,B,F"); + } + + [Fact] + public async Task ExecuteAsync_ShouldRunPipelineSuccessfully_WithException() + { + var services = new ServiceCollection(); + services.AddPipeline(_assemblies, [TestConstants.Filter2]); + + var provider = services.BuildServiceProvider(); + var runner = provider.GetRequiredService(); + runner.ShouldNotBeNull(); + + var context = new SampleContext(); + + var ex = await Should.ThrowAsync>(() => runner.ExecuteAsync(context)); + + ex.ShouldNotBeNull(); + ex.InnerException.ShouldBeOfType(); + ex.StepName.ShouldBe("F"); + ex.StepOrder.ShouldBe(3); + + var result = context.ToString(); + result.ShouldBe("A,C,B"); + } +} diff --git a/src/PipeForge/Adapters/Json/NewtonsoftJsonSerializer.cs b/src/PipeForge/Adapters/Json/NewtonsoftJsonSerializer.cs index 42994d0..33cb785 100644 --- a/src/PipeForge/Adapters/Json/NewtonsoftJsonSerializer.cs +++ b/src/PipeForge/Adapters/Json/NewtonsoftJsonSerializer.cs @@ -5,7 +5,7 @@ namespace PipeForge.Adapters.Json; [ExcludeFromCodeCoverage] -internal class NewtonsoftJsonSerializer : IJsonSerializer +internal sealed class NewtonsoftJsonSerializer : IJsonSerializer { private static readonly NewtonsoftJsonSerializer _instance = new(); diff --git a/src/PipeForge/Adapters/Json/SystemTextJsonSerializer.cs b/src/PipeForge/Adapters/Json/SystemTextJsonSerializer.cs index 3e77aa5..085e1bc 100644 --- a/src/PipeForge/Adapters/Json/SystemTextJsonSerializer.cs +++ b/src/PipeForge/Adapters/Json/SystemTextJsonSerializer.cs @@ -6,7 +6,7 @@ namespace PipeForge.Adapters.Json; [ExcludeFromCodeCoverage] -internal class SystemTextJsonSerializer : IJsonSerializer +internal sealed class SystemTextJsonSerializer : IJsonSerializer { private static readonly SystemTextJsonSerializer _instance = new(); diff --git a/src/PipeForge/AddPipelineExtensions.cs b/src/PipeForge/AddPipelineExtensions.cs new file mode 100644 index 0000000..36ab9aa --- /dev/null +++ b/src/PipeForge/AddPipelineExtensions.cs @@ -0,0 +1,456 @@ +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; + +namespace PipeForge; + +[ExcludeFromCodeCoverage] +public static class AddPipelineExtensions +{ + #region AddPipeline + + /// + /// Discovers and registers all pipeline steps in the that implement . + /// Steps are registered in the order defined by the attribute, + /// using the service lifetime. Also registers an instance of + /// as , + /// enabling resolution of the pipeline runner from the dependency injection container. + /// + /// + /// Specifying a filter allows for conditional registration of pipeline steps based on the filter. + /// + /// Steps without a filter will always be registered. + /// Steps with a filter will only be registered if the current filter contains a match for any of the filter constraints on the step. + /// + /// + /// + /// Thrown when the type TContext implements the generic or interface. + /// + /// + /// + /// + /// + public static IServiceCollection AddPipeline( + this IServiceCollection services, + string[]? filters = null) + where TContext : class + { + return services.AddPipeline, IPipelineRunner>( + AppDomain.CurrentDomain.GetAssemblies(), + ServiceLifetime.Transient, + filters); + } + + /// + /// Discovers and registers all pipeline steps in the specified assemblies that implement . + /// Steps are registered in the order defined by the attribute, + /// using the service lifetime. Also registers an instance of + /// as , + /// enabling resolution of the pipeline runner from the dependency injection container. + /// + /// + /// Specifying a filter allows for conditional registration of pipeline steps based on the filter. + /// + /// Steps without a filter will always be registered. + /// Steps with a filter will only be registered if the current filter contains a match for any of the filter constraints on the step. + /// + /// + /// + /// Thrown when the type TContext implements the generic or interface. + /// + /// + /// + /// + /// + /// + public static IServiceCollection AddPipeline( + this IServiceCollection services, + IEnumerable assemblies, + string[]? filters = null) + where TContext : class + { + return services.AddPipeline, IPipelineRunner>( + assemblies, + ServiceLifetime.Transient, + filters); + } + + /// + /// Discovers and registers all pipeline steps in the that implement . + /// Steps are registered in the order defined by the attribute, + /// using the specified . Also registers an instance of + /// as , + /// enabling resolution of the pipeline runner from the dependency injection container. + /// + /// + /// Specifying a filter allows for conditional registration of pipeline steps based on the filter. + /// + /// Steps without a filter will always be registered. + /// Steps with a filter will only be registered if the current filter contains a match for any of the filter constraints on the step. + /// + /// + /// + /// Thrown when the type TContext implements the generic or interface. + /// + /// + /// + /// + /// + /// + public static IServiceCollection AddPipeline( + this IServiceCollection services, + ServiceLifetime lifetime, + string[]? filters = null) + where TContext : class + { + return services.AddPipeline, IPipelineRunner>( + AppDomain.CurrentDomain.GetAssemblies(), + lifetime, + filters); + } + + /// + /// Discovers and registers all pipeline steps in the specified assemblies that implement . + /// Steps are registered in the order defined by the attribute, + /// using the specified . Also registers an instance of + /// as , + /// enabling resolution of the pipeline runner from the dependency injection container. + /// + /// + /// Specifying a filter allows for conditional registration of pipeline steps based on the filter. + /// + /// Steps without a filter will always be registered. + /// Steps with a filter will only be registered if the current filter contains a match for any of the filter constraints on the step. + /// + /// + /// + /// Thrown when the type TContext implements the generic or interface. + /// + /// + /// + /// + /// + /// + /// + public static IServiceCollection AddPipeline( + this IServiceCollection services, + IEnumerable assemblies, + ServiceLifetime lifetime, + string[]? filters = null) + where TContext : class + { + return services.AddPipeline, IPipelineRunner>( + assemblies, + lifetime, + filters); + } + + #endregion + + #region AddPipeline + + /// + /// Discovers and registers all pipeline steps in the that implement the given step interface. + /// Steps are registered in the order defined by the attribute, + /// using the service lifetime. Also registers an instance of + /// as , + /// enabling resolution of the pipeline runner from the dependency injection container. + /// + /// + /// Specifying a filter allows for conditional registration of pipeline steps based on the filter. + /// + /// Steps without a filter will always be registered. + /// Steps with a filter will only be registered if the current filter contains a match for any of the filter constraints on the step. + /// + /// + /// + /// Thrown when the type TContext implements the generic or interface. + /// + /// + /// + /// + /// + /// + public static IServiceCollection AddPipeline( + this IServiceCollection services, + string[]? filters = null) + where TContext : class + where TStepInterface : IPipelineStep + { + return services.AddPipeline>( + AppDomain.CurrentDomain.GetAssemblies(), + ServiceLifetime.Transient, + filters); + } + + /// + /// Discovers and registers all pipeline steps in the that implement the given step interface. + /// Steps are registered in the order defined by the attribute, + /// using the specified . Also registers an instance of + /// as , + /// enabling resolution of the pipeline runner from the dependency injection container. + /// + /// + /// Specifying a filter allows for conditional registration of pipeline steps based on the filter. + /// + /// Steps without a filter will always be registered. + /// Steps with a filter will only be registered if the current filter contains a match for any of the filter constraints on the step. + /// + /// + /// + /// Thrown when the type TContext implements the generic or interface. + /// + /// + /// + /// + /// + /// + /// + public static IServiceCollection AddPipeline( + this IServiceCollection services, + ServiceLifetime lifetime, + string[]? filters = null) + where TContext : class + where TStepInterface : IPipelineStep + { + return services.AddPipeline>( + AppDomain.CurrentDomain.GetAssemblies(), + lifetime, + filters); + } + + /// + /// Discovers and registers all pipeline steps in the specified assemblies that implement the given step interface. + /// Steps are registered in the order defined by the attribute, + /// using the lifetime. Also registers an instance of + /// as , + /// enabling resolution of the pipeline runner from the dependency injection container. + /// + /// + /// Specifying a filter allows for conditional registration of pipeline steps based on the filter. + /// + /// Steps without a filter will always be registered. + /// Steps with a filter will only be registered if the current filter contains a match for any of the filter constraints on the step. + /// + /// + /// + /// Thrown when the type TContext implements the generic or interface. + /// + /// + /// + /// + /// + /// + /// + public static IServiceCollection AddPipeline( + this IServiceCollection services, + IEnumerable assemblies, + string[]? filters = null) + where TContext : class + where TStepInterface : IPipelineStep + { + return services.AddPipeline>( + assemblies, + ServiceLifetime.Transient, + filters); + } + + /// + /// Discovers and registers all pipeline steps in the specified assemblies that implement the given step interface. + /// Steps are registered in the order defined by the attribute, + /// using the specified . Also registers an instance of + /// as , + /// enabling resolution of the pipeline runner from the dependency injection container. + /// + /// + /// Specifying a filter allows for conditional registration of pipeline steps based on the filter. + /// + /// Steps without a filter will always be registered. + /// Steps with a filter will only be registered if the current filter contains a match for any of the filter constraints on the step. + /// + /// + /// + /// Thrown when the type TContext implements the generic or interface. + /// + /// + /// + /// + /// + /// + /// + /// + public static IServiceCollection AddPipeline( + this IServiceCollection services, + IEnumerable assemblies, + ServiceLifetime lifetime, + string[]? filters = null) + where TContext : class + where TStepInterface : IPipelineStep + { + return services.AddPipeline>( + assemblies, + lifetime, + filters); + } + + #endregion + + #region AddPipeline + + /// + /// Discovers and registers all pipeline steps in the that implement the given step interface. + /// Steps are registered in the order defined by the attribute, + /// using the service lifetime. Also registers an instance of + /// as , + /// allowing for convenient resolution of the pipeline runner from the dependency injection container. + /// + /// + /// Specifying a filter allows for conditional registration of pipeline steps based on the filter. + /// + /// Steps without a filter will always be registered. + /// Steps with a filter will only be registered if the current filter contains a match for any of the filter constraints on the step. + /// + /// + /// + /// Thrown when the type TContext implements the generic or interface. + /// + /// + /// + /// + /// + /// + /// + public static IServiceCollection AddPipeline( + this IServiceCollection services, + string[]? filters = null) + where TContext : class + where TStepInterface : IPipelineStep + where TRunnerInterface : IPipelineRunner + { + return services.AddPipeline( + AppDomain.CurrentDomain.GetAssemblies(), + ServiceLifetime.Transient, + filters); + } + + /// + /// Discovers and registers all pipeline steps in the that implement the given step interface. + /// Steps are registered in the order defined by the attribute, + /// using the specified . Also registers an instance of + /// as , + /// allowing for convenient resolution of the pipeline runner from the dependency injection container. + /// + /// + /// Specifying a filter allows for conditional registration of pipeline steps based on the filter. + /// + /// Steps without a filter will always be registered. + /// Steps with a filter will only be registered if the current filter contains a match for any of the filter constraints on the step. + /// + /// + /// + /// Thrown when the type TContext implements the generic or interface. + /// + /// + /// + /// + /// + /// + /// + /// + public static IServiceCollection AddPipeline( + this IServiceCollection services, + ServiceLifetime lifetime, + string[]? filters = null) + where TContext : class + where TStepInterface : IPipelineStep + where TRunnerInterface : IPipelineRunner + { + return services.AddPipeline( + AppDomain.CurrentDomain.GetAssemblies(), + lifetime, + filters); + } + + /// + /// Discovers and registers all pipeline steps in the specified assemblies that implement the given step interface. + /// Steps are registered in the order defined by the attribute, + /// using the service lifetime. Also registers an instance of + /// as , + /// allowing for convenient resolution of the pipeline runner from the dependency injection container. + /// + /// + /// Specifying a filter allows for conditional registration of pipeline steps based on the filter. + /// + /// Steps without a filter will always be registered. + /// Steps with a filter will only be registered if the current filter contains a match for any of the filter constraints on the step. + /// + /// + /// + /// Thrown when the type TContext implements the generic or interface. + /// + /// + /// + /// + /// + /// + /// + /// + public static IServiceCollection AddPipeline( + this IServiceCollection services, + IEnumerable assemblies, + string[]? filters = null) + where TContext : class + where TStepInterface : IPipelineStep + where TRunnerInterface : IPipelineRunner + { + return services.AddPipeline( + assemblies, + ServiceLifetime.Transient, + filters); + } + + /// + /// Discovers and registers all pipeline steps in the specified assemblies that implement the given step interface. + /// Steps are registered in the order defined by the attribute, + /// using the specified . Also registers an instance of + /// as , + /// allowing for convenient resolution of the pipeline runner from the dependency injection container. + /// + /// + /// Specifying a filter allows for conditional registration of pipeline steps based on the filter. + /// + /// Steps without a filter will always be registered. + /// Steps with a filter will only be registered if the current filter contains a match for any of the filter constraints on the step. + /// + /// + /// + /// Thrown when the type TContext implements the generic or interface. + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static IServiceCollection AddPipeline( + this IServiceCollection services, + IEnumerable assemblies, + ServiceLifetime lifetime, + string[]? filters = null) + where TContext : class + where TStepInterface : IPipelineStep + where TRunnerInterface : IPipelineRunner + { + // Calls the internal registration method to handle the actual registration logic. + // This is the only place where the registration logic is defined, ensuring + // that the same logic is used regardless of the overload called. + return services.RegisterPipeline( + assemblies, + lifetime, + filters); + } + + #endregion +} diff --git a/src/PipeForge/AddPipelineStepExtensions.cs b/src/PipeForge/AddPipelineStepExtensions.cs new file mode 100644 index 0000000..c957e8e --- /dev/null +++ b/src/PipeForge/AddPipelineStepExtensions.cs @@ -0,0 +1,52 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using PipeForge.Extensions; +using PipeForge.Metadata; + +namespace PipeForge; + +[ExcludeFromCodeCoverage] +public static class AddPipelineStepExtensions +{ + /// + /// Registers the specified pipeline step using its implemented interface. + /// This overload is ideal when no custom interface is used for the step. + /// + /// The concrete pipeline step type implementing . + /// The service collection to register with. + /// The desired service lifetime. Defaults to . + /// The modified . + public static IServiceCollection AddPipelineStep( + this IServiceCollection services, + ServiceLifetime lifetime = ServiceLifetime.Transient) + where TStep : class, IPipelineStep + { + var typeDescriptor = StepTypeDescriptor.Create(typeof(TStep)); + return services.AddPipelineStep(typeDescriptor, lifetime); + } + + /// + /// Registers the specified pipeline step using the provided step interface. + /// This overload allows registration using a custom step interface derived from . + /// + /// The concrete pipeline step type. + /// The interface implemented by , derived from . + /// The service collection to register with. + /// The desired service lifetime. Defaults to . + /// The modified . + public static IServiceCollection AddPipelineStep( + this IServiceCollection services, + ServiceLifetime lifetime = ServiceLifetime.Transient) + where TStep : class, TStepInterface + where TStepInterface : class, IPipelineStep + { + var typeDescriptor = StepTypeDescriptor.Create(typeof(TStep)); + return services.AddPipelineStep(typeDescriptor, lifetime); + } + + private static IServiceCollection AddPipelineStep(this IServiceCollection services, StepTypeDescriptor typeDescriptor, ServiceLifetime lifetime) + { + services.RegisterStep(typeDescriptor, lifetime, services.GetLogger()); + return services; + } +} diff --git a/src/PipeForge/CompositionExtensions.cs b/src/PipeForge/CompositionExtensions.cs deleted file mode 100644 index d4fcf85..0000000 --- a/src/PipeForge/CompositionExtensions.cs +++ /dev/null @@ -1,309 +0,0 @@ -using System.Collections.Concurrent; -using System.Diagnostics.CodeAnalysis; -using System.Reflection; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace PipeForge; - -public static class CompositionExtensions -{ - private static readonly ConcurrentDictionary _lazyStepTypes = new(); - - private static readonly Type loggerFactoryType = typeof(ILoggerFactory); - private static readonly Type _openGenericPipelineStepType = typeof(IPipelineStep<>); - - private static ILoggerFactory? _loggerFactory = null; - - internal static readonly string ArgumentExceptionMessage = "Type {0} does not implement the required interface IPipelineStep."; - - internal static readonly string InvalidOperationExceptionMessage = "Pipeline step '{0}' is already registered. Pipeline steps must be uniquely registered."; - - /// - /// Registers a pipeline step of type in the service collection. Pipeline steps must be uniquely registered in order to be discoverable by the . - /// - /// - /// By default, the step is registered as a transient service. - /// If you want to register it with a different lifetime, you can specify that using the parameter. - /// - /// - /// - /// - /// - public static IServiceCollection AddPipelineStep( - this IServiceCollection services, - ServiceLifetime lifetime = ServiceLifetime.Transient) - where T : IPipelineStep - { - services.GetLoggerFactory(); - var logger = _loggerFactory?.CreateLogger(nameof(Pipeline)); - - if (IsValidPipelineStep(out var concreteType, out var interfaceType, out var lazyType)) - { - if (!services.Any(s => s.ServiceType == concreteType)) - { - Pipeline.RegisterStep(concreteType, interfaceType!, lazyType!, services, lifetime, logger); - } - else - { - logger?.LogWarning("Attempt to register pipeline step '{Step}' multiple times.", concreteType.FullName ?? concreteType.Name); -#if DEBUG - throw new InvalidOperationException(string.Format(InvalidOperationExceptionMessage, concreteType.FullName ?? concreteType.Name)); -#endif - } - } - else - { - logger?.LogError("Type {Type} does not implement the required interface IPipelineStep.", concreteType.FullName ?? concreteType.Name); - throw new ArgumentException(string.Format(ArgumentExceptionMessage, concreteType.FullName ?? concreteType.Name)); - } - - return services; - } - - /// - /// Discovers and registers all pipeline steps in all assemblies in the AppDomain for the specified context type using ServiceLifetime.Transient. - /// - /// - /// Specifying an filter allows for conditional registration of pipeline steps. - /// Steps without a filter will always be registered. - /// Steps with a filter will only be registered if the current filter matches the specified name. - /// - /// - /// - /// - [ExcludeFromCodeCoverage] - public static IServiceCollection AddPipelineFor(this IServiceCollection services) - where TContext : class - { - return services.AddPipelineFor(AppDomain.CurrentDomain.GetAssemblies(), ServiceLifetime.Transient, null); - } - - /// - /// Discovers and registers all pipeline steps in all assemblies in the AppDomain for the specified context type using ServiceLifetime.Transient. - /// - /// - /// Specifying a filter allows for conditional registration of pipeline steps based on the filter. - /// Steps without a filter will always be registered. - /// Steps with a filter will only be registered if the current filter matches the specified name. - /// - /// - /// - /// - /// - [ExcludeFromCodeCoverage] - public static IServiceCollection AddPipelineFor( - this IServiceCollection services, - string? filterName) - where TContext : class - { - return services.AddPipelineFor(AppDomain.CurrentDomain.GetAssemblies(), ServiceLifetime.Transient, filterName); - } - - /// - /// Discovers and registers all pipeline steps in all assemblies in the AppDomain for the specified context type using the specified service lifetime. - /// - /// - /// Specifying a filter allows for conditional registration of pipeline steps based on the filter. - /// Steps without a filter will always be registered. - /// Steps with a filter will only be registered if the current filter matches the specified name. - /// - /// - /// - /// - /// - /// - [ExcludeFromCodeCoverage] - public static IServiceCollection AddPipelineFor( - this IServiceCollection services, - ServiceLifetime lifetime, - string? filterName) - where TContext : class - { - return services.AddPipelineFor(AppDomain.CurrentDomain.GetAssemblies(), lifetime, filterName); - } - - /// - /// Discovers and registers all pipeline steps in the assembly containing the marker type for the specified context type using ServiceLifetime.Transient. - /// - /// - /// Specifying a filter allows for conditional registration of pipeline steps based on the filter. - /// Steps without a filter will always be registered. - /// Steps with a filter will only be registered if the current filter matches the specified name. - /// - /// - /// - /// - /// - /// - [ExcludeFromCodeCoverage] - public static IServiceCollection AddPipelineFor( - this IServiceCollection services, - Type markerType, - string? filterName) - where TContext : class - { - return services.AddPipelineFor(new[] { markerType.Assembly }, ServiceLifetime.Transient, filterName); - } - - /// - /// Discovers and registers all pipeline steps in the assembly containing the marker type for the specified context type using the specified service lifetime. - /// - /// - /// Specifying a filter allows for conditional registration of pipeline steps based on the filter. - /// Steps without a filter will always be registered. - /// Steps with a filter will only be registered if the current filter matches the specified name. - /// - /// - /// - /// - /// - /// - /// - [ExcludeFromCodeCoverage] - public static IServiceCollection AddPipelineFor( - this IServiceCollection services, - Type markerType, - ServiceLifetime lifetime, - string? filterName) - where TContext : class - { - return services.AddPipelineFor(new[] { markerType.Assembly }, lifetime, filterName); - } - - /// - /// Discovers and registers all pipeline steps in the specified assembly for the specified context type using ServiceLifetime.Transient. - /// - /// - /// Specifying a filter allows for conditional registration of pipeline steps based on the filter. - /// Steps without a filter will always be registered. - /// Steps with a filter will only be registered if the current filter matches the specified name. - /// - /// - /// - /// - /// - /// - [ExcludeFromCodeCoverage] - public static IServiceCollection AddPipelineFor( - this IServiceCollection services, - Assembly assembly, - string? filterName) - where TContext : class - { - return services.AddPipelineFor(new[] { assembly }, ServiceLifetime.Transient, filterName); - } - - /// - /// Discovers and registers all pipeline steps in the specified assembly for the specified context type using the specified service lifetime. - /// - /// - /// Specifying a filter allows for conditional registration of pipeline steps based on the filter. - /// Steps without a filter will always be registered. - /// Steps with a filter will only be registered if the current filter matches the specified name. - /// - /// - /// - /// - /// - /// - /// - [ExcludeFromCodeCoverage] - public static IServiceCollection AddPipelineFor( - this IServiceCollection services, - Assembly assembly, - ServiceLifetime lifetime, - string? filterName) - where TContext : class - { - return services.AddPipelineFor(new[] { assembly }, lifetime, filterName); - } - - /// - /// Discovers and registers all pipeline steps in the specified assemblies for the specified context type using ServiceLifetime.Transient. - /// - /// - /// Specifying a filter allows for conditional registration of pipeline steps based on the filter. - /// Steps without a filter will always be registered. - /// Steps with a filter will only be registered if the current filter matches the specified name. - /// - /// - /// - /// - /// - /// - [ExcludeFromCodeCoverage] - public static IServiceCollection AddPipelineFor( - this IServiceCollection services, - IEnumerable assemblies, - string? filterName) - where TContext : class - { - return services.AddPipelineFor(assemblies, ServiceLifetime.Transient, filterName); - } - - /// - /// Discovers and registers all pipeline steps in the specified assemblies for the specified context type using the specified service lifetime. - /// - /// - /// Specifying a filter allows for conditional registration of pipeline steps based on the filter. - /// Steps without a filter will always be registered. - /// Steps with a filter will only be registered if the current filter matches the specified name. - /// - /// - /// - /// - /// - /// - /// - public static IServiceCollection AddPipelineFor( - this IServiceCollection services, - IEnumerable assemblies, - ServiceLifetime lifetime, - string? filterName) - where TContext : class - { - services.GetLoggerFactory(); - var logger = _loggerFactory?.CreateLogger(nameof(Pipeline)); - - var descriptors = Pipeline.Discover(assemblies, filterName, logger); - Pipeline.Register(services, descriptors, lifetime, logger); - - return services; - } - - /// - /// Retrieves the from the service collection if it has been registered. - /// If it has not been registered, it will build the service provider to retrieve it. - /// - /// - private static void GetLoggerFactory(this IServiceCollection services) - { - if (_loggerFactory == null && services.Any(s => s.ServiceType == loggerFactoryType)) - { - var provider = services.BuildServiceProvider(); - _loggerFactory = provider.GetService(); - } - } - - /// - /// Returns true if the type implements the open generic IPipelineStep<T> interface. - /// - /// - /// - /// - /// - private static bool IsValidPipelineStep(out Type concreteType, out Type? interfaceType, out Type? lazyType) - { - concreteType = typeof(T); - - interfaceType = concreteType.GetInterfaces() - .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == _openGenericPipelineStepType); - - lazyType = interfaceType == null - ? null - : _lazyStepTypes.GetOrAdd(interfaceType, it => typeof(Lazy<>).MakeGenericType(it)); - - return interfaceType != null; - } -} diff --git a/src/PipeForge/DefaultPipelineRunner.cs b/src/PipeForge/DefaultPipelineRunner.cs new file mode 100644 index 0000000..b8bd0c2 --- /dev/null +++ b/src/PipeForge/DefaultPipelineRunner.cs @@ -0,0 +1,23 @@ + +using System.Diagnostics.CodeAnalysis; + +namespace PipeForge; + +[ExcludeFromCodeCoverage] +internal sealed class DefaultPipelineRunner : PipelineRunner, IPipelineRunner + where TContext : class + where TStepInterface : IPipelineStep +{ + public DefaultPipelineRunner(IServiceProvider serviceProvider) : base(serviceProvider) + { + } +} + +[ExcludeFromCodeCoverage] +internal sealed class DefaultPipelineRunner : PipelineRunner>, IPipelineRunner + where TContext : class +{ + public DefaultPipelineRunner(IServiceProvider serviceProvider) : base(serviceProvider) + { + } +} diff --git a/src/PipeForge/DelegatePipelineStep.cs b/src/PipeForge/DelegatePipelineStep.cs index e8cf8be..da27707 100644 --- a/src/PipeForge/DelegatePipelineStep.cs +++ b/src/PipeForge/DelegatePipelineStep.cs @@ -5,6 +5,7 @@ namespace PipeForge; /// /// The type of the pipeline context. internal sealed class DelegatePipelineStep : PipelineStep + where TContext : class { private readonly Func, CancellationToken, Task> _invoke; diff --git a/src/PipeForge/Extensions/AssemblyExtensions.cs b/src/PipeForge/Extensions/AssemblyExtensions.cs new file mode 100644 index 0000000..90cbe48 --- /dev/null +++ b/src/PipeForge/Extensions/AssemblyExtensions.cs @@ -0,0 +1,84 @@ +using System.Reflection; +using PipeForge.Metadata; + +namespace PipeForge.Extensions; + +/// +/// Extension methods for working with assemblies. +/// +internal static class AssemblyExtensions +{ + internal static readonly string MessageNotAnInterface = "The type '{0}' is not an interface."; + + /// + /// Finds all types in the provided assemblies that are concrete, closed implementations of the specified interface. + /// + /// + /// + /// + public static IEnumerable FindClosedImplementationsOf(this IEnumerable assemblies) + { + var targetInterface = typeof(T); + if (!targetInterface.IsInterface) + { + var targetInterfaceName = targetInterface.GetTypeName(); + throw new ArgumentException(string.Format(MessageNotAnInterface, targetInterfaceName), nameof(T)); + } + + return assemblies + .SelectMany(a => SafeGetTypes(() => a.GetTypes())) + .Where(t => + targetInterface.IsAssignableFrom(t) + && t.IsClass + && !t.IsAbstract + && !t.IsGenericTypeDefinition + && !t.ContainsGenericParameters + ); + } + + /// + /// Retrieves pipeline step descriptors for the specified interface from + /// the provided assemblies + /// + /// + /// Includes steps that match any of the (optional) provided filters. + /// + /// + /// + /// + /// + public static IEnumerable GetDescriptorsFor(this IEnumerable assemblies, string[]? filters) + { + return assemblies + .FindClosedImplementationsOf() + .Select(t => new PipelineStepDescriptor(t)) + .Where(d => d.Filters.MatchesAnyFilter(filters)) + .OrderBy(d => d.Order); + } + + /// + /// Safely retrieves types from an assembly, handling exceptions that may occur during reflection. + /// Returns: + /// + /// A collection of types from the provided delegate. + /// If a ReflectionTypeLoadException is thrown, it filters out null types. + /// For any other exception, it returns an empty collection. + /// + /// + /// + internal static IEnumerable SafeGetTypes(Func getTypes) + { + try + { + return getTypes(); + } + catch (ReflectionTypeLoadException ex) + { + return ex.Types.Where(t => t is not null)!; + } + catch + { + return []; + } + } +} diff --git a/src/PipeForge/Extensions/FilterExtensions.cs b/src/PipeForge/Extensions/FilterExtensions.cs new file mode 100644 index 0000000..ade5e9d --- /dev/null +++ b/src/PipeForge/Extensions/FilterExtensions.cs @@ -0,0 +1,20 @@ +namespace PipeForge.Extensions; + +internal static class FilterExtensions +{ + private static readonly StringComparison _comp = StringComparison.OrdinalIgnoreCase; + + /// + /// Returns true if the descriptor's filters match any of the provided filters. + /// If no filters are provided, it returns true by default. + /// + /// + /// + /// + public static bool MatchesAnyFilter(this IEnumerable descriptorFilters, string[]? filters) + { + if (!descriptorFilters.Any()) return true; + if (filters is null || !filters.Any()) return false; + return descriptorFilters.Any(df => filters.Any(f => string.Equals(df, f, _comp))); + } +} diff --git a/src/PipeForge/Extensions/LoggingExtensions.cs b/src/PipeForge/Extensions/LoggingExtensions.cs new file mode 100644 index 0000000..e055af3 --- /dev/null +++ b/src/PipeForge/Extensions/LoggingExtensions.cs @@ -0,0 +1,41 @@ +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace PipeForge.Extensions; + +[ExcludeFromCodeCoverage] +internal static class LoggingExtensions +{ + private static readonly string _loggerCategory = "PipeForge"; + + /// + /// Retrieves the from the service collection if it has been registered. + /// If it has not been registered, it will build the service provider to retrieve it. + /// + /// + internal static ILogger? GetLogger(this IServiceCollection services) + { + var provider = services.BuildServiceProvider(); + var loggerFactory = provider.GetService(); + return loggerFactory?.CreateLogger(_loggerCategory); + } + + /// + /// Logs the number of steps registered for a specific type. + /// + /// + /// + /// + internal static void LogStepsRegistered(this ILogger? logger, int count, string typeName) + { + if (count == 0) + { + logger?.LogWarning(PipelineRegistration.MessageNoStepsFound, typeName); + } + else + { + logger?.LogInformation(PipelineRegistration.MessageNumberStepsFound, count, typeName); + } + } +} diff --git a/src/PipeForge/Extensions/TypeExtensions.cs b/src/PipeForge/Extensions/TypeExtensions.cs new file mode 100644 index 0000000..6b7fc14 --- /dev/null +++ b/src/PipeForge/Extensions/TypeExtensions.cs @@ -0,0 +1,47 @@ +using System.Collections.Concurrent; + +namespace PipeForge.Extensions; + +internal static class TypeExtensions +{ + private static readonly ConcurrentDictionary _typeNamesCache = new(); + private static readonly Type _pipelineStepType = typeof(IPipelineStep<>); + + /// + /// Checks if the type implements the IPipelineStep interface. + /// + /// + /// + public static bool ImplementsPipelineStep(this Type type) + { + if (type == null) return false; + return (type.IsGenericType && type.GetGenericTypeDefinition() == _pipelineStepType) || + type.GetInterfaces().Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == _pipelineStepType); + } + + /// + /// Checks if the type directly implements the specified interface type. + /// + /// + /// + /// + public static bool DirectlyImplements(this Type type, Type interfaceType) + { + var baseInterfaces = type.BaseType?.GetInterfaces() ?? []; + var directInterfaces = type.GetInterfaces().Except(baseInterfaces); + return directInterfaces.Contains(interfaceType); + } + + /// + /// Gets the full name of the type, or its name if the full name is not available. + /// + /// + /// + public static string GetTypeName(this Type type) + { + return _typeNamesCache.GetOrAdd(type, t => + { + return t.FullName ?? t.Name ?? string.Empty; + }); + } +} diff --git a/src/PipeForge/IPipelineRunner.cs b/src/PipeForge/IPipelineRunner.cs index 1cd00e7..1cdfb88 100644 --- a/src/PipeForge/IPipelineRunner.cs +++ b/src/PipeForge/IPipelineRunner.cs @@ -1,15 +1,24 @@ +namespace PipeForge; + /// -/// Executes a sequence of pipeline steps for a given context type +/// Represents a pipeline runner for a specific context type, using the provided interface as the step interface. /// -/// The type of context passed through the pipeline -public interface IPipelineRunner +/// +/// The type of the context that is passed to each pipeline step. +/// +/// +/// The interface that all pipeline steps implement. Must be assignable to . +/// +public interface IPipelineRunner + where TContext : class + where TStepInterface : IPipelineStep { /// - /// Runs the pipeline steps in order using the provided context + /// Executes all registered pipeline steps in order using the provided context. /// - /// The context object to be processed by the pipeline - /// A token that can be used to cancel the operation - Task ExecuteAsync(T context, CancellationToken cancellationToken = default); + /// The context instance to pass to each step. + /// A token to cancel the execution pipeline. + Task ExecuteAsync(TContext context, CancellationToken cancellationToken = default); /// /// Returns a JSON array describing the steps in the pipeline @@ -21,3 +30,16 @@ public interface IPipelineRunner /// string DescribeSchema(); } + +/// +/// Represents a pipeline runner for a specific context type, using as the step interface. +/// +/// +/// The type of the context that is passed to each pipeline step. +/// +/// +/// This interface simplifies registration and resolution when a custom step interface is not needed. +/// +public interface IPipelineRunner : IPipelineRunner> + where TContext : class +{ } diff --git a/src/PipeForge/Metadata/PipelineStepDescriptor.cs b/src/PipeForge/Metadata/PipelineStepDescriptor.cs index c108a32..f4cdf36 100644 --- a/src/PipeForge/Metadata/PipelineStepDescriptor.cs +++ b/src/PipeForge/Metadata/PipelineStepDescriptor.cs @@ -1,12 +1,15 @@ using System.Reflection; +using PipeForge.Extensions; namespace PipeForge.Metadata; /// /// Represents metadata extracted from a pipeline step implementation and its associated attribute /// -public sealed class PipelineStepDescriptor +internal sealed class PipelineStepDescriptor { + public static readonly string InvalidOperationExceptionMessage = $"Pipeline step '{{0}}' must be decorated with attribute {nameof(PipelineStepAttribute)}."; + /// /// The concrete type that implements the pipeline step /// @@ -20,7 +23,7 @@ public sealed class PipelineStepDescriptor /// /// An optional step filter, such as "Production" or "Development" /// - public string? Filter { get; } + public IEnumerable Filters { get; } /// /// Creates a descriptor from the pipeline step type and extracts its metadata from the PipelineStepAttribute @@ -32,9 +35,9 @@ public PipelineStepDescriptor(Type implementationType) ImplementationType = implementationType; var attribute = implementationType.GetCustomAttribute() - ?? throw new InvalidOperationException($"Pipeline step '{implementationType.FullName}' must be decorated with [PipelineStep]."); + ?? throw new InvalidOperationException(string.Format(InvalidOperationExceptionMessage, implementationType.GetTypeName())); Order = attribute.Order; - Filter = attribute.Filter; + Filters = attribute.Filters; } } diff --git a/src/PipeForge/Metadata/StepInterfaceDescriptor.cs b/src/PipeForge/Metadata/StepInterfaceDescriptor.cs new file mode 100644 index 0000000..1f50191 --- /dev/null +++ b/src/PipeForge/Metadata/StepInterfaceDescriptor.cs @@ -0,0 +1,40 @@ +using PipeForge.Extensions; + +namespace PipeForge.Metadata; + +/// +/// A static class the contains type information for an interface. +/// This is used to avoid repetitive reflection in the code that needs these types. +/// +/// +internal static class StepInterfaceDescriptor +{ + internal static readonly string MessageArgumentException = "The type {0} is not an interface."; + + internal static readonly string MessageNotPipelineStep = "The type {0} does not implement the IPipelineStep interface."; + + /// + /// The type of the interface. + /// + public static readonly Type InterfaceType = typeof(TStepInterface); + + /// + /// The type of a lazy instance of the interface. + /// + public static readonly Type LazyType = typeof(Lazy<>).MakeGenericType(InterfaceType); + + static StepInterfaceDescriptor() + { + var name = InterfaceType.GetTypeName(); + + if (!InterfaceType.IsInterface) + { + throw new ArgumentException(string.Format(MessageArgumentException, name), nameof(TStepInterface)); + } + + if (!InterfaceType.ImplementsPipelineStep()) + { + throw new ArgumentException(string.Format(MessageNotPipelineStep, name), nameof(TStepInterface)); + } + } +} diff --git a/src/PipeForge/Metadata/StepTypeDescriptor.cs b/src/PipeForge/Metadata/StepTypeDescriptor.cs new file mode 100644 index 0000000..53c425c --- /dev/null +++ b/src/PipeForge/Metadata/StepTypeDescriptor.cs @@ -0,0 +1,74 @@ +using System.Collections.Concurrent; +using PipeForge.Extensions; + +namespace PipeForge.Metadata; + +/// +/// Contains a concrete type and its associated interface and lazy type. +/// This is used to describe types that implement a specific interface and can be lazily instantiated. +/// The lazy type is used to defer the instantiation of the concrete type until it is actually needed. +/// +internal sealed class StepTypeDescriptor +{ + private static readonly ConcurrentDictionary _lazyStepTypes = new(); + private static readonly Type _openGenericPipelineStepType = typeof(IPipelineStep<>); + + public static readonly string ArgumentExceptionMessage = "Type {0} does not implement the interface {1}."; + + /// + /// The concrete type that implements the interface + /// + public Type ConcreteType { get; internal set; } = null!; + + /// + /// The interface that the concrete type implements + /// + public Type InterfaceType { get; internal set; } = null!; + + /// + /// The lazy type that can be used to register the concrete type + /// + public Type LazyType { get; internal set; } = null!; + + /// + /// The name of the type, used for logging and diagnostics. + /// + public string TypeName { get; internal set; } = null!; + + internal StepTypeDescriptor() { } + + public static StepTypeDescriptor Create(Type type) + { + if (!typeof(TStepInterface).IsAssignableFrom(type)) + { + var interfaceType = typeof(TStepInterface); + var interfaceTypeName = interfaceType.GetTypeName(); + throw new ArgumentException(string.Format(ArgumentExceptionMessage, type.GetTypeName(), interfaceTypeName)); + } + + return new StepTypeDescriptor + { + ConcreteType = type, + InterfaceType = StepInterfaceDescriptor.InterfaceType, + LazyType = StepInterfaceDescriptor.LazyType, + TypeName = type.GetTypeName() + }; + } + + public static StepTypeDescriptor Create(Type type) + { + var interfaceType = type.GetInterfaces() + .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == _openGenericPipelineStepType) + ?? throw new ArgumentException(string.Format(ArgumentExceptionMessage, type.GetTypeName(), _openGenericPipelineStepType.GetTypeName())); + + var lazyType = _lazyStepTypes.GetOrAdd(interfaceType, it => typeof(Lazy<>).MakeGenericType(it)); + + return new StepTypeDescriptor + { + ConcreteType = type, + InterfaceType = interfaceType, + LazyType = lazyType, + TypeName = type.GetTypeName() + }; + } +} diff --git a/src/PipeForge/PipeForge.csproj b/src/PipeForge/PipeForge.csproj index 21c9427..570e444 100644 --- a/src/PipeForge/PipeForge.csproj +++ b/src/PipeForge/PipeForge.csproj @@ -2,7 +2,7 @@ netstandard2.0;net5.0 - 10.0 + 12.0 enable enable $(NoWarn);NETSDK1138;CS1591 diff --git a/src/PipeForge/Pipeline.cs b/src/PipeForge/Pipeline.cs index 2d7550c..2bb8a98 100644 --- a/src/PipeForge/Pipeline.cs +++ b/src/PipeForge/Pipeline.cs @@ -1,360 +1,30 @@ -using System.Reflection; -using PipeForge.Metadata; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; -using System.Diagnostics.CodeAnalysis; +using PipeForge.Extensions; namespace PipeForge; public static class Pipeline { - internal static readonly string NoStepsFoundMessage = "No pipeline steps found for {0} in the specified assemblies."; - internal static readonly string NumberStepsFoundMessage = "Discovered {0} pipeline steps for {1}."; - internal static readonly string StepDiscoveredMessage = "Discovered pipeline step {0} [Order={1}, Env={2}]"; - - internal static readonly string RunnerAlreadyRegisteredMessage = "Pipeline runner for {0} already registered. Skipping step registration."; - internal static readonly string StepRegistrationMessage = "Registering pipeline step {0} with {1} lifetime"; - internal static readonly string RunnerRegistrationMessage = "Registering pipeline runner {0} with {1} lifetime"; + internal static readonly string MessageInvalidContextType = "The context type '{0}' cannot implement IPipelineStep<>."; /// - /// Creates a new instance of for the specified context type. + /// Creates a new instance of for the specified context type. /// /// /// This method is used to start building a pipeline for a specific type. /// It allows for fluent configuration of pipeline steps. /// - /// - /// - public static PipelineBuilder CreateFor() where T : class - { - return new PipelineBuilder(); - } - - /// - /// Discovers pipeline steps for a specific context type from all assemblies in the current AppDomain. - /// - /// - /// Specifying a filter allows for conditional discovery of pipeline steps based on the filter. - /// Steps without a filter will always be discovered. - /// Steps with a filter will only be discovered if the current filter matches the specified name. - /// - /// - /// - /// - [ExcludeFromCodeCoverage] - internal static IEnumerable Discover( - ILogger? logger = null) - where TContext : class - { - return Discover(AppDomain.CurrentDomain.GetAssemblies(), null, logger); - } - - /// - /// Discovers pipeline steps for a specific context type from all assemblies in the current AppDomain. - /// - /// - /// Specifying a filter allows for conditional discovery of pipeline steps based on the filter. - /// Steps without a filter will always be discovered. - /// Steps with a filter will only be discovered if the current filter matches the specified name. - /// - /// - /// - /// - /// - [ExcludeFromCodeCoverage] - internal static IEnumerable Discover( - string? filterName, - ILogger? logger = null) - where TContext : class - { - return Discover(AppDomain.CurrentDomain.GetAssemblies(), filterName, logger); - } - - /// - /// Discovers pipeline steps for a specific context type from the assembly containing the provided type marker. - /// - /// - /// Specifying a filter allows for conditional discovery of pipeline steps based on the filter. - /// Steps without a filter will always be discovered. - /// Steps with a filter will only be discovered if the current filter matches the specified name. - /// - /// - /// - /// - /// - [ExcludeFromCodeCoverage] - internal static IEnumerable Discover( - Type typeMarker, - ILogger? logger = null) - where TContext : class - { - return Discover(new[] { typeMarker.Assembly }, null, logger); - } - - /// - /// Discovers pipeline steps for a specific context type from assembly containing the provided type marker. - /// - /// - /// Specifying a filter allows for conditional discovery of pipeline steps based on the filter. - /// Steps without a filter will always be discovered. - /// Steps with a filter will only be discovered if the current filter matches the specified name. - /// - /// - /// - /// - /// - /// - [ExcludeFromCodeCoverage] - internal static IEnumerable Discover( - Type typeMarker, - string? filterName, - ILogger? logger = null) - where TContext : class - { - return Discover(new[] { typeMarker.Assembly }, filterName, logger); - } - - /// - /// Discovers pipeline steps for a specific context type from the provided assembly. - /// - /// - /// Specifying a filter allows for conditional discovery of pipeline steps based on the filter. - /// Steps without a filter will always be discovered. - /// Steps with a filter will only be discovered if the current filter matches the specified name. - /// - /// - /// - /// - /// - [ExcludeFromCodeCoverage] - internal static IEnumerable Discover( - Assembly assembly, - ILogger? logger = null) - where TContext : class - { - return Discover(new[] { assembly }, null, logger); - } - - /// - /// Discovers pipeline steps for a specific context type from the provided assembly. - /// - /// - /// Specifying a filter allows for conditional discovery of pipeline steps based on the filter. - /// Steps without a filter will always be discovered. - /// Steps with a filter will only be discovered if the current filter matches the specified name. - /// - /// - /// - /// - /// - /// - [ExcludeFromCodeCoverage] - internal static IEnumerable Discover( - Assembly assembly, - string? filterName, - ILogger? logger = null) - where TContext : class - { - return Discover(new[] { assembly }, filterName, logger); - } - - /// - /// Discovers pipeline steps for a specific context type from the provided assemblies. - /// - /// - /// Specifying a filter allows for conditional discovery of pipeline steps based on the filter. - /// Steps without a filter will always be discovered. - /// Steps with a filter will only be discovered if the current filter matches the specified name. - /// /// - /// - /// /// - [ExcludeFromCodeCoverage] - internal static IEnumerable Discover( - IEnumerable assemblies, - ILogger? logger = null) - where TContext : class - { - return Discover(assemblies, null, logger); - } - - /// - /// Discovers pipeline steps for a specific context type from the provided assemblies. - /// - /// - /// Specifying a filter allows for conditional discovery of pipeline steps based on the filter. - /// Steps without a filter will always be discovered. - /// Steps with a filter will only be discovered if the current filter matches the specified name. - /// - /// - /// - /// - /// - /// - internal static IEnumerable Discover( - IEnumerable assemblies, - string? filterName, - ILogger? logger = null) + public static PipelineBuilder CreateFor(ILoggerFactory? loggerFactory = null) where TContext : class { var contextType = typeof(TContext); - var stepInterface = typeof(IPipelineStep); - - var descriptors = assemblies - .SelectMany(a => SafeGetTypes(() => a.GetTypes())) - .Where(t => !t.IsAbstract && !t.IsInterface && stepInterface.IsAssignableFrom(t) && t.GetCustomAttribute() != null) - .Select(t => new PipelineStepDescriptor(t)) - .Where(d => d.Filter == null || d.Filter.Equals(filterName, StringComparison.OrdinalIgnoreCase)) - .OrderBy(d => d.Order) - .ToList(); - - if (logger is not null) + if (contextType.ImplementsPipelineStep()) { - if (descriptors.Any()) - { - logger.LogDebug(NumberStepsFoundMessage, descriptors.Count, contextType.FullName ?? contextType.Name); - - foreach (var descriptor in descriptors) - { - logger.LogDebug( - StepDiscoveredMessage, - descriptor.ImplementationType.FullName ?? descriptor.ImplementationType.Name, - descriptor.Order, - descriptor.Filter ?? "(none)"); - } - } - else - { - logger.LogDebug(NoStepsFoundMessage, contextType.FullName ?? contextType.Name); - } + throw new ArgumentException(string.Format(MessageInvalidContextType, contextType.GetTypeName())); } - return descriptors; - } - - /// - /// Registers pipeline steps for a specific context type using the provided descriptors. - /// - /// - /// This method will register all pipeline steps defined in the provided descriptors - /// as transient services in the service collection. - /// - /// - /// - /// - /// - [ExcludeFromCodeCoverage] - internal static void Register( - IServiceCollection services, - IEnumerable descriptors, - ILogger? logger = null) - where TContext : class - { - Register(services, descriptors, ServiceLifetime.Transient, logger); - } - - /// - /// Registers pipeline steps for a specific context type. - /// - /// - /// - /// - /// - /// - internal static void Register( - IServiceCollection services, - IEnumerable descriptors, - ServiceLifetime lifetime, - ILogger? logger = null) - where TContext : class - { - var runnerType = typeof(IPipelineRunner); - if (services.Any(s => s.ServiceType == runnerType)) - { - logger?.LogDebug(RunnerAlreadyRegisteredMessage, typeof(TContext).FullName ?? typeof(TContext).Name); - return; - } - - var interfaceType = typeof(IPipelineStep); - var lazyType = typeof(Lazy<>).MakeGenericType(interfaceType); - foreach (var descriptor in descriptors) - { - RegisterStep(descriptor.ImplementationType, interfaceType, lazyType, services, lifetime, logger); - } - - logger?.LogDebug(RunnerRegistrationMessage, runnerType.FullName ?? runnerType.Name, lifetime); - - services.TryAdd(ServiceDescriptor.Describe(runnerType, typeof(PipelineRunner), lifetime)); - } - - /// - /// Registers a single pipeline step implementation type with the service collection. - /// This method is used internally to register pipeline steps discovered from assemblies, - /// as well as explicitly registered steps from extensions. - /// It registers the step as both an implementation of IPipelineStep<T> and as a concrete type. - /// It also registers a Lazy<IPipelineStep<T>> for deferred resolution. - /// - /// - /// - /// - /// - /// - /// - /// - internal static void RegisterStep( - Type concreteType, - Type interfaceType, - Type lazyType, - IServiceCollection services, - ServiceLifetime lifetime, - ILogger? logger) - { - logger?.LogDebug(StepRegistrationMessage, concreteType.FullName ?? concreteType.Name, lifetime); - - // Register the IPipelineStep implementation - services.TryAddEnumerable(ServiceDescriptor.Describe(interfaceType, concreteType, lifetime)); - - // Register the concrete implementation type - services.Add(ServiceDescriptor.Describe(concreteType, concreteType, lifetime)); - - // Register Lazy> - services.Add(ServiceDescriptor.Describe(lazyType, sp => - { - var funcType = typeof(Func<>).MakeGenericType(interfaceType); - - var methodInfo = typeof(ServiceProviderServiceExtensions) - .GetMethod(nameof(ServiceProviderServiceExtensions.GetRequiredService), new[] { typeof(IServiceProvider) }); - - if (methodInfo is null) - throw new InvalidOperationException("Could not find GetRequiredService(IServiceProvider)."); - - var genericMethod = methodInfo.MakeGenericMethod(concreteType); - - var factoryDelegate = Delegate.CreateDelegate(funcType, sp, genericMethod); - - return Activator.CreateInstance(lazyType, factoryDelegate) - ?? throw new InvalidOperationException($"Could not create Lazy<{interfaceType}>."); - }, lifetime)); - } - - /// - /// Safely retrieves all loadable types from the specified assembly, skipping those that cannot be loaded. - /// - internal static IEnumerable SafeGetTypes(Func getTypes) - { - try - { - return getTypes(); - } - catch (ReflectionTypeLoadException ex) - { - return ex.Types.Where(t => t is not null)!; - } - catch - { - return Enumerable.Empty(); - } + return new PipelineBuilder(); } } diff --git a/src/PipeForge/PipelineBuilder.cs b/src/PipeForge/PipelineBuilder.cs index e352e7c..b4fc1ad 100644 --- a/src/PipeForge/PipelineBuilder.cs +++ b/src/PipeForge/PipelineBuilder.cs @@ -20,7 +20,7 @@ internal PipelineBuilder() /// public IPipelineRunner Build() { - //return new PipelineRunner(_services.BuildServiceProvider()); + return new PipelineRunner(_services.BuildServiceProvider()); throw new NotImplementedException(); } diff --git a/src/PipeForge/PipelineRegistration.cs b/src/PipeForge/PipelineRegistration.cs new file mode 100644 index 0000000..10640bb --- /dev/null +++ b/src/PipeForge/PipelineRegistration.cs @@ -0,0 +1,180 @@ +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using PipeForge.Extensions; +using PipeForge.Metadata; + +namespace PipeForge; + +internal static class PipelineRegistration +{ + internal static readonly string MessageInvalidContextType = "The context type '{0}' cannot implement IPipelineStep<>."; + internal static readonly string MessageNoStepsFound = "No pipeline steps found for {0} in the specified assemblies."; + internal static readonly string MessageNumberStepsFound = "Discovered and registered {0} pipeline steps for {1}."; + internal static readonly string MessageRunnerAlreadyRegistered = "Pipeline runner for {0} already registered. Skipping step registration."; + internal static readonly string MessageRunnerImplementationNotFound = "No concrete implementation found for pipeline runner interface '{0}'. If you are using a custom runner interface, you must also provide an implementation for it."; + internal static readonly string MessageRunnerRegistration = "Registering pipeline runner implementation {0} for interface {1} with {2} lifetime"; + internal static readonly string MessageStepAlreadyRegistered = "Pipeline step '{0}' is already registered. Pipeline steps must be uniquely registered."; + internal static readonly string MessageStepDiscovered = "Discovered pipeline step {0} [Order={1}, Filter={2}]"; + internal static readonly string MessageStepRegistration = "Registering pipeline step {0} with {1} lifetime"; + + public static IServiceCollection RegisterPipeline( + this IServiceCollection services, + IEnumerable assemblies, + ServiceLifetime lifetime, + string[]? filters) + where TContext : class + where TStepInterface : IPipelineStep + where TRunnerInterface : IPipelineRunner + { + var logger = services.GetLogger(); + var contextType = typeof(TContext); + + // 0. Ensure that the context type does not implement IPipelineStep<> + if (contextType.ImplementsPipelineStep()) + { + logger?.LogError(MessageInvalidContextType, contextType.GetTypeName()); + throw new ArgumentException(string.Format(MessageInvalidContextType, contextType.GetTypeName())); + } + + // 1. Register the pipeline runner first. We don't want to register + // any steps if the runner cannot be resolved. + if (!services.RegisterRunner(assemblies, lifetime, logger)) + { + return services; + } + + // 2. Get descriptor for each step in the pipeline. + var descriptors = assemblies.GetDescriptorsFor(filters); + + // 3. Register each discovered step in the pipeline. + var counter = 0; + foreach (var descriptor in descriptors) + { + if (descriptor is null) continue; + + counter++; + var typeDescriptor = StepTypeDescriptor.Create(descriptor.ImplementationType); + + // 3a. Log the step discovery + logger?.LogDebug(MessageStepDiscovered, typeDescriptor.TypeName, descriptor.Order, string.Join(",", descriptor.Filters)); + + // 3b. Register the step + services.RegisterStep(typeDescriptor, lifetime, logger); + } + + // 4. Log the number of steps found and registered. + logger?.LogStepsRegistered(counter, contextType.GetTypeName()); + + return services; + } + + public static bool RegisterRunner( + this IServiceCollection services, + IEnumerable assemblies, + ServiceLifetime lifetime, + ILogger? logger) + where TContext : class + where TStepInterface : IPipelineStep + where TRunnerInterface : IPipelineRunner + { + var runnerType = typeof(TRunnerInterface); + + // 0. Return early if the runner is already registered. + if (services.Any(service => service.ServiceType == runnerType)) + { + logger?.LogWarning(MessageRunnerAlreadyRegistered, runnerType.GetTypeName()); + return false; + } + + // 1. Look for a concrete implementation of the runner interface. + var concreteRunner = assemblies + .FindClosedImplementationsOf() + .FirstOrDefault(t => t.DirectlyImplements(runnerType)); + + // 2. If a concrete implementation is found, register it. + if (concreteRunner is not null) + { + logger?.LogDebug(MessageRunnerRegistration, concreteRunner.GetTypeName(), runnerType.GetTypeName(), lifetime.ToString()); + services.TryAdd(ServiceDescriptor.Describe(runnerType, concreteRunner, lifetime)); + return true; + } + + // 3. Otherwise, attempt to register the default runner implementations. + var defaultImplementation = GetDefaultRunnerImplementation(runnerType); + if (defaultImplementation is not null) + { + logger?.LogDebug(MessageRunnerRegistration, defaultImplementation.GetTypeName(), runnerType.GetTypeName(), lifetime.ToString()); + services.TryAdd(ServiceDescriptor.Describe(runnerType, defaultImplementation, lifetime)); + return true; + } + + // 4. No implementation found, and the default is not valid — throw + logger?.LogWarning(MessageRunnerImplementationNotFound, runnerType.GetTypeName()); + throw new InvalidOperationException(string.Format(MessageRunnerImplementationNotFound, runnerType.GetTypeName())); + } + + public static void RegisterStep( + this IServiceCollection services, + StepTypeDescriptor descriptor, + ServiceLifetime lifetime, + ILogger? logger) + { + // 0. We absolutely do not want to register duplicate steps. Start by + // checking if the step is already registered. In debug mode, throw + // an exception if it is already registered. + if (services.Any(s => s.ServiceType == descriptor.InterfaceType && s.ImplementationType == descriptor.ConcreteType)) + { + logger?.LogWarning(MessageStepAlreadyRegistered, descriptor.TypeName); +#if DEBUG + throw new InvalidOperationException(string.Format(MessageStepAlreadyRegistered, descriptor.TypeName)); +#else + return; +#endif + } + + // 1. Log the initialization of step registration. + logger?.LogDebug(MessageStepRegistration, descriptor.TypeName, lifetime); + + // 2. Register the IPipelineStep implementation + services.TryAddEnumerable(ServiceDescriptor.Describe(descriptor.InterfaceType, descriptor.ConcreteType, lifetime)); + + // 3. Register the concrete implementation type + services.TryAdd(ServiceDescriptor.Describe(descriptor.ConcreteType, descriptor.ConcreteType, lifetime)); + + // 4. Register Lazy> + services.Add(ServiceDescriptor.Describe(descriptor.LazyType, sp => + { + var funcType = typeof(Func<>).MakeGenericType(descriptor.InterfaceType); + + var methodInfo = typeof(ServiceProviderServiceExtensions) + .GetMethod(nameof(ServiceProviderServiceExtensions.GetRequiredService), [typeof(IServiceProvider)]) + ?? throw new InvalidOperationException("Could not find GetRequiredService(IServiceProvider)."); + + var genericMethod = methodInfo.MakeGenericMethod(descriptor.ConcreteType); + + var factoryDelegate = Delegate.CreateDelegate(funcType, sp, genericMethod); + + return Activator.CreateInstance(descriptor.LazyType, factoryDelegate) + ?? throw new InvalidOperationException($"Could not create Lazy<{descriptor.InterfaceType}>."); + }, lifetime)); + } + + private static Type? GetDefaultRunnerImplementation(Type runnerType) + where TContext : class + where TStepInterface : IPipelineStep + { + if (runnerType.IsAssignableFrom(typeof(DefaultPipelineRunner))) + { + return typeof(DefaultPipelineRunner); + } + + if (runnerType.IsAssignableFrom(typeof(DefaultPipelineRunner))) + { + return typeof(DefaultPipelineRunner); + } + + return null; + } +} diff --git a/src/PipeForge/PipelineRunner.cs b/src/PipeForge/PipelineRunner.cs index 6e30b01..14119a7 100644 --- a/src/PipeForge/PipelineRunner.cs +++ b/src/PipeForge/PipelineRunner.cs @@ -1,28 +1,44 @@ using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using PipeForge.Adapters.Json; using PipeForge.Adapters.Diagnostics; +using PipeForge.Adapters.Json; namespace PipeForge; -public class PipelineRunner : IPipelineRunner - where T : class +public abstract class PipelineRunner : IPipelineRunner + where TContext : class + where TStepInterface : IPipelineStep { private static readonly IJsonSerializer _jsonSerializer = JsonSerializerFactory.Create(); - private static readonly IPipelineDiagnostics _diagnostics = PipelineDiagnosticsFactory.Create(); + private static readonly IPipelineDiagnostics _diagnostics = PipelineDiagnosticsFactory.Create(); + + private static readonly string _pipelineContextType = "PipelineContextType"; + private static readonly string _pipelineStepName = "PipelineStepName"; + private static readonly string _pipelineStepOrder = "PipelineStepOrder"; + + protected internal static readonly string MessagePipelineExecutionEnd = "Pipeline execution ended for {0}"; + protected internal static readonly string MessagePipelineExecutionStart = "Pipeline execution started for {0}"; + protected internal static readonly string MessageStepExecutionEnd = "Pipeline step completed {0} (Order {1}) in {2} ms"; + protected internal static readonly string MessageStepExecutionException = "Pipeline step exception {0} (Order {1})"; + protected internal static readonly string MessageStepExecutionStart = "Pipeline step executing {0} (Order {1})"; + protected internal static readonly string MessageStepShortCircuited = "Pipeline step short circuit {0} (Order {1}) after {2} ms"; - private readonly IEnumerable>> _lazySteps; - private readonly ILogger? _logger; + private readonly IServiceProvider _serviceProvider; - public PipelineRunner(IEnumerable>> lazySteps, ILoggerFactory? loggerFactory = null) + protected readonly ILogger? Logger; + protected IEnumerable> LazySteps => _serviceProvider.GetServices>(); + + public PipelineRunner(IServiceProvider serviceProvider) { - _lazySteps = lazySteps; - _logger = loggerFactory?.CreateLogger($"PipelineRunner<{typeof(T).Name}>"); + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + Logger = _serviceProvider.GetService()?.CreateLogger(GetType()); } public string Describe() { - var steps = _lazySteps + var steps = LazySteps .Select(s => s.Value) .Select((step, index) => new { @@ -51,20 +67,20 @@ public string DescribeSchema() ["MayShortCircuit"] = new { type = "boolean", description = "Whether the step may halt pipeline execution early" }, ["ShortCircuitCondition"] = new { type = "string", description = "Explanation of the short-circuit condition, if any" }, }, - ["required"] = new[] { "Order", "Name", "MayShortCircuit" } + ["required"] = new[] { "Order" } }; return _jsonSerializer.Serialize(schema); } - public async Task ExecuteAsync(T context, CancellationToken cancellationToken = default) + public async Task ExecuteAsync(TContext context, CancellationToken cancellationToken = default) { if (context is null) throw new ArgumentNullException(nameof(context)); - PipelineDelegate next = (_, _) => Task.CompletedTask; + PipelineDelegate next = (_, _) => Task.CompletedTask; - var steps = _lazySteps + var steps = LazySteps .Select((lazy, index) => (LazyStep: lazy, Order: index)) .Reverse() .ToList(); @@ -77,56 +93,71 @@ public async Task ExecuteAsync(T context, CancellationToken cancellationToken = { var step = lazyStep.Value; - using (_logger?.BeginScope(new Dictionary + using (Logger?.BeginScope(new Dictionary { - ["PipelineContextType"] = typeof(T).Name, - ["PipelineStepName"] = step.Name, - ["PipelineStepOrder"] = order + [_pipelineContextType] = typeof(TContext).Name, + [_pipelineStepName] = step.Name, + [_pipelineStepOrder] = order })) { using var activity = _diagnostics.BeginStep(step, order); try { + if (ct.IsCancellationRequested) + { + Logger?.LogInformation("Pipeline cancelled before executing step {Step} (Order {Order})", step.Name, order); + activity?.SetCanceled(); + ct.ThrowIfCancellationRequested(); + } + var sw = Stopwatch.StartNew(); var wasCalled = false; - async Task wrappedNext(T c, CancellationToken token = default) + async Task wrappedNext(TContext c, CancellationToken token = default) { wasCalled = true; await previous(c, token); } - _logger?.LogTrace("Executing pipeline step {StepName} (Order {StepOrder})", step.Name, order); + Logger?.LogTrace(MessageStepExecutionStart, step.Name, order); await step.InvokeAsync(ctx, wrappedNext, ct); sw.Stop(); if (wasCalled) { - _logger?.LogTrace("Completed pipeline step {StepName} (Order {StepOrder}) in {Elapsed} ms", - step.Name, order, sw.ElapsedMilliseconds); + Logger?.LogTrace(MessageStepExecutionEnd, step.Name, order, sw.ElapsedMilliseconds); } else { - _logger?.LogInformation("Pipeline short-circuited by step {StepName} (Order {StepOrder}) after {Elapsed} ms", - step.Name, order, sw.ElapsedMilliseconds); + Logger?.LogInformation(MessageStepShortCircuited, step.Name, order, sw.ElapsedMilliseconds); } activity?.SetShortCircuited(!wasCalled); } - catch (Exception ex) + catch (Exception ex) when (ex is not OperationCanceledException and not PipelineExecutionException) { _diagnostics.ReportException(ex, step, order); - _logger?.LogError(ex, "Exception in pipeline step {StepName} (Order {StepOrder})", step.Name, order); - - if (ex is PipelineExecutionException) throw; + Logger?.LogError(ex, MessageStepExecutionException, step.Name, order); - throw new PipelineExecutionException(step.Name, order, ex); + throw new PipelineExecutionException(step.Name, order, ex); } } }; } + Logger?.LogDebug(MessagePipelineExecutionStart, GetType().Name); await next(context, cancellationToken); + Logger?.LogDebug(MessagePipelineExecutionEnd, GetType().Name); + } +} + +[ExcludeFromCodeCoverage] +public class PipelineRunner : PipelineRunner>, IPipelineRunner + where TContext : class +{ + public PipelineRunner(IServiceProvider serviceProvider) + : base(serviceProvider) + { } } diff --git a/src/PipeForge/PipelineStep.cs b/src/PipeForge/PipelineStep.cs index 56adb59..78f98b6 100644 --- a/src/PipeForge/PipelineStep.cs +++ b/src/PipeForge/PipelineStep.cs @@ -1,10 +1,10 @@ - using System.Diagnostics.CodeAnalysis; namespace PipeForge; [ExcludeFromCodeCoverage] -public abstract class PipelineStep : IPipelineStep +public abstract class PipelineStep : IPipelineStep + where TContext : class { public string? Description { get; set; } = null; @@ -14,5 +14,5 @@ public abstract class PipelineStep : IPipelineStep public string? ShortCircuitCondition { get; set; } = null; - public abstract Task InvokeAsync(T context, PipelineDelegate next, CancellationToken cancellationToken = default); + public abstract Task InvokeAsync(TContext context, PipelineDelegate next, CancellationToken cancellationToken = default); } diff --git a/src/PipeForge/PipelineStepAttribute.cs b/src/PipeForge/PipelineStepAttribute.cs index 3e4a070..1203b02 100644 --- a/src/PipeForge/PipelineStepAttribute.cs +++ b/src/PipeForge/PipelineStepAttribute.cs @@ -10,23 +10,53 @@ public sealed class PipelineStepAttribute : Attribute /// The order in which the step should be executed relative to other steps /// /// - /// Lower numbers are executed first. Steps with the same order are executed in the order they are registered. + /// Lower numbers are executed first. Steps with the same value for order are executed in the order they are registered (or discovered). /// public int Order { get; } /// /// An optional step filter, such as "Production" or "Development" /// - public string? Filter { get; } + /// + /// Supports multiple filters, allowing the step to be active in different contexts. + /// + public IEnumerable Filters { get; } + + #region CLS Compliant Constructors + + /// + /// Initializes a new instance of the attribute with the specified order. + /// + /// The execution order of the step + public PipelineStepAttribute(int order) + { + Order = order; + Filters = []; + } /// - /// Initializes a new instance of the attribute with the specified order, enabled state, and filter. + /// Initializes a new instance of the attribute with the specified order and single filter. /// /// The execution order of the step /// The filter in which the step should be active - public PipelineStepAttribute(int order, string? filter = null) + public PipelineStepAttribute(int order, string filter) + { + Order = order; + Filters = string.IsNullOrWhiteSpace(filter) + ? [] + : [filter]; + } + + #endregion + + /// + /// Initializes a new instance of the attribute with the specified order and filters. + /// + /// The execution order of the step + /// The filters in which the step should be active + public PipelineStepAttribute(int order, params string[] filters) { Order = order; - Filter = filter; + Filters = filters?.Where(f => !string.IsNullOrWhiteSpace(f)) ?? []; } } diff --git a/src/PipeForge/version.json b/src/PipeForge/version.json index 13b4e49..94e8a89 100644 --- a/src/PipeForge/version.json +++ b/src/PipeForge/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": "2.0-beta", "publicReleaseRefSpec": [ "^refs/heads/main$", "^refs/heads/v\\d+(?:\\.\\d+)?$"