Browse Source

Merge pull request #342 from Squidex/feature_usage_triggers

Feature usage triggers
pull/344/head
Sebastian Stehle 8 years ago
committed by GitHub
parent
commit
4b675f0985
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 15
      extensions/Squidex.Extensions/Actions/RuleElementRegistry.cs
  2. 2
      src/Squidex.Domain.Apps.Core.Model/Rules/IRuleTriggerVisitor.cs
  3. 2
      src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/AssetChangedTriggerV2.cs
  4. 2
      src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs
  5. 26
      src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/UsageTrigger.cs
  6. 4
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/ClientPool.cs
  7. 6
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedEntityEvent.cs
  8. 11
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedEvent.cs
  9. 21
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedUsageExceededEvent.cs
  10. 21
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedUserEvent.cs
  11. 78
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventEnricher.cs
  12. 2
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/IEventEnricher.cs
  13. 8
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleTriggerHandler.cs
  14. 21
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs
  15. 42
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs
  16. 39
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleTriggerHandler.cs
  17. 32
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/AssetChangedTriggerHandler.cs
  18. 80
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/ContentChangedTriggerHandler.cs
  19. 2
      src/Squidex.Domain.Apps.Entities/Apps/InviteUserCommandMiddleware.cs
  20. 73
      src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs
  21. 2
      src/Squidex.Domain.Apps.Entities/Backup/BackupHandlerWithStore.cs
  22. 2
      src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs
  23. 140
      src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs
  24. 192
      src/Squidex.Domain.Apps.Entities/Rules/EventEnricher.cs
  25. 48
      src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleTriggerValidator.cs
  26. 2
      src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs
  27. 24
      src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/IUsageTrackerGrain.cs
  28. 61
      src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerCommandMiddleware.cs
  29. 155
      src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerGrain.cs
  30. 38
      src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTriggerHandler.cs
  31. 22
      src/Squidex.Domain.Apps.Events/AppUsageExceeded.cs
  32. 2
      src/Squidex.Domain.Apps.Events/SquidexHeaderExtensions.cs
  33. 5
      src/Squidex.Infrastructure/Commands/CommandContext.cs
  34. 7
      src/Squidex.Infrastructure/Commands/DomainObjectGrain.cs
  35. 2
      src/Squidex.Infrastructure/Commands/LogSnapshotDomainObjectGrain.cs
  36. 4
      src/Squidex.Infrastructure/EventSourcing/Envelope.cs
  37. 12
      src/Squidex.Infrastructure/EventSourcing/EnvelopeExtensions.cs
  38. 9
      src/Squidex.Infrastructure/EventSourcing/Envelope{T}.cs
  39. 43
      src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs
  40. 19
      src/Squidex.Infrastructure/InstantExtensions.cs
  41. 5
      src/Squidex.Infrastructure/Log/IArrayWriter.cs
  42. 5
      src/Squidex.Infrastructure/Log/IObjectWriter.cs
  43. 3
      src/Squidex.Infrastructure/Log/Internal/FileLogProcessor.cs
  44. 29
      src/Squidex.Infrastructure/Log/JsonLogWriter.cs
  45. 17
      src/Squidex.Infrastructure/Log/TimestampLogAppender.cs
  46. 20
      src/Squidex.Infrastructure/None.cs
  47. 2
      src/Squidex.Infrastructure/Orleans/GrainOfGuid{T}.cs
  48. 2
      src/Squidex.Infrastructure/Orleans/GrainOfString{T}.cs
  49. 21
      src/Squidex.Infrastructure/RetryWindow.cs
  50. 2
      src/Squidex.Infrastructure/States/IPersistence.cs
  51. 11
      src/Squidex.Infrastructure/States/IStore.cs
  52. 7
      src/Squidex.Infrastructure/States/Persistence.cs
  53. 14
      src/Squidex.Infrastructure/States/Persistence{TSnapshot,TKey}.cs
  54. 15
      src/Squidex.Infrastructure/States/Store.cs
  55. 66
      src/Squidex.Infrastructure/States/StoreExtensions.cs
  56. 12
      src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs
  57. 18
      src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs
  58. 2
      src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs
  59. 5
      src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs
  60. 2
      src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs
  61. 5
      src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleTriggerDtoFactory.cs
  62. 33
      src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/UsageRuleTriggerDto.cs
  63. 4
      src/Squidex/Config/Domain/EntitiesServices.cs
  64. 7
      src/Squidex/Config/Domain/RuleServices.cs
  65. 2
      src/Squidex/Config/Orleans/SiloWrapper.cs
  66. 2
      src/Squidex/Pipeline/CommandMiddlewares/ETagCommandMiddleware.cs
  67. 1
      src/Squidex/app/features/rules/declarations.ts
  68. 2
      src/Squidex/app/features/rules/module.ts
  69. 7
      src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html
  70. 25
      src/Squidex/app/features/rules/pages/rules/triggers/usage-trigger.component.html
  71. 6
      src/Squidex/app/features/rules/pages/rules/triggers/usage-trigger.component.scss
  72. 39
      src/Squidex/app/features/rules/pages/rules/triggers/usage-trigger.component.ts
  73. 4
      src/Squidex/app/features/schemas/pages/schema/schema-preview-urls-form.component.html
  74. 4
      src/Squidex/app/framework/angular/forms/control-errors.component.ts
  75. 16
      src/Squidex/app/framework/angular/forms/validators.spec.ts
  76. 14
      src/Squidex/app/framework/angular/forms/validators.ts
  77. 2
      tests/Squidex.Domain.Apps.Core.Tests/Operations/EnrichContent/ContentEnrichmentTests.cs
  78. 4
      tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs
  79. 142
      tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs
  80. 4
      tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/DateTimeFieldTests.cs
  81. 2
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs
  82. 2
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByNameIndexGrainTests.cs
  83. 5
      tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByUserIndexGrainTests.cs
  84. 59
      tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs
  85. 18
      tests/Squidex.Domain.Apps.Entities.Tests/Comments/Guards/GuardCommentsTests.cs
  86. 73
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs
  87. 6
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs
  88. 40
      tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/Triggers/ContentChangedTriggerTests.cs
  89. 72
      tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/Triggers/UsageTriggerValidationTests.cs
  90. 5
      tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesByAppIndexGrainTests.cs
  91. 17
      tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerTests.cs
  92. 20
      tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs
  93. 68
      tests/Squidex.Domain.Apps.Entities.Tests/Rules/UsageTracking/UsageTriggerHandlerTests.cs
  94. 3
      tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Guards/FieldProperties/DateTimeFieldPropertiesTests.cs
  95. 5
      tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasByAppIndexGrainTests.cs
  96. 5
      tests/Squidex.Domain.Apps.Entities.Tests/Tags/TagGrainTests.cs
  97. 5
      tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs
  98. 19
      tests/Squidex.Infrastructure.Tests/Commands/CommandContextTests.cs
  99. 2
      tests/Squidex.Infrastructure.Tests/Commands/DomainObjectGrainTests.cs
  100. 22
      tests/Squidex.Infrastructure.Tests/Commands/EnrichWithTimestampCommandMiddlewareTests.cs

15
extensions/Squidex.Extensions/Actions/RuleElementRegistry.cs

@ -10,6 +10,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Infrastructure;
namespace Squidex.Extensions.Actions
@ -21,20 +22,26 @@ namespace Squidex.Extensions.Actions
private static readonly Dictionary<string, RuleElement> ActionTypes = new Dictionary<string, RuleElement>();
private static readonly Dictionary<string, RuleElement> TriggerTypes = new Dictionary<string, RuleElement>
{
["ContentChanged"] = new RuleElement
[ContentChangedTriggerV2.Name] = new RuleElement
{
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 28 28'><path d='M21.875 28H6.125A6.087 6.087 0 0 1 0 21.875V6.125A6.087 6.087 0 0 1 6.125 0h15.75A6.087 6.087 0 0 1 28 6.125v15.75A6.088 6.088 0 0 1 21.875 28zM6.125 1.75A4.333 4.333 0 0 0 1.75 6.125v15.75a4.333 4.333 0 0 0 4.375 4.375h15.75a4.333 4.333 0 0 0 4.375-4.375V6.125a4.333 4.333 0 0 0-4.375-4.375H6.125z'/><path d='M13.125 12.25H7.35c-1.575 0-2.888-1.313-2.888-2.888V7.349c0-1.575 1.313-2.888 2.888-2.888h5.775c1.575 0 2.887 1.313 2.887 2.888v2.013c0 1.575-1.312 2.888-2.887 2.888zM7.35 6.212c-.613 0-1.138.525-1.138 1.138v2.012A1.16 1.16 0 0 0 7.35 10.5h5.775a1.16 1.16 0 0 0 1.138-1.138V7.349a1.16 1.16 0 0 0-1.138-1.138H7.35zM22.662 16.713H5.337c-.525 0-.875-.35-.875-.875s.35-.875.875-.875h17.237c.525 0 .875.35.875.875s-.35.875-.787.875zM15.138 21.262h-9.8c-.525 0-.875-.35-.875-.875s.35-.875.875-.875h9.713c.525 0 .875.35.875.875s-.35.875-.787.875z'/></svg>",
IconColor = "#3389ff",
Display = "Content changed",
Description = "For content changes like created, updated, published, unpublished..."
},
["AssetChanged"] = new RuleElement
[AssetChangedTriggerV2.Name] = new RuleElement
{
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 28 28'><path d='M21.875 28H6.125A6.087 6.087 0 0 1 0 21.875V6.125A6.087 6.087 0 0 1 6.125 0h15.75A6.087 6.087 0 0 1 28 6.125v15.75A6.088 6.088 0 0 1 21.875 28zM6.125 1.75A4.333 4.333 0 0 0 1.75 6.125v15.75a4.333 4.333 0 0 0 4.375 4.375h15.75a4.333 4.333 0 0 0 4.375-4.375V6.125a4.333 4.333 0 0 0-4.375-4.375H6.125z'/><path d='M21.088 23.537H9.1c-.35 0-.612-.175-.787-.525s-.088-.7.088-.962l8.225-9.713c.175-.175.438-.35.7-.35s.525.175.7.35l5.25 7.525c.088.087.088.175.088.262.438 1.225.087 2.012-.175 2.45-.613.875-1.925.963-2.1.963zm-10.063-1.75h10.15c.175 0 .612-.088.7-.262.088-.088.088-.35 0-.7l-4.55-6.475-6.3 7.438zM9.1 13.737c-2.1 0-3.85-1.75-3.85-3.85S7 6.037 9.1 6.037s3.85 1.75 3.85 3.85-1.663 3.85-3.85 3.85zm0-5.949c-1.138 0-2.1.875-2.1 2.1s.962 2.1 2.1 2.1 2.1-.962 2.1-2.1-.875-2.1-2.1-2.1z'/></svg>",
IconColor = "#3389ff",
Display = "Asset changed",
Description = "For asset changes like uploaded, updated, renamed, deleted..."
Description = "For asset changes like uploaded, updated (reuploaded), renamed, deleted..."
},
[UsageTrigger.Name] = new RuleElement
{
IconImage = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><path d='M21.2 11.4c-.2 0-.4-.1-.6-.2-.5-.3-.6-.9-.3-1.4L22 7.2c.3-.5.9-.6 1.4-.3.6.4.7 1.1.4 1.5L22.1 11c-.2.3-.5.4-.9.4zM16 20.9h-.2c-1-.1-2-.6-2.5-1.5l-6-8.7c-.3-.3-.3-.8 0-1.2.3-.3.8-.4 1.2-.2l9.2 5.4c.9.5 1.5 1.4 1.6 2.4.1 1-.2 2-.9 2.8-.6.7-1.5 1-2.4 1zm-4.6-7.5l3.4 5c.2.3.6.6 1 .6s.8-.1 1.1-.4c.3-.3.4-.7.3-1.1-.1-.4-.3-.7-.6-1zM25.9 32H6.1C2.8 32 0 29.2 0 25.9v-10C0 7.1 7.1 0 15.8 0 24.8 0 32 7.2 32 16.2v9.7c0 3.3-2.8 6.1-6.1 6.1zM15.8 2C8.2 2 2 8.2 2 15.8v10C2 28.1 3.9 30 6.1 30h19.7c2.3 0 4.1-1.9 4.1-4.1v-9.7C30 8.4 23.6 2 15.8 2z'/></svg>",
IconColor = "#3389ff",
Display = "Usage exceeded",
Description = "When monthly API calls exceed a specified limit for one time a month..."
}
};

2
src/Squidex.Domain.Apps.Core.Model/Rules/IRuleTriggerVisitor.cs

@ -14,5 +14,7 @@ namespace Squidex.Domain.Apps.Core.Rules
T Visit(AssetChangedTriggerV2 trigger);
T Visit(ContentChangedTriggerV2 trigger);
T Visit(UsageTrigger trigger);
}
}

2
src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/AssetChangedTriggerV2.cs

