Skip to content

Commit

Permalink
Merge pull request #121 from Archomeda/fix/many-caching
Browse files Browse the repository at this point in the history
Fix many item caching so that individual items have proper cache ids
  • Loading branch information
Archomeda authored May 22, 2022
2 parents d175391 + 225e246 commit 0cfbb50
Show file tree
Hide file tree
Showing 2 changed files with 100 additions and 54 deletions.
118 changes: 81 additions & 37 deletions Gw2Sharp.Tests/WebApi/Middleware/CacheMiddlewareTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using AutoFixture;
using AutoFixture.Kernel;
Expand Down Expand Up @@ -34,16 +35,17 @@ public class Element

#region Data

public static readonly object[] HeaderCases =
public static readonly string[] HeadersToCacheSeparately = new[]
{
new[] { default(string) },
new[] { "Accept-Language" },
new[] { "Authorization" }
"Accept-Language",
"Authorization"
};

public static readonly object[] QueryWithManyCases =
{
// All
new[] { "ids=all" },
// Many
new object[]
{
"ids=14,19,20",
Expand All @@ -54,30 +56,23 @@ public class Element
new Element { Id = "20", Value = "Value20" }
}
},
// Page
new[] { "page=0" },
// Page + custom page size
new[] { "page=3&page_size=40" }
};

public static IEnumerable<object[]> HeaderWithQueryWithManyMatrixCases()
{
foreach (object[] header in HeaderCases)
foreach (object[] queryWithMany in QueryWithManyCases)
yield return header.Concat(queryWithMany).ToArray();
}

#endregion


