Browse Source

Better cache dependency handling. (#483)

* Better cache dependency handling.

* Tests fixed.
pull/484/head
Sebastian Stehle 6 years ago
committed by GitHub
parent
commit
5becaf0360
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 11
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetEnricher.cs
  2. 3
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs
  3. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/IEnrichedContentEntity.cs
  4. 20
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichForCaching.cs
  5. 11
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveAssets.cs
  6. 15
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveReferences.cs
  7. 2
      backend/src/Squidex.Domain.Apps.Entities/Rules/IEnrichedRuleEntity.cs
  8. 14
      backend/src/Squidex.Domain.Apps.Entities/Rules/Queries/RuleEnricher.cs
  9. 3
      backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEntity.cs
  10. 12
      backend/src/Squidex.Infrastructure/Caching/IRequestCache.cs
  11. 2
      backend/src/Squidex.Web/CommandMiddlewares/ETagCommandMiddleware.cs
  12. 85
      backend/src/Squidex.Web/ETagExtensions.cs
  13. 15
      backend/src/Squidex.Web/Extensions.cs
  14. 17
      backend/src/Squidex.Web/Pipeline/ActionContextLogAppender.cs
  15. 22
      backend/src/Squidex.Web/Pipeline/CachingFilter.cs
  16. 157
      backend/src/Squidex.Web/Pipeline/CachingManager.cs
  17. 6
      backend/src/Squidex.Web/Pipeline/CachingOptions.cs
  18. 19
      backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs
  19. 1
      backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetQuery.cs
  20. 41
      backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  21. 14
      backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasOpenApiGenerator.cs
  22. 16
      backend/src/Squidex/Areas/Api/Controllers/Contents/MyContentsControllerOptions.cs
  23. 2
      backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs
  24. 7
      backend/src/Squidex/Config/Domain/InfrastructureServices.cs
  25. 6
      backend/src/Squidex/Config/Web/WebServices.cs
  26. 23
      backend/src/Squidex/appsettings.json
  27. 15
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetEnricherTests.cs
  28. 9
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherTests.cs
  29. 10
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ConvertDataTests.cs
  30. 22
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/EnrichForCachingTests.cs
  31. 11
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/EnrichWithSchemaTests.cs
  32. 8
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/EnrichWithWorkflowsTests.cs
  33. 16
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveAssetsTests.cs
  34. 30
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveReferencesTests.cs
  35. 38
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Queries/RuleEnricherTests.cs
  36. 220
      backend/tests/Squidex.Web.Tests/Pipeline/CachingFilterTests.cs
  37. 102
      backend/tests/Squidex.Web.Tests/Pipeline/ETagFilterTests.cs
  38. 6
      frontend/app/framework/angular/list-view.component.scss

11
backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetEnricher.cs

@ -11,6 +11,7 @@ using System.Text;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Tags;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Reflection;
@ -20,14 +21,17 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
{
private readonly ITagService tagService;
private readonly IEnumerable<IAssetMetadataSource> assetMetadataSources;
private readonly IRequestCache requestCache;
public AssetEnricher(ITagService tagService, IEnumerable<IAssetMetadataSource> assetMetadataSources)
public AssetEnricher(ITagService tagService, IEnumerable<IAssetMetadataSource> assetMetadataSources, IRequestCache requestCache)
{
Guard.NotNull(tagService);
Guard.NotNull(assetMetadataSources);
Guard.NotNull(requestCache);
this.tagService = tagService;
this.assetMetadataSources = assetMetadataSources;
this.requestCache = requestCache;
}
public async Task<IEnrichedAssetEntity> EnrichAsync(IAssetEntity asset, Context context)
@ -49,6 +53,11 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
{
var results = assets.Select(x => SimpleMapper.Map(x, new AssetEntity())).ToList();
foreach (var asset in results)
{
requestCache.AddDependency(asset.Id, asset.Version);
}
if (ShouldEnrich(context))
{
await EnrichTagsAsync(results);

3
backend/src/Squidex.Domain.Apps.Entities/Contents/ContentEntity.cs

@ -6,7 +6,6 @@
// ==========================================================================
using System;
using System.Collections.Generic;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.Schemas;
@ -55,7 +54,5 @@ namespace Squidex.Domain.Apps.Entities.Contents
public bool CanUpdate { get; set; }
public bool IsPending { get; set; }
public HashSet<object?> CacheDependencies { get; set; }
}
}

2
backend/src/Squidex.Domain.Apps.Entities/Contents/IEnrichedContentEntity.cs

@ -10,7 +10,7 @@ using Squidex.Domain.Apps.Core.Schemas;
namespace Squidex.Domain.Apps.Entities.Contents
{
public interface IEnrichedContentEntity : IContentEntity, IEntityWithCacheDependencies
public interface IEnrichedContentEntity : IContentEntity
{
bool CanUpdate { get; }

20
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/EnrichForCaching.cs

@ -8,11 +8,22 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
{
public sealed class EnrichForCaching : IContentEnricherStep
{
private readonly IRequestCache requestCache;
public EnrichForCaching(IRequestCache requestCache)
{
Guard.NotNull(requestCache);
this.requestCache = requestCache;
}
public async Task EnrichAsync(Context context, IEnumerable<ContentEntity> contents, ProvideSchema schemas)
{
var app = context.App;
@ -23,12 +34,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
foreach (var content in group)
{
content.CacheDependencies ??= new HashSet<object?>();
content.CacheDependencies.Add(app.Id);
content.CacheDependencies.Add(app.Version);
content.CacheDependencies.Add(schema.Id);
content.CacheDependencies.Add(schema.Version);
requestCache.AddDependency(content.Id, content.Version);
requestCache.AddDependency(app.Id, app.Version);
requestCache.AddDependency(schema.Id, schema.Version);
}
}
}

11
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveAssets.cs

@ -17,6 +17,7 @@ using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
@ -27,14 +28,17 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
private readonly IAssetUrlGenerator assetUrlGenerator;
private readonly IAssetQueryService assetQuery;
private readonly IRequestCache requestCache;
public ResolveAssets(IAssetUrlGenerator assetUrlGenerator, IAssetQueryService assetQuery)
public ResolveAssets(IAssetUrlGenerator assetUrlGenerator, IAssetQueryService assetQuery, IRequestCache requestCache)
{
Guard.NotNull(assetUrlGenerator);
Guard.NotNull(assetQuery);
Guard.NotNull(requestCache);
this.assetUrlGenerator = assetUrlGenerator;
this.assetQuery = assetQuery;
this.requestCache = requestCache;
}
public async Task EnrichAsync(Context context, IEnumerable<ContentEntity> contents, ProvideSchema schemas)
@ -88,10 +92,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
{
var url = assetUrlGenerator.GenerateUrl(referencedImage.Id.ToString());
content.CacheDependencies ??= new HashSet<object?>();
content.CacheDependencies.Add(referencedImage.Id);
content.CacheDependencies.Add(referencedImage.Version);
requestCache.AddDependency(referencedImage.Id, referencedImage.Version);
fieldReference.AddJsonValue(partitionKey, JsonValue.Create(url));
}

15
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/Steps/ResolveReferences.cs

@ -14,6 +14,7 @@ using Squidex.Domain.Apps.Core.ExtractReferenceIds;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.Json.Objects;
namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
@ -22,17 +23,21 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
{
private static readonly ILookup<Guid, IEnrichedContentEntity> EmptyContents = Enumerable.Empty<IEnrichedContentEntity>().ToLookup(x => x.Id);
private readonly Lazy<IContentQueryService> contentQuery;
private readonly IRequestCache requestCache;
private IContentQueryService ContentQuery
{
get { return contentQuery.Value; }
}
public ResolveReferences(Lazy<IContentQueryService> contentQuery)
public ResolveReferences(Lazy<IContentQueryService> contentQuery, IRequestCache requestCache)
{
Guard.NotNull(contentQuery);
Guard.NotNull(requestCache);
this.contentQuery = contentQuery;
this.requestCache = requestCache;
}
public async Task EnrichAsync(Context context, IEnumerable<ContentEntity> contents, ProvideSchema schemas)
@ -92,12 +97,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries.Steps
var referencedSchema = await schemas(reference.SchemaId.Id);
content.CacheDependencies ??= new HashSet<object?>();
content.CacheDependencies.Add(referencedSchema.Id);
content.CacheDependencies.Add(referencedSchema.Version);
content.CacheDependencies.Add(reference.Id);
content.CacheDependencies.Add(reference.Version);
requestCache.AddDependency(referencedSchema.Id, referencedSchema.Version);
requestCache.AddDependency(reference.Id, reference.Version);
var value = formatted.GetOrAdd(reference, x => Format(x, context, referencedSchema));

2
backend/src/Squidex.Domain.Apps.Entities/Rules/IEnrichedRuleEntity.cs

@ -9,7 +9,7 @@ using NodaTime;
namespace Squidex.Domain.Apps.Entities.Rules
{
public interface IEnrichedRuleEntity : IRuleEntity, IEntityWithCacheDependencies
public interface IEnrichedRuleEntity : IRuleEntity
{
int NumSucceeded { get; }

14
backend/src/Squidex.Domain.Apps.Entities/Rules/Queries/RuleEnricher.cs

@ -10,6 +10,7 @@ using System.Linq;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Entities.Rules.Repositories;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Reflection;
@ -18,12 +19,16 @@ namespace Squidex.Domain.Apps.Entities.Rules.Queries
public sealed class RuleEnricher : IRuleEnricher
{
private readonly IRuleEventRepository ruleEventRepository;
private readonly IRequestCache requestCache;
public RuleEnricher(IRuleEventRepository ruleEventRepository)
public RuleEnricher(IRuleEventRepository ruleEventRepository, IRequestCache requestCache)
{
Guard.NotNull(ruleEventRepository);
Guard.NotNull(requestCache);
this.ruleEventRepository = ruleEventRepository;
this.requestCache = requestCache;
}
public async Task<IEnrichedRuleEntity> EnrichAsync(IRuleEntity rule, Context context)
@ -57,6 +62,8 @@ namespace Squidex.Domain.Apps.Entities.Rules.Queries
foreach (var rule in group)
{
requestCache.AddDependency(rule.Id, rule.Version);
var statistic = statistics.FirstOrDefault(x => x.RuleId == rule.Id);
if (statistic != null)
@ -65,10 +72,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Queries
rule.NumFailed = statistic.NumFailed;
rule.NumSucceeded = statistic.NumSucceeded;
rule.CacheDependencies = new HashSet<object?>
{
statistic.LastExecuted
};
requestCache.AddDependency(rule.LastExecuted);
}
}
}

3
backend/src/Squidex.Domain.Apps.Entities/Rules/RuleEntity.cs

@ -6,7 +6,6 @@
// ==========================================================================
using System;
using System.Collections.Generic;
using NodaTime;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Infrastructure;
@ -40,7 +39,5 @@ namespace Squidex.Domain.Apps.Entities.Rules
public int NumFailed { get; set; }
public Instant? LastExecuted { get; set; }
public HashSet<object?> CacheDependencies { get; set; }
}
}

12
backend/src/Squidex.Domain.Apps.Entities/IEntityWithCacheDependencies.cs → backend/src/Squidex.Infrastructure/Caching/IRequestCache.cs

@ -5,12 +5,14 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System;
namespace Squidex.Domain.Apps.Entities
namespace Squidex.Infrastructure.Caching
{
public interface IEntityWithCacheDependencies
public interface IRequestCache
{
HashSet<object?> CacheDependencies { get; }
void AddDependency(Guid key, long version);
void AddDependency(object? value);
}
}
}

2
backend/src/Squidex.Web/CommandMiddlewares/ETagCommandMiddleware.cs

@ -71,7 +71,7 @@ namespace Squidex.Web.CommandMiddlewares
{
version = default;
if (httpContext.Request.Headers.TryGetHeaderString(HeaderNames.IfMatch, out var etag))
if (httpContext.Request.Headers.TryGetString(HeaderNames.IfMatch, out var etag))
{
if (etag.StartsWith("W/", StringComparison.OrdinalIgnoreCase))
{

85
backend/src/Squidex.Web/ETagExtensions.cs

@ -7,7 +7,7 @@
using System;
using System.Collections.Generic;
using System.Text;
using System.Security.Cryptography;
using Squidex.Domain.Apps.Entities;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
@ -16,95 +16,48 @@ namespace Squidex.Web
{
public static class ETagExtensions
{
private static readonly int GuidLength = Guid.Empty.ToString().Length;
public static string ToEtag<T>(this IReadOnlyList<T> items) where T : IEntity, IEntityWithVersion
{
using (Profiler.Trace("CalculateEtag"))
{
var unhashed = Unhashed(items, 0);
var hash = Create(items, 0);
return unhashed.Sha256Base64();
return hash;
}
}
public static string ToEtag<T>(this IResultList<T> items) where T : IEntity, IEntityWithVersion
public static string ToEtag<T>(this IResultList<T> entities) where T : IEntity, IEntityWithVersion
{
using (Profiler.Trace("CalculateEtag"))
{
var unhashed = Unhashed(items, items.Total);
return unhashed.Sha256Base64();
}
}
private static string Unhashed<T>(IReadOnlyList<T> items, long total) where T : IEntity, IEntityWithVersion
{
var sb = new StringBuilder();
foreach (var item in items)
{
AppendItem(item, sb);
var hash = Create(entities, entities.Total);
sb.Append(";");
return hash;
}
sb.Append(total);
return sb.ToString();
}
public static string ToSurrogateKey<T>(this T item) where T : IEntity
private static string Create<T>(IReadOnlyList<T> entities, long total) where T : IEntity, IEntityWithVersion
{
return item.Id.ToString();
}
public static string ToSurrogateKeys<T>(this IReadOnlyList<T> items) where T : IEntity
{
if (items.Count == 0)
using (var hasher = IncrementalHash.CreateHash(HashAlgorithmName.SHA256))
{
return string.Empty;
}
hasher.AppendData(BitConverter.GetBytes(total));
var sb = new StringBuilder(items.Count * (GuidLength + 1));
foreach (var item in entities)
{
hasher.AppendData(item.Id.ToByteArray());
hasher.AppendData(BitConverter.GetBytes(item.Version));
}
sb.Append(items[0].Id.ToString());
var cacheBuffer = hasher.GetHashAndReset();
var cacheString = BitConverter.ToString(cacheBuffer).Replace("-", string.Empty).ToUpperInvariant();
for (var i = 1; i < items.Count; i++)
{
sb.Append(" ");
sb.Append(items[i].Id.ToString());
return cacheString;
}
return sb.ToString();
}
public static string ToEtag<T>(this T item) where T : IEntity, IEntityWithVersion
{
var sb = new StringBuilder();
AppendItem(item, sb);
return sb.ToString();
}
private static void AppendItem<T>(T item, StringBuilder sb) where T : IEntity, IEntityWithVersion
public static string ToEtag<T>(this T entity) where T : IEntity, IEntityWithVersion
{
sb.Append(item.Id);
sb.Append(";");
sb.Append(item.Version);
if (item is IEntityWithCacheDependencies withDependencies)
{
if (withDependencies.CacheDependencies != null)
{
foreach (var dependency in withDependencies.CacheDependencies)
{
sb.Append(";");
sb.Append(dependency);
}
}
}
return entity.Version.ToString();
}
}
}

15
backend/src/Squidex.Web/Extensions.cs

@ -9,6 +9,7 @@ using System;
using System.Diagnostics.CodeAnalysis;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Primitives;
using Squidex.Infrastructure.Security;
namespace Squidex.Web
@ -46,21 +47,19 @@ namespace Squidex.Web
return string.Equals(subject, userId, StringComparison.OrdinalIgnoreCase);
}
public static bool TryGetHeaderString(this IHeaderDictionary headers, string header, [MaybeNullWhen(false)] out string result)
public static bool TryGetString(this IHeaderDictionary headers, string header, [MaybeNullWhen(false)] out string result)
{
if (headers.TryGetValue(header, out var value))
{
string valueString = value;
result = null!;
if (!string.IsNullOrWhiteSpace(valueString))
if (headers.TryGetValue(header, out var value) && value != StringValues.Empty)
{
if (!string.IsNullOrWhiteSpace(value))
{
result = valueString;
result = value;
return true;
}
}
result = null!;
return false;
}
}

17
backend/src/Squidex.Web/Pipeline/ActionContextLogAppender.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System;
using System.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Squidex.Infrastructure.Log;
@ -32,16 +33,7 @@ namespace Squidex.Web.Pipeline
return;
}
Guid requestId;
if (httpContext.Items.TryGetValue(nameof(requestId), out var requestIdvalue) && requestIdvalue is Guid requestIdValue)
{
requestId = requestIdValue;
}
else
{
httpContext.Items[nameof(requestId)] = requestId = Guid.NewGuid();
}
var requestId = GetRequestId(httpContext);
var logContext = (requestId, context: httpContext, actionContextAccessor);
@ -65,5 +57,10 @@ namespace Squidex.Web.Pipeline
}
});
}
private static string GetRequestId(HttpContext httpContext)
{
return Activity.Current?.Id ?? httpContext.TraceIdentifier ?? Guid.NewGuid().ToString();
}
}
}

22
backend/src/Squidex.Web/Pipeline/ETagFilter.cs → backend/src/Squidex.Web/Pipeline/CachingFilter.cs

@ -15,24 +15,30 @@ using Microsoft.Net.Http.Headers;
namespace Squidex.Web.Pipeline
{
public sealed class ETagFilter : IAsyncActionFilter
public sealed class CachingFilter : IAsyncActionFilter
{
private readonly ETagOptions options;
private readonly CachingOptions cachingOptions;
private readonly CachingManager cachingManager;
public ETagFilter(IOptions<ETagOptions> options)
public CachingFilter(CachingManager cachingManager, IOptions<CachingOptions> cachingOptions)
{
this.options = options.Value;
this.cachingOptions = cachingOptions.Value;
this.cachingManager = cachingManager;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var httpContext = context.HttpContext;
cachingManager.Start(httpContext);
var resultContext = await next();
var httpContext = context.HttpContext;
cachingManager.Finish(httpContext, cachingOptions.MaxSurrogateKeys);
if (httpContext.Response.Headers.TryGetHeaderString(HeaderNames.ETag, out var etag))
if (httpContext.Response.Headers.TryGetString(HeaderNames.ETag, out var etag))
{
if (!options.Strong && !etag.StartsWith("W/", StringComparison.OrdinalIgnoreCase))
if (!cachingOptions.StrongETag && !etag.StartsWith("W/", StringComparison.OrdinalIgnoreCase))
{
etag = $"W/{etag}";
@ -41,7 +47,7 @@ namespace Squidex.Web.Pipeline
if (HttpMethods.IsGet(httpContext.Request.Method) &&
httpContext.Response.StatusCode == 200 &&
httpContext.Request.Headers.TryGetHeaderString(HeaderNames.IfNoneMatch, out var noneMatch) &&
httpContext.Request.Headers.TryGetString(HeaderNames.IfNoneMatch, out var noneMatch) &&
string.Equals(etag, noneMatch, StringComparison.Ordinal))
{
resultContext.Result = new StatusCodeResult(304);

157
backend/src/Squidex.Web/Pipeline/CachingManager.cs

@ -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);
}
}
}
}

6
backend/src/Squidex.Web/Pipeline/ETagOptions.cs → backend/src/Squidex.Web/Pipeline/CachingOptions.cs

@ -7,8 +7,10 @@
namespace Squidex.Web.Pipeline
{
public sealed class ETagOptions
public sealed class CachingOptions
{
public bool Strong { get; set; }
public bool StrongETag { get; set; }
public int MaxSurrogateKeys { get; set; } = 200;
}
}

19
backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs

@ -10,10 +10,8 @@ using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using Squidex.Areas.Api.Controllers.Assets.Models;
using Squidex.Areas.Api.Controllers.Contents;
using Squidex.Domain.Apps.Core.Tags;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Apps.Services;
@ -36,7 +34,6 @@ namespace Squidex.Areas.Api.Controllers.Assets
private readonly IAssetQueryService assetQuery;
private readonly IAssetUsageTracker assetStatsRepository;
private readonly IAppPlansProvider appPlansProvider;
private readonly MyContentsControllerOptions controllerOptions;
private readonly ITagService tagService;
public AssetsController(
@ -44,14 +41,12 @@ namespace Squidex.Areas.Api.Controllers.Assets
IAssetQueryService assetQuery,
IAssetUsageTracker assetStatsRepository,
IAppPlansProvider appPlansProvider,
IOptions<MyContentsControllerOptions> controllerOptions,
ITagService tagService)
: base(commandBus)
{
this.assetQuery = assetQuery;
this.assetStatsRepository = assetStatsRepository;
this.appPlansProvider = appPlansProvider;
this.controllerOptions = controllerOptions.Value;
this.tagService = tagService;
}
@ -112,13 +107,6 @@ namespace Squidex.Areas.Api.Controllers.Assets
return AssetsDto.FromAssets(assets, this, app);
});
if (controllerOptions.EnableSurrogateKeys && assets.Count <= controllerOptions.MaxItemsForSurrogateKeys)
{
Response.Headers["Surrogate-Key"] = assets.ToSurrogateKeys();
}
Response.Headers[HeaderNames.ETag] = assets.ToEtag();
return Ok(response);
}
@ -150,13 +138,6 @@ namespace Squidex.Areas.Api.Controllers.Assets
return AssetDto.FromAsset(asset, this, app);
});
if (controllerOptions.EnableSurrogateKeys)
{
Response.Headers["Surrogate-Key"] = asset.ToSurrogateKey();
}
Response.Headers[HeaderNames.ETag] = asset.ToEtag();
return Ok(response);
}