@ -12,6 +12,8 @@ namespace Squidex.Domain.Apps.Core.Rules.Triggers
[TypeName(nameof(AssetChangedTriggerV2))]
public sealed class AssetChangedTriggerV2 : RuleTrigger
{
public const string Name = "AssetChanged";
public string Condition { get; set; }
public override T Accept<T>(IRuleTriggerVisitor<T> visitor)

2
src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerV2.cs

@ -13,6 +13,8 @@ namespace Squidex.Domain.Apps.Core.Rules.Triggers
[TypeName(nameof(ContentChangedTriggerV2))]
public sealed class ContentChangedTriggerV2 : RuleTrigger
{
public const string Name = "ContentChanged";
public ReadOnlyCollection<ContentChangedTriggerSchemaV2> Schemas { get; set; }
public bool HandleAll { get; set; }

26
src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/UsageTrigger.cs

@ -0,0 +1,26 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.Rules.Triggers
{
[TypeName(nameof(UsageTrigger))]
public sealed class UsageTrigger : RuleTrigger
{
public const string Name = "Usage";
public int Limit { get; set; }
public int? NumDays { get; set; }
public override T Accept<T>(IRuleTriggerVisitor<T> visitor)
{
return visitor.Visit(this);
}
}
}

4
src/Squidex.Domain.Apps.Core.Operations/HandleRules/ClientPool.cs

@ -16,7 +16,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules
{
internal sealed class ClientPool<TKey, TClient>
{
private static readonly TimeSpan TTL = TimeSpan.FromMinutes(30);
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(30);
private readonly MemoryCache memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
private readonly Func<TKey, Task<TClient>> factory;
@ -41,7 +41,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules
{
client = await factory(key);
memoryCache.Set(key, client, TTL);
memoryCache.Set(key, client, CacheDuration);
}
return client;

6
src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedEntityEvent.cs

@ -11,7 +11,7 @@ using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents
{
public abstract class EnrichedEntityEvent : EnrichedEvent
public abstract class EnrichedEntityEvent : EnrichedUserEvent
{
public Guid Id { get; set; }
@ -23,9 +23,9 @@ namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents
public RefToken LastModifiedBy { get; set; }
public override void CalculatePartition()
public override long Partition
{
Partition = Id.GetHashCode();
get { return Id.GetHashCode(); }
}
}
}

11
src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedEvent.cs

@ -6,10 +6,8 @@
// ==========================================================================
using System;
using System.Runtime.Serialization;
using NodaTime;
using Squidex.Infrastructure;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents
{
@ -17,19 +15,12 @@ namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents
{
public NamedId<Guid> AppId { get; set; }
public RefToken Actor { get; set; }
public Instant Timestamp { get; set; }
public string Name { get; set; }
public long Version { get; set; }
public long Partition { get; set; }
[IgnoreDataMember]
public IUser User { get; set; }
public abstract void CalculatePartition();
public abstract long Partition { get; }
}
}

21
src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedUsageExceededEvent.cs

@ -0,0 +1,21 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents
{
public sealed class EnrichedUsageExceededEvent : EnrichedEvent
{
public long CallsCurrent { get; set; }
public long CallsLimit { get; set; }
public override long Partition
{
get { return AppId.GetHashCode(); }
}
}
}

21
src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedUserEvent.cs

@ -0,0 +1,21 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Runtime.Serialization;
using Squidex.Infrastructure;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents
{
public abstract class EnrichedUserEvent : EnrichedEvent
{
public RefToken Actor { get; set; }
[IgnoreDataMember]
public IUser User { get; set; }
}
}

78
src/Squidex.Domain.Apps.Core.Operations/HandleRules/EventEnricher.cs

@ -0,0 +1,78 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents;
using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Core.HandleRules
{
public sealed class EventEnricher : IEventEnricher
{
private static readonly TimeSpan UserCacheDuration = TimeSpan.FromMinutes(10);
private readonly IMemoryCache userCache;
private readonly IUserResolver userResolver;
public EventEnricher(IMemoryCache userCache, IUserResolver userResolver)
{
Guard.NotNull(userCache, nameof(userCache));
Guard.NotNull(userResolver, nameof(userResolver));
this.userCache = userCache;
this.userResolver = userResolver;
}
public async Task EnrichAsync(EnrichedEvent enrichedEvent, Envelope<AppEvent> @event)
{
enrichedEvent.Timestamp = @event.Headers.Timestamp();
if (enrichedEvent is EnrichedUserEvent userEvent)
{
if (@event.Payload is SquidexEvent squidexEvent)
{
userEvent.Actor = squidexEvent.Actor;
}
userEvent.User = await FindUserAsync(userEvent.Actor);
}
enrichedEvent.AppId = @event.Payload.AppId;
}
private Task<IUser> FindUserAsync(RefToken actor)
{
var key = $"EventEnrichers_Users_${actor.Identifier}";
return userCache.GetOrCreateAsync(key, async x =>
{
x.AbsoluteExpirationRelativeToNow = UserCacheDuration;
IUser user;
try
{
user = await userResolver.FindByIdOrEmailAsync(actor.Identifier);
}
catch
{
user = null;
}
if (user == null && actor.Type.Equals(RefTokenType.Client, StringComparison.OrdinalIgnoreCase))
{
user = new ClientUser(actor);
}
return user;
});
}
}
}

2
src/Squidex.Domain.Apps.Core.Operations/HandleRules/IEventEnricher.cs

@ -14,6 +14,6 @@ namespace Squidex.Domain.Apps.Core.HandleRules
{
public interface IEventEnricher
{
Task<EnrichedEvent> EnrichAsync(Envelope<AppEvent> @event);
Task EnrichAsync(EnrichedEvent enrichedEvent, Envelope<AppEvent> @event);
}
}

8
src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleTriggerHandler.cs

@ -6,8 +6,10 @@
// ==========================================================================
using System;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Core.HandleRules
@ -16,8 +18,10 @@ namespace Squidex.Domain.Apps.Core.HandleRules
{
Type TriggerType { get; }
bool Triggers(EnrichedEvent @event, RuleTrigger trigger);
Task<EnrichedEvent> CreateEnrichedEventAsync(Envelope<AppEvent> @event);
bool Triggers(IEvent @event, RuleTrigger trigger);
bool Trigger(EnrichedEvent @event, RuleTrigger trigger);
bool Trigger(AppEvent @event, RuleTrigger trigger, Guid ruleId);
}
}

21
src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs

@ -224,17 +224,32 @@ namespace Squidex.Domain.Apps.Core.HandleRules
private static string UserName(EnrichedEvent @event)
{
return @event.User?.DisplayName() ?? Fallback;
if (@event is EnrichedUserEvent userEvent)
{
return userEvent.User?.DisplayName() ?? Fallback;
}
return Fallback;
}
private static string UserId(EnrichedEvent @event)
{
return @event.User?.Id ?? Fallback;
if (@event is EnrichedUserEvent userEvent)
{
return userEvent.User?.Id ?? Fallback;
}
return Fallback;
}
private static string UserEmail(EnrichedEvent @event)
{
return @event.User?.Email ?? Fallback;
if (@event is EnrichedUserEvent userEvent)
{
return userEvent.User?.Email ?? Fallback;
}
return Fallback;
}
private static string CalculateData(NamedContentData data, Match match)

42
src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs

@ -7,7 +7,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
@ -56,7 +55,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules
this.clock = clock;
}
public virtual async Task<RuleJob> CreateJobAsync(Rule rule, Envelope<IEvent> @event)
public virtual async Task<RuleJob> CreateJobAsync(Rule rule, Guid ruleId, Envelope<IEvent> @event)
{
Guard.NotNull(rule, nameof(rule));
Guard.NotNull(@event, nameof(@event));
@ -71,6 +70,8 @@ namespace Squidex.Domain.Apps.Core.HandleRules
return null;
}
var typed = @event.To<AppEvent>();
var actionType = rule.Action.GetType();
if (!ruleTriggerHandlers.TryGetValue(rule.Trigger.GetType(), out var triggerHandler))
@ -83,11 +84,6 @@ namespace Squidex.Domain.Apps.Core.HandleRules
return null;
}
if (!triggerHandler.Triggers(@event.Payload, rule.Trigger))
{
return null;
}
var now = clock.GetCurrentInstant();
var eventTime =
@ -102,11 +98,23 @@ namespace Squidex.Domain.Apps.Core.HandleRules
return null;
}
if (!triggerHandler.Trigger(typed.Payload, rule.Trigger, ruleId))
{
return null;
}
var appEventEnvelope = @event.To<AppEvent>();
var enrichedEvent = await eventEnricher.EnrichAsync(appEventEnvelope);
var enrichedEvent = await triggerHandler.CreateEnrichedEventAsync(appEventEnvelope);
if (!triggerHandler.Triggers(enrichedEvent, rule.Trigger))
if (enrichedEvent == null)
{
return null;
}
await eventEnricher.EnrichAsync(enrichedEvent, typed);
if (!triggerHandler.Trigger(enrichedEvent, rule.Trigger))
{
return null;
}
@ -116,14 +124,12 @@ namespace Squidex.Domain.Apps.Core.HandleRules
var json = jsonSerializer.Serialize(actionData.Data);
enrichedEvent.CalculatePartition();
var job = new RuleJob
{
JobId = Guid.NewGuid(),
ActionName = actionName,
ActionData = json,
AppId = appEvent.AppId.Id,
AppId = enrichedEvent.AppId.Id,
Created = now,
EventName = enrichedEvent.Name,
ExecutionPartition = enrichedEvent.Partition,
@ -139,7 +145,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules
try
{
var actionType = typeNameRegistry.GetType(actionName);
var actionWatch = Stopwatch.StartNew();
var actionWatch = ValueStopwatch.StartNew();
var actionHandler = ruleActionHandlers[actionType];
@ -147,12 +153,12 @@ namespace Squidex.Domain.Apps.Core.HandleRules
var result = await actionHandler.ExecuteJobAsync(deserialized);
actionWatch.Stop();
var elapsed = TimeSpan.FromMilliseconds(actionWatch.Stop());
var dumpBuilder = new StringBuilder(result.Dump);
dumpBuilder.AppendLine();
dumpBuilder.AppendFormat("Elapsed {0}.", actionWatch.Elapsed);
dumpBuilder.AppendFormat("Elapsed {0}.", elapsed);
dumpBuilder.AppendLine();
if (result.Exception is TimeoutException || result.Exception is OperationCanceledException)
@ -160,15 +166,15 @@ namespace Squidex.Domain.Apps.Core.HandleRules
dumpBuilder.AppendLine();
dumpBuilder.AppendLine("Action timed out.");
return (dumpBuilder.ToString(), RuleResult.Timeout, actionWatch.Elapsed);
return (dumpBuilder.ToString(), RuleResult.Timeout, elapsed);
}
else if (result.Exception != null)
{
return (dumpBuilder.ToString(), RuleResult.Failed, actionWatch.Elapsed);
return (dumpBuilder.ToString(), RuleResult.Failed, elapsed);
}
else
{
return (dumpBuilder.ToString(), RuleResult.Success, actionWatch.Elapsed);
return (dumpBuilder.ToString(), RuleResult.Success, elapsed);
}
}
catch (Exception ex)

39
src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleTriggerHandler.cs

@ -6,15 +6,19 @@
// ==========================================================================
using System;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure.EventSourcing;
#pragma warning disable IDE0019 // Use pattern matching
namespace Squidex.Domain.Apps.Core.HandleRules
{
public abstract class RuleTriggerHandler<TTrigger, TEvent, TEnrichedEvent> : IRuleTriggerHandler
where TTrigger : RuleTrigger
where TEvent : IEvent
where TEvent : AppEvent
where TEnrichedEvent : EnrichedEvent
{
public Type TriggerType
@ -22,19 +26,40 @@ namespace Squidex.Domain.Apps.Core.HandleRules
get { return typeof(TTrigger); }
}
bool IRuleTriggerHandler.Triggers(EnrichedEvent @event, RuleTrigger trigger)
async Task<EnrichedEvent> IRuleTriggerHandler.CreateEnrichedEventAsync(Envelope<AppEvent> @event)
{
return @event is TEnrichedEvent e && Triggers(e, (TTrigger)trigger);
return await CreateEnrichedEventAsync(@event.To<TEvent>());
}
bool IRuleTriggerHandler.Triggers(IEvent @event, RuleTrigger trigger)
bool IRuleTriggerHandler.Trigger(EnrichedEvent @event, RuleTrigger trigger)
{
return @event is TEvent e && Triggers(e, (TTrigger)trigger);
var typed = @event as TEnrichedEvent;
if (typed != null)
{
return Trigger(typed, (TTrigger)trigger);
}
return false;
}
protected abstract bool Triggers(TEnrichedEvent @event, TTrigger trigger);
bool IRuleTriggerHandler.Trigger(AppEvent @event, RuleTrigger trigger, Guid ruleId)
{
var typed = @event as TEvent;
if (typed != null)
{
return Trigger(typed, (TTrigger)trigger, ruleId);
}
return false;
}
protected abstract Task<TEnrichedEvent> CreateEnrichedEventAsync(Envelope<TEvent> @event);
protected abstract bool Trigger(TEnrichedEvent @event, TTrigger trigger);
protected virtual bool Triggers(TEvent @event, TTrigger trigger)
protected virtual bool Trigger(TEvent @event, TTrigger trigger, Guid ruleId)
{
return true;
}

32
src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/AssetChangedTriggerHandler.cs

@ -1,32 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Events.Assets;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.HandleRules.Triggers
{
public sealed class AssetChangedTriggerHandler : RuleTriggerHandler<AssetChangedTriggerV2, AssetEvent, EnrichedAssetEvent>
{
private readonly IScriptEngine scriptEngine;
public AssetChangedTriggerHandler(IScriptEngine scriptEngine)
{
Guard.NotNull(scriptEngine, nameof(scriptEngine));
this.scriptEngine = scriptEngine;
}
protected override bool Triggers(EnrichedAssetEvent @event, AssetChangedTriggerV2 trigger)
{
return string.IsNullOrWhiteSpace(trigger.Condition) || scriptEngine.Evaluate("event", @event, trigger.Condition);
}
}
}

80
src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/ContentChangedTriggerHandler.cs

@ -1,80 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Events.Contents;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.HandleRules.Triggers
{
public sealed class ContentChangedTriggerHandler : RuleTriggerHandler<ContentChangedTriggerV2, ContentEvent, EnrichedContentEvent>
{
private readonly IScriptEngine scriptEngine;
public ContentChangedTriggerHandler(IScriptEngine scriptEngine)
{
Guard.NotNull(scriptEngine, nameof(scriptEngine));
this.scriptEngine = scriptEngine;
}
protected override bool Triggers(ContentEvent @event, ContentChangedTriggerV2 trigger)
{
if (trigger.HandleAll)
{
return true;
}
if (trigger.Schemas != null)
{
foreach (var schema in trigger.Schemas)
{
if (MatchsSchema(schema, @event.SchemaId))
{
return true;
}
}
}
return false;
}
protected override bool Triggers(EnrichedContentEvent @event, ContentChangedTriggerV2 trigger)
{
if (trigger.HandleAll)
{
return true;
}
if (trigger.Schemas != null)
{
foreach (var schema in trigger.Schemas)
{
if (MatchsSchema(schema, @event.SchemaId) && MatchsCondition(schema, @event))
{
return true;
}
}
}
return false;
}
private static bool MatchsSchema(ContentChangedTriggerSchemaV2 schema, NamedId<Guid> eventId)
{
return eventId.Id == schema.SchemaId;
}
private bool MatchsCondition(ContentChangedTriggerSchemaV2 schema, EnrichedSchemaEvent @event)
{
return string.IsNullOrWhiteSpace(schema.Condition) || scriptEngine.Evaluate("event", @event, schema.Condition);
}
}
}

2
src/Squidex.Domain.Apps.Entities/Apps/InviteUserCommandMiddleware.cs

@ -35,7 +35,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
await next();
if (isInvited && context.Result<object>() is EntityCreatedResult<string> id)
if (isInvited && context.PlainResult is EntityCreatedResult<string> id)
{
context.Complete(new InvitedResult { Id = id });
}

73
src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs

@ -0,0 +1,73 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Orleans;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Events.Assets;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Domain.Apps.Entities.Assets
{
public sealed class AssetChangedTriggerHandler : RuleTriggerHandler<AssetChangedTriggerV2, AssetEvent, EnrichedAssetEvent>
{
private readonly IScriptEngine scriptEngine;
private readonly IGrainFactory grainFactory;
public AssetChangedTriggerHandler(IScriptEngine scriptEngine, IGrainFactory grainFactory)
{
Guard.NotNull(scriptEngine, nameof(scriptEngine));
Guard.NotNull(grainFactory, nameof(grainFactory));
this.scriptEngine = scriptEngine;
this.grainFactory = grainFactory;
}
protected override async Task<EnrichedAssetEvent> CreateEnrichedEventAsync(Envelope<AssetEvent> @event)
{
var result = new EnrichedAssetEvent();
var asset =
(await grainFactory
.GetGrain<IAssetGrain>(@event.Payload.AssetId)
.GetStateAsync(@event.Headers.EventStreamNumber())).Value;
SimpleMapper.Map(asset, result);
switch (@event.Payload)
{
case AssetCreated _:
result.Type = EnrichedAssetEventType.Created;
break;
case AssetRenamed _:
result.Type = EnrichedAssetEventType.Renamed;
break;
case AssetUpdated _:
result.Type = EnrichedAssetEventType.Updated;
break;
case AssetDeleted _:
result.Type = EnrichedAssetEventType.Deleted;
break;
}
result.Name = $"Asset{result.Type}";
return result;
}
protected override bool Trigger(EnrichedAssetEvent @event, AssetChangedTriggerV2 trigger)
{
return string.IsNullOrWhiteSpace(trigger.Condition) || scriptEngine.Evaluate("event", @event, trigger.Condition);
}
}
}

2
src/Squidex.Domain.Apps.Entities/Backup/BackupHandlerWithStore.cs

@ -46,7 +46,7 @@ namespace Squidex.Domain.Apps.Entities.Backup
Version = EtagVersion.Empty
};
var persistence = store.WithSnapshotsAndEventSourcing<TState, Guid>(typeof(TGrain), key, s => state = s, e =>
var persistence = store.WithSnapshotsAndEventSourcing(typeof(TGrain), key, (TState s) => state = s, e =>
{
state = func(e, state);

2
src/Squidex.Domain.Apps.Entities/Comments/CommentsGrain.cs

@ -55,7 +55,7 @@ namespace Squidex.Domain.Apps.Entities.Comments
protected override Task ReadAsync(Type type, Guid id)
{
persistence = store.WithEventSourcing<Guid>(GetType(), id, ApplyEvent);
persistence = store.WithEventSourcing(GetType(), id, ApplyEvent);
return persistence.ReadAsync();
}

140
src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs

@ -0,0 +1,140 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Orleans;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Events.Contents;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Domain.Apps.Entities.Contents
{
public sealed class ContentChangedTriggerHandler : RuleTriggerHandler<ContentChangedTriggerV2, ContentEvent, EnrichedContentEvent>
{
private readonly IScriptEngine scriptEngine;
private readonly IGrainFactory grainFactory;
public ContentChangedTriggerHandler(IScriptEngine scriptEngine, IGrainFactory grainFactory)
{
Guard.NotNull(scriptEngine, nameof(scriptEngine));
Guard.NotNull(grainFactory, nameof(grainFactory));
this.scriptEngine = scriptEngine;
this.grainFactory = grainFactory;
}
protected override async Task<EnrichedContentEvent> CreateEnrichedEventAsync(Envelope<ContentEvent> @event)
{
var result = new EnrichedContentEvent();
var content =
(await grainFactory
.GetGrain<IContentGrain>(@event.Payload.ContentId)
.GetStateAsync(@event.Headers.EventStreamNumber())).Value;
SimpleMapper.Map(content, result);
result.Data = content.Data ?? content.DataDraft;
switch (@event.Payload)
{
case ContentCreated _:
result.Type = EnrichedContentEventType.Created;
break;
case ContentDeleted _:
result.Type = EnrichedContentEventType.Deleted;
break;
case ContentChangesPublished _:
case ContentUpdated _:
result.Type = EnrichedContentEventType.Updated;
break;
case ContentStatusChanged contentStatusChanged:
switch (contentStatusChanged.Change)
{
case StatusChange.Published:
result.Type = EnrichedContentEventType.Published;
break;
case StatusChange.Unpublished:
result.Type = EnrichedContentEventType.Unpublished;
break;
case StatusChange.Archived:
result.Type = EnrichedContentEventType.Archived;
break;
case StatusChange.Restored:
result.Type = EnrichedContentEventType.Restored;
break;
}
break;
}
result.Name = $"{content.SchemaId.Name.ToPascalCase()}{result.Type}";
return result;
}
protected override bool Trigger(ContentEvent @event, ContentChangedTriggerV2 trigger, Guid ruleId)
{
if (trigger.HandleAll)
{
return true;
}
if (trigger.Schemas != null)
{
foreach (var schema in trigger.Schemas)
{
if (MatchsSchema(schema, @event.SchemaId))
{
return true;
}
}
}
return false;
}
protected override bool Trigger(EnrichedContentEvent @event, ContentChangedTriggerV2 trigger)
{
if (trigger.HandleAll)
{
return true;
}
if (trigger.Schemas != null)
{
foreach (var schema in trigger.Schemas)
{
if (MatchsSchema(schema, @event.SchemaId) && MatchsCondition(schema, @event))
{
return true;
}
}
}
return false;
}
private static bool MatchsSchema(ContentChangedTriggerSchemaV2 schema, NamedId<Guid> eventId)
{
return eventId.Id == schema.SchemaId;
}
private bool MatchsCondition(ContentChangedTriggerSchemaV2 schema, EnrichedSchemaEvent @event)
{
return string.IsNullOrWhiteSpace(schema.Condition) || scriptEngine.Evaluate("event", @event, schema.Condition);
}
}
}

192
src/Squidex.Domain.Apps.Entities/Rules/EventEnricher.cs

@ -1,192 +0,0 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Orleans;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Assets;
using Squidex.Domain.Apps.Events.Contents;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Reflection;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Rules
{
public sealed class EventEnricher : IEventEnricher
{
private static readonly TimeSpan UserCacheDuration = TimeSpan.FromMinutes(10);
private readonly IGrainFactory grainFactory;
private readonly IMemoryCache userCache;
private readonly IUserResolver userResolver;
public EventEnricher(IGrainFactory grainFactory, IMemoryCache userCache, IUserResolver userResolver)
{
Guard.NotNull(grainFactory, nameof(grainFactory));
Guard.NotNull(userCache, nameof(userCache));
Guard.NotNull(userResolver, nameof(userResolver));
this.grainFactory = grainFactory;
this.userCache = userCache;
this.userResolver = userResolver;
}
public async Task<EnrichedEvent> EnrichAsync(Envelope<AppEvent> @event)
{
Guard.NotNull(@event, nameof(@event));
if (@event.Payload is ContentEvent contentEvent)
{
var result = new EnrichedContentEvent();
await Task.WhenAll(
EnrichContentAsync(result, contentEvent, @event),
EnrichDefaultAsync(result, @event));
return result;
}
if (@event.Payload is AssetEvent assetEvent)
{
var result = new EnrichedAssetEvent();
await Task.WhenAll(
EnrichAssetAsync(result, assetEvent, @event),
EnrichDefaultAsync(result, @event));
return result;
}
return null;
}
private async Task EnrichAssetAsync(EnrichedAssetEvent result, AssetEvent assetEvent, Envelope<AppEvent> @event)
{
var asset =
(await grainFactory
.GetGrain<IAssetGrain>(assetEvent.AssetId)
.GetStateAsync(@event.Headers.EventStreamNumber())).Value;
SimpleMapper.Map(asset, result);
switch (assetEvent)
{
case AssetCreated _:
result.Type = EnrichedAssetEventType.Created;
break;
case AssetRenamed _:
result.Type = EnrichedAssetEventType.Renamed;
break;
case AssetUpdated _:
result.Type = EnrichedAssetEventType.Updated;
break;
case AssetDeleted _:
result.Type = EnrichedAssetEventType.Deleted;
break;
}
result.Name = $"Asset{result.Type}";
}
private async Task EnrichContentAsync(EnrichedContentEvent result, ContentEvent contentEvent, Envelope<AppEvent> @event)
{
var content =
(await grainFactory
.GetGrain<IContentGrain>(contentEvent.ContentId)
.GetStateAsync(@event.Headers.EventStreamNumber())).Value;
SimpleMapper.Map(content, result);
result.Data = content.Data ?? content.DataDraft;
switch (contentEvent)
{
case ContentCreated _:
result.Type = EnrichedContentEventType.Created;
break;
case ContentDeleted _:
result.Type = EnrichedContentEventType.Deleted;
break;
case ContentChangesPublished _:
case ContentUpdated _:
result.Type = EnrichedContentEventType.Updated;
break;
case ContentStatusChanged contentStatusChanged:
switch (contentStatusChanged.Change)
{
case StatusChange.Published:
result.Type = EnrichedContentEventType.Published;
break;
case StatusChange.Unpublished:
result.Type = EnrichedContentEventType.Unpublished;
break;
case StatusChange.Archived:
result.Type = EnrichedContentEventType.Archived;
break;
case StatusChange.Restored:
result.Type = EnrichedContentEventType.Restored;
break;
}
break;
}
result.Name = $"{content.SchemaId.Name.ToPascalCase()}{result.Type}";
}
private async Task EnrichDefaultAsync(EnrichedEvent result, Envelope<AppEvent> @event)
{
result.Timestamp = @event.Headers.Timestamp();
if (@event.Payload is SquidexEvent squidexEvent)
{
result.Actor = squidexEvent.Actor;
}
if (@event.Payload is AppEvent appEvent)
{
result.AppId = appEvent.AppId;
}
result.User = await FindUserAsync(result.Actor);
}
private Task<IUser> FindUserAsync(RefToken actor)
{
var key = $"EventEnrichers_Users_${actor.Identifier}";
return userCache.GetOrCreateAsync(key, async x =>
{
x.AbsoluteExpirationRelativeToNow = UserCacheDuration;
IUser user;
try
{
user = await userResolver.FindByIdOrEmailAsync(actor.Identifier);
}
catch
{
user = null;
}
if (user == null && actor.Type.Equals(RefTokenType.Client, StringComparison.OrdinalIgnoreCase))
{
user = new ClientUser(actor);
}
return user;
});
}
}
}

48
src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleTriggerValidator.cs

@ -40,20 +40,54 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards
return Task.FromResult(Enumerable.Empty<ValidationError>());
}
public Task<IEnumerable<ValidationError>> Visit(UsageTrigger trigger)
{
var errors = new List<ValidationError>();
if (trigger.NumDays.HasValue && (trigger.NumDays < 1 || trigger.NumDays > 30))
{
errors.Add(new ValidationError("Num days must be between 1 and 30.", nameof(trigger.NumDays)));
}
return Task.FromResult< IEnumerable<ValidationError>>(errors);
}
public async Task<IEnumerable<ValidationError>> Visit(ContentChangedTriggerV2 trigger)
{
var errors = new List<ValidationError>();
if (trigger.Schemas != null)
{
var schemaErrors = await Task.WhenAll(
trigger.Schemas.Select(async s =>
await SchemaProvider(s.SchemaId) == null
? new ValidationError($"Schema {s.SchemaId} does not exist.", nameof(trigger.Schemas))
: null));
var tasks = new List<Task<ValidationError>>();
return schemaErrors.Where(x => x != null).ToList();
foreach (var schema in trigger.Schemas)
{
if (schema.SchemaId == Guid.Empty)
{
errors.Add(new ValidationError("Schema id is required.", nameof(trigger.Schemas)));
}
else
{
tasks.Add(CheckSchemaAsync(schema));
}
}
var checkErrors = await Task.WhenAll(tasks);
errors.AddRange(checkErrors.Where(x => x != null));
}
return errors;
}
private async Task<ValidationError> CheckSchemaAsync(ContentChangedTriggerSchemaV2 schema)
{
if (await SchemaProvider(schema.SchemaId) == null)
{
return new ValidationError($"Schema {schema.SchemaId} does not exist.", nameof(ContentChangedTriggerV2.Schemas));
}
return new List<ValidationError>();
return null;
}
}
}

