mirror of https://github.com/Squidex/squidex.git
committed by
GitHub
38 changed files with 605 additions and 408 deletions
@ -0,0 +1,157 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Security.Cryptography; |
|||
using System.Text; |
|||
using System.Threading; |
|||
using Microsoft.AspNetCore.Http; |
|||
using Microsoft.Net.Http.Headers; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Caching; |
|||
using Squidex.Infrastructure.Log; |
|||
|
|||
namespace Squidex.Web.Pipeline |
|||
{ |
|||
public sealed class CachingManager : IRequestCache |
|||
{ |
|||
private readonly IHttpContextAccessor httpContextAccessor; |
|||
|
|||
internal sealed class CacheContext : IRequestCache, IDisposable |
|||
{ |
|||
private readonly IncrementalHash hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); |
|||
private readonly HashSet<string> keys = new HashSet<string>(); |
|||
private readonly ReaderWriterLockSlim slimLock = new ReaderWriterLockSlim(); |
|||
private bool hasDependency; |
|||
|
|||
public void Dispose() |
|||
{ |
|||
hasher.Dispose(); |
|||
|
|||
slimLock.Dispose(); |
|||
} |
|||
|
|||
public void AddDependency(Guid key, long version) |
|||
{ |
|||
if (key != default) |
|||
{ |
|||
try |
|||
{ |
|||
slimLock.EnterWriteLock(); |
|||
|
|||
keys.Add(key.ToString()); |
|||
|
|||
hasher.AppendData(key.ToByteArray()); |
|||
hasher.AppendData(BitConverter.GetBytes(version)); |
|||
|
|||
hasDependency = true; |
|||
} |
|||
finally |
|||
{ |
|||
slimLock.ExitWriteLock(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
public void AddDependency(object? value) |
|||
{ |
|||
if (value != null) |
|||
{ |
|||
try |
|||
{ |
|||
slimLock.EnterWriteLock(); |
|||
|
|||
var formatted = value.ToString(); |
|||
|
|||
if (formatted != null) |
|||
{ |
|||
hasher.AppendData(Encoding.Default.GetBytes(formatted)); |
|||
} |
|||
} |
|||
finally |
|||
{ |
|||
slimLock.ExitWriteLock(); |
|||
} |
|||
} |
|||
} |
|||
|
|||
public void Finish(HttpResponse response, int maxSurrogateKeys) |
|||
{ |
|||
if (hasDependency && !response.Headers.ContainsKey(HeaderNames.ETag)) |
|||
{ |
|||
using (Profiler.Trace("CalculateEtag")) |
|||
{ |
|||
var cacheBuffer = hasher.GetHashAndReset(); |
|||
var cacheString = BitConverter.ToString(cacheBuffer).Replace("-", string.Empty).ToUpperInvariant(); |
|||
|
|||
response.Headers.Add(HeaderNames.ETag, cacheString); |
|||
} |
|||
} |
|||
|
|||
if (keys.Count <= maxSurrogateKeys) |
|||
{ |
|||
var value = string.Join(" ", keys); |
|||
|
|||
response.Headers.Add("Surrogate-Key", value); |
|||
} |
|||
} |
|||
} |
|||
|
|||
public CachingManager(IHttpContextAccessor httpContextAccessor) |
|||
{ |
|||
Guard.NotNull(httpContextAccessor); |
|||
|
|||
this.httpContextAccessor = httpContextAccessor; |
|||
} |
|||
|
|||
public void Start(HttpContext httpContext) |
|||
{ |
|||
Guard.NotNull(httpContext); |
|||
|
|||
httpContext.Features.Set(new CacheContext()); |
|||
} |
|||
|
|||
public void AddDependency(Guid key, long version) |
|||
{ |
|||
if (httpContextAccessor.HttpContext != null) |
|||
{ |
|||
var cacheContext = httpContextAccessor.HttpContext.Features.Get<CacheContext>(); |
|||
|
|||
if (cacheContext != null) |
|||
{ |
|||
cacheContext.AddDependency(key, version); |
|||
} |
|||
} |
|||
} |
|||
|
|||
public void AddDependency(object? value) |
|||
{ |
|||
if (httpContextAccessor.HttpContext != null) |
|||
{ |
|||
var cacheContext = httpContextAccessor.HttpContext.Features.Get<CacheContext>(); |
|||
|
|||
if (cacheContext != null) |
|||
{ |
|||
cacheContext.AddDependency(value); |
|||
} |
|||
} |
|||
} |
|||
|
|||
public void Finish(HttpContext httpContext, int maxSurrogateKeys) |
|||
{ |
|||
Guard.NotNull(httpContext); |
|||
|
|||
var cacheContext = httpContextAccessor.HttpContext.Features.Get<CacheContext>(); |
|||
|
|||
if (cacheContext != null) |
|||
{ |
|||
cacheContext.Finish(httpContext.Response, maxSurrogateKeys); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -1,16 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Areas.Api.Controllers.Contents |
|||
{ |
|||
public sealed class MyContentsControllerOptions |
|||
{ |
|||
public bool EnableSurrogateKeys { get; set; } |
|||
|
|||
public int MaxItemsForSurrogateKeys { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,220 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using FakeItEasy; |
|||
using Microsoft.AspNetCore.Http; |
|||
using Microsoft.AspNetCore.Mvc; |
|||
using Microsoft.AspNetCore.Mvc.Abstractions; |
|||
using Microsoft.AspNetCore.Mvc.Filters; |
|||
using Microsoft.AspNetCore.Routing; |
|||
using Microsoft.Extensions.Options; |
|||
using Microsoft.Extensions.Primitives; |
|||
using Microsoft.Net.Http.Headers; |
|||
using Xunit; |
|||
|
|||
namespace Squidex.Web.Pipeline |
|||
{ |
|||
public class CachingFilterTests |
|||
{ |
|||
private readonly IHttpContextAccessor httpContextAccessor = A.Fake<IHttpContextAccessor>(); |
|||
private readonly HttpContext httpContext = new DefaultHttpContext(); |
|||
private readonly ActionExecutingContext executingContext; |
|||
private readonly ActionExecutedContext executedContext; |
|||
private readonly CachingOptions cachingOptions = new CachingOptions(); |
|||
private readonly CachingManager cachingManager; |
|||
private readonly CachingFilter sut; |
|||
|
|||
public CachingFilterTests() |
|||
{ |
|||
A.CallTo(() => httpContextAccessor.HttpContext) |
|||
.Returns(httpContext); |
|||
|
|||
cachingManager = new CachingManager(httpContextAccessor); |
|||
|
|||
var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); |
|||
var actionFilters = new List<IFilterMetadata>(); |
|||
|
|||
executingContext = new ActionExecutingContext(actionContext, actionFilters, new Dictionary<string, object>(), this); |
|||
executedContext = new ActionExecutedContext(actionContext, actionFilters, this) |
|||
{ |
|||
Result = new OkResult() |
|||
}; |
|||
|
|||
sut = new CachingFilter(cachingManager, Options.Create(cachingOptions)); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_not_append_etag_if_not_found() |
|||
{ |
|||
await sut.OnActionExecutionAsync(executingContext, Next()); |
|||
|
|||
Assert.Equal(StringValues.Empty, httpContext.Response.Headers[HeaderNames.ETag]); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_not_append_etag_if_empty() |
|||
{ |
|||
httpContext.Response.Headers[HeaderNames.ETag] = string.Empty; |
|||
|
|||
await sut.OnActionExecutionAsync(executingContext, Next()); |
|||
|
|||
Assert.Equal(string.Empty, httpContext.Response.Headers[HeaderNames.ETag]); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_not_convert_strong_etag_if_disabled() |
|||
{ |
|||
cachingOptions.StrongETag = true; |
|||
|
|||
httpContext.Response.Headers[HeaderNames.ETag] = "13"; |
|||
|
|||
await sut.OnActionExecutionAsync(executingContext, Next()); |
|||
|
|||
Assert.Equal("13", httpContext.Response.Headers[HeaderNames.ETag]); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_not_convert_already_weak_tag() |
|||
{ |
|||
httpContext.Response.Headers[HeaderNames.ETag] = "W/13"; |
|||
|
|||
await sut.OnActionExecutionAsync(executingContext, Next()); |
|||
|
|||
Assert.Equal("W/13", httpContext.Response.Headers[HeaderNames.ETag]); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_convert_strong_to_weak_tag() |
|||
{ |
|||
httpContext.Response.Headers[HeaderNames.ETag] = "13"; |
|||
|
|||
await sut.OnActionExecutionAsync(executingContext, Next()); |
|||
|
|||
Assert.Equal("W/13", httpContext.Response.Headers[HeaderNames.ETag]); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_not_convert_empty_string_to_weak_tag() |
|||
{ |
|||
httpContext.Response.Headers[HeaderNames.ETag] = string.Empty; |
|||
|
|||
await sut.OnActionExecutionAsync(executingContext, Next()); |
|||
|
|||
Assert.Equal(string.Empty, httpContext.Response.Headers[HeaderNames.ETag]); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_return_304_for_same_etags() |
|||
{ |
|||
httpContext.Request.Method = HttpMethods.Get; |
|||
httpContext.Request.Headers[HeaderNames.IfNoneMatch] = "W/13"; |
|||
|
|||
httpContext.Response.Headers[HeaderNames.ETag] = "13"; |
|||
|
|||
await sut.OnActionExecutionAsync(executingContext, Next()); |
|||
|
|||
Assert.Equal(304, ((StatusCodeResult)executedContext.Result).StatusCode); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_not_return_304_for_different_etags() |
|||
{ |
|||
httpContext.Request.Method = HttpMethods.Get; |
|||
httpContext.Request.Headers[HeaderNames.IfNoneMatch] = "W/11"; |
|||
|
|||
httpContext.Response.Headers[HeaderNames.ETag] = "13"; |
|||
|
|||
await sut.OnActionExecutionAsync(executingContext, Next()); |
|||
|
|||
Assert.Equal(200, ((StatusCodeResult)executedContext.Result).StatusCode); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_append_surrogate_keys() |
|||
{ |
|||
var id1 = Guid.NewGuid(); |
|||
var id2 = Guid.NewGuid(); |
|||
|
|||
cachingOptions.MaxSurrogateKeys = 2; |
|||
|
|||
await sut.OnActionExecutionAsync(executingContext, () => |
|||
{ |
|||
cachingManager.AddDependency(id1, 12); |
|||
cachingManager.AddDependency(id2, 12); |
|||
|
|||
return Task.FromResult(executedContext); |
|||
}); |
|||
|
|||
Assert.Equal($"{id1} {id2}", httpContext.Response.Headers["Surrogate-Key"]); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_not_append_surrogate_keys_if_maximum_is_exceeded() |
|||
{ |
|||
var id1 = Guid.NewGuid(); |
|||
var id2 = Guid.NewGuid(); |
|||
|
|||
cachingOptions.MaxSurrogateKeys = 1; |
|||
|
|||
await sut.OnActionExecutionAsync(executingContext, () => |
|||
{ |
|||
cachingManager.AddDependency(id1, 12); |
|||
cachingManager.AddDependency(id2, 12); |
|||
|
|||
return Task.FromResult(executedContext); |
|||
}); |
|||
|
|||
Assert.Equal(StringValues.Empty, httpContext.Response.Headers["Surrogate-Key"]); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_generate_etag_from_ids_and_versions() |
|||
{ |
|||
var id1 = Guid.NewGuid(); |
|||
var id2 = Guid.NewGuid(); |
|||
|
|||
await sut.OnActionExecutionAsync(executingContext, () => |
|||
{ |
|||
cachingManager.AddDependency(id1, 12); |
|||
cachingManager.AddDependency(id2, 12); |
|||
cachingManager.AddDependency(12); |
|||
|
|||
return Task.FromResult(executedContext); |
|||
}); |
|||
|
|||
Assert.True(httpContext.Response.Headers[HeaderNames.ETag].ToString().Length > 20); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_not_generate_etag_when_already_added() |
|||
{ |
|||
var id1 = Guid.NewGuid(); |
|||
var id2 = Guid.NewGuid(); |
|||
|
|||
await sut.OnActionExecutionAsync(executingContext, () => |
|||
{ |
|||
cachingManager.AddDependency(id1, 12); |
|||
cachingManager.AddDependency(id2, 12); |
|||
cachingManager.AddDependency(12); |
|||
|
|||
executedContext.HttpContext.Response.Headers[HeaderNames.ETag] = "W/20"; |
|||
|
|||
return Task.FromResult(executedContext); |
|||
}); |
|||
|
|||
Assert.Equal("W/20", httpContext.Response.Headers[HeaderNames.ETag]); |
|||
} |
|||
|
|||
private ActionExecutionDelegate Next() |
|||
{ |
|||
return () => Task.FromResult(executedContext); |
|||
} |
|||
} |
|||
} |
|||
@ -1,102 +0,0 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.AspNetCore.Http; |
|||
using Microsoft.AspNetCore.Mvc; |
|||
using Microsoft.AspNetCore.Mvc.Abstractions; |
|||
using Microsoft.AspNetCore.Mvc.Filters; |
|||
using Microsoft.AspNetCore.Routing; |
|||
using Microsoft.Extensions.Options; |
|||
using Microsoft.Net.Http.Headers; |
|||
using Xunit; |
|||
|
|||
namespace Squidex.Web.Pipeline |
|||
{ |
|||
public class ETagFilterTests |
|||
{ |
|||
private readonly HttpContext httpContext = new DefaultHttpContext(); |
|||
private readonly ActionExecutingContext executingContext; |
|||
private readonly ActionExecutedContext executedContext; |
|||
private readonly ETagFilter sut = new ETagFilter(Options.Create(new ETagOptions())); |
|||
|
|||
public ETagFilterTests() |
|||
{ |
|||
var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); |
|||
|
|||
var filters = new List<IFilterMetadata>(); |
|||
|
|||
executingContext = new ActionExecutingContext(actionContext, filters, new Dictionary<string, object>(), this); |
|||
executedContext = new ActionExecutedContext(actionContext, filters, this) |
|||
{ |
|||
Result = new OkResult() |
|||
}; |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_not_convert_already_weak_tag() |
|||
{ |
|||
httpContext.Response.Headers[HeaderNames.ETag] = "W/13"; |
|||
|
|||
await sut.OnActionExecutionAsync(executingContext, Next()); |
|||
|
|||
Assert.Equal("W/13", httpContext.Response.Headers[HeaderNames.ETag]); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_convert_strong_to_weak_tag() |
|||
{ |
|||
httpContext.Response.Headers[HeaderNames.ETag] = "13"; |
|||
|
|||
await sut.OnActionExecutionAsync(executingContext, Next()); |
|||
|
|||
Assert.Equal("W/13", httpContext.Response.Headers[HeaderNames.ETag]); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_not_convert_empty_string_to_weak_tag() |
|||
{ |
|||
httpContext.Response.Headers[HeaderNames.ETag] = string.Empty; |
|||
|
|||
await sut.OnActionExecutionAsync(executingContext, Next()); |
|||
|
|||
Assert.Equal(string.Empty, httpContext.Response.Headers[HeaderNames.ETag]); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_return_304_for_same_etags() |
|||
{ |
|||
httpContext.Request.Method = HttpMethods.Get; |
|||
httpContext.Request.Headers[HeaderNames.IfNoneMatch] = "W/13"; |
|||
|
|||
httpContext.Response.Headers[HeaderNames.ETag] = "13"; |
|||
|
|||
await sut.OnActionExecutionAsync(executingContext, Next()); |
|||
|
|||
Assert.Equal(304, ((StatusCodeResult)executedContext.Result).StatusCode); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_not_return_304_for_different_etags() |
|||
{ |
|||
httpContext.Request.Method = HttpMethods.Get; |
|||
httpContext.Request.Headers[HeaderNames.IfNoneMatch] = "W/11"; |
|||
|
|||
httpContext.Response.Headers[HeaderNames.ETag] = "13"; |
|||
|
|||
await sut.OnActionExecutionAsync(executingContext, Next()); |
|||
|
|||
Assert.Equal(200, ((StatusCodeResult)executedContext.Result).StatusCode); |
|||
} |
|||
|
|||
private ActionExecutionDelegate Next() |
|||
{ |
|||
return () => Task.FromResult(executedContext); |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue