Browse Source

Feature/replicated cache (#543)

* Performance improvements.
pull/544/head
Sebastian Stehle 6 years ago
committed by GitHub
parent
commit
50747db360
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      .drone.yml
  2. 2
      backend/src/Migrations/Migrations/AddPatterns.cs
  3. 4
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs
  4. 4
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionPublished.cs
  5. 24
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs
  6. 8
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByIds.cs
  7. 2
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryIdsAsync.cs
  8. 111
      backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs
  9. 103
      backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsIndex.cs
  10. 4
      backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsIndex.cs
  11. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/DefaultWorkflowsValidator.cs
  12. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs
  13. 6
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs
  14. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs
  15. 10
      backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs
  16. 2
      backend/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleTriggerValidator.cs
  17. 30
      backend/src/Squidex.Domain.Apps.Entities/Rules/Indexes/RulesIndex.cs
  18. 6
      backend/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/ISchemasIndex.cs
  19. 93
      backend/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasIndex.cs
  20. 18
      backend/src/Squidex.Infrastructure/Caching/IPubSub.cs
  21. 13
      backend/src/Squidex.Infrastructure/Caching/IPubSubSubscription.cs
  22. 20
      backend/src/Squidex.Infrastructure/Caching/IReplicatedCache.cs
  23. 29
      backend/src/Squidex.Infrastructure/Caching/LocalCacheExtensions.cs
  24. 72
      backend/src/Squidex.Infrastructure/Caching/ReplicatedCache.cs
  25. 32
      backend/src/Squidex.Infrastructure/Caching/SimplePubSub.cs
  26. 19
      backend/src/Squidex.Infrastructure/Orleans/IPubSubGrain.cs
  27. 19
      backend/src/Squidex.Infrastructure/Orleans/IPubSubGrainObserver.cs
  28. 79
      backend/src/Squidex.Infrastructure/Orleans/OrleansPubSub.cs
  29. 42
      backend/src/Squidex.Infrastructure/Orleans/OrleansPubSubGrain.cs
  30. 4
      backend/src/Squidex.Infrastructure/Security/Permission.Part.cs
  31. 70
      backend/src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs
  32. 4
      backend/src/Squidex.Web/Pipeline/AppResolver.cs
  33. 13
      backend/src/Squidex.Web/Pipeline/SchemaResolver.cs
  34. 2
      backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs
  35. 2
      backend/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs
  36. 6
      backend/src/Squidex/Config/Domain/InfrastructureServices.cs
  37. 1
      backend/src/Squidex/Program.cs
  38. 104
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsIndexTests.cs
  39. 4
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs
  40. 4
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultWorkflowsValidatorTests.cs
  41. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs
  42. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs
  43. 8
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs
  44. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferenceFluidExtensionTests.cs
  45. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/GuardRuleTests.cs
  46. 6
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/Triggers/ContentChangedTriggerTests.cs
  47. 12
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesIndexTests.cs
  48. 113
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasIndexTests.cs
  49. 36
      backend/tests/Squidex.Infrastructure.Tests/Caching/AsyncLocalCacheTests.cs
  50. 100
      backend/tests/Squidex.Infrastructure.Tests/Caching/ReplicatedCacheTests.cs
  51. 79
      backend/tests/Squidex.Infrastructure.Tests/Orleans/PubSubTests.cs
  52. 1
      backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj
  53. 77
      backend/tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithSchemaIdCommandMiddlewareTests.cs
  54. 37
      backend/tests/Squidex.Web.Tests/Pipeline/AppResolverTests.cs
  55. 60
      backend/tests/Squidex.Web.Tests/Pipeline/SchemaResolverTests.cs
  56. 1
      backend/tools/k6/docker-compose.yml
  57. 13
      backend/tools/k6/get-clients.js
  58. 33
      backend/tools/k6/get-content.js
  59. 46
      backend/tools/k6/shared.js
  60. 23
      backend/tools/k6/test.js

4
.drone.yml

@ -18,6 +18,7 @@ steps:
- pull_request - pull_request
branch: branch:
- master - master
- release/*
- name: build_dev - name: build_dev
image: docker image: docker
@ -48,6 +49,7 @@ steps:
- pull_request - pull_request
branch: branch:
- master - master
- release/*
- name: test_run - name: test_run
image: mcr.microsoft.com/dotnet/core/sdk:3.1-buster image: mcr.microsoft.com/dotnet/core/sdk:3.1-buster
@ -63,6 +65,7 @@ steps:
- pull_request - pull_request
branch: branch:
- master - master
- release/*
- name: test_cleanup - name: test_cleanup
image: docker/compose image: docker/compose
@ -83,6 +86,7 @@ steps:
- pull_request - pull_request
branch: branch:
- master - master
- release/*
- name: push_dev - name: push_dev
image: docker image: docker

2
backend/src/Migrations/Migrations/AddPatterns.cs

@ -34,7 +34,7 @@ namespace Migrations.Migrations
foreach (var id in ids) foreach (var id in ids)
{ {
var app = await indexForApps.GetAppAsync(id); var app = await indexForApps.GetAppAsync(id, false);
if (app != null && app.Patterns.Count == 0) if (app != null && app.Patterns.Count == 0)
{ {

4
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs

@ -75,7 +75,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByIds")) using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByIds"))
{ {
var result = await queryContentsById.DoAsync(app.Id, schema, ids); var result = await queryContentsById.DoAsync(app.Id, schema, ids, false);
return ResultList.Create(result.Count, result.Select(x => x.Content)); return ResultList.Create(result.Count, result.Select(x => x.Content));
} }
@ -87,7 +87,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByIdsWithoutSchema")) using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByIdsWithoutSchema"))
{ {
var result = await queryContentsById.DoAsync(app.Id, null, ids); var result = await queryContentsById.DoAsync(app.Id, null, ids, false);
return result; return result;
} }

4
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionPublished.cs

@ -79,7 +79,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByIds")) using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByIds"))
{ {
var result = await queryContentsById.DoAsync(app.Id, schema, ids); var result = await queryContentsById.DoAsync(app.Id, schema, ids, true);
return ResultList.Create(result.Count, result.Select(x => x.Content)); return ResultList.Create(result.Count, result.Select(x => x.Content));
} }
@ -91,7 +91,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByIdsWithoutSchema")) using (Profiler.TraceMethod<MongoContentRepository>("QueryAsyncByIdsWithoutSchema"))
{ {
var result = await queryContentsById.DoAsync(app.Id, null, ids); var result = await queryContentsById.DoAsync(app.Id, null, ids, true);
return result; return result;
} }

24
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs

@ -53,6 +53,11 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
{ {
var schema = await GetSchemaAsync(contentEntity.IndexedAppId, contentEntity.IndexedSchemaId); var schema = await GetSchemaAsync(contentEntity.IndexedAppId, contentEntity.IndexedSchemaId);
if (schema == null)
{
return (null!, EtagVersion.NotFound);
}
contentEntity.ParseData(schema.SchemaDef, converter); contentEntity.ParseData(schema.SchemaDef, converter);
return (SimpleMapper.Map(contentEntity, new ContentState()), contentEntity.Version); return (SimpleMapper.Map(contentEntity, new ContentState()), contentEntity.Version);
@ -73,9 +78,15 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
var schema = await GetSchemaAsync(value.AppId.Id, value.SchemaId.Id); var schema = await GetSchemaAsync(value.AppId.Id, value.SchemaId.Id);
await Task.WhenAll( if (schema == null)
UpsertDraftContentAsync(value, oldVersion, newVersion, schema), {
UpsertOrDeletePublishedAsync(value, oldVersion, newVersion, schema)); return;
}
var saveDraft = UpsertDraftContentAsync(value, oldVersion, newVersion, schema);
var savePublic = UpsertOrDeletePublishedAsync(value, oldVersion, newVersion, schema);
await Task.WhenAll(saveDraft, savePublic);
} }
} }
@ -132,15 +143,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents
await collectionPublished.UpsertVersionedAsync(content.Id, oldVersion, content); await collectionPublished.UpsertVersionedAsync(content.Id, oldVersion, content);
} }
private async Task<ISchemaEntity> GetSchemaAsync(Guid appId, Guid schemaId) private async Task<ISchemaEntity?> GetSchemaAsync(Guid appId, Guid schemaId)
{ {
var schema = await appProvider.GetSchemaAsync(appId, schemaId, true); var schema = await appProvider.GetSchemaAsync(appId, schemaId, true);
if (schema == null)
{
throw new DomainObjectNotFoundException(schemaId.ToString(), typeof(ISchemaEntity));
}
return schema; return schema;
} }
} }

8
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByIds.cs

@ -28,14 +28,14 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
this.appProvider = appProvider; this.appProvider = appProvider;
} }
public async Task<List<(IContentEntity Content, ISchemaEntity Schema)>> DoAsync(Guid appId, ISchemaEntity? schema, HashSet<Guid> ids) public async Task<List<(IContentEntity Content, ISchemaEntity Schema)>> DoAsync(Guid appId, ISchemaEntity? schema, HashSet<Guid> ids, bool canCache)
{ {
Guard.NotNull(ids, nameof(ids)); Guard.NotNull(ids, nameof(ids));
var find = Collection.Find(CreateFilter(appId, ids)); var find = Collection.Find(CreateFilter(appId, ids));
var contentItems = await find.ToListAsync(); var contentItems = await find.ToListAsync();
var contentSchemas = await GetSchemasAsync(appId, schema, contentItems); var contentSchemas = await GetSchemasAsync(appId, schema, contentItems, canCache);
var result = new List<(IContentEntity Content, ISchemaEntity Schema)>(); var result = new List<(IContentEntity Content, ISchemaEntity Schema)>();
@ -52,7 +52,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
return result; return result;
} }
private async Task<IDictionary<Guid, ISchemaEntity>> GetSchemasAsync(Guid appId, ISchemaEntity? schema, List<MongoContentEntity> contentItems) private async Task<IDictionary<Guid, ISchemaEntity>> GetSchemasAsync(Guid appId, ISchemaEntity? schema, List<MongoContentEntity> contentItems, bool canCache)
{ {
var schemas = new Dictionary<Guid, ISchemaEntity>(); var schemas = new Dictionary<Guid, ISchemaEntity>();
@ -67,7 +67,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
{ {
if (!schemas.ContainsKey(schemaId)) if (!schemas.ContainsKey(schemaId))
{ {
var found = await appProvider.GetSchemaAsync(appId, schemaId); var found = await appProvider.GetSchemaAsync(appId, schemaId, false, canCache);
if (found != null) if (found != null)
{ {

2
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryIdsAsync.cs

@ -57,7 +57,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations
public async Task<IReadOnlyList<(Guid SchemaId, Guid Id)>> DoAsync(Guid appId, Guid schemaId, FilterNode<ClrValue> filterNode) public async Task<IReadOnlyList<(Guid SchemaId, Guid Id)>> DoAsync(Guid appId, Guid schemaId, FilterNode<ClrValue> filterNode)
{ {
var schema = await appProvider.GetSchemaAsync(appId, schemaId); var schema = await appProvider.GetSchemaAsync(appId, schemaId, false);
if (schema == null) if (schema == null)
{ {

111
backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs

@ -7,6 +7,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Apps.Indexes; using Squidex.Domain.Apps.Entities.Apps.Indexes;
@ -40,24 +41,16 @@ namespace Squidex.Domain.Apps.Entities
this.indexSchemas = indexSchemas; this.indexSchemas = indexSchemas;
} }
public Task<(IAppEntity?, ISchemaEntity?)> GetAppWithSchemaAsync(Guid appId, Guid id) public async Task<(IAppEntity?, ISchemaEntity?)> GetAppWithSchemaAsync(Guid appId, Guid id, bool canCache = false)
{ {
return localCache.GetOrCreateAsync($"GetAppWithSchemaAsync({appId}, {id})", async () => var app = await GetAppAsync(appId, canCache);
{
return await GetAppWithSchemaUncachedAsync(appId, id);
});
}
private async Task<(IAppEntity?, ISchemaEntity?)> GetAppWithSchemaUncachedAsync(Guid appId, Guid id)
{
var app = await GetAppAsync(appId);
if (app == null) if (app == null)
{ {
return (null, null); return (null, null);
} }
var schema = await GetSchemaAsync(appId, id, false); var schema = await GetSchemaAsync(appId, id, false, canCache);
if (schema == null) if (schema == null)
{ {
@ -67,60 +60,114 @@ namespace Squidex.Domain.Apps.Entities
return (app, schema); return (app, schema);
} }
public Task<IAppEntity?> GetAppAsync(Guid appId) public async Task<IAppEntity?> GetAppAsync(Guid appId, bool canCache = false)
{ {
return localCache.GetOrCreateAsync($"GetAppAsync({appId})", async () => var app = await localCache.GetOrCreateAsync(AppCacheKey(appId), () =>
{ {
return await indexForApps.GetAppAsync(appId); return indexForApps.GetAppAsync(appId, canCache);
}); });
if (app != null)
{
localCache.Add(AppCacheKey(app.Id), app);
}
return app?.IsArchived == true ? null : app;
} }
public Task<IAppEntity?> GetAppAsync(string appName) public async Task<IAppEntity?> GetAppAsync(string appName, bool canCache = false)
{ {
return localCache.GetOrCreateAsync($"GetAppAsync({appName})", async () => var app = await localCache.GetOrCreateAsync(AppCacheKey(appName), () =>
{ {
return await indexForApps.GetAppByNameAsync(appName); return indexForApps.GetAppByNameAsync(appName, canCache);
}); });
if (app != null)
{
localCache.Add(AppCacheKey(app.Id), app);
} }
public Task<List<IAppEntity>> GetUserAppsAsync(string userId, PermissionSet permissions) return app?.IsArchived == true ? null : app;
}
public async Task<ISchemaEntity?> GetSchemaAsync(Guid appId, string name, bool canCache = false)
{ {
return localCache.GetOrCreateAsync($"GetUserApps({userId})", async () => var schema = await localCache.GetOrCreateAsync(SchemaCacheKey(appId, name), () =>
{ {
return await indexForApps.GetAppsForUserAsync(userId, permissions); return indexSchemas.GetSchemaByNameAsync(appId, name, canCache);
}); });
if (schema != null)
{
localCache.Add(SchemaCacheKey(appId, schema.Id), schema);
}
return schema?.IsDeleted == true ? null : schema;
} }
public Task<ISchemaEntity?> GetSchemaAsync(Guid appId, string name) public async Task<ISchemaEntity?> GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false, bool canCache = false)
{ {
return localCache.GetOrCreateAsync($"GetSchemaAsync({appId}, {name})", async () => var schema = await localCache.GetOrCreateAsync(SchemaCacheKey(appId, id), () =>
{ {
return await indexSchemas.GetSchemaByNameAsync(appId, name); return indexSchemas.GetSchemaAsync(appId, id, canCache);
}); });
if (schema != null)
{
localCache.Add(SchemaCacheKey(appId, schema.Id), schema);
}
return schema?.IsDeleted == true && !allowDeleted ? null : schema;
} }
public Task<ISchemaEntity?> GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false) public async Task<List<IAppEntity>> GetUserAppsAsync(string userId, PermissionSet permissions)
{ {
return localCache.GetOrCreateAsync($"GetSchemaAsync({appId}, {id}, {allowDeleted})", async () => var apps = await localCache.GetOrCreateAsync($"GetUserApps({userId})", () =>
{ {
return await indexSchemas.GetSchemaAsync(appId, id, allowDeleted); return indexForApps.GetAppsForUserAsync(userId, permissions);
}); });
return apps.Where(x => !x.IsArchived).ToList();
} }
public Task<List<ISchemaEntity>> GetSchemasAsync(Guid appId) public async Task<List<ISchemaEntity>> GetSchemasAsync(Guid appId)
{ {
return localCache.GetOrCreateAsync($"GetSchemasAsync({appId})", async () => var schemas = await localCache.GetOrCreateAsync($"GetSchemasAsync({appId})", () =>
{ {
return await indexSchemas.GetSchemasAsync(appId); return indexSchemas.GetSchemasAsync(appId);
}); });
return schemas.Where(x => !x.IsDeleted).ToList();
} }
public Task<List<IRuleEntity>> GetRulesAsync(Guid appId) public async Task<List<IRuleEntity>> GetRulesAsync(Guid appId)
{ {
return localCache.GetOrCreateAsync($"GetRulesAsync({appId})", async () => var rules = await localCache.GetOrCreateAsync($"GetRulesAsync({appId})", () =>
{ {
return await indexRules.GetRulesAsync(appId); return indexRules.GetRulesAsync(appId);
}); });
return rules.Where(x => !x.IsDeleted).ToList();
}
private static string AppCacheKey(Guid appId)
{
return $"APPS_ID_{appId}";
}
private static string AppCacheKey(string appName)
{
return $"APPS_NAME_{appName}";
}
private static string SchemaCacheKey(Guid appId, Guid id)
{
return $"SCHEMAS_ID_{appId}_{id}";
}
private static string SchemaCacheKey(Guid appId, string name)
{
return $"SCHEMAS_NAME_{appId}_{name}";
} }
} }
} }

103
backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsIndex.cs

@ -12,6 +12,7 @@ using System.Threading.Tasks;
using Orleans; using Orleans;
using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Orleans;
@ -23,13 +24,18 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
{ {
public sealed class AppsIndex : IAppsIndex, ICommandMiddleware public sealed class AppsIndex : IAppsIndex, ICommandMiddleware
{ {
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
private readonly IGrainFactory grainFactory; private readonly IGrainFactory grainFactory;
private readonly IReplicatedCache replicatedCache;
public AppsIndex(IGrainFactory grainFactory) public AppsIndex(IGrainFactory grainFactory, IReplicatedCache replicatedCache)
{ {
Guard.NotNull(grainFactory, nameof(grainFactory)); Guard.NotNull(grainFactory, nameof(grainFactory));
Guard.NotNull(replicatedCache, nameof(replicatedCache));
this.grainFactory = grainFactory; this.grainFactory = grainFactory;
this.replicatedCache = replicatedCache;
} }
public async Task RebuildByContributorsAsync(Guid appId, HashSet<string> contributors) public async Task RebuildByContributorsAsync(Guid appId, HashSet<string> contributors)
@ -78,7 +84,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
var apps = var apps =
await Task.WhenAll(ids await Task.WhenAll(ids
.Select(GetAppAsync)); .Select(id => GetAppAsync(id, false)));
return apps.NotNull().ToList(); return apps.NotNull().ToList();
} }
@ -96,16 +102,24 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
var apps = var apps =
await Task.WhenAll(ids await Task.WhenAll(ids
.SelectMany(x => x).Distinct() .SelectMany(x => x).Distinct()
.Select(GetAppAsync)); .Select(id => GetAppAsync(id, false)));
return apps.NotNull().ToList(); return apps.NotNull().ToList();
} }
} }
public async Task<IAppEntity?> GetAppByNameAsync(string name) public async Task<IAppEntity?> GetAppByNameAsync(string name, bool canCache = false)
{ {
using (Profiler.TraceMethod<AppsIndex>()) using (Profiler.TraceMethod<AppsIndex>())
{ {
if (canCache)
{
if (replicatedCache.TryGetValue(GetCacheKey(name), out var cached))
{
return cached as IAppEntity;
}
}
var appId = await GetAppIdAsync(name); var appId = await GetAppIdAsync(name);
if (appId == default) if (appId == default)
@ -113,22 +127,30 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
return null; return null;
} }
return await GetAppAsync(appId); return await GetAppAsync(appId, canCache);
} }
} }
public async Task<IAppEntity?> GetAppAsync(Guid appId) public async Task<IAppEntity?> GetAppAsync(Guid appId, bool canCache)
{ {
using (Profiler.TraceMethod<AppsIndex>()) using (Profiler.TraceMethod<AppsIndex>())
{ {
var app = await grainFactory.GetGrain<IAppGrain>(appId).GetStateAsync(); if (canCache)
{
if (replicatedCache.TryGetValue(GetCacheKey(appId), out var cached))
{
return cached as IAppEntity;
}
}
var app = await GetAppCoreAsync(appId);
if (IsFound(app.Value, false)) if (app != null)
{ {
return app.Value; CacheIt(app, false);
} }
return null; return app;
} }
} }
@ -197,8 +219,14 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
{ {
await next(context); await next(context);
if (context.IsCompleted) if (context.IsCompleted && context.Command is AppCommand appCommand)
{ {
var app = await GetAppCoreAsync(appCommand.AppId);
if (app != null)
{
CacheIt(app, true);
switch (context.Command) switch (context.Command)
{ {
case AssignContributor assignContributor: case AssignContributor assignContributor:
@ -210,12 +238,13 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
break; break;
case ArchiveApp archiveApp: case ArchiveApp archiveApp:
await ArchiveAppAsync(archiveApp); await ArchiveAppAsync(app);
break; break;
} }
} }
} }
} }
}
private static async Task<string?> CheckAppAsync(IAppsByNameIndexGrain index, CreateApp command) private static async Task<string?> CheckAppAsync(IAppsByNameIndexGrain index, CreateApp command)
{ {
@ -238,46 +267,62 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
return null; return null;
} }
private Task AssignContributorAsync(AssignContributor command) private async Task AssignContributorAsync(AssignContributor command)
{ {
return Index(command.ContributorId).AddAsync(command.AppId); await Index(command.ContributorId).AddAsync(command.AppId);
} }
private Task RemoveContributorAsync(RemoveContributor command) private async Task RemoveContributorAsync(RemoveContributor command)
{ {
return Index(command.ContributorId).RemoveAsync(command.AppId); await Index(command.ContributorId).RemoveAsync(command.AppId);
} }
private async Task ArchiveAppAsync(ArchiveApp command) private async Task ArchiveAppAsync(IAppEntity app)
{ {
var appId = command.AppId; await Index().RemoveAsync(app.Id);
var app = await grainFactory.GetGrain<IAppGrain>(appId).GetStateAsync(); foreach (var contributorId in app.Contributors.Keys)
{
await Index(contributorId).RemoveAsync(app.Id);
}
}
if (IsFound(app.Value, true)) private IAppsByNameIndexGrain Index()
{ {
await Index().RemoveAsync(appId); return grainFactory.GetGrain<IAppsByNameIndexGrain>(SingleGrain.Id);
} }
foreach (var contributorId in app.Value.Contributors.Keys) private IAppsByUserIndexGrain Index(string id)
{ {
await Index(contributorId).RemoveAsync(appId); return grainFactory.GetGrain<IAppsByUserIndexGrain>(id);
} }
private async Task<IAppEntity?> GetAppCoreAsync(Guid appId)
{
var app = (await grainFactory.GetGrain<IAppGrain>(appId).GetStateAsync()).Value;
if (app.Version <= EtagVersion.Empty)
{
return null;
}
return app;
} }
private static bool IsFound(IAppEntity entity, bool allowArchived) private static string GetCacheKey(Guid id)
{ {
return entity.Version > EtagVersion.Empty && (!entity.IsArchived || allowArchived); return $"APPS_ID_{id}";
} }
private IAppsByNameIndexGrain Index() private static string GetCacheKey(string name)
{ {
return grainFactory.GetGrain<IAppsByNameIndexGrain>(SingleGrain.Id); return $"APPS_NAME_{name}";
} }
private IAppsByUserIndexGrain Index(string id) private void CacheIt(IAppEntity app, bool publish)
{ {
return grainFactory.GetGrain<IAppsByUserIndexGrain>(id); replicatedCache.Add(GetCacheKey(app.Id), app, CacheDuration, publish);
replicatedCache.Add(GetCacheKey(app.Name), app, CacheDuration, publish);
} }
} }
} }

4
backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsIndex.cs

@ -20,9 +20,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
Task<List<IAppEntity>> GetAppsForUserAsync(string userId, PermissionSet permissions); Task<List<IAppEntity>> GetAppsForUserAsync(string userId, PermissionSet permissions);
Task<IAppEntity?> GetAppByNameAsync(string name); Task<IAppEntity?> GetAppByNameAsync(string name, bool canCache);
Task<IAppEntity?> GetAppAsync(Guid appId); Task<IAppEntity?> GetAppAsync(Guid appId, bool canCache);
Task<string?> ReserveAsync(Guid id, string name); Task<string?> ReserveAsync(Guid id, string name);

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

@ -42,7 +42,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
if (workflows.Values.Count(x => x.SchemaIds.Contains(schemaId)) > 1) if (workflows.Values.Count(x => x.SchemaIds.Contains(schemaId)) > 1)
{ {
var schema = await appProvider.GetSchemaAsync(appId, schemaId); var schema = await appProvider.GetSchemaAsync(appId, schemaId, false);
if (schema != null) if (schema != null)
{ {

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

@ -130,7 +130,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
Workflow? result = null; Workflow? result = null;
var app = await appProvider.GetAppAsync(appId); var app = await appProvider.GetAppAsync(appId, false);
if (app != null) if (app != null)
{ {

6
backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs

@ -156,14 +156,16 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
{ {
ISchemaEntity? schema = null; ISchemaEntity? schema = null;
var canCache = !context.IsFrontendClient;
if (Guid.TryParse(schemaIdOrName, out var id)) if (Guid.TryParse(schemaIdOrName, out var id))
{ {
schema = await appProvider.GetSchemaAsync(context.App.Id, id); schema = await appProvider.GetSchemaAsync(context.App.Id, id, false, canCache);
} }
if (schema == null) if (schema == null)
{ {
schema = await appProvider.GetSchemaAsync(context.App.Id, schemaIdOrName); schema = await appProvider.GetSchemaAsync(context.App.Id, schemaIdOrName, canCache);
} }
if (schema == null) if (schema == null)

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

@ -41,7 +41,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
if (arguments.Length == 2 && context.GetValue("event")?.ToObjectValue() is EnrichedEvent enrichedEvent) if (arguments.Length == 2 && context.GetValue("event")?.ToObjectValue() is EnrichedEvent enrichedEvent)
{ {
var app = await appProvider.GetAppAsync(enrichedEvent.AppId.Id); var app = await appProvider.GetAppAsync(enrichedEvent.AppId.Id, false);
if (app == null) if (app == null)
{ {

10
backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs

@ -17,17 +17,17 @@ namespace Squidex.Domain.Apps.Entities
{ {
public interface IAppProvider public interface IAppProvider
{ {
Task<(IAppEntity?, ISchemaEntity?)> GetAppWithSchemaAsync(Guid appId, Guid id); Task<(IAppEntity?, ISchemaEntity?)> GetAppWithSchemaAsync(Guid appId, Guid id, bool canCache = false);
Task<IAppEntity?> GetAppAsync(Guid appId); Task<IAppEntity?> GetAppAsync(Guid appId, bool canCache = false);
Task<IAppEntity?> GetAppAsync(string appName); Task<IAppEntity?> GetAppAsync(string appName, bool canCache = false);
Task<List<IAppEntity>> GetUserAppsAsync(string userId, PermissionSet permissions); Task<List<IAppEntity>> GetUserAppsAsync(string userId, PermissionSet permissions);
Task<ISchemaEntity?> GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false); Task<ISchemaEntity?> GetSchemaAsync(Guid appId, Guid id, bool allowDeleted, bool canCache = false);
Task<ISchemaEntity?> GetSchemaAsync(Guid appId, string name); Task<ISchemaEntity?> GetSchemaAsync(Guid appId, string name, bool canCache = false);
Task<List<ISchemaEntity>> GetSchemasAsync(Guid appId); Task<List<ISchemaEntity>> GetSchemasAsync(Guid appId);

2
backend/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleTriggerValidator.cs

@ -31,7 +31,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards
Guard.NotNull(action, nameof(action)); Guard.NotNull(action, nameof(action));
Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(appProvider, nameof(appProvider));
var visitor = new RuleTriggerValidator(x => appProvider.GetSchemaAsync(appId, x)); var visitor = new RuleTriggerValidator(x => appProvider.GetSchemaAsync(appId, x, false));
return action.Accept(visitor); return action.Accept(visitor);
} }

30
backend/src/Squidex.Domain.Apps.Entities/Rules/Indexes/RulesIndex.cs

@ -41,7 +41,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes
var rules = var rules =
await Task.WhenAll( await Task.WhenAll(
ids.Select(GetRuleAsync)); ids.Select(GetRuleCoreAsync));
return rules.NotNull().ToList(); return rules.NotNull().ToList();
} }
@ -51,14 +51,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes
{ {
using (Profiler.TraceMethod<RulesIndex>()) using (Profiler.TraceMethod<RulesIndex>())
{ {
var ruleEntity = await grainFactory.GetGrain<IRuleGrain>(id).GetStateAsync(); return await GetRuleCoreAsync(id);
if (IsFound(ruleEntity.Value))
{
return ruleEntity.Value;
}
return null;
} }
} }
@ -95,13 +88,11 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes
private async Task DeleteRuleAsync(DeleteRule command) private async Task DeleteRuleAsync(DeleteRule command)
{ {
var id = command.RuleId; var rule = await GetRuleAsync(command.RuleId);
var rule = await grainFactory.GetGrain<IRuleGrain>(id).GetStateAsync();
if (IsFound(rule.Value)) if (rule != null)
{ {
await Index(rule.Value.AppId.Id).RemoveAsync(id); await Index(rule.AppId.Id).RemoveAsync(rule.Id);
} }
} }
@ -110,9 +101,16 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes
return grainFactory.GetGrain<IRulesByAppIndexGrain>(appId); return grainFactory.GetGrain<IRulesByAppIndexGrain>(appId);
} }
private static bool IsFound(IRuleEntity rule) private async Task<IRuleEntity?> GetRuleCoreAsync(Guid ruleId)
{ {
return rule.Version > EtagVersion.Empty && !rule.IsDeleted; var rule = (await grainFactory.GetGrain<IRuleGrain>(ruleId).GetStateAsync()).Value;
if (rule.Version <= EtagVersion.Empty)
{
return null;
}
return rule;
} }
} }
} }

6
backend/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/ISchemasIndex.cs

@ -13,11 +13,11 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes
{ {
public interface ISchemasIndex public interface ISchemasIndex
{ {
Task<ISchemaEntity?> GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false); Task<ISchemaEntity?> GetSchemaAsync(Guid appId, Guid id, bool canCache);
Task<ISchemaEntity?> GetSchemaByNameAsync(Guid appId, string name, bool allowDeleted = false); Task<ISchemaEntity?> GetSchemaByNameAsync(Guid appId, string name, bool canCache);
Task<List<ISchemaEntity>> GetSchemasAsync(Guid appId, bool allowDeleted = false); Task<List<ISchemaEntity>> GetSchemasAsync(Guid appId);
Task RebuildAsync(Guid appId, Dictionary<string, Guid> schemas); Task RebuildAsync(Guid appId, Dictionary<string, Guid> schemas);
} }

93
backend/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasIndex.cs

@ -12,6 +12,7 @@ using System.Threading.Tasks;
using Orleans; using Orleans;
using Squidex.Domain.Apps.Entities.Schemas.Commands; using Squidex.Domain.Apps.Entities.Schemas.Commands;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
@ -20,13 +21,18 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes
{ {
public sealed class SchemasIndex : ICommandMiddleware, ISchemasIndex public sealed class SchemasIndex : ICommandMiddleware, ISchemasIndex
{ {
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
private readonly IGrainFactory grainFactory; private readonly IGrainFactory grainFactory;
private readonly IReplicatedCache replicatedCache;
public SchemasIndex(IGrainFactory grainFactory) public SchemasIndex(IGrainFactory grainFactory, IReplicatedCache replicatedCache)
{ {
Guard.NotNull(grainFactory, nameof(grainFactory)); Guard.NotNull(grainFactory, nameof(grainFactory));
Guard.NotNull(replicatedCache, nameof(replicatedCache));
this.grainFactory = grainFactory; this.grainFactory = grainFactory;
this.replicatedCache = replicatedCache;
} }
public Task RebuildAsync(Guid appId, Dictionary<string, Guid> schemas) public Task RebuildAsync(Guid appId, Dictionary<string, Guid> schemas)
@ -34,7 +40,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes
return Index(appId).RebuildAsync(schemas); return Index(appId).RebuildAsync(schemas);
} }
public async Task<List<ISchemaEntity>> GetSchemasAsync(Guid appId, bool allowDeleted = false) public async Task<List<ISchemaEntity>> GetSchemasAsync(Guid appId)
{ {
using (Profiler.TraceMethod<SchemasIndex>()) using (Profiler.TraceMethod<SchemasIndex>())
{ {
@ -42,16 +48,26 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes
var schemas = var schemas =
await Task.WhenAll( await Task.WhenAll(
ids.Select(id => GetSchemaAsync(appId, id, allowDeleted))); ids.Select(id => GetSchemaAsync(appId, id, false)));
return schemas.NotNull().ToList(); return schemas.NotNull().ToList();
} }
} }
public async Task<ISchemaEntity?> GetSchemaByNameAsync(Guid appId, string name, bool allowDeleted = false) public async Task<ISchemaEntity?> GetSchemaByNameAsync(Guid appId, string name, bool canCache)
{ {
using (Profiler.TraceMethod<SchemasIndex>()) using (Profiler.TraceMethod<SchemasIndex>())
{ {
var cacheKey = GetCacheKey(appId, name);
if (canCache)
{
if (replicatedCache.TryGetValue(cacheKey, out var cachedSchema))
{
return cachedSchema as ISchemaEntity;
}
}
var id = await GetSchemaIdAsync(appId, name); var id = await GetSchemaIdAsync(appId, name);
if (id == default) if (id == default)
@ -59,22 +75,32 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes
return null; return null;
} }
return await GetSchemaAsync(appId, id, allowDeleted); return await GetSchemaAsync(appId, id, canCache);
} }
} }
public async Task<ISchemaEntity?> GetSchemaAsync(Guid appId, Guid id, bool allowDeleted = false) public async Task<ISchemaEntity?> GetSchemaAsync(Guid appId, Guid id, bool canCache)
{ {
using (Profiler.TraceMethod<SchemasIndex>()) using (Profiler.TraceMethod<SchemasIndex>())
{ {
var schema = await grainFactory.GetGrain<ISchemaGrain>(id).GetStateAsync(); var cacheKey = GetCacheKey(appId, id);
if (IsFound(schema.Value, allowDeleted)) if (canCache)
{ {
return schema.Value; if (replicatedCache.TryGetValue(cacheKey, out var cachedSchema))
{
return cachedSchema as ISchemaEntity;
}
} }
return null; var schema = await GetSchemaCoreAsync(id);
if (schema != null)
{
CacheIt(schema, false);
}
return schema;
} }
} }
@ -125,11 +151,18 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes
{ {
await next(context); await next(context);
if (context.IsCompleted) if (context.IsCompleted && context.Command is SchemaCommand schemaCommand)
{ {
if (context.Command is DeleteSchema deleteSchema) var schema = await GetSchemaCoreAsync(schemaCommand.SchemaId);
if (schema != null)
{ {
await DeleteSchemaAsync(deleteSchema); CacheIt(schema, true);
if (context.Command is DeleteSchema)
{
await DeleteSchemaAsync(schema);
}
} }
} }
} }
@ -156,26 +189,42 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes
return null; return null;
} }
private async Task DeleteSchemaAsync(DeleteSchema commmand) private Task DeleteSchemaAsync(ISchemaEntity schema)
{
return Index(schema.AppId.Id).RemoveAsync(schema.Id);
}
private ISchemasByAppIndexGrain Index(Guid appId)
{ {
var schemaId = commmand.SchemaId; return grainFactory.GetGrain<ISchemasByAppIndexGrain>(appId);
}
var schema = await grainFactory.GetGrain<ISchemaGrain>(schemaId).GetStateAsync(); private async Task<ISchemaEntity?> GetSchemaCoreAsync(Guid schemaId)
{
var schema = (await grainFactory.GetGrain<ISchemaGrain>(schemaId).GetStateAsync()).Value;
if (IsFound(schema.Value, true)) if (schema.Version <= EtagVersion.Empty)
{ {
await Index(schema.Value.AppId.Id).RemoveAsync(schemaId); return null;
} }
return schema;
} }
private ISchemasByAppIndexGrain Index(Guid appId) private string GetCacheKey(Guid appId, string name)
{ {
return grainFactory.GetGrain<ISchemasByAppIndexGrain>(appId); return $"SCHEMAS_NAME_{appId}_{name}";
}
private string GetCacheKey(Guid appId, Guid id)
{
return $"SCHEMAS_ID_{appId}_{id}";
} }
private static bool IsFound(ISchemaEntity entity, bool allowDeleted) private void CacheIt(ISchemaEntity schema, bool publish)
{ {
return entity.Version > EtagVersion.Empty && (!entity.IsDeleted || allowDeleted); replicatedCache.Add(GetCacheKey(schema.AppId.Id, schema.Id), schema, CacheDuration, publish);
replicatedCache.Add(GetCacheKey(schema.AppId.Id, schema.SchemaDef.Name), schema, CacheDuration, publish);
} }
} }
} }

18
backend/src/Squidex.Infrastructure/Caching/IPubSub.cs

@ -0,0 +1,18 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
namespace Squidex.Infrastructure.Caching
{
public interface IPubSub
{
void Publish(object message);
void Subscribe(Action<object> handler);
}
}

13
backend/src/Squidex.Infrastructure/Caching/IPubSubSubscription.cs

@ -0,0 +1,13 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Infrastructure.Caching
{
internal interface IPubSubSubscription
{
}
}

20
backend/src/Squidex.Infrastructure/Caching/IReplicatedCache.cs

@ -0,0 +1,20 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
namespace Squidex.Infrastructure.Caching
{
public interface IReplicatedCache
{
void Add(string key, object? value, TimeSpan expiration, bool invalidate);
void Remove(string key);
bool TryGetValue(string key, out object? value);
}
}

29
backend/src/Squidex.Infrastructure/Caching/RequestCacheExtensions.cs → backend/src/Squidex.Infrastructure/Caching/LocalCacheExtensions.cs

@ -10,34 +10,27 @@ using System.Threading.Tasks;
namespace Squidex.Infrastructure.Caching namespace Squidex.Infrastructure.Caching
{ {
public static class RequestCacheExtensions public static class LocalCacheExtensions
{ {
public static async Task<T> GetOrCreateAsync<T>(this ILocalCache cache, object key, Func<Task<T>> task) public static async Task<T> GetOrCreateAsync<T>(this ILocalCache cache, object key, Func<Task<T>> task)
{ {
if (cache.TryGetValue(key, out var value) && value is T typedValue) if (cache.TryGetValue(key, out var value))
{ {
return typedValue; if (value is T typed)
}
typedValue = await task();
cache.Add(key, typedValue);
return typedValue;
}
public static T GetOrCreate<T>(this ILocalCache cache, object key, Func<T> task)
{ {
if (cache.TryGetValue(key, out var value) && value is T typedValue) return typed;
}
else
{ {
return typedValue; return default!;
}
} }
typedValue = task(); var result = await task();
cache.Add(key, typedValue); cache.Add(key, result);
return typedValue; return result;
} }
} }
} }

72
backend/src/Squidex.Infrastructure/Caching/ReplicatedCache.cs

@ -0,0 +1,72 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Microsoft.Extensions.Caching.Memory;
namespace Squidex.Infrastructure.Caching
{
public sealed class ReplicatedCache : IReplicatedCache
{
private readonly Guid instanceId = Guid.NewGuid();
private readonly IMemoryCache memoryCache;
private readonly IPubSub pubSub;
public class InvalidateMessage
{
public Guid Source { get; set; }
public string Key { get; set; }
}
public ReplicatedCache(IMemoryCache memoryCache, IPubSub pubSub)
{
Guard.NotNull(memoryCache, nameof(memoryCache));
Guard.NotNull(pubSub, nameof(pubSub));
this.memoryCache = memoryCache;
this.pubSub = pubSub;
this.pubSub.Subscribe(OnMessage);
}
private void OnMessage(object message)
{
if (message is InvalidateMessage invalidate && invalidate.Source != instanceId)
{
memoryCache.Remove(invalidate.Key);
}
}
public void Add(string key, object? value, TimeSpan expiration, bool invalidate)
{
memoryCache.Set(key, value, expiration);
if (invalidate)
{
Invalidate(key);
}
}
public void Remove(string key)
{
memoryCache.Remove(key);
Invalidate(key);
}
public bool TryGetValue(string key, out object? value)
{
return memoryCache.TryGetValue(key, out value);
}
private void Invalidate(string key)
{
pubSub.Publish(new InvalidateMessage { Key = key, Source = instanceId });
}
}
}

32
backend/src/Squidex.Infrastructure/Caching/SimplePubSub.cs

@ -0,0 +1,32 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
namespace Squidex.Infrastructure.Caching
{
public sealed class SimplePubSub : IPubSub
{
private readonly List<Action<object>> handlers = new List<Action<object>>();
public void Publish(object message)
{
foreach (var handler in handlers)
{
handler(message);
}
}
public void Subscribe(Action<object> handler)
{
Guard.NotNull(handler, nameof(handler));
handlers.Add(handler);
}
}
}

19
backend/src/Squidex.Infrastructure/Orleans/IPubSubGrain.cs

@ -0,0 +1,19 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Orleans;
namespace Squidex.Infrastructure.Orleans
{
public interface IPubSubGrain : IGrainWithStringKey
{
Task SubscribeAsync(IPubSubGrainObserver observer);
Task PublishAsync(object message);
}
}

19
backend/src/Squidex.Infrastructure/Orleans/IPubSubGrainObserver.cs

@ -0,0 +1,19 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Orleans;
namespace Squidex.Infrastructure.Orleans
{
public interface IPubSubGrainObserver : IGrainObserver
{
void Handle(object message);
void Subscribe(Action<object> handler);
}
}

79
backend/src/Squidex.Infrastructure/Orleans/OrleansPubSub.cs

@ -0,0 +1,79 @@
// ==========================================================================
// 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;
using System.Threading.Tasks;
using Orleans;
using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Infrastructure.Orleans
{
public sealed class OrleansPubSub : IBackgroundProcess, IPubSub
{
private readonly IPubSubGrain pubSubGrain;
private readonly IPubSubGrainObserver pubSubGrainObserver = new Observer();
private readonly IGrainFactory grainFactory;
private sealed class Observer : IPubSubGrainObserver
{
private readonly List<Action<object>> subscriptions = new List<Action<object>>();
public void Handle(object message)
{
foreach (var subscription in subscriptions)
{
try
{
subscription(message);
}
catch
{
continue;
}
}
}
public void Subscribe(Action<object> handler)
{
subscriptions.Add(handler);
}
}
public OrleansPubSub(IGrainFactory grainFactory)
{
Guard.NotNull(grainFactory, nameof(grainFactory));
this.grainFactory = grainFactory;
pubSubGrain = grainFactory.GetGrain<IPubSubGrain>(SingleGrain.Id);
}
public async Task StartAsync(CancellationToken ct)
{
var reference = await grainFactory.CreateObjectReference<IPubSubGrainObserver>(pubSubGrainObserver);
await pubSubGrain.SubscribeAsync(reference);
}
public void Publish(object message)
{
Guard.NotNull(message, nameof(message));
pubSubGrain.PublishAsync(message).Forget();
}
public void Subscribe(Action<object> handler)
{
Guard.NotNull(handler, nameof(handler));
pubSubGrainObserver.Subscribe(handler);
}
}
}

42
backend/src/Squidex.Infrastructure/Orleans/OrleansPubSubGrain.cs

@ -0,0 +1,42 @@
// ==========================================================================
// 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 Orleans;
namespace Squidex.Infrastructure.Orleans
{
public sealed class OrleansPubSubGrain : Grain, IPubSubGrain
{
private readonly List<IPubSubGrainObserver> subscriptions = new List<IPubSubGrainObserver>();
public Task PublishAsync(object message)
{
foreach (var subscription in subscriptions)
{
try
{
subscription.Handle(message);
}
catch
{
continue;
}
}
return Task.CompletedTask;
}
public Task SubscribeAsync(IPubSubGrainObserver observer)
{
subscriptions.Add(observer);
return Task.CompletedTask;
}
}
}

4
backend/src/Squidex.Infrastructure/Security/Permission.Part.cs

@ -132,9 +132,11 @@ namespace Squidex.Infrastructure.Security
private static int CountOf(ReadOnlySpan<char> text, char character) private static int CountOf(ReadOnlySpan<char> text, char character)
{ {
var length = text.Length;
var count = 0; var count = 0;
for (var i = 0; i < text.Length; i++) for (var i = 0; i < length; i++)
{ {
if (text[i] == character) if (text[i] == character)
{ {

70
backend/src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs

@ -7,10 +7,8 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Http;
using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.Schemas.Commands; using Squidex.Domain.Apps.Entities.Schemas.Commands;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
@ -19,38 +17,27 @@ namespace Squidex.Web.CommandMiddlewares
{ {
public sealed class EnrichWithSchemaIdCommandMiddleware : ICommandMiddleware public sealed class EnrichWithSchemaIdCommandMiddleware : ICommandMiddleware
{ {
private readonly IAppProvider appProvider; private readonly IHttpContextAccessor httpContextAccessor;
private readonly IActionContextAccessor actionContextAccessor;
public EnrichWithSchemaIdCommandMiddleware(IAppProvider appProvider, IActionContextAccessor actionContextAccessor) public EnrichWithSchemaIdCommandMiddleware(IHttpContextAccessor httpContextAccessor)
{ {
Guard.NotNull(appProvider, nameof(appProvider)); Guard.NotNull(httpContextAccessor, nameof(httpContextAccessor));
Guard.NotNull(actionContextAccessor, nameof(actionContextAccessor));
this.appProvider = appProvider; this.httpContextAccessor = httpContextAccessor;
this.actionContextAccessor = actionContextAccessor;
} }
public async Task HandleAsync(CommandContext context, NextDelegate next) public async Task HandleAsync(CommandContext context, NextDelegate next)
{ {
if (actionContextAccessor.ActionContext == null)
{
await next(context);
return;
}
if (context.Command is ISchemaCommand schemaCommand && schemaCommand.SchemaId == null) if (context.Command is ISchemaCommand schemaCommand && schemaCommand.SchemaId == null)
{ {
var schemaId = await GetSchemaIdAsync(context); var schemaId = GetSchemaId();
schemaCommand.SchemaId = schemaId!; schemaCommand.SchemaId = schemaId!;
} }
if (context.Command is SchemaCommand schemaSelfCommand && schemaSelfCommand.SchemaId == Guid.Empty) if (context.Command is SchemaCommand schemaSelfCommand && schemaSelfCommand.SchemaId == Guid.Empty)
{ {
var schemaId = await GetSchemaIdAsync(context); var schemaId = GetSchemaId();
schemaSelfCommand.SchemaId = schemaId?.Id ?? Guid.Empty; schemaSelfCommand.SchemaId = schemaId?.Id ?? Guid.Empty;
} }
@ -58,49 +45,16 @@ namespace Squidex.Web.CommandMiddlewares
await next(context); await next(context);
} }
private async Task<NamedId<Guid>?> GetSchemaIdAsync(CommandContext context) private NamedId<Guid> GetSchemaId()
{ {
NamedId<Guid>? appId = null; var feature = httpContextAccessor.HttpContext.Features.Get<ISchemaFeature>();
if (context.Command is IAppCommand appCommand) if (feature == null)
{ {
appId = appCommand.AppId; throw new InvalidOperationException("Cannot resolve schema.");
}
if (appId == null)
{
appId = actionContextAccessor.ActionContext.HttpContext.Context().App?.NamedId();
}
if (appId != null)
{
var routeValues = actionContextAccessor.ActionContext.RouteData.Values;
if (routeValues.ContainsKey("name"))
{
var schemaName = routeValues["name"].ToString()!;
ISchemaEntity? schema;
if (Guid.TryParse(schemaName, out var id))
{
schema = await appProvider.GetSchemaAsync(appId.Id, id);
}
else
{
schema = await appProvider.GetSchemaAsync(appId.Id, schemaName);
}
if (schema == null)
{
throw new DomainObjectNotFoundException(schemaName, typeof(ISchemaEntity));
}
return schema.NamedId();
}
} }
return null; return feature.SchemaId;
} }
} }
} }

4
backend/src/Squidex.Web/Pipeline/AppResolver.cs

@ -41,7 +41,9 @@ namespace Squidex.Web.Pipeline
if (!string.IsNullOrWhiteSpace(appName)) if (!string.IsNullOrWhiteSpace(appName))
{ {
var app = await appProvider.GetAppAsync(appName); var canCache = !user.IsInClient(DefaultClients.Frontend);
var app = await appProvider.GetAppAsync(appName, canCache);
if (app == null) if (app == null)
{ {

13
backend/src/Squidex.Web/Pipeline/SchemaResolver.cs

@ -6,12 +6,15 @@
// ========================================================================== // ==========================================================================
using System; using System;
using System.Security.Claims;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters; using Microsoft.AspNetCore.Mvc.Filters;
using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
using Squidex.Shared;
namespace Squidex.Web.Pipeline namespace Squidex.Web.Pipeline
{ {
@ -36,7 +39,7 @@ namespace Squidex.Web.Pipeline
if (!string.IsNullOrWhiteSpace(schemaIdOrName)) if (!string.IsNullOrWhiteSpace(schemaIdOrName))
{ {
var schema = await GetSchemaAsync(appId, schemaIdOrName); var schema = await GetSchemaAsync(appId, schemaIdOrName, context.HttpContext.User);
if (schema == null) if (schema == null)
{ {
@ -51,15 +54,17 @@ namespace Squidex.Web.Pipeline
await next(); await next();
} }
private Task<ISchemaEntity?> GetSchemaAsync(Guid appId, string schemaIdOrName) private Task<ISchemaEntity?> GetSchemaAsync(Guid appId, string schemaIdOrName, ClaimsPrincipal user)
{ {
var canCache = !user.IsInClient(DefaultClients.Frontend);
if (Guid.TryParse(schemaIdOrName, out var id)) if (Guid.TryParse(schemaIdOrName, out var id))
{ {
return appProvider.GetSchemaAsync(appId, id); return appProvider.GetSchemaAsync(appId, id, false, canCache);
} }
else else
{ {
return appProvider.GetSchemaAsync(appId, schemaIdOrName); return appProvider.GetSchemaAsync(appId, schemaIdOrName, canCache);
} }
} }
} }

2
backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs

@ -80,7 +80,7 @@ namespace Squidex.Areas.Api.Controllers.Schemas
if (Guid.TryParse(name, out var id)) if (Guid.TryParse(name, out var id))
{ {
schema = await appProvider.GetSchemaAsync(AppId, id); schema = await appProvider.GetSchemaAsync(AppId, id, false);
} }
else else
{ {

2
backend/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs

@ -65,7 +65,7 @@ namespace Squidex.Areas.IdentityServer.Config
if (!string.IsNullOrWhiteSpace(appName) && !string.IsNullOrWhiteSpace(appClientId)) if (!string.IsNullOrWhiteSpace(appName) && !string.IsNullOrWhiteSpace(appClientId))
{ {
var app = await appProvider.GetAppAsync(appName); var app = await appProvider.GetAppAsync(appName, true);
var appClient = app?.Clients.GetOrDefault(appClientId); var appClient = app?.Clients.GetOrDefault(appClientId);

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

@ -59,6 +59,12 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<AsyncLocalCache>() services.AddSingletonAs<AsyncLocalCache>()
.As<ILocalCache>(); .As<ILocalCache>();
services.AddSingletonAs<ReplicatedCache>()
.As<IReplicatedCache>();
services.AddSingletonAs<OrleansPubSub>()
.As<IPubSub>();
services.AddSingletonAs<JintScriptEngine>() services.AddSingletonAs<JintScriptEngine>()
.AsOptional<IScriptEngine>(); .AsOptional<IScriptEngine>();

1
backend/src/Squidex/Program.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Net;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;

104
backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsIndexTests.cs

@ -9,10 +9,13 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Orleans; using Orleans;
using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Domain.Apps.Entities.Apps.Commands;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Security; using Squidex.Infrastructure.Security;
@ -41,13 +44,15 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
A.CallTo(() => grainFactory.GetGrain<IAppsByUserIndexGrain>(userId, null)) A.CallTo(() => grainFactory.GetGrain<IAppsByUserIndexGrain>(userId, null))
.Returns(indexByUser); .Returns(indexByUser);
sut = new AppsIndex(grainFactory); var cache = new ReplicatedCache(new MemoryCache(Options.Create(new MemoryCacheOptions())), new SimplePubSub());
sut = new AppsIndex(grainFactory, cache);
} }
[Fact] [Fact]
public async Task Should_resolve_all_apps_from_user_permissions() public async Task Should_resolve_all_apps_from_user_permissions()
{ {
var expected = SetupApp(0, false); var expected = SetupApp();
A.CallTo(() => indexByName.GetIdsAsync(A<string[]>.That.IsSameSequenceAs(new[] { appId.Name }))) A.CallTo(() => indexByName.GetIdsAsync(A<string[]>.That.IsSameSequenceAs(new[] { appId.Name })))
.Returns(new List<Guid> { appId.Id }); .Returns(new List<Guid> { appId.Id });
@ -60,7 +65,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
[Fact] [Fact]
public async Task Should_resolve_all_apps_from_user() public async Task Should_resolve_all_apps_from_user()
{ {
var expected = SetupApp(0, false); var expected = SetupApp();
A.CallTo(() => indexByUser.GetIdsAsync()) A.CallTo(() => indexByUser.GetIdsAsync())
.Returns(new List<Guid> { appId.Id }); .Returns(new List<Guid> { appId.Id });
@ -73,7 +78,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
[Fact] [Fact]
public async Task Should_resolve_combined_apps() public async Task Should_resolve_combined_apps()
{ {
var expected = SetupApp(0, false); var expected = SetupApp();
A.CallTo(() => indexByName.GetIdsAsync(A<string[]>.That.IsSameSequenceAs(new[] { appId.Name }))) A.CallTo(() => indexByName.GetIdsAsync(A<string[]>.That.IsSameSequenceAs(new[] { appId.Name })))
.Returns(new List<Guid> { appId.Id }); .Returns(new List<Guid> { appId.Id });
@ -90,7 +95,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
[Fact] [Fact]
public async Task Should_resolve_all_apps() public async Task Should_resolve_all_apps()
{ {
var expected = SetupApp(0, false); var expected = SetupApp();
A.CallTo(() => indexByName.GetIdsAsync()) A.CallTo(() => indexByName.GetIdsAsync())
.Returns(new List<Guid> { appId.Id }); .Returns(new List<Guid> { appId.Id });
@ -103,42 +108,91 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
[Fact] [Fact]
public async Task Should_resolve_app_by_name() public async Task Should_resolve_app_by_name()
{ {
var expected = SetupApp(0, false); var expected = SetupApp();
A.CallTo(() => indexByName.GetIdAsync(appId.Name)) A.CallTo(() => indexByName.GetIdAsync(appId.Name))
.Returns(appId.Id); .Returns(appId.Id);
var actual = await sut.GetAppByNameAsync(appId.Name); var actual1 = await sut.GetAppByNameAsync(appId.Name, false);
var actual2 = await sut.GetAppByNameAsync(appId.Name, false);
Assert.Same(expected, actual1);
Assert.Same(expected, actual2);
A.CallTo(() => grainFactory.GetGrain<IAppGrain>(appId.Id, null))
.MustHaveHappenedTwiceExactly();
Assert.Same(expected, actual); A.CallTo(() => indexByName.GetIdAsync(A<string>._))
.MustHaveHappenedTwiceExactly();
}
[Fact]
public async Task Should_resolve_app_by_name_and_id_if_cached_before()
{
var expected = SetupApp();
A.CallTo(() => indexByName.GetIdAsync(appId.Name))
.Returns(appId.Id);
var actual1 = await sut.GetAppByNameAsync(appId.Name, true);
var actual2 = await sut.GetAppByNameAsync(appId.Name, true);
var actual3 = await sut.GetAppAsync(appId.Id, true);
Assert.Same(expected, actual1);
Assert.Same(expected, actual2);
Assert.Same(expected, actual3);
A.CallTo(() => grainFactory.GetGrain<IAppGrain>(appId.Id, null))
.MustHaveHappenedOnceExactly();
A.CallTo(() => indexByName.GetIdAsync(A<string>._))
.MustHaveHappenedOnceExactly();
} }
[Fact] [Fact]
public async Task Should_resolve_app_by_id() public async Task Should_resolve_app_by_id()
{ {
var expected = SetupApp(0, false); var expected = SetupApp();
var actual1 = await sut.GetAppAsync(appId.Id, false);
var actual2 = await sut.GetAppAsync(appId.Id, false);
var actual = await sut.GetAppAsync(appId.Id); Assert.Same(expected, actual1);
Assert.Same(expected, actual2);
A.CallTo(() => grainFactory.GetGrain<IAppGrain>(appId.Id, null))
.MustHaveHappenedTwiceExactly();
Assert.Same(expected, actual); A.CallTo(() => indexByName.GetIdAsync(A<string>._))
.MustNotHaveHappened();
} }
[Fact] [Fact]
public async Task Should_return_null_if_app_archived() public async Task Should_resolve_app_by_id_and_name_if_cached_before()
{ {
SetupApp(0, true); var expected = SetupApp();
var actual = await sut.GetAppAsync(appId.Id); var actual1 = await sut.GetAppAsync(appId.Id, true);
var actual2 = await sut.GetAppAsync(appId.Id, true);
var actual3 = await sut.GetAppByNameAsync(appId.Name, true);
Assert.Null(actual); Assert.Same(expected, actual1);
Assert.Same(expected, actual2);
Assert.Same(expected, actual3);
A.CallTo(() => grainFactory.GetGrain<IAppGrain>(appId.Id, null))
.MustHaveHappenedOnceExactly();
A.CallTo(() => indexByName.GetIdAsync(A<string>._))
.MustNotHaveHappened();
} }
[Fact] [Fact]
public async Task Should_return_null_if_app_not_created() public async Task Should_return_null_if_app_not_created()
{ {
SetupApp(-1, false); SetupApp(EtagVersion.NotFound);
var actual = await sut.GetAppAsync(appId.Id); var actual = await sut.GetAppAsync(appId.Id, false);
Assert.Null(actual); Assert.Null(actual);
} }
@ -258,6 +312,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
[Fact] [Fact]
public async Task Should_add_app_to_index_on_contributor_assignment() public async Task Should_add_app_to_index_on_contributor_assignment()
{ {
SetupApp();
var command = new AssignContributor { AppId = appId.Id, ContributorId = userId }; var command = new AssignContributor { AppId = appId.Id, ContributorId = userId };
var context = var context =
@ -273,6 +329,8 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
[Fact] [Fact]
public async Task Should_remove_from_user_index_on_remove_of_contributor() public async Task Should_remove_from_user_index_on_remove_of_contributor()
{ {
SetupApp();
var command = new RemoveContributor { AppId = appId.Id, ContributorId = userId }; var command = new RemoveContributor { AppId = appId.Id, ContributorId = userId };
var context = var context =
@ -285,10 +343,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
.MustHaveHappened(); .MustHaveHappened();
} }
[Theory, InlineData(true), InlineData(false)] [Fact]
public async Task Should_remove_app_from_indexes_on_archive(bool isArchived) public async Task Should_remove_app_from_indexes_on_archive()
{ {
SetupApp(0, isArchived); SetupApp();
var command = new ArchiveApp { AppId = appId.Id }; var command = new ArchiveApp { AppId = appId.Id };
@ -365,16 +423,16 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
.MustHaveHappened(); .MustHaveHappened();
} }
private IAppEntity SetupApp(long version, bool archived) private IAppEntity SetupApp(long version = 0)
{ {
var appEntity = A.Fake<IAppEntity>(); var appEntity = A.Fake<IAppEntity>();
A.CallTo(() => appEntity.Id)
.Returns(appId.Id);
A.CallTo(() => appEntity.Name) A.CallTo(() => appEntity.Name)
.Returns(appId.Name); .Returns(appId.Name);
A.CallTo(() => appEntity.Version) A.CallTo(() => appEntity.Version)
.Returns(version); .Returns(version);
A.CallTo(() => appEntity.IsArchived)
.Returns(archived);
A.CallTo(() => appEntity.Contributors) A.CallTo(() => appEntity.Contributors)
.Returns(AppContributors.Empty.Assign(userId, Role.Owner)); .Returns(AppContributors.Empty.Assign(userId, Role.Owner));

4
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs

@ -94,10 +94,10 @@ namespace Squidex.Domain.Apps.Entities.Contents
schema = Mocks.Schema(AppNamedId, SchemaNamedId, schemaDef); schema = Mocks.Schema(AppNamedId, SchemaNamedId, schemaDef);
A.CallTo(() => appProvider.GetAppAsync(AppName)) A.CallTo(() => appProvider.GetAppAsync(AppName, false))
.Returns(app); .Returns(app);
A.CallTo(() => appProvider.GetAppWithSchemaAsync(AppId, SchemaId)) A.CallTo(() => appProvider.GetAppWithSchemaAsync(AppId, SchemaId, false))
.Returns((app, schema)); .Returns((app, schema));
A.CallTo(() => scriptEngine.TransformAsync(A<ScriptVars>._, A<string>._, ScriptOptions())) A.CallTo(() => scriptEngine.TransformAsync(A<ScriptVars>._, A<string>._, ScriptOptions()))

4
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultWorkflowsValidatorTests.cs

@ -29,10 +29,10 @@ namespace Squidex.Domain.Apps.Entities.Contents
{ {
var schema = Mocks.Schema(appId, schemaId, new Schema(schemaId.Name)); var schema = Mocks.Schema(appId, schemaId, new Schema(schemaId.Name));
A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, A<Guid>._, false)) A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, A<Guid>._, false, false))
.Returns(Task.FromResult<ISchemaEntity?>(null)); .Returns(Task.FromResult<ISchemaEntity?>(null));
A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false)) A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false, false))
.Returns(schema); .Returns(schema);
sut = new DefaultWorkflowsValidator(appProvider); sut = new DefaultWorkflowsValidator(appProvider);

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs

@ -86,7 +86,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
var workflows = Workflows.Empty.Set(workflow).Set(Guid.NewGuid(), simpleWorkflow); var workflows = Workflows.Empty.Set(workflow).Set(Guid.NewGuid(), simpleWorkflow);
A.CallTo(() => appProvider.GetAppAsync(appId.Id)) A.CallTo(() => appProvider.GetAppAsync(appId.Id, false))
.Returns(app); .Returns(app);
A.CallTo(() => app.Workflows) A.CallTo(() => app.Workflows)

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs

@ -143,7 +143,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.MongoDb
{ {
var appProvider = A.Fake<IAppProvider>(); var appProvider = A.Fake<IAppProvider>();
A.CallTo(() => appProvider.GetSchemaAsync(A<Guid>._, A<Guid>._, false)) A.CallTo(() => appProvider.GetSchemaAsync(A<Guid>._, A<Guid>._, false, false))
.ReturnsLazily(x => Task.FromResult<ISchemaEntity?>(CreateSchema(x.GetArgument<Guid>(0)!, x.GetArgument<Guid>(1)!))); .ReturnsLazily(x => Task.FromResult<ISchemaEntity?>(CreateSchema(x.GetArgument<Guid>(0)!, x.GetArgument<Guid>(1)!)));
return appProvider; return appProvider;

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

@ -53,7 +53,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
SetupEnricher(); SetupEnricher();
A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Name)) A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Name, A<bool>._))
.Returns(schema); .Returns(schema);
A.CallTo(() => queryParser.ParseQueryAsync(A<Context>._, schema, A<Q>._)) A.CallTo(() => queryParser.ParseQueryAsync(A<Context>._, schema, A<Q>._))
@ -74,7 +74,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
var ctx = CreateContext(isFrontend: false, allowSchema: true); var ctx = CreateContext(isFrontend: false, allowSchema: true);
A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false)) A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false, true))
.Returns(schema); .Returns(schema);
var result = await sut.GetSchemaOrThrowAsync(ctx, input); var result = await sut.GetSchemaOrThrowAsync(ctx, input);
@ -89,7 +89,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
var ctx = CreateContext(isFrontend: false, allowSchema: true); var ctx = CreateContext(isFrontend: false, allowSchema: true);
A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Name)) A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Name, true))
.Returns(schema); .Returns(schema);
var result = await sut.GetSchemaOrThrowAsync(ctx, input); var result = await sut.GetSchemaOrThrowAsync(ctx, input);
@ -102,7 +102,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
{ {
var ctx = CreateContext(isFrontend: false, allowSchema: true); var ctx = CreateContext(isFrontend: false, allowSchema: true);
A.CallTo(() => appProvider.GetSchemaAsync(A<Guid>._, A<string>._)) A.CallTo(() => appProvider.GetSchemaAsync(A<Guid>._, A<string>._, true))
.Returns((ISchemaEntity?)null); .Returns((ISchemaEntity?)null);
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.GetSchemaOrThrowAsync(ctx, schemaId.Name)); await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.GetSchemaOrThrowAsync(ctx, schemaId.Name));

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferenceFluidExtensionTests.cs

@ -33,7 +33,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
new ReferencesFluidExtension(contentQuery, appProvider) new ReferencesFluidExtension(contentQuery, appProvider)
}; };
A.CallTo(() => appProvider.GetAppAsync(appId.Id)) A.CallTo(() => appProvider.GetAppAsync(appId.Id, false))
.Returns(Mocks.App(appId)); .Returns(Mocks.App(appId));
sut = new FluidTemplateEngine(extensions); sut = new FluidTemplateEngine(extensions);

2
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/GuardRuleTests.cs

@ -35,7 +35,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards
public GuardRuleTests() public GuardRuleTests()
{ {
A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false)) A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false, false))
.Returns(Mocks.Schema(appId, schemaId)); .Returns(Mocks.Schema(appId, schemaId));
} }

6
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/Triggers/ContentChangedTriggerTests.cs

@ -42,14 +42,14 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards.Triggers
new ValidationError("Schema id is required.", "Schemas") new ValidationError("Schema id is required.", "Schemas")
}); });
A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, A<Guid>._, false)) A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, A<Guid>._, false, false))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
[Fact] [Fact]
public async Task Should_add_error_if_schemas_ids_are_not_valid() public async Task Should_add_error_if_schemas_ids_are_not_valid()
{ {
A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false)) A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false, false))
.Returns(Task.FromResult<ISchemaEntity?>(null)); .Returns(Task.FromResult<ISchemaEntity?>(null));
var trigger = new ContentChangedTriggerV2 var trigger = new ContentChangedTriggerV2
@ -92,7 +92,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards.Triggers
[Fact] [Fact]
public async Task Should_not_add_error_if_schemas_ids_are_valid() public async Task Should_not_add_error_if_schemas_ids_are_valid()
{ {
A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, A<Guid>._, false)) A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, A<Guid>._, false, false))
.Returns(Mocks.Schema(appId, schemaId)); .Returns(Mocks.Schema(appId, schemaId));
var trigger = new ContentChangedTriggerV2 var trigger = new ContentChangedTriggerV2

12
backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesIndexTests.cs

@ -37,7 +37,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes
[Fact] [Fact]
public async Task Should_resolve_rules_by_id() public async Task Should_resolve_rules_by_id()
{ {
var rule = SetupRule(0, false); var rule = SetupRule(0);
A.CallTo(() => index.GetIdsAsync()) A.CallTo(() => index.GetIdsAsync())
.Returns(new List<Guid> { rule.Id }); .Returns(new List<Guid> { rule.Id });
@ -50,7 +50,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes
[Fact] [Fact]
public async Task Should_return_empty_rule_if_rule_not_created() public async Task Should_return_empty_rule_if_rule_not_created()
{ {
var rule = SetupRule(-1, false); var rule = SetupRule(-1);
A.CallTo(() => index.GetIdsAsync()) A.CallTo(() => index.GetIdsAsync())
.Returns(new List<Guid> { rule.Id }); .Returns(new List<Guid> { rule.Id });
@ -63,7 +63,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes
[Fact] [Fact]
public async Task Should_return_empty_rule_if_rule_deleted() public async Task Should_return_empty_rule_if_rule_deleted()
{ {
var rule = SetupRule(-1, false); var rule = SetupRule(-1);
A.CallTo(() => index.GetIdsAsync()) A.CallTo(() => index.GetIdsAsync())
.Returns(new List<Guid> { rule.Id }); .Returns(new List<Guid> { rule.Id });
@ -93,7 +93,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes
[Fact] [Fact]
public async Task Should_remove_rule_from_index_on_delete() public async Task Should_remove_rule_from_index_on_delete()
{ {
var rule = SetupRule(0, false); var rule = SetupRule(0);
var command = new DeleteRule { RuleId = rule.Id }; var command = new DeleteRule { RuleId = rule.Id };
@ -118,11 +118,11 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes
.MustHaveHappened(); .MustHaveHappened();
} }
private IRuleEntity SetupRule(long version, bool deleted) private IRuleEntity SetupRule(long version)
{ {
var ruleId = Guid.NewGuid(); var ruleId = Guid.NewGuid();
var ruleEntity = new RuleEntity { Id = ruleId, AppId = appId, Version = version, IsDeleted = deleted }; var ruleEntity = new RuleEntity { Id = ruleId, AppId = appId, Version = version };
var ruleGrain = A.Fake<IRuleGrain>(); var ruleGrain = A.Fake<IRuleGrain>();
A.CallTo(() => ruleGrain.GetStateAsync()) A.CallTo(() => ruleGrain.GetStateAsync())

113
backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasIndexTests.cs

@ -9,10 +9,13 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Orleans; using Orleans;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Schemas.Commands; using Squidex.Domain.Apps.Entities.Schemas.Commands;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Caching;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
@ -36,36 +39,97 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes
A.CallTo(() => grainFactory.GetGrain<ISchemasByAppIndexGrain>(appId.Id, null)) A.CallTo(() => grainFactory.GetGrain<ISchemasByAppIndexGrain>(appId.Id, null))
.Returns(index); .Returns(index);
sut = new SchemasIndex(grainFactory); var cache = new ReplicatedCache(new MemoryCache(Options.Create(new MemoryCacheOptions())), new SimplePubSub());
sut = new SchemasIndex(grainFactory, cache);
}
[Fact]
public async Task Should_resolve_schema_by_name()
{
var expected = SetupSchema();
A.CallTo(() => index.GetIdAsync(schemaId.Name))
.Returns(schemaId.Id);
var actual1 = await sut.GetSchemaByNameAsync(appId.Id, schemaId.Name, false);
var actual2 = await sut.GetSchemaByNameAsync(appId.Id, schemaId.Name, false);
Assert.Same(expected, actual1);
Assert.Same(expected, actual2);
A.CallTo(() => grainFactory.GetGrain<ISchemaGrain>(schemaId.Id, null))
.MustHaveHappenedTwiceExactly();
A.CallTo(() => index.GetIdAsync(A<string>._))
.MustHaveHappenedTwiceExactly();
}
[Fact]
public async Task Should_resolve_schema_by_name_and_id_if_cached_before()
{
var expected = SetupSchema();
A.CallTo(() => index.GetIdAsync(schemaId.Name))
.Returns(schemaId.Id);
var actual1 = await sut.GetSchemaByNameAsync(appId.Id, schemaId.Name, true);
var actual2 = await sut.GetSchemaByNameAsync(appId.Id, schemaId.Name, true);
var actual3 = await sut.GetSchemaAsync(appId.Id, schemaId.Id, true);
Assert.Same(expected, actual1);
Assert.Same(expected, actual2);
Assert.Same(expected, actual3);
A.CallTo(() => grainFactory.GetGrain<ISchemaGrain>(schemaId.Id, null))
.MustHaveHappenedOnceExactly();
A.CallTo(() => index.GetIdAsync(A<string>._))
.MustHaveHappenedOnceExactly();
} }
[Fact] [Fact]
public async Task Should_resolve_schema_by_id() public async Task Should_resolve_schema_by_id()
{ {
var schema = SetupSchema(0, false); var expected = SetupSchema();
var actual1 = await sut.GetSchemaAsync(appId.Id, schemaId.Id, false);
var actual2 = await sut.GetSchemaAsync(appId.Id, schemaId.Id, false);
Assert.Same(expected, actual1);
Assert.Same(expected, actual2);
var actual = await sut.GetSchemaAsync(appId.Id, schema.Id); A.CallTo(() => grainFactory.GetGrain<ISchemaGrain>(schemaId.Id, null))
.MustHaveHappenedTwiceExactly();
Assert.Same(actual, schema); A.CallTo(() => index.GetIdAsync(A<string>._))
.MustNotHaveHappened();
} }
[Fact] [Fact]
public async Task Should_resolve_schema_by_name() public async Task Should_resolve_schema_by_id_and_name_if_cached_before()
{ {
var schema = SetupSchema(0, false); var expected = SetupSchema();
A.CallTo(() => index.GetIdAsync(schema.SchemaDef.Name)) var actual1 = await sut.GetSchemaAsync(appId.Id, schemaId.Id, true);
.Returns(schema.Id); var actual2 = await sut.GetSchemaAsync(appId.Id, schemaId.Id, true);
var actual3 = await sut.GetSchemaByNameAsync(appId.Id, schemaId.Name, true);
var actual = await sut.GetSchemaByNameAsync(appId.Id, schema.SchemaDef.Name); Assert.Same(expected, actual1);
Assert.Same(expected, actual2);
Assert.Same(expected, actual3);
Assert.Same(actual, schema); A.CallTo(() => grainFactory.GetGrain<ISchemaGrain>(schemaId.Id, null))
.MustHaveHappenedOnceExactly();
A.CallTo(() => index.GetIdAsync(A<string>._))
.MustNotHaveHappened();
} }
[Fact] [Fact]
public async Task Should_resolve_schemas_by_id() public async Task Should_resolve_schemas_by_id()
{ {
var schema = SetupSchema(0, false); var schema = SetupSchema();
A.CallTo(() => index.GetIdsAsync()) A.CallTo(() => index.GetIdsAsync())
.Returns(new List<Guid> { schema.Id }); .Returns(new List<Guid> { schema.Id });
@ -78,7 +142,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes
[Fact] [Fact]
public async Task Should_return_empty_schema_if_schema_not_created() public async Task Should_return_empty_schema_if_schema_not_created()
{ {
var schema = SetupSchema(-1, false); var schema = SetupSchema(EtagVersion.NotFound);
A.CallTo(() => index.GetIdsAsync()) A.CallTo(() => index.GetIdsAsync())
.Returns(new List<Guid> { schema.Id }); .Returns(new List<Guid> { schema.Id });
@ -91,7 +155,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes
[Fact] [Fact]
public async Task Should_return_empty_schema_if_schema_deleted() public async Task Should_return_empty_schema_if_schema_deleted()
{ {
var schema = SetupSchema(0, true); var schema = SetupSchema();
A.CallTo(() => index.GetIdAsync(schema.SchemaDef.Name)) A.CallTo(() => index.GetIdAsync(schema.SchemaDef.Name))
.Returns(schema.Id); .Returns(schema.Id);
@ -101,19 +165,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes
Assert.Empty(actual); Assert.Empty(actual);
} }
[Fact]
public async Task Should_also_return_schema_if_deleted_allowed()
{
var schema = SetupSchema(-1, true);
A.CallTo(() => index.GetIdAsync(schema.SchemaDef.Name))
.Returns(schema.Id);
var actual = await sut.GetSchemasAsync(appId.Id, true);
Assert.Empty(actual);
}
[Fact] [Fact]
public async Task Should_add_schema_to_index_on_create() public async Task Should_add_schema_to_index_on_create()
{ {
@ -190,10 +241,10 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
[Theory, InlineData(true), InlineData(false)] [Fact]
public async Task Should_remove_schema_from_index_on_delete_when_existed_before(bool isDeleted) public async Task Should_remove_schema_from_index_on_delete_when_existed_before()
{ {
var schema = SetupSchema(0, isDeleted); var schema = SetupSchema();
var command = new DeleteSchema { SchemaId = schema.Id }; var command = new DeleteSchema { SchemaId = schema.Id };
@ -223,7 +274,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes
return new CreateSchema { SchemaId = schemaId.Id, Name = name, AppId = appId }; return new CreateSchema { SchemaId = schemaId.Id, Name = name, AppId = appId };
} }
private ISchemaEntity SetupSchema(long version, bool deleted) private ISchemaEntity SetupSchema(long version = 0)
{ {
var schemaEntity = A.Fake<ISchemaEntity>(); var schemaEntity = A.Fake<ISchemaEntity>();
@ -235,8 +286,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes
.Returns(appId); .Returns(appId);
A.CallTo(() => schemaEntity.Version) A.CallTo(() => schemaEntity.Version)
.Returns(version); .Returns(version);
A.CallTo(() => schemaEntity.IsDeleted)
.Returns(deleted);
var schemaGrain = A.Fake<ISchemaGrain>(); var schemaGrain = A.Fake<ISchemaGrain>();

36
backend/tests/Squidex.Infrastructure.Tests/Caching/AsyncLocalCacheTests.cs

@ -24,19 +24,13 @@ namespace Squidex.Infrastructure.Caching
await Task.Delay(5); await Task.Delay(5);
var found = sut.TryGetValue("Key", out var value); AssertCache(sut, "Key", 1, true);
Assert.True(found);
Assert.Equal(1, value);
await Task.Delay(5); await Task.Delay(5);
sut.Remove("Key"); sut.Remove("Key");
var foundAfterRemove = sut.TryGetValue("Key", out value); AssertCache(sut, "Key", null, false);
Assert.False(foundAfterRemove);
Assert.Null(value);
} }
} }
@ -47,19 +41,13 @@ namespace Squidex.Infrastructure.Caching
await Task.Delay(5); await Task.Delay(5);
var found = sut.TryGetValue("Key", out var value); AssertCache(sut, "Key", null, false);
Assert.False(found);
Assert.Null(value);
sut.Remove("Key"); sut.Remove("Key");
await Task.Delay(5); await Task.Delay(5);
var foundAfterRemove = sut.TryGetValue("Key", out value); AssertCache(sut, "Key", null, false);
Assert.False(foundAfterRemove);
Assert.Null(value);
} }
[Fact] [Fact]
@ -67,11 +55,11 @@ namespace Squidex.Infrastructure.Caching
{ {
using (sut.StartContext()) using (sut.StartContext())
{ {
var value1 = sut.GetOrCreate("Key", () => ++called); var value1 = await sut.GetOrCreateAsync("Key", () => Task.FromResult(++called));
await Task.Delay(5); await Task.Delay(5);
var value2 = sut.GetOrCreate("Key", () => ++called); var value2 = await sut.GetOrCreateAsync("Key", () => Task.FromResult(++called));
Assert.Equal(1, called); Assert.Equal(1, called);
Assert.Equal(1, value1); Assert.Equal(1, value1);
@ -82,11 +70,11 @@ namespace Squidex.Infrastructure.Caching
[Fact] [Fact]
public async Task Should_call_creator_twice_when_context_not_exists() public async Task Should_call_creator_twice_when_context_not_exists()
{ {
var value1 = sut.GetOrCreate("Key", () => ++called); var value1 = await sut.GetOrCreateAsync("Key", () => Task.FromResult(++called));
await Task.Delay(5); await Task.Delay(5);
var value2 = sut.GetOrCreate("Key", () => ++called); var value2 = await sut.GetOrCreateAsync("Key", () => Task.FromResult(++called));
Assert.Equal(2, called); Assert.Equal(2, called);
Assert.Equal(1, value1); Assert.Equal(1, value1);
@ -123,5 +111,13 @@ namespace Squidex.Infrastructure.Caching
Assert.Equal(1, value1); Assert.Equal(1, value1);
Assert.Equal(2, value2); Assert.Equal(2, value2);
} }
private static void AssertCache(ILocalCache cache, string key, object? expectedValue, bool expectedFound)
{
var found = cache.TryGetValue(key, out var value);
Assert.Equal(expectedFound, found);
Assert.Equal(expectedValue, value);
}
} }
} }

100
backend/tests/Squidex.Infrastructure.Tests/Caching/ReplicatedCacheTests.cs

@ -0,0 +1,100 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Xunit;
namespace Squidex.Infrastructure.Caching
{
public class ReplicatedCacheTests
{
private readonly IPubSub pubSub = new SimplePubSub();
private readonly ReplicatedCache sut;
public ReplicatedCacheTests()
{
sut = new ReplicatedCache(CreateMemoryCache(), pubSub);
}
[Fact]
public void Should_serve_from_cache()
{
sut.Add("Key", 1, TimeSpan.FromMinutes(10), true);
AssertCache(sut, "Key", 1, true);
sut.Remove("Key");
AssertCache(sut, "Key", null, false);
}
[Fact]
public async Task Should_not_served_when_expired()
{
sut.Add("Key", 1, TimeSpan.FromMilliseconds(1), true);
await Task.Delay(100);
AssertCache(sut, "Key", null, false);
}
[Fact]
public void Should_not_invalidate_other_instances_when_item_added_and_flag_is_false()
{
var cache1 = new ReplicatedCache(CreateMemoryCache(), pubSub);
var cache2 = new ReplicatedCache(CreateMemoryCache(), pubSub);
cache1.Add("Key", 1, TimeSpan.FromMinutes(1), false);
cache2.Add("Key", 2, TimeSpan.FromMinutes(1), false);
AssertCache(cache1, "Key", 1, true);
AssertCache(cache2, "Key", 2, true);
}
[Fact]
public void Should_invalidate_other_instances_when_item_added_and_flag_is_true()
{
var cache1 = new ReplicatedCache(CreateMemoryCache(), pubSub);
var cache2 = new ReplicatedCache(CreateMemoryCache(), pubSub);
cache1.Add("Key", 1, TimeSpan.FromMinutes(1), true);
cache2.Add("Key", 2, TimeSpan.FromMinutes(1), true);
AssertCache(cache1, "Key", null, false);
AssertCache(cache2, "Key", 2, true);
}
[Fact]
public void Should_invalidate_other_instances_when_item_removed()
{
var cache1 = new ReplicatedCache(CreateMemoryCache(), pubSub);
var cache2 = new ReplicatedCache(CreateMemoryCache(), pubSub);
cache1.Add("Key", 1, TimeSpan.FromMinutes(1), true);
cache2.Remove("Key");
AssertCache(cache1, "Key", null, false);
AssertCache(cache2, "Key", null, false);
}
private static void AssertCache(IReplicatedCache cache, string key, object? expectedValue, bool expectedFound)
{
var found = cache.TryGetValue(key, out var value);
Assert.Equal(expectedFound, found);
Assert.Equal(expectedValue, value);
}
private static MemoryCache CreateMemoryCache()
{
return new MemoryCache(Options.Create(new MemoryCacheOptions()));
}
}
}

79
backend/tests/Squidex.Infrastructure.Tests/Orleans/PubSubTests.cs

@ -0,0 +1,79 @@
// ==========================================================================
// 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 Orleans;
using Orleans.TestingHost;
using Xunit;
namespace Squidex.Infrastructure.Orleans
{
public class PubSubTests
{
[Fact]
public async Task Simple_pubsub_tests()
{
var cluster = new TestClusterBuilder(3).Build();
await cluster.DeployAsync();
var sent = new HashSet<Guid>
{
Guid.NewGuid(),
Guid.NewGuid(),
Guid.NewGuid(),
};
var received1 = await CreateSubscriber(cluster.Client, sent.Count);
var received2 = await CreateSubscriber(cluster.Client, sent.Count);
var pubSub = new OrleansPubSub(cluster.Client);
foreach (var message in sent)
{
pubSub.Publish(message);
}
await Task.WhenAny(
Task.WhenAll(
received1,
received2
),
Task.Delay(10000));
Assert.True(received1.Result.SetEquals(sent));
Assert.True(received2.Result.SetEquals(sent));
}
private async Task<Task<HashSet<Guid>>> CreateSubscriber(IGrainFactory grainFactory, int expectedCount)
{
var pubSub = new OrleansPubSub(grainFactory);
await pubSub.StartAsync(default);
var received = new HashSet<Guid>();
var receivedCompleted = new TaskCompletionSource<HashSet<Guid>>();
pubSub.Subscribe(message =>
{
if (message is Guid guid)
{
received.Add(guid);
}
if (received.Count == expectedCount)
{
receivedCompleted.TrySetResult(received);
}
});
return receivedCompleted.Task;
}
}
}

1
backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj

@ -24,6 +24,7 @@
<PackageReference Include="Google.Cloud.Storage.V1" Version="3.1.0" /> <PackageReference Include="Google.Cloud.Storage.V1" Version="3.1.0" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="3.1.4" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="3.1.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.6.1" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.6.1" />
<PackageReference Include="Microsoft.Orleans.TestingHost" Version="3.2.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" /> <PackageReference Include="System.ValueTuple" Version="4.5.0" />

77
backend/tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithSchemaIdCommandMiddlewareTests.cs

@ -9,102 +9,47 @@ using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using FakeItEasy; using FakeItEasy;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Routing;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Apps;
using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Domain.Apps.Entities.Contents.Commands;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.Schemas.Commands; using Squidex.Domain.Apps.Entities.Schemas.Commands;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Web.Pipeline;
using Xunit; using Xunit;
namespace Squidex.Web.CommandMiddlewares namespace Squidex.Web.CommandMiddlewares
{ {
public class EnrichWithSchemaIdCommandMiddlewareTests public class EnrichWithSchemaIdCommandMiddlewareTests
{ {
private readonly IActionContextAccessor actionContextAccessor = A.Fake<IActionContextAccessor>(); private readonly IHttpContextAccessor httpContextAccessor = A.Fake<IHttpContextAccessor>();
private readonly IAppProvider appProvider = A.Fake<IAppProvider>();
private readonly ICommandBus commandBus = A.Fake<ICommandBus>(); private readonly ICommandBus commandBus = A.Fake<ICommandBus>();
private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app"); private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app");
private readonly NamedId<Guid> schemaId = NamedId.Of(Guid.NewGuid(), "my-schema"); private readonly NamedId<Guid> schemaId = NamedId.Of(Guid.NewGuid(), "my-schema");
private readonly HttpContext httpContext = new DefaultHttpContext(); private readonly HttpContext httpContext = new DefaultHttpContext();
private readonly ActionContext actionContext = new ActionContext();
private readonly EnrichWithSchemaIdCommandMiddleware sut; private readonly EnrichWithSchemaIdCommandMiddleware sut;
public EnrichWithSchemaIdCommandMiddlewareTests() public EnrichWithSchemaIdCommandMiddlewareTests()
{ {
actionContext.RouteData = new RouteData(); httpContext.Features.Set<IAppFeature>(new AppFeature(appId));
actionContext.HttpContext = httpContext;
A.CallTo(() => actionContextAccessor.ActionContext) A.CallTo(() => httpContextAccessor.HttpContext)
.Returns(actionContext); .Returns(httpContext);
var app = A.Fake<IAppEntity>(); sut = new EnrichWithSchemaIdCommandMiddleware(httpContextAccessor);
A.CallTo(() => app.Id).Returns(appId.Id);
A.CallTo(() => app.Name).Returns(appId.Name);
httpContext.Context().App = app;
var schema = A.Fake<ISchemaEntity>();
A.CallTo(() => schema.Id).Returns(schemaId.Id);
A.CallTo(() => schema.SchemaDef).Returns(new Schema(schemaId.Name));
A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Name))
.Returns(schema);
A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false))
.Returns(schema);
sut = new EnrichWithSchemaIdCommandMiddleware(appProvider, actionContextAccessor);
} }
[Fact] [Fact]
public async Task Should_throw_exception_if_schema_not_found() public async Task Should_throw_exception_if_schema_not_found()
{ {
A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, "other-schema"))
.Returns(Task.FromResult<ISchemaEntity?>(null));
actionContext.RouteData.Values["name"] = "other-schema";
var command = new CreateContent { AppId = appId }; var command = new CreateContent { AppId = appId };
var context = Ctx(command); var context = Ctx(command);
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.HandleAsync(context)); await Assert.ThrowsAsync<InvalidOperationException>(() => sut.HandleAsync(context));
}
[Fact]
public async Task Should_do_nothing_when_route_has_no_parameter()
{
var command = new CreateContent();
var context = Ctx(command);
await sut.HandleAsync(context);
Assert.Null(command.Actor);
}
[Fact]
public async Task Should_assign_schema_id_and_name_from_name()
{
actionContext.RouteData.Values["name"] = schemaId.Name;
var command = new CreateContent { AppId = appId };
var context = Ctx(command);
await sut.HandleAsync(context);
Assert.Equal(schemaId, command.SchemaId);
} }
[Fact] [Fact]
public async Task Should_assign_schema_id_and_name_from_id() public async Task Should_assign_schema_id_and_name_from_id()
{ {
actionContext.RouteData.Values["name"] = schemaId.Id; httpContext.Features.Set<ISchemaFeature>(new SchemaFeature(schemaId));
var command = new CreateContent { AppId = appId }; var command = new CreateContent { AppId = appId };
var context = Ctx(command); var context = Ctx(command);
@ -117,7 +62,7 @@ namespace Squidex.Web.CommandMiddlewares
[Fact] [Fact]
public async Task Should_assign_schema_id_from_id() public async Task Should_assign_schema_id_from_id()
{ {
actionContext.RouteData.Values["name"] = schemaId.Name; httpContext.Features.Set<ISchemaFeature>(new SchemaFeature(schemaId));
var command = new UpdateSchema(); var command = new UpdateSchema();
var context = Ctx(command); var context = Ctx(command);
@ -130,6 +75,8 @@ namespace Squidex.Web.CommandMiddlewares
[Fact] [Fact]
public async Task Should_not_override_schema_id() public async Task Should_not_override_schema_id()
{ {
httpContext.Features.Set<ISchemaFeature>(new SchemaFeature(schemaId));
var command = new CreateSchema { SchemaId = Guid.NewGuid() }; var command = new CreateSchema { SchemaId = Guid.NewGuid() };
var context = Ctx(command); var context = Ctx(command);
@ -141,6 +88,8 @@ namespace Squidex.Web.CommandMiddlewares
[Fact] [Fact]
public async Task Should_not_override_schema_id_and_name() public async Task Should_not_override_schema_id_and_name()
{ {
httpContext.Features.Set<ISchemaFeature>(new SchemaFeature(schemaId));
var command = new CreateContent { SchemaId = NamedId.Of(Guid.NewGuid(), "other-schema") }; var command = new CreateContent { SchemaId = NamedId.Of(Guid.NewGuid(), "other-schema") };
var context = Ctx(command); var context = Ctx(command);

37
backend/tests/Squidex.Web.Tests/Pipeline/AppResolverTests.cs

@ -34,13 +34,16 @@ namespace Squidex.Web.Pipeline
private readonly ActionContext actionContext; private readonly ActionContext actionContext;
private readonly ActionExecutingContext actionExecutingContext; private readonly ActionExecutingContext actionExecutingContext;
private readonly ActionExecutionDelegate next; private readonly ActionExecutionDelegate next;
private readonly ClaimsIdentity user = new ClaimsIdentity(); private readonly ClaimsIdentity userIdentiy = new ClaimsIdentity();
private readonly ClaimsPrincipal user;
private readonly string appName = "my-app"; private readonly string appName = "my-app";
private readonly AppResolver sut; private readonly AppResolver sut;
private bool isNextCalled; private bool isNextCalled;
public AppResolverTests() public AppResolverTests()
{ {
user = new ClaimsPrincipal(userIdentiy);
actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor
{ {
EndpointMetadata = new List<object>() EndpointMetadata = new List<object>()
@ -48,7 +51,7 @@ namespace Squidex.Web.Pipeline
actionExecutingContext = new ActionExecutingContext(actionContext, new List<IFilterMetadata>(), new Dictionary<string, object>(), this); actionExecutingContext = new ActionExecutingContext(actionContext, new List<IFilterMetadata>(), new Dictionary<string, object>(), this);
actionExecutingContext.HttpContext = httpContext; actionExecutingContext.HttpContext = httpContext;
actionExecutingContext.HttpContext.User = new ClaimsPrincipal(user); actionExecutingContext.HttpContext.User = user;
actionExecutingContext.RouteData.Values["app"] = appName; actionExecutingContext.RouteData.Values["app"] = appName;
next = () => next = () =>
@ -64,7 +67,7 @@ namespace Squidex.Web.Pipeline
[Fact] [Fact]
public async Task Should_return_not_found_if_app_not_found() public async Task Should_return_not_found_if_app_not_found()
{ {
A.CallTo(() => appProvider.GetAppAsync(appName)) A.CallTo(() => appProvider.GetAppAsync(appName, false))
.Returns(Task.FromResult<IAppEntity?>(null)); .Returns(Task.FromResult<IAppEntity?>(null));
await sut.OnActionExecutionAsync(actionExecutingContext, next); await sut.OnActionExecutionAsync(actionExecutingContext, next);
@ -78,10 +81,10 @@ namespace Squidex.Web.Pipeline
{ {
var app = CreateApp(appName, appUser: "user1"); var app = CreateApp(appName, appUser: "user1");
user.AddClaim(new Claim(OpenIdClaims.Subject, "user1")); userIdentiy.AddClaim(new Claim(OpenIdClaims.Subject, "user1"));
user.AddClaim(new Claim(SquidexClaimTypes.Permissions, "squidex.apps.my-app")); userIdentiy.AddClaim(new Claim(SquidexClaimTypes.Permissions, "squidex.apps.my-app"));
A.CallTo(() => appProvider.GetAppAsync(appName)) A.CallTo(() => appProvider.GetAppAsync(appName, true))
.Returns(app); .Returns(app);
await sut.OnActionExecutionAsync(actionExecutingContext, next); await sut.OnActionExecutionAsync(actionExecutingContext, next);
@ -96,9 +99,9 @@ namespace Squidex.Web.Pipeline
{ {
var app = CreateApp(appName, appClient: "client1"); var app = CreateApp(appName, appClient: "client1");
user.AddClaim(new Claim(OpenIdClaims.ClientId, $"{appName}:client1")); userIdentiy.AddClaim(new Claim(OpenIdClaims.ClientId, $"{appName}:client1"));
A.CallTo(() => appProvider.GetAppAsync(appName)) A.CallTo(() => appProvider.GetAppAsync(appName, true))
.Returns(app); .Returns(app);
await sut.OnActionExecutionAsync(actionExecutingContext, next); await sut.OnActionExecutionAsync(actionExecutingContext, next);
@ -113,12 +116,12 @@ namespace Squidex.Web.Pipeline
{ {
var app = CreateApp(appName); var app = CreateApp(appName);
user.AddClaim(new Claim(OpenIdClaims.ClientId, $"{appName}:client1")); userIdentiy.AddClaim(new Claim(OpenIdClaims.ClientId, $"{appName}:client1"));
user.AddClaim(new Claim(SquidexClaimTypes.Permissions, "squidex.apps.other-app")); userIdentiy.AddClaim(new Claim(SquidexClaimTypes.Permissions, "squidex.apps.other-app"));
actionContext.ActionDescriptor.EndpointMetadata.Add(new AllowAnonymousAttribute()); actionContext.ActionDescriptor.EndpointMetadata.Add(new AllowAnonymousAttribute());
A.CallTo(() => appProvider.GetAppAsync(appName)) A.CallTo(() => appProvider.GetAppAsync(appName, true))
.Returns(app); .Returns(app);
await sut.OnActionExecutionAsync(actionExecutingContext, next); await sut.OnActionExecutionAsync(actionExecutingContext, next);
@ -133,10 +136,10 @@ namespace Squidex.Web.Pipeline
{ {
var app = CreateApp(appName); var app = CreateApp(appName);
user.AddClaim(new Claim(OpenIdClaims.ClientId, $"{appName}:client1")); userIdentiy.AddClaim(new Claim(OpenIdClaims.ClientId, $"{appName}:client1"));
user.AddClaim(new Claim(SquidexClaimTypes.Permissions, "squidex.apps.other-app")); userIdentiy.AddClaim(new Claim(SquidexClaimTypes.Permissions, "squidex.apps.other-app"));
A.CallTo(() => appProvider.GetAppAsync(appName)) A.CallTo(() => appProvider.GetAppAsync(appName, false))
.Returns(app); .Returns(app);
await sut.OnActionExecutionAsync(actionExecutingContext, next); await sut.OnActionExecutionAsync(actionExecutingContext, next);
@ -150,9 +153,9 @@ namespace Squidex.Web.Pipeline
{ {
var app = CreateApp(appName, appClient: "client1"); var app = CreateApp(appName, appClient: "client1");
user.AddClaim(new Claim(OpenIdClaims.ClientId, "other:client1")); userIdentiy.AddClaim(new Claim(OpenIdClaims.ClientId, "other:client1"));
A.CallTo(() => appProvider.GetAppAsync(appName)) A.CallTo(() => appProvider.GetAppAsync(appName, false))
.Returns(app); .Returns(app);
await sut.OnActionExecutionAsync(actionExecutingContext, next); await sut.OnActionExecutionAsync(actionExecutingContext, next);
@ -170,7 +173,7 @@ namespace Squidex.Web.Pipeline
Assert.True(isNextCalled); Assert.True(isNextCalled);
A.CallTo(() => appProvider.GetAppAsync(A<string>._)) A.CallTo(() => appProvider.GetAppAsync(A<string>._, false))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }

60
backend/tests/Squidex.Web.Tests/Pipeline/SchemaResolverTests.cs

@ -19,6 +19,8 @@ using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities;
using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Security;
using Squidex.Shared;
using Xunit; using Xunit;
#pragma warning disable IDE0017 // Simplify object initialization #pragma warning disable IDE0017 // Simplify object initialization
@ -48,6 +50,7 @@ namespace Squidex.Web.Pipeline
actionExecutingContext = new ActionExecutingContext(actionContext, new List<IFilterMetadata>(), new Dictionary<string, object>(), this); actionExecutingContext = new ActionExecutingContext(actionContext, new List<IFilterMetadata>(), new Dictionary<string, object>(), this);
actionExecutingContext.HttpContext = httpContext; actionExecutingContext.HttpContext = httpContext;
actionExecutingContext.HttpContext.User = new ClaimsPrincipal(user); actionExecutingContext.HttpContext.User = new ClaimsPrincipal(user);
actionExecutingContext.HttpContext.Features.Set<IAppFeature>(new AppFeature(appId));
next = () => next = () =>
{ {
@ -62,10 +65,9 @@ namespace Squidex.Web.Pipeline
[Fact] [Fact]
public async Task Should_return_not_found_if_schema_not_found() public async Task Should_return_not_found_if_schema_not_found()
{ {
actionExecutingContext.HttpContext.Features.Set<IAppFeature>(new AppFeature(appId));
actionContext.RouteData.Values["name"] = schemaId.Id.ToString(); actionContext.RouteData.Values["name"] = schemaId.Id.ToString();
A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false)) A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false, true))
.Returns(Task.FromResult<ISchemaEntity?>(null)); .Returns(Task.FromResult<ISchemaEntity?>(null));
await sut.OnActionExecutionAsync(actionExecutingContext, next); await sut.OnActionExecutionAsync(actionExecutingContext, next);
@ -77,12 +79,29 @@ namespace Squidex.Web.Pipeline
[Fact] [Fact]
public async Task Should_resolve_schema_from_id() public async Task Should_resolve_schema_from_id()
{ {
actionExecutingContext.HttpContext.Features.Set<IAppFeature>(new AppFeature(appId));
actionContext.RouteData.Values["name"] = schemaId.Id.ToString(); actionContext.RouteData.Values["name"] = schemaId.Id.ToString();
var schema = CreateSchema(); var schema = CreateSchema();
A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false)) A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false, true))
.Returns(schema);
await sut.OnActionExecutionAsync(actionExecutingContext, next);
Assert.Equal(schemaId, actionContext.HttpContext.Features.Get<ISchemaFeature>().SchemaId);
Assert.True(isNextCalled);
}
[Fact]
public async Task Should_resolve_schema_from_id_without_caching_if_frontend()
{
user.AddClaim(new Claim(OpenIdClaims.ClientId, DefaultClients.Frontend));
actionContext.RouteData.Values["name"] = schemaId.Id.ToString();
var schema = CreateSchema();
A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false, false))
.Returns(schema); .Returns(schema);
await sut.OnActionExecutionAsync(actionExecutingContext, next); await sut.OnActionExecutionAsync(actionExecutingContext, next);
@ -94,12 +113,11 @@ namespace Squidex.Web.Pipeline
[Fact] [Fact]
public async Task Should_resolve_schema_from_name() public async Task Should_resolve_schema_from_name()
{ {
actionExecutingContext.HttpContext.Features.Set<IAppFeature>(new AppFeature(appId));
actionContext.RouteData.Values["name"] = schemaId.Name; actionContext.RouteData.Values["name"] = schemaId.Name;
var schema = CreateSchema(); var schema = CreateSchema();
A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Name)) A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Name, true))
.Returns(schema); .Returns(schema);
await sut.OnActionExecutionAsync(actionExecutingContext, next); await sut.OnActionExecutionAsync(actionExecutingContext, next);
@ -109,29 +127,45 @@ namespace Squidex.Web.Pipeline
} }
[Fact] [Fact]
public async Task Should_do_nothing_if_app_feature_not_set() public async Task Should_resolve_schema_from_name_without_caching_if_frontend()
{ {
actionExecutingContext.RouteData.Values["name"] = schemaId.Name; user.AddClaim(new Claim(OpenIdClaims.ClientId, DefaultClients.Frontend));
actionContext.RouteData.Values["name"] = schemaId.Name;
var schema = CreateSchema();
A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Name, false))
.Returns(schema);
await sut.OnActionExecutionAsync(actionExecutingContext, next);
Assert.Equal(schemaId, actionContext.HttpContext.Features.Get<ISchemaFeature>().SchemaId);
Assert.True(isNextCalled);
}
[Fact]
public async Task Should_do_nothing_if_parameter_not_set()
{
await sut.OnActionExecutionAsync(actionExecutingContext, next); await sut.OnActionExecutionAsync(actionExecutingContext, next);
Assert.True(isNextCalled); Assert.True(isNextCalled);
A.CallTo(() => appProvider.GetAppAsync(A<string>._)) A.CallTo(() => appProvider.GetAppAsync(A<string>._, false))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }
[Fact] [Fact]
public async Task Should_do_nothing_if_parameter_not_set() public async Task Should_do_nothing_if_app_feature_not_set()
{ {
actionExecutingContext.HttpContext.Features.Set<IAppFeature>(new AppFeature(appId)); actionExecutingContext.HttpContext.Features.Set<IAppFeature>(null!);
actionExecutingContext.RouteData.Values.Remove("name"); actionExecutingContext.RouteData.Values["name"] = schemaId.Name;
await sut.OnActionExecutionAsync(actionExecutingContext, next); await sut.OnActionExecutionAsync(actionExecutingContext, next);
Assert.True(isNextCalled); Assert.True(isNextCalled);
A.CallTo(() => appProvider.GetAppAsync(A<string>._)) A.CallTo(() => appProvider.GetAppAsync(A<string>._, false))
.MustNotHaveHappened(); .MustNotHaveHappened();
} }

1
backend/tools/k6/docker-compose.yml

@ -12,6 +12,7 @@ services:
- "8086:8086" - "8086:8086"
environment: environment:
- INFLUXDB_DB=k6 - INFLUXDB_DB=k6
- INFLUXDB_HTTP_MAX_BODY_SIZE=0
grafana: grafana:
image: grafana/grafana:latest image: grafana/grafana:latest

13
backend/tools/k6/get-clients.js

@ -2,20 +2,19 @@ import { check } from 'k6';
import http from 'k6/http'; import http from 'k6/http';
import { variables, getBearerToken } from './shared.js'; import { variables, getBearerToken } from './shared.js';
export let options = { export const options = {
stages: [ stages: [
{ duration: "2m", target: 200 }, { duration: "2m", target: 500 },
{ duration: "2m", target: 200 },
{ duration: "2m", target: 0 }, { duration: "2m", target: 0 },
], ],
thresholds: { thresholds: {
'http_req_duration': ['p(99)<1500'], // 99% of requests must complete below 1.5s 'http_req_duration': ['p(99)<300'], // 99% of requests must complete below 300ms
} },
discardResponseBodies: true
}; };
export function setup() { export function setup() {
const token = getBearerToken(); const token = getBearerToken(variables.appName);
return { token }; return { token };
} }

33
backend/tools/k6/get-content.js

@ -0,0 +1,33 @@
import http from 'k6/http';
import { check } from 'k6';
import { variables, getBearerToken } from './shared.js';
export const options = {
stages: [
{ duration: "2m", target: 500 },
{ duration: "2m", target: 0 },
],
thresholds: {
'http_req_duration': ['p(99)<300'], // 99% of requests must complete below 300ms
}
};
export function setup() {
const token = getBearerToken('ci-semantic-search');
return { token };
}
export default function (data) {
const url = `${variables.serverUrl}/api/content/ci-semantic-search/test/5d648f76-7ae9-4141-a325-0c31ed155e5c`;
const response = http.get(url, {
headers: {
Authorization: `Bearer ${data.token}`
}
});
check(response, {
'is status 200': (r) => r.status === 200,
});
}

46
backend/tools/k6/shared.js

@ -9,25 +9,53 @@ export const variables = {
let bearerToken = null; let bearerToken = null;
export function getBearerToken() { export function getBearerToken(appName) {
if (!bearerToken) { if (!bearerToken) {
const url = `${variables.serverUrl}/identity-server/connect/token`; const adminToken = getToken(variables.clientId, variables.clientSecret);
const response = http.post(url, { const clientsUrl = `${variables.serverUrl}/api/apps/${appName}/clients`;
grant_type: 'client_credentials',
client_id: variables.clientId, const clientsResponse = http.get(clientsUrl, {
client_secret: variables.clientSecret, headers: {
scope: 'squidex-api' Authorization: `Bearer ${adminToken}`
}
}); });
const json = JSON.parse(response.body); const clientsJson = JSON.parse(clientsResponse.body);
const client = clientsJson.items[0];
bearerToken = json.access_token; const clientId = `${appName}:${client.id}`;
const clientSecret = client.secret;
console.log(`Using ${clientId} / ${clientSecret}`);
bearerToken = getToken(clientId, clientSecret);
} }
return bearerToken; return bearerToken;
} }
function getToken(clientId, clientSecret) {
const tokenUrl = `${variables.serverUrl}/identity-server/connect/token`;
const tokenResponse = http.post(tokenUrl, {
grant_type: 'client_credentials',
client_id: clientId,
client_secret: clientSecret,
scope: 'squidex-api'
}, {
responseType: 'text'
});
if (tokenResponse.status !== 200) {
throw new Error('Invalid response.');
}
const tokenJson = JSON.parse(tokenResponse.body);
return tokenJson.access_token;
}
function getValue(key, fallback) { function getValue(key, fallback) {
const result = __ENV[key] || fallback; const result = __ENV[key] || fallback;

23
backend/tools/k6/test.js

@ -0,0 +1,23 @@
import { check } from 'k6';
import http from 'k6/http';
export const options = {
stages: [
{ duration: "2m", target: 300 },
{ duration: "2m", target: 300 },
{ duration: "2m", target: 0 },
],
thresholds: {
'http_req_duration': ['p(99)<300'], // 99% of requests must complete below 300ms
}
};
export default function () {
const url = `https://test-api.k6.io/`;
const response = http.get(url);
check(response, {
'is status 200': (r) => r.status === 200,
});
}
Loading…
Cancel
Save