2
src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs

@ -65,7 +65,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
foreach (var ruleEntity in rules)
{
var job = await ruleService.CreateJobAsync(ruleEntity.RuleDef, @event);
var job = await ruleService.CreateJobAsync(ruleEntity.RuleDef, ruleEntity.Id, @event);
if (job != null)
{

24
src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/IUsageTrackerGrain.cs

@ -0,0 +1,24 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Orleans;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Orleans;
namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking
{
public interface IUsageTrackerGrain : IGrainWithStringKey, IBackgroundGrain
{
Task AddTargetAsync(Guid ruleId, NamedId<Guid> appId, int limits, int? numDays);
Task RemoveTargetAsync(Guid ruleId);
Task UpdateTargetAsync(Guid ruleId, int limits, int? numDays);
}
}

61
src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerCommandMiddleware.cs

@ -0,0 +1,61 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Orleans;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Entities.Rules.Commands;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Orleans;
namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking
{
public sealed class UsageTrackerCommandMiddleware : ICommandMiddleware
{
private readonly IUsageTrackerGrain usageTrackerGrain;
public UsageTrackerCommandMiddleware(IGrainFactory grainFactory)
{
Guard.NotNull(grainFactory, nameof(grainFactory));
usageTrackerGrain = grainFactory.GetGrain<IUsageTrackerGrain>(SingleGrain.Id);
}
public async Task HandleAsync(CommandContext context, Func<Task> next)
{
switch (context.Command)
{
case DeleteRule deleteRule:
await usageTrackerGrain.RemoveTargetAsync(deleteRule.RuleId);
break;
case CreateRule createRule:
{
if (createRule.Trigger is UsageTrigger usage)
{
await usageTrackerGrain.AddTargetAsync(createRule.RuleId, createRule.AppId, usage.Limit, usage.NumDays);
}
break;
}
case UpdateRule ruleUpdated:
{
if (ruleUpdated.Trigger is UsageTrigger usage)
{
await usageTrackerGrain.UpdateTargetAsync(ruleUpdated.RuleId, usage.Limit, usage.NumDays);
}
break;
}
}
await next();
}
}
}

155
src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTrackerGrain.cs

@ -0,0 +1,155 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Orleans;
using Orleans.Concurrency;
using Orleans.Runtime;
using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.States;
using Squidex.Infrastructure.Tasks;
using Squidex.Infrastructure.UsageTracking;
namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking
{
[Reentrant]
public sealed class UsageTrackerGrain : GrainOfString<UsageTrackerGrain.GrainState>, IRemindable, IUsageTrackerGrain
{
private readonly IUsageTracker usageTracker;
public sealed class Target
{
public int Limits { get; set; }
public int? NumDays { get; set; }
public DateTime? Triggered { get; set; }
public NamedId<Guid> AppId { get; set; }
}
[CollectionName("UsageTracker")]
public sealed class GrainState
{
public Dictionary<Guid, Target> Targets { get; set; } = new Dictionary<Guid, Target>();
}
public UsageTrackerGrain(IStore<string> store, IUsageTracker usageTracker)
: base(store)
{
Guard.NotNull(usageTracker, nameof(usageTracker));
this.usageTracker = usageTracker;
}
protected override Task OnActivateAsync(string key)
{
DelayDeactivation(TimeSpan.FromDays(1));
RegisterOrUpdateReminder("Default", TimeSpan.Zero, TimeSpan.FromMinutes(10));
RegisterTimer(x => CheckUsagesAsync(), null, TimeSpan.Zero, TimeSpan.FromMinutes(10));
return Task.CompletedTask;
}
public Task ActivateAsync()
{
return TaskHelper.Done;
}
public Task ReceiveReminder(string reminderName, TickStatus status)
{
return TaskHelper.Done;
}
public async Task CheckUsagesAsync()
{
var today = DateTime.Today;
foreach (var kvp in State.Targets)
{
var target = kvp.Value;
var (from, to) = GetDateRange(today, target.NumDays);
if (!target.Triggered.HasValue || target.Triggered < from)
{
var usage = await usageTracker.GetMonthlyCallsAsync(target.AppId.Id.ToString(), today);
var limit = kvp.Value.Limits;
if (usage > limit)
{
kvp.Value.Triggered = today;
var @event = new AppUsageExceeded
{
AppId = target.AppId,
CallsCurrent = usage,
CallsLimit = limit,
RuleId = kvp.Key
};
await Persistence.WriteEventAsync(Envelope.Create<IEvent>(@event));
}
}
}
await WriteStateAsync();
}
private (DateTime, DateTime) GetDateRange(DateTime today, int? numDays)
{
if (numDays.HasValue)
{
return (today.AddDays(-numDays.Value).AddDays(1), today);
}
else
{
return (new DateTime(today.Year, today.Month, 1), today);
}
}
public Task AddTargetAsync(Guid ruleId, NamedId<Guid> appId, int limits, int? numDays)
{
UpdateTarget(ruleId, t => { t.Limits = limits; t.AppId = appId; t.NumDays = numDays; });
return WriteStateAsync();
}
public Task UpdateTargetAsync(Guid ruleId, int limits, int? numDays)
{
UpdateTarget(ruleId, t => { t.Limits = limits; t.NumDays = numDays; });
return WriteStateAsync();
}
public Task AddTargetAsync(Guid ruleId, int limits)
{
UpdateTarget(ruleId, t => t.Limits = limits);
return WriteStateAsync();
}
public Task RemoveTargetAsync(Guid ruleId)
{
State.Targets.Remove(ruleId);
return WriteStateAsync();
}
private void UpdateTarget(Guid ruleId, Action<Target> updater)
{
updater(State.Targets.GetOrAddNew(ruleId));
}
}
}

38
src/Squidex.Domain.Apps.Entities/Rules/UsageTracking/UsageTriggerHandler.cs

@ -0,0 +1,38 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking
{
public sealed class UsageTriggerHandler : RuleTriggerHandler<UsageTrigger, AppUsageExceeded, EnrichedUsageExceededEvent>
{
private const string EventName = "Usage exceeded";
protected override Task<EnrichedUsageExceededEvent> CreateEnrichedEventAsync(Envelope<AppUsageExceeded> @event)
{
var result = new EnrichedUsageExceededEvent
{
CallsCurrent = @event.Payload.CallsCurrent,
CallsLimit = @event.Payload.CallsLimit,
Name = EventName
};
return Task.FromResult(result);
}
protected override bool Trigger(EnrichedUsageExceededEvent @event, UsageTrigger trigger)
{
return @event.CallsLimit == trigger.Limit;
}
}
}

22
src/Squidex.Domain.Apps.Events/AppUsageExceeded.cs

@ -0,0 +1,22 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Events
{
[EventType(nameof(AppUsageExceeded))]
public sealed class AppUsageExceeded : AppEvent
{
public long CallsCurrent { get; set; }
public long CallsLimit { get; set; }
public Guid RuleId { get; set; }
}
}

2
src/Squidex.Domain.Apps.Events/SquidexHeaderExtensions.cs

@ -17,7 +17,7 @@ namespace Squidex.Domain.Apps.Events
return headers.GetGuid(SquidexHeaders.AppId);
}
public static Envelope<T> SetAppId<T>(this Envelope<T> envelope, Guid value) where T : class
public static Envelope<T> SetAppId<T>(this Envelope<T> envelope, Guid value) where T : class, IEvent
{
envelope.Headers.Add(SquidexHeaders.AppId, value.ToString());

5
src/Squidex.Infrastructure/Commands/CommandContext.cs

@ -19,6 +19,11 @@ namespace Squidex.Infrastructure.Commands
public ICommandBus CommandBus { get; }
public object PlainResult
{
get { return result?.Item1; }
}
public bool IsCompleted
{
get { return result != null; }

7
src/Squidex.Infrastructure/Commands/DomainObjectGrain.cs

@ -47,11 +47,16 @@ namespace Squidex.Infrastructure.Commands
protected sealed override Task ReadAsync(Type type, Guid id)
{
persistence = store.WithSnapshotsAndEventSourcing<T, Guid>(GetType(), id, x => snapshot = x, ApplyEvent);
persistence = store.WithSnapshotsAndEventSourcing(GetType(), id, new HandleSnapshot<T>(ApplySnapshot), ApplyEvent);
return persistence.ReadAsync();
}
private void ApplySnapshot(T state)
{
snapshot = state;
}
protected sealed override async Task WriteAsync(Envelope<IEvent>[] events, long previousVersion)
{
if (events.Length > 0)

2
src/Squidex.Infrastructure/Commands/LogSnapshotDomainObjectGrain.cs

@ -64,7 +64,7 @@ namespace Squidex.Infrastructure.Commands
protected sealed override Task ReadAsync(Type type, Guid id)
{
persistence = store.WithEventSourcing<Guid>(type, id, ApplyEvent);
persistence = store.WithEventSourcing(type, id, ApplyEvent);
return persistence.ReadAsync();
}

4
src/Squidex.Infrastructure/EventSourcing/Envelope.cs

@ -12,12 +12,12 @@ namespace Squidex.Infrastructure.EventSourcing
{
public static class Envelope
{
public static Envelope<IEvent> Create<TPayload>(TPayload payload) where TPayload : IEvent
public static Envelope<TPayload> Create<TPayload>(TPayload payload) where TPayload : class, IEvent
{
var eventId = Guid.NewGuid();
var envelope =
new Envelope<IEvent>(payload)
new Envelope<TPayload>(payload)
.SetEventId(eventId)
.SetTimestamp(SystemClock.Instance.GetCurrentInstant());

12
src/Squidex.Infrastructure/EventSourcing/EnvelopeExtensions.cs

@ -20,7 +20,7 @@ namespace Squidex.Infrastructure.EventSourcing
return headers.GetString(CommonHeaders.EventNumber);
}
public static Envelope<T> SetEventPosition<T>(this Envelope<T> envelope, string value) where T : class
public static Envelope<T> SetEventPosition<T>(this Envelope<T> envelope, string value) where T : class, IEvent
{
envelope.Headers.Add(CommonHeaders.EventNumber, value);
@ -32,7 +32,7 @@ namespace Squidex.Infrastructure.EventSourcing
return headers.GetLong(CommonHeaders.EventStreamNumber);
}
public static Envelope<T> SetEventStreamNumber<T>(this Envelope<T> envelope, long value) where T : class
public static Envelope<T> SetEventStreamNumber<T>(this Envelope<T> envelope, long value) where T : class, IEvent
{
envelope.Headers.Add(CommonHeaders.EventStreamNumber, value);
@ -44,7 +44,7 @@ namespace Squidex.Infrastructure.EventSourcing
return headers.GetGuid(CommonHeaders.CommitId);
}
public static Envelope<T> SetCommitId<T>(this Envelope<T> envelope, Guid value) where T : class
public static Envelope<T> SetCommitId<T>(this Envelope<T> envelope, Guid value) where T : class, IEvent
{
envelope.Headers.Add(CommonHeaders.CommitId, value.ToString());
@ -56,7 +56,7 @@ namespace Squidex.Infrastructure.EventSourcing
return headers.GetGuid(CommonHeaders.AggregateId);
}
public static Envelope<T> SetAggregateId<T>(this Envelope<T> envelope, Guid value) where T : class
public static Envelope<T> SetAggregateId<T>(this Envelope<T> envelope, Guid value) where T : class, IEvent
{
envelope.Headers.Add(CommonHeaders.AggregateId, value.ToString());
@ -68,7 +68,7 @@ namespace Squidex.Infrastructure.EventSourcing
return headers.GetGuid(CommonHeaders.EventId);
}
public static Envelope<T> SetEventId<T>(this Envelope<T> envelope, Guid value) where T : class
public static Envelope<T> SetEventId<T>(this Envelope<T> envelope, Guid value) where T : class, IEvent
{
envelope.Headers.Add(CommonHeaders.EventId, value.ToString());
@ -80,7 +80,7 @@ namespace Squidex.Infrastructure.EventSourcing
return headers.GetInstant(CommonHeaders.Timestamp);
}
public static Envelope<T> SetTimestamp<T>(this Envelope<T> envelope, Instant value) where T : class
public static Envelope<T> SetTimestamp<T>(this Envelope<T> envelope, Instant value) where T : class, IEvent
{
envelope.Headers.Add(CommonHeaders.Timestamp, value.ToString());

9
src/Squidex.Infrastructure/EventSourcing/Envelope{T}.cs

@ -7,7 +7,7 @@
namespace Squidex.Infrastructure.EventSourcing
{
public class Envelope<T> where T : class
public class Envelope<T> where T : class, IEvent
{
private readonly EnvelopeHeaders headers;
private readonly T payload;
@ -30,9 +30,14 @@ namespace Squidex.Infrastructure.EventSourcing
this.headers = headers ?? new EnvelopeHeaders();
}
public Envelope<TOther> To<TOther>() where TOther : class
public Envelope<TOther> To<TOther>() where TOther : class, IEvent
{
return new Envelope<TOther>(payload as TOther, headers.Clone());
}
public static implicit operator Envelope<IEvent>(Envelope<T> source)
{
return source == null ? source : new Envelope<IEvent>(source.payload, source.headers);
}
}
}

43
src/Squidex.Infrastructure/EventSourcing/Grains/EventConsumerGrain.cs

@ -17,18 +17,15 @@ using Squidex.Infrastructure.Tasks;
namespace Squidex.Infrastructure.EventSourcing.Grains
{
public class EventConsumerGrain : GrainOfString, IEventConsumerGrain
public class EventConsumerGrain : GrainOfString<EventConsumerState>, IEventConsumerGrain
{
private readonly EventConsumerFactory eventConsumerFactory;
private readonly IStore<string> store;
private readonly IEventDataFormatter eventDataFormatter;
private readonly IEventStore eventStore;
private readonly ISemanticLog log;
private TaskScheduler scheduler;
private IPersistence<EventConsumerState> persistence;
private IEventSubscription currentSubscription;
private IEventConsumer eventConsumer;
private EventConsumerState state = new EventConsumerState();
public EventConsumerGrain(
EventConsumerFactory eventConsumerFactory,
@ -36,18 +33,18 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
IEventStore eventStore,
IEventDataFormatter eventDataFormatter,
ISemanticLog log)
: base(store)
{
Guard.NotNull(log, nameof(log));
Guard.NotNull(store, nameof(store));
Guard.NotNull(eventStore, nameof(eventStore));
Guard.NotNull(eventDataFormatter, nameof(eventDataFormatter));
Guard.NotNull(eventConsumerFactory, nameof(eventConsumerFactory));
Guard.NotNull(log, nameof(log));
this.log = log;
this.store = store;
this.eventStore = eventStore;
this.eventDataFormatter = eventDataFormatter;
this.eventConsumerFactory = eventConsumerFactory;
this.log = log;
}
protected override Task OnActivateAsync(string key)
@ -56,14 +53,12 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
eventConsumer = eventConsumerFactory(key);
persistence = store.WithSnapshots<EventConsumerState, string>(GetType(), eventConsumer.Name, s => state = s);
return persistence.ReadAsync();
return Task.CompletedTask;
}
public Task<Immutable<EventConsumerInfo>> GetStateAsync()
{
return Task.FromResult(state.ToInfo(eventConsumer.Name).AsImmutable());
return Task.FromResult(State.ToInfo(eventConsumer.Name).AsImmutable());
}
public Task OnEventAsync(Immutable<IEventSubscription> subscription, Immutable<StoredEvent> storedEvent)
@ -82,7 +77,7 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
await DispatchConsumerAsync(@event);
}
state = state.Handled(storedEvent.Value.EventPosition);
State = State.Handled(storedEvent.Value.EventPosition);
});
}
@ -97,15 +92,15 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
{
Unsubscribe();
state = state.Failed(exception.Value);
State = State.Failed(exception.Value);
});
}
public Task ActivateAsync()
{
if (!state.IsStopped)
if (!State.IsStopped)
{
Subscribe(state.Position);
Subscribe(State.Position);
}
return TaskHelper.Done;
@ -113,22 +108,22 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
public Task StartAsync()
{
if (!state.IsStopped)
if (!State.IsStopped)
{
return TaskHelper.Done;
}
return DoAndUpdateStateAsync(() =>
{
Subscribe(state.Position);
Subscribe(State.Position);
state = state.Started();
State = State.Started();
});
}
public Task StopAsync()
{
if (state.IsStopped)
if (State.IsStopped)
{
return TaskHelper.Done;
}
@ -137,7 +132,7 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
{
Unsubscribe();
state = state.Stopped();
State = State.Stopped();
});
}
@ -151,7 +146,7 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
Subscribe(null);
state = state.Reset();
State = State.Reset();
});
}
@ -182,10 +177,10 @@ namespace Squidex.Infrastructure.EventSourcing.Grains
.WriteProperty("status", "Failed")
.WriteProperty("eventConsumer", eventConsumer.Name));
state = state.Failed(ex);
State = State.Failed(ex);
}
await persistence.WriteSnapshotAsync(state);
await WriteStateAsync();
}
private async Task ClearAsync()

