diff --git a/.drone.yml b/.drone.yml index c44c9b078..3ff23d5a4 100644 --- a/.drone.yml +++ b/.drone.yml @@ -18,6 +18,7 @@ steps: - pull_request branch: - master + - release/* - name: build_dev image: docker @@ -48,6 +49,7 @@ steps: - pull_request branch: - master + - release/* - name: test_run image: mcr.microsoft.com/dotnet/core/sdk:3.1-buster @@ -63,6 +65,7 @@ steps: - pull_request branch: - master + - release/* - name: test_cleanup image: docker/compose @@ -83,6 +86,7 @@ steps: - pull_request branch: - master + - release/* - name: push_dev image: docker diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventFluidExtensions.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventFluidExtensions.cs index 723dbac9f..a6d0897f7 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventFluidExtensions.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/Extensions/EventFluidExtensions.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using Fluid; using Fluid.Values; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs index 53b19feda..78088a606 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionAll.cs @@ -75,7 +75,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents using (Profiler.TraceMethod("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)); } @@ -87,7 +87,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents using (Profiler.TraceMethod("QueryAsyncByIdsWithoutSchema")) { - var result = await queryContentsById.DoAsync(app.Id, null, ids); + var result = await queryContentsById.DoAsync(app.Id, null, ids, false); return result; } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionPublished.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionPublished.cs index 27240d141..7ace0278e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionPublished.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentCollectionPublished.cs @@ -79,7 +79,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents using (Profiler.TraceMethod("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)); } @@ -91,7 +91,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents using (Profiler.TraceMethod("QueryAsyncByIdsWithoutSchema")) { - var result = await queryContentsById.DoAsync(app.Id, null, ids); + var result = await queryContentsById.DoAsync(app.Id, null, ids, true); return result; } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs index 7cd875916..6bc3c1b50 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/MongoContentRepository_SnapshotStore.cs +++ b/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); + if (schema == null) + { + return (null!, EtagVersion.NotFound); + } + contentEntity.ParseData(schema.SchemaDef, converter); 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); - await Task.WhenAll( - UpsertDraftContentAsync(value, oldVersion, newVersion, schema), - UpsertOrDeletePublishedAsync(value, oldVersion, newVersion, schema)); + if (schema == null) + { + return; + } + + var saveDraft = UpsertDraftContentAsync(value, oldVersion, newVersion, schema); + var savePublic = UpsertOrDeletePublishedAsync(value, oldVersion, newVersion, schema); + + await Task.WhenAll(saveDraft, savePublic); } } @@ -137,15 +148,10 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents await collectionPublished.UpsertVersionedAsync(content.DocumentId, oldVersion, content); } - private async Task GetSchemaAsync(DomainId appId, DomainId schemaId) + private async Task GetSchemaAsync(DomainId appId, DomainId schemaId) { var schema = await appProvider.GetSchemaAsync(appId, schemaId, true); - if (schema == null) - { - throw new DomainObjectNotFoundException(schemaId.ToString(), typeof(ISchemaEntity)); - } - return schema; } } diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByIds.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByIds.cs index a805ac94e..32af83166 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByIds.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryContentsByIds.cs @@ -27,14 +27,14 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations this.appProvider = appProvider; } - public async Task> DoAsync(DomainId appId, ISchemaEntity? schema, HashSet ids) + public async Task> DoAsync(DomainId appId, ISchemaEntity? schema, HashSet ids, bool canCache) { Guard.NotNull(ids, nameof(ids)); var find = Collection.Find(CreateFilter(appId, ids)); 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)>(); @@ -51,7 +51,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations return result; } - private async Task> GetSchemasAsync(DomainId appId, ISchemaEntity? schema, List contentItems) + private async Task> GetSchemasAsync(DomainId appId, ISchemaEntity? schema, List contentItems, bool canCache) { var schemas = new Dictionary(); @@ -66,7 +66,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations { if (!schemas.ContainsKey(schemaId)) { - var found = await appProvider.GetSchemaAsync(appId, schemaId); + var found = await appProvider.GetSchemaAsync(appId, schemaId, false, canCache); if (found != null) { diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryIdsAsync.cs b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryIdsAsync.cs index 683a95529..33a5690e5 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryIdsAsync.cs +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Contents/Operations/QueryIdsAsync.cs @@ -60,7 +60,7 @@ namespace Squidex.Domain.Apps.Entities.MongoDb.Contents.Operations public async Task> DoAsync(DomainId appId, DomainId schemaId, FilterNode filterNode) { - var schema = await appProvider.GetSchemaAsync(appId, schemaId); + var schema = await appProvider.GetSchemaAsync(appId, schemaId, false); if (schema == null) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs b/backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs index 462d210ee..804f065ac 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/AppProvider.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Squidex.Domain.Apps.Entities.Apps; using Squidex.Domain.Apps.Entities.Apps.Indexes; @@ -39,24 +40,16 @@ namespace Squidex.Domain.Apps.Entities this.indexSchemas = indexSchemas; } - public Task<(IAppEntity?, ISchemaEntity?)> GetAppWithSchemaAsync(DomainId appId, DomainId id) + public async Task<(IAppEntity?, ISchemaEntity?)> GetAppWithSchemaAsync(DomainId appId, DomainId id, bool canCache = false) { - return localCache.GetOrCreateAsync($"GetAppWithSchemaAsync({appId}, {id})", async () => - { - return await GetAppWithSchemaUncachedAsync(appId, id); - }); - } - - private async Task<(IAppEntity?, ISchemaEntity?)> GetAppWithSchemaUncachedAsync(DomainId appId, DomainId id) - { - var app = await GetAppAsync(appId); + var app = await GetAppAsync(appId, canCache); if (app == null) { return (null, null); } - var schema = await GetSchemaAsync(appId, id, false); + var schema = await GetSchemaAsync(appId, id, false, canCache); if (schema == null) { @@ -66,60 +59,114 @@ namespace Squidex.Domain.Apps.Entities return (app, schema); } - public Task GetAppAsync(DomainId appId) + public async Task GetAppAsync(DomainId 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 GetAppAsync(string appName) + public async Task 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); + } + + return app?.IsArchived == true ? null : app; } - public Task> GetUserAppsAsync(string userId, PermissionSet permissions) + public async Task GetSchemaAsync(DomainId 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 GetSchemaAsync(DomainId appId, string name) + public async Task GetSchemaAsync(DomainId appId, DomainId 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 GetSchemaAsync(DomainId appId, DomainId id, bool allowDeleted = false) + public async Task> 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> GetSchemasAsync(DomainId appId) + public async Task> GetSchemasAsync(DomainId 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> GetRulesAsync(DomainId appId) + public async Task> GetRulesAsync(DomainId 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(DomainId appId) + { + return $"APPS_ID_{appId}"; + } + + private static string AppCacheKey(string appName) + { + return $"APPS_NAME_{appName}"; + } + + private static string SchemaCacheKey(DomainId appId, DomainId id) + { + return $"SCHEMAS_ID_{appId}_{id}"; + } + + private static string SchemaCacheKey(DomainId appId, string name) + { + return $"SCHEMAS_NAME_{appId}_{name}"; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsIndex.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsIndex.cs index cd6b04992..cea62616c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsIndex.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/AppsIndex.cs @@ -5,12 +5,14 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Orleans; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure; +using Squidex.Infrastructure.Caching; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Orleans; @@ -22,13 +24,18 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes { public sealed class AppsIndex : IAppsIndex, ICommandMiddleware { + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); 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(replicatedCache, nameof(replicatedCache)); this.grainFactory = grainFactory; + + this.replicatedCache = replicatedCache; } public async Task RebuildByContributorsAsync(DomainId appId, HashSet contributors) @@ -77,7 +84,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes var apps = await Task.WhenAll(ids - .Select(GetAppAsync)); + .Select(id => GetAppAsync(id, false))); return apps.NotNull().ToList(); } @@ -95,16 +102,24 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes var apps = await Task.WhenAll(ids .SelectMany(x => x).Distinct() - .Select(GetAppAsync)); + .Select(id => GetAppAsync(id, false))); return apps.NotNull().ToList(); } } - public async Task GetAppByNameAsync(string name) + public async Task GetAppByNameAsync(string name, bool canCache = false) { using (Profiler.TraceMethod()) { + if (canCache) + { + if (replicatedCache.TryGetValue(GetCacheKey(name), out var cached)) + { + return cached as IAppEntity; + } + } + var appId = await GetAppIdAsync(name); if (appId == DomainId.Empty) @@ -112,22 +127,30 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes return null; } - return await GetAppAsync(appId); + return await GetAppAsync(appId, canCache); } } - public async Task GetAppAsync(DomainId appId) + public async Task GetAppAsync(DomainId appId, bool canCache) { using (Profiler.TraceMethod()) { - var app = await grainFactory.GetGrain(appId.ToString()).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; } } @@ -196,21 +219,28 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes { await next(context); - if (context.IsCompleted) + if (context.IsCompleted && context.Command is AppCommand appCommand) { - switch (context.Command) + var app = await GetAppCoreAsync(appCommand.AggregateId); + + if (app != null) { - case AssignContributor assignContributor: - await AssignContributorAsync(assignContributor); - break; + CacheIt(app, true); - case RemoveContributor removeContributor: - await RemoveContributorAsync(removeContributor); - break; + switch (context.Command) + { + case AssignContributor assignContributor: + await AssignContributorAsync(assignContributor); + break; - case ArchiveApp archiveApp: - await ArchiveAppAsync(archiveApp); - break; + case RemoveContributor removeContributor: + await RemoveContributorAsync(removeContributor); + break; + + case ArchiveApp archiveApp: + await ArchiveAppAsync(app); + break; + } } } } @@ -237,46 +267,62 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes return null; } - private Task AssignContributorAsync(AssignContributor command) + private async Task AssignContributorAsync(AssignContributor command) { - return Index(command.ContributorId).AddAsync(command.AppId.Id); + await Index(command.ContributorId).AddAsync(command.AggregateId); } - private Task RemoveContributorAsync(RemoveContributor command) + private async Task RemoveContributorAsync(RemoveContributor command) { - return Index(command.ContributorId).RemoveAsync(command.AppId.Id); + await Index(command.ContributorId).RemoveAsync(command.AggregateId); } - 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(appId.Id.ToString()).GetStateAsync(); - - if (IsFound(app.Value, true)) + foreach (var contributorId in app.Contributors.Keys) { - await Index().RemoveAsync(appId.Id); + await Index(contributorId).RemoveAsync(app.Id); } + } - foreach (var contributorId in app.Value.Contributors.Keys) + private IAppsByNameIndexGrain Index() + { + return grainFactory.GetGrain(SingleGrain.Id); + } + + private IAppsByUserIndexGrain Index(string id) + { + return grainFactory.GetGrain(id); + } + + private async Task GetAppCoreAsync(DomainId id) + { + var app = (await grainFactory.GetGrain(id.ToString()).GetStateAsync()).Value; + + if (app.Version <= EtagVersion.Empty) { - await Index(contributorId).RemoveAsync(appId.Id); + return null; } + + return app; } - private static bool IsFound(IAppEntity entity, bool allowArchived) + private static string GetCacheKey(DomainId 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(SingleGrain.Id); + return $"APPS_NAME_{name}"; } - private IAppsByUserIndexGrain Index(string id) + private void CacheIt(IAppEntity app, bool publish) { - return grainFactory.GetGrain(id); + replicatedCache.Add(GetCacheKey(app.Id), app, CacheDuration, publish); + replicatedCache.Add(GetCacheKey(app.Name), app, CacheDuration, publish); } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsIndex.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsIndex.cs index a881f0eff..e72a0f4bb 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsIndex.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Indexes/IAppsIndex.cs @@ -20,9 +20,9 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes Task> GetAppsForUserAsync(string userId, PermissionSet permissions); - Task GetAppByNameAsync(string name); + Task GetAppByNameAsync(string name, bool canCache); - Task GetAppAsync(DomainId appId); + Task GetAppAsync(DomainId appId, bool canCache); Task ReserveAsync(DomainId id, string name); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetCommand.cs index 6fd0ff579..528c7f854 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetCommand.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetCommand.cs @@ -16,7 +16,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Commands public DomainId AssetId { get; set; } - DomainId IAggregateCommand.AggregateId + public DomainId AggregateId { get { return DomainId.Combine(AppId, AssetId); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetFolderCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetFolderCommand.cs index f93ec017f..305c68547 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetFolderCommand.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/AssetFolderCommand.cs @@ -16,7 +16,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.Commands public DomainId AssetFolderId { get; set; } - DomainId IAggregateCommand.AggregateId + public DomainId AggregateId { get { return DomainId.Combine(AppId, AssetFolderId); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentCommand.cs index 68317c2dd..f9176103e 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentCommand.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/ContentCommand.cs @@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands public bool DoNotScript { get; set; } - DomainId IAggregateCommand.AggregateId + public DomainId AggregateId { get { return DomainId.Combine(AppId, ContentId); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/DefaultWorkflowsValidator.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/DefaultWorkflowsValidator.cs index fec9cfc3d..167ebe6a2 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/DefaultWorkflowsValidator.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/DefaultWorkflowsValidator.cs @@ -41,7 +41,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { 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) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs index 8b13dfe96..05cfbd6ee 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/DynamicContentWorkflow.cs @@ -129,7 +129,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { Workflow? result = null; - var app = await appProvider.GetAppAsync(appId); + var app = await appProvider.GetAppAsync(appId, false); if (app != null) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs index b264c8bff..4db3ba7a1 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs @@ -156,14 +156,16 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries { ISchemaEntity? schema = null; + var canCache = !context.IsFrontendClient; + if (Guid.TryParse(schemaIdOrName, out var guid)) { - schema = await appProvider.GetSchemaAsync(context.App.Id, guid); + schema = await appProvider.GetSchemaAsync(context.App.Id, guid, false, canCache); } if (schema == null) { - schema = await appProvider.GetSchemaAsync(context.App.Id, schemaIdOrName); + schema = await appProvider.GetSchemaAsync(context.App.Id, schemaIdOrName, canCache); } if (schema == null) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs index 99431f618..a184fde81 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ReferencesFluidExtension.cs @@ -40,7 +40,7 @@ namespace Squidex.Domain.Apps.Entities.Contents { 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) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs b/backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs index 5c6d41c14..2176cbc53 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/IAppProvider.cs @@ -17,17 +17,17 @@ namespace Squidex.Domain.Apps.Entities { public interface IAppProvider { - Task<(IAppEntity?, ISchemaEntity?)> GetAppWithSchemaAsync(DomainId appId, DomainId id); + Task<(IAppEntity?, ISchemaEntity?)> GetAppWithSchemaAsync(DomainId appId, DomainId id, bool canCache = false); - Task GetAppAsync(DomainId appId); + Task GetAppAsync(DomainId appId, bool canCache = false); - Task GetAppAsync(string appName); + Task GetAppAsync(string appName, bool canCache = false); Task> GetUserAppsAsync(string userId, PermissionSet permissions); - Task GetSchemaAsync(DomainId appId, DomainId id, bool allowDeleted = false); + Task GetSchemaAsync(DomainId appId, DomainId id, bool allowDeleted, bool canCache = false); - Task GetSchemaAsync(DomainId appId, string name); + Task GetSchemaAsync(DomainId appId, string name, bool canCache = false); Task> GetSchemasAsync(DomainId appId); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleCommand.cs index 28499b531..6457491aa 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleCommand.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Commands/RuleCommand.cs @@ -16,7 +16,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Commands public DomainId RuleId { get; set; } - DomainId IAggregateCommand.AggregateId + public DomainId AggregateId { get { return DomainId.Combine(AppId, RuleId); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleTriggerValidator.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleTriggerValidator.cs index 465b530fa..bfc74a55d 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleTriggerValidator.cs +++ b/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(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); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Rules/Indexes/RulesIndex.cs b/backend/src/Squidex.Domain.Apps.Entities/Rules/Indexes/RulesIndex.cs index aaa08424c..94a89f41c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Rules/Indexes/RulesIndex.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Rules/Indexes/RulesIndex.cs @@ -40,7 +40,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes var rules = await Task.WhenAll( - ids.Select(x => GetRuleAsync(appId, x))); + ids.Select(id => GetRuleCoreAsync(DomainId.Combine(appId, id)))); return rules.NotNull().ToList(); } @@ -50,14 +50,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes { using (Profiler.TraceMethod()) { - var rule = await GetRuleInternalAsync(appId, id); - - if (IsFound(rule)) - { - return rule; - } - - return null; + return await GetRuleCoreAsync(DomainId.Combine(appId, id)); } } @@ -94,9 +87,9 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes private async Task DeleteRuleAsync(DeleteRule command) { - var rule = await GetRuleInternalAsync(command.AppId.Id, command.RuleId); + var rule = await GetRuleCoreAsync(command.AggregateId); - if (IsFound(rule)) + if (rule != null) { await Index(rule.AppId.Id).RemoveAsync(rule.Id); } @@ -116,9 +109,16 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes return grainFactory.GetGrain(appId.ToString()); } - private static bool IsFound(IRuleEntity rule) + private async Task GetRuleCoreAsync(DomainId id) { - return rule.Version > EtagVersion.Empty && !rule.IsDeleted; + var rule = (await grainFactory.GetGrain(id.ToString()).GetStateAsync()).Value; + + if (rule.Version <= EtagVersion.Empty) + { + return null; + } + + return rule; } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/ISchemasIndex.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/ISchemasIndex.cs index ffdb82df8..88be8162b 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/ISchemasIndex.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/ISchemasIndex.cs @@ -13,11 +13,11 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes { public interface ISchemasIndex { - Task GetSchemaAsync(DomainId appId, DomainId id, bool allowDeleted = false); + Task GetSchemaAsync(DomainId appId, DomainId id, bool canCache); - Task GetSchemaByNameAsync(DomainId appId, string name, bool allowDeleted = false); + Task GetSchemaByNameAsync(DomainId appId, string name, bool canCache); - Task> GetSchemasAsync(DomainId appId, bool allowDeleted = false); + Task> GetSchemasAsync(DomainId appId); Task RebuildAsync(DomainId appId, Dictionary schemas); } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasIndex.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasIndex.cs index 222ac9974..474cf946a 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasIndex.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/Indexes/SchemasIndex.cs @@ -5,12 +5,14 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Orleans; using Squidex.Domain.Apps.Entities.Schemas.Commands; using Squidex.Infrastructure; +using Squidex.Infrastructure.Caching; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Log; using Squidex.Infrastructure.Validation; @@ -19,13 +21,18 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes { public sealed class SchemasIndex : ICommandMiddleware, ISchemasIndex { + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5); 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(replicatedCache, nameof(replicatedCache)); this.grainFactory = grainFactory; + + this.replicatedCache = replicatedCache; } public Task RebuildAsync(DomainId appId, Dictionary schemas) @@ -33,7 +40,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes return Index(appId).RebuildAsync(schemas); } - public async Task> GetSchemasAsync(DomainId appId, bool allowDeleted = false) + public async Task> GetSchemasAsync(DomainId appId) { using (Profiler.TraceMethod()) { @@ -41,16 +48,26 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes var schemas = await Task.WhenAll( - ids.Select(id => GetSchemaAsync(appId, id, allowDeleted))); + ids.Select(id => GetSchemaAsync(appId, id, false))); return schemas.NotNull().ToList(); } } - public async Task GetSchemaByNameAsync(DomainId appId, string name, bool allowDeleted = false) + public async Task GetSchemaByNameAsync(DomainId appId, string name, bool canCache) { using (Profiler.TraceMethod()) { + var cacheKey = GetCacheKey(appId, name); + + if (canCache) + { + if (replicatedCache.TryGetValue(cacheKey, out var cachedSchema)) + { + return cachedSchema as ISchemaEntity; + } + } + var id = await GetSchemaIdAsync(appId, name); if (id == DomainId.Empty) @@ -58,22 +75,32 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes return null; } - return await GetSchemaAsync(appId, id, allowDeleted); + return await GetSchemaAsync(appId, id, canCache); } } - public async Task GetSchemaAsync(DomainId appId, DomainId id, bool allowDeleted = false) + public async Task GetSchemaAsync(DomainId appId, DomainId id, bool canCache) { using (Profiler.TraceMethod()) { - var schema = await GetSchemaInternalAsync(appId, id); + var cacheKey = GetCacheKey(appId, id); + + if (canCache) + { + if (replicatedCache.TryGetValue(cacheKey, out var cachedSchema)) + { + return cachedSchema as ISchemaEntity; + } + } - if (IsFound(schema, allowDeleted)) + var schema = await GetSchemaCoreAsync(id); + + if (schema != null) { - return schema; + CacheIt(schema, false); } - return null; + return schema; } } @@ -124,11 +151,18 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes { 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.AggregateId); + + if (schema != null) { - await DeleteSchemaAsync(deleteSchema); + CacheIt(schema, true); + + if (context.Command is DeleteSchema) + { + await DeleteSchemaAsync(schema); + } } } } @@ -155,33 +189,42 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes return null; } - private async Task DeleteSchemaAsync(DeleteSchema commmand) + private Task DeleteSchemaAsync(ISchemaEntity schema) { - var schema = await GetSchemaInternalAsync(commmand.AppId.Id, commmand.SchemaId.Id); + return Index(schema.AppId.Id).RemoveAsync(schema.Id); + } - if (IsFound(schema, true)) - { - await Index(schema.AppId.Id).RemoveAsync(schema.Id); - } + private ISchemasByAppIndexGrain Index(DomainId appId) + { + return grainFactory.GetGrain(appId.ToString()); } - private async Task GetSchemaInternalAsync(DomainId appId, DomainId id) + private async Task GetSchemaCoreAsync(DomainId id) { - var key = DomainId.Combine(appId, id).ToString(); + var schema = (await grainFactory.GetGrain(id.ToString()).GetStateAsync()).Value; - var rule = await grainFactory.GetGrain(key).GetStateAsync(); + if (schema.Version <= EtagVersion.Empty) + { + return null; + } - return rule.Value; + return schema; } - private ISchemasByAppIndexGrain Index(DomainId appId) + private string GetCacheKey(DomainId appId, string name) { - return grainFactory.GetGrain(appId.ToString()); + return $"SCHEMAS_NAME_{appId}_{name}"; + } + + private string GetCacheKey(DomainId appId, DomainId 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); } } } diff --git a/backend/src/Squidex.Infrastructure/Caching/IPubSub.cs b/backend/src/Squidex.Infrastructure/Caching/IPubSub.cs new file mode 100644 index 000000000..a2dbad92e --- /dev/null +++ b/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 handler); + } +} diff --git a/backend/src/Squidex.Infrastructure/Caching/IPubSubSubscription.cs b/backend/src/Squidex.Infrastructure/Caching/IPubSubSubscription.cs new file mode 100644 index 000000000..c735e6d08 --- /dev/null +++ b/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 + { + } +} \ No newline at end of file diff --git a/backend/src/Squidex.Infrastructure/Caching/IReplicatedCache.cs b/backend/src/Squidex.Infrastructure/Caching/IReplicatedCache.cs new file mode 100644 index 000000000..075e515f9 --- /dev/null +++ b/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); + } +} diff --git a/backend/src/Squidex.Infrastructure/Caching/RequestCacheExtensions.cs b/backend/src/Squidex.Infrastructure/Caching/LocalCacheExtensions.cs similarity index 50% rename from backend/src/Squidex.Infrastructure/Caching/RequestCacheExtensions.cs rename to backend/src/Squidex.Infrastructure/Caching/LocalCacheExtensions.cs index 2a3d6ed8c..aa50178bb 100644 --- a/backend/src/Squidex.Infrastructure/Caching/RequestCacheExtensions.cs +++ b/backend/src/Squidex.Infrastructure/Caching/LocalCacheExtensions.cs @@ -10,34 +10,27 @@ using System.Threading.Tasks; namespace Squidex.Infrastructure.Caching { - public static class RequestCacheExtensions + public static class LocalCacheExtensions { public static async Task GetOrCreateAsync(this ILocalCache cache, object key, Func> 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) + { + return typed; + } + else + { + return default!; + } } - typedValue = await task(); + var result = await task(); - cache.Add(key, typedValue); + cache.Add(key, result); - return typedValue; - } - - public static T GetOrCreate(this ILocalCache cache, object key, Func task) - { - if (cache.TryGetValue(key, out var value) && value is T typedValue) - { - return typedValue; - } - - typedValue = task(); - - cache.Add(key, typedValue); - - return typedValue; + return result; } } } diff --git a/backend/src/Squidex.Infrastructure/Caching/ReplicatedCache.cs b/backend/src/Squidex.Infrastructure/Caching/ReplicatedCache.cs new file mode 100644 index 000000000..e9ded4109 --- /dev/null +++ b/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 }); + } + } +} diff --git a/backend/src/Squidex.Infrastructure/Caching/SimplePubSub.cs b/backend/src/Squidex.Infrastructure/Caching/SimplePubSub.cs new file mode 100644 index 000000000..7a30c2ddb --- /dev/null +++ b/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> handlers = new List>(); + + public void Publish(object message) + { + foreach (var handler in handlers) + { + handler(message); + } + } + + public void Subscribe(Action handler) + { + Guard.NotNull(handler, nameof(handler)); + + handlers.Add(handler); + } + } +} diff --git a/backend/src/Squidex.Infrastructure/Orleans/IPubSubGrain.cs b/backend/src/Squidex.Infrastructure/Orleans/IPubSubGrain.cs new file mode 100644 index 000000000..3c45bd87d --- /dev/null +++ b/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); + } +} diff --git a/backend/src/Squidex.Infrastructure/Orleans/IPubSubGrainObserver.cs b/backend/src/Squidex.Infrastructure/Orleans/IPubSubGrainObserver.cs new file mode 100644 index 000000000..a7ea60e54 --- /dev/null +++ b/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 handler); + } +} diff --git a/backend/src/Squidex.Infrastructure/Orleans/OrleansPubSub.cs b/backend/src/Squidex.Infrastructure/Orleans/OrleansPubSub.cs new file mode 100644 index 000000000..a222c09d9 --- /dev/null +++ b/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> subscriptions = new List>(); + + public void Handle(object message) + { + foreach (var subscription in subscriptions) + { + try + { + subscription(message); + } + catch + { + continue; + } + } + } + + public void Subscribe(Action handler) + { + subscriptions.Add(handler); + } + } + + public OrleansPubSub(IGrainFactory grainFactory) + { + Guard.NotNull(grainFactory, nameof(grainFactory)); + + this.grainFactory = grainFactory; + + pubSubGrain = grainFactory.GetGrain(SingleGrain.Id); + } + + public async Task StartAsync(CancellationToken ct) + { + var reference = await grainFactory.CreateObjectReference(pubSubGrainObserver); + + await pubSubGrain.SubscribeAsync(reference); + } + + public void Publish(object message) + { + Guard.NotNull(message, nameof(message)); + + pubSubGrain.PublishAsync(message).Forget(); + } + + public void Subscribe(Action handler) + { + Guard.NotNull(handler, nameof(handler)); + + pubSubGrainObserver.Subscribe(handler); + } + } +} diff --git a/backend/src/Squidex.Infrastructure/Orleans/OrleansPubSubGrain.cs b/backend/src/Squidex.Infrastructure/Orleans/OrleansPubSubGrain.cs new file mode 100644 index 000000000..776287d13 --- /dev/null +++ b/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 subscriptions = new List(); + + 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; + } + } +} diff --git a/backend/src/Squidex.Infrastructure/Reflection/SimpleEquals.cs b/backend/src/Squidex.Infrastructure/Reflection/SimpleEquals.cs index 63d928b21..b04cf7708 100644 --- a/backend/src/Squidex.Infrastructure/Reflection/SimpleEquals.cs +++ b/backend/src/Squidex.Infrastructure/Reflection/SimpleEquals.cs @@ -18,9 +18,16 @@ namespace Squidex.Infrastructure.Reflection public static class SimpleEquals { private static readonly ConcurrentDictionary Comparers = new ConcurrentDictionary(); + private static readonly HashSet SimpleTypes = new HashSet(); private static readonly DefaultComparer DefaultComparer = new DefaultComparer(); private static readonly NoopComparer NoopComparer = new NoopComparer(); + static SimpleEquals() + { + SimpleTypes.Add(typeof(string)); + SimpleTypes.Add(typeof(Uri)); + } + internal static IDeepComparer Build(Type type) { return BuildCore(type) ?? DefaultComparer; @@ -91,7 +98,7 @@ namespace Squidex.Infrastructure.Reflection private static bool IsSimpleType(Type type) { - return type.IsValueType || type == typeof(string); + return type.IsValueType || SimpleTypes.Contains(type); } private static bool IsEquatable(Type type) diff --git a/backend/src/Squidex.Infrastructure/Security/Permission.Part.cs b/backend/src/Squidex.Infrastructure/Security/Permission.Part.cs index 8b6934dd0..d05e4ce0d 100644 --- a/backend/src/Squidex.Infrastructure/Security/Permission.Part.cs +++ b/backend/src/Squidex.Infrastructure/Security/Permission.Part.cs @@ -132,9 +132,11 @@ namespace Squidex.Infrastructure.Security private static int CountOf(ReadOnlySpan text, char character) { + var length = text.Length; + var count = 0; - for (var i = 0; i < text.Length; i++) + for (var i = 0; i < length; i++) { if (text[i] == character) { diff --git a/backend/src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs b/backend/src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs index e94331cbf..503d21cf8 100644 --- a/backend/src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs +++ b/backend/src/Squidex.Web/CommandMiddlewares/EnrichWithSchemaIdCommandMiddleware.cs @@ -44,14 +44,14 @@ namespace Squidex.Web.CommandMiddlewares private NamedId GetSchemaId() { - var schemaFeature = httpContextAccessor.HttpContext.Features.Get(); + var feature = httpContextAccessor.HttpContext.Features.Get(); - if (schemaFeature == null) + if (feature == null) { throw new InvalidOperationException("Cannot resolve schema."); } - return schemaFeature.SchemaId; + return feature.SchemaId; } } } \ No newline at end of file diff --git a/backend/src/Squidex.Web/Pipeline/AppResolver.cs b/backend/src/Squidex.Web/Pipeline/AppResolver.cs index 923291de3..84769ed5f 100644 --- a/backend/src/Squidex.Web/Pipeline/AppResolver.cs +++ b/backend/src/Squidex.Web/Pipeline/AppResolver.cs @@ -41,7 +41,9 @@ namespace Squidex.Web.Pipeline 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) { diff --git a/backend/src/Squidex.Web/Pipeline/SchemaResolver.cs b/backend/src/Squidex.Web/Pipeline/SchemaResolver.cs index a02da6f97..a39ffa655 100644 --- a/backend/src/Squidex.Web/Pipeline/SchemaResolver.cs +++ b/backend/src/Squidex.Web/Pipeline/SchemaResolver.cs @@ -6,12 +6,15 @@ // ========================================================================== using System; +using System.Security.Claims; using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; +using Squidex.Infrastructure.Security; +using Squidex.Shared; namespace Squidex.Web.Pipeline { @@ -36,7 +39,7 @@ namespace Squidex.Web.Pipeline if (!string.IsNullOrWhiteSpace(schemaIdOrName)) { - var schema = await GetSchemaAsync(appId, schemaIdOrName); + var schema = await GetSchemaAsync(appId, schemaIdOrName, context.HttpContext.User); if (schema == null) { @@ -51,15 +54,17 @@ namespace Squidex.Web.Pipeline await next(); } - private Task GetSchemaAsync(DomainId appId, string schemaIdOrName) + private Task GetSchemaAsync(DomainId appId, string schemaIdOrName, ClaimsPrincipal user) { + var canCache = !user.IsInClient(DefaultClients.Frontend); + if (Guid.TryParse(schemaIdOrName, out var guid)) { - return appProvider.GetSchemaAsync(appId, guid); + return appProvider.GetSchemaAsync(appId, guid, false, canCache); } else { - return appProvider.GetSchemaAsync(appId, schemaIdOrName); + return appProvider.GetSchemaAsync(appId, schemaIdOrName, canCache); } } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs index 60a64fed7..c54422ebe 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs +++ b/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 guid)) { - schema = await appProvider.GetSchemaAsync(AppId, guid); + schema = await appProvider.GetSchemaAsync(AppId, guid, false); } else { diff --git a/backend/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs b/backend/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs index 3d36fb6d6..ce23bffe7 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs +++ b/backend/src/Squidex/Areas/IdentityServer/Config/LazyClientStore.cs @@ -65,7 +65,7 @@ namespace Squidex.Areas.IdentityServer.Config 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); diff --git a/backend/src/Squidex/Config/Domain/InfrastructureServices.cs b/backend/src/Squidex/Config/Domain/InfrastructureServices.cs index 23c5ca589..debaf394e 100644 --- a/backend/src/Squidex/Config/Domain/InfrastructureServices.cs +++ b/backend/src/Squidex/Config/Domain/InfrastructureServices.cs @@ -59,6 +59,12 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .AsOptional(); diff --git a/backend/src/Squidex/Program.cs b/backend/src/Squidex/Program.cs index 00b462958..a2cb7d1ed 100644 --- a/backend/src/Squidex/Program.cs +++ b/backend/src/Squidex/Program.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Net; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsIndexTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsIndexTests.cs index 497d877c3..35259b0cc 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsIndexTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsIndexTests.cs @@ -8,10 +8,13 @@ using System.Collections.Generic; using System.Threading.Tasks; using FakeItEasy; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; using Orleans; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure; +using Squidex.Infrastructure.Caching; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Security; @@ -40,13 +43,15 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes A.CallTo(() => grainFactory.GetGrain(userId, null)) .Returns(indexByUser); - sut = new AppsIndex(grainFactory); + var cache = new ReplicatedCache(new MemoryCache(Options.Create(new MemoryCacheOptions())), new SimplePubSub()); + + sut = new AppsIndex(grainFactory, cache); } [Fact] public async Task Should_resolve_all_apps_from_user_permissions() { - var expected = SetupApp(0, false); + var expected = SetupApp(); A.CallTo(() => indexByName.GetIdsAsync(A.That.IsSameSequenceAs(new[] { appId.Name }))) .Returns(new List { appId.Id }); @@ -59,7 +64,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes [Fact] public async Task Should_resolve_all_apps_from_user() { - var expected = SetupApp(0, false); + var expected = SetupApp(); A.CallTo(() => indexByUser.GetIdsAsync()) .Returns(new List { appId.Id }); @@ -72,7 +77,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes [Fact] public async Task Should_resolve_combined_apps() { - var expected = SetupApp(0, false); + var expected = SetupApp(); A.CallTo(() => indexByName.GetIdsAsync(A.That.IsSameSequenceAs(new[] { appId.Name }))) .Returns(new List { appId.Id }); @@ -89,7 +94,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes [Fact] public async Task Should_resolve_all_apps() { - var expected = SetupApp(0, false); + var expected = SetupApp(); A.CallTo(() => indexByName.GetIdsAsync()) .Returns(new List { appId.Id }); @@ -102,42 +107,91 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes [Fact] public async Task Should_resolve_app_by_name() { - var expected = SetupApp(0, false); + var expected = SetupApp(); A.CallTo(() => indexByName.GetIdAsync(appId.Name)) .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(appId.Id.ToString(), null)) + .MustHaveHappenedTwiceExactly(); - Assert.Same(expected, actual); + A.CallTo(() => indexByName.GetIdAsync(A._)) + .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(appId.Id.ToString(), null)) + .MustHaveHappenedOnceExactly(); + + A.CallTo(() => indexByName.GetIdAsync(A._)) + .MustHaveHappenedOnceExactly(); } [Fact] public async Task Should_resolve_app_by_id() { - var expected = SetupApp(0, false); + var expected = SetupApp(); - var actual = await sut.GetAppAsync(appId.Id); + var actual1 = await sut.GetAppAsync(appId.Id, false); + var actual2 = await sut.GetAppAsync(appId.Id, false); - Assert.Same(expected, actual); + Assert.Same(expected, actual1); + Assert.Same(expected, actual2); + + A.CallTo(() => grainFactory.GetGrain(appId.Id.ToString(), null)) + .MustHaveHappenedTwiceExactly(); + + A.CallTo(() => indexByName.GetIdAsync(A._)) + .MustNotHaveHappened(); } [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(appId.Id.ToString(), null)) + .MustHaveHappenedOnceExactly(); + + A.CallTo(() => indexByName.GetIdAsync(A._)) + .MustNotHaveHappened(); } [Fact] 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); } @@ -284,10 +338,10 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes .MustHaveHappened(); } - [Theory, InlineData(true), InlineData(false)] - public async Task Should_remove_app_from_indexes_on_archive(bool isArchived) + [Fact] + public async Task Should_remove_app_from_indexes_on_archive() { - SetupApp(0, isArchived); + SetupApp(); var command = new ArchiveApp { AppId = appId }; @@ -364,16 +418,16 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes .MustHaveHappened(); } - private IAppEntity SetupApp(long version, bool archived) + private IAppEntity SetupApp(long version = 0) { var appEntity = A.Fake(); + A.CallTo(() => appEntity.Id) + .Returns(appId.Id); A.CallTo(() => appEntity.Name) .Returns(appId.Name); A.CallTo(() => appEntity.Version) .Returns(version); - A.CallTo(() => appEntity.IsArchived) - .Returns(archived); A.CallTo(() => appEntity.Contributors) .Returns(AppContributors.Empty.Assign(userId, Role.Owner)); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BackupContentsTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BackupContentsTests.cs index 97004c9bc..3042f8ba5 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BackupContentsTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/BackupContentsTests.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs index c6ebcda38..27d99257b 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentDomainObjectTests.cs @@ -93,10 +93,10 @@ namespace Squidex.Domain.Apps.Entities.Contents schema = Mocks.Schema(AppNamedId, SchemaNamedId, schemaDef); - A.CallTo(() => appProvider.GetAppAsync(AppName)) + A.CallTo(() => appProvider.GetAppAsync(AppName, false)) .Returns(app); - A.CallTo(() => appProvider.GetAppWithSchemaAsync(AppId, SchemaId)) + A.CallTo(() => appProvider.GetAppWithSchemaAsync(AppId, SchemaId, false)) .Returns((app, schema)); A.CallTo(() => scriptEngine.TransformAsync(A._, A._, ScriptOptions())) diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultWorkflowsValidatorTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultWorkflowsValidatorTests.cs index 9d61f4254..2a2960d3d 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultWorkflowsValidatorTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DefaultWorkflowsValidatorTests.cs @@ -28,10 +28,10 @@ namespace Squidex.Domain.Apps.Entities.Contents { var schema = Mocks.Schema(appId, schemaId, new Schema(schemaId.Name)); - A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, A._, false)) + A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, A._, false, false)) .Returns(Task.FromResult(null)); - A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false)) + A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Id, false, false)) .Returns(schema); sut = new DefaultWorkflowsValidator(appProvider); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs index 3be1a1ea3..1881a532a 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DynamicContentWorkflowTests.cs @@ -85,7 +85,7 @@ namespace Squidex.Domain.Apps.Entities.Contents var workflows = Workflows.Empty.Set(workflow).Set(DomainId.NewGuid(), simpleWorkflow); - A.CallTo(() => appProvider.GetAppAsync(appId.Id)) + A.CallTo(() => appProvider.GetAppAsync(appId.Id, false)) .Returns(app); A.CallTo(() => app.Workflows) diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs index e698dd29d..aeb95db32 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/MongoDb/ContentsQueryFixture.cs +++ b/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(); - A.CallTo(() => appProvider.GetSchemaAsync(A._, A._, false)) + A.CallTo(() => appProvider.GetSchemaAsync(A._, A._, false, false)) .ReturnsLazily(x => Task.FromResult(CreateSchema(x.GetArgument(0)!, x.GetArgument(1)!))); return appProvider; diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs index b2171f0c6..f075dee21 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentQueryServiceTests.cs @@ -52,7 +52,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries SetupEnricher(); - A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Name)) + A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Name, A._)) .Returns(schema); A.CallTo(() => queryParser.ParseQueryAsync(A._, schema, A._)) @@ -73,7 +73,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries 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); var result = await sut.GetSchemaOrThrowAsync(ctx, input); @@ -88,7 +88,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries 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); var result = await sut.GetSchemaOrThrowAsync(ctx, input); @@ -101,7 +101,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries { var ctx = CreateContext(isFrontend: false, allowSchema: true); - A.CallTo(() => appProvider.GetSchemaAsync(A._, A._)) + A.CallTo(() => appProvider.GetSchemaAsync(A._, A._, true)) .Returns((ISchemaEntity?)null); await Assert.ThrowsAsync(() => sut.GetSchemaOrThrowAsync(ctx, schemaId.Name)); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferenceFluidExtensionTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferenceFluidExtensionTests.cs index 3e168f7c3..66632b4ea 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferenceFluidExtensionTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/ReferenceFluidExtensionTests.cs @@ -33,7 +33,7 @@ namespace Squidex.Domain.Apps.Entities.Contents new ReferencesFluidExtension(contentQuery, appProvider) }; - A.CallTo(() => appProvider.GetAppAsync(appId.Id)) + A.CallTo(() => appProvider.GetAppAsync(appId.Id, false)) .Returns(Mocks.App(appId)); sut = new FluidTemplateEngine(extensions); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/GuardRuleTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/GuardRuleTests.cs index 84b1f430e..9e6c0f3aa 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/GuardRuleTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/GuardRuleTests.cs @@ -35,7 +35,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards 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)); } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/Triggers/ContentChangedTriggerTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/Triggers/ContentChangedTriggerTests.cs index dc20f8964..68a703924 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/Triggers/ContentChangedTriggerTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/Triggers/ContentChangedTriggerTests.cs @@ -41,14 +41,14 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards.Triggers new ValidationError("Schema id is required.", "Schemas") }); - A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, A._, false)) + A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, A._, false, false)) .MustNotHaveHappened(); } [Fact] 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(null)); var trigger = new ContentChangedTriggerV2 @@ -91,7 +91,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards.Triggers [Fact] public async Task Should_not_add_error_if_schemas_ids_are_valid() { - A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, A._, false)) + A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, A._, false, false)) .Returns(Mocks.Schema(appId, schemaId)); var trigger = new ContentChangedTriggerV2 diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesIndexTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesIndexTests.cs index c8b3812aa..604e559b0 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesIndexTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesIndexTests.cs @@ -36,7 +36,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes [Fact] public async Task Should_resolve_rules_by_id() { - var rule = SetupRule(0, false); + var rule = SetupRule(0); A.CallTo(() => index.GetIdsAsync()) .Returns(new List { rule.Id }); @@ -49,7 +49,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes [Fact] public async Task Should_return_empty_rule_if_rule_not_created() { - var rule = SetupRule(-1, false); + var rule = SetupRule(-1); A.CallTo(() => index.GetIdsAsync()) .Returns(new List { rule.Id }); @@ -62,7 +62,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes [Fact] public async Task Should_return_empty_rule_if_rule_deleted() { - var rule = SetupRule(-1, false); + var rule = SetupRule(-1); A.CallTo(() => index.GetIdsAsync()) .Returns(new List { rule.Id }); @@ -92,7 +92,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes [Fact] 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, AppId = appId }; @@ -117,11 +117,11 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes .MustHaveHappened(); } - private IRuleEntity SetupRule(long version, bool deleted) + private IRuleEntity SetupRule(long version) { var ruleId = DomainId.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(); A.CallTo(() => ruleGrain.GetStateAsync()) diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasIndexTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasIndexTests.cs index a4203fcf1..425aa08c0 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasIndexTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasIndexTests.cs @@ -8,10 +8,13 @@ using System.Collections.Generic; using System.Threading.Tasks; using FakeItEasy; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Options; using Orleans; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Entities.Schemas.Commands; using Squidex.Infrastructure; +using Squidex.Infrastructure.Caching; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Orleans; using Squidex.Infrastructure.Validation; @@ -35,36 +38,97 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes A.CallTo(() => grainFactory.GetGrain(appId.Id.ToString(), null)) .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(schemaId.Id.ToString(), null)) + .MustHaveHappenedTwiceExactly(); + + A.CallTo(() => index.GetIdAsync(A._)) + .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(schemaId.Id.ToString(), null)) + .MustHaveHappenedOnceExactly(); + + A.CallTo(() => index.GetIdAsync(A._)) + .MustHaveHappenedOnceExactly(); } [Fact] 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(schemaId.Id.ToString(), null)) + .MustHaveHappenedTwiceExactly(); - Assert.Same(actual, schema); + A.CallTo(() => index.GetIdAsync(A._)) + .MustNotHaveHappened(); } [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)) - .Returns(schema.Id); + var actual1 = await sut.GetSchemaAsync(appId.Id, schemaId.Id, true); + var actual2 = await sut.GetSchemaAsync(appId.Id, schemaId.Id, true); + var actual3 = await sut.GetSchemaByNameAsync(appId.Id, schemaId.Name, true); + + Assert.Same(expected, actual1); + Assert.Same(expected, actual2); + Assert.Same(expected, actual3); - var actual = await sut.GetSchemaByNameAsync(appId.Id, schema.SchemaDef.Name); + A.CallTo(() => grainFactory.GetGrain(schemaId.Id.ToString(), null)) + .MustHaveHappenedOnceExactly(); - Assert.Same(actual, schema); + A.CallTo(() => index.GetIdAsync(A._)) + .MustNotHaveHappened(); } [Fact] public async Task Should_resolve_schemas_by_id() { - var schema = SetupSchema(0, false); + var schema = SetupSchema(); A.CallTo(() => index.GetIdsAsync()) .Returns(new List { schema.Id }); @@ -77,7 +141,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes [Fact] 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()) .Returns(new List { schema.Id }); @@ -90,7 +154,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes [Fact] 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)) .Returns(schema.Id); @@ -100,19 +164,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes 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] public async Task Should_add_schema_to_index_on_create() { @@ -189,10 +240,10 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes .MustNotHaveHappened(); } - [Theory, InlineData(true), InlineData(false)] - public async Task Should_remove_schema_from_index_on_delete_when_existed_before(bool isDeleted) + [Fact] + 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 = schemaId, AppId = appId }; @@ -222,7 +273,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes 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(); @@ -234,8 +285,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes .Returns(appId); A.CallTo(() => schemaEntity.Version) .Returns(version); - A.CallTo(() => schemaEntity.IsDeleted) - .Returns(deleted); var schemaGrain = A.Fake(); diff --git a/backend/tests/Squidex.Infrastructure.Tests/Caching/AsyncLocalCacheTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Caching/AsyncLocalCacheTests.cs index 65911bb77..4ac0b04a9 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Caching/AsyncLocalCacheTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/Caching/AsyncLocalCacheTests.cs @@ -24,19 +24,13 @@ namespace Squidex.Infrastructure.Caching await Task.Delay(5); - var found = sut.TryGetValue("Key", out var value); - - Assert.True(found); - Assert.Equal(1, value); + AssertCache(sut, "Key", 1, true); await Task.Delay(5); sut.Remove("Key"); - var foundAfterRemove = sut.TryGetValue("Key", out value); - - Assert.False(foundAfterRemove); - Assert.Null(value); + AssertCache(sut, "Key", null, false); } } @@ -47,19 +41,13 @@ namespace Squidex.Infrastructure.Caching await Task.Delay(5); - var found = sut.TryGetValue("Key", out var value); - - Assert.False(found); - Assert.Null(value); + AssertCache(sut, "Key", null, false); sut.Remove("Key"); await Task.Delay(5); - var foundAfterRemove = sut.TryGetValue("Key", out value); - - Assert.False(foundAfterRemove); - Assert.Null(value); + AssertCache(sut, "Key", null, false); } [Fact] @@ -67,11 +55,11 @@ namespace Squidex.Infrastructure.Caching { using (sut.StartContext()) { - var value1 = sut.GetOrCreate("Key", () => ++called); + var value1 = await sut.GetOrCreateAsync("Key", () => Task.FromResult(++called)); 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, value1); @@ -82,11 +70,11 @@ namespace Squidex.Infrastructure.Caching [Fact] 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); - var value2 = sut.GetOrCreate("Key", () => ++called); + var value2 = await sut.GetOrCreateAsync("Key", () => Task.FromResult(++called)); Assert.Equal(2, called); Assert.Equal(1, value1); @@ -123,5 +111,13 @@ namespace Squidex.Infrastructure.Caching Assert.Equal(1, value1); 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); + } } } diff --git a/backend/tests/Squidex.Infrastructure.Tests/Caching/ReplicatedCacheTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Caching/ReplicatedCacheTests.cs new file mode 100644 index 000000000..b354bb1a7 --- /dev/null +++ b/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())); + } + } +} diff --git a/backend/tests/Squidex.Infrastructure.Tests/Orleans/Indexes/IdsIndexGrainTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Orleans/Indexes/IdsIndexGrainTests.cs index 2f6edcc48..05ea874d9 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Orleans/Indexes/IdsIndexGrainTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/Orleans/Indexes/IdsIndexGrainTests.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System; using System.Collections.Generic; using System.Threading.Tasks; using FakeItEasy; diff --git a/backend/tests/Squidex.Infrastructure.Tests/Orleans/PubSubTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Orleans/PubSubTests.cs new file mode 100644 index 000000000..e24013483 --- /dev/null +++ b/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.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>> CreateSubscriber(IGrainFactory grainFactory, int expectedCount) + { + var pubSub = new OrleansPubSub(grainFactory); + + await pubSub.StartAsync(default); + + var received = new HashSet(); + var receivedCompleted = new TaskCompletionSource>(); + + pubSub.Subscribe(message => + { + if (message is Guid guid) + { + received.Add(guid); + } + + if (received.Count == expectedCount) + { + receivedCompleted.TrySetResult(received); + } + }); + + return receivedCompleted.Task; + } + } +} diff --git a/backend/tests/Squidex.Infrastructure.Tests/Reflection/SimpleEqualsTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Reflection/SimpleEqualsTests.cs index fab1d637d..ef155d05e 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Reflection/SimpleEqualsTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/Reflection/SimpleEqualsTests.cs @@ -84,6 +84,12 @@ namespace Squidex.Infrastructure.Reflection TimeSpan.FromMilliseconds(123), TimeSpan.FromMilliseconds(55) }; + + yield return new object[] + { + new Uri("/url1", UriKind.Relative), + new Uri("/url2", UriKind.Relative), + }; } [Theory] diff --git a/backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj b/backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj index e81c73b3d..42be07719 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj +++ b/backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj @@ -24,6 +24,7 @@ + diff --git a/backend/tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithSchemaIdCommandMiddlewareTests.cs b/backend/tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithSchemaIdCommandMiddlewareTests.cs index d0cd29125..350a84c93 100644 --- a/backend/tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithSchemaIdCommandMiddlewareTests.cs +++ b/backend/tests/Squidex.Web.Tests/CommandMiddlewares/EnrichWithSchemaIdCommandMiddlewareTests.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using FakeItEasy; using Microsoft.AspNetCore.Http; using Squidex.Domain.Apps.Entities.Contents.Commands; +using Squidex.Domain.Apps.Entities.Schemas.Commands; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Web.Pipeline; @@ -19,47 +20,51 @@ namespace Squidex.Web.CommandMiddlewares { public class EnrichWithSchemaIdCommandMiddlewareTests { - private readonly IHttpContextAccessor httpContextAccesor = A.Fake(); + private readonly IHttpContextAccessor httpContextAccessor = A.Fake(); private readonly ICommandBus commandBus = A.Fake(); + private readonly NamedId appId = NamedId.Of(DomainId.NewGuid(), "my-app"); private readonly NamedId schemaId = NamedId.Of(DomainId.NewGuid(), "my-schema"); private readonly HttpContext httpContext = new DefaultHttpContext(); private readonly EnrichWithSchemaIdCommandMiddleware sut; public EnrichWithSchemaIdCommandMiddlewareTests() { - A.CallTo(() => httpContextAccesor.HttpContext) + httpContext.Features.Set(new AppFeature(appId)); + + A.CallTo(() => httpContextAccessor.HttpContext) .Returns(httpContext); - sut = new EnrichWithSchemaIdCommandMiddleware(httpContextAccesor); + sut = new EnrichWithSchemaIdCommandMiddleware(httpContextAccessor); } [Fact] public async Task Should_throw_exception_if_schema_not_found() { - var command = new CreateContent(); + var command = new CreateContent { AppId = appId }; var context = Ctx(command); await Assert.ThrowsAsync(() => sut.HandleAsync(context)); } [Fact] - public async Task Should_do_nothing_if_http_context_not_found() + public async Task Should_assign_schema_id_and_name_to_app_command() { - A.CallTo(() => httpContextAccesor.HttpContext) - .Returns(null!); + httpContext.Features.Set(new SchemaFeature(schemaId)); var command = new CreateContent(); var context = Ctx(command); await sut.HandleAsync(context); + + Assert.Equal(schemaId, command.SchemaId); } [Fact] - public async Task Should_assign_schema_id_and_name_to_app_command() + public async Task Should_assign_schema_id_from_id() { httpContext.Features.Set(new SchemaFeature(schemaId)); - var command = new CreateContent(); + var command = new UpdateSchema(); var context = Ctx(command); await sut.HandleAsync(context); @@ -67,6 +72,19 @@ namespace Squidex.Web.CommandMiddlewares Assert.Equal(schemaId, command.SchemaId); } + [Fact] + public async Task Should_not_override_schema_id() + { + httpContext.Features.Set(new SchemaFeature(schemaId)); + + var command = new CreateSchema { SchemaId = Guid.NewGuid() }; + var context = Ctx(command); + + await sut.HandleAsync(context); + + Assert.NotEqual(schemaId.Id, command.SchemaId); + } + [Fact] public async Task Should_not_override_schema_id_and_name() { diff --git a/backend/tests/Squidex.Web.Tests/Pipeline/AppResolverTests.cs b/backend/tests/Squidex.Web.Tests/Pipeline/AppResolverTests.cs index f5d11dcec..2b25a85b0 100644 --- a/backend/tests/Squidex.Web.Tests/Pipeline/AppResolverTests.cs +++ b/backend/tests/Squidex.Web.Tests/Pipeline/AppResolverTests.cs @@ -34,13 +34,16 @@ namespace Squidex.Web.Pipeline private readonly ActionContext actionContext; private readonly ActionExecutingContext actionExecutingContext; 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 AppResolver sut; private bool isNextCalled; public AppResolverTests() { + user = new ClaimsPrincipal(userIdentiy); + actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor { EndpointMetadata = new List() @@ -48,7 +51,7 @@ namespace Squidex.Web.Pipeline actionExecutingContext = new ActionExecutingContext(actionContext, new List(), new Dictionary(), this); actionExecutingContext.HttpContext = httpContext; - actionExecutingContext.HttpContext.User = new ClaimsPrincipal(user); + actionExecutingContext.HttpContext.User = user; actionExecutingContext.RouteData.Values["app"] = appName; next = () => @@ -64,7 +67,7 @@ namespace Squidex.Web.Pipeline [Fact] 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(null)); await sut.OnActionExecutionAsync(actionExecutingContext, next); @@ -78,10 +81,10 @@ namespace Squidex.Web.Pipeline { var app = CreateApp(appName, appUser: "user1"); - user.AddClaim(new Claim(OpenIdClaims.Subject, "user1")); - user.AddClaim(new Claim(SquidexClaimTypes.Permissions, "squidex.apps.my-app")); + userIdentiy.AddClaim(new Claim(OpenIdClaims.Subject, "user1")); + userIdentiy.AddClaim(new Claim(SquidexClaimTypes.Permissions, "squidex.apps.my-app")); - A.CallTo(() => appProvider.GetAppAsync(appName)) + A.CallTo(() => appProvider.GetAppAsync(appName, true)) .Returns(app); await sut.OnActionExecutionAsync(actionExecutingContext, next); @@ -96,9 +99,9 @@ namespace Squidex.Web.Pipeline { 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); await sut.OnActionExecutionAsync(actionExecutingContext, next); @@ -113,12 +116,12 @@ namespace Squidex.Web.Pipeline { var app = CreateApp(appName); - user.AddClaim(new Claim(OpenIdClaims.ClientId, $"{appName}:client1")); - user.AddClaim(new Claim(SquidexClaimTypes.Permissions, "squidex.apps.other-app")); + userIdentiy.AddClaim(new Claim(OpenIdClaims.ClientId, $"{appName}:client1")); + userIdentiy.AddClaim(new Claim(SquidexClaimTypes.Permissions, "squidex.apps.other-app")); actionContext.ActionDescriptor.EndpointMetadata.Add(new AllowAnonymousAttribute()); - A.CallTo(() => appProvider.GetAppAsync(appName)) + A.CallTo(() => appProvider.GetAppAsync(appName, true)) .Returns(app); await sut.OnActionExecutionAsync(actionExecutingContext, next); @@ -133,10 +136,10 @@ namespace Squidex.Web.Pipeline { var app = CreateApp(appName); - user.AddClaim(new Claim(OpenIdClaims.ClientId, $"{appName}:client1")); - user.AddClaim(new Claim(SquidexClaimTypes.Permissions, "squidex.apps.other-app")); + userIdentiy.AddClaim(new Claim(OpenIdClaims.ClientId, $"{appName}:client1")); + userIdentiy.AddClaim(new Claim(SquidexClaimTypes.Permissions, "squidex.apps.other-app")); - A.CallTo(() => appProvider.GetAppAsync(appName)) + A.CallTo(() => appProvider.GetAppAsync(appName, false)) .Returns(app); await sut.OnActionExecutionAsync(actionExecutingContext, next); @@ -150,9 +153,9 @@ namespace Squidex.Web.Pipeline { 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); await sut.OnActionExecutionAsync(actionExecutingContext, next); @@ -170,7 +173,7 @@ namespace Squidex.Web.Pipeline Assert.True(isNextCalled); - A.CallTo(() => appProvider.GetAppAsync(A._)) + A.CallTo(() => appProvider.GetAppAsync(A._, false)) .MustNotHaveHappened(); } diff --git a/backend/tests/Squidex.Web.Tests/Pipeline/SchemaResolverTests.cs b/backend/tests/Squidex.Web.Tests/Pipeline/SchemaResolverTests.cs index 3240ba63c..169aaaed1 100644 --- a/backend/tests/Squidex.Web.Tests/Pipeline/SchemaResolverTests.cs +++ b/backend/tests/Squidex.Web.Tests/Pipeline/SchemaResolverTests.cs @@ -18,6 +18,8 @@ using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Infrastructure; +using Squidex.Infrastructure.Security; +using Squidex.Shared; using Xunit; #pragma warning disable IDE0017 // Simplify object initialization @@ -47,6 +49,7 @@ namespace Squidex.Web.Pipeline actionExecutingContext = new ActionExecutingContext(actionContext, new List(), new Dictionary(), this); actionExecutingContext.HttpContext = httpContext; actionExecutingContext.HttpContext.User = new ClaimsPrincipal(user); + actionExecutingContext.HttpContext.Features.Set(new AppFeature(appId)); next = () => { @@ -61,10 +64,9 @@ namespace Squidex.Web.Pipeline [Fact] public async Task Should_return_not_found_if_schema_not_found() { - actionExecutingContext.HttpContext.Features.Set(new AppFeature(appId)); actionContext.RouteData.Values["name"] = schemaId.Id.ToString(); - A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, A._, false)) + A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, A._, false, false)) .Returns(Task.FromResult(null)); await sut.OnActionExecutionAsync(actionExecutingContext, next); @@ -76,12 +78,29 @@ namespace Squidex.Web.Pipeline [Fact] public async Task Should_resolve_schema_from_id() { - actionExecutingContext.HttpContext.Features.Set(new AppFeature(appId)); actionContext.RouteData.Values["name"] = schemaId.Id.ToString(); 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().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); await sut.OnActionExecutionAsync(actionExecutingContext, next); @@ -93,12 +112,11 @@ namespace Squidex.Web.Pipeline [Fact] public async Task Should_resolve_schema_from_name() { - actionExecutingContext.HttpContext.Features.Set(new AppFeature(appId)); actionContext.RouteData.Values["name"] = schemaId.Name; var schema = CreateSchema(); - A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Name)) + A.CallTo(() => appProvider.GetSchemaAsync(appId.Id, schemaId.Name, true)) .Returns(schema); await sut.OnActionExecutionAsync(actionExecutingContext, next); @@ -108,29 +126,45 @@ namespace Squidex.Web.Pipeline } [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().SchemaId); + Assert.True(isNextCalled); + } + [Fact] + public async Task Should_do_nothing_if_parameter_not_set() + { await sut.OnActionExecutionAsync(actionExecutingContext, next); Assert.True(isNextCalled); - A.CallTo(() => appProvider.GetAppAsync(A._)) + A.CallTo(() => appProvider.GetAppAsync(A._, false)) .MustNotHaveHappened(); } [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(new AppFeature(appId)); - actionExecutingContext.RouteData.Values.Remove("name"); + actionExecutingContext.HttpContext.Features.Set(null!); + actionExecutingContext.RouteData.Values["name"] = schemaId.Name; await sut.OnActionExecutionAsync(actionExecutingContext, next); Assert.True(isNextCalled); - A.CallTo(() => appProvider.GetAppAsync(A._)) + A.CallTo(() => appProvider.GetAppAsync(A._, false)) .MustNotHaveHappened(); } diff --git a/backend/tools/k6/docker-compose.yml b/backend/tools/k6/docker-compose.yml index 3e7d28660..91439c7c2 100644 --- a/backend/tools/k6/docker-compose.yml +++ b/backend/tools/k6/docker-compose.yml @@ -12,6 +12,7 @@ services: - "8086:8086" environment: - INFLUXDB_DB=k6 + - INFLUXDB_HTTP_MAX_BODY_SIZE=0 grafana: image: grafana/grafana:latest diff --git a/backend/tools/k6/get-clients.js b/backend/tools/k6/get-clients.js index ad67f6e2a..5de17f74a 100644 --- a/backend/tools/k6/get-clients.js +++ b/backend/tools/k6/get-clients.js @@ -2,20 +2,19 @@ import { check } from 'k6'; import http from 'k6/http'; import { variables, getBearerToken } from './shared.js'; -export let options = { +export const options = { stages: [ - { duration: "2m", target: 200 }, - { duration: "2m", target: 200 }, + { duration: "2m", target: 500 }, { duration: "2m", target: 0 }, ], 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() { - const token = getBearerToken(); + const token = getBearerToken(variables.appName); return { token }; } diff --git a/backend/tools/k6/get-content.js b/backend/tools/k6/get-content.js new file mode 100644 index 000000000..48c24b692 --- /dev/null +++ b/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, + }); +} diff --git a/backend/tools/k6/shared.js b/backend/tools/k6/shared.js index a8aef7935..5824f8f9a 100644 --- a/backend/tools/k6/shared.js +++ b/backend/tools/k6/shared.js @@ -9,25 +9,53 @@ export const variables = { let bearerToken = null; -export function getBearerToken() { +export function getBearerToken(appName) { if (!bearerToken) { - const url = `${variables.serverUrl}/identity-server/connect/token`; + const adminToken = getToken(variables.clientId, variables.clientSecret); - const response = http.post(url, { - grant_type: 'client_credentials', - client_id: variables.clientId, - client_secret: variables.clientSecret, - scope: 'squidex-api' + const clientsUrl = `${variables.serverUrl}/api/apps/${appName}/clients`; + + const clientsResponse = http.get(clientsUrl, { + headers: { + Authorization: `Bearer ${adminToken}` + } }); - const json = JSON.parse(response.body); + const clientsJson = JSON.parse(clientsResponse.body); + const client = clientsJson.items[0]; + + const clientId = `${appName}:${client.id}`; + const clientSecret = client.secret; + + console.log(`Using ${clientId} / ${clientSecret}`); - bearerToken = json.access_token; + bearerToken = getToken(clientId, clientSecret); } 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) { const result = __ENV[key] || fallback; diff --git a/backend/tools/k6/test.js b/backend/tools/k6/test.js new file mode 100644 index 000000000..fc958f78c --- /dev/null +++ b/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, + }); +} \ No newline at end of file diff --git a/frontend/app/shared/components/forms/rich-editor.component.ts b/frontend/app/shared/components/forms/rich-editor.component.ts index 256b6cd5e..9fbc5c997 100644 --- a/frontend/app/shared/components/forms/rich-editor.component.ts +++ b/frontend/app/shared/components/forms/rich-editor.component.ts @@ -128,38 +128,48 @@ export class RichEditorComponent extends StatefulControlComponent { - const value = editor.getContent(); - - if (this.value !== value) { - this.value = value; - - self.callChange(value); - } + self.onValueChanged(); }); editor.on('paste', (event: ClipboardEvent) => { + let hasFileDropped = false; + if (event.clipboardData) { for (let i = 0; i < event.clipboardData.items.length; i++) { const file = event.clipboardData.items[i].getAsFile(); if (file && ImageTypes.indexOf(file.type) >= 0) { self.uploadFile(file); + + hasFileDropped = true; } } } + + if (!hasFileDropped) { + self.onValueChanged(); + } }); editor.on('drop', (event: DragEvent) => { + let hasFileDropped = false; + if (event.dataTransfer) { for (let i = 0; i < event.dataTransfer.files.length; i++) { const file = event.dataTransfer.files.item(i); if (file && ImageTypes.indexOf(file.type) >= 0) { self.uploadFile(file); + + hasFileDropped = true; } } } + if (!hasFileDropped) { + self.onValueChanged(); + } + return false; }); @@ -172,6 +182,16 @@ export class RichEditorComponent extends StatefulControlComponent