1
backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetQuery.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Text;
using Microsoft.AspNetCore.Mvc;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Infrastructure;

41
backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs

@ -6,12 +6,9 @@
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using Squidex.Areas.Api.Controllers.Contents.Models;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities;
@ -26,7 +23,6 @@ namespace Squidex.Areas.Api.Controllers.Contents
{
public sealed class ContentsController : ApiController
{
private readonly MyContentsControllerOptions controllerOptions;
private readonly IContentQueryService contentQuery;
private readonly IContentWorkflow contentWorkflow;
private readonly IGraphQLService graphQl;
@ -34,13 +30,11 @@ namespace Squidex.Areas.Api.Controllers.Contents
public ContentsController(ICommandBus commandBus,
IContentQueryService contentQuery,
IContentWorkflow contentWorkflow,
IGraphQLService graphQl,
IOptions<MyContentsControllerOptions> controllerOptions)
IGraphQLService graphQl)
: base(commandBus)
{
this.contentQuery = contentQuery;
this.contentWorkflow = contentWorkflow;
this.controllerOptions = controllerOptions.Value;
this.graphQl = graphQl;
}
@ -133,13 +127,6 @@ namespace Squidex.Areas.Api.Controllers.Contents
return ContentsDto.FromContentsAsync(contents, Context, this, null, contentWorkflow);
});
if (ShouldProvideSurrogateKeys(contents))
{
Response.Headers["Surrogate-Key"] = contents.ToSurrogateKeys();
}
Response.Headers[HeaderNames.ETag] = contents.ToEtag();
return Ok(response);
}
@ -177,13 +164,6 @@ namespace Squidex.Areas.Api.Controllers.Contents
return await ContentsDto.FromContentsAsync(contents, Context, this, schema, contentWorkflow);
});
if (ShouldProvideSurrogateKeys(contents))
{
Response.Headers["Surrogate-Key"] = contents.ToSurrogateKeys();
}
Response.Headers[HeaderNames.ETag] = contents.ToEtag();
return Ok(response);
}
@ -211,13 +191,6 @@ namespace Squidex.Areas.Api.Controllers.Contents
var response = ContentDto.FromContent(Context, content, this);
if (controllerOptions.EnableSurrogateKeys)
{
Response.Headers["Surrogate-Key"] = content.ToSurrogateKey();
}
Response.Headers[HeaderNames.ETag] = content.ToEtag();
return Ok(response);
}
@ -246,13 +219,6 @@ namespace Squidex.Areas.Api.Controllers.Contents
var response = ContentDto.FromContent(Context, content, this);
if (controllerOptions.EnableSurrogateKeys)
{
Response.Headers["Surrogate-Key"] = content.ToSurrogateKey();
}
Response.Headers[HeaderNames.ETag] = content.ToEtag();
return Ok(response.Data);
}
@ -482,10 +448,5 @@ namespace Squidex.Areas.Api.Controllers.Contents
return response;
}
private bool ShouldProvideSurrogateKeys(IReadOnlyList<IContentEntity> response)
{
return controllerOptions.EnableSurrogateKeys && response.Count <= controllerOptions.MaxItemsForSurrogateKeys;
}
}
}

