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