19
src/Squidex.Infrastructure/InstantExtensions.cs

@ -0,0 +1,19 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using NodaTime;
namespace Squidex.Infrastructure
{
public static class InstantExtensions
{
public static Instant WithoutMs(this Instant value)
{
return Instant.FromUnixTimeSeconds(value.ToUnixTimeSeconds());
}
}
}

5
src/Squidex.Infrastructure/Log/IArrayWriter.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System;
using NodaTime;
namespace Squidex.Infrastructure.Log
{
@ -21,9 +22,7 @@ namespace Squidex.Infrastructure.Log
IArrayWriter WriteValue(TimeSpan value);
IArrayWriter WriteValue(DateTime value);
IArrayWriter WriteValue(DateTimeOffset value);
IArrayWriter WriteValue(Instant value);
IArrayWriter WriteObject(Action<IObjectWriter> objectWriter);
}

5
src/Squidex.Infrastructure/Log/IObjectWriter.cs

@ -6,6 +6,7 @@
// ==========================================================================
using System;
using NodaTime;
namespace Squidex.Infrastructure.Log
{
@ -21,9 +22,7 @@ namespace Squidex.Infrastructure.Log
IObjectWriter WriteProperty(string property, TimeSpan value);
IObjectWriter WriteProperty(string property, DateTime value);
IObjectWriter WriteProperty(string property, DateTimeOffset value);
IObjectWriter WriteProperty(string property, Instant value);
IObjectWriter WriteObject(string property, Action<IObjectWriter> objectWriter);

3
src/Squidex.Infrastructure/Log/Internal/FileLogProcessor.cs

@ -10,6 +10,7 @@ using System.Collections.Concurrent;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using NodaTime;
namespace Squidex.Infrastructure.Log.Internal
{
@ -70,7 +71,7 @@ namespace Squidex.Infrastructure.Log.Internal
AutoFlush = true
};
writer.WriteLine($"--- Started Logging {DateTime.UtcNow} ---", 1);
writer.WriteLine($"--- Started Logging {SystemClock.Instance.GetCurrentInstant()} ---", 1);
}
catch (Exception ex)
{

29
src/Squidex.Infrastructure/Log/JsonLogWriter.cs

@ -6,9 +6,9 @@
// ==========================================================================
using System;
using System.Globalization;
using System.IO;
using Newtonsoft.Json;
using NodaTime;
namespace Squidex.Infrastructure.Log
{
@ -73,23 +73,16 @@ namespace Squidex.Infrastructure.Log
return this;
}
IArrayWriter IArrayWriter.WriteValue(DateTime value)
IArrayWriter IArrayWriter.WriteValue(Instant value)
{
jsonWriter.WriteValue(value.ToString("yyyy-MM-ddTHH:mm:ssZ", CultureInfo.InvariantCulture));
return this;
}
IArrayWriter IArrayWriter.WriteValue(DateTimeOffset value)
{
jsonWriter.WriteValue(value.ToString("o", CultureInfo.InvariantCulture));
jsonWriter.WriteValue(value.ToString());
return this;
}
IArrayWriter IArrayWriter.WriteValue(TimeSpan value)
{
jsonWriter.WriteValue(value);
jsonWriter.WriteValue(value.ToString());
return this;
}
@ -126,18 +119,10 @@ namespace Squidex.Infrastructure.Log
return this;
}
IObjectWriter IObjectWriter.WriteProperty(string property, DateTime value)
IObjectWriter IObjectWriter.WriteProperty(string property, Instant value)
{
jsonWriter.WritePropertyName(Format(property));
jsonWriter.WriteValue(value.ToString("o", CultureInfo.InvariantCulture));
return this;
}
IObjectWriter IObjectWriter.WriteProperty(string property, DateTimeOffset value)
{
jsonWriter.WritePropertyName(Format(property));
jsonWriter.WriteValue(value.ToString("o", CultureInfo.InvariantCulture));
jsonWriter.WriteValue(value.ToString());
return this;
}
@ -145,7 +130,7 @@ namespace Squidex.Infrastructure.Log
IObjectWriter IObjectWriter.WriteProperty(string property, TimeSpan value)
{
jsonWriter.WritePropertyName(Format(property));
jsonWriter.WriteValue(value);
jsonWriter.WriteValue(value.ToString());
return this;
}

17
src/Squidex.Infrastructure/Log/TimestampLogAppender.cs

@ -5,29 +5,22 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using NodaTime;
namespace Squidex.Infrastructure.Log
{
public sealed class TimestampLogAppender : ILogAppender
{
private readonly Func<DateTime> timestamp;
private readonly IClock clock;
public TimestampLogAppender()
: this(() => DateTime.UtcNow)
public TimestampLogAppender(IClock clock = null)
{
}
public TimestampLogAppender(Func<DateTime> timestamp)
{
Guard.NotNull(timestamp, nameof(timestamp));
this.timestamp = timestamp;
this.clock = clock ?? SystemClock.Instance;
}
public void Append(IObjectWriter writer)
{
writer.WriteProperty("timestamp", timestamp());
writer.WriteProperty("timestamp", clock.GetCurrentInstant());
}
}
}

20
src/Squidex.Infrastructure/None.cs

@ -0,0 +1,20 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
namespace Squidex.Infrastructure
{
public sealed class None
{
public static readonly Type Type = typeof(None);
private None()
{
}
}
}

2
src/Squidex.Infrastructure/Orleans/GrainOfGuid{T}.cs

@ -43,7 +43,7 @@ namespace Squidex.Infrastructure.Orleans
{
Key = key;
persistence = store.WithSnapshots<T, Guid>(GetType(), key, ApplyState);
persistence = store.WithSnapshots(GetType(), key, new HandleSnapshot<T>(ApplyState));
await persistence.ReadAsync();

2
src/Squidex.Infrastructure/Orleans/GrainOfString{T}.cs

@ -42,7 +42,7 @@ namespace Squidex.Infrastructure.Orleans
{
Key = key;
persistence = store.WithSnapshots<T, string>(GetType(), key, ApplyState);
persistence = store.WithSnapshots(GetType(), key, new HandleSnapshot<T>(ApplyState));
await persistence.ReadAsync();

21
src/Squidex.Infrastructure/RetryWindow.cs

@ -7,19 +7,23 @@
using System;
using System.Collections.Generic;
using NodaTime;
namespace Squidex.Infrastructure
{
public sealed class RetryWindow
{
private readonly TimeSpan windowDuration;
private readonly Duration windowDuration;
private readonly int windowSize;
private readonly Queue<DateTime> retries = new Queue<DateTime>();
private readonly Queue<Instant> retries = new Queue<Instant>();
private readonly IClock clock;
public RetryWindow(TimeSpan windowDuration, int windowSize)
public RetryWindow(TimeSpan windowDuration, int windowSize, IClock clock = null)
{
this.windowDuration = windowDuration;
this.windowDuration = Duration.FromTimeSpan(windowDuration);
this.windowSize = windowSize + 1;
this.clock = clock ?? SystemClock.Instance;
}
public void Reset()
@ -29,19 +33,16 @@ namespace Squidex.Infrastructure
public bool CanRetryAfterFailure()
{
return CanRetryAfterFailure(DateTime.UtcNow);
}
var now = clock.GetCurrentInstant();
public bool CanRetryAfterFailure(DateTime utcNow)
{
retries.Enqueue(utcNow);
retries.Enqueue(now);
while (retries.Count > windowSize)
{
retries.Dequeue();
}
return retries.Count < windowSize || (retries.Count > 0 && (utcNow - retries.Peek()) > windowDuration);
return retries.Count < windowSize || (retries.Count > 0 && (now - retries.Peek()) > windowDuration);
}
}
}

2
src/Squidex.Infrastructure/States/IPersistence.cs

@ -7,7 +7,7 @@
namespace Squidex.Infrastructure.States
{
public interface IPersistence : IPersistence<object>
public interface IPersistence : IPersistence<None>
{
}
}

11
src/Squidex.Infrastructure/States/IStore.cs

@ -6,18 +6,21 @@
// ==========================================================================
using System;
using System.Threading.Tasks;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Infrastructure.States
{
public delegate void HandleEvent(Envelope<IEvent> @event);
public delegate void HandleSnapshot<T>(T state);
public interface IStore<in TKey>
{
IPersistence WithEventSourcing(Type owner, TKey key, Func<Envelope<IEvent>, Task> applyEvent);
IPersistence WithEventSourcing(Type owner, TKey key, HandleEvent applyEvent);
IPersistence<TState> WithSnapshots<TState>(Type owner, TKey key, Func<TState, Task> applySnapshot);
IPersistence<TState> WithSnapshots<TState>(Type owner, TKey key, HandleSnapshot<TState> applySnapshot);
IPersistence<TState> WithSnapshotsAndEventSourcing<TState>(Type owner, TKey key, Func<TState, Task> applySnapshot, Func<Envelope<IEvent>, Task> applyEvent);
IPersistence<TState> WithSnapshotsAndEventSourcing<TState>(Type owner, TKey key, HandleSnapshot<TState> applySnapshot, HandleEvent applyEvent);
ISnapshotStore<TState, TKey> GetSnapshotStore<TState>();
}

7
src/Squidex.Infrastructure/States/Persistence.cs

@ -6,19 +6,18 @@
// ==========================================================================
using System;
using System.Threading.Tasks;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Infrastructure.States
{
internal sealed class Persistence<TKey> : Persistence<object, TKey>, IPersistence
internal sealed class Persistence<TKey> : Persistence<None, TKey>, IPersistence
{
public Persistence(TKey ownerKey, Type ownerType,
IEventStore eventStore,
IEventDataFormatter eventDataFormatter,
ISnapshotStore<object, TKey> snapshotStore,
ISnapshotStore<None, TKey> snapshotStore,
IStreamNameResolver streamNameResolver,
Func<Envelope<IEvent>, Task> applyEvent)
HandleEvent applyEvent)
: base(ownerKey, ownerType, eventStore, eventDataFormatter, snapshotStore, streamNameResolver, PersistenceMode.EventSourcing, null, applyEvent)
{
}

14
src/Squidex.Infrastructure/States/Persistence{TSnapshot,TKey}.cs

@ -24,8 +24,8 @@ namespace Squidex.Infrastructure.States
private readonly IEventStore eventStore;
private readonly IEventDataFormatter eventDataFormatter;
private readonly PersistenceMode persistenceMode;
private readonly Func<TSnapshot, Task> applyState;
private readonly Func<Envelope<IEvent>, Task> applyEvent;
private readonly HandleSnapshot<TSnapshot> applyState;
private readonly HandleEvent applyEvent;
private long versionSnapshot = EtagVersion.Empty;
private long versionEvents = EtagVersion.Empty;
private long version;
@ -41,8 +41,8 @@ namespace Squidex.Infrastructure.States
ISnapshotStore<TSnapshot, TKey> snapshotStore,
IStreamNameResolver streamNameResolver,
PersistenceMode persistenceMode,
Func<TSnapshot, Task> applyState,
Func<Envelope<IEvent>, Task> applyEvent)
HandleSnapshot<TSnapshot> applyState,
HandleEvent applyEvent)
{
this.ownerKey = ownerKey;
this.ownerType = ownerType;
@ -94,7 +94,7 @@ namespace Squidex.Infrastructure.States
if (applyState != null && position >= 0)
{
await applyState(state);
applyState(state);
}
}
}
@ -116,9 +116,9 @@ namespace Squidex.Infrastructure.States
var parsedEvent = ParseKnownEvent(@event);
if (parsedEvent != null && applyEvent != null)
if (applyEvent != null && parsedEvent != null)
{
await applyEvent(parsedEvent);
applyEvent(parsedEvent);
}
}
}

15
src/Squidex.Infrastructure/States/Store.cs

@ -30,26 +30,31 @@ namespace Squidex.Infrastructure.States
this.streamNameResolver = streamNameResolver;
}
public IPersistence<TState> WithSnapshots<TState>(Type owner, TKey key, Func<TState, Task> applySnapshot)
public IPersistence WithEventSourcing(Type owner, TKey key, HandleEvent applyEvent)
{
return CreatePersistence(owner, key, applyEvent);
}
public IPersistence<TState> WithSnapshots<TState>(Type owner, TKey key, HandleSnapshot<TState> applySnapshot)
{
return CreatePersistence(owner, key, PersistenceMode.Snapshots, applySnapshot, null);
}
public IPersistence<TState> WithSnapshotsAndEventSourcing<TState>(Type owner, TKey key, Func<TState, Task> applySnapshot, Func<Envelope<IEvent>, Task> applyEvent)
public IPersistence<TState> WithSnapshotsAndEventSourcing<TState>(Type owner, TKey key, HandleSnapshot<TState> applySnapshot, HandleEvent applyEvent)
{
return CreatePersistence(owner, key, PersistenceMode.SnapshotsAndEventSourcing, applySnapshot, applyEvent);
}
public IPersistence WithEventSourcing(Type owner, TKey key, Func<Envelope<IEvent>, Task> applyEvent)
private IPersistence CreatePersistence(Type owner, TKey key, HandleEvent applyEvent)
{
Guard.NotNull(key, nameof(key));
var snapshotStore = GetSnapshotStore<object>();
var snapshotStore = GetSnapshotStore<None>();
return new Persistence<TKey>(key, owner, eventStore, eventDataFormatter, snapshotStore, streamNameResolver, applyEvent);
}
private IPersistence<TState> CreatePersistence<TState>(Type owner, TKey key, PersistenceMode mode, Func<TState, Task> applySnapshot, Func<Envelope<IEvent>, Task> applyEvent)
private IPersistence<TState> CreatePersistence<TState>(Type owner, TKey key, PersistenceMode mode, HandleSnapshot<TState> applySnapshot, HandleEvent applyEvent)
{
Guard.NotNull(key, nameof(key));

66
src/Squidex.Infrastructure/States/StoreExtensions.cs

@ -5,83 +5,31 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Infrastructure.States
{
public static class StoreExtensions
{
public static IPersistence WithEventSourcing<TOwner, TKey>(this IStore<TKey> store, TKey key, Func<Envelope<IEvent>, Task> applyEvent)
{
return store.WithEventSourcing(typeof(TOwner), key, applyEvent);
}
public static IPersistence<TState> WithSnapshots<TOwner, TState, TKey>(this IStore<TKey> store, TKey key, Func<TState, Task> applySnapshot)
{
return store.WithSnapshots(typeof(TOwner), key, applySnapshot);
}
public static IPersistence<TState> WithSnapshotsAndEventSourcing<TOwner, TState, TKey>(this IStore<TKey> store, TKey key, Func<TState, Task> applySnapshot, Func<Envelope<IEvent>, Task> applyEvent)
{
return store.WithSnapshotsAndEventSourcing(typeof(TOwner), key, applySnapshot, applyEvent);
}
public static IPersistence WithEventSourcing<TKey>(this IStore<TKey> store, Type owner, TKey key, Action<Envelope<IEvent>> applyEvent)
{
return store.WithEventSourcing(owner, key, applyEvent.ToAsync());
}
public static IPersistence<TState> WithSnapshots<TState, TKey>(this IStore<TKey> store, Type owner, TKey key, Action<TState> applySnapshot)
{
return store.WithSnapshots(owner, key, applySnapshot.ToAsync());
}
public static IPersistence<TState> WithSnapshotsAndEventSourcing<TState, TKey>(this IStore<TKey> store, Type owner, TKey key, Action<TState> applySnapshot, Action<Envelope<IEvent>> applyEvent)
{
return store.WithSnapshotsAndEventSourcing(owner, key, applySnapshot.ToAsync(), applyEvent.ToAsync());
}
public static IPersistence WithEventSourcing<TOwner, TKey>(this IStore<TKey> store, TKey key, Action<Envelope<IEvent>> applyEvent)
{
return store.WithEventSourcing(typeof(TOwner), key, applyEvent.ToAsync());
}
public static IPersistence<TState> WithSnapshots<TOwner, TState, TKey>(this IStore<TKey> store, TKey key, Action<TState> applySnapshot)
{
return store.WithSnapshots(typeof(TOwner), key, applySnapshot.ToAsync());
}
public static IPersistence<TState> WithSnapshotsAndEventSourcing<TOwner, TState, TKey>(this IStore<TKey> store, TKey key, Action<TState> applySnapshot, Action<Envelope<IEvent>> applyEvent)
{
return store.WithSnapshotsAndEventSourcing(typeof(TOwner), key, applySnapshot.ToAsync(), applyEvent.ToAsync());
}
public static Task WriteEventAsync<T>(IPersistence<T> persistence, Envelope<IEvent> @event)
public static Task WriteEventAsync<T>(this IPersistence<T> persistence, Envelope<IEvent> @event)
{
return persistence.WriteEventsAsync(new[] { @event });
}
public static Task WriteEventAsync<T>(IPersistence<T> persistence, IEvent @event)
{
return persistence.WriteEventsAsync(new[] { Envelope.Create(@event) });
}
public static Task ClearSnapshotsAsync<TKey, TState>(this IStore<TKey> store)
public static Task ClearSnapshotsAsync<TKey, TSnapshot>(this IStore<TKey> store)
{
return store.GetSnapshotStore<TState>().ClearAsync();
return store.GetSnapshotStore<TSnapshot>().ClearAsync();
}
public static Task RemoveSnapshotAsync<TKey, TState>(this IStore<TKey> store, TKey key)
public static Task RemoveSnapshotAsync<TKey, TSnapshot>(this IStore<TKey> store, TKey key)
{
return store.GetSnapshotStore<TState>().RemoveAsync(key);
return store.GetSnapshotStore<TSnapshot>().RemoveAsync(key);
}
public static async Task<TState> GetSnapshotAsync<TKey, TState>(this IStore<TKey> store, TKey key)
public static async Task<TSnapshot> GetSnapshotAsync<TKey, TSnapshot>(this IStore<TKey> store, TKey key)
{
var result = await store.GetSnapshotStore<TState>().ReadAsync(key);
var result = await store.GetSnapshotStore<TSnapshot>().ReadAsync(key);
return result.Value;
}

12
src/Squidex.Infrastructure/UsageTracking/BackgroundUsageTracker.cs

@ -167,16 +167,18 @@ namespace Squidex.Infrastructure.UsageTracking
return result;
}
public async Task<long> GetMonthlyCallsAsync(string key, DateTime date)
public Task<long> GetMonthlyCallsAsync(string key, DateTime date)
{
return GetPreviousCallsAsync(key, new DateTime(date.Year, date.Month, 1), date);
}
public async Task<long> GetPreviousCallsAsync(string key, DateTime fromDate, DateTime toDate)
{
key = GetKey(key);
ThrowIfDisposed();
var dateFrom = new DateTime(date.Year, date.Month, 1);
var dateTo = dateFrom.AddMonths(1).AddDays(-1);
var originalUsages = await usageRepository.QueryAsync(key, dateFrom, dateTo);
var originalUsages = await usageRepository.QueryAsync(key, fromDate, toDate);
return originalUsages.Sum(x => (long)x.Counters.Get(CounterTotalCalls));
}

18
src/Squidex.Infrastructure/UsageTracking/CachingUsageTracker.cs

@ -14,7 +14,7 @@ namespace Squidex.Infrastructure.UsageTracking
{
public sealed class CachingUsageTracker : CachingProviderBase, IUsageTracker
{
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(10);
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
private readonly IUsageTracker inner;
public CachingUsageTracker(IUsageTracker inner, IMemoryCache cache)
@ -43,7 +43,7 @@ namespace Squidex.Infrastructure.UsageTracking
{
Guard.NotNull(key, nameof(key));
var cacheKey = string.Concat(key, date);
var cacheKey = string.Join("$", "Usage", nameof(GetMonthlyCallsAsync), key, date);
return Cache.GetOrCreateAsync(cacheKey, entry =>
{
@ -52,5 +52,19 @@ namespace Squidex.Infrastructure.UsageTracking
return inner.GetMonthlyCallsAsync(key, date);
});
}
public Task<long> GetPreviousCallsAsync(string key, DateTime fromDate, DateTime toDate)
{
Guard.NotNull(key, nameof(key));
var cacheKey = string.Join("$", "Usage", nameof(GetPreviousCallsAsync), key, fromDate, toDate);
return Cache.GetOrCreateAsync(cacheKey, entry =>
{
entry.AbsoluteExpirationRelativeToNow = CacheDuration;
return inner.GetPreviousCallsAsync(key, fromDate, toDate);
});
}
}
}

2
src/Squidex.Infrastructure/UsageTracking/IUsageTracker.cs

@ -17,6 +17,8 @@ namespace Squidex.Infrastructure.UsageTracking
Task<long> GetMonthlyCallsAsync(string key, DateTime date);
Task<long> GetPreviousCallsAsync(string key, DateTime fromDate, DateTime toDate);
Task<IReadOnlyDictionary<string, IReadOnlyList<DateUsage>>> QueryAsync(string key, DateTime fromDate, DateTime toDate);
}
}

5
src/Squidex/Areas/Api/Controllers/Apps/AppContributorsController.cs

@ -75,14 +75,13 @@ namespace Squidex.Areas.Api.Controllers.Apps
var command = request.ToCommand();
var context = await CommandBus.PublishAsync(command);
var result = context.Result<object>();
var response = (ContributorAssignedDto)null;
if (result is EntityCreatedResult<string> idOrValue)
if (context.PlainResult is EntityCreatedResult<string> idOrValue)
{
response = ContributorAssignedDto.FromId(idOrValue.IdOrValue, false);
}
else if (result is InvitedResult invited)
else if (context.PlainResult is InvitedResult invited)
{
response = ContributorAssignedDto.FromId(invited.Id.IdOrValue, true);
}

2
src/Squidex/Areas/Api/Controllers/Plans/AppPlansController.cs

@ -80,7 +80,7 @@ namespace Squidex.Areas.Api.Controllers.Plans
string redirectUri = null;
if (context.Result<object>() is RedirectToCheckoutResult result)
if (context.PlainResult is RedirectToCheckoutResult result)
{
redirectUri = result.Url.ToString();
}

5
src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleTriggerDtoFactory.cs

@ -31,6 +31,11 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models.Converters
return SimpleMapper.Map(trigger, new AssetChangedRuleTriggerDto());
}
public RuleTriggerDto Visit(UsageTrigger trigger)
{
return SimpleMapper.Map(trigger, new UsageRuleTriggerDto());
}
public RuleTriggerDto Visit(ContentChangedTriggerV2 trigger)
{
var schemas = trigger.Schemas.Select(x => SimpleMapper.Map(x, new ContentChangedRuleTriggerSchemaDto())).ToArray();

33
src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/UsageRuleTriggerDto.cs

@ -0,0 +1,33 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers
{
public sealed class UsageRuleTriggerDto : RuleTriggerDto
{
/// <summary>
/// The number of monthly api calls.
/// </summary>
public int Limit { get; set; }
/// <summary>
/// The number of days to check or null for the current month.
/// </summary>
[Range(1, 30)]
public int? NumDays { get; set; }
public override RuleTrigger ToTrigger()
{
return SimpleMapper.Map(this, new UsageTrigger());
}
}
}

4
src/Squidex/Config/Domain/EntitiesServices.cs

@ -35,6 +35,7 @@ using Squidex.Domain.Apps.Entities.History;
using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Domain.Apps.Entities.Rules.Commands;
using Squidex.Domain.Apps.Entities.Rules.Indexes;
using Squidex.Domain.Apps.Entities.Rules.UsageTracking;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.Schemas.Commands;
using Squidex.Domain.Apps.Entities.Schemas.Indexes;
@ -207,6 +208,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<CreateProfileCommandMiddleware>()
.As<ICommandMiddleware>();
services.AddSingletonAs<UsageTrackerCommandMiddleware>()
.As<ICommandMiddleware>();
}
private static void AddBackupHandlers(this IServiceCollection services)

7
src/Squidex/Config/Domain/RuleServices.cs

@ -7,8 +7,10 @@
using Microsoft.Extensions.DependencyInjection;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.HandleRules.Triggers;
using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Domain.Apps.Entities.Rules.UsageTracking;
using Squidex.Extensions.Actions;
using Squidex.Infrastructure.EventSourcing;
@ -27,6 +29,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<ContentChangedTriggerHandler>()
.As<IRuleTriggerHandler>();
services.AddSingletonAs<UsageTriggerHandler>()
.As<IRuleTriggerHandler>();
services.AddSingletonAs<RuleEnqueuer>()
.As<IEventConsumer>();

2
src/Squidex/Config/Orleans/SiloWrapper.cs

@ -18,6 +18,7 @@ using Orleans.Configuration;
using Orleans.Hosting;
using Squidex.Domain.Apps.Entities.Contents;
using Squidex.Domain.Apps.Entities.Rules;
using Squidex.Domain.Apps.Entities.Rules.UsageTracking;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing.Grains;
using Squidex.Infrastructure.Log;
@ -68,6 +69,7 @@ namespace Squidex.Config.Orleans
.AddStartupTask<Bootstrap<IContentSchedulerGrain>>()
.AddStartupTask<Bootstrap<IEventConsumerManagerGrain>>()
.AddStartupTask<Bootstrap<IRuleDequeuerGrain>>()
.AddStartupTask<Bootstrap<IUsageTrackerGrain>>()
.Configure<ClusterOptions>(options =>
{
options.Configure();

2
src/Squidex/Pipeline/CommandMiddlewares/ETagCommandMiddleware.cs

@ -54,7 +54,7 @@ namespace Squidex.Pipeline.CommandMiddlewares
await next();
if (context.Result<object>() is EntitySavedResult result)
if (context.PlainResult is EntitySavedResult result)
{
httpContextAccessor.HttpContext.Response.Headers[HeaderNames.ETag] = result.Version.ToString();
}

1
src/Squidex/app/features/rules/declarations.ts

@ -19,6 +19,7 @@ export * from './pages/rules/actions/webhook-action.component';
export * from './pages/rules/triggers/asset-changed-trigger.component';
export * from './pages/rules/triggers/content-changed-trigger.component';
export * from './pages/rules/triggers/usage-trigger.component';
export * from './pages/rules/rule-element.component';
export * from './pages/rules/rule-wizard.component';

2
src/Squidex/app/features/rules/module.ts

@ -32,6 +32,7 @@ import {
RuleWizardComponent,
SlackActionComponent,
TweetActionComponent,
UsageTriggerComponent,
WebhookActionComponent
} from './declarations';
@ -79,6 +80,7 @@ const routes: Routes = [
RuleWizardComponent,
SlackActionComponent,
TweetActionComponent,
UsageTriggerComponent,
WebhookActionComponent
]
})

7
src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html

@ -49,6 +49,13 @@
[triggerFormSubmitted]="triggerForm.submitted | async">
</sqx-content-changed-trigger>
</ng-container>
<ng-container *ngSwitchCase="'Usage'">
<sqx-usage-trigger
[trigger]="trigger"
[triggerForm]="triggerForm.form"
[triggerFormSubmitted]="triggerForm.submitted | async">
</sqx-usage-trigger>
</ng-container>
</ng-container>
</form>
</ng-container>

25
src/Squidex/app/features/rules/pages/rules/triggers/usage-trigger.component.html

@ -0,0 +1,25 @@
<div [formGroup]="triggerForm" class="form-horizontal">
<div class="form-group">
<label for="condition">Limit</label>
<sqx-control-errors for="limit" [submitted]="triggerFormSubmitted"></sqx-control-errors>
<input type="number" step="1" class="form-control" id="limit" formControlName="limit" />
<small class="form-text text-muted">
The monthly api calls to trigger.
</small>
</div>
<div class="form-group">
<label for="condition">Days</label>
<sqx-control-errors for="numDays" fieldName="Days" [submitted]="triggerFormSubmitted"></sqx-control-errors>
<input type="number" step="1" class="form-control" id="numDays" formControlName="numDays" />
<small class="form-text text-muted">
The number of days to check or empty to check the current month.
</small>
</div>
</div>