14
backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasOpenApiGenerator.cs

@ -21,6 +21,7 @@ using Squidex.Areas.Api.Controllers.Contents.Models;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
using Squidex.Pipeline.OpenApi;
namespace Squidex.Areas.Api.Controllers.Contents.Generator
@ -28,12 +29,13 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator
public sealed class SchemasOpenApiGenerator
{
private readonly OpenApiDocumentGeneratorSettings settings = new OpenApiDocumentGeneratorSettings();
private readonly IRequestCache requestCache;
private OpenApiSchemaGenerator schemaGenerator;
private OpenApiDocument document;
private JsonSchema statusSchema;
private JsonSchemaResolver schemaResolver;
public SchemasOpenApiGenerator(IEnumerable<IDocumentProcessor> documentProcessors)
public SchemasOpenApiGenerator(IEnumerable<IDocumentProcessor> documentProcessors, IRequestCache requestCache)
{
settings.ConfigureSchemaSettings();
@ -41,6 +43,8 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator
{
settings.DocumentProcessors.Add(processor);
}
this.requestCache = requestCache;
}
public OpenApiDocument Generate(HttpContext httpContext, IAppEntity app, IEnumerable<ISchemaEntity> schemas)
@ -79,13 +83,17 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator
private void GenerateSchemasOperations(IEnumerable<ISchemaEntity> schemas, IAppEntity app)
{
requestCache.AddDependency(app.Id, app.Version);
var appBasePath = $"/content/{app.Name}";
foreach (var schema in schemas.Select(x => x.SchemaDef).Where(x => x.IsPublished))
foreach (var schema in schemas.Where(x => x.SchemaDef.IsPublished))
{
requestCache.AddDependency(schema.Id, schema.Version);
var partition = app.PartitionResolver();
new SchemaOpenApiGenerator(document, app.Name, appBasePath, schema, AppendSchema, statusSchema, partition)
new SchemaOpenApiGenerator(document, app.Name, appBasePath, schema.SchemaDef, AppendSchema, statusSchema, partition)
.GenerateSchemaOperations();
}
}

16
backend/src/Squidex/Areas/Api/Controllers/Contents/MyContentsControllerOptions.cs

@ -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; }
}
}

