From eaf262959749425df13a7f9934a3320a331a2063 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Thu, 20 Oct 2022 19:19:28 +0200 Subject: [PATCH] Timeouts (#930) * Fix timeouts. * Correctly save the Validate flag of workflows. * New bulk endpoint. * Update packages. * Fix tests * Fix SDK version. * Add some docs. --- .../Contents/Json/WorkflowStepSurrogate.cs | 8 +- .../HandleRules/RuleService.cs | 10 +- .../Scripting/JintScriptEngine.cs | 70 ++++----- ...Squidex.Domain.Apps.Core.Operations.csproj | 2 +- .../AssetsBulkUpdateCommandMiddleware.cs | 9 +- .../Assets/Queries/AssetQueryService.cs | 80 +++++----- .../Comments/Commands/_CommentsCommand.cs | 2 +- .../Contents/Commands/BulkUpdateContents.cs | 2 + .../ContentsBulkUpdateCommandMiddleware.cs | 82 +++++----- .../Contents/Queries/ContentQueryService.cs | 30 ++-- .../Diagnostics/Diagnoser.cs | 10 +- .../Email/SmtpEmailSender.cs | 32 ++-- .../Squidex.Infrastructure.csproj | 12 +- .../Api/Config/OpenApi/XmlTagProcessor.cs | 2 +- .../Api/Controllers/Assets/Models/AssetDto.cs | 4 +- .../Contents/ContentsController.cs | 88 +---------- .../Contents/ContentsSharedController.cs | 148 ++++++++++++++++++ .../Generator/SchemasOpenApiGenerator.cs | 8 + .../Contents/Models/BulkUpdateContentsDto.cs | 24 +-- .../Contents/Models/ImportContentsDto.cs | 21 +-- .../Config/Messaging/MessagingServices.cs | 2 +- backend/src/Squidex/Squidex.csproj | 24 +-- .../Model/Contents/WorkflowJsonTests.cs | 3 +- .../Apps/DomainObject/AppState.json | 7 +- ...ontentsBulkUpdateCommandMiddlewareTests.cs | 22 +++ .../TestSuite.ApiTests/ContentQueryTests.cs | 40 +++-- .../TestSuite.ApiTests/ContentUpdateTests.cs | 82 ++++++++++ .../TestSuite.ApiTests/GraphQLTests.cs | 6 +- .../TestSuite.ApiTests.csproj | 2 +- .../Fixtures/ClientFixture.cs | 5 + .../TestSuite.Shared/TestSuite.Shared.csproj | 4 +- .../workflows/workflow-step.component.html | 2 +- 32 files changed, 534 insertions(+), 309 deletions(-) create mode 100644 backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsSharedController.cs diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Contents/Json/WorkflowStepSurrogate.cs b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/Json/WorkflowStepSurrogate.cs index 8fc27a389..0f3f6b4e9 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Contents/Json/WorkflowStepSurrogate.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Contents/Json/WorkflowStepSurrogate.cs @@ -16,11 +16,13 @@ namespace Squidex.Domain.Apps.Core.Contents.Json { public Dictionary Transitions { get; set; } + [JsonPropertyName("noUpdateRules")] + public NoUpdate? NoUpdate { get; set; } + [JsonPropertyName("noUpdate")] public bool NoUpdateFlag { get; set; } - [JsonPropertyName("noUpdateRules")] - public NoUpdate? NoUpdate { get; set; } + public bool Validate { get; set; } public string? Color { get; set; } @@ -52,7 +54,7 @@ namespace Squidex.Domain.Apps.Core.Contents.Json x => x.Key, x => x.Value.ToSource()); - return new WorkflowStep(transitions, Color, noUpdate); + return new WorkflowStep(transitions, Color, noUpdate, Validate); } } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs index 6acbae68f..49f2d33f5 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs @@ -364,12 +364,12 @@ namespace Squidex.Domain.Apps.Core.HandleRules var deserialized = serializer.Deserialize(job, actionHandler.DataType); - using (var cts = new CancellationTokenSource(GetTimeoutInMs())) + using (var combined = CancellationTokenSource.CreateLinkedTokenSource(ct)) { - using (var combined = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, ct)) - { - result = await actionHandler.ExecuteJobAsync(deserialized, combined.Token).WithCancellation(combined.Token); - } + // Enforce a timeout after a configured time span. + combined.CancelAfter(GetTimeoutInMs()); + + result = await actionHandler.ExecuteJobAsync(deserialized, combined.Token).WithCancellation(combined.Token); } } catch (Exception ex) diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs index d0e3c82a8..954e18403 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs @@ -46,25 +46,25 @@ namespace Squidex.Domain.Apps.Core.Scripting Guard.NotNull(vars); Guard.NotNullOrEmpty(script); - using (var cts = new CancellationTokenSource(timeoutExecution)) + using (var combined = CancellationTokenSource.CreateLinkedTokenSource(ct)) { - using (var combined = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, ct)) - { - var context = - CreateEngine(options, combined.Token) - .Extend(vars, options) - .Extend(extensions) - .ExtendAsync(extensions); + // Enforce a timeout after a configured time span. + combined.CancelAfter(timeoutExecution); - context.Engine.SetValue("complete", new Action(value => - { - context.Complete(JsonMapper.Map(value)); - })); + var context = + CreateEngine(options, combined.Token) + .Extend(vars, options) + .Extend(extensions) + .ExtendAsync(extensions); + + context.Engine.SetValue("complete", new Action(value => + { + context.Complete(JsonMapper.Map(value)); + })); - var result = Execute(context.Engine, script); + var result = Execute(context.Engine, script); - return await context.CompleteAsync() ?? JsonMapper.Map(result); - } + return await context.CompleteAsync() ?? JsonMapper.Map(result); } } @@ -74,38 +74,38 @@ namespace Squidex.Domain.Apps.Core.Scripting Guard.NotNull(vars); Guard.NotNullOrEmpty(script); - using (var cts = new CancellationTokenSource(timeoutExecution)) + using (var combined = CancellationTokenSource.CreateLinkedTokenSource(ct)) { - using (var combined = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, ct)) - { - var context = + // Enforce a timeout after a configured time span. + combined.CancelAfter(timeoutExecution); + + var context = CreateEngine(options, combined.Token) .Extend(vars, options) .Extend(extensions) .ExtendAsync(extensions); - context.Engine.SetValue("complete", new Action(_ => - { - context.Complete(vars.Data!); - })); + context.Engine.SetValue("complete", new Action(_ => + { + context.Complete(vars.Data!); + })); - context.Engine.SetValue("replace", new Action(() => - { - var dataInstance = context.Engine.GetValue("ctx").AsObject().Get("data"); + context.Engine.SetValue("replace", new Action(() => + { + var dataInstance = context.Engine.GetValue("ctx").AsObject().Get("data"); - if (dataInstance != null && dataInstance.IsObject() && dataInstance.AsObject() is ContentDataObject data) + if (dataInstance != null && dataInstance.IsObject() && dataInstance.AsObject() is ContentDataObject data) + { + if (!context.IsCompleted && data.TryUpdate(out var modified)) { - if (!context.IsCompleted && data.TryUpdate(out var modified)) - { - context.Complete(modified); - } + context.Complete(modified); } - })); + } + })); - Execute(context.Engine, script); + Execute(context.Engine, script); - return await context.CompleteAsync() ?? vars.Data!; - } + return await context.CompleteAsync() ?? vars.Data!; } } diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj b/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj index 439726aac..b0b4bf921 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj @@ -28,7 +28,7 @@ - + diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetsBulkUpdateCommandMiddleware.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetsBulkUpdateCommandMiddleware.cs index ad0da8828..b51fd0828 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetsBulkUpdateCommandMiddleware.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetsBulkUpdateCommandMiddleware.cs @@ -35,7 +35,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject BulkUpdateJob CommandJob, BulkUpdateAssets Command, ConcurrentBag Results, - CancellationToken CancellationToken); + CancellationToken Aborted); public AssetsBulkUpdateCommandMiddleware(IContextProvider contextProvider, ILogger log) { @@ -56,6 +56,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount / 2) }; + // Each job can create exactly one command. var createCommandsBlock = new TransformBlock(task => { try @@ -69,6 +70,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject } }, executionOptions); + // Execute the commands in batches var executeCommandBlock = new ActionBlock(async command => { try @@ -113,6 +115,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject createCommandsBlock.Complete(); + // Wait for all commands to be executed. await executeCommandBlock.Completion; context.Complete(new BulkUpdateResult(results)); @@ -150,14 +153,14 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject private BulkTaskCommand? CreateCommand(BulkTask task) { var id = task.CommandJob.Id; - try { var command = CreateCommandCore(task); + // Set the asset id here in case we have another way to resolve ids. command.AssetId = id; - return new BulkTaskCommand(task, id, command, task.CancellationToken); + return new BulkTaskCommand(task, id, command, task.Aborted); } catch (Exception ex) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs index 4a44fd6ed..2ef1b7f66 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs @@ -218,96 +218,96 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries private async Task> QueryFoldersCoreAsync(Context context, DomainId parentId, CancellationToken ct) { - using (var timeout = new CancellationTokenSource(options.TimeoutQuery)) + using (var combined = CancellationTokenSource.CreateLinkedTokenSource(ct)) { - using (var combined = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, ct)) - { - return await assetFolderRepository.QueryAsync(context.App.Id, parentId, combined.Token); - } + // Enforce a hard timeout + combined.CancelAfter(options.TimeoutQuery); + + return await assetFolderRepository.QueryAsync(context.App.Id, parentId, combined.Token); } } private async Task> QueryFoldersCoreAsync(DomainId appId, DomainId parentId, CancellationToken ct) { - using (var timeout = new CancellationTokenSource(options.TimeoutQuery)) + using (var combined = CancellationTokenSource.CreateLinkedTokenSource(ct)) { - using (var combined = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, ct)) - { - return await assetFolderRepository.QueryAsync(appId, parentId, combined.Token); - } + // Enforce a hard timeout + combined.CancelAfter(options.TimeoutQuery); + + return await assetFolderRepository.QueryAsync(appId, parentId, combined.Token); } } private async Task> QueryCoreAsync(Context context, DomainId? parentId, Q q, CancellationToken ct) { - using (var timeout = new CancellationTokenSource(options.TimeoutQuery)) + using (var combined = CancellationTokenSource.CreateLinkedTokenSource(ct)) { - using (var combined = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, ct)) - { - return await assetRepository.QueryAsync(context.App.Id, parentId, q, combined.Token); - } + // Enforce a hard timeout + combined.CancelAfter(options.TimeoutQuery); + + return await assetRepository.QueryAsync(context.App.Id, parentId, q, combined.Token); } } private async Task FindFolderCoreAsync(DomainId appId, DomainId id, CancellationToken ct) { - using (var timeout = new CancellationTokenSource(options.TimeoutFind)) + using (var combined = CancellationTokenSource.CreateLinkedTokenSource(ct)) { - using (var combined = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, ct)) - { - return await assetFolderRepository.FindAssetFolderAsync(appId, id, combined.Token); - } + // Enforce a hard timeout + combined.CancelAfter(options.TimeoutFind); + + return await assetFolderRepository.FindAssetFolderAsync(appId, id, combined.Token); } } private async Task FindByHashCoreAsync(Context context, string hash, string fileName, long fileSize, CancellationToken ct) { - using (var timeout = new CancellationTokenSource(options.TimeoutFind)) + using (var combined = CancellationTokenSource.CreateLinkedTokenSource(ct)) { - using (var combined = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, ct)) - { - return await assetRepository.FindAssetByHashAsync(context.App.Id, hash, fileName, fileSize, combined.Token); - } + // Enforce a hard timeout + combined.CancelAfter(options.TimeoutFind); + + return await assetRepository.FindAssetByHashAsync(context.App.Id, hash, fileName, fileSize, combined.Token); } } private async Task FindBySlugCoreAsync(Context context, string slug, CancellationToken ct) { - using (var timeout = new CancellationTokenSource(options.TimeoutFind)) + using (var combined = CancellationTokenSource.CreateLinkedTokenSource(ct)) { - using (var combined = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, ct)) - { - return await assetRepository.FindAssetBySlugAsync(context.App.Id, slug, combined.Token); - } + // Enforce a hard timeout + combined.CancelAfter(options.TimeoutFind); + + return await assetRepository.FindAssetBySlugAsync(context.App.Id, slug, combined.Token); } } private async Task FindCoreAsync(DomainId id, CancellationToken ct) { - using (var timeout = new CancellationTokenSource(options.TimeoutFind)) + using (var combined = CancellationTokenSource.CreateLinkedTokenSource(ct)) { - using (var combined = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, ct)) - { - return await assetRepository.FindAssetAsync(id, combined.Token); - } + // Enforce a hard timeout + combined.CancelAfter(options.TimeoutFind); + + return await assetRepository.FindAssetAsync(id, combined.Token); } } private async Task FindCoreAsync(Context context, DomainId id, CancellationToken ct) { - using (var timeout = new CancellationTokenSource(options.TimeoutFind)) + using (var combined = CancellationTokenSource.CreateLinkedTokenSource(ct)) { - using (var combined = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, ct)) - { - return await assetRepository.FindAssetAsync(context.App.Id, id, combined.Token); - } + // Enforce a hard timeout + combined.CancelAfter(options.TimeoutFind); + + return await assetRepository.FindAssetAsync(context.App.Id, id, combined.Token); } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/_CommentsCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/_CommentsCommand.cs index dabb7d7fe..afbb766f9 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/_CommentsCommand.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/_CommentsCommand.cs @@ -19,7 +19,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.Commands public abstract class CommentsCommand : CommentsCommandBase { - public static readonly NamedId NoApp = NamedId.Of(DomainId.NewGuid(), "none"); + public static readonly NamedId NoApp = NamedId.Of(DomainId.Empty, "none"); public DomainId CommentsId { get; set; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateContents.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateContents.cs index 7d6868ef0..bf4576feb 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateContents.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateContents.cs @@ -11,6 +11,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.Commands { public sealed class BulkUpdateContents : SquidexCommand, IAppCommand, ISchemaCommand { + public static readonly NamedId NoSchema = NamedId.Of(DomainId.Empty, "none"); + public NamedId AppId { get; set; } public NamedId SchemaId { get; set; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentsBulkUpdateCommandMiddleware.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentsBulkUpdateCommandMiddleware.cs index aaad23bc1..126e09e1f 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentsBulkUpdateCommandMiddleware.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentsBulkUpdateCommandMiddleware.cs @@ -34,12 +34,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject private sealed record BulkTask( ICommandBus Bus, - string Schema, + NamedId SchemaId, int JobIndex, BulkUpdateJob CommandJob, BulkUpdateContents Command, ConcurrentBag Results, - CancellationToken CancellationToken); + CancellationToken Aborted); public ContentsBulkUpdateCommandMiddleware( IContentQueryService contentQuery, @@ -64,6 +64,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount / 2) }; + // Each job can create one or more commands. var createCommandsBlock = new TransformManyBlock(async task => { try @@ -77,6 +78,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject } }, executionOptions); + // Execute the commands in batches. var executeCommandBlock = new ActionBlock(async command => { try @@ -98,15 +100,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject .WithUnpublished(true) .WithoutTotal()); - var requestedSchema = bulkUpdates.SchemaId.Name; - var results = new ConcurrentBag(); for (var i = 0; i < bulkUpdates.Jobs.Length; i++) { var task = new BulkTask( context.CommandBus, - requestedSchema, + bulkUpdates.SchemaId, i, bulkUpdates.Jobs[i], bulkUpdates, @@ -121,6 +121,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject createCommandsBlock.Complete(); + // Wait for all commands to be executed. await executeCommandBlock.Completion; context.Complete(new BulkUpdateResult(results)); @@ -158,11 +159,28 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject private async Task> CreateCommandsAsync(BulkTask task) { + // The task parallel pipeline does not allow async-enumerable. var commands = new List(); - try { - var resolvedIds = await FindIdAsync(task); + // Check whether another schema is defined for the current job and override the schema id if necessary. + var overridenSchema = task.CommandJob.Schema; + + if (!string.IsNullOrWhiteSpace(overridenSchema)) + { + var schema = await contentQuery.GetSchemaOrThrowAsync(contextProvider.Context, overridenSchema, task.Aborted); + + // Task is immutable, so we have to create a copy. + task = task with { SchemaId = schema.NamedId() }; + } + + // The bulk command can be invoke in a schema controller or without a schema controller, therefore the name might be null. + if (task.SchemaId == null || task.SchemaId.Id == default) + { + throw new DomainObjectNotFoundException("undefined"); + } + + var resolvedIds = await FindIdAsync(task, task.SchemaId.Name); if (resolvedIds.Length == 0) { @@ -173,15 +191,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject { try { - var command = await CreateCommandAsync(task); + var command = CreateCommand(task); command.ContentId = id; - - commands.Add(new BulkTaskCommand(task, id, command, task.CancellationToken)); + commands.Add(new BulkTaskCommand(task, id, command, task.Aborted)); } catch (Exception ex) { - log.LogError(ex, "Failed to execute content bulk job with index {index} of type {type}.", + log.LogError(ex, "Failed to create content bulk job with index {index} of type {type}.", task.JobIndex, task.CommandJob.Type); @@ -191,13 +208,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject } catch (Exception ex) { - task.Results.Add(new BulkUpdateResultItem(null, task.JobIndex, ex)); + task.Results.Add(new BulkUpdateResultItem(task.CommandJob.Id, task.JobIndex, ex)); } return commands; } - private async Task CreateCommandAsync(BulkTask task) + private ContentCommand CreateCommand(BulkTask task) { var job = task.CommandJob; @@ -207,7 +224,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject { var command = new CreateContent(); - await EnrichAndCheckPermissionAsync(task, command, PermissionIds.AppContentsCreate); + EnrichAndCheckPermission(task, command, PermissionIds.AppContentsCreate); return command; } @@ -215,7 +232,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject { var command = new UpdateContent(); - await EnrichAndCheckPermissionAsync(task, command, PermissionIds.AppContentsUpdateOwn); + EnrichAndCheckPermission(task, command, PermissionIds.AppContentsUpdateOwn); return command; } @@ -223,7 +240,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject { var command = new UpsertContent(); - await EnrichAndCheckPermissionAsync(task, command, PermissionIds.AppContentsUpsert); + EnrichAndCheckPermission(task, command, PermissionIds.AppContentsUpsert); return command; } @@ -231,7 +248,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject { var command = new PatchContent(); - await EnrichAndCheckPermissionAsync(task, command, PermissionIds.AppContentsUpdateOwn); + EnrichAndCheckPermission(task, command, PermissionIds.AppContentsUpdateOwn); return command; } @@ -239,7 +256,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject { var command = new ValidateContent(); - await EnrichAndCheckPermissionAsync(task, command, PermissionIds.AppContentsReadOwn); + EnrichAndCheckPermission(task, command, PermissionIds.AppContentsReadOwn); return command; } @@ -247,7 +264,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject { var command = new ChangeContentStatus { Status = job.Status ?? Status.Draft }; - await EnrichAndCheckPermissionAsync(task, command, PermissionIds.AppContentsChangeStatusOwn); + EnrichAndCheckPermission(task, command, PermissionIds.AppContentsChangeStatusOwn); return command; } @@ -255,7 +272,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject { var command = new DeleteContent(); - await EnrichAndCheckPermissionAsync(task, command, PermissionIds.AppContentsDeleteOwn); + EnrichAndCheckPermission(task, command, PermissionIds.AppContentsDeleteOwn); return command; } @@ -265,29 +282,21 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject } } - private async Task EnrichAndCheckPermissionAsync(BulkTask task, T command, string permissionId) where T : ContentCommand + private void EnrichAndCheckPermission(BulkTask task, T command, string permissionId) where T : ContentCommand { SimpleMapper.Map(task.Command, command); SimpleMapper.Map(task.CommandJob, command); - var overridenSchema = task.CommandJob.Schema; - - if (!string.IsNullOrWhiteSpace(overridenSchema)) - { - var schema = await contentQuery.GetSchemaOrThrowAsync(contextProvider.Context, overridenSchema, task.CancellationToken); - - command.SchemaId = schema.NamedId(); - } - if (!contextProvider.Context.Allows(permissionId, command.SchemaId.Name)) { throw new DomainForbiddenException("Forbidden"); } + command.SchemaId = task.SchemaId; command.ExpectedVersion = task.Command.ExpectedVersion; } - private async Task FindIdAsync(BulkTask task) + private async Task FindIdAsync(BulkTask task, string schema) { var id = task.CommandJob.Id; @@ -300,19 +309,22 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject { task.CommandJob.Query.Take = task.CommandJob.ExpectedCount; - var existing = await contentQuery.QueryAsync(contextProvider.Context, task.Schema, Q.Empty.WithJsonQuery(task.CommandJob.Query), task.CancellationToken); + var existingQuery = Q.Empty.WithJsonQuery(task.CommandJob.Query); + var existingResult = await contentQuery.QueryAsync(contextProvider.Context, schema, existingQuery, task.Aborted); - if (existing.Total > task.CommandJob.ExpectedCount) + if (existingResult.Total > task.CommandJob.ExpectedCount) { throw new DomainException(T.Get("contents.bulkInsertQueryNotUnique")); } - if (existing.Count == 0 && task.CommandJob.Type == BulkUpdateContentType.Upsert) + // Upsert means that we either update the content if we find it or that we create a new one. + // Therefore we create a new ID if we cannot find the ID for the query. + if (existingResult.Count == 0 && task.CommandJob.Type == BulkUpdateContentType.Upsert) { return new[] { DomainId.NewGuid() }; } - return existing.Select(x => x.Id).ToArray(); + return existingResult.Select(x => x.Id).ToArray(); } if (task.CommandJob.Type is BulkUpdateContentType.Create or BulkUpdateContentType.Upsert) 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 505472e31..01d9d4878 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs @@ -218,36 +218,36 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries private async Task> QueryCoreAsync(Context context, Q q, ISchemaEntity schema, CancellationToken ct) { - using (var timeout = new CancellationTokenSource(options.TimeoutQuery)) + using (var combined = CancellationTokenSource.CreateLinkedTokenSource(ct)) { - using (var combined = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, ct)) - { - return await contentRepository.QueryAsync(context.App, schema, q, context.Scope(), ct); - } + // Enforce a hard timeout + combined.CancelAfter(options.TimeoutQuery); + + return await contentRepository.QueryAsync(context.App, schema, q, context.Scope(), combined.Token); } } private async Task> QueryCoreAsync(Context context, Q q, List schemas, CancellationToken ct) { - using (var timeout = new CancellationTokenSource(options.TimeoutQuery)) + using (var combined = CancellationTokenSource.CreateLinkedTokenSource(ct)) { - using (var combined = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, ct)) - { - return await contentRepository.QueryAsync(context.App, schemas, q, context.Scope(), ct); - } + // Enforce a hard timeout + combined.CancelAfter(options.TimeoutQuery); + + return await contentRepository.QueryAsync(context.App, schemas, q, context.Scope(), combined.Token); } } private async Task FindCoreAsync(Context context, DomainId id, ISchemaEntity schema, CancellationToken ct) { - using (var timeout = new CancellationTokenSource(options.TimeoutFind)) + using (var combined = CancellationTokenSource.CreateLinkedTokenSource(ct)) { - using (var combined = CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, ct)) - { - return await contentRepository.FindContentAsync(context.App, schema, id, context.Scope(), combined.Token); - } + // Enforce a hard timeout + combined.CancelAfter(options.TimeoutFind); + + return await contentRepository.FindContentAsync(context.App, schema, id, context.Scope(), combined.Token); } } diff --git a/backend/src/Squidex.Infrastructure/Diagnostics/Diagnoser.cs b/backend/src/Squidex.Infrastructure/Diagnostics/Diagnoser.cs index e9c63bbeb..e50d36866 100644 --- a/backend/src/Squidex.Infrastructure/Diagnostics/Diagnoser.cs +++ b/backend/src/Squidex.Infrastructure/Diagnostics/Diagnoser.cs @@ -107,8 +107,10 @@ namespace Squidex.Infrastructure.Diagnostics return false; } - using var cts = new CancellationTokenSource(DefaultTimeout); - using var ctl = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, ct); + using var combined = CancellationTokenSource.CreateLinkedTokenSource(ct); + + // Enforce a hard timeout. + combined.CancelAfter(DefaultTimeout); var tempPath = Path.Combine(Path.GetTempPath(), Path.GetTempFileName()); @@ -121,7 +123,7 @@ namespace Squidex.Infrastructure.Diagnostics process.StartInfo.UseShellExecute = false; process.Start(); - await process.WaitForExitAsync(ctl.Token); + await process.WaitForExitAsync(combined.Token); if (process.ExitCode != 0) { @@ -132,7 +134,7 @@ namespace Squidex.Infrastructure.Diagnostics { var name = $"diagnostics/{extension}/{DateTime.UtcNow:yyyy-MM-dd-HH-mm-ss}.{extension}"; - await assetStore.UploadAsync(name, fs, true, ctl.Token); + await assetStore.UploadAsync(name, fs, true, combined.Token); } } finally diff --git a/backend/src/Squidex.Infrastructure/Email/SmtpEmailSender.cs b/backend/src/Squidex.Infrastructure/Email/SmtpEmailSender.cs index 5b359b25b..3009c61d6 100644 --- a/backend/src/Squidex.Infrastructure/Email/SmtpEmailSender.cs +++ b/backend/src/Squidex.Infrastructure/Email/SmtpEmailSender.cs @@ -33,29 +33,29 @@ namespace Squidex.Infrastructure.Email var smtpClient = clientPool.Get(); try { - using (var timeout = new CancellationTokenSource(options.Timeout)) + using (var combined = CancellationTokenSource.CreateLinkedTokenSource(ct)) { - using (var combined = CancellationTokenSource.CreateLinkedTokenSource(ct, timeout.Token)) - { - await EnsureConnectedAsync(smtpClient, combined.Token); + // Enforce a hard timeout from the configuration. + combined.CancelAfter(options.Timeout); + + await EnsureConnectedAsync(smtpClient, combined.Token); - var smtpMessage = new MimeMessage(); + var smtpMessage = new MimeMessage(); - smtpMessage.From.Add(MailboxAddress.Parse( - options.Sender)); + smtpMessage.From.Add(MailboxAddress.Parse( + options.Sender)); - smtpMessage.To.Add(MailboxAddress.Parse( - recipient)); + smtpMessage.To.Add(MailboxAddress.Parse( + recipient)); - smtpMessage.Body = new TextPart(TextFormat.Html) - { - Text = body - }; + smtpMessage.Body = new TextPart(TextFormat.Html) + { + Text = body + }; - smtpMessage.Subject = subject; + smtpMessage.Subject = subject; - await smtpClient.SendAsync(smtpMessage, ct); - } + await smtpClient.SendAsync(smtpMessage, ct); } } finally diff --git a/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj b/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj index c2f78a254..671068360 100644 --- a/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj +++ b/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj @@ -24,12 +24,12 @@ - - - - - - + + + + + + diff --git a/backend/src/Squidex/Areas/Api/Config/OpenApi/XmlTagProcessor.cs b/backend/src/Squidex/Areas/Api/Config/OpenApi/XmlTagProcessor.cs index df41260ba..9d4c938a9 100644 --- a/backend/src/Squidex/Areas/Api/Config/OpenApi/XmlTagProcessor.cs +++ b/backend/src/Squidex/Areas/Api/Config/OpenApi/XmlTagProcessor.cs @@ -33,7 +33,7 @@ namespace Squidex.Areas.Api.Config.OpenApi if (description != null) { - tag.Description ??= string.Empty; + tag.Description ??= string.Empty; if (!tag.Description.Contains(description, StringComparison.Ordinal)) { diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs index 616c742e7..2da7a3a5f 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs @@ -145,7 +145,7 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models /// /// The width of the image in pixels if the asset is an image. /// - [Obsolete("Use 'metdata' field now.")] + [Obsolete("Use 'metadata' field now.")] public int? PixelWidth { get => Metadata.GetPixelWidth(); @@ -154,7 +154,7 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models /// /// The height of the image in pixels if the asset is an image. /// - [Obsolete("Use 'metdata' field now.")] + [Obsolete("Use 'metadata' field now.")] public int? PixelHeight { get => Metadata.GetPixelHeight(); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs index c0acda5d5..d9865c2eb 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs @@ -15,7 +15,6 @@ using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; using Squidex.Shared; using Squidex.Web; -using Squidex.Web.GraphQL; namespace Squidex.Areas.Api.Controllers.Contents { @@ -24,97 +23,14 @@ namespace Squidex.Areas.Api.Controllers.Contents { private readonly IContentQueryService contentQuery; private readonly IContentWorkflow contentWorkflow; - private readonly GraphQLRunner graphQLRunner; public ContentsController(ICommandBus commandBus, IContentQueryService contentQuery, - IContentWorkflow contentWorkflow, - GraphQLRunner graphQLRunner) + IContentWorkflow contentWorkflow) : base(commandBus) { this.contentQuery = contentQuery; this.contentWorkflow = contentWorkflow; - this.graphQLRunner = graphQLRunner; - } - - /// - /// GraphQL endpoint. - /// - /// The name of the app. - /// - /// 200 => Contents returned or mutated. - /// 404 => App not found. - /// - /// - /// You can read the generated documentation for your app at /api/content/{appName}/docs. - /// - [HttpGet] - [HttpPost] - [Route("content/{app}/graphql/")] - [Route("content/{app}/graphql/batch")] - [ApiPermissionOrAnonymous] - [ApiCosts(2)] - public Task GetGraphQL(string app) - { - return graphQLRunner.InvokeAsync(HttpContext); - } - - /// - /// Queries contents. - /// - /// The name of the app. - /// The required query object. - /// - /// 200 => Contents returned. - /// 404 => App not found. - /// - /// - /// You can read the generated documentation for your app at /api/content/{appName}/docs. - /// - [HttpGet] - [Route("content/{app}/")] - [ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)] - [ApiPermissionOrAnonymous] - [ApiCosts(1)] - public async Task GetAllContents(string app, AllContentsByGetDto query) - { - var contents = await contentQuery.QueryAsync(Context, query?.ToQuery() ?? Q.Empty, HttpContext.RequestAborted); - - var response = Deferred.AsyncResponse(() => - { - return ContentsDto.FromContentsAsync(contents, Resources, null, contentWorkflow); - }); - - return Ok(response); - } - - /// - /// Queries contents. - /// - /// The name of the app. - /// The required query object. - /// - /// 200 => Contents returned. - /// 404 => App not found. - /// - /// - /// You can read the generated documentation for your app at /api/content/{appName}/docs. - /// - [HttpPost] - [Route("content/{app}/")] - [ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)] - [ApiPermissionOrAnonymous] - [ApiCosts(1)] - public async Task GetAllContentsPost(string app, [FromBody] AllContentsByPostDto query) - { - var contents = await contentQuery.QueryAsync(Context, query?.ToQuery() ?? Q.Empty, HttpContext.RequestAborted); - - var response = Deferred.AsyncResponse(() => - { - return ContentsDto.FromContentsAsync(contents, Resources, null, contentWorkflow); - }); - - return Ok(response); } /// @@ -415,7 +331,7 @@ namespace Squidex.Areas.Api.Controllers.Contents [ApiCosts(5)] public async Task BulkUpdateContents(string app, string schema, [FromBody] BulkUpdateContentsDto request) { - var command = request.ToCommand(); + var command = request.ToCommand(false); var context = await CommandBus.PublishAsync(command, HttpContext.RequestAborted); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsSharedController.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsSharedController.cs new file mode 100644 index 000000000..c2ec9c737 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsSharedController.cs @@ -0,0 +1,148 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.AspNetCore.Mvc; +using Squidex.Areas.Api.Controllers.Contents.Models; +using Squidex.Domain.Apps.Entities; +using Squidex.Domain.Apps.Entities.Contents; +using Squidex.Infrastructure.Commands; +using Squidex.Shared; +using Squidex.Web; +using Squidex.Web.GraphQL; + +namespace Squidex.Areas.Api.Controllers.Contents +{ + [SchemaMustBePublished] + public sealed class ContentsSharedController : ApiController + { + private readonly IContentQueryService contentQuery; + private readonly IContentWorkflow contentWorkflow; + private readonly GraphQLRunner graphQLRunner; + + public ContentsSharedController(ICommandBus commandBus, + IContentQueryService contentQuery, + IContentWorkflow contentWorkflow, + GraphQLRunner graphQLRunner) + : base(commandBus) + { + this.contentQuery = contentQuery; + this.contentWorkflow = contentWorkflow; + this.graphQLRunner = graphQLRunner; + } + + /// + /// GraphQL endpoint. + /// + /// The name of the app. + /// + /// 200 => Contents returned or mutated. + /// 404 => App not found. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs. + /// + [HttpGet] + [HttpPost] + [Route("content/{app}/graphql/")] + [Route("content/{app}/graphql/batch")] + [ApiPermissionOrAnonymous] + [ApiCosts(2)] + public Task GetGraphQL(string app) + { + return graphQLRunner.InvokeAsync(HttpContext); + } + + /// + /// Queries contents. + /// + /// The name of the app. + /// The required query object. + /// + /// 200 => Contents returned. + /// 404 => App not found. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs. + /// + [HttpGet] + [Route("content/{app}/")] + [ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)] + [ApiPermissionOrAnonymous] + [ApiCosts(1)] + public async Task GetAllContents(string app, AllContentsByGetDto query) + { + var contents = await contentQuery.QueryAsync(Context, query?.ToQuery() ?? Q.Empty, HttpContext.RequestAborted); + + var response = Deferred.AsyncResponse(() => + { + return ContentsDto.FromContentsAsync(contents, Resources, null, contentWorkflow); + }); + + return Ok(response); + } + + /// + /// Queries contents. + /// + /// The name of the app. + /// The required query object. + /// + /// 200 => Contents returned. + /// 404 => App not found. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs. + /// + [HttpPost] + [Route("content/{app}/")] + [ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)] + [ApiPermissionOrAnonymous] + [ApiCosts(1)] + public async Task GetAllContentsPost(string app, [FromBody] AllContentsByPostDto query) + { + var contents = await contentQuery.QueryAsync(Context, query?.ToQuery() ?? Q.Empty, HttpContext.RequestAborted); + + var response = Deferred.AsyncResponse(() => + { + return ContentsDto.FromContentsAsync(contents, Resources, null, contentWorkflow); + }); + + return Ok(response); + } + + /// + /// Bulk update content items. + /// + /// The name of the app. + /// The name of the schema. + /// The bulk update request. + /// + /// 201 => Contents created, update or delete. + /// 400 => Contents request not valid. + /// 404 => Contents references, schema or app not found. + /// + /// + /// You can read the generated documentation for your app at /api/content/{appName}/docs. + /// + [HttpPost] + [Route("content/{app}/bulk")] + [ProducesResponseType(typeof(BulkResultDto[]), StatusCodes.Status200OK)] + [ApiPermissionOrAnonymous(PermissionIds.AppContentsReadOwn)] + [ApiCosts(5)] + public async Task BulkUpdateContents(string app, string schema, [FromBody] BulkUpdateContentsDto request) + { + var command = request.ToCommand(true); + + var context = await CommandBus.PublishAsync(command, HttpContext.RequestAborted); + + var result = context.Result(); + var response = result.Select(x => BulkResultDto.FromDomain(x, HttpContext)).ToArray(); + + return Ok(response); + } + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasOpenApiGenerator.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasOpenApiGenerator.cs index 15faeb851..c3e8d3e93 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasOpenApiGenerator.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasOpenApiGenerator.cs @@ -100,6 +100,14 @@ namespace Squidex.Areas.Api.Controllers.Contents.Generator .HasQuery("ids", JsonObjectType.String, "Comma-separated list of content IDs.") .Responds(200, "Content items retrieved.", builder.ContentsSchema) .Responds(400, "Query not valid."); + + builder.AddOperation(OpenApiOperationMethod.Post, "/bulk") + .RequirePermission(PermissionIds.AppContentsReadOwn) + .Operation("Bulk") + .OperationSummary("Bulk update content items across all schemas.") + .HasBody("request", builder.Parent.BulkRequestSchema, null) + .Responds(200, "Contents created, update or delete.", builder.Parent.BulkResponseSchema) + .Responds(400, "Contents request not valid."); } private static void GenerateSchemaOperations(OperationsBuilder builder) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateContentsDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateContentsDto.cs index 90fb3e458..c42e7d8d6 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateContentsDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateContentsDto.cs @@ -51,25 +51,29 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models /// public bool OptimizeValidation { get; set; } = true; - public BulkUpdateContents ToCommand() + public BulkUpdateContents ToCommand(bool setSchema) { var result = SimpleMapper.Map(this, new BulkUpdateContents()); - result.Jobs = Jobs?.Select(x => x.ToJob())?.ToArray(); + result.Jobs = Jobs?.Select(x => + { + var job = x.ToJob(); #pragma warning disable CS0618 // Type or member is obsolete - if (result.Jobs != null && Publish) - { - foreach (var job in result.Jobs) + if (Publish) { - if (job != null) - { - job.Status = Status.Published; - } + job.Status = Status.Published; } - } #pragma warning restore CS0618 // Type or member is obsolete + return job; + }).ToArray(); + + if (setSchema) + { + result.SchemaId = BulkUpdateContents.NoSchema; + } + return result; } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ImportContentsDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ImportContentsDto.cs index f30ba560c..b9144d3f4 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ImportContentsDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ImportContentsDto.cs @@ -40,21 +40,24 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models { var result = SimpleMapper.Map(this, new BulkUpdateContents()); - result.Jobs = Datas?.Select(x => new BulkUpdateJob { Type = BulkUpdateContentType.Create, Data = x }).ToArray(); + result.Jobs = Datas?.Select(x => + { + var job = new BulkUpdateJob + { + Type = BulkUpdateContentType.Create, + Data = x + }; #pragma warning disable CS0618 // Type or member is obsolete - if (result.Jobs != null && Publish) - { - foreach (var job in result.Jobs) + if (Publish) { - if (job != null) - { - job.Status = Status.Published; - } + job.Status = Status.Published; } - } #pragma warning restore CS0618 // Type or member is obsolete + return job; + }).ToArray(); + return result; } } diff --git a/backend/src/Squidex/Config/Messaging/MessagingServices.cs b/backend/src/Squidex/Config/Messaging/MessagingServices.cs index 4886b0b27..891ca883f 100644 --- a/backend/src/Squidex/Config/Messaging/MessagingServices.cs +++ b/backend/src/Squidex/Config/Messaging/MessagingServices.cs @@ -63,7 +63,7 @@ namespace Squidex.Config.Messaging } services.AddSingleton(c => - new SystemTextJsonTransportSerializer(c.GetRequiredService())); + new SystemTextJsonMessagingSerializer(c.GetRequiredService())); services.AddSingletonAs() .As(); diff --git a/backend/src/Squidex/Squidex.csproj b/backend/src/Squidex/Squidex.csproj index 318ad5390..83f6b6df4 100644 --- a/backend/src/Squidex/Squidex.csproj +++ b/backend/src/Squidex/Squidex.csproj @@ -69,19 +69,19 @@ - - - - - - - - + + + + + + + + - - - - + + + + diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowJsonTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowJsonTests.cs index a10be6f1b..836de411d 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowJsonTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowJsonTests.cs @@ -29,7 +29,8 @@ namespace Squidex.Domain.Apps.Core.Model.Contents [Status.Published] = WorkflowTransition.When("Expression", "Role1", "Role2") }.ToReadonlyDictionary(), "#00ff00", - NoUpdate.When("Expression", "Role1", "Role2")) + NoUpdate.When("Expression", "Role1", "Role2"), + true) }.ToReadonlyDictionary(), ReadonlyList.Create(DomainId.NewGuid()), "MyName"); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppState.json b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppState.json index 5a998bbd4..511d67952 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppState.json +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppState.json @@ -87,10 +87,9 @@ } }, + "noUpdateRules": {}, "noUpdate": false, - "noUpdateRules": { - - }, + "validate": true, "color": "#eb3142" }, "Draft": { @@ -103,6 +102,7 @@ } }, "noUpdate": false, + "validate": false, "color": "#8091a5" }, "Published": { @@ -115,6 +115,7 @@ } }, "noUpdate": false, + "validate": false, "color": "#4bb958" } }, diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/ContentsBulkUpdateCommandMiddlewareTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/ContentsBulkUpdateCommandMiddlewareTests.cs index 61857cb49..7bf6f5438 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/ContentsBulkUpdateCommandMiddlewareTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/ContentsBulkUpdateCommandMiddlewareTests.cs @@ -513,6 +513,28 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject .MustHaveHappened(); } + [Fact] + public async Task Should_throw_exception_if_schema_name_not_defined() + { + SetupContext(PermissionIds.AppContentsDeleteOwn); + + var (id, _, _) = CreateTestData(false); + + var command = BulkCommand(BulkUpdateContentType.Delete, new BulkUpdateJob(), id); + + // Unset schema id, so that no schema id is set for the command. + command.SchemaId = null!; + + var actual = await PublishAsync(command); + + Assert.Single(actual); + Assert.Single(actual, x => x.JobIndex == 0 && x.Id == id && x.Exception is DomainObjectNotFoundException); + + A.CallTo(() => commandBus.PublishAsync( + A.That.Matches(x => x.SchemaId == schemaCustomId), ct)) + .MustNotHaveHappened(); + } + private async Task PublishAsync(ICommand command) { var context = new CommandContext(command, commandBus); diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs index d1eed6b93..073d1dd42 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs +++ b/backend/tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs @@ -46,9 +46,28 @@ namespace TestSuite.ApiTests { var q = new ContentQuery { OrderBy = "data/number/iv asc" }; - var items = await _.Contents.GetAsync(q); + var itemsByQ = await _.Contents.GetAsync(q); + var itemsIds = itemsByQ.Items.Take(3).Select(x => x.Id).ToHashSet(); + var itemsById = await _.Contents.GetAsync(new ContentQuery { Ids = itemsIds }); + + Assert.Equal(3, itemsById.Items.Count); + Assert.Equal(3, itemsById.Total); + + foreach (var item in itemsById.Items) + { + Assert.Equal(_.AppName, item.AppName); + Assert.Equal(_.SchemaName, item.SchemaName); + } + } - var itemsById = await _.Contents.GetAsync(new HashSet(items.Items.Take(3).Select(x => x.Id))); + [Fact] + public async Task Should_query_by_ids_across_schemas() + { + var q = new ContentQuery { OrderBy = "data/number/iv asc" }; + + var itemsByQ = await _.Contents.GetAsync(q); + var itemsIds = itemsByQ.Items.Take(3).Select(x => x.Id).ToHashSet(); + var itemsById = await _.SharedContents.GetAsync(itemsIds); Assert.Equal(3, itemsById.Items.Count); Assert.Equal(3, itemsById.Total); @@ -418,7 +437,7 @@ namespace TestSuite.ApiTests }" }; - var result = await _.Contents.GraphQlAsync(query); + var result = await _.SharedContents.GraphQlAsync(query); var value = result["createMyReadsContent"]["data"]["number"]["iv"].Value(); @@ -489,7 +508,7 @@ namespace TestSuite.ApiTests } }; - var result = await _.Contents.GraphQlAsync(query); + var result = await _.SharedContents.GraphQlAsync(query); var value = result["createMyReadsContent"]["data"]["number"]["iv"].Value(); @@ -537,7 +556,7 @@ namespace TestSuite.ApiTests } }; - var results = await _.Contents.GraphQlAsync(new[] { query1, query2 }); + var results = await _.SharedContents.GraphQlAsync(new[] { query1, query2 }); var items1 = results.ElementAt(0).Data.Items; var items2 = results.ElementAt(1).Data.Items; @@ -568,8 +587,7 @@ namespace TestSuite.ApiTests } }; - var result = await _.Contents.GraphQlAsync(query); - + var result = await _.SharedContents.GraphQlAsync(query); var items = result.Items; Assert.Equal(items.Select(x => x.Data.Number).ToArray(), new[] { 4, 5, 6 }); @@ -597,8 +615,7 @@ namespace TestSuite.ApiTests } }; - var result = await _.Contents.GraphQlGetAsync(query); - + var result = await _.SharedContents.GraphQlGetAsync(query); var items = result.Items; Assert.Equal(items.Select(x => x.Data.Number).ToArray(), new[] { 4, 5, 6 }); @@ -622,8 +639,7 @@ namespace TestSuite.ApiTests }" }; - var result = await _.Contents.GraphQlAsync(query); - + var result = await _.SharedContents.GraphQlAsync(query); var items = result["queryMyReadsContents"]; Assert.Equal(items.Select(x => x["data"]["number"]["iv"].Value()).ToArray(), new[] { 4, 5, 6 }); @@ -651,7 +667,7 @@ namespace TestSuite.ApiTests } }; - await _.Contents.GraphQlAsync(query); + await _.SharedContents.GraphQlAsync(query); } [Fact] diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs index acb3b63f1..7b615a07f 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs +++ b/backend/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs @@ -915,6 +915,88 @@ namespace TestSuite.ApiTests } } + [Fact] + public async Task Should_update_content_with_bulk_and_shared_client() + { + TestEntity content = null; + try + { + var schemaName = $"schema-{Guid.NewGuid()}"; + + // STEP 0: Create dummy schema. + var createSchema = new CreateSchemaDto + { + Name = schemaName, + + // Publish it to avoid validations issues. + IsPublished = true + }; + + await _.Schemas.PostSchemaAsync(_.AppName, createSchema); + + + + // STEP 1: Create a new item. + content = await _.Contents.CreateAsync(new TestEntityData + { + String = "test" + }, ContentCreateOptions.AsPublish); + + + // STEP 2: Patch an item. + await _.SharedContents.BulkUpdateAsync(new BulkUpdate + { + Jobs = new List + { + new BulkUpdateJob + { + Id = content.Id, + Data = new + { + number = new + { + iv = 1 + } + }, + Schema = _.SchemaName + } + } + }); + + + // STEP 3: Update the item and ensure that the data has changed. + await _.SharedContents.BulkUpdateAsync(new BulkUpdate + { + Jobs = new List + { + new BulkUpdateJob + { + Id = content.Id, + Data = new + { + number = new + { + iv = 2 + } + }, + Schema = _.SchemaName + } + } + }); + + var updated = await _.Contents.GetAsync(content.Id); + + Assert.Equal(2, updated.Data.Number); + } + finally + { + if (content != null) + { + await _.Contents.DeleteAsync(content.Id); + } + } + } + [Fact] public async Task Should_create_draft_version() { diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/GraphQLTests.cs b/backend/tools/TestSuite/TestSuite.ApiTests/GraphQLTests.cs index f299ad454..ad80217e2 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/GraphQLTests.cs +++ b/backend/tools/TestSuite/TestSuite.ApiTests/GraphQLTests.cs @@ -92,7 +92,7 @@ namespace TestSuite.ApiTests }".Replace("", content_0.Id, StringComparison.Ordinal) }; - var result1 = await _.Contents.GraphQlAsync(query); + var result1 = await _.SharedContents.GraphQlAsync(query); Assert.Equal(1, result1["findMyWritesContent"]["flatData"]["json"]["value"].Value()); Assert.Equal(2, result1["findMyWritesContent"]["flatData"]["json"]["obj"]["value"].Value()); @@ -119,8 +119,6 @@ namespace TestSuite.ApiTests // Do nothing } - var countriesClient = _.ClientManager.CreateContentsClient("countries"); - var query = new { query = @" @@ -143,7 +141,7 @@ namespace TestSuite.ApiTests }" }; - var result1 = await countriesClient.GraphQlAsync(query); + var result1 = await _.SharedContents.GraphQlAsync(query); var typed = result1["queryCountriesContents"].ToObject>(); diff --git a/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj b/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj index 3b5c30ccf..9bd31c758 100644 --- a/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj +++ b/backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj @@ -22,7 +22,7 @@ - + diff --git a/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ClientFixture.cs b/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ClientFixture.cs index 8029ebeab..d8e40e7c8 100644 --- a/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ClientFixture.cs +++ b/backend/tools/TestSuite/TestSuite.Shared/Fixtures/ClientFixture.cs @@ -100,6 +100,11 @@ namespace TestSuite.Fixtures get => ClientManager.CreateUserManagementClient(); } + public IContentsSharedClient SharedContents + { + get => ClientManager.CreateSharedDynamicContentsClient(); + } + static ClientFixture() { VerifierSettings.IgnoreMember("AppName"); diff --git a/backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj b/backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj index fed01e6d1..ba0b66893 100644 --- a/backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj +++ b/backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj @@ -16,8 +16,8 @@ - - + + diff --git a/frontend/src/app/features/settings/pages/workflows/workflow-step.component.html b/frontend/src/app/features/settings/pages/workflows/workflow-step.component.html index e40ae9f60..0bf8fa525 100644 --- a/frontend/src/app/features/settings/pages/workflows/workflow-step.component.html +++ b/frontend/src/app/features/settings/pages/workflows/workflow-step.component.html @@ -1,7 +1,7 @@
-