6
src/Squidex/app/features/rules/pages/rules/triggers/usage-trigger.component.scss

@ -0,0 +1,6 @@
@import '_vars';
@import '_mixins';
textarea {
height: 100px;
}

39
src/Squidex/app/features/rules/pages/rules/triggers/usage-trigger.component.ts

@ -0,0 +1,39 @@
/*
* Squidex Headless CMS
*
* @license
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
*/
import { Component, Input, OnInit } from '@angular/core';
import { FormControl, FormGroup, Validators } from '@angular/forms';
import { ValidatorsEx } from '@app/shared';
@Component({
selector: 'sqx-usage-trigger',
styleUrls: ['./usage-trigger.component.scss'],
templateUrl: './usage-trigger.component.html'
})
export class UsageTriggerComponent implements OnInit {
@Input()
public trigger: any;
@Input()
public triggerForm: FormGroup;
@Input()
public triggerFormSubmitted = false;
public ngOnInit() {
this.triggerForm.setControl('limit',
new FormControl(this.trigger.limit || 20000, [
Validators.required
]));
this.triggerForm.setControl('numDays',
new FormControl(this.trigger.numDays, [
ValidatorsEx.between(1, 30)
]));
}
}

4
src/Squidex/app/features/schemas/pages/schema/schema-preview-urls-form.component.html

@ -33,13 +33,13 @@
<div class="form-group row no-gutters" [formGroup]="addForm.form">
<div class="col col-name pr-1">
<sqx-control-errors for="name" fieldName="Name" [submitted]="addForm.submitted | async"></sqx-control-errors>
<sqx-control-errors for="name" [submitted]="addForm.submitted | async"></sqx-control-errors>
<input type="text" class="form-control" maxlength="1000" formControlName="name" placeholder="Name, e.g. Web or Mobile" />
</div>
<div class="col pr-1">
<sqx-control-errors for="url" fieldName="Url" [submitted]="addForm.submitted | async"></sqx-control-errors>
<sqx-control-errors for="url" [submitted]="addForm.submitted | async"></sqx-control-errors>
<input type="text" class="form-control" maxlength="1000" formControlName="url" placeholder="Url" />

4
src/Squidex/app/framework/angular/forms/control-errors.component.ts

