From 14d856a4cf40a0b8cfeda635d0e86fdb9d0f7766 Mon Sep 17 00:00:00 2001 From: Rafal Mielowski Date: Fri, 24 Jan 2025 15:27:28 +0100 Subject: [PATCH 1/2] Add cache store to emulator --- .../Document/MockCacheStoreProvider.cs | 5 + .../Document/TestDocumentExtensions.cs | 3 + src/Testing/Emulator/Data/CacheInfo.cs | 93 ++++++++++++++++ .../Emulator/Policies/CacheStoreHandler.cs | 38 ++++++- src/Testing/Expressions/MockResponse.cs | 8 ++ src/Testing/GatewayContext.cs | 1 + .../Emulator/Policies/CacheStoreTests.cs | 101 ++++++++++++++++++ 7 files changed, 244 insertions(+), 5 deletions(-) create mode 100644 src/Testing/Emulator/Data/CacheInfo.cs create mode 100644 test/Test.Testing/Emulator/Policies/CacheStoreTests.cs diff --git a/src/Testing/Document/MockCacheStoreProvider.cs b/src/Testing/Document/MockCacheStoreProvider.cs index ae764ee4..c4ff676a 100644 --- a/src/Testing/Document/MockCacheStoreProvider.cs +++ b/src/Testing/Document/MockCacheStoreProvider.cs @@ -35,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 26c02c97..5cb86853 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 00000000..e2f79258 --- /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 778719e2..0a425399 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 Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring; +using Microsoft.Azure.ApiManagement.PolicyToolkit.Testing.Emulator.Data; namespace Microsoft.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 c9875973..c7f0e101 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 ac3516a7..409eca70 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 00000000..fe14d9b7 --- /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 From 2b513d1cdb59271da866d94433330f8153f8e69c Mon Sep 17 00:00:00 2001 From: Rafal Mielowski Date: Mon, 27 Jan 2025 22:53:57 +0100 Subject: [PATCH 2/2] wip --- .../Document/MockCacheLookupProvider.cs | 12 ++- src/Testing/Emulator/Data/CacheInfo.cs | 15 ++-- .../Emulator/Policies/CacheLookupHandler.cs | 36 ++++++++- .../Emulator/Policies/CacheStoreHandler.cs | 13 +++- .../Emulator/Policies/CacheLookupTests.cs | 23 ++++++ .../Emulator/Policies/CacheStoreTests.cs | 75 +++++++++++++++---- 6 files changed, 148 insertions(+), 26 deletions(-) create mode 100644 test/Test.Testing/Emulator/Policies/CacheLookupTests.cs diff --git a/src/Testing/Document/MockCacheLookupProvider.cs b/src/Testing/Document/MockCacheLookupProvider.cs index e2f97da9..2fa2995b 100644 --- a/src/Testing/Document/MockCacheLookupProvider.cs +++ b/src/Testing/Document/MockCacheLookupProvider.cs @@ -21,8 +21,8 @@ public static Setup CacheLookup( public class Setup { - private readonly Func _predicate; private readonly CacheLookupHandler _handler; + private readonly Func _predicate; internal Setup( Func predicate, @@ -34,5 +34,15 @@ internal Setup( public void WithCallback(Action callback) => _handler.CallbackSetup.Add((_predicate, callback).ToTuple()); + + public void WithCacheKey(Func callback) + { + _handler.CacheKeyProvider.Add((_predicate, callback).ToTuple()); + } + + public void WithCacheKey(string key) + { + WithCacheKey((_, _) => key); + } } } \ No newline at end of file diff --git a/src/Testing/Emulator/Data/CacheInfo.cs b/src/Testing/Emulator/Data/CacheInfo.cs index e2f79258..ce566959 100644 --- a/src/Testing/Emulator/Data/CacheInfo.cs +++ b/src/Testing/Emulator/Data/CacheInfo.cs @@ -1,19 +1,20 @@ using System.Text; -using Azure.ApiManagement.PolicyToolkit.Authoring; +using Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring; -namespace Azure.ApiManagement.PolicyToolkit.Testing.Emulator.Data; +namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Testing.Emulator.Data; public class CacheInfo { + internal bool AllowPrivateResponseCaching; 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 bool ShouldBeCached = false; + + internal bool VaryByDeveloper = false; + internal bool VaryByDeveloperGroups = false; internal string[]? VaryByHeaders; internal string[]? VaryByQueryParameters; @@ -23,7 +24,7 @@ public CacheInfo WithExecutedCacheLookup(bool isSetup = true) return this; } - internal CacheInfo WithExecutedCacheLookup(CacheLookupConfig config) + public CacheInfo WithExecutedCacheLookup(CacheLookupConfig config) { CacheSetup = true; VaryByDeveloper = config.VaryByDeveloper; diff --git a/src/Testing/Emulator/Policies/CacheLookupHandler.cs b/src/Testing/Emulator/Policies/CacheLookupHandler.cs index f3392138..d1ad6ed5 100644 --- a/src/Testing/Emulator/Policies/CacheLookupHandler.cs +++ b/src/Testing/Emulator/Policies/CacheLookupHandler.cs @@ -2,16 +2,50 @@ // Licensed under the MIT License. using Microsoft.Azure.ApiManagement.PolicyToolkit.Authoring; +using Microsoft.Azure.ApiManagement.PolicyToolkit.Testing.Emulator.Data; +using Microsoft.Azure.ApiManagement.PolicyToolkit.Testing.Expressions; namespace Microsoft.Azure.ApiManagement.PolicyToolkit.Testing.Emulator.Policies; [Section(nameof(IInboundContext))] internal class CacheLookupHandler : PolicyHandler { + public List, + Func + >> CacheKeyProvider { get; } = new(); + public override string PolicyName => nameof(IInboundContext.CacheLookup); protected override void Handle(GatewayContext context, CacheLookupConfig config) { - throw new NotImplementedException(); + if (context.CacheInfo.CacheSetup) + { + return; + } + + Dictionary? store = context.CacheStore.GetCache(context.CacheInfo.CachingType); + if (store is null) + { + return; + } + + context.CacheInfo.WithExecutedCacheLookup(config); + + string key = CacheKeyProvider.Find(hook => hook.Item1(context, config)) + ?.Item2(context, config) + ?? CacheInfo.CacheKey(context); + if (!store.TryGetValue(key, out CacheValue? cacheHit)) + { + return; + } + + if (cacheHit.Value is not MockResponse cachedResponse) + { + return; + } + + context.Response = cachedResponse.Clone(); + throw new FinishSectionProcessingException(); } } \ No newline at end of file diff --git a/src/Testing/Emulator/Policies/CacheStoreHandler.cs b/src/Testing/Emulator/Policies/CacheStoreHandler.cs index 0a425399..f9c4333c 100644 --- a/src/Testing/Emulator/Policies/CacheStoreHandler.cs +++ b/src/Testing/Emulator/Policies/CacheStoreHandler.cs @@ -45,13 +45,23 @@ private void Handle(GatewayContext context, uint duration, bool cacheResponse) return; } + if (!context.Request.Method.Equals("GET", StringComparison.InvariantCultureIgnoreCase)) + { + return; + } + var store = context.CacheStore.GetCache(context.CacheInfo.CachingType); if (store is null) { return; } - if (context.Response.StatusCode != 200 && !cacheResponse) + if (!cacheResponse || context.Response.StatusCode != 200) + { + return; + } + + if (context.Response.Headers.ContainsKey("Authorization") && !context.CacheInfo.AllowPrivateResponseCaching) { return; } @@ -62,6 +72,7 @@ private void Handle(GatewayContext context, uint duration, bool cacheResponse) ?.Item2(context, duration, cacheResponse) ?? CacheInfo.CacheKey(context); + store[key] = new CacheValue(cacheValue) { Duration = duration }; } diff --git a/test/Test.Testing/Emulator/Policies/CacheLookupTests.cs b/test/Test.Testing/Emulator/Policies/CacheLookupTests.cs new file mode 100644 index 00000000..80f5b8f5 --- /dev/null +++ b/test/Test.Testing/Emulator/Policies/CacheLookupTests.cs @@ -0,0 +1,23 @@ +using Azure.ApiManagement.PolicyToolkit.Authoring; + +namespace Test.Emulator.Emulator.Policies; + +[TestClass] +public class CacheLookupTests +{ + private class SimpleCacheLookup : IDocument + { + public void Inbound(IInboundContext context) + { + context.CacheLookup(new CacheLookupConfig { VaryByDeveloper = false, VaryByDeveloperGroups = false }); + } + } + + private class SimpleCacheLookup1 : IDocument + { + public void Outbound(IOutboundContext context) + { + context.CacheStore(10, true); + } + } +} \ No newline at end of file diff --git a/test/Test.Testing/Emulator/Policies/CacheStoreTests.cs b/test/Test.Testing/Emulator/Policies/CacheStoreTests.cs index fe14d9b7..c41d334c 100644 --- a/test/Test.Testing/Emulator/Policies/CacheStoreTests.cs +++ b/test/Test.Testing/Emulator/Policies/CacheStoreTests.cs @@ -5,28 +5,13 @@ using Azure.ApiManagement.PolicyToolkit.Authoring.Expressions; using Azure.ApiManagement.PolicyToolkit.Testing; using Azure.ApiManagement.PolicyToolkit.Testing.Document; +using Azure.ApiManagement.PolicyToolkit.Testing.Emulator.Data; 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() { @@ -42,6 +27,18 @@ public void CacheStore_Callback() executedCallback.Should().BeTrue(); } + [TestMethod] + public void CacheStore_NotStoreWhenLookupWasNotExecuted() + { + TestDocument test = new SimpleCacheStore().AsTestDocument(); + CacheStore cache = test.SetupCacheStore(); + test.SetupOutbound().CacheStore().WithCacheKey("key"); + + test.RunOutbound(); + + cache.InternalCache.Should().NotContainKey("key"); + } + [TestMethod] public void CacheStore_StoreResponseInCache() { @@ -62,6 +59,23 @@ public void CacheStore_StoreResponseInCache() response.Headers.Should().Equal(contextResponse.Headers); } + [TestMethod] + public void CacheStore_StoreResponseInCache_WhenExternal() + { + TestDocument test = new SimpleCacheStore().AsTestDocument(); + CacheStore cache = test.SetupCacheStore(); + test.SetupCacheStore().WithExternalCacheSetup(); + test.SetupCacheInfo().WithExecutedCacheLookup(); + test.SetupOutbound().CacheStore().WithCacheKey("key"); + + test.RunOutbound(); + + CacheValue? cacheValue = cache.ExternalCache.Should().ContainKey("key").WhoseValue; + cacheValue.Duration.Should().Be(10); + cacheValue.Value.Should().BeAssignableTo() + .And.NotBeSameAs(test.Context.Response, "Should be a copy of response"); + } + [TestMethod] public void CacheStore_NotStoreIfResponseIsNot200() { @@ -98,4 +112,33 @@ public void CacheStore_StoreIfResponseIsNot200_WhenCacheResponseIsSetToTrue() response.StatusReason.Should().Be(contextResponse.StatusReason); response.Headers.Should().Equal(contextResponse.Headers); } + + [TestMethod] + public void CacheStore_() + { + TestDocument test = new SimpleCacheStore().AsTestDocument(); + test.SetupCacheInfo().WithExecutedCacheLookup(new CacheLookupConfig + { + VaryByDeveloper = true, + VaryByDeveloperGroups = true, + CachingType = "internal", + AllowPrivateResponseCaching = + }); + } + + private class SimpleCacheStore : IDocument + { + public void Outbound(IOutboundContext context) + { + context.CacheStore(10); + } + } + + private class SimpleCacheStoreStoreResponse : IDocument + { + public void Outbound(IOutboundContext context) + { + context.CacheStore(10, true); + } + } } \ No newline at end of file