Browse Source

Timeouts (#930)

* Fix timeouts.

* Correctly save the Validate flag of workflows.

* New bulk endpoint.

* Update packages.

* Fix tests

* Fix SDK version.

* Add some docs.
pull/932/head
Sebastian Stehle 4 years ago
committed by GitHub
parent
commit
eaf2629597
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      backend/src/Squidex.Domain.Apps.Core.Model/Contents/Json/WorkflowStepSurrogate.cs
  2. 10
      backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs
  3. 70
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/JintScriptEngine.cs
  4. 2
      backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
  5. 9
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetsBulkUpdateCommandMiddleware.cs
  6. 80
      backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs
  7. 2
      backend/src/Squidex.Domain.Apps.Entities/Comments/Commands/_CommentsCommand.cs
  8. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Commands/BulkUpdateContents.cs
  9. 82
      backend/src/Squidex.Domain.Apps.Entities/Contents/DomainObject/ContentsBulkUpdateCommandMiddleware.cs
  10. 30
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/ContentQueryService.cs
  11. 10
      backend/src/Squidex.Infrastructure/Diagnostics/Diagnoser.cs
  12. 32
      backend/src/Squidex.Infrastructure/Email/SmtpEmailSender.cs
  13. 12
      backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  14. 2
      backend/src/Squidex/Areas/Api/Config/OpenApi/XmlTagProcessor.cs
  15. 4
      backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs
  16. 88
      backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsController.cs
  17. 148
      backend/src/Squidex/Areas/Api/Controllers/Contents/ContentsSharedController.cs
  18. 8
      backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasOpenApiGenerator.cs
  19. 24
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateContentsDto.cs
  20. 21
      backend/src/Squidex/Areas/Api/Controllers/Contents/Models/ImportContentsDto.cs
  21. 2
      backend/src/Squidex/Config/Messaging/MessagingServices.cs
  22. 24
      backend/src/Squidex/Squidex.csproj
  23. 3
      backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Contents/WorkflowJsonTests.cs
  24. 7
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/DomainObject/AppState.json
  25. 22
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/DomainObject/ContentsBulkUpdateCommandMiddlewareTests.cs
  26. 40
      backend/tools/TestSuite/TestSuite.ApiTests/ContentQueryTests.cs
  27. 82
      backend/tools/TestSuite/TestSuite.ApiTests/ContentUpdateTests.cs
  28. 6
      backend/tools/TestSuite/TestSuite.ApiTests/GraphQLTests.cs
  29. 2
      backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj
  30. 5
      backend/tools/TestSuite/TestSuite.Shared/Fixtures/ClientFixture.cs
  31. 4
      backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj
  32. 2
      frontend/src/app/features/settings/pages/workflows/workflow-step.component.html

8
backend/src/Squidex.Domain.Apps.Core.Model/Contents/Json/WorkflowStepSurrogate.cs

@ -16,11 +16,13 @@ namespace Squidex.Domain.Apps.Core.Contents.Json
{
public Dictionary<Status, WorkflowTransitionSurrogate> 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);
}
}
}

10
backend/src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs

@ -364,12 +364,12 @@ namespace Squidex.Domain.Apps.Core.HandleRules
var deserialized = serializer.Deserialize<object>(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)

70
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<JsonValue?>(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<JsValue?>(value =>
{
context.Complete(JsonMapper.Map(value));
}));
var context =
CreateEngine<JsonValue?>(options, combined.Token)
.Extend(vars, options)
.Extend(extensions)
.ExtendAsync(extensions);
context.Engine.SetValue("complete", new Action<JsValue?>(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<ContentData>(options, combined.Token)
.Extend(vars, options)
.Extend(extensions)
.ExtendAsync(extensions);
context.Engine.SetValue("complete", new Action<JsValue?>(_ =>
{
context.Complete(vars.Data!);
}));
context.Engine.SetValue("complete", new Action<JsValue?>(_ =>
{
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!;
}
}

2
backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj

@ -28,7 +28,7 @@
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
<PackageReference Include="NJsonSchema" Version="10.7.2" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="4.12.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="4.13.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="6.0.0" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />

9
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<BulkUpdateResultItem> Results,
CancellationToken CancellationToken);
CancellationToken Aborted);
public AssetsBulkUpdateCommandMiddleware(IContextProvider contextProvider, ILogger<AssetsBulkUpdateCommandMiddleware> 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<BulkTask, BulkTaskCommand?>(task =>
{
try
@ -69,6 +70,7 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject
}
}, executionOptions);
// Execute the commands in batches
var executeCommandBlock = new ActionBlock<BulkTaskCommand?>(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)
{

80
backend/src/Squidex.Domain.Apps.Entities/Assets/Queries/AssetQueryService.cs

@ -218,96 +218,96 @@ namespace Squidex.Domain.Apps.Entities.Assets.Queries
private async Task<IResultList<IAssetFolderEntity>> 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<IResultList<IAssetFolderEntity>> 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<IResultList<IAssetEntity>> 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<IAssetFolderEntity?> 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<IAssetEntity?> 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<IAssetEntity?> 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<IAssetEntity?> 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<IAssetEntity?> 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);
}
}
}

2
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<DomainId> NoApp = NamedId.Of(DomainId.NewGuid(), "none");
public static readonly NamedId<DomainId> NoApp = NamedId.Of(DomainId.Empty, "none");
public DomainId CommentsId { get; set; }

2
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<DomainId> NoSchema = NamedId.Of(DomainId.Empty, "none");
public NamedId<DomainId> AppId { get; set; }
public NamedId<DomainId> SchemaId { get; set; }

82
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<DomainId> SchemaId,
int JobIndex,
BulkUpdateJob CommandJob,
BulkUpdateContents Command,
ConcurrentBag<BulkUpdateResultItem> 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<BulkTask, BulkTaskCommand>(async task =>
{
try
@ -77,6 +78,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.DomainObject
}
}, executionOptions);
// Execute the commands in batches.
var executeCommandBlock = new ActionBlock<BulkTaskCommand>(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<BulkUpdateResultItem>();
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<IEnumerable<BulkTaskCommand>> CreateCommandsAsync(BulkTask task)
{
// The task parallel pipeline does not allow async-enumerable.
var commands = new List<BulkTaskCommand>();
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<ContentCommand> 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<T>(BulkTask task, T command, string permissionId) where T : ContentCommand
private void EnrichAndCheckPermission<T>(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<DomainId[]> FindIdAsync(BulkTask task)
private async Task<DomainId[]> 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)

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

@ -218,36 +218,36 @@ namespace Squidex.Domain.Apps.Entities.Contents.Queries
private async Task<IResultList<IContentEntity>> 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<IResultList<IContentEntity>> QueryCoreAsync(Context context, Q q, List<ISchemaEntity> 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<IContentEntity?> 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);
}
}

10
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

32
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

12
backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -24,12 +24,12 @@
<PackageReference Include="NodaTime" Version="3.1.2" />
<PackageReference Include="OpenTelemetry.Api" Version="1.3.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets" Version="4.12.0" />
<PackageReference Include="Squidex.Caching" Version="4.12.0" />
<PackageReference Include="Squidex.Hosting.Abstractions" Version="4.12.0" />
<PackageReference Include="Squidex.Log" Version="4.12.0" />
<PackageReference Include="Squidex.Messaging" Version="4.12.0" />
<PackageReference Include="Squidex.Text" Version="4.12.0" />
<PackageReference Include="Squidex.Assets" Version="4.13.0" />
<PackageReference Include="Squidex.Caching" Version="4.13.0" />
<PackageReference Include="Squidex.Hosting.Abstractions" Version="4.13.0" />
<PackageReference Include="Squidex.Log" Version="4.13.0" />
<PackageReference Include="Squidex.Messaging" Version="4.13.0" />
<PackageReference Include="Squidex.Text" Version="4.13.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="6.0.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />

2
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))
{

4
backend/src/Squidex/Areas/Api/Controllers/Assets/Models/AssetDto.cs

@ -145,7 +145,7 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models
/// <summary>
/// The width of the image in pixels if the asset is an image.
/// </summary>
[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
/// <summary>
/// The height of the image in pixels if the asset is an image.
/// </summary>
[Obsolete("Use 'metdata' field now.")]
[Obsolete("Use 'metadata' field now.")]
public int? PixelHeight
{
get => Metadata.GetPixelHeight();

88
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;
}
/// <summary>
/// GraphQL endpoint.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <returns>
/// 200 => Contents returned or mutated.
/// 404 => App not found.
/// </returns>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
[HttpGet]
[HttpPost]
[Route("content/{app}/graphql/")]
[Route("content/{app}/graphql/batch")]
[ApiPermissionOrAnonymous]
[ApiCosts(2)]
public Task GetGraphQL(string app)
{
return graphQLRunner.InvokeAsync(HttpContext);
}
/// <summary>
/// Queries contents.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="query">The required query object.</param>
/// <returns>
/// 200 => Contents returned.
/// 404 => App not found.
/// </returns>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
[HttpGet]
[Route("content/{app}/")]
[ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous]
[ApiCosts(1)]
public async Task<IActionResult> 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);
}
/// <summary>
/// Queries contents.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="query">The required query object.</param>
/// <returns>
/// 200 => Contents returned.
/// 404 => App not found.
/// </returns>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
[HttpPost]
[Route("content/{app}/")]
[ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous]
[ApiCosts(1)]
public async Task<IActionResult> 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);
}
/// <summary>
@ -415,7 +331,7 @@ namespace Squidex.Areas.Api.Controllers.Contents
[ApiCosts(5)]
public async Task<IActionResult> 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);

148
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;
}
/// <summary>
/// GraphQL endpoint.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <returns>
/// 200 => Contents returned or mutated.
/// 404 => App not found.
/// </returns>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
[HttpGet]
[HttpPost]
[Route("content/{app}/graphql/")]
[Route("content/{app}/graphql/batch")]
[ApiPermissionOrAnonymous]
[ApiCosts(2)]
public Task GetGraphQL(string app)
{
return graphQLRunner.InvokeAsync(HttpContext);
}
/// <summary>
/// Queries contents.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="query">The required query object.</param>
/// <returns>
/// 200 => Contents returned.
/// 404 => App not found.
/// </returns>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
[HttpGet]
[Route("content/{app}/")]
[ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous]
[ApiCosts(1)]
public async Task<IActionResult> 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);
}
/// <summary>
/// Queries contents.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="query">The required query object.</param>
/// <returns>
/// 200 => Contents returned.
/// 404 => App not found.
/// </returns>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
[HttpPost]
[Route("content/{app}/")]
[ProducesResponseType(typeof(ContentsDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous]
[ApiCosts(1)]
public async Task<IActionResult> 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);
}
/// <summary>
/// Bulk update content items.
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="schema">The name of the schema.</param>
/// <param name="request">The bulk update request.</param>
/// <returns>
/// 201 => Contents created, update or delete.
/// 400 => Contents request not valid.
/// 404 => Contents references, schema or app not found.
/// </returns>
/// <remarks>
/// You can read the generated documentation for your app at /api/content/{appName}/docs.
/// </remarks>
[HttpPost]
[Route("content/{app}/bulk")]
[ProducesResponseType(typeof(BulkResultDto[]), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(PermissionIds.AppContentsReadOwn)]
[ApiCosts(5)]
public async Task<IActionResult> 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<BulkUpdateResult>();
var response = result.Select(x => BulkResultDto.FromDomain(x, HttpContext)).ToArray();
return Ok(response);
}
}
}

8
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)

24
backend/src/Squidex/Areas/Api/Controllers/Contents/Models/BulkUpdateContentsDto.cs

@ -51,25 +51,29 @@ namespace Squidex.Areas.Api.Controllers.Contents.Models
/// </summary>
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;
}
}

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

2
backend/src/Squidex/Config/Messaging/MessagingServices.cs

@ -63,7 +63,7 @@ namespace Squidex.Config.Messaging
}
services.AddSingleton<IMessagingSerializer>(c =>
new SystemTextJsonTransportSerializer(c.GetRequiredService<JsonSerializerOptions>()));
new SystemTextJsonMessagingSerializer(c.GetRequiredService<JsonSerializerOptions>()));
services.AddSingletonAs<SubscriptionPublisher>()
.As<IEventConsumer>();

24
backend/src/Squidex/Squidex.csproj

@ -69,19 +69,19 @@
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.0.0-rc7" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="ReportGenerator" Version="5.1.9" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets.Azure" Version="4.12.0" />
<PackageReference Include="Squidex.Assets.GoogleCloud" Version="4.12.0" />
<PackageReference Include="Squidex.Assets.FTP" Version="4.12.0" />
<PackageReference Include="Squidex.Assets.ImageMagick" Version="4.12.0" />
<PackageReference Include="Squidex.Assets.ImageSharp" Version="4.12.0" />
<PackageReference Include="Squidex.Assets.Mongo" Version="4.12.0" />
<PackageReference Include="Squidex.Assets.S3" Version="4.12.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="4.12.0" />
<PackageReference Include="Squidex.Assets.Azure" Version="4.13.0" />
<PackageReference Include="Squidex.Assets.GoogleCloud" Version="4.13.0" />
<PackageReference Include="Squidex.Assets.FTP" Version="4.13.0" />
<PackageReference Include="Squidex.Assets.ImageMagick" Version="4.13.0" />
<PackageReference Include="Squidex.Assets.ImageSharp" Version="4.13.0" />
<PackageReference Include="Squidex.Assets.Mongo" Version="4.13.0" />
<PackageReference Include="Squidex.Assets.S3" Version="4.13.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="4.13.0" />
<PackageReference Include="Squidex.Caching.Orleans" Version="1.9.0" />
<PackageReference Include="Squidex.ClientLibrary" Version="9.2.0" />
<PackageReference Include="Squidex.Hosting" Version="4.12.0" />
<PackageReference Include="Squidex.Messaging.All" Version="4.12.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="4.12.0" />
<PackageReference Include="Squidex.ClientLibrary" Version="10.2.0" />
<PackageReference Include="Squidex.Hosting" Version="4.13.0" />
<PackageReference Include="Squidex.Messaging.All" Version="4.13.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="4.13.0" />
<PackageReference Include="Squidex.Namotion.Reflection" Version="2.0.10" />
<PackageReference Include="Squidex.OpenIddict.MongoDb" Version="4.0.1-dev" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />

3
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");

7
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"
}
},

22
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<DeleteContent>.That.Matches(x => x.SchemaId == schemaCustomId), ct))
.MustNotHaveHappened();
}
private async Task<BulkUpdateResult> PublishAsync(ICommand command)
{
var context = new CommandContext(command, commandBus);

40
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<string>(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<JObject>(query);
var result = await _.SharedContents.GraphQlAsync<JObject>(query);
var value = result["createMyReadsContent"]["data"]["number"]["iv"].Value<int>();
@ -489,7 +508,7 @@ namespace TestSuite.ApiTests
}
};
var result = await _.Contents.GraphQlAsync<JObject>(query);
var result = await _.SharedContents.GraphQlAsync<JObject>(query);
var value = result["createMyReadsContent"]["data"]["number"]["iv"].Value<int>();
@ -537,7 +556,7 @@ namespace TestSuite.ApiTests
}
};
var results = await _.Contents.GraphQlAsync<QueryResult>(new[] { query1, query2 });
var results = await _.SharedContents.GraphQlAsync<QueryResult>(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<QueryResult>(query);
var result = await _.SharedContents.GraphQlAsync<QueryResult>(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<QueryResult>(query);
var result = await _.SharedContents.GraphQlGetAsync<QueryResult>(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<JObject>(query);
var result = await _.SharedContents.GraphQlAsync<JObject>(query);
var items = result["queryMyReadsContents"];
Assert.Equal(items.Select(x => x["data"]["number"]["iv"].Value<int>()).ToArray(), new[] { 4, 5, 6 });
@ -651,7 +667,7 @@ namespace TestSuite.ApiTests
}
};
await _.Contents.GraphQlAsync<QueryResult>(query);
await _.SharedContents.GraphQlAsync<QueryResult>(query);
}
[Fact]

