Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add cache store to emulator #87

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Authoring/IOutboundContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ public interface IOutboundContext : IHaveExpressionContext
/// </summary>
/// <param name="duration"></param>
/// <param name="cacheResponse"></param>
void CacheStore(uint duration, bool? cacheResponse);
void CacheStore(uint duration, bool? cacheResponse = null);

/// <summary>
/// TODO
Expand Down
6 changes: 5 additions & 1 deletion src/Testing/Document/MockCacheStoreProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -36,5 +35,10 @@ internal Setup(

public void WithCallback(Action<GatewayContext, uint, bool> callback) =>
_handler.CallbackHooks.Add((_predicate, callback).ToTuple());

public void WithCacheKey(Func<GatewayContext, uint, bool, string> callback) =>
_handler.CacheKeyProvider.Add((_predicate, callback).ToTuple());

public void WithCacheKey(string key) => this.WithCacheKey((_, _, _) => key);
}
}
3 changes: 3 additions & 0 deletions src/Testing/Document/TestDocumentExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
93 changes: 93 additions & 0 deletions src/Testing/Emulator/Data/CacheInfo.cs
Original file line number Diff line number Diff line change
@@ -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, "&params:", 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<string, string[]> 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);
}
}
}
38 changes: 33 additions & 5 deletions src/Testing/Emulator/Policies/CacheStoreHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -13,6 +14,11 @@ public List<Tuple<
Action<GatewayContext, uint, bool>
>> CallbackHooks { get; } = new();

public List<Tuple<
Func<GatewayContext, uint, bool, bool>,
Func<GatewayContext, uint, bool, string>
>> CacheKeyProvider { get; } = new();

public string PolicyName => nameof(IOutboundContext.CacheStore);

public object? Handle(GatewayContext context, object?[]? args)
Expand All @@ -32,9 +38,31 @@ public List<Tuple<
return null;
}

protected void Handle(GatewayContext context, uint duration, bool cacheResponse)
private void Handle(GatewayContext context, uint duration, bool cacheResponse)
{
throw new NotImplementedException();
if (!context.CacheInfo.CacheSetup)
{
return;
}

var store = context.CacheStore.GetCache(context.CacheInfo.CachingType);
if (store is null)
{
return;
}

if (context.Response.StatusCode != 200 && !cacheResponse)
{
return;
}

var cacheValue = context.Response.Clone();

var key = CacheKeyProvider.Find(hook => 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)
Expand All @@ -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));
}
Expand Down
8 changes: 8 additions & 0 deletions src/Testing/Expressions/MockResponse.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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, },
};
}
1 change: 1 addition & 0 deletions src/Testing/GatewayContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public class GatewayContext : MockExpressionContext
internal readonly SectionContextProxy<IOnErrorContext> OnErrorProxy;
internal readonly CertificateStore CertificateStore = new();
internal readonly CacheStore CacheStore = new();
internal readonly CacheInfo CacheInfo = new();

public GatewayContext()
{
Expand Down
101 changes: 101 additions & 0 deletions test/Test.Testing/Emulator/Policies/CacheStoreTests.cs
Original file line number Diff line number Diff line change
@@ -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<IResponse>().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<IResponse>().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);
}
}
Loading