@ -15,8 +15,8 @@ const DEFAULT_ERRORS: { [key: string]: string } = {
required: '{field} is required.',
pattern: '{field} does not follow the pattern.',
patternmessage: '{message}',
minvalue: '{field} must be larger than {minValue}.',
maxvalue: '{field} must be smaller than {maxValue}.',
minvalue: '{field} must be larger or equals to {minValue}.',
maxvalue: '{field} must be smaller or equals to {maxValue}.',
minmax: '{field} must have a length of more than {requiredLength}.',
maxlength: '{field} must have a length of less than {requiredLength}.',
match: '{message}',

16
src/Squidex/app/framework/angular/forms/validators.spec.ts

@ -26,6 +26,22 @@ describe('ValidatorsEx.between', () => {
expect(error).toBeNull();
});
it('should return null when value is null', () => {
const input = new FormControl(null);
const error = <any>ValidatorsEx.between(1, 5)(input);
expect(error).toBeNull();
});
it('should return null when value is undefined', () => {
const input = new FormControl(undefined);
const error = <any>ValidatorsEx.between(1, 5)(input);
expect(error).toBeNull();
});
it('should return error when not a number', () => {
const input = new FormControl('text');

14
src/Squidex/app/framework/angular/forms/validators.ts

@ -97,12 +97,14 @@ export module ValidatorsEx {
return (control: AbstractControl) => {
const value: number = control.value;
if (!Types.isNumber(value)) {
return { validnumber: false };
} else if (minValue && value < minValue) {
return { minvalue: { minValue, actualValue: value } };
} else if (maxValue && value > maxValue) {
return { maxvalue: { maxValue, actualValue: value } };
if (!Types.isUndefined(value) && !Types.isNull(value)) {
if (!Types.isNumber(value)) {
return { validnumber: false };
} else if (minValue && value < minValue) {
return { minvalue: { minValue, actualValue: value } };
} else if (maxValue && value > maxValue) {
return { maxvalue: { maxValue, actualValue: value } };
}
}
return null;

2
tests/Squidex.Domain.Apps.Core.Tests/Operations/EnrichContent/ContentEnrichmentTests.cs

@ -193,7 +193,7 @@ namespace Squidex.Domain.Apps.Core.Operations.EnrichContent
private static Instant FutureDays(int days)
{
return Instant.FromDateTimeUtc(DateTime.UtcNow.Date.AddDays(days));
return SystemClock.Instance.GetCurrentInstant().WithoutMs().Plus(Duration.FromDays(days));
}
}
}

4
tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs

@ -105,9 +105,9 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
[InlineData("Script(`Date: ${formatDate(event.timestamp, 'yyyy-MM-dd')}, Full: ${formatDate(event.timestamp, 'yyyy-MM-dd-hh-mm-ss')}`)")]
public void Should_replace_timestamp_information_from_event(string script)
{
var now = DateTime.UtcNow;
var now = SystemClock.Instance.GetCurrentInstant();
var envelope = new EnrichedContentEvent { Timestamp = Instant.FromDateTimeUtc(now) };
var envelope = new EnrichedContentEvent { Timestamp = now };
var result = sut.Format(script, envelope);

142
tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs

@ -33,6 +33,9 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
private readonly string actionDump = "MyDump";
private readonly string actionName = "ValidAction";
private readonly string actionDescription = "MyDescription";
private readonly Guid ruleId = Guid.NewGuid();
private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app");
private readonly NamedId<Guid> schemaId = NamedId.Of(Guid.NewGuid(), "my-schema");
private readonly TypeNameRegistry typeNameRegistry = new TypeNameRegistry();
private readonly RuleService sut;
@ -46,7 +49,6 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
public sealed class ValidAction : RuleAction
{
public int Value { get; set; }
}
public sealed class ValidData
@ -67,8 +69,8 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
typeNameRegistry.Map(typeof(ContentCreated));
typeNameRegistry.Map(typeof(ValidAction), actionName);
A.CallTo(() => eventEnricher.EnrichAsync(A<Envelope<AppEvent>>.Ignored))
.Returns(new EnrichedContentEvent());
A.CallTo(() => clock.GetCurrentInstant())
.Returns(SystemClock.Instance.GetCurrentInstant().WithoutMs());
A.CallTo(() => ruleActionHandler.ActionType)
.Returns(typeof(ValidAction));
@ -83,146 +85,153 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
}
[Fact]
public async Task Should_not_create_if_rule_disabled()
public async Task Should_not_create_job_if_rule_disabled()
{
var ruleConfig = ValidRule().Disable();
var ruleEnvelope = Envelope.Create(new ContentCreated());
var @event = Envelope.Create(new ContentCreated());
var job = await sut.CreateJobAsync(ruleConfig, ruleEnvelope);
var job = await sut.CreateJobAsync(ValidRule().Disable(), ruleId, @event);
Assert.Null(job);
A.CallTo(() => eventEnricher.EnrichAsync(A<Envelope<AppEvent>>.Ignored))
A.CallTo(() => ruleTriggerHandler.Trigger(A<AppEvent>.Ignored, A<RuleTrigger>.Ignored, ruleId))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_not_create_job_for_invalid_event()
{
var ruleConfig = ValidRule();
var ruleEnvelope = Envelope.Create(new InvalidEvent());
var @event = Envelope.Create(new InvalidEvent());
var job = await sut.CreateJobAsync(ruleConfig, ruleEnvelope);
var job = await sut.CreateJobAsync(ValidRule(), ruleId, @event);
Assert.Null(job);
A.CallTo(() => eventEnricher.EnrichAsync(A<Envelope<AppEvent>>.Ignored))
A.CallTo(() => ruleTriggerHandler.Trigger(A<AppEvent>.Ignored, A<RuleTrigger>.Ignored, ruleId))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_not_create_job_if_no_trigger_handler_registered()
{
var ruleConfig = new Rule(new InvalidTrigger(), new ValidAction());
var ruleEnvelope = Envelope.Create(new ContentCreated());
var @event = Envelope.Create(new ContentCreated());
var job = await sut.CreateJobAsync(ruleConfig, ruleEnvelope);
var job = await sut.CreateJobAsync(RuleInvalidTrigger(), ruleId, @event);
Assert.Null(job);
A.CallTo(() => eventEnricher.EnrichAsync(A<Envelope<AppEvent>>.Ignored))
A.CallTo(() => ruleTriggerHandler.Trigger(A<AppEvent>.Ignored, A<RuleTrigger>.Ignored, ruleId))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_not_create_job_if_no_action_handler_registered()
{
var ruleConfig = new Rule(new ContentChangedTriggerV2(), new InvalidAction());
var ruleEnvelope = Envelope.Create(new ContentCreated());
var @event = Envelope.Create(new ContentCreated());
var job = await sut.CreateJobAsync(ruleConfig, ruleEnvelope);
var job = await sut.CreateJobAsync(RuleInvalidAction(), ruleId, @event);
Assert.Null(job);
A.CallTo(() => eventEnricher.EnrichAsync(A<Envelope<AppEvent>>.Ignored))
A.CallTo(() => ruleTriggerHandler.Trigger(A<AppEvent>.Ignored, A<RuleTrigger>.Ignored, ruleId))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_not_create_job_if_too_old()
{
var @event = Envelope.Create(new ContentCreated()).SetTimestamp(clock.GetCurrentInstant().Minus(Duration.FromDays(3)));
var job = await sut.CreateJobAsync(ValidRule(), ruleId, @event);
Assert.Null(job);
A.CallTo(() => ruleTriggerHandler.Trigger(A<AppEvent>.Ignored, A<RuleTrigger>.Ignored, ruleId))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_not_create_job_if_not_triggered_with_precheck()
{
var ruleConfig = ValidRule();
var ruleEnvelope = Envelope.Create(new ContentCreated());
var rule = ValidRule();
var @event = Envelope.Create(new ContentCreated());
A.CallTo(() => ruleTriggerHandler.Triggers(A<IEvent>.Ignored, ruleConfig.Trigger))
A.CallTo(() => ruleTriggerHandler.Trigger(@event.Payload, rule.Trigger, ruleId))
.Returns(false);
var job = await sut.CreateJobAsync(ruleConfig, ruleEnvelope);
var job = await sut.CreateJobAsync(rule, ruleId, @event);
Assert.Null(job);
A.CallTo(() => eventEnricher.EnrichAsync(A<Envelope<AppEvent>>.Ignored))
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventAsync(A<Envelope<AppEvent>>.Ignored))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_not_create_job_if_not_triggered()
public async Task Should_not_create_job_if_enriched_event_not_created()
{
var ruleConfig = ValidRule();
var ruleEnvelope = Envelope.Create(new ContentCreated());
var rule = ValidRule();
var @event = Envelope.Create(new ContentCreated());
A.CallTo(() => ruleTriggerHandler.Triggers(A<IEvent>.Ignored, ruleConfig.Trigger))
A.CallTo(() => ruleTriggerHandler.Trigger(@event.Payload, rule.Trigger, ruleId))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.Triggers(A<EnrichedEvent>.Ignored, ruleConfig.Trigger))
.Returns(false);
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventAsync(A<Envelope<AppEvent>>.That.Matches(x => x.Payload == @event.Payload)))
.Returns(Task.FromResult<EnrichedEvent>(null));
var job = await sut.CreateJobAsync(ruleConfig, ruleEnvelope);
var job = await sut.CreateJobAsync(rule, ruleId, @event);
Assert.Null(job);
}
[Fact]
public async Task Should_not_create_job_if_too_old()
public async Task Should_not_create_job_if_not_triggered()
{
var @event = new ContentCreated { SchemaId = NamedId.Of(Guid.NewGuid(), "my-schema"), AppId = NamedId.Of(Guid.NewGuid(), "my-event") };
var rule = ValidRule();
var now = SystemClock.Instance.GetCurrentInstant();
var enrichedEvent = new EnrichedContentEvent { AppId = appId };
var ruleConfig = ValidRule();
var ruleEnvelope = Envelope.Create(@event);
var @event = Envelope.Create(new ContentCreated());
ruleEnvelope.SetTimestamp(now.Minus(Duration.FromDays(3)));
A.CallTo(() => ruleTriggerHandler.Trigger(@event.Payload, rule.Trigger, ruleId))
.Returns(true);
A.CallTo(() => clock.GetCurrentInstant())
.Returns(now);
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventAsync(A<Envelope<AppEvent>>.That.Matches(x => x.Payload == @event.Payload)))
.Returns(enrichedEvent);
A.CallTo(() => ruleActionHandler.CreateJobAsync(A<EnrichedEvent>.Ignored, ruleConfig.Action))
.Returns((actionDescription, actionData));
A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent, rule.Trigger))
.Returns(false);
var job = await sut.CreateJobAsync(ruleConfig, ruleEnvelope);
var job = await sut.CreateJobAsync(rule, ruleId, @event);
Assert.Null(job);
A.CallTo(() => eventEnricher.EnrichAsync(A<Envelope<AppEvent>>.Ignored))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_create_job_if_triggered()
{
var @event = new ContentCreated { SchemaId = NamedId.Of(Guid.NewGuid(), "my-schema"), AppId = NamedId.Of(Guid.NewGuid(), "my-event") };
var now = Instant.FromUnixTimeSeconds(SystemClock.Instance.GetCurrentInstant().ToUnixTimeSeconds());
var now = clock.GetCurrentInstant();
var ruleConfig = ValidRule();
var ruleEnvelope = Envelope.Create(@event);
var rule = ValidRule();
ruleEnvelope.SetTimestamp(now);
var enrichedEvent = new EnrichedContentEvent { AppId = appId };
A.CallTo(() => clock.GetCurrentInstant())
.Returns(now);
var @event = Envelope.Create(new ContentCreated()).SetTimestamp(now);
A.CallTo(() => ruleTriggerHandler.Triggers(A<EnrichedEvent>.Ignored, ruleConfig.Trigger))
A.CallTo(() => ruleTriggerHandler.Trigger(@event.Payload, rule.Trigger, ruleId))
.Returns(true);
A.CallTo(() => ruleTriggerHandler.Triggers(A<IEvent>.Ignored, ruleConfig.Trigger))
A.CallTo(() => ruleTriggerHandler.Trigger(enrichedEvent, rule.Trigger))
.Returns(true);
A.CallTo(() => ruleActionHandler.CreateJobAsync(A<EnrichedEvent>.Ignored, ruleConfig.Action))
A.CallTo(() => ruleTriggerHandler.CreateEnrichedEventAsync(A<Envelope<AppEvent>>.That.Matches(x => x.Payload == @event.Payload)))
.Returns(enrichedEvent);
A.CallTo(() => ruleActionHandler.CreateJobAsync(A<EnrichedEvent>.Ignored, rule.Action))
.Returns((actionDescription, new ValidData { Value = 10 }));
var job = await sut.CreateJobAsync(ruleConfig, ruleEnvelope);
var job = await sut.CreateJobAsync(rule, ruleId, @event);
Assert.Equal(actionData, job.ActionData);
Assert.Equal(actionName, job.ActionName);
@ -231,9 +240,12 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
Assert.Equal(now, job.Created);
Assert.Equal(now.Plus(Duration.FromDays(2)), job.Expires);
Assert.Equal(@event.AppId.Id, job.AppId);
Assert.Equal(enrichedEvent.AppId.Id, job.AppId);
Assert.NotEqual(Guid.Empty, job.JobId);
A.CallTo(() => eventEnricher.EnrichAsync(enrichedEvent, A<Envelope<AppEvent>>.That.Matches(x => x.Payload == @event.Payload)))
.MustHaveHappened();
}
[Fact]
@ -247,7 +259,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
Assert.Equal(RuleResult.Success, result.Result);
Assert.True(result.Elapsed >= TimeSpan.Zero);
Assert.StartsWith(actionDump, result.Dump, StringComparison.OrdinalIgnoreCase);
Assert.True(result.Dump.StartsWith(actionDump, StringComparison.OrdinalIgnoreCase));
}
[Fact]
@ -293,6 +305,16 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
Assert.Equal((ruleError.ToString(), RuleResult.Failed, TimeSpan.Zero), result);
}
private static Rule RuleInvalidAction()
{
return new Rule(new ContentChangedTriggerV2(), new InvalidAction());
}
private static Rule RuleInvalidTrigger()
{
return new Rule(new InvalidTrigger(), new ValidAction());
}
private static Rule ValidRule()
{
return new Rule(new ContentChangedTriggerV2(), new ValidAction());

4
tests/Squidex.Domain.Apps.Core.Tests/Operations/ValidateContent/DateTimeFieldTests.cs

@ -5,12 +5,12 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using FluentAssertions;
using NodaTime;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Json.Objects;
using Xunit;
@ -95,7 +95,7 @@ namespace Squidex.Domain.Apps.Core.Operations.ValidateContent
private static Instant FutureDays(int days)
{
return Instant.FromDateTimeUtc(DateTime.UtcNow.Date.AddDays(days));
return SystemClock.Instance.GetCurrentInstant().WithoutMs().Plus(Duration.FromDays(days));
}
private static IJsonValue CreateValue(Instant v)

2
tests/Squidex.Domain.Apps.Entities.Tests/Apps/AppUISettingsGrainTests.cs

@ -23,7 +23,7 @@ namespace Squidex.Domain.Apps.Entities.Apps
public AppUISettingsGrainTests()
{
A.CallTo(() => store.WithSnapshots(A<Type>.Ignored, A<Guid>.Ignored, A<Func<AppUISettingsGrain.GrainState, Task>>.Ignored))
A.CallTo(() => store.WithSnapshots(typeof(AppUISettingsGrain), Guid.Empty, A<HandleSnapshot<AppUISettingsGrain.GrainState>>.Ignored))
.Returns(persistence);
sut = new AppUISettingsGrain(store);

2
tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByNameIndexGrainTests.cs

@ -27,7 +27,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
public AppsByNameIndexGrainTests()
{
A.CallTo(() => store.WithSnapshots(A<Type>.Ignored, A<string>.Ignored, A<Func<AppsByNameIndexGrain.GrainState, Task>>.Ignored))
A.CallTo(() => store.WithSnapshots(typeof(AppsByNameIndexGrain), SingleGrain.Id, A<HandleSnapshot<AppsByNameIndexGrain.GrainState>>.Ignored))
.Returns(persistence);
sut = new AppsByNameIndexGrain(store);

5
tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsByUserIndexGrainTests.cs

@ -21,15 +21,16 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
private readonly IPersistence<AppsByUserIndexGrain.GrainState> persistence = A.Fake<IPersistence<AppsByUserIndexGrain.GrainState>>();
private readonly Guid appId1 = Guid.NewGuid();
private readonly Guid appId2 = Guid.NewGuid();
private readonly string userId = "user";
private readonly AppsByUserIndexGrain sut;
public AppsByUserIndexGrainTests()
{
A.CallTo(() => store.WithSnapshots(A<Type>.Ignored, A<string>.Ignored, A<Func<AppsByUserIndexGrain.GrainState, Task>>.Ignored))
A.CallTo(() => store.WithSnapshots(typeof(AppsByUserIndexGrain), userId, A<HandleSnapshot<AppsByUserIndexGrain.GrainState>>.Ignored))
.Returns(persistence);
sut = new AppsByUserIndexGrain(store);
sut.ActivateAsync("user").Wait();
sut.ActivateAsync(userId).Wait();
}
[Fact]

59
tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/Triggers/AssetChangedTriggerTests.cs → tests/Squidex.Domain.Apps.Entities.Tests/Assets/AssetChangedTriggerHandlerTests.cs

@ -6,32 +6,67 @@
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using FakeItEasy;
using Orleans;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents;
using Squidex.Domain.Apps.Core.HandleRules.Triggers;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Assets;
using Squidex.Domain.Apps.Events.Contents;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Orleans;
using Xunit;
namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
#pragma warning disable SA1401 // Fields must be private
namespace Squidex.Domain.Apps.Entities.Assets
{
public class AssetChangedTriggerTests
public class AssetChangedTriggerHandlerTests
{
private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>();
private readonly IGrainFactory grainFactory = A.Fake<IGrainFactory>();
private readonly IRuleTriggerHandler sut;
public AssetChangedTriggerTests()
public AssetChangedTriggerHandlerTests()
{
sut = new AssetChangedTriggerHandler(scriptEngine);
A.CallTo(() => scriptEngine.Evaluate("event", A<object>.Ignored, "true"))
.Returns(true);
A.CallTo(() => scriptEngine.Evaluate("event", A<object>.Ignored, "false"))
.Returns(false);
sut = new AssetChangedTriggerHandler(scriptEngine, grainFactory);
}
public static IEnumerable<object[]> TestEvents = new[]
{
new object[] { new AssetCreated(), EnrichedAssetEventType.Created },
new object[] { new AssetUpdated(), EnrichedAssetEventType.Updated },
new object[] { new AssetRenamed(), EnrichedAssetEventType.Renamed },
new object[] { new AssetDeleted(), EnrichedAssetEventType.Deleted }
};
[Theory]
[MemberData(nameof(TestEvents))]
public async Task Should_enrich_events(AssetEvent @event, EnrichedAssetEventType type)
{
var envelope = Envelope.Create<AppEvent>(@event).SetEventStreamNumber(12);
var assetGrain = A.Fake<IAssetGrain>();
A.CallTo(() => grainFactory.GetGrain<IAssetGrain>(@event.AssetId, null))
.Returns(assetGrain);
A.CallTo(() => assetGrain.GetStateAsync(12))
.Returns(A.Fake<IAssetEntity>().AsJ());
var result = await sut.CreateEnrichedEventAsync(envelope);
Assert.Equal(type, ((EnrichedAssetEvent)result).Type);
}
[Fact]
@ -39,7 +74,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
{
TestForCondition(string.Empty, trigger =>
{
var result = sut.Triggers(new ContentCreated(), trigger);
var result = sut.Trigger(new ContentCreated(), trigger, Guid.NewGuid());
Assert.False(result);
});
@ -50,7 +85,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
{
TestForCondition(string.Empty, trigger =>
{
var result = sut.Triggers(new AssetCreated(), trigger);
var result = sut.Trigger(new AssetCreated(), trigger, Guid.NewGuid());
Assert.True(result);
});
@ -61,7 +96,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
{
TestForCondition(string.Empty, trigger =>
{
var result = sut.Triggers(new EnrichedContentEvent(), trigger);
var result = sut.Trigger(new EnrichedContentEvent(), trigger);
Assert.False(result);
});
@ -72,7 +107,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
{
TestForCondition(string.Empty, trigger =>
{
var result = sut.Triggers(new EnrichedAssetEvent(), trigger);
var result = sut.Trigger(new EnrichedAssetEvent(), trigger);
Assert.True(result);
});
@ -83,7 +118,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
{
TestForCondition("true", trigger =>
{
var result = sut.Triggers(new EnrichedAssetEvent(), trigger);
var result = sut.Trigger(new EnrichedAssetEvent(), trigger);
Assert.True(result);
});
@ -94,7 +129,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
{
TestForCondition("false", trigger =>
{
var result = sut.Triggers(new EnrichedAssetEvent(), trigger);
var result = sut.Trigger(new EnrichedAssetEvent(), trigger);
Assert.False(result);
});

18
tests/Squidex.Domain.Apps.Entities.Tests/Comments/Guards/GuardCommentsTests.cs

@ -46,7 +46,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards
var events = new List<Envelope<CommentsEvent>>
{
Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
};
ValidationAssert.Throws(() => GuardComments.CanUpdate(events, command),
@ -61,7 +61,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards
var events = new List<Envelope<CommentsEvent>>
{
Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
};
Assert.Throws<DomainException>(() => GuardComments.CanUpdate(events, command));
@ -86,8 +86,8 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards
var events = new List<Envelope<CommentsEvent>>
{
Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>(),
Envelope.Create(new CommentDeleted { CommentId = commentId }).To<CommentsEvent>()
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>(),
Envelope.Create<CommentsEvent>(new CommentDeleted { CommentId = commentId }).To<CommentsEvent>()
};
Assert.Throws<DomainObjectNotFoundException>(() => GuardComments.CanUpdate(events, command));
@ -101,7 +101,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards
var events = new List<Envelope<CommentsEvent>>
{
Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
};
GuardComments.CanUpdate(events, command);
@ -115,7 +115,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards
var events = new List<Envelope<CommentsEvent>>
{
Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
};
Assert.Throws<DomainException>(() => GuardComments.CanDelete(events, command));
@ -140,8 +140,8 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards
var events = new List<Envelope<CommentsEvent>>
{
Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>(),
Envelope.Create(new CommentDeleted { CommentId = commentId }).To<CommentsEvent>()
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }),
Envelope.Create<CommentsEvent>(new CommentDeleted { CommentId = commentId })
};
Assert.Throws<DomainObjectNotFoundException>(() => GuardComments.CanDelete(events, command));
@ -155,7 +155,7 @@ namespace Squidex.Domain.Apps.Entities.Comments.Guards
var events = new List<Envelope<CommentsEvent>>
{
Envelope.Create(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
Envelope.Create<CommentsEvent>(new CommentCreated { CommentId = commentId, Actor = user1 }).To<CommentsEvent>()
};
GuardComments.CanDelete(events, command);

73
tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/Triggers/ContentChangedTriggerTests.cs → tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentChangedTriggerHandlerTests.cs

@ -8,38 +8,75 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using FakeItEasy;
using Orleans;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents;
using Squidex.Domain.Apps.Core.HandleRules.Triggers;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Assets;
using Squidex.Domain.Apps.Events.Contents;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Orleans;
using Xunit;
#pragma warning disable SA1401 // Fields must be private
#pragma warning disable RECS0070
namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
namespace Squidex.Domain.Apps.Entities.Contents
{
public class ContentChangedTriggerTests
public class ContentChangedTriggerHandlerTests
{
private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>();
private readonly IGrainFactory grainFactory = A.Fake<IGrainFactory>();
private readonly IRuleTriggerHandler sut;
private readonly Guid ruleId = Guid.NewGuid();
private static readonly NamedId<Guid> SchemaMatch = NamedId.Of(Guid.NewGuid(), "my-schema1");
private static readonly NamedId<Guid> SchemaNonMatch = NamedId.Of(Guid.NewGuid(), "my-schema2");
public ContentChangedTriggerTests()
public ContentChangedTriggerHandlerTests()
{
sut = new ContentChangedTriggerHandler(scriptEngine);
A.CallTo(() => scriptEngine.Evaluate("event", A<object>.Ignored, "true"))
.Returns(true);
A.CallTo(() => scriptEngine.Evaluate("event", A<object>.Ignored, "false"))
.Returns(false);
sut = new ContentChangedTriggerHandler(scriptEngine, grainFactory);
}
public static IEnumerable<object[]> TestEvents = new[]
{
new object[] { new ContentCreated(), EnrichedContentEventType.Created },
new object[] { new ContentUpdated(), EnrichedContentEventType.Updated },
new object[] { new ContentDeleted(), EnrichedContentEventType.Deleted },
new object[] { new ContentStatusChanged { Change = StatusChange.Archived }, EnrichedContentEventType.Archived },
new object[] { new ContentStatusChanged { Change = StatusChange.Published }, EnrichedContentEventType.Published },
new object[] { new ContentStatusChanged { Change = StatusChange.Restored }, EnrichedContentEventType.Restored },
new object[] { new ContentStatusChanged { Change = StatusChange.Unpublished }, EnrichedContentEventType.Unpublished }
};
[Theory]
[MemberData(nameof(TestEvents))]
public async Task Should_enrich_events(ContentEvent @event, EnrichedContentEventType type)
{
var envelope = Envelope.Create<AppEvent>(@event).SetEventStreamNumber(12);
var contentGrain = A.Fake<IContentGrain>();
A.CallTo(() => grainFactory.GetGrain<IContentGrain>(@event.ContentId, null))
.Returns(contentGrain);
A.CallTo(() => contentGrain.GetStateAsync(12))
.Returns(A.Fake<IContentEntity>().AsJ());
var result = await sut.CreateEnrichedEventAsync(envelope);
Assert.Equal(type, ((EnrichedContentEvent)result).Type);
}
[Fact]
@ -47,7 +84,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
{
TestForTrigger(handleAll: true, schemaId: null, condition: null, action: trigger =>
{
var result = sut.Triggers(new AssetCreated(), trigger);
var result = sut.Trigger(new AssetCreated(), trigger, ruleId);
Assert.False(result);
});
@ -58,7 +95,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
{
TestForTrigger(handleAll: false, schemaId: null, condition: null, action: trigger =>
{
var result = sut.Triggers(new ContentCreated { SchemaId = SchemaMatch }, trigger);
var result = sut.Trigger(new ContentCreated { SchemaId = SchemaMatch }, trigger, ruleId);
Assert.False(result);
});
@ -69,7 +106,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
{
TestForTrigger(handleAll: true, schemaId: SchemaMatch, condition: null, action: trigger =>
{
var result = sut.Triggers(new ContentCreated { SchemaId = SchemaMatch }, trigger);
var result = sut.Trigger(new ContentCreated { SchemaId = SchemaMatch }, trigger, ruleId);
Assert.True(result);
});
@ -80,7 +117,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
{
TestForTrigger(handleAll: false, schemaId: SchemaMatch, condition: string.Empty, action: trigger =>
{
var result = sut.Triggers(new ContentCreated { SchemaId = SchemaMatch }, trigger);
var result = sut.Trigger(new ContentCreated { SchemaId = SchemaMatch }, trigger, ruleId);
Assert.True(result);
});
@ -91,7 +128,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
{
TestForTrigger(handleAll: false, schemaId: SchemaNonMatch, condition: null, action: trigger =>
{
var result = sut.Triggers(new ContentCreated { SchemaId = SchemaMatch }, trigger);
var result = sut.Trigger(new ContentCreated { SchemaId = SchemaMatch }, trigger, ruleId);
Assert.False(result);
});
@ -102,7 +139,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
{
TestForTrigger(handleAll: true, schemaId: null, condition: null, action: trigger =>
{
var result = sut.Triggers(new EnrichedAssetEvent(), trigger);
var result = sut.Trigger(new EnrichedAssetEvent(), trigger);
Assert.False(result);
});
@ -113,7 +150,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
{
TestForTrigger(handleAll: false, schemaId: null, condition: null, action: trigger =>
{
var result = sut.Triggers(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger);
var result = sut.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger);
Assert.False(result);
});
@ -124,7 +161,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
{
TestForTrigger(handleAll: true, schemaId: SchemaMatch, condition: null, action: trigger =>
{
var result = sut.Triggers(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger);
var result = sut.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger);
Assert.True(result);
});
@ -135,7 +172,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
{
TestForTrigger(handleAll: false, schemaId: SchemaMatch, condition: string.Empty, action: trigger =>
{
var result = sut.Triggers(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger);
var result = sut.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger);
Assert.True(result);
});
@ -146,7 +183,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
{
TestForTrigger(handleAll: false, schemaId: SchemaMatch, condition: "true", action: trigger =>
{
var result = sut.Triggers(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger);
var result = sut.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger);
Assert.True(result);
});
@ -157,7 +194,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
{
TestForTrigger(handleAll: false, schemaId: SchemaNonMatch, condition: null, action: trigger =>
{
var result = sut.Triggers(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger);
var result = sut.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger);
Assert.False(result);
});
@ -168,7 +205,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
{
TestForTrigger(handleAll: false, schemaId: SchemaMatch, condition: "false", action: trigger =>
{
var result = sut.Triggers(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger);
var result = sut.Trigger(new EnrichedContentEvent { SchemaId = SchemaMatch }, trigger);
Assert.False(result);
});

6
tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs

@ -12,7 +12,7 @@ using FakeItEasy;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using NodaTime.Extensions;
using NodaTime;
using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Apps;
using Squidex.Domain.Apps.Core.Contents;
@ -99,7 +99,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
protected static IContentEntity CreateContent(Guid id, Guid refId, Guid assetId, NamedContentData data = null)
{
var now = DateTime.UtcNow.ToInstant();
var now = SystemClock.Instance.GetCurrentInstant();
data = data ??
new NamedContentData()
@ -159,7 +159,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
protected static IAssetEntity CreateAsset(Guid id)
{
var now = DateTime.UtcNow.ToInstant();
var now = SystemClock.Instance.GetCurrentInstant();
var asset = new FakeAssetEntity
{

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

@ -6,10 +6,13 @@
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using FakeItEasy;
using FluentAssertions;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Collections;
using Xunit;
@ -19,23 +22,46 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards.Triggers
{
private readonly IAppProvider appProvider = A.Fake<IAppProvider>();
private readonly Guid appId = Guid.NewGuid();
private readonly Guid schemaId = Guid.NewGuid();
[Fact]
public async Task Should_add_error_if_schemas_ids_are_not_valid()
public async Task Should_add_error_if_schema_id_is_not_defined()
{
var trigger = new ContentChangedTriggerV2
{
Schemas = ReadOnlyCollection.Create(new ContentChangedTriggerSchemaV2())
};
var errors = await RuleTriggerValidator.ValidateAsync(appId, trigger, appProvider);
errors.Should().BeEquivalentTo(
new List<ValidationError>
{
new ValidationError("Schema id is required.", "Schemas")
});
A.CallTo(() => appProvider.GetSchemaAsync(appId, A<Guid>.Ignored, false))
.MustNotHaveHappened();
}
[Fact]
public async Task Should_add_error_if_schemas_ids_are_not_valid()
{
A.CallTo(() => appProvider.GetSchemaAsync(appId, schemaId, false))
.Returns(Task.FromResult<ISchemaEntity>(null));
var trigger = new ContentChangedTriggerV2
{
Schemas = ReadOnlyCollection.Create(
new ContentChangedTriggerSchemaV2()
)
Schemas = ReadOnlyCollection.Create(new ContentChangedTriggerSchemaV2 { SchemaId = schemaId })
};
var errors = await RuleTriggerValidator.ValidateAsync(appId, trigger, appProvider);
Assert.NotEmpty(errors);
errors.Should().BeEquivalentTo(
new List<ValidationError>
{
new ValidationError($"Schema {schemaId} does not exist.", "Schemas")
});
}
[Fact]
@ -69,9 +95,7 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards.Triggers
var trigger = new ContentChangedTriggerV2
{
Schemas = ReadOnlyCollection.Create(
new ContentChangedTriggerSchemaV2()
)
Schemas = ReadOnlyCollection.Create(new ContentChangedTriggerSchemaV2 { SchemaId = schemaId })
};
var errors = await RuleTriggerValidator.ValidateAsync(appId, trigger, appProvider);

72
tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/Triggers/UsageTriggerValidationTests.cs

@ -0,0 +1,72 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using FakeItEasy;
using FluentAssertions;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Infrastructure;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Rules.Guards.Triggers
{
public class UsageTriggerValidationTests
{
private readonly IAppProvider appProvider = A.Fake<IAppProvider>();
private readonly Guid appId = Guid.NewGuid();
[Fact]
public async Task Should_add_error_if_num_days_less_than_1()
{
var trigger = new UsageTrigger { NumDays = 0 };
var errors = await RuleTriggerValidator.ValidateAsync(appId, trigger, appProvider);
errors.Should().BeEquivalentTo(
new List<ValidationError>
{
new ValidationError("Num days must be between 1 and 30.", "NumDays")
});
}
[Fact]
public async Task Should_add_error_if_num_days_greater_than_30()
{
var trigger = new UsageTrigger { NumDays = 32 };
var errors = await RuleTriggerValidator.ValidateAsync(appId, trigger, appProvider);
errors.Should().BeEquivalentTo(
new List<ValidationError>
{
new ValidationError("Num days must be between 1 and 30.", "NumDays")
});
}
[Fact]
public async Task Should_not_add_error_if_num_days_is_valid()
{
var trigger = new UsageTrigger { NumDays = 20 };
var errors = await RuleTriggerValidator.ValidateAsync(appId, trigger, appProvider);
Assert.Empty(errors);
}
[Fact]
public async Task Should_not_add_error_if_num_days_is_not_defined()
{
var trigger = new UsageTrigger();
var errors = await RuleTriggerValidator.ValidateAsync(appId, trigger, appProvider);
Assert.Empty(errors);
}
}
}

5
tests/Squidex.Domain.Apps.Entities.Tests/Rules/Indexes/RulesByAppIndexGrainTests.cs

@ -18,17 +18,18 @@ namespace Squidex.Domain.Apps.Entities.Rules.Indexes
{
private readonly IStore<Guid> store = A.Fake<IStore<Guid>>();
private readonly IPersistence<RulesByAppIndexGrain.GrainState> persistence = A.Fake<IPersistence<RulesByAppIndexGrain.GrainState>>();
private readonly Guid appId = Guid.NewGuid();
private readonly Guid ruleId1 = Guid.NewGuid();
private readonly Guid ruleId2 = Guid.NewGuid();
private readonly RulesByAppIndexGrain sut;
public RulesByAppIndexGrainTests()
{
A.CallTo(() => store.WithSnapshots(A<Type>.Ignored, A<Guid>.Ignored, A<Func<RulesByAppIndexGrain.GrainState, Task>>.Ignored))
A.CallTo(() => store.WithSnapshots(typeof(RulesByAppIndexGrain), appId, A<HandleSnapshot<RulesByAppIndexGrain.GrainState>>.Ignored))
.Returns(persistence);
sut = new RulesByAppIndexGrain(store);
sut.ActivateAsync(Guid.NewGuid()).Wait();
sut.ActivateAsync(appId).Wait();
}
[Fact]

17
tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleDequeuerTests.cs

@ -12,6 +12,7 @@ using NodaTime;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Entities.Rules.Repositories;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
using Xunit;
@ -24,19 +25,15 @@ namespace Squidex.Domain.Apps.Entities.Rules
private readonly IClock clock = A.Fake<IClock>();
private readonly ISemanticLog log = A.Dummy<ISemanticLog>();
private readonly IRuleEventRepository ruleEventRepository = A.Fake<IRuleEventRepository>();
private readonly Instant now = SystemClock.Instance.GetCurrentInstant();
private readonly RuleService ruleService = A.Fake<RuleService>();
private readonly RuleDequeuerGrain sut;
public RuleDequeuerTests()
{
A.CallTo(() => clock.GetCurrentInstant()).Returns(now);
A.CallTo(() => clock.GetCurrentInstant())
.Returns(SystemClock.Instance.GetCurrentInstant().WithoutMs());
sut = new RuleDequeuerGrain(
ruleService,
ruleEventRepository,
log,
clock);
sut = new RuleDequeuerGrain(ruleService, ruleEventRepository, log, clock);
}
[Theory]
@ -63,7 +60,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
if (minutes > 0)
{
nextCall = now.Plus(Duration.FromMinutes(minutes));
nextCall = clock.GetCurrentInstant().Plus(Duration.FromMinutes(minutes));
}
await sut.HandleAsync(@event);
@ -81,12 +78,12 @@ namespace Squidex.Domain.Apps.Entities.Rules
JobId = Guid.NewGuid(),
ActionData = actionData,
ActionName = actionName,
Created = now
Created = clock.GetCurrentInstant()
};
A.CallTo(() => @event.Id).Returns(Guid.NewGuid());
A.CallTo(() => @event.Job).Returns(job);
A.CallTo(() => @event.Created).Returns(now);
A.CallTo(() => @event.Created).Returns(clock.GetCurrentInstant());
A.CallTo(() => @event.NumCalls).Returns(numCalls);
return @event;

20
tests/Squidex.Domain.Apps.Entities.Tests/Rules/RuleEnqueuerTests.cs

@ -28,9 +28,9 @@ namespace Squidex.Domain.Apps.Entities.Rules
private readonly IAppProvider appProvider = A.Fake<IAppProvider>();
private readonly IMemoryCache cache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
private readonly IRuleEventRepository ruleEventRepository = A.Fake<IRuleEventRepository>();
private readonly RuleService ruleService = A.Fake<RuleService>();
private readonly Instant now = SystemClock.Instance.GetCurrentInstant();
private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app");
private readonly RuleService ruleService = A.Fake<RuleService>();
private readonly RuleEnqueuer sut;
public sealed class TestAction : RuleAction
@ -68,42 +68,32 @@ namespace Squidex.Domain.Apps.Entities.Rules
[Fact]
public async Task Should_update_repositories_on_with_jobs_from_sender()
{
var @event = Envelope.Create(new ContentCreated { AppId = appId });
var @event = Envelope.Create<IEvent>(new ContentCreated { AppId = appId });
var rule1 = new Rule(new ContentChangedTriggerV2(), new TestAction { Url = new Uri("https://squidex.io") });
var rule2 = new Rule(new ContentChangedTriggerV2(), new TestAction { Url = new Uri("https://squidex.io") });
var rule3 = new Rule(new ContentChangedTriggerV2(), new TestAction { Url = new Uri("https://squidex.io") });
var job1 = new RuleJob { Created = now };
var job2 = new RuleJob { Created = now };
var ruleEntity1 = A.Fake<IRuleEntity>();
var ruleEntity2 = A.Fake<IRuleEntity>();
var ruleEntity3 = A.Fake<IRuleEntity>();
A.CallTo(() => ruleEntity1.RuleDef).Returns(rule1);
A.CallTo(() => ruleEntity2.RuleDef).Returns(rule2);
A.CallTo(() => ruleEntity3.RuleDef).Returns(rule3);
A.CallTo(() => appProvider.GetRulesAsync(appId.Id))
.Returns(new List<IRuleEntity> { ruleEntity1, ruleEntity2, ruleEntity3 });
.Returns(new List<IRuleEntity> { ruleEntity1, ruleEntity2 });
A.CallTo(() => ruleService.CreateJobAsync(rule1, @event))
A.CallTo(() => ruleService.CreateJobAsync(rule1, ruleEntity1.Id, @event))
.Returns(job1);
A.CallTo(() => ruleService.CreateJobAsync(rule2, @event))
.Returns(job2);
A.CallTo(() => ruleService.CreateJobAsync(rule3, @event))
A.CallTo(() => ruleService.CreateJobAsync(rule2, ruleEntity2.Id, @event))
.Returns(Task.FromResult<RuleJob>(null));
await sut.On(@event);
A.CallTo(() => ruleEventRepository.EnqueueAsync(job1, now))
.MustHaveHappened();
A.CallTo(() => ruleEventRepository.EnqueueAsync(job2, now))
.MustHaveHappened();
}
}
}

68
tests/Squidex.Domain.Apps.Entities.Tests/Rules/UsageTracking/UsageTriggerHandlerTests.cs

@ -0,0 +1,68 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Contents;
using Squidex.Infrastructure.EventSourcing;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Rules.UsageTracking
{
public class UsageTriggerHandlerTests
{
private readonly Guid ruleId = Guid.NewGuid();
private readonly IRuleTriggerHandler sut = new UsageTriggerHandler();
[Fact]
public void Should_not_trigger_precheck_when_event_type_not_correct()
{
var result = sut.Trigger(new ContentCreated(), new UsageTrigger(), ruleId);
Assert.False(result);
}
[Fact]
public void Should_not_trigger_precheck_when_rule_id_not_matchs()
{
var result = sut.Trigger(new AppUsageExceeded { RuleId = Guid.NewGuid() }, new UsageTrigger(), ruleId);
Assert.True(result);
}
[Fact]
public void Should_trigger_precheck_when_event_type_correct_and_rule_id_matchs()
{
var result = sut.Trigger(new AppUsageExceeded { RuleId = ruleId }, new UsageTrigger(), ruleId);
Assert.True(result);
}
[Fact]
public void Should_not_trigger_check_when_event_type_not_correct()
{
var result = sut.Trigger(new EnrichedContentEvent(), new UsageTrigger());
Assert.False(result);
}
[Fact]
public async Task Should_create_enriched_event()
{
var @event = new AppUsageExceeded { CallsCurrent = 80, CallsLimit = 120 };
var result = (EnrichedUsageExceededEvent)await sut.CreateEnrichedEventAsync(Envelope.Create<AppEvent>(@event));
Assert.Equal(@event.CallsCurrent, result.CallsCurrent);
Assert.Equal(@event.CallsLimit, result.CallsLimit);
}
}
}

3
tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Guards/FieldProperties/DateTimeFieldPropertiesTests.cs

@ -5,7 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using FluentAssertions;
@ -119,7 +118,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Guards.FieldProperties
private static Instant FutureDays(int days)
{
return Instant.FromDateTimeUtc(DateTime.UtcNow.Date.AddDays(days));
return SystemClock.Instance.GetCurrentInstant().WithoutMs().Plus(Duration.FromDays(days));
}
}
}

5
tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasByAppIndexGrainTests.cs

@ -18,6 +18,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes
{
private readonly IStore<Guid> store = A.Fake<IStore<Guid>>();
private readonly IPersistence<SchemasByAppIndexGrain.GrainState> persistence = A.Fake<IPersistence<SchemasByAppIndexGrain.GrainState>>();
private readonly Guid appId = Guid.NewGuid();
private readonly Guid schemaId1 = Guid.NewGuid();
private readonly Guid schemaId2 = Guid.NewGuid();
private readonly string schemaName1 = "my-schema1";
@ -26,11 +27,11 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes
public SchemasByAppIndexGrainTests()
{
A.CallTo(() => store.WithSnapshots(A<Type>.Ignored, A<Guid>.Ignored, A<Func<SchemasByAppIndexGrain.GrainState, Task>>.Ignored))
A.CallTo(() => store.WithSnapshots(typeof(SchemasByAppIndexGrain), appId, A<HandleSnapshot<SchemasByAppIndexGrain.GrainState>>.Ignored))
.Returns(persistence);
sut = new SchemasByAppIndexGrain(store);
sut.ActivateAsync(Guid.NewGuid()).Wait();
sut.ActivateAsync(appId).Wait();
}
[Fact]

5
tests/Squidex.Domain.Apps.Entities.Tests/Tags/TagGrainTests.cs

@ -20,15 +20,16 @@ namespace Squidex.Domain.Apps.Entities.Tags
{
private readonly IStore<string> store = A.Fake<IStore<string>>();
private readonly IPersistence<TagGrain.GrainState> persistence = A.Fake<IPersistence<TagGrain.GrainState>>();
private readonly string id = Guid.NewGuid().ToString();
private readonly TagGrain sut;
public TagGrainTests()
{
A.CallTo(() => store.WithSnapshots(A<Type>.Ignored, A<string>.Ignored, A<Func<TagGrain.GrainState, Task>>.Ignored))
A.CallTo(() => store.WithSnapshots(typeof(TagGrain), id, A<HandleSnapshot<TagGrain.GrainState>>.Ignored))
.Returns(persistence);
sut = new TagGrain(store);
sut.ActivateAsync(string.Empty).Wait();
sut.ActivateAsync(id).Wait();
}
[Fact]

5
tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs

@ -8,7 +8,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure;
@ -58,10 +57,10 @@ namespace Squidex.Domain.Apps.Entities.TestHelpers
protected HandlerTestBase()
{
A.CallTo(() => store.WithSnapshotsAndEventSourcing(A<Type>.Ignored, Id, A<Func<TState, Task>>.Ignored, A<Func<Envelope<IEvent>, Task>>.Ignored))
A.CallTo(() => store.WithSnapshotsAndEventSourcing(A<Type>.Ignored, Id, A<HandleSnapshot<TState>>.Ignored, A<HandleEvent>.Ignored))
.Returns(persistence1);
A.CallTo(() => store.WithEventSourcing(A<Type>.Ignored, Id, A<Func<Envelope<IEvent>, Task>>.Ignored))
A.CallTo(() => store.WithEventSourcing(A<Type>.Ignored, Id, A<HandleEvent>.Ignored))
.Returns(persistence2);
A.CallTo(() => persistence1.WriteEventsAsync(A<IEnumerable<Envelope<IEvent>>>.Ignored))

19
tests/Squidex.Infrastructure.Tests/Commands/CommandContextTests.cs

@ -26,8 +26,13 @@ namespace Squidex.Infrastructure.Commands
public void Should_instantiate_and_provide_command()
{
Assert.Equal(command, sut.Command);
Assert.False(sut.IsCompleted);
Assert.Null(sut.PlainResult);
Assert.Null(sut.Result<string>());
Assert.NotEqual(Guid.Empty, sut.ContextId);
Assert.False(sut.IsCompleted);
}
[Fact]
@ -39,9 +44,19 @@ namespace Squidex.Infrastructure.Commands
}
[Fact]
public void Should_provide_result_valid_when_succeeded_with_value()
public void Should_provide_result_when_succeeded_with_value()
{
sut.Complete("RESULT");
Assert.Equal("RESULT", sut.Result<string>());
}
[Fact]
public void Should_provide_plain_result_when_succeeded_with_value()
{
sut.Complete("RESULT");
Assert.Equal("RESULT", sut.PlainResult);
}
}
}

2
tests/Squidex.Infrastructure.Tests/Commands/DomainObjectGrainTests.cs

@ -109,7 +109,7 @@ namespace Squidex.Infrastructure.Commands
public DomainObjectGrainTests()
{
A.CallTo(() => store.WithSnapshotsAndEventSourcing(typeof(MyDomainObject), id, A<Func<MyDomainState, Task>>.Ignored, A<Func<Envelope<IEvent>, Task>>.Ignored))
A.CallTo(() => store.WithSnapshotsAndEventSourcing(typeof(MyDomainObject), id, A<HandleSnapshot<MyDomainState>>.Ignored, A<HandleEvent>.Ignored))
.Returns(persistence);
sut = new MyDomainObject(store);

22
tests/Squidex.Infrastructure.Tests/Commands/EnrichWithTimestampCommandMiddlewareTests.cs

@ -17,31 +17,33 @@ namespace Squidex.Infrastructure.Commands
{
private readonly IClock clock = A.Fake<IClock>();
private readonly ICommandBus commandBus = A.Dummy<ICommandBus>();
private readonly EnrichWithTimestampCommandMiddleware sut;
[Fact]
public async Task Should_set_timestamp_for_timestamp_command()
public EnrichWithTimestampCommandMiddlewareTests()
{
var utc = Instant.FromUnixTimeSeconds(1000);
var sut = new EnrichWithTimestampCommandMiddleware(clock);
A.CallTo(() => clock.GetCurrentInstant())
.Returns(utc);
.Returns(SystemClock.Instance.GetCurrentInstant().WithoutMs());
sut = new EnrichWithTimestampCommandMiddleware(clock);
}
[Fact]
public async Task Should_set_timestamp_for_timestamp_command()
{
var command = new MyCommand();
await sut.HandleAsync(new CommandContext(command, commandBus));
Assert.Equal(utc, command.Timestamp);
Assert.Equal(clock.GetCurrentInstant(), command.Timestamp);
}
[Fact]
public async Task Should_do_nothing_for_normal_command()
{
var sut = new EnrichWithTimestampCommandMiddleware(clock);
await sut.HandleAsync(new CommandContext(A.Dummy<ICommand>(), commandBus));
A.CallTo(() => clock.GetCurrentInstant()).MustNotHaveHappened();
A.CallTo(() => clock.GetCurrentInstant())
.MustNotHaveHappened();
}
}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save