2
backend/src/Squidex/Areas/Api/Controllers/Rules/RulesController.cs

@ -92,8 +92,6 @@ namespace Squidex.Areas.Api.Controllers.Rules
return RulesDto.FromRules(rules, this, app);
});
Response.Headers[HeaderNames.ETag] = rules.ToEtag();
return Ok(response);
}

7
backend/src/Squidex/Config/Domain/InfrastructureServices.cs

@ -11,7 +11,6 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using NodaTime;
using Orleans;
using Squidex.Areas.Api.Controllers.Contents;
using Squidex.Areas.Api.Controllers.Contents.Generator;
using Squidex.Areas.Api.Controllers.News;
using Squidex.Areas.Api.Controllers.News.Service;
@ -97,10 +96,8 @@ namespace Squidex.Config.Domain
{
services.Configure<RobotsTxtOptions>(
config.GetSection("robots"));
services.Configure<ETagOptions>(
config.GetSection("etags"));
services.Configure<MyContentsControllerOptions>(
config.GetSection("contentsController"));
services.Configure<CachingOptions>(
config.GetSection("caching"));
services.Configure<MyUIOptions>(
config.GetSection("ui"));
services.Configure<MyNewsOptions>(

6
backend/src/Squidex/Config/Web/WebServices.cs

@ -13,6 +13,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Squidex.Config.Domain;
using Squidex.Domain.Apps.Entities;
using Squidex.Infrastructure.Caching;
using Squidex.Pipeline.Plugins;
using Squidex.Pipeline.Robots;
using Squidex.Web;
@ -48,6 +49,9 @@ namespace Squidex.Config.Web
services.AddSingletonAs<RequestLogPerformanceMiddleware>()
.AsSelf();
services.AddSingletonAs<CachingManager>()
.As<IRequestCache>();
services.AddSingletonAs<ContextProvider>()
.As<IContextProvider>();
@ -64,7 +68,7 @@ namespace Squidex.Config.Web
services.AddMvc(options =>
{
options.Filters.Add<ETagFilter>();
options.Filters.Add<CachingFilter>();
options.Filters.Add<DeferredActionFilter>();
options.Filters.Add<AppResolver>();
options.Filters.Add<MeasureResultFilter>();

23
backend/src/Squidex/appsettings.json

@ -30,11 +30,16 @@
"Squidex.Extensions.dll"
],
"etags": {
"caching": {
/*
* Set to true, to use strong etags.
*/
"strong": false
"strongETag": false,
/*
* Restrict the surrogate keys to results that have less than 200 items.
*/
"maxItemsForSurrogateKeys": 200
},
"languages": {
@ -176,20 +181,6 @@
}
},
"contentsController": {
/*
* Enable surrogate keys as headers.
*
* Nginx Has problems with long headers. It might make sense to disable this feature if you do not use a CDN.
*/
"enableSurrogateKeys": true,
/*
* Restrict the surrogate keys to results that have less than 200 items.
*/
"maxItemsForSurrogateKeys": 200
},
"contents": {
/*
* The default page size if not specified by a query.

15
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/Queries/AssetEnricherTests.cs

@ -12,6 +12,7 @@ using FakeItEasy;
using Squidex.Domain.Apps.Core.Tags;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Assets.Queries
@ -19,6 +20,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
public class AssetEnricherTests
{
private readonly ITagService tagService = A.Fake<ITagService>();
private readonly IRequestCache requestCache = A.Fake<IRequestCache>();
private readonly IAssetMetadataSource assetMetadataSource1 = A.Fake<IAssetMetadataSource>();
private readonly IAssetMetadataSource assetMetadataSource2 = A.Fake<IAssetMetadataSource>();
private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app");
@ -33,7 +35,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
assetMetadataSource2
};
sut = new AssetEnricher(tagService, assetMetadataSources);
sut = new AssetEnricher(tagService, assetMetadataSources, requestCache);
}
[Fact]
@ -46,6 +48,17 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
Assert.Empty(result.TagNames);
}
[Fact]
public async Task Should_enrich_with_cache_dependencies()
{
var source = new AssetEntity { AppId = appId, Id = Guid.NewGuid(), Version = 13 };
var result = await sut.EnrichAsync(source, requestContext);
A.CallTo(() => requestCache.AddDependency(result.Id, result.Version))
.MustHaveHappened();
}
[Fact]
public async Task Should_enrich_asset_with_tag_names()
{

9
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherTests.cs

@ -10,7 +10,6 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
@ -71,7 +70,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
[Fact]
public async Task Should_invoke_steps()
{
var source = PublishedContent();
var source = CreateContent();
var step1 = A.Fake<IContentEnricherStep>();
var step2 = A.Fake<IContentEnricherStep>();
@ -90,7 +89,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
[Fact]
public async Task Should_provide_and_cache_schema()
{
var source = PublishedContent();
var source = CreateContent();
var step1 = new ResolveSchema();
var step2 = new ResolveSchema();
@ -106,9 +105,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
.MustHaveHappenedOnceExactly();
}
private ContentEntity PublishedContent()
private ContentEntity CreateContent()
{
return new ContentEntity { Status = Status.Published, SchemaId = schemaId };
return new ContentEntity { SchemaId = schemaId };
}
}
}

10
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ConvertDataTests.cs

@ -57,7 +57,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
[Fact]
public async Task Should_convert_data_only()
{
var content = PublishedContent(new NamedContentData());
var content = CreateContent(new NamedContentData());
await sut.EnrichAsync(requestContext, Enumerable.Repeat(content, 1), schemaProvider);
@ -68,7 +68,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
[Fact]
public async Task Should_convert_data_and_data_draft_when_frontend_user()
{
var content = PublishedContent(new NamedContentData());
var content = CreateContent(new NamedContentData());
var ctx = new Context(Mocks.FrontendUser(), Mocks.App(appId));
@ -86,7 +86,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
var source = BuildTestData(id1, id2);
var content = PublishedContent(source);
var content = CreateContent(source);
var expected =
new NamedContentData()
@ -125,7 +125,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
var source = BuildTestData(id1, id2);
var content = PublishedContent(source);
var content = CreateContent(source);
var expected =
new NamedContentData()
@ -173,7 +173,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
.Add("nested", JsonValue.Array(id1, id2)))));
}
private ContentEntity PublishedContent(NamedContentData data)
private ContentEntity CreateContent(NamedContentData data)
{
return new ContentEntity
{

22
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/EnrichForCachingTests.cs

@ -8,11 +8,12 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using FakeItEasy;
using Squidex.Domain.Apps.Entities.Contents.Queries.Steps;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents.Queries
@ -20,6 +21,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
public class EnrichForCachingTests
{
private readonly ISchemaEntity schema;
private readonly IRequestCache requestCache = A.Fake<IRequestCache>();
private readonly Context requestContext;
private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app");
private readonly NamedId<Guid> schemaId = NamedId.Of(Guid.NewGuid(), "my-schema");
@ -33,25 +35,29 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
schema = Mocks.Schema(appId, schemaId);
schemaProvider = x => Task.FromResult(schema);
sut = new EnrichForCaching();
sut = new EnrichForCaching(requestCache);
}
[Fact]
public async Task Should_add_app_version_and_schema_as_dependency()
{
var content = PublishedContent();
var content = CreateContent();
await sut.EnrichAsync(requestContext, Enumerable.Repeat(content, 1), schemaProvider);
Assert.Contains(requestContext.App.Version, content.CacheDependencies);
A.CallTo(() => requestCache.AddDependency(content.Id, content.Version))
.MustHaveHappened();
Assert.Contains(schema.Id, content.CacheDependencies);
Assert.Contains(schema.Version, content.CacheDependencies);
A.CallTo(() => requestCache.AddDependency(schema.Id, schema.Version))
.MustHaveHappened();
A.CallTo(() => requestCache.AddDependency(requestContext.App.Id, requestContext.App.Version))
.MustHaveHappened();
}
private ContentEntity PublishedContent()
private ContentEntity CreateContent()
{
return new ContentEntity { Status = Status.Published, SchemaId = schemaId };
return new ContentEntity { Id = Guid.NewGuid(), SchemaId = schemaId, Version = 13 };
}
}
}

11
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/EnrichWithSchemaTests.cs

@ -8,7 +8,6 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Entities.Contents.Queries.Steps;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.TestHelpers;
@ -39,7 +38,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
[Fact]
public async Task Should_enrich_with_reference_fields()
{
var content = PublishedContent();
var content = CreateContent();
var ctx = new Context(Mocks.FrontendUser(), requestContext.App);
@ -51,7 +50,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
[Fact]
public async Task Should_not_enrich_with_reference_fields_when_not_frontend()
{
var source = PublishedContent();
var source = CreateContent();
await sut.EnrichAsync(requestContext, Enumerable.Repeat(source, 1), schemaProvider);
@ -61,7 +60,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
[Fact]
public async Task Should_enrich_with_schema_names()
{
var content = PublishedContent();
var content = CreateContent();
await sut.EnrichAsync(requestContext, Enumerable.Repeat(content, 1), schemaProvider);
@ -69,9 +68,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
Assert.Equal("my-schema", content.SchemaDisplayName);
}
private ContentEntity PublishedContent()
private ContentEntity CreateContent()
{
return new ContentEntity { Status = Status.Published, SchemaId = schemaId };
return new ContentEntity { SchemaId = schemaId };
}
}
}

8
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/EnrichWithWorkflowsTests.cs

@ -44,7 +44,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
[Fact]
public async Task Should_enrich_content_with_status_color()
{
var content = PublishedContent();
var content = CreateContent();
A.CallTo(() => contentWorkflow.GetInfoAsync(content))
.Returns(new StatusInfo(Status.Published, StatusColors.Published));
@ -57,7 +57,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
[Fact]
public async Task Should_enrich_content_with_default_color_if_not_found()
{
var content = PublishedContent();
var content = CreateContent();
A.CallTo(() => contentWorkflow.GetInfoAsync(content))
.Returns(Task.FromResult<StatusInfo>(null!));
@ -101,9 +101,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
.MustNotHaveHappened();
}
private ContentEntity PublishedContent()
private ContentEntity CreateContent()
{
return new ContentEntity { Status = Status.Published, SchemaId = schemaId };
return new ContentEntity { SchemaId = schemaId };
}
}
}

16
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveAssetsTests.cs

@ -19,6 +19,7 @@ using Squidex.Domain.Apps.Entities.Contents.Queries.Steps;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.Json.Objects;
using Xunit;
@ -28,6 +29,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
{
private readonly IAssetQueryService assetQuery = A.Fake<IAssetQueryService>();
private readonly IAssetUrlGenerator assetUrlGenerator = A.Fake<IAssetUrlGenerator>();
private readonly IRequestCache requestCache = A.Fake<IRequestCache>();
private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app");
private readonly NamedId<Guid> schemaId = NamedId.Of(Guid.NewGuid(), "my-schema");
private readonly ProvideSchema schemaProvider;
@ -69,7 +71,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
}
};
sut = new ResolveAssets(assetUrlGenerator, assetQuery);
sut = new ResolveAssets(assetUrlGenerator, assetQuery, requestCache);
}
[Fact]
@ -96,15 +98,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
await sut.EnrichAsync(requestContext, contents, schemaProvider);
var enriched1 = contents[0];
A.CallTo(() => requestCache.AddDependency(image1.Id, image1.Version))
.MustHaveHappened();
Assert.Contains(image1.Id, enriched1.CacheDependencies);
Assert.Contains(image1.Version, enriched1.CacheDependencies);
var enriched2 = contents[1];
Assert.Contains(image2.Id, enriched2.CacheDependencies);
Assert.Contains(image2.Version, enriched2.CacheDependencies);
A.CallTo(() => requestCache.AddDependency(image2.Id, image2.Version))
.MustHaveHappened();
}
[Fact]

30
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ResolveReferencesTests.cs

@ -17,6 +17,7 @@ using Squidex.Domain.Apps.Entities.Contents.Queries.Steps;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.Json.Objects;
using Xunit;
@ -25,6 +26,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
public class ResolveReferencesTests
{
private readonly IContentQueryService contentQuery = A.Fake<IContentQueryService>();
private readonly IRequestCache requestCache = A.Fake<IRequestCache>();
private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app");
private readonly NamedId<Guid> refSchemaId1 = NamedId.Of(Guid.NewGuid(), "my-ref1");
private readonly NamedId<Guid> refSchemaId2 = NamedId.Of(Guid.NewGuid(), "my-ref2");
@ -83,7 +85,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
}
};
sut = new ResolveReferences(new Lazy<IContentQueryService>(() => contentQuery));
sut = new ResolveReferences(new Lazy<IContentQueryService>(() => contentQuery), requestCache);
}
[Fact]
@ -107,25 +109,23 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
var enriched1 = contents[0];
Assert.Contains(refSchemaId1.Id, enriched1.CacheDependencies);
Assert.Contains(refSchemaId2.Id, enriched1.CacheDependencies);
A.CallTo(() => requestCache.AddDependency(refSchemaId1.Id, 0))
.MustHaveHappened();
Assert.Contains(ref1_1.Id, enriched1.CacheDependencies);
Assert.Contains(ref1_1.Version, enriched1.CacheDependencies);
A.CallTo(() => requestCache.AddDependency(refSchemaId2.Id, 0))
.MustHaveHappened();
Assert.Contains(ref2_1.Id, enriched1.CacheDependencies);
Assert.Contains(ref2_1.Version, enriched1.CacheDependencies);
A.CallTo(() => requestCache.AddDependency(ref1_1.Id, ref1_1.Version))
.MustHaveHappened();
var enriched2 = contents[1];
A.CallTo(() => requestCache.AddDependency(ref2_1.Id, ref2_1.Version))
.MustHaveHappened();
Assert.Contains(refSchemaId1.Id, enriched2.CacheDependencies);
Assert.Contains(refSchemaId2.Id, enriched2.CacheDependencies);
A.CallTo(() => requestCache.AddDependency(ref1_2.Id, ref1_2.Version))
.MustHaveHappened();
Assert.Contains(ref1_2.Id, enriched2.CacheDependencies);
Assert.Contains(ref1_2.Version, enriched2.CacheDependencies);
Assert.Contains(ref2_2.Id, enriched2.CacheDependencies);
Assert.Contains(ref2_2.Version, enriched2.CacheDependencies);
A.CallTo(() => requestCache.AddDependency(ref2_2.Id, ref2_2.Version))
.MustHaveHappened();
}
[Fact]

38
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Queries/RuleEnricherTests.cs

@ -7,12 +7,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FakeItEasy;
using NodaTime;
using Squidex.Domain.Apps.Entities.Rules.Repositories;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Rules.Queries
@ -20,36 +20,43 @@ namespace Squidex.Domain.Apps.Entities.Rules.Queries
public class RuleEnricherTests
{
private readonly IRuleEventRepository ruleEventRepository = A.Fake<IRuleEventRepository>();
private readonly IRequestCache requestCache = A.Fake<IRequestCache>();
private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app");
private readonly Context requestContext = Context.Anonymous();
private readonly RuleEnricher sut;
public RuleEnricherTests()
{
sut = new RuleEnricher(ruleEventRepository);
sut = new RuleEnricher(ruleEventRepository, requestCache);
}
[Fact]
public async Task Should_not_enrich_if_statistics_not_found()
{
var source = new RuleEntity { AppId = appId };
var source = CreateRule();
var result = await sut.EnrichAsync(source, requestContext);
Assert.Equal(0, result.NumFailed);
Assert.Equal(0, result.NumSucceeded);
Assert.Null(result.LastExecuted);
A.CallTo(() => requestCache.AddDependency(source.Id, source.Version))
.MustHaveHappened();
A.CallTo(() => requestCache.AddDependency(null))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_enrich_rules_with_found_statistics()
{
var source1 = new RuleEntity { AppId = appId, Id = Guid.NewGuid() };
var source2 = new RuleEntity { AppId = appId, Id = Guid.NewGuid() };
var source = CreateRule();
var stats = new RuleStatistics
{
RuleId = source1.Id,
RuleId = source.Id,
NumFailed = 12,
NumSucceeded = 17,
LastExecuted = SystemClock.Instance.GetCurrentInstant()
@ -58,19 +65,18 @@ namespace Squidex.Domain.Apps.Entities.Rules.Queries
A.CallTo(() => ruleEventRepository.QueryStatisticsByAppAsync(appId.Id))
.Returns(new List<RuleStatistics> { stats });
var result = await sut.EnrichAsync(new[] { source1, source2 }, requestContext);
var enriched1 = result.ElementAt(0);
var result = await sut.EnrichAsync(source, requestContext);
Assert.Equal(12, enriched1.NumFailed);
Assert.Equal(17, enriched1.NumSucceeded);
Assert.Equal(stats.LastExecuted, enriched1.LastExecuted);
A.CallTo(() => requestCache.AddDependency(source.Id, source.Version))
.MustHaveHappened();
var enriched2 = result.ElementAt(1);
A.CallTo(() => requestCache.AddDependency(stats.LastExecuted))
.MustHaveHappened();
}
Assert.Equal(0, enriched2.NumFailed);
Assert.Equal(0, enriched2.NumSucceeded);
Assert.Null(enriched2.LastExecuted);
private RuleEntity CreateRule()
{
return new RuleEntity { AppId = appId, Id = Guid.NewGuid(), Version = 13 };
}
}
}

220
backend/tests/Squidex.Web.Tests/Pipeline/CachingFilterTests.cs

@ -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);
}
}
}

102
backend/tests/Squidex.Web.Tests/Pipeline/ETagFilterTests.cs

@ -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);
}
}
}

6
frontend/app/framework/angular/list-view.component.scss

@ -75,8 +75,10 @@
flex-shrink: 0;
}
::ng-deep .pagination {
margin: .25rem 0;
::ng-deep {
.pagination {
margin: .25rem 0;
}
}
}
}

Loading…
Cancel
Save