82
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<BulkUpdateJob>
{
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<BulkUpdateJob>
{
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()
{

6
backend/tools/TestSuite/TestSuite.ApiTests/GraphQLTests.cs

@ -92,7 +92,7 @@ namespace TestSuite.ApiTests
}".Replace("<ID>", content_0.Id, StringComparison.Ordinal)
};
var result1 = await _.Contents.GraphQlAsync<JToken>(query);
var result1 = await _.SharedContents.GraphQlAsync<JToken>(query);
Assert.Equal(1, result1["findMyWritesContent"]["flatData"]["json"]["value"].Value<int>());
Assert.Equal(2, result1["findMyWritesContent"]["flatData"]["json"]["obj"]["value"].Value<int>());
@ -119,8 +119,6 @@ namespace TestSuite.ApiTests
// Do nothing
}
var countriesClient = _.ClientManager.CreateContentsClient<DynamicEntity, object>("countries");
var query = new
{
query = @"
@ -143,7 +141,7 @@ namespace TestSuite.ApiTests
}"
};
var result1 = await countriesClient.GraphQlAsync<JToken>(query);
var result1 = await _.SharedContents.GraphQlAsync<JToken>(query);
var typed = result1["queryCountriesContents"].ToObject<List<Country>>();

2
backend/tools/TestSuite/TestSuite.ApiTests/TestSuite.ApiTests.csproj

@ -22,7 +22,7 @@
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.2.0" />
<PackageReference Include="NSwag.Core" Version="13.16.1" />
<PackageReference Include="PuppeteerSharp" Version="7.1.0" />
<PackageReference Include="Squidex.Assets" Version="4.11.0" />
<PackageReference Include="Squidex.Assets" Version="4.13.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="Verify.Xunit" Version="17.5.0" />
<PackageReference Include="xunit" Version="2.4.1" />

5
backend/tools/TestSuite/TestSuite.Shared/Fixtures/ClientFixture.cs

@ -100,6 +100,11 @@ namespace TestSuite.Fixtures
get => ClientManager.CreateUserManagementClient();
}
public IContentsSharedClient<DynamicContent, DynamicData> SharedContents
{
get => ClientManager.CreateSharedDynamicContentsClient();
}
static ClientFixture()
{
VerifierSettings.IgnoreMember("AppName");

4
backend/tools/TestSuite/TestSuite.Shared/TestSuite.Shared.csproj

@ -16,8 +16,8 @@
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="6.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.ClientLibrary" Version="10.0.1" />
<PackageReference Include="Squidex.ClientLibrary.ServiceExtensions" Version="10.0.1" />
<PackageReference Include="Squidex.ClientLibrary" Version="11.0.0-beta1" />
<PackageReference Include="Squidex.ClientLibrary.ServiceExtensions" Version="11.0.0-beta1" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="Verify" Version="17.5.0" />
<PackageReference Include="xunit" Version="2.4.1" />

2
frontend/src/app/features/settings/pages/workflows/workflow-step.component.html

@ -1,7 +1,7 @@
<div class="step">
<div class="row g-0 step-header">
<div class="col-auto">
<button class="btn btn-initial me-1" (click)="makeInitial.emit()"
<button class="btn btn-text-secondary btn-initial me-1" (click)="makeInitial.emit()"
[class.enabled]="step.name !== workflow.initial && !step.isLocked"
[class.active]="step.name === workflow.initial"
[disabled]="step.name === workflow.initial || step.isLocked || disabled">

Loading…
Cancel
Save