[Theory]
[MemberAutoMockData(nameof(HeaderCases), ShareFixture = false)]
[AutoMockData]
public async Task SingleRequestAsyncTest(
string responseHeaderNameToAdd,
[CustomizeWith(typeof(MemoryCacheMethodAutoFixture)), Frozen] MiddlewareContext context,
[Frozen] IWebApiResponse response,
IFixture fixture)
{
context.Request.Options.Returns(CreateRequestOptions());
SetResponseHeader(response, responseHeaderNameToAdd, fixture.Create<string>());
response.CacheState.Returns(CacheState.FromLive);

// Do the request
Expand All @@ -88,15 +83,13 @@ public async Task SingleRequestAsyncTest(
}

[Theory]
[MemberAutoMockData(nameof(HeaderCases), ShareFixture = false)]
[AutoMockData]
public async Task CachesSingleRequestAsyncTest(
string responseHeaderNameToAdd,
[CustomizeWith(typeof(MemoryCacheMethodAutoFixture)), Frozen] MiddlewareContext context,
[CustomizeWith(typeof(ExpiresHeaderAutoFixture)), Frozen] IWebApiResponse response,
IFixture fixture)
{
context.Request.Options.Returns(CreateRequestOptions());
SetResponseHeader(response, responseHeaderNameToAdd, fixture.Create<string>());
response.CacheState.Returns(CacheState.FromCache);

// Do the first request
Expand All @@ -109,19 +102,49 @@ public async Task CachesSingleRequestAsyncTest(
cachedResponse.Should().BeEquivalentTo(response);
}

[Theory]
[AutoMockData]
public async Task CachesSingleRequestSeparatelyHeaderAsyncTest(
[CustomizeWith(typeof(MemoryCacheMethodAutoFixture)), Frozen] MiddlewareContext context,
[CustomizeWith(typeof(ExpiresHeaderAutoFixture)), Frozen] IWebApiResponse response,
IFixture fixture)
{
context.Request.Options.Returns(CreateRequestOptions());
response.CacheState.Returns(CacheState.FromLive);

// Do the first request without specific headers
var middleware = new CacheMiddleware();
await middleware.OnRequestAsync(context, (r, t) => Task.FromResult(response));

// Repeat the request for every header to see if it's taken from live
foreach (var cacheHeader in HeadersToCacheSeparately)
{
context.Request.Options.RequestHeaders = new Dictionary<string, string>()
{
[cacheHeader] = fixture.Create<string>()
};

var nextMock = Substitute.For<Func<MiddlewareContext, CancellationToken, Task<IWebApiResponse>>>();
nextMock(Arg.Any<MiddlewareContext>(), Arg.Any<CancellationToken>()).Returns(Task.FromResult(response));
await middleware.OnRequestAsync(context, nextMock);

// Because the middleware passes the request to the next middleware if the response wasn't cached,
// we can check here if that call has arrived
await nextMock.Received(1)(Arg.Any<MiddlewareContext>(), Arg.Any<CancellationToken>());
}
}


[Theory]
[MemberAutoMockData(nameof(HeaderWithQueryWithManyMatrixCases), ShareFixture = false)]
[AutoMockData]
public async Task ManyRequestAsyncTest(
string responseHeaderNameToAdd,
string queryParams,
[Frozen] IList<Element> responseElements,
[CustomizeWith(typeof(MemoryCacheMethodAutoFixture)), Frozen] MiddlewareContext context,
[Frozen] IWebApiResponse response,
IFixture fixture)
{
context.Request.Options.Returns(CreateRequestOptions(queryParams));
SetResponseHeader(response, responseHeaderNameToAdd, fixture.Create<string>());
SetResponseContent(response, responseElements);
response.CacheState.Returns(CacheState.FromLive);

Expand All @@ -133,17 +156,15 @@ public async Task ManyRequestAsyncTest(
}

[Theory]
[MemberAutoMockData(nameof(HeaderWithQueryWithManyMatrixCases), ShareFixture = false)]
[MemberAutoMockData(nameof(QueryWithManyCases), ShareFixture = false)]
public async Task CachesManyRequestAsyncTest(
string responseHeaderNameToAdd,
string queryParams,
[Frozen] IList<Element> responseElements,
[CustomizeWith(typeof(MemoryCacheMethodAutoFixture)), Frozen] MiddlewareContext context,
[CustomizeWith(typeof(ExpiresHeaderAutoFixture)), Frozen] IWebApiResponse response,
IFixture fixture)
{
context.Request.Options.Returns(CreateRequestOptions(queryParams));
SetResponseHeader(response, responseHeaderNameToAdd, fixture.Create<string>());
SetResponseContent(response, responseElements);
response.CacheState.Returns(CacheState.FromCache);

Expand All @@ -157,9 +178,8 @@ public async Task CachesManyRequestAsyncTest(
}

[Theory]
[MemberAutoMockData(nameof(HeaderWithQueryWithManyMatrixCases), ShareFixture = false)]
[MemberAutoMockData(nameof(QueryWithManyCases), ShareFixture = false)]
public async Task CachesManyRequestSeparatelyAsyncTest(
string responseHeaderNameToAdd,
string queryParams,
[Frozen] IList<Element> responseElements,
[CustomizeWith(typeof(MemoryCacheMethodAutoFixture)), Frozen] MiddlewareContext context,
Expand All @@ -168,7 +188,6 @@ public async Task CachesManyRequestSeparatelyAsyncTest(
{
var options = CreateRequestOptions(queryParams);
context.Request.Options.Returns(options);
SetResponseHeader(response, responseHeaderNameToAdd, fixture.Create<string>());
SetResponseContent(response, responseElements);
response.CacheState.Returns(CacheState.FromLive);

Expand All @@ -190,6 +209,42 @@ public async Task CachesManyRequestSeparatelyAsyncTest(
}


[Theory]
[MemberAutoMockData(nameof(QueryWithManyCases), ShareFixture = false)]
public async Task CachesManyRequestSeparatelyHeaderAsyncTest(
string queryParams,
[Frozen] IList<Element> responseElements,
[CustomizeWith(typeof(MemoryCacheMethodAutoFixture)), Frozen] MiddlewareContext context,
[CustomizeWith(typeof(ExpiresHeaderAutoFixture)), Frozen] IWebApiResponse response,
IFixture fixture)
{
context.Request.Options.Returns(CreateRequestOptions(queryParams));
SetResponseContent(response, responseElements);
response.CacheState.Returns(CacheState.FromCache);

// Do the first request without specific headers
var middleware = new CacheMiddleware();
await middleware.OnRequestAsync(context, (r, t) => Task.FromResult(response));

// Repeat the request for every header to see if it's taken from live
foreach (var cacheHeader in HeadersToCacheSeparately)
{
context.Request.Options.RequestHeaders = new Dictionary<string, string>()
{
[cacheHeader] = fixture.Create<string>()
};

var nextMock = Substitute.For<Func<MiddlewareContext, CancellationToken, Task<IWebApiResponse>>>();
nextMock(Arg.Any<MiddlewareContext>(), Arg.Any<CancellationToken>()).Returns(x => Task.FromResult(response));
await middleware.OnRequestAsync(context, nextMock);

// Because the middleware passes the request to the next middleware if the response wasn't cached,
// we can check here if that call has arrived
await nextMock.Received(1)(Arg.Any<MiddlewareContext>(), Arg.Any<CancellationToken>());
}
}


#region Helpers

private static WebApiRequestOptions CreateRequestOptions(string queryParams = null)
Expand All @@ -209,17 +264,6 @@ private static void SetResponseContent<T>(IWebApiResponse response, T content)
response.Content.Returns(rawResponse);
}

private static void SetResponseHeader(IWebApiResponse response, string headerName, string headerValue)
{
if (string.IsNullOrEmpty(headerName))
return;

if (response.ResponseHeaders is Dictionary<string, string> dictionary)
dictionary[headerName] = headerValue;
else
response.ResponseHeaders.Returns(new Dictionary<string, string> { [headerName] = headerValue });
}

#endregion


Expand Down
36 changes: 19 additions & 17 deletions Gw2Sharp/WebApi/Middleware/CacheMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,7 @@ private static async Task<IWebApiResponse> OnEndpointRequestAsync(MiddlewareCont
RequestGetAsync(cacheCategory, cacheId, context, callNext, cancellationToken))
.ConfigureAwait(false);

var response = new WebApiResponse(cacheItem.StringItem, cacheItem.StatusCode, GetCacheState(cacheItem), cacheItem.Metadata);
return response;
return new WebApiResponse(cacheItem.StringItem, cacheItem.StatusCode, GetCacheState(cacheItem), cacheItem.Metadata);
}

private static async Task<IWebApiResponse> OnManyRequestAsync(MiddlewareContext context,
Expand All @@ -70,13 +69,13 @@ private static async Task<IWebApiResponse> OnManyRequestAsync(MiddlewareContext
CancellationToken cancellationToken)
{
string cacheCategory = context.Request.Options.EndpointPath;
var cacheIds = ids.ToDictionary(x => GetCacheId(context.Request, x), x => x);
var cacheItems = await context.Connection.CacheMethod
.GetOrUpdateManyAsync(cacheCategory, ids, (cacheCategory, missingIds) =>
RequestManyAsync(cacheCategory, missingIds, context, callNext, cancellationToken))
.GetOrUpdateManyAsync(cacheCategory, cacheIds.Keys, (cacheCategory, missingIds) =>
RequestManyAsync(cacheCategory, cacheIds.Where(x => missingIds.Contains(x.Key)).Select(x => x.Value), context, callNext, cancellationToken))
.ConfigureAwait(false);

var response = cacheItems.Select(x => new WebApiResponse(x.StringItem, x.StatusCode, GetCacheState(x), x.Metadata)).Merge();
return response;
return cacheItems.Select(x => new WebApiResponse(x.StringItem, x.StatusCode, GetCacheState(x), x.Metadata)).Merge();
}

private static async Task<IWebApiResponse> OnAllRequestAsync(MiddlewareContext context,
Expand All @@ -93,7 +92,7 @@ private static async Task<IWebApiResponse> OnAllRequestAsync(MiddlewareContext c
var response = new WebApiResponse(cacheItem.StringItem, cacheItem.StatusCode, GetCacheState(cacheItem), cacheItem.Metadata);

// Update individual items
var cacheItems = SplitResponseIntoIndividualCacheObjects(cacheCategory, response, context.Request.Options.BulkObjectIdName);
var cacheItems = SplitResponseIntoIndividualCacheObjects(cacheCategory, context.Request, response);
await context.Connection.CacheMethod.SetManyAsync(cacheItems).ConfigureAwait(false);

return response;
Expand All @@ -116,7 +115,7 @@ private static async Task<IWebApiResponse> OnPageRequestAsync(MiddlewareContext
var response = new WebApiResponse(cacheItem.StringItem, cacheItem.StatusCode, GetCacheState(cacheItem), cacheItem.Metadata);

// Update individual items
var cacheItems = SplitResponseIntoIndividualCacheObjects(cacheCategory, response, context.Request.Options.BulkObjectIdName);
var cacheItems = SplitResponseIntoIndividualCacheObjects(cacheCategory, context.Request, response);
await context.Connection.CacheMethod.SetManyAsync(cacheItems).ConfigureAwait(false);

return response;
Expand All @@ -131,23 +130,25 @@ private static async Task<CacheItem> RequestGetAsync(string cacheCategory,
var response = await callNext(context, cancellationToken).ConfigureAwait(false);
var responseInfo = new HttpResponseInfo(response.StatusCode, response.CacheState, response.ResponseHeaders.AsReadOnly());
return new CacheItem(cacheCategory, cacheId, response.Content, response.StatusCode,
responseInfo.Expires.GetValueOrDefault(DateTimeOffset.Now), CacheItemStatus.New, response.ResponseHeaders);
responseInfo.Expires ?? DateTimeOffset.Now, CacheItemStatus.New, response.ResponseHeaders);
}

private static async Task<IList<CacheItem>> RequestManyAsync(string cacheCategory,
IEnumerable<string> cacheIds,
IEnumerable<string> ids,
MiddlewareContext context,
Func<MiddlewareContext, CancellationToken, Task<IWebApiResponse>> callNext,
CancellationToken cancellationToken)
{
var newContext = new MiddlewareContext(context.Connection, context.Request.DeepCopy());
newContext.Request.Options.EndpointQuery[context.Request.Options.BulkQueryParameterIdsName] = string.Join(",", cacheIds);
newContext.Request.Options.EndpointQuery[context.Request.Options.BulkQueryParameterIdsName] = string.Join(",", ids);

var response = await callNext(newContext, cancellationToken).ConfigureAwait(false);
return SplitResponseIntoIndividualCacheObjects(cacheCategory, response, context.Request.Options.BulkObjectIdName);
return SplitResponseIntoIndividualCacheObjects(cacheCategory, context.Request, response);
}

private static IList<CacheItem> SplitResponseIntoIndividualCacheObjects(string cacheCategory, IWebApiResponse response, string bulkObjectPropertyIdName)
private static IList<CacheItem> SplitResponseIntoIndividualCacheObjects(string cacheCategory,
IWebApiRequest request,
IWebApiResponse response)
{
var responseInfo = new HttpResponseInfo(response.StatusCode, response.CacheState, response.ResponseHeaders.AsReadOnly());

Expand All @@ -156,13 +157,14 @@ private static IList<CacheItem> SplitResponseIntoIndividualCacheObjects(string c
using var doc = JsonDocument.Parse(response.Content);
foreach (var item in doc.RootElement.EnumerateArray())
{
if (item.TryGetProperty(bulkObjectPropertyIdName, out var id))
if (item.TryGetProperty(request.Options.BulkObjectIdName, out var id))
{
string? idString = id.ToString();
if (!(idString is null))
if (idString is not null)
{
items.Add(new CacheItem(cacheCategory, idString, item.GetRawText(), response.StatusCode,
responseInfo.Expires.GetValueOrDefault(DateTimeOffset.Now), CacheItemStatus.New, response.ResponseHeaders));
string cacheId = GetCacheId(request, idString);
items.Add(new CacheItem(cacheCategory, cacheId, item.GetRawText(), response.StatusCode,
responseInfo.Expires ?? DateTimeOffset.Now, CacheItemStatus.New, response.ResponseHeaders));
}
}
}
Expand Down

0 comments on commit 0cfbb50

Please sign in to comment.