diff --git a/src/Confix.Tool/src/Confix.Library/Variables/Exceptions/CircularVariableReferenceException.cs b/src/Confix.Tool/src/Confix.Library/Variables/Exceptions/CircularVariableReferenceException.cs new file mode 100644 index 00000000..ed5242fc --- /dev/null +++ b/src/Confix.Tool/src/Confix.Library/Variables/Exceptions/CircularVariableReferenceException.cs @@ -0,0 +1,12 @@ +namespace Confix.Variables; + +public sealed class CircularVariableReferenceException : Exception +{ + public CircularVariableReferenceException(VariablePath path) + : base($"Variable {path} could not be resolved, due to circular references") + { + Path = path; + } + + public VariablePath Path { get; } +} diff --git a/src/Confix.Tool/src/Confix.Library/Variables/VariableReplacerService.cs b/src/Confix.Tool/src/Confix.Library/Variables/VariableReplacerService.cs index 6388cd88..f43af826 100644 --- a/src/Confix.Tool/src/Confix.Library/Variables/VariableReplacerService.cs +++ b/src/Confix.Tool/src/Confix.Library/Variables/VariableReplacerService.cs @@ -1,4 +1,4 @@ -using System.Text.Json; +using System.Collections.Immutable; using System.Text.Json.Nodes; using Confix.Tool.Commands.Logging; using Json.Schema; @@ -20,21 +20,68 @@ public VariableReplacerService(IVariableResolver variableResolver) { return null; } - var variables = GetVariables(node).ToArray(); - App.Log.DetectedVariables(variables.Length); - var resolved = await _variableResolver.ResolveVariables(variables, cancellationToken); + return await RewriteAsync(node, ImmutableHashSet.Empty, cancellationToken); + } + + private async Task RewriteAsync( + JsonNode node, + IReadOnlySet resolvedPaths, + CancellationToken cancellationToken) + { + var resolvedVariables = await ResolveVariables(node, resolvedPaths, cancellationToken); - return new JsonVariableRewriter().Rewrite(node, new(resolved)); + return new JsonVariableRewriter().Rewrite(node, new(resolvedVariables)); } private static IEnumerable GetVariables(JsonNode node) - => JsonParser.ParseNode(node).Values - .Where(v => v.GetSchemaValueType() == SchemaValueType.String) - .SelectMany(v => v!.ToString().GetVariables()); + { + if (node is JsonValue value) + { + return value.GetSchemaValueType() == SchemaValueType.String + ? value.ToString().GetVariables() + : Enumerable.Empty(); + } + + return JsonParser.ParseNode(node).Values + .Where(v => v.GetSchemaValueType() == SchemaValueType.String) + .SelectMany(v => v!.ToString().GetVariables()); + } + + private async Task> ResolveVariables( + JsonNode node, + IReadOnlySet resolvedPaths, + CancellationToken cancellationToken) + { + var variables = GetVariables(node).ToArray(); + if (variables.Length == 0) + { + return ImmutableDictionary.Empty; + } + App.Log.DetectedVariables(variables.Length); + if (resolvedPaths.Overlaps(variables)) + { + throw new CircularVariableReferenceException(variables.First(v => resolvedPaths.Contains(v))); + } + + var resolvedVariables = await _variableResolver.ResolveVariables(variables, cancellationToken); + + var resolved = new Dictionary(); + foreach (var variable in resolvedVariables) + { + var currentPath = new HashSet() { variable.Key }; + currentPath.UnionWith(resolvedPaths); + resolved[variable.Key] = await RewriteAsync( + variable.Value, + currentPath, + cancellationToken); + } + + return resolved; + } } -file static class LogExtensionts +file static class LogExtensions { public static void DetectedVariables(this IConsoleLogger log, int count) { diff --git a/src/Confix.Tool/src/Confix.Library/Variables/VariableResolver.cs b/src/Confix.Tool/src/Confix.Library/Variables/VariableResolver.cs index 80f111b8..8c024bec 100644 --- a/src/Confix.Tool/src/Confix.Library/Variables/VariableResolver.cs +++ b/src/Confix.Tool/src/Confix.Library/Variables/VariableResolver.cs @@ -1,10 +1,8 @@ -using System.Diagnostics.CodeAnalysis; using System.Reactive.Linq; using System.Text.Json.Nodes; using Confix.Tool; using Confix.Tool.Commands.Logging; using Confix.Tool.Middlewares; -using Json.Schema; namespace Confix.Variables; @@ -85,14 +83,8 @@ public async Task ResolveVariable( App.Log.ResolvingVariable(key); var configuration = GetProviderConfiguration(key.ProviderName); await using var provider = _variableProviderFactory.CreateProvider(configuration); - var resolvedValue = await provider.ResolveAsync(key.Path, cancellationToken); - - if (resolvedValue.IsVariable(out VariablePath? variablePath)) - { - return await ResolveVariable(variablePath.Value, cancellationToken); - } - - return resolvedValue; + + return await provider.ResolveAsync(key.Path, cancellationToken); } public async Task> ResolveVariables( @@ -120,30 +112,14 @@ private async Task> ResolveVariables { App.Log.ResolvingVariables(providerName, paths.Count); var resolvedVariables = new Dictionary(); - var nestedVariables = new Dictionary(); - + var providerConfiguration = GetProviderConfiguration(providerName); await using var provider = _variableProviderFactory.CreateProvider(providerConfiguration); var resolvedValues = await provider.ResolveManyAsync(paths, cancellationToken); foreach (var (key, value) in resolvedValues) { - if (value.IsVariable(out var variablePath)) - { - nestedVariables.Add(variablePath.Value, key); - } - else - { - resolvedVariables.Add(new(providerName, key), value); - } - } - - var resolvedNestedVariables = - await ResolveVariables(nestedVariables.Keys.ToList(), cancellationToken); - - foreach (var (key, value) in resolvedNestedVariables) - { - resolvedVariables.Add(new(providerName, nestedVariables[key]), value); + resolvedVariables.Add(new(providerName, key), value); } return resolvedVariables; @@ -155,24 +131,6 @@ private VariableProviderConfiguration GetProviderConfiguration(string providerNa $"No VariableProvider with name '{providerName.AsHighlighted()}' configured."); } -public static class Extension -{ - public static bool IsVariable( - this JsonNode node, - [NotNullWhen(true)] out VariablePath? variablePath) - { - if (node.GetSchemaValueType() == SchemaValueType.String - && VariablePath.TryParse((string) node!, out var parsed)) - { - variablePath = parsed; - return true; - } - - variablePath = default; - return false; - } -} - file static class Log { public static void ResolvingVariable(this IConsoleLogger log, VariablePath key) diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/Variables/VariableReplacerServiceTests.cs b/src/Confix.Tool/test/Confix.Tool.Tests/Variables/VariableReplacerServiceTests.cs index eb359328..e0ba8fec 100644 --- a/src/Confix.Tool/test/Confix.Tool.Tests/Variables/VariableReplacerServiceTests.cs +++ b/src/Confix.Tool/test/Confix.Tool.Tests/Variables/VariableReplacerServiceTests.cs @@ -31,7 +31,7 @@ public async Task RewriteAsync_ValidVariables_ReplaceAllVariablesAsync() var result = new Dictionary(); foreach (var key in keys) { - result[key] = JsonValue.Create("Replaced Value of " + key)!; + result[key] = JsonValue.Create("Replaced Value of " + key.Path)!; } return result; }); @@ -43,4 +43,246 @@ public async Task RewriteAsync_ValidVariables_ReplaceAllVariablesAsync() // assert result?.ToString().MatchSnapshot(); } + + [Fact] + public async Task RewriteAsync_JsonValue_ResolvesCorrectly() + { + // arrange + JsonNode node = JsonValue.Create("$test:variable.number")!; + + Mock variableResolverMock = new(MockBehavior.Strict); + variableResolverMock.Setup(x => x.ResolveVariables( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync((IReadOnlyList keys, CancellationToken _) => + { + var result = new Dictionary(); + foreach (var key in keys) + { + result[key] = JsonValue.Create("Replaced")!; + } + return result; + }); + VariableReplacerService service = new(variableResolverMock.Object); + + // act + var result = await service.RewriteAsync(node, default); + + // assert + Assert.Equal("Replaced", result?.ToString()); + } + + [Fact] + public async Task RewriteAsync_VariablesInNestedArray_ResolvesCorrectly() + { + // arrange + JsonNode node = JsonNode.Parse(""" + { + "foo": "$test:variable.array" + } + """)!; + + Mock variableResolverMock = new(MockBehavior.Strict); + variableResolverMock.Setup(x => x.ResolveVariables( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync((IReadOnlyList keys, CancellationToken _) => + { + var result = new Dictionary(); + foreach (var key in keys) + { + if (key.Path == "variable.array") + { + result[key] = JsonNode.Parse(""" + [ + "notAVariable", + "$test:variable.string_nested" + ] + """)!; + } + else if (key.Path == "variable.string_nested") + { + result[key] = JsonValue.Create("$test:variable.string")!; + } + else + { + result[key] = JsonValue.Create("Replaced Value of " + key.Path)!; + } + } + return result; + }); + VariableReplacerService service = new(variableResolverMock.Object); + + // act + var result = await service.RewriteAsync(node, default); + + // assert + result?.ToString().MatchSnapshot(); + } + + [Fact] + public async Task RewriteAsync_VariablesInNestedObject_ResolvesCorrectly() + { + // arrange + JsonNode node = JsonNode.Parse(""" + { + "foo": "$test:variable.someObject" + } + """)!; + + Mock variableResolverMock = new(MockBehavior.Strict); + variableResolverMock.Setup(x => x.ResolveVariables( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync((IReadOnlyList keys, CancellationToken _) => + { + var result = new Dictionary(); + foreach (var key in keys) + { + if (key.Path == "variable.someObject") + { + result[key] = JsonNode.Parse(""" + { + "notAVariable": "notAVariable", + "string_nested": "$test:variable.string_nested" + } + """)!; + } + else if (key.Path == "variable.string_nested") + { + result[key] = JsonValue.Create("$test:variable.string")!; + } + else + { + result[key] = JsonValue.Create("Replaced Value of " + key.Path)!; + } + } + return result; + }); + VariableReplacerService service = new(variableResolverMock.Object); + + // act + var result = await service.RewriteAsync(node, default); + + // assert + result?.ToString().MatchSnapshot(); + } + + [Fact] + public async Task RewriteAsync_RecursiveVariables_CorrectlyResolve() + { + // arrange + JsonNode node = JsonNode.Parse(""" + { + "var1": "$test:variable.string", + "var2": "$test:variable.string_nested" + } + """)!; + + Mock variableResolverMock = new(MockBehavior.Strict); + variableResolverMock.Setup(x => x.ResolveVariables( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync((IReadOnlyList keys, CancellationToken _) => + { + var result = new Dictionary(); + foreach (var key in keys) + { + if (key.Path == "variable.string_nested") + { + result[key] = JsonValue.Create("$test:variable.string")!; + } + else if (key.Path == "variable.string") + { + result[key] = JsonValue.Create("ReplacedValue")!; + } + } + return result; + }); + VariableReplacerService service = new(variableResolverMock.Object); + + // act + var result = await service.RewriteAsync(node, default); + + // assert + result?.ToString().MatchSnapshot(); + } + + [Fact] + public async Task RewriteAsync_RecursiveVariablesWithDirectLoop_ThrowCircularVariableReferenceException() + { + // arrange + JsonNode node = JsonNode.Parse(""" + { + "var1": "$test:variable.string", + "var2": "$test:variable.string_nested" + } + """)!; + + Mock variableResolverMock = new(MockBehavior.Strict); + variableResolverMock.Setup(x => x.ResolveVariables( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync((IReadOnlyList keys, CancellationToken _) => + { + var result = new Dictionary(); + foreach (var key in keys) + { + if (key.Path == "variable.string_nested") + { + result[key] = JsonValue.Create("$test:variable.string_nested")!; + } + else if (key.Path == "variable.string") + { + result[key] = JsonValue.Create("ReplacedValue")!; + } + } + return result; + }); + VariableReplacerService service = new(variableResolverMock.Object); + + // act && assert + await Assert.ThrowsAsync(() => service.RewriteAsync(node, default)); + } + + [Fact] + public async Task RewriteAsync_RecursiveVariablesWithindirectLoop_ThrowsCircularVariableReferenceException() + { + // arrange + JsonNode node = JsonNode.Parse(""" + { + "var1": "$test:variable.string", + "var2": "$test:variable.string_nested" + } + """)!; + + Mock variableResolverMock = new(MockBehavior.Strict); + variableResolverMock.Setup(x => x.ResolveVariables( + It.IsAny>(), + It.IsAny())) + .ReturnsAsync((IReadOnlyList keys, CancellationToken _) => + { + var result = new Dictionary(); + foreach (var key in keys) + { + if (key.Path == "variable.string_nested") + { + result[key] = JsonValue.Create("$test:variable.intermediate")!; + } + else if (key.Path == "variable.intermediate") + { + result[key] = JsonValue.Create("$test:variable.string_nested")!; + } + else if (key.Path == "variable.string") + { + result[key] = JsonValue.Create("ReplacedValue")!; + } + } + return result; + }); + VariableReplacerService service = new(variableResolverMock.Object); + + // act && assert + await Assert.ThrowsAsync(() => service.RewriteAsync(node, default)); + } } \ No newline at end of file diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/Variables/VariableResolverTests.cs b/src/Confix.Tool/test/Confix.Tool.Tests/Variables/VariableResolverTests.cs index ea81ee83..ccf864e3 100644 --- a/src/Confix.Tool/test/Confix.Tool.Tests/Variables/VariableResolverTests.cs +++ b/src/Confix.Tool/test/Confix.Tool.Tests/Variables/VariableResolverTests.cs @@ -138,75 +138,6 @@ public async Task ResolveVariables_DuplicatePaths_OnlyFetchOnce() cancellationToken), Times.Once); } - [Fact] - public async Task ResolveVariables_NestedVariables_CorrectResult() - { - // Arrange - var factoryMock = new Mock(); - - var keys = new List - { - new VariablePath("Provider1", "Key1"), - }; - - var configurations = new List - { - new VariableProviderConfiguration - { - Name = "Provider1", - Type = "local", - Configuration = JsonNode.Parse(""" - { - "path": "/path/to/file.json" - } - """)! - }, - new VariableProviderConfiguration - { - Name = "Provider2", - Type = "local", - Configuration = JsonNode.Parse(""" - { - "path": "/path/to/file.json" - } - """)! - } - }; - var resolver = new VariableResolver(factoryMock.Object, new VariableListCache(), configurations); - var cancellationToken = CancellationToken.None; - - var provider1Mock = new Mock(); - provider1Mock.Setup(p => p.ResolveManyAsync( - It.Is>(paths => paths.SequenceEqual(new[] { "Key1" })), - cancellationToken)) - .ReturnsAsync(new Dictionary - { - { "Key1", JsonValue.Create(new VariablePath("Provider2", "Key2").ToString())!}, - }); - - var provider2Mock = new Mock(); - provider2Mock.Setup(p => p.ResolveManyAsync( - It.Is>(paths => paths.SequenceEqual(new[] { "Key2" })), - cancellationToken)) - .ReturnsAsync(new Dictionary - { - { "Key2", JsonValue.Create("FinalResult")! }, - }); - - factoryMock.Setup(f => f.CreateProvider(configurations[0])) - .Returns(provider1Mock.Object); - - factoryMock.Setup(f => f.CreateProvider(configurations[1])) - .Returns(provider2Mock.Object); - - // Act - var result = await resolver.ResolveVariables(keys, cancellationToken); - - // Assert - result.Should().HaveCount(1); - Assert.True(result[new VariablePath("Provider1", "Key1")].IsEquivalentTo(JsonValue.Create("FinalResult"))); - } - [Fact] public async Task ResolveVariable_ProviderNotFound_ThrowsExitException() { diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/Variables/__snapshots__/VariableReplacerServiceTests.RewriteAsync_RecursiveVariables_CorrectlyResolve.snap b/src/Confix.Tool/test/Confix.Tool.Tests/Variables/__snapshots__/VariableReplacerServiceTests.RewriteAsync_RecursiveVariables_CorrectlyResolve.snap new file mode 100644 index 00000000..745579bc --- /dev/null +++ b/src/Confix.Tool/test/Confix.Tool.Tests/Variables/__snapshots__/VariableReplacerServiceTests.RewriteAsync_RecursiveVariables_CorrectlyResolve.snap @@ -0,0 +1,4 @@ +{ + "var1": "ReplacedValue", + "var2": "ReplacedValue" +} diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/Variables/__snapshots__/VariableReplacerServiceTests.RewriteAsync_ValidVariables_ReplaceAllVariablesAsync.snap b/src/Confix.Tool/test/Confix.Tool.Tests/Variables/__snapshots__/VariableReplacerServiceTests.RewriteAsync_ValidVariables_ReplaceAllVariablesAsync.snap index ad1e03f2..24ccac9e 100644 --- a/src/Confix.Tool/test/Confix.Tool.Tests/Variables/__snapshots__/VariableReplacerServiceTests.RewriteAsync_ValidVariables_ReplaceAllVariablesAsync.snap +++ b/src/Confix.Tool/test/Confix.Tool.Tests/Variables/__snapshots__/VariableReplacerServiceTests.RewriteAsync_ValidVariables_ReplaceAllVariablesAsync.snap @@ -1,8 +1,8 @@ { "foo": { "bar": "baz", - "test": "Replaced Value of $test:variable.number", - "interpolated": "prefix-Replaced Value of $test:variable.string1-suffix", - "interpolatedMultiple": "asterix-Replaced Value of $test:variable.string2-midefix-Replaced Value of $test:variable.string3-suffix" + "test": "Replaced Value of variable.number", + "interpolated": "prefix-Replaced Value of variable.string1-suffix", + "interpolatedMultiple": "asterix-Replaced Value of variable.string2-midefix-Replaced Value of variable.string3-suffix" } } diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/Variables/__snapshots__/VariableReplacerServiceTests.RewriteAsync_VariablesInNestedArray_ResolvesCorrectly.snap b/src/Confix.Tool/test/Confix.Tool.Tests/Variables/__snapshots__/VariableReplacerServiceTests.RewriteAsync_VariablesInNestedArray_ResolvesCorrectly.snap new file mode 100644 index 00000000..29881f16 --- /dev/null +++ b/src/Confix.Tool/test/Confix.Tool.Tests/Variables/__snapshots__/VariableReplacerServiceTests.RewriteAsync_VariablesInNestedArray_ResolvesCorrectly.snap @@ -0,0 +1,6 @@ +{ + "foo": [ + "notAVariable", + "Replaced Value of variable.string" + ] +} diff --git a/src/Confix.Tool/test/Confix.Tool.Tests/Variables/__snapshots__/VariableReplacerServiceTests.RewriteAsync_VariablesInNestedObject_ResolvesCorrectly.snap b/src/Confix.Tool/test/Confix.Tool.Tests/Variables/__snapshots__/VariableReplacerServiceTests.RewriteAsync_VariablesInNestedObject_ResolvesCorrectly.snap new file mode 100644 index 00000000..6cac2580 --- /dev/null +++ b/src/Confix.Tool/test/Confix.Tool.Tests/Variables/__snapshots__/VariableReplacerServiceTests.RewriteAsync_VariablesInNestedObject_ResolvesCorrectly.snap @@ -0,0 +1,6 @@ +{ + "foo": { + "notAVariable": "notAVariable", + "string_nested": "Replaced Value of variable.string" + } +}