diff --git a/src/Polly.Core.Tests/Builder/ResilienceStrategyBuilderOptionsTests.cs b/src/Polly.Core.Tests/Builder/ResilienceStrategyBuilderOptionsTests.cs new file mode 100644 index 00000000000..4f2b13f74f9 --- /dev/null +++ b/src/Polly.Core.Tests/Builder/ResilienceStrategyBuilderOptionsTests.cs @@ -0,0 +1,16 @@ +using FluentAssertions; +using Polly.Builder; +using Xunit; + +namespace Polly.Core.Tests.Builder; + +public class ResilienceStrategyBuilderOptionsTests +{ + [Fact] + public void Ctor_EnsureDefaults() + { + var options = new ResilienceStrategyBuilderOptions(); + + options.BuilderName.Should().Be(""); + } +} diff --git a/src/Polly.Core.Tests/Builder/ResilienceStrategyBuilderTests.cs b/src/Polly.Core.Tests/Builder/ResilienceStrategyBuilderTests.cs new file mode 100644 index 00000000000..f991df6c060 --- /dev/null +++ b/src/Polly.Core.Tests/Builder/ResilienceStrategyBuilderTests.cs @@ -0,0 +1,308 @@ +using System; +using System.ComponentModel.DataAnnotations; +using FluentAssertions; +using Polly.Builder; +using Polly.Core.Tests.Utils; +using Xunit; + +namespace Polly.Core.Tests.Builder; + +public class ResilienceStrategyBuilderTests +{ + [Fact] + public void AddStrategy_Single_Ok() + { + // arrange + var executions = new List(); + var builder = new ResilienceStrategyBuilder(); + var first = new TestResilienceStrategy + { + Before = (_, _) => executions.Add(1), + After = (_, _) => executions.Add(3), + }; + + builder.AddStrategy(first); + + // act + var strategy = builder.Build(); + + // assert + strategy.Execute(_ => executions.Add(2)); + strategy.Should().BeOfType(); + executions.Should().BeInAscendingOrder(); + executions.Should().HaveCount(3); + } + + [Fact] + public void AddStrategy_Multiple_Ok() + { + // arrange + var executions = new List(); + var builder = new ResilienceStrategyBuilder(); + var first = new TestResilienceStrategy + { + Before = (_, _) => executions.Add(1), + After = (_, _) => executions.Add(7), + }; + var second = new TestResilienceStrategy + { + Before = (_, _) => executions.Add(2), + After = (_, _) => executions.Add(6), + }; + var third = new TestResilienceStrategy + { + Before = (_, _) => executions.Add(3), + After = (_, _) => executions.Add(5), + }; + + builder.AddStrategy(first); + builder.AddStrategy(second); + builder.AddStrategy(third); + + // act + var strategy = builder.Build(); + strategy + .Should() + .BeOfType() + .Subject + .Strategies.Should().HaveCount(3); + + // assert + strategy.Execute(_ => executions.Add(4)); + + executions.Should().BeInAscendingOrder(); + executions.Should().HaveCount(7); + } + + [Fact] + public void AddStrategy_Duplicate_Throws() + { + // arrange + var executions = new List(); + var builder = new ResilienceStrategyBuilder() + .AddStrategy(NullResilienceStrategy.Instance) + .AddStrategy(NullResilienceStrategy.Instance); + + builder.Invoking(b => b.Build()) + .Should() + .Throw() + .WithMessage("The resilience pipeline must contain unique resilience strategies."); + } + + [Fact] + public void AddStrategy_MultipleNonDelegating_Ok() + { + // arrange + var executions = new List(); + var builder = new ResilienceStrategyBuilder(); + var first = new Strategy + { + Before = () => executions.Add(1), + After = () => executions.Add(7), + }; + var second = new Strategy + { + Before = () => executions.Add(2), + After = () => executions.Add(6), + }; + var third = new Strategy + { + Before = () => executions.Add(3), + After = () => executions.Add(5), + }; + + builder.AddStrategy(first); + builder.AddStrategy(second); + builder.AddStrategy(third); + + // act + var strategy = builder.Build(); + + // assert + strategy.Execute(_ => executions.Add(4)); + + executions.Should().BeInAscendingOrder(); + executions.Should().HaveCount(7); + } + + [Fact] + public void Build_Empty_ReturnsNullResilienceStrategy() + { + new ResilienceStrategyBuilder().Build().Should().BeSameAs(NullResilienceStrategy.Instance); + } + + [Fact] + public void AddStrategy_AfterUsed_Throws() + { + var builder = new ResilienceStrategyBuilder(); + + builder.Build(); + + builder + .Invoking(b => b.AddStrategy(NullResilienceStrategy.Instance)) + .Should() + .Throw() + .WithMessage("Cannot add any more resilience strategies to the builder after it has been used to build a strategy once."); + } + + [Fact] + public void Options_SetNull_Throws() + { + var builder = new ResilienceStrategyBuilder(); + + builder.Invoking(b => b.Options = null!).Should().Throw(); + } + + [Fact] + public void Build_InvalidBuilderOptions_Throw() + { + var builder = new ResilienceStrategyBuilder(); + builder.Options.BuilderName = null!; + + builder.Invoking(b => b.Build()) + .Should() + .Throw() + .WithMessage( +""" +The 'ResilienceStrategyBuilderOptions' options are not valid. + +Validation Errors: +The BuilderName field is required. +"""); + } + + [Fact] + public void AddStrategy_InvalidOptions_Throws() + { + var builder = new ResilienceStrategyBuilder(); + + builder + .Invoking(b => b.AddStrategy(NullResilienceStrategy.Instance, new ResilienceStrategyOptions { StrategyName = null!, StrategyType = null! })) + .Should() + .Throw() + .WithMessage( +""" +The 'ResilienceStrategyOptions' options are not valid. + +Validation Errors: +The StrategyName field is required. +The StrategyType field is required. +"""); + } + + [Fact] + public void AddStrategy_NullFactory_Throws() + { + var builder = new ResilienceStrategyBuilder(); + + builder + .Invoking(b => b.AddStrategy((Func)null!)) + .Should() + .Throw() + .And.ParamName + .Should() + .Be("factory"); + } + + [Fact] + public void AddStrategy_CombinePipelines_Ok() + { + // arrange + var executions = new List(); + var first = new TestResilienceStrategy + { + Before = (_, _) => executions.Add(1), + After = (_, _) => executions.Add(7), + }; + var second = new TestResilienceStrategy + { + Before = (_, _) => executions.Add(2), + After = (_, _) => executions.Add(6), + }; + + var pipeline1 = new ResilienceStrategyBuilder().AddStrategy(first).AddStrategy(second).Build(); + + var third = new TestResilienceStrategy + { + Before = (_, _) => executions.Add(3), + After = (_, _) => executions.Add(5), + }; + var pipeline2 = new ResilienceStrategyBuilder().AddStrategy(third).Build(); + + // act + var strategy = new ResilienceStrategyBuilder().AddStrategy(pipeline1).AddStrategy(pipeline2).Build(); + + // assert + strategy.Execute(_ => executions.Add(4)); + + executions.Should().BeInAscendingOrder(); + executions.Should().HaveCount(7); + } + + [Fact] + public void BuildStrategy_EnsureCorrectContext() + { + // arrange + bool verified1 = false; + bool verified2 = false; + + var builder = new ResilienceStrategyBuilder + { + Options = new ResilienceStrategyBuilderOptions + { + BuilderName = "builder-name" + } + }; + + builder.AddStrategy( + context => + { + context.BuilderName.Should().Be("builder-name"); + context.StrategyName.Should().Be("strategy-name"); + context.StrategyType.Should().Be("strategy-type"); + verified1 = true; + + return new TestResilienceStrategy(); + }, + new ResilienceStrategyOptions { StrategyName = "strategy-name", StrategyType = "strategy-type" }); + + builder.AddStrategy( + context => + { + context.BuilderName.Should().Be("builder-name"); + context.StrategyName.Should().Be("strategy-name-2"); + context.StrategyType.Should().Be("strategy-type-2"); + verified2 = true; + + return new TestResilienceStrategy(); + }, + new ResilienceStrategyOptions { StrategyName = "strategy-name-2", StrategyType = "strategy-type-2" }); + + // act + builder.Build(); + + // assert + verified1.Should().BeTrue(); + verified2.Should().BeTrue(); + } + + private class Strategy : IResilienceStrategy + { + public Action? Before { get; set; } + + public Action? After { get; set; } + + async ValueTask IResilienceStrategy.ExecuteInternalAsync(Func> callback, ResilienceContext context, TState state) + { + try + { + Before?.Invoke(); + return await callback(context, state); + } + finally + { + After?.Invoke(); + } + } + } +} diff --git a/src/Polly.Core.Tests/Builder/ResilienceStrategyOptionsTests.cs b/src/Polly.Core.Tests/Builder/ResilienceStrategyOptionsTests.cs new file mode 100644 index 00000000000..f8870527190 --- /dev/null +++ b/src/Polly.Core.Tests/Builder/ResilienceStrategyOptionsTests.cs @@ -0,0 +1,17 @@ +using FluentAssertions; +using Polly.Builder; +using Xunit; + +namespace Polly.Core.Tests.Builder; + +public class ResilienceStrategyOptionsTests +{ + [Fact] + public void Ctor_EnsureDefaults() + { + var options = new ResilienceStrategyOptions(); + + options.StrategyType.Should().Be(""); + options.StrategyName.Should().Be(""); + } +} diff --git a/src/Polly.Core.Tests/Builder/ResilienceStrategyPipelineTests.cs b/src/Polly.Core.Tests/Builder/ResilienceStrategyPipelineTests.cs new file mode 100644 index 00000000000..31086d86f5a --- /dev/null +++ b/src/Polly.Core.Tests/Builder/ResilienceStrategyPipelineTests.cs @@ -0,0 +1,83 @@ +using System; +using FluentAssertions; +using Polly.Builder; +using Polly.Core.Tests.Utils; +using Xunit; + +namespace Polly.Core.Tests.Builder; + +public class ResilienceStrategyPipelineTests +{ + [Fact] + public void CreateAndFreezeStrategies_ArgValidation() + { + Assert.Throws(() => ResilienceStrategyPipeline.CreatePipelineAndFreezeStrategies(null!)); + Assert.Throws(() => ResilienceStrategyPipeline.CreatePipelineAndFreezeStrategies(Array.Empty())); + Assert.Throws(() => ResilienceStrategyPipeline.CreatePipelineAndFreezeStrategies(new IResilienceStrategy[] { new TestResilienceStrategy() })); + Assert.Throws(() => ResilienceStrategyPipeline.CreatePipelineAndFreezeStrategies(new IResilienceStrategy[] + { + NullResilienceStrategy.Instance, + NullResilienceStrategy.Instance + })); + } + + [Fact] + public void CreateAndFreezeStrategies_EnsureStrategiesLinked() + { + var s1 = new TestResilienceStrategy(); + var s2 = new TestResilienceStrategy(); + var s3 = new TestResilienceStrategy(); + + var pipeline = ResilienceStrategyPipeline.CreatePipelineAndFreezeStrategies(new[] { s1, s2, s3 }); + + s1.Next.Should().Be(s2); + s2.Next.Should().Be(s3); + s3.Next.Should().Be(NullResilienceStrategy.Instance); + } + + [Fact] + public void Create_EnsureStrategiesFrozen() + { + var strategies = new[] + { + new TestResilienceStrategy(), + new TestResilienceStrategy(), + new TestResilienceStrategy(), + }; + + var pipeline = ResilienceStrategyPipeline.CreatePipelineAndFreezeStrategies(strategies); + + foreach (var s in strategies) + { + Assert.Throws(() => s.Next = NullResilienceStrategy.Instance); + } + } + + [Fact] + public void Create_EnsureOriginalStrategiesPreserved() + { + var strategies = new IResilienceStrategy[] + { + new TestResilienceStrategy(), + new Strategy(), + new TestResilienceStrategy(), + }; + + var pipeline = ResilienceStrategyPipeline.CreatePipelineAndFreezeStrategies(strategies); + + for (int i = 0; i < strategies.Length; i++) + { + pipeline.Strategies[i].Should().BeSameAs(strategies[i]); + } + + pipeline.Strategies.SequenceEqual(strategies).Should().BeTrue(); + } + + private class Strategy : IResilienceStrategy + { + ValueTask IResilienceStrategy.ExecuteInternalAsync(Func> callback, ResilienceContext context, TState state) + { + throw new NotSupportedException(); + } + } +} diff --git a/src/Polly.Core.Tests/DelegatingResilienceStrategyTests.cs b/src/Polly.Core.Tests/DelegatingResilienceStrategyTests.cs index 84d7cce8116..58ffc9ec52d 100644 --- a/src/Polly.Core.Tests/DelegatingResilienceStrategyTests.cs +++ b/src/Polly.Core.Tests/DelegatingResilienceStrategyTests.cs @@ -37,7 +37,7 @@ public void Next_ChangeAfterExecuted_Throws() .Invoking(s => s.Next = NullResilienceStrategy.Instance) .Should() .Throw() - .WithMessage("The delegating resilience strategy has already been executed and changing the value of 'Next' property is not allowed."); + .WithMessage("The delegating resilience strategy is already frozen and changing the value of 'Next' property is not allowed."); } [Fact] diff --git a/src/Polly.Core/Builder/ResilienceStrategyBuilder.cs b/src/Polly.Core/Builder/ResilienceStrategyBuilder.cs new file mode 100644 index 00000000000..aca88357ec2 --- /dev/null +++ b/src/Polly.Core/Builder/ResilienceStrategyBuilder.cs @@ -0,0 +1,111 @@ +namespace Polly.Builder; + +/// +/// A builder that is used to create an instance of . +/// +/// +/// The builder supports chaining multiple strategies into a pipeline of strategies. +/// The resulting instance of created by the call will execute the strategies in the same order they were added to the builder. +/// The order of the strategies is important. +/// +public class ResilienceStrategyBuilder +{ + private readonly List _entries = new(); + private ResilienceStrategyBuilderOptions _options = new(); + private bool _used; + + /// + /// Gets or sets the builder options. + /// + public ResilienceStrategyBuilderOptions Options + { + get => _options; + set => _options = Guard.NotNull(value); + } + + /// + /// Adds an already created strategy instance to the builder. + /// + /// The strategy instance. + /// The options associated with the strategy. If none are provided the default instance of is created. + /// The same builder instance. + public ResilienceStrategyBuilder AddStrategy(IResilienceStrategy strategy, ResilienceStrategyOptions? options = null) + { + Guard.NotNull(strategy); + + return AddStrategy(_ => strategy, options); + } + + /// + /// Adds a strategy to the builder. + /// + /// The factory that creates a resilience strategy. + /// The options associated with the strategy. If none are provided the default instance of is created. + /// The same builder instance. + public ResilienceStrategyBuilder AddStrategy(Func factory, ResilienceStrategyOptions? options = null) + { + Guard.NotNull(factory); + + if (options is not null) + { + ValidationHelper.ValidateObject(options, $"The '{nameof(ResilienceStrategyOptions)}' options are not valid."); + } + + if (_used) + { + throw new InvalidOperationException("Cannot add any more resilience strategies to the builder after it has been used to build a strategy once."); + } + + _entries.Add(new Entry(factory, options ?? new ResilienceStrategyOptions())); + + return this; + } + + /// + /// Builds the resilience strategy. + /// + /// An instance of . + public IResilienceStrategy Build() + { + ValidationHelper.ValidateObject(Options, $"The '{nameof(ResilienceStrategyBuilderOptions)}' options are not valid."); + + _used = true; + + if (_entries.Count == 0) + { + return NullResilienceStrategy.Instance; + } + + if (_entries.Count == 1) + { + return CreateResilienceStrategy(_entries[0]); + } + + var strategies = _entries.Select(CreateResilienceStrategy).ToList(); + + return ResilienceStrategyPipeline.CreatePipelineAndFreezeStrategies(strategies); + } + + private IResilienceStrategy CreateResilienceStrategy(Entry entry) + { + var context = new ResilienceStrategyBuilderContext( + builderName: Options.BuilderName, + strategyName: entry.Properties.StrategyName, + strategyType: entry.Properties.StrategyType); + + return entry.Factory(context); + } + + private sealed class Entry + { + public Entry(Func factory, ResilienceStrategyOptions properties) + { + Factory = factory; + Properties = properties; + } + + public Func Factory { get; } + + public ResilienceStrategyOptions Properties { get; } + } +} diff --git a/src/Polly.Core/Builder/ResilienceStrategyBuilderContext.cs b/src/Polly.Core/Builder/ResilienceStrategyBuilderContext.cs new file mode 100644 index 00000000000..ed9bcc18c6f --- /dev/null +++ b/src/Polly.Core/Builder/ResilienceStrategyBuilderContext.cs @@ -0,0 +1,35 @@ +namespace Polly.Builder; + +/// +/// The context used for building an individual resilience strategy. +/// +public class ResilienceStrategyBuilderContext +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the builder. + /// The strategy name. + /// The strategy type. + public ResilienceStrategyBuilderContext(string builderName, string strategyName, string strategyType) + { + BuilderName = Guard.NotNull(builderName); + StrategyName = Guard.NotNull(strategyName); + StrategyType = Guard.NotNull(strategyType); + } + + /// + /// Gets the name of the builder. + /// + public string BuilderName { get; } + + /// + /// Gets the name of the strategy. + /// + public string StrategyName { get; } + + /// + /// Gets the type of the strategy. + /// + public string StrategyType { get; } +} diff --git a/src/Polly.Core/Builder/ResilienceStrategyBuilderOptions.cs b/src/Polly.Core/Builder/ResilienceStrategyBuilderOptions.cs new file mode 100644 index 00000000000..654e4d5ba37 --- /dev/null +++ b/src/Polly.Core/Builder/ResilienceStrategyBuilderOptions.cs @@ -0,0 +1,16 @@ +using System.ComponentModel.DataAnnotations; + +namespace Polly.Builder; + +/// +/// The builder options used by . +/// +public class ResilienceStrategyBuilderOptions +{ + /// + /// Gets or sets the name of the builder. + /// + /// This property is also included in the telemetry that is produced by the individual resilience strategies. + [Required(AllowEmptyStrings = true)] + public string BuilderName { get; set; } = string.Empty; +} diff --git a/src/Polly.Core/Builder/ResilienceStrategyOptions.cs b/src/Polly.Core/Builder/ResilienceStrategyOptions.cs new file mode 100644 index 00000000000..81eceba15c7 --- /dev/null +++ b/src/Polly.Core/Builder/ResilienceStrategyOptions.cs @@ -0,0 +1,23 @@ +using System.ComponentModel.DataAnnotations; + +namespace Polly.Builder; + +/// +/// The options associated with the . +/// +public class ResilienceStrategyOptions +{ + /// + /// Gets or sets the name of the strategy. + /// + /// This property is also included in the telemetry that is produced by the individual resilience strategies. + [Required(AllowEmptyStrings = true)] + public string StrategyName { get; set; } = string.Empty; + + /// + /// Gets or sets the type of the strategy. + /// + /// This property is also included in the telemetry that is produced by the individual resilience strategies. + [Required(AllowEmptyStrings = true)] + public string StrategyType { get; set; } = string.Empty; +} diff --git a/src/Polly.Core/Builder/ResilienceStrategyPipeline.cs b/src/Polly.Core/Builder/ResilienceStrategyPipeline.cs new file mode 100644 index 00000000000..439857c52cf --- /dev/null +++ b/src/Polly.Core/Builder/ResilienceStrategyPipeline.cs @@ -0,0 +1,89 @@ +using System; + +namespace Polly.Builder; + +/// +/// A pipeline of strategies. +/// +internal sealed class ResilienceStrategyPipeline : DelegatingResilienceStrategy +{ + private readonly IResilienceStrategy _pipeline; + + public static ResilienceStrategyPipeline CreatePipelineAndFreezeStrategies(IReadOnlyList strategies) + { + Guard.NotNull(strategies); + + if (strategies.Count < 2) + { +#pragma warning disable S2302 // "nameof" should be used + throw new InvalidOperationException("The resilience pipeline must contain at least two resilience strategies."); +#pragma warning restore S2302 // "nameof" should be used + } + + if (strategies.Distinct().Count() != strategies.Count) + { +#pragma warning disable S2302 // "nameof" should be used + throw new InvalidOperationException("The resilience pipeline must contain unique resilience strategies."); +#pragma warning restore S2302 // "nameof" should be used + } + + var delegatingStrategies = strategies.Select(strategy => + { + if (strategy is DelegatingResilienceStrategy delegatingStrategy) + { + return delegatingStrategy; + } + else + { + return new DelegatingStrategyWrapper(strategy); + } + }).ToList(); + + for (var i = 0; i < delegatingStrategies.Count - 1; i++) + { + delegatingStrategies[i].Next = delegatingStrategies[i + 1]; + } + + // now, freeze the strategies so any further modifications are not allowed + foreach (var strategy in delegatingStrategies) + { + strategy.Freeze(); + } + + return new ResilienceStrategyPipeline(delegatingStrategies[0], strategies); + } + + private ResilienceStrategyPipeline(IResilienceStrategy pipeline, IReadOnlyList strategies) + { + Strategies = strategies; + _pipeline = pipeline; + } + + public IReadOnlyList Strategies { get; } + + protected override ValueTask ExecuteCoreAsync(Func> callback, ResilienceContext context, TState state) + { + return _pipeline.ExecuteAsync( + static (context, state) => state.Next.ExecuteAsync(state.callback, context, state.state), + context, + (Next, callback, state)); + } + + /// + /// A wrapper that converts a into a . + /// + private sealed class DelegatingStrategyWrapper : DelegatingResilienceStrategy + { + private readonly IResilienceStrategy _strategy; + + public DelegatingStrategyWrapper(IResilienceStrategy strategy) => _strategy = strategy; + + protected override ValueTask ExecuteCoreAsync(Func> callback, ResilienceContext context, TState state) + { + return _strategy.ExecuteAsync( + static (context, state) => state.Next.ExecuteAsync(state.callback, context, state.state), + context, + (Next, callback, state)); + } + } +} diff --git a/src/Polly.Core/DelegatingResilienceStrategy.cs b/src/Polly.Core/DelegatingResilienceStrategy.cs index f1f8cdfd2bf..16d743ef9e6 100644 --- a/src/Polly.Core/DelegatingResilienceStrategy.cs +++ b/src/Polly.Core/DelegatingResilienceStrategy.cs @@ -5,7 +5,7 @@ namespace Polly; /// public class DelegatingResilienceStrategy : IResilienceStrategy { - private bool _executed; + private bool _frozen; private IResilienceStrategy _next = NullResilienceStrategy.Instance; /// @@ -26,9 +26,9 @@ public IResilienceStrategy Next { Guard.NotNull(value); - if (_executed) + if (_frozen) { - throw new InvalidOperationException($"The delegating resilience strategy has already been executed and changing the value of '{nameof(Next)}' property is not allowed."); + throw new InvalidOperationException($"The delegating resilience strategy is already frozen and changing the value of '{nameof(Next)}' property is not allowed."); } _next = value; @@ -64,8 +64,10 @@ protected virtual ValueTask ExecuteCoreAsync( ResilienceContext context, TState state) { - _executed = true; + _frozen = true; return Next.ExecuteInternalAsync(callback, context, state); } + + internal void Freeze() => _frozen = true; } diff --git a/src/Polly.Core/Polly.Core.csproj b/src/Polly.Core/Polly.Core.csproj index 7dce169e674..8abfbb4006f 100644 --- a/src/Polly.Core/Polly.Core.csproj +++ b/src/Polly.Core/Polly.Core.csproj @@ -21,6 +21,7 @@ + diff --git a/src/Polly.Core/Utils/ValidationHelper.cs b/src/Polly.Core/Utils/ValidationHelper.cs new file mode 100644 index 00000000000..ec4fc33e601 --- /dev/null +++ b/src/Polly.Core/Utils/ValidationHelper.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Text; + +namespace Polly.Utils; + +internal static class ValidationHelper +{ + public static void ValidateObject(object instance, string mainMessage) + { + var errors = new List(); + + if (!Validator.TryValidateObject(instance, new ValidationContext(instance), errors)) + { + var stringBuilder = new StringBuilder(mainMessage); + stringBuilder.AppendLine(); + + stringBuilder.AppendLine("Validation Errors:"); + foreach (var error in errors) + { + stringBuilder.AppendLine(error.ErrorMessage); + } + + throw new ValidationException(stringBuilder.ToString()); + } + } +}