diff --git a/src/Authoring/IOutboundContext.cs b/src/Authoring/IOutboundContext.cs index 2e7b12c..1368a4c 100644 --- a/src/Authoring/IOutboundContext.cs +++ b/src/Authoring/IOutboundContext.cs @@ -88,7 +88,7 @@ public interface IOutboundContext : IHaveExpressionContext /// /// /// - void CacheStore(uint duration, bool? cacheResponse); + void CacheStore(uint duration, bool? cacheResponse = null); /// /// TODO diff --git a/src/Testing/Document/MockCacheStoreProvider.cs b/src/Testing/Document/MockCacheStoreProvider.cs index b1b1fed..e432835 100644 --- a/src/Testing/Document/MockCacheStoreProvider.cs +++ b/src/Testing/Document/MockCacheStoreProvider.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. using Azure.ApiManagement.PolicyToolkit.Authoring; -using Azure.ApiManagement.PolicyToolkit.Testing.Emulator; using Azure.ApiManagement.PolicyToolkit.Testing.Emulator.Policies; namespace Azure.ApiManagement.PolicyToolkit.Testing.Document; @@ -36,5 +35,10 @@ internal Setup( public void WithCallback(Action callback) => _handler.CallbackHooks.Add((_predicate, callback).ToTuple()); + + public void WithCacheKey(Func callback) => + _handler.CacheKeyProvider.Add((_predicate, callback).ToTuple()); + + public void WithCacheKey(string key) => this.WithCacheKey((_, _, _) => key); } } \ No newline at end of file diff --git a/src/Testing/Document/TestDocumentExtensions.cs b/src/Testing/Document/TestDocumentExtensions.cs index 6336346..5e3b6b7 100644 --- a/src/Testing/Document/TestDocumentExtensions.cs +++ b/src/Testing/Document/TestDocumentExtensions.cs @@ -25,4 +25,7 @@ public static CertificateStore SetupCertificateStore(this TestDocument document) public static CacheStore SetupCacheStore(this TestDocument document) => document.Context.CacheStore; + + public static CacheInfo SetupCacheInfo(this TestDocument document) => + document.Context.CacheInfo; } \ No newline at end of file diff --git a/src/Testing/Emulator/Data/CacheInfo.cs b/src/Testing/Emulator/Data/CacheInfo.cs new file mode 100644 index 0000000..e2f7925 --- /dev/null +++ b/src/Testing/Emulator/Data/CacheInfo.cs @@ -0,0 +1,93 @@ +using System.Text; + +using Azure.ApiManagement.PolicyToolkit.Authoring; + +namespace Azure.ApiManagement.PolicyToolkit.Testing.Emulator.Data; + +public class CacheInfo +{ + internal bool CacheSetup = false; + + internal bool VaryByDeveloper = false; + internal bool VaryByDeveloperGroups = false; + internal string CachingType = "prefer-external"; + internal string DownstreamCachingType = "none"; + internal bool MustRevalidate = true; + internal bool AllowPrivateResponseCaching = false; + internal string[]? VaryByHeaders; + internal string[]? VaryByQueryParameters; + + public CacheInfo WithExecutedCacheLookup(bool isSetup = true) + { + CacheSetup = isSetup; + return this; + } + + internal CacheInfo WithExecutedCacheLookup(CacheLookupConfig config) + { + CacheSetup = true; + VaryByDeveloper = config.VaryByDeveloper; + VaryByDeveloperGroups = config.VaryByDeveloperGroups; + CachingType = config.CachingType ?? CachingType; + DownstreamCachingType = config.DownstreamCachingType ?? DownstreamCachingType; + MustRevalidate = config.MustRevalidate ?? MustRevalidate; + AllowPrivateResponseCaching = config.AllowPrivateResponseCaching ?? AllowPrivateResponseCaching; + VaryByHeaders = config.VaryByHeaders; + VaryByQueryParameters = config.VaryByQueryParameters; + return this; + } + + internal static string CacheKey(GatewayContext context) + { + var keyBuilder = new StringBuilder("key:"); + + if (context.Product is not null) + { + keyBuilder.Append("&product:").Append(context.Product.Id).Append(':'); + } + + keyBuilder.Append("&api:").Append(context.Api.Id).Append(':'); + keyBuilder.Append("&operation:").Append(context.Operation.Id).Append(':'); + + ProcessVaryBy(keyBuilder, "¶ms:", context.CacheInfo.VaryByQueryParameters, context.Request.Url.Query); + ProcessVaryBy(keyBuilder, "&headers:", context.CacheInfo.VaryByHeaders, context.Request.Headers); + + if (context.CacheInfo.VaryByDeveloper) + { + keyBuilder.Append("&bydeveloper:").Append(context.User?.Id); + } + + if (context.CacheInfo.VaryByDeveloperGroups) + { + keyBuilder.Append("&bygroups:"); + if (context.User is not null) + { + keyBuilder.AppendJoin(",", context.User.Groups.Select(g => g.Id)); + } + } + + return keyBuilder.ToString(); + } + + private static void ProcessVaryBy(StringBuilder builder, string prefix, string[]? keys, + Dictionary map) + { + if (keys is null || keys.Length == 0) + { + return; + } + + builder.Append(prefix); + var keyList = keys.ToList(); + keyList.Sort(StringComparer.InvariantCultureIgnoreCase); + foreach (var key in keyList) + { + if (!map.TryGetValue(key, out var v)) + { + continue; + } + + builder.Append(key).Append('=').AppendJoin(",", v); + } + } +} \ No newline at end of file diff --git a/src/Testing/Emulator/Policies/CacheStoreHandler.cs b/src/Testing/Emulator/Policies/CacheStoreHandler.cs index 807e41e..29896e7 100644 --- a/src/Testing/Emulator/Policies/CacheStoreHandler.cs +++ b/src/Testing/Emulator/Policies/CacheStoreHandler.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using Azure.ApiManagement.PolicyToolkit.Authoring; +using Azure.ApiManagement.PolicyToolkit.Testing.Emulator.Data; namespace Azure.ApiManagement.PolicyToolkit.Testing.Emulator.Policies; @@ -13,6 +14,11 @@ public List >> CallbackHooks { get; } = new(); + public List, + Func + >> CacheKeyProvider { get; } = new(); + public string PolicyName => nameof(IOutboundContext.CacheStore); public object? Handle(GatewayContext context, object?[]? args) @@ -32,9 +38,31 @@ public List hook.Item1(context, duration, cacheResponse)) + ?.Item2(context, duration, cacheResponse) + ?? CacheInfo.CacheKey(context); + + store[key] = new CacheValue(cacheValue) { Duration = duration }; } private static (uint, bool) ExtractParameters(object?[]? args) @@ -49,12 +77,12 @@ private static (uint, bool) ExtractParameters(object?[]? args) throw new ArgumentException($"Expected {typeof(uint).Name} as first argument", nameof(args)); } - if (args.Length != 2) + if (args.Length != 2 || args[1] is null) { - return (duration, true); + return (duration, false); } - if (args[0] is not bool cacheValue) + if (args[1] is not bool cacheValue) { throw new ArgumentException($"Expected {typeof(bool).Name} as second argument", nameof(args)); } diff --git a/src/Testing/Expressions/MockResponse.cs b/src/Testing/Expressions/MockResponse.cs index cd8477f..1fe11c3 100644 --- a/src/Testing/Expressions/MockResponse.cs +++ b/src/Testing/Expressions/MockResponse.cs @@ -14,4 +14,12 @@ public class MockResponse : MockMessage, IResponse public int StatusCode { get; set; } = 200; public string StatusReason { get; set; } = "OK"; + + public MockResponse Clone() => new() + { + StatusCode = StatusCode, + StatusReason = StatusReason, + Headers = Headers.ToDictionary(pair => pair.Key, pair => (string[])pair.Value.Clone()), + Body = new MockBody() { Content = Body.Content, }, + }; } \ No newline at end of file diff --git a/src/Testing/GatewayContext.cs b/src/Testing/GatewayContext.cs index e478d20..113c6a8 100644 --- a/src/Testing/GatewayContext.cs +++ b/src/Testing/GatewayContext.cs @@ -16,6 +16,7 @@ public class GatewayContext : MockExpressionContext internal readonly SectionContextProxy OnErrorProxy; internal readonly CertificateStore CertificateStore = new(); internal readonly CacheStore CacheStore = new(); + internal readonly CacheInfo CacheInfo = new(); public GatewayContext() { diff --git a/test/Test.Testing/Emulator/Policies/CacheStoreTests.cs b/test/Test.Testing/Emulator/Policies/CacheStoreTests.cs new file mode 100644 index 0000000..fe14d9b --- /dev/null +++ b/test/Test.Testing/Emulator/Policies/CacheStoreTests.cs @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.ApiManagement.PolicyToolkit.Authoring; +using Azure.ApiManagement.PolicyToolkit.Authoring.Expressions; +using Azure.ApiManagement.PolicyToolkit.Testing; +using Azure.ApiManagement.PolicyToolkit.Testing.Document; + +namespace Test.Emulator.Emulator.Policies; + +[TestClass] +public class CacheStoreTests +{ + class SimpleCacheStore : IDocument + { + public void Outbound(IOutboundContext context) + { + context.CacheStore(10); + } + } + + class SimpleCacheStoreStoreResponse : IDocument + { + public void Outbound(IOutboundContext context) + { + context.CacheStore(10, true); + } + } + + [TestMethod] + public void CacheStore_Callback() + { + var test = new SimpleCacheStore().AsTestDocument(); + var executedCallback = false; + test.SetupOutbound().CacheStore().WithCallback((_, _, _) => + { + executedCallback = true; + }); + + test.RunOutbound(); + + executedCallback.Should().BeTrue(); + } + + [TestMethod] + public void CacheStore_StoreResponseInCache() + { + var test = new SimpleCacheStore().AsTestDocument(); + var cache = test.SetupCacheStore(); + test.SetupCacheInfo().WithExecutedCacheLookup(); + test.SetupOutbound().CacheStore().WithCacheKey("key"); + + test.RunOutbound(); + + var cacheValue = cache.InternalCache.Should().ContainKey("key").WhoseValue; + cacheValue.Duration.Should().Be(10); + var response = cacheValue.Value.Should().BeAssignableTo().Which; + var contextResponse = test.Context.Response; + response.Should().NotBeSameAs(contextResponse, "Should be a copy of response"); + response.StatusCode.Should().Be(contextResponse.StatusCode); + response.StatusReason.Should().Be(contextResponse.StatusReason); + response.Headers.Should().Equal(contextResponse.Headers); + } + + [TestMethod] + public void CacheStore_NotStoreIfResponseIsNot200() + { + var test = new SimpleCacheStore().AsTestDocument(); + test.Context.Response.StatusCode = 401; + test.Context.Response.StatusReason = "Unauthorized"; + var cache = test.SetupCacheStore(); + test.SetupCacheInfo().WithExecutedCacheLookup(); + test.SetupOutbound().CacheStore().WithCacheKey("key"); + + test.RunOutbound(); + + cache.InternalCache.Should().NotContainKey("key"); + } + + [TestMethod] + public void CacheStore_StoreIfResponseIsNot200_WhenCacheResponseIsSetToTrue() + { + var test = new SimpleCacheStoreStoreResponse().AsTestDocument(); + var contextResponse = test.Context.Response; + contextResponse.StatusCode = 401; + contextResponse.StatusReason = "Unauthorized"; + var cache = test.SetupCacheStore(); + test.SetupCacheInfo().WithExecutedCacheLookup(); + test.SetupOutbound().CacheStore().WithCacheKey("key"); + + test.RunOutbound(); + + var cacheValue = cache.InternalCache.Should().ContainKey("key").WhoseValue; + cacheValue.Duration.Should().Be(10); + var response = cacheValue.Value.Should().BeAssignableTo().Which; + response.Should().NotBeSameAs(contextResponse, "Should be a copy of response"); + response.StatusCode.Should().Be(contextResponse.StatusCode); + response.StatusReason.Should().Be(contextResponse.StatusReason); + response.Headers.Should().Equal(contextResponse.Headers); + } +} \ No newline at end of file