Browse Source

Merge pull request #306 from Squidex/medium

Medium
pull/307/merge
Sebastian Stehle 8 years ago
committed by GitHub
parent
commit
6a8a59e40b
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      src/Squidex.Domain.Apps.Core.Model/Contents/NamedContentData.cs
  2. 17
      src/Squidex.Domain.Apps.Core.Model/Contents/StatusChange.cs
  3. 32
      src/Squidex.Domain.Apps.Core.Model/Rules/Actions/MediumAction.cs
  4. 2
      src/Squidex.Domain.Apps.Core.Model/Rules/IRuleActionVisitor.cs
  5. 6
      src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerSchema.cs
  6. 1
      src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs
  7. 73
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AlgoliaActionHandler.cs
  8. 10
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AzureQueueActionHandler.cs
  9. 120
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/ElasticSearchActionHandler.cs
  10. 57
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/FastlyActionHandler.cs
  11. 145
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/MediumActionHandler.cs
  12. 48
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/SlackActionHandler.cs
  13. 45
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/Utils/HttpHelper.cs
  14. 64
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/WebhookActionHandler.cs
  15. 47
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedAssetEvent.cs
  16. 17
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedAssetEventType.cs
  17. 38
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEvent.cs
  18. 20
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEventType.cs
  19. 35
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedEvent.cs
  20. 17
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedSchemaEvent.cs
  21. 15
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/IContentResolver.cs
  22. 19
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/IEventEnricher.cs
  23. 5
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleActionHandler.cs
  24. 19
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionHandler.cs
  25. 336
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleEventFormatter.cs
  26. 53
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleService.cs
  27. 52
      src/Squidex.Domain.Apps.Core.Operations/HandleRules/Triggers/ContentChangedTriggerHandler.cs
  28. 2
      src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
  29. 2
      src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj
  30. 1
      src/Squidex.Domain.Apps.Entities/AppProvider.cs
  31. 12
      src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs
  32. 22
      src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs
  33. 4
      src/Squidex.Domain.Apps.Entities/Assets/IAssetGrain.cs
  34. 60
      src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs
  35. 33
      src/Squidex.Domain.Apps.Entities/Contents/ContentVersionLoader.cs
  36. 4
      src/Squidex.Domain.Apps.Entities/Contents/IContentGrain.cs
  37. 4
      src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs
  38. 184
      src/Squidex.Domain.Apps.Entities/Rules/EventEnricher.cs
  39. 22
      src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleActionValidator.cs
  40. 2
      src/Squidex.Domain.Apps.Entities/Rules/RuleEnqueuer.cs
  41. 4
      src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs
  42. 2
      src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaNestedField.cs
  43. 12
      src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs
  44. 2
      src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj
  45. 34
      src/Squidex.Domain.Apps.Entities/SquidexDomainObjectGrainLogSnapshots.cs
  46. 2
      src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs
  47. 2
      src/Squidex.Domain.Apps.Events/Schemas/SchemaCreatedNestedField.cs
  48. 195
      src/Squidex.Infrastructure/Commands/DomainObjectGrain.cs
  49. 202
      src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs
  50. 93
      src/Squidex.Infrastructure/Commands/LogSnapshotDomainObjectGrain.cs
  51. 12
      src/Squidex.Infrastructure/Http/DumpFormatter.cs
  52. 2
      src/Squidex.Infrastructure/Orleans/GrainOfGuid.cs
  53. 2
      src/Squidex.Infrastructure/Orleans/GrainOfString.cs
  54. 15
      src/Squidex.Infrastructure/States/IPersistence.cs
  55. 24
      src/Squidex.Infrastructure/States/IPersistence{TState}.cs
  56. 2
      src/Squidex.Infrastructure/States/IStore.cs
  57. 11
      src/Squidex.Infrastructure/States/Store.cs
  58. 1
      src/Squidex.Infrastructure/Validate.cs
  59. 2
      src/Squidex.Infrastructure/ValidationException.cs
  60. 57
      src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/MediumActionDto.cs
  61. 5
      src/Squidex/Areas/Api/Controllers/Rules/Models/Converters/RuleActionDtoFactory.cs
  62. 15
      src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedTriggerSchemaDto.cs
  63. 6
      src/Squidex/Config/Domain/RuleServices.cs
  64. 2
      src/Squidex/Pipeline/ApiExceptionFilterAttribute.cs
  65. 1
      src/Squidex/Program.cs
  66. 2
      src/Squidex/WebStartup.cs
  67. 1
      src/Squidex/app/features/rules/declarations.ts
  68. 2
      src/Squidex/app/features/rules/module.ts
  69. 84
      src/Squidex/app/features/rules/pages/rules/actions/medium-action.component.html
  70. 6
      src/Squidex/app/features/rules/pages/rules/actions/medium-action.component.scss
  71. 51
      src/Squidex/app/features/rules/pages/rules/actions/medium-action.component.ts
  72. 51
      src/Squidex/app/features/rules/pages/rules/rule-wizard.component.html
  73. 2
      src/Squidex/app/features/rules/pages/rules/rule-wizard.component.scss
  74. 4
      src/Squidex/app/features/rules/pages/rules/rules-page.component.scss
  75. 21
      src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.html
  76. 26
      src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.ts
  77. 3
      src/Squidex/app/shared/services/rules.service.ts
  78. 2
      src/Squidex/app/shared/state/rules.state.ts
  79. 7
      src/Squidex/app/theme/_rules.scss
  80. 171
      tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleEventFormatterTests.cs
  81. 17
      tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/RuleServiceTests.cs
  82. 43
      tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/Triggers/ContentChangedTriggerTests.cs
  83. 77
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentGrainTests.cs
  84. 74
      tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentVersionLoaderTests.cs
  85. 71
      tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/Actions/MediumActionTests.cs
  86. 16
      tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs
  87. 66
      tests/Squidex.Infrastructure.Tests/Commands/DomainObjectGrainFormatterTests.cs
  88. 33
      tests/Squidex.Infrastructure.Tests/Commands/DomainObjectGrainTests.cs
  89. 300
      tests/Squidex.Infrastructure.Tests/Commands/LogSnapshotDomainObjectGrainTests.cs
  90. 91
      tests/Squidex.Infrastructure.Tests/TestHelpers/MyDomainObject.cs
  91. 4
      tests/Squidex.Infrastructure.Tests/TestHelpers/MyDomainState.cs
  92. 6
      tests/Squidex.Infrastructure.Tests/TestHelpers/MyGrain.cs
  93. 4
      tools/Migrate_01/Migrate_01.csproj

1
src/Squidex.Domain.Apps.Core.Model/Contents/NamedContentData.cs

@ -6,7 +6,6 @@
// ==========================================================================
using System;
using System.Collections.Generic;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.Contents

17
src/Squidex.Domain.Apps.Core.Model/Contents/StatusChange.cs

@ -0,0 +1,17 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Core.Contents
{
public enum StatusChange
{
Archived,
Published,
Restored,
Unpublished
}
}

32
src/Squidex.Domain.Apps.Core.Model/Rules/Actions/MediumAction.cs

@ -0,0 +1,32 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.Rules.Actions
{
[TypeName(nameof(MediumAction))]
public sealed class MediumAction : RuleAction
{
public string AccessToken { get; set; }
public string Tags { get; set; }
public string Title { get; set; }
public string CanonicalUrl { get; set; }
public string Content { get; set; }
public bool IsHtml { get; set; }
public override T Accept<T>(IRuleActionVisitor<T> visitor)
{
return visitor.Visit(this);
}
}
}

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

@ -19,6 +19,8 @@ namespace Squidex.Domain.Apps.Core.Rules
T Visit(FastlyAction action);
T Visit(MediumAction action);
T Visit(SlackAction action);
T Visit(WebhookAction action);

6
src/Squidex.Domain.Apps.Core.Model/Rules/Triggers/ContentChangedTriggerSchema.cs

@ -20,5 +20,11 @@ namespace Squidex.Domain.Apps.Core.Rules.Triggers
public bool SendDelete { get; set; }
public bool SendPublish { get; set; }
public bool SendUnpublish { get; set; }
public bool SendArchived { get; set; }
public bool SendRestore { get; set; }
}
}

1
src/Squidex.Domain.Apps.Core.Model/Schemas/FieldCollection.cs

@ -9,7 +9,6 @@ using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics.Contracts;
using System.Globalization;
using System.Linq;
using Squidex.Infrastructure;

73
src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AlgoliaActionHandler.cs

@ -10,12 +10,9 @@ using System.Threading.Tasks;
using Algolia.Search;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Actions;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Contents;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
#pragma warning disable SA1649 // File name must match first type name
@ -24,6 +21,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions
public sealed class AlgoliaJob
{
public string AppId { get; set; }
public string ApiKey { get; set; }
public string ContentId { get; set; }
@ -54,11 +52,11 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions
});
}
protected override async Task<(string Description, AlgoliaJob Data)> CreateJobAsync(Envelope<AppEvent> @event, string eventName, AlgoliaAction action)
protected override (string Description, AlgoliaJob Data) CreateJob(EnrichedEvent @event, AlgoliaAction action)
{
if (@event.Payload is ContentEvent contentEvent)
if (@event is EnrichedContentEvent contentEvent)
{
var contentId = contentEvent.ContentId.ToString();
var contentId = contentEvent.Id.ToString();
var ruleDescription = string.Empty;
var ruleJob = new AlgoliaJob
@ -66,59 +64,20 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions
AppId = action.AppId,
ApiKey = action.ApiKey,
ContentId = contentId,
IndexName = await formatter.FormatStringAsync(action.IndexName, @event)
IndexName = formatter.Format(action.IndexName, @event)
};
var timestamp = @event.Headers.Timestamp().ToString();
switch (@event.Payload)
if (contentEvent.Type == EnrichedContentEventType.Deleted ||
contentEvent.Type == EnrichedContentEventType.Unpublished)
{
ruleDescription = $"Delete entry from Algolia index: {action.IndexName}";
}
else
{
case ContentCreated created:
{
ruleDescription = $"Add entry to Algolia index: {action.IndexName}";
ruleJob.Content = new JObject(
new JProperty("objectID", contentId),
new JProperty("id", contentId),
new JProperty("created", timestamp),
new JProperty("createdBy", created.Actor.ToString()),
new JProperty("lastModified", timestamp),
new JProperty("lastModifiedBy", created.Actor.ToString()),
new JProperty("status", Status.Draft.ToString()),
new JProperty("data", formatter.ToRouteData(created.Data)));
break;
}
case ContentUpdated updated:
{
ruleDescription = $"Update entry in Algolia index: {action.IndexName}";
ruleJob.Content = new JObject(
new JProperty("objectID", contentId),
new JProperty("lastModified", timestamp),
new JProperty("lastModifiedBy", updated.Actor.ToString()),
new JProperty("data", formatter.ToRouteData(updated.Data)));
break;
}
case ContentStatusChanged statusChanged:
{
ruleDescription = $"Update entry in Algolia index: {action.IndexName}";
ruleJob.Content = new JObject(
new JProperty("objectID", contentId),
new JProperty("lastModified", timestamp),
new JProperty("lastModifiedBy", statusChanged.Actor.ToString()),
new JProperty("status", statusChanged.Status.ToString()));
break;
}
case ContentDeleted deleted:
{
ruleDescription = $"Delete entry from Algolia index: {action.IndexName}";
break;
}
ruleDescription = $"Add entry to Algolia index: {action.IndexName}";
ruleJob.Content = formatter.ToPayload(contentEvent);
ruleJob.Content["objectID"] = contentId;
}
return (ruleDescription, ruleJob);

10
src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/AzureQueueActionHandler.cs

@ -11,10 +11,9 @@ using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Queue;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Actions;
using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
#pragma warning disable SA1649 // File name must match first type name
@ -23,6 +22,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions
public sealed class AzureQueueJob
{
public string QueueConnectionString { get; set; }
public string QueueName { get; set; }
public string MessageBodyV2 { get; set; }
@ -60,11 +60,11 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions
});
}
protected override async Task<(string Description, AzureQueueJob Data)> CreateJobAsync(Envelope<AppEvent> @event, string eventName, AzureQueueAction action)
protected override (string Description, AzureQueueJob Data) CreateJob(EnrichedEvent @event, AzureQueueAction action)
{
var body = formatter.ToRouteData(@event, eventName).ToString(Formatting.Indented);
var body = formatter.ToEnvelope(@event).ToString(Formatting.Indented);
var queueName = await formatter.FormatStringAsync(action.Queue, @event);
var queueName = formatter.Format(action.Queue, @event);
var ruleDescription = $"Send AzureQueueJob to azure queue '{action.Queue}'";
var ruleJob = new AzureQueueJob

120
src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/ElasticSearchActionHandler.cs

@ -9,12 +9,9 @@ using System;
using System.Threading.Tasks;
using Elasticsearch.Net;
using Newtonsoft.Json.Linq;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Actions;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Contents;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
#pragma warning disable SA1649 // File name must match first type name
@ -25,14 +22,14 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions
public string Host { get; set; }
public string Username { get; set; }
public string Password { get; set; }
public string ContentId { get; set; }
public string IndexName { get; set; }
public string IndexType { get; set; }
public string Operation { get; set; }
public string IndexType { get; set; }
public JObject Content { get; set; }
}
@ -63,11 +60,11 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions
});
}
protected override async Task<(string Description, ElasticSearchJob Data)> CreateJobAsync(Envelope<AppEvent> @event, string eventName, ElasticSearchAction action)
protected override (string Description, ElasticSearchJob Data) CreateJob(EnrichedEvent @event, ElasticSearchAction action)
{
if (@event.Payload is ContentEvent contentEvent)
if (@event is EnrichedContentEvent contentEvent)
{
var contentId = contentEvent.ContentId.ToString();
var contentId = contentEvent.Id.ToString();
var ruleDescription = string.Empty;
var ruleJob = new ElasticSearchJob
@ -76,63 +73,21 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions
Username = action.Username,
Password = action.Password,
ContentId = contentId,
IndexName = await formatter.FormatStringAsync(action.IndexName, @event),
IndexType = await formatter.FormatStringAsync(action.IndexType, @event),
IndexName = formatter.Format(action.IndexName, @event),
IndexType = formatter.Format(action.IndexType, @event),
};
var timestamp = @event.Headers.Timestamp().ToString();
var actor = @event.Payload.Actor.ToString();
switch (@event.Payload)
if (contentEvent.Type == EnrichedContentEventType.Deleted ||
contentEvent.Type == EnrichedContentEventType.Unpublished)
{
case ContentCreated created:
{
ruleDescription = $"Add entry to ES index: {action.IndexName}";
ruleJob.Operation = "Create";
ruleJob.Content = new JObject(
new JProperty("id", contentId),
new JProperty("created", timestamp),
new JProperty("createdBy", actor),
new JProperty("lastModified", timestamp),
new JProperty("lastModifiedBy", actor),
new JProperty("status", Status.Draft.ToString()),
new JProperty("data", formatter.ToRouteData(created.Data)));
break;
}
case ContentUpdated updated:
{
ruleDescription = $"Update entry in ES index: {action.IndexName}";
ruleJob.Operation = "Update";
ruleJob.Content = new JObject(
new JProperty("lastModified", timestamp),
new JProperty("lastModifiedBy", actor),
new JProperty("data", formatter.ToRouteData(updated.Data)));
break;
}
case ContentStatusChanged statusChanged:
{
ruleDescription = $"Update entry in ES index: {action.IndexName}";
ruleJob.Operation = "Update";
ruleJob.Content = new JObject(
new JProperty("lastModified", timestamp),
new JProperty("lastModifiedBy", actor),
new JProperty("status", statusChanged.Status.ToString()));
break;
}
case ContentDeleted deleted:
{
ruleDescription = $"Delete entry from ES index: {action.IndexName}";
ruleJob.Operation = "Delete";
break;
}
ruleDescription = $"Delete entry index: {action.IndexName}";
}
else
{
ruleDescription = $"Upsert to index: {action.IndexName}";
ruleJob.Content = formatter.ToPayload(contentEvent);
ruleJob.Content["objectID"] = contentId;
}
}
@ -141,44 +96,23 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions
protected override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(ElasticSearchJob job)
{
if (string.IsNullOrWhiteSpace(job.Operation))
{
return (null, new InvalidOperationException("The action cannot handle this event."));
}
var client = clients.GetClient((new Uri(job.Host, UriKind.Absolute), job.Username, job.Password));
try
{
switch (job.Operation)
if (job.Content != null)
{
case "Create":
{
var doc = job.Content.ToString();
var response = await client.IndexAsync<StringResponse>(job.IndexName, job.IndexType, job.ContentId, doc);
return (response.Body, response.OriginalException);
}
case "Update":
{
var doc = new JObject(new JProperty("doc", job.Content)).ToString();
var doc = job.Content.ToString();
var response = await client.UpdateAsync<StringResponse>(job.IndexName, job.IndexType, job.ContentId, doc);
var response = await client.IndexAsync<StringResponse>(job.IndexName, job.IndexType, job.ContentId, doc);
return (response.Body, response.OriginalException);
}
case "Delete":
{
var response = await client.DeleteAsync<StringResponse>(job.IndexName, job.IndexType, job.ContentId);
return (response.Body, response.OriginalException);
}
return (response.Body, response.OriginalException);
}
else
{
var response = await client.DeleteAsync<StringResponse>(job.IndexName, job.IndexType, job.ContentId);
default:
return (null, null);
return (response.Body, response.OriginalException);
}
}
catch (ElasticsearchClientException ex)

57
src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/FastlyActionHandler.cs

@ -8,10 +8,9 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.HandleRules.Actions.Utils;
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Actions;
using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Http;
#pragma warning disable SA1649 // File name must match first type name
@ -28,51 +27,37 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions
public sealed class FastlyActionHandler : RuleActionHandler<FastlyAction, FastlyJob>
{
private const string Description = "Purge key in fastly";
private const string DescriptionIgnore = "Ignore";
protected override Task<(string Description, FastlyJob Data)> CreateJobAsync(Envelope<AppEvent> @event, string eventName, FastlyAction action)
private readonly ClientPool<string, HttpClient> clients;
public FastlyActionHandler()
{
if (@event.Headers.Contains(CommonHeaders.AggregateId))
clients = new ClientPool<string, HttpClient>(key =>
{
var ruleJob = new FastlyJob
return new HttpClient
{
Key = @event.Headers.AggregateId().ToString(),
FastlyApiKey = action.ApiKey,
FastlyServiceID = action.ServiceId
Timeout = TimeSpan.FromSeconds(2)
};
return Task.FromResult((Description, ruleJob));
}
return Task.FromResult((DescriptionIgnore, new FastlyJob()));
});
}
protected override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(FastlyJob job)
protected override (string Description, FastlyJob Data) CreateJob(EnrichedEvent @event, FastlyAction action)
{
if (string.IsNullOrWhiteSpace(job.Key))
{
return (null, new InvalidOperationException("The action cannot handle this event."));
}
var requestMsg = BuildRequest(job);
HttpResponseMessage response = null;
try
var ruleJob = new FastlyJob
{
response = await HttpClientPool.GetHttpClient().SendAsync(requestMsg);
Key = @event.AggregateId.ToString(),
FastlyApiKey = action.ApiKey,
FastlyServiceID = action.ServiceId
};
var responseString = await response.Content.ReadAsStringAsync();
var requestDump = DumpFormatter.BuildDump(requestMsg, response, null, responseString, TimeSpan.Zero, false);
return (Description, ruleJob);
}
return (requestDump, null);
}
catch (Exception ex)
{
var requestDump = DumpFormatter.BuildDump(requestMsg, response, null, ex.ToString(), TimeSpan.Zero, false);
protected override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(FastlyJob job)
{
var httpClient = clients.GetClient(string.Empty);
return (requestDump, ex);
}
return await httpClient.OneWayRequestAsync(BuildRequest(job), null);
}
private static HttpRequestMessage BuildRequest(FastlyJob job)

145
src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/MediumActionHandler.cs

@ -0,0 +1,145 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
#pragma warning disable SA1649 // File name must match first type name
using System;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Squidex.Domain.Apps.Core.HandleRules.Actions.Utils;
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Actions;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Http;
namespace Squidex.Domain.Apps.Core.HandleRules.Actions
{
public sealed class MediumJob
{
public string RequestBody { get; set; }
public string AccessToken { get; set; }
}
public sealed class MediumActionHandler : RuleActionHandler<MediumAction, MediumJob>
{
private const string Description = "Post to medium";
private readonly RuleEventFormatter formatter;
private readonly ClientPool<string, HttpClient> clients;
public MediumActionHandler(RuleEventFormatter formatter)
{
Guard.NotNull(formatter, nameof(formatter));
this.formatter = formatter;
clients = new ClientPool<string, HttpClient>(key =>
{
var client = new HttpClient
{
Timeout = TimeSpan.FromSeconds(4)
};
client.DefaultRequestHeaders.Add("Accept", "application/json");
client.DefaultRequestHeaders.Add("Accept-Charset", "utf-8");
client.DefaultRequestHeaders.Add("User-Agent", "Squidex Headless CMS");
return client;
});
}
protected override (string Description, MediumJob Data) CreateJob(EnrichedEvent @event, MediumAction action)
{
var requestBody =
new JObject(
new JProperty("title", formatter.Format(action.Title, @event)),
new JProperty("contentFormat", action.IsHtml ? "html" : "markdown"),
new JProperty("content", formatter.Format(action.Content, @event)),
new JProperty("canonicalUrl", formatter.Format(action.CanonicalUrl, @event)),
new JProperty("tags", ParseTags(@event, action)));
var ruleJob = new MediumJob { AccessToken = action.AccessToken, RequestBody = requestBody.ToString(Formatting.Indented) };
return (Description, ruleJob);
}
private JArray ParseTags(EnrichedEvent @event, MediumAction action)
{
if (string.IsNullOrWhiteSpace(action.Tags))
{
return null;
}
string[] tags;
try
{
var jsonTags = formatter.Format(action.Tags, @event);
tags = JsonConvert.DeserializeObject<string[]>(jsonTags);
}
catch
{
tags = action.Tags.Split(',');
}
return new JArray(tags);
}
protected override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(MediumJob job)
{
var httpClient = clients.GetClient(string.Empty);
string id;
HttpResponseMessage response = null;
var meRequest = BuildMeRequest(job);
try
{
response = await httpClient.SendAsync(meRequest);
var responseString = await response.Content.ReadAsStringAsync();
var responseJson = JToken.Parse(responseString);
id = responseJson["data"]["id"].ToString();
}
catch (Exception ex)
{
var requestDump = DumpFormatter.BuildDump(meRequest, response, ex.ToString());
return (requestDump, ex);
}
return await httpClient.OneWayRequestAsync(BuildPostRequest(job, id), job.RequestBody);
}
private static HttpRequestMessage BuildPostRequest(MediumJob job, string id)
{
var request = new HttpRequestMessage(HttpMethod.Post, $"https://api.medium.com/v1/users/{id}/posts")
{
Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json")
};
request.Headers.Add("Authorization", $"Bearer {job.AccessToken}");
return request;
}
private static HttpRequestMessage BuildMeRequest(MediumJob job)
{
var request = new HttpRequestMessage(HttpMethod.Get, "https://api.medium.com/v1/me");
request.Headers.Add("Authorization", $"Bearer {job.AccessToken}");
return request;
}
}
}

48
src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/SlackActionHandler.cs

@ -11,11 +11,10 @@ using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Squidex.Domain.Apps.Core.HandleRules.Actions.Utils;
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Actions;
using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Http;
#pragma warning disable SA1649 // File name must match first type name
@ -42,17 +41,28 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions
private const string Description = "Send message to slack";
private readonly RuleEventFormatter formatter;
private readonly ClientPool<string, HttpClient> clients;
public SlackActionHandler(RuleEventFormatter formatter)
{
Guard.NotNull(formatter, nameof(formatter));
this.formatter = formatter;
clients = new ClientPool<string, HttpClient>(key =>
{
return new HttpClient
{
Timeout = TimeSpan.FromSeconds(2)
};
});
}
protected override async Task<(string Description, SlackJob Data)> CreateJobAsync(Envelope<AppEvent> @event, string eventName, SlackAction action)
protected override (string Description, SlackJob Data) CreateJob(EnrichedEvent @event, SlackAction action)
{
var body = await CreatePayloadAsync(@event, action.Text);
var body =
new JObject(
new JProperty("text", formatter.Format(action.Text, @event)));
var ruleJob = new SlackJob
{
@ -63,33 +73,11 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions
return (Description, ruleJob);
}
private async Task<JObject> CreatePayloadAsync(Envelope<AppEvent> @event, string text)
protected override Task<(string Dump, Exception Exception)> ExecuteJobAsync(SlackJob job)
{
return new JObject(new JProperty("text", await formatter.FormatStringAsync(text, @event)));
}
var httpClient = clients.GetClient(string.Empty);
protected override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(SlackJob job)
{
var requestBody = job.Body;
var requestMessage = BuildRequest(job, requestBody);
HttpResponseMessage response = null;
try
{
response = await HttpClientPool.GetHttpClient().SendAsync(requestMessage);
var responseString = await response.Content.ReadAsStringAsync();
var requestDump = DumpFormatter.BuildDump(requestMessage, response, requestBody, responseString, TimeSpan.Zero, false);
return (requestDump, null);
}
catch (Exception ex)
{
var requestDump = DumpFormatter.BuildDump(requestMessage, response, requestBody, ex.ToString(), TimeSpan.Zero, false);
return (requestDump, ex);
}
return httpClient.OneWayRequestAsync(BuildRequest(job, job.Body), job.Body);
}
private static HttpRequestMessage BuildRequest(SlackJob job, string requestBody)

45
src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/Utils/HttpHelper.cs

@ -0,0 +1,45 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Net.Http;
using System.Threading.Tasks;
using Squidex.Infrastructure.Http;
namespace Squidex.Domain.Apps.Core.HandleRules.Actions.Utils
{
public static class HttpHelper
{
public static async Task<(string Dump, Exception Exception)> OneWayRequestAsync(this HttpClient client, HttpRequestMessage request, string requestBody = null)
{
HttpResponseMessage response = null;
try
{
response = await client.SendAsync(request);
var responseString = await response.Content.ReadAsStringAsync();
var requestDump = DumpFormatter.BuildDump(request, response, requestBody, responseString);
Exception ex = null;
if (!response.IsSuccessStatusCode)
{
ex = new HttpRequestException($"Response code does not indicate success: {(int)response.StatusCode} ({response.StatusCode}).");
}
return (requestDump, ex);
}
catch (Exception ex)
{
var requestDump = DumpFormatter.BuildDump(request, response, requestBody, ex.ToString());
return (requestDump, ex);
}
}
}
}

64
src/Squidex.Domain.Apps.Core.Operations/HandleRules/Actions/WebhookActionHandler.cs

@ -11,11 +11,10 @@ using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Squidex.Domain.Apps.Core.HandleRules.Actions.Utils;
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Actions;
using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Http;
#pragma warning disable SA1649 // File name must match first type name
@ -24,7 +23,9 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions
public sealed class WebhookJob
{
public string RequestUrl { get; set; }
public string RequestSignature { get; set; }
public string RequestBodyV2 { get; set; }
public JObject RequestBody { get; set; }
@ -41,58 +42,48 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions
public sealed class WebhookActionHandler : RuleActionHandler<WebhookAction, WebhookJob>
{
private readonly RuleEventFormatter formatter;
private readonly ClientPool<string, HttpClient> clients;
public WebhookActionHandler(RuleEventFormatter formatter)
{
Guard.NotNull(formatter, nameof(formatter));
this.formatter = formatter;
clients = new ClientPool<string, HttpClient>(key =>
{
var client = new HttpClient
{
Timeout = TimeSpan.FromSeconds(4)
};
client.DefaultRequestHeaders.Add("User-Agent", "Squidex Webhook");
return client;
});
}
protected override async Task<(string Description, WebhookJob Data)> CreateJobAsync(Envelope<AppEvent> @event, string eventName, WebhookAction action)
protected override (string Description, WebhookJob Data) CreateJob(EnrichedEvent @event, WebhookAction action)
{
var body = formatter.ToRouteData(@event, eventName).ToString(Formatting.Indented);
var requestBody = formatter.ToEnvelope(@event).ToString(Formatting.Indented);
var requestUrl = formatter.Format(action.Url.ToString(), @event);
var ruleDescription = $"Send event to webhook '{action.Url}'";
var ruleDescription = $"Send event to webhook '{requestUrl}'";
var ruleJob = new WebhookJob
{
RequestUrl = await formatter.FormatStringAsync(action.Url.ToString(), @event),
RequestSignature = $"{body}{action.SharedSecret}".Sha256Base64(),
RequestBodyV2 = body
RequestUrl = requestUrl,
RequestSignature = $"{requestBody}{action.SharedSecret}".Sha256Base64(),
RequestBodyV2 = requestBody
};
return (ruleDescription, ruleJob);
}
protected override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(WebhookJob job)
protected override Task<(string Dump, Exception Exception)> ExecuteJobAsync(WebhookJob job)
{
var requestBody = job.Body;
var requestMessage = BuildRequest(job, requestBody);
HttpResponseMessage response = null;
try
{
response = await HttpClientPool.GetHttpClient().SendAsync(requestMessage);
var responseString = await response.Content.ReadAsStringAsync();
var requestDump = DumpFormatter.BuildDump(requestMessage, response, requestBody, responseString, TimeSpan.Zero, false);
Exception ex = null;
if (!response.IsSuccessStatusCode)
{
ex = new HttpRequestException($"Response code does not indicate success: {(int)response.StatusCode} ({response.StatusCode}).");
}
var httpClient = clients.GetClient(string.Empty);
return (requestDump, ex);
}
catch (Exception ex)
{
var requestDump = DumpFormatter.BuildDump(requestMessage, response, requestBody, ex.ToString(), TimeSpan.Zero, false);
return (requestDump, ex);
}
return httpClient.OneWayRequestAsync(BuildRequest(job, job.Body), job.Body);
}
private static HttpRequestMessage BuildRequest(WebhookJob job, string requestBody)
@ -103,7 +94,6 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Actions
};
request.Headers.Add("X-Signature", job.RequestSignature);
request.Headers.Add("User-Agent", "Squidex Webhook");
return request;
}

47
src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedAssetEvent.cs

@ -0,0 +1,47 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using NodaTime;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents
{
public sealed class EnrichedAssetEvent : EnrichedEvent
{
public EnrichedAssetEventType Type { get; set; }
public Guid Id { get; set; }
public Instant Created { get; set; }
public Instant LastModified { get; set; }
public RefToken CreatedBy { get; set; }
public RefToken LastModifiedBy { get; set; }
public string MimeType { get; set; }
public string FileName { get; set; }
public long FileVersion { get; set; }
public long FileSize { get; set; }
public bool IsImage { get; set; }
public int? PixelWidth { get; set; }
public int? PixelHeight { get; set; }
public override Guid AggregateId
{
get { return Id; }
}
}
}

17
src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedAssetEventType.cs

@ -0,0 +1,17 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents
{
public enum EnrichedAssetEventType
{
Created,
Deleted,
Renamed,
Updated
}
}

38
src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEvent.cs

@ -0,0 +1,38 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents
{
public sealed class EnrichedContentEvent : EnrichedSchemaEvent
{
public EnrichedContentEventType Type { get; set; }
public Guid Id { get; set; }
public Instant Created { get; set; }
public Instant LastModified { get; set; }
public RefToken CreatedBy { get; set; }
public RefToken LastModifiedBy { get; set; }
public NamedContentData Data { get; set; }
public Status Status { get; set; }
public override Guid AggregateId
{
get { return Id; }
}
}
}

20
src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedContentEventType.cs

@ -0,0 +1,20 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents
{
public enum EnrichedContentEventType
{
Archived,
Created,
Deleted,
Published,
Restored,
Unpublished,
Updated
}
}

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

@ -0,0 +1,35 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Newtonsoft.Json;
using NodaTime;
using Squidex.Infrastructure;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents
{
public abstract class EnrichedEvent
{
public NamedId<Guid> AppId { get; set; }
public RefToken Actor { get; set; }
public Instant Timestamp { get; set; }
public long Version { get; set; }
[JsonIgnore]
public abstract Guid AggregateId { get; }
[JsonIgnore]
public string Name { get; set; }
[JsonIgnore]
public IUser User { get; set; }
}
}

17
src/Squidex.Domain.Apps.Core.Operations/HandleRules/EnrichedEvents/EnrichedSchemaEvent.cs

@ -0,0 +1,17 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Infrastructure;
namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents
{
public abstract class EnrichedSchemaEvent : EnrichedEvent
{
public NamedId<Guid> SchemaId { get; set; }
}
}

15
src/Squidex.Domain.Apps.Core.Operations/HandleRules/HttpClientPool.cs → src/Squidex.Domain.Apps.Core.Operations/HandleRules/IContentResolver.cs

@ -6,20 +6,13 @@
// ==========================================================================
using System;
using System.Net.Http;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Contents;
namespace Squidex.Domain.Apps.Core.HandleRules
{
public static class HttpClientPool
public interface IContentResolver
{
private static readonly ClientPool<string, HttpClient> Pool = new ClientPool<string, HttpClient>(key =>
{
return new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
});
public static HttpClient GetHttpClient()
{
return Pool.GetClient(string.Empty);
}
Task<NamedContentData> GetContentDataAsync(Guid id);
}
}

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

@ -0,0 +1,19 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents;
using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Domain.Apps.Core.HandleRules
{
public interface IEventEnricher
{
Task<EnrichedEvent> EnrichAsync(Envelope<AppEvent> @event);
}
}

5
src/Squidex.Domain.Apps.Core.Operations/HandleRules/IRuleActionHandler.cs

@ -8,9 +8,8 @@
using System;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
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
{
@ -18,7 +17,7 @@ namespace Squidex.Domain.Apps.Core.HandleRules
{
Type ActionType { get; }
Task<(string Description, JObject Data)> CreateJobAsync(Envelope<AppEvent> @event, string eventName, RuleAction action);
Task<(string Description, JObject Data)> CreateJobAsync(EnrichedEvent @event, RuleAction action);
Task<(string Dump, Exception Exception)> ExecuteJobAsync(JObject data);
}

19
src/Squidex.Domain.Apps.Core.Operations/HandleRules/RuleActionHandler.cs

@ -8,9 +8,10 @@
using System;
using System.Threading.Tasks;
using Newtonsoft.Json.Linq;
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 RECS0083 // Shows NotImplementedException throws in the quick task bar
namespace Squidex.Domain.Apps.Core.HandleRules
{
@ -21,9 +22,9 @@ namespace Squidex.Domain.Apps.Core.HandleRules
get { return typeof(TAction); }
}
async Task<(string Description, JObject Data)> IRuleActionHandler.CreateJobAsync(Envelope<AppEvent> @event, string eventName, RuleAction action)
async Task<(string Description, JObject Data)> IRuleActionHandler.CreateJobAsync(EnrichedEvent @event, RuleAction action)
{
var (description, data) = await CreateJobAsync(@event, eventName, (TAction)action);
var (description, data) = await CreateJobAsync(@event, (TAction)action);
return (description, JObject.FromObject(data));
}
@ -35,7 +36,15 @@ namespace Squidex.Domain.Apps.Core.HandleRules
return await ExecuteJobAsync(typedData);
}
protected abstract Task<(string Description, TData Data)> CreateJobAsync(Envelope<AppEvent> @event, string eventName, TAction action);
protected virtual Task<(string Description, TData Data)> CreateJobAsync(EnrichedEvent @event, TAction action)
{
return Task.FromResult(CreateJob(@event, action));
}
protected virtual (string Description, TData Data) CreateJob(EnrichedEvent @event, TAction action)
{
throw new NotImplementedException();
}
protected abstract Task<(string Dump, Exception Exception)> ExecuteJobAsync(TData job);
}

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

@ -6,18 +6,15 @@
// =========================================-=================================
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Contents;
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Core.HandleRules
@ -25,222 +22,271 @@ namespace Squidex.Domain.Apps.Core.HandleRules
public class RuleEventFormatter
{
private const string Undefined = "UNDEFINED";
private const string AppIdPlaceholder = "$APP_ID";
private const string AppNamePlaceholder = "$APP_NAME";
private const string SchemaIdPlaceholder = "$SCHEMA_ID";
private const string SchemaNamePlaceholder = "$SCHEMA_NAME";
private const string TimestampDatePlaceholder = "$TIMESTAMP_DATE";
private const string TimestampDateTimePlaceholder = "$TIMESTAMP_DATETIME";
private const string ContentActionPlaceholder = "$CONTENT_ACTION";
private const string ContentUrlPlaceholder = "$CONTENT_URL";
private const string UserNamePlaceholder = "$USER_NAME";
private const string UserEmailPlaceholder = "$USER_EMAIL";
private static readonly Regex ContentDataPlaceholder = new Regex(@"\$CONTENT_DATA(\.([0-9A-Za-z\-_]*)){2,}", RegexOptions.Compiled);
private static readonly TimeSpan UserCacheDuration = TimeSpan.FromMinutes(10);
private static readonly Regex ContentDataPlaceholder = new Regex(@"^CONTENT_DATA(\.([0-9A-Za-z\-_]*)){2,}", RegexOptions.Compiled);
private static readonly Regex ContentDataPlaceholder2 = new Regex(@"^\{CONTENT_DATA(\.([0-9A-Za-z\-_]*)){2,}\}", RegexOptions.Compiled);
private readonly List<(string Pattern, Func<EnrichedEvent, string> Replacer)> patterns = new List<(string Pattern, Func<EnrichedEvent, string> Replacer)>();
private readonly JsonSerializer serializer;
private readonly IRuleUrlGenerator urlGenerator;
private readonly IMemoryCache memoryCache;
private readonly IUserResolver userResolver;
public RuleEventFormatter(JsonSerializer serializer, IRuleUrlGenerator urlGenerator, IMemoryCache memoryCache, IUserResolver userResolver)
public RuleEventFormatter(JsonSerializer serializer, IRuleUrlGenerator urlGenerator)
{
Guard.NotNull(memoryCache, nameof(memoryCache));
Guard.NotNull(serializer, nameof(serializer));
Guard.NotNull(urlGenerator, nameof(urlGenerator));
Guard.NotNull(userResolver, nameof(userResolver));
this.memoryCache = memoryCache;
this.serializer = serializer;
this.userResolver = userResolver;
this.urlGenerator = urlGenerator;
AddPattern("APP_ID", AppId);
AddPattern("APP_NAME", AppName);
AddPattern("CONTENT_ACTION", ContentAction);
AddPattern("CONTENT_URL", ContentUrl);
AddPattern("SCHEMA_ID", SchemaId);
AddPattern("SCHEMA_NAME", SchemaName);
AddPattern("TIMESTAMP_DATETIME", TimestampTime);
AddPattern("TIMESTAMP_DATE", TimestampDate);
AddPattern("USER_NAME", UserName);
AddPattern("USER_EMAIL", UserEmail);
}
public virtual JToken ToRouteData(object value)
private void AddPattern(string placeholder, Func<EnrichedEvent, string> generator)
{
return JToken.FromObject(value, serializer);
patterns.Add((placeholder, generator));
}
public virtual JToken ToRouteData(Envelope<AppEvent> @event, string eventName)
public virtual JObject ToPayload<T>(T @event)
{
return new JObject(
new JProperty("type", eventName),
new JProperty("payload", JToken.FromObject(@event.Payload, serializer)),
new JProperty("timestamp", @event.Headers.Timestamp().ToString()));
return JObject.FromObject(@event, serializer);
}
public async virtual Task<string> FormatStringAsync(string text, Envelope<AppEvent> @event)
public virtual JObject ToEnvelope(EnrichedEvent @event)
{
var sb = new StringBuilder(text);
return new JObject(
new JProperty("type", @event.Name),
new JProperty("payload", ToPayload(@event)),
new JProperty("timestamp", @event.Timestamp.ToString()));
}
if (@event.Headers.Contains(CommonHeaders.Timestamp))
public string Format(string text, EnrichedEvent @event)
{
if (string.IsNullOrWhiteSpace(text))
{
var timestamp = @event.Headers.Timestamp().ToDateTimeUtc();
sb.Replace(TimestampDateTimePlaceholder, timestamp.ToString("yyy-MM-dd-hh-mm-ss", CultureInfo.InvariantCulture));
sb.Replace(TimestampDatePlaceholder, timestamp.ToString("yyy-MM-dd", CultureInfo.InvariantCulture));
return text;
}
if (@event.Payload.AppId != null)
{
sb.Replace(AppIdPlaceholder, @event.Payload.AppId.Id.ToString());
sb.Replace(AppNamePlaceholder, @event.Payload.AppId.Name);
}
var current = text.AsSpan();
if (@event.Payload is SchemaEvent schemaEvent && schemaEvent.SchemaId != null)
{
sb.Replace(SchemaIdPlaceholder, schemaEvent.SchemaId.Id.ToString());
sb.Replace(SchemaNamePlaceholder, schemaEvent.SchemaId.Name);
}
var sb = new StringBuilder();
if (@event.Payload is ContentEvent contentEvent)
for (var i = 0; i < current.Length; i++)
{
sb.Replace(ContentUrlPlaceholder, urlGenerator.GenerateContentUIUrl(@event.Payload.AppId, contentEvent.SchemaId, contentEvent.ContentId));
}
var c = current[i];
if (c == '$')
{
sb.Append(current.Slice(0, i));
await FormatUserInfoAsync(@event, sb);
current = current.Slice(i);
FormatContentAction(@event, sb);
var test = current.Slice(1);
var tested = false;
var result = sb.ToString();
for (var j = 0; j < patterns.Count; j++)
{
var (Pattern, Replacer) = patterns[j];
if (@event.Payload is ContentCreated contentCreated && contentCreated.Data != null)
{
result = ReplaceData(contentCreated.Data, result);
}
if (test.StartsWith(Pattern, StringComparison.OrdinalIgnoreCase))
{
sb.Append(Replacer(@event));
if (@event.Payload is ContentUpdated contentUpdated && contentUpdated.Data != null)
{
result = ReplaceData(contentUpdated.Data, result);
current = current.Slice(Pattern.Length + 1);
i = 0;
tested = true;
break;
}
}
if (!tested &&
(test.StartsWith("CONTENT_DATA", StringComparison.OrdinalIgnoreCase) ||
test.StartsWith("{CONTENT_DATA", StringComparison.OrdinalIgnoreCase)))
{
var currentString = new string(test);
var match = ContentDataPlaceholder.Match(currentString);
if (!match.Success)
{
match = ContentDataPlaceholder2.Match(currentString);
}
if (match.Success)
{
if (@event is EnrichedContentEvent contentEvent)
{
sb.Append(CalculateData(contentEvent.Data, match));
}
else
{
sb.Append(Undefined);
}
current = current.Slice(match.Length + 1);
i = 0;
}
}
}
}
return result;
sb.Append(current);
return sb.ToString();
}
private async Task FormatUserInfoAsync(Envelope<AppEvent> @event, StringBuilder sb)
private static string TimestampDate(EnrichedEvent @event)
{
var text = sb.ToString();
return @event.Timestamp.ToDateTimeUtc().ToString("yyy-MM-dd", CultureInfo.InvariantCulture);
}
if (text.Contains(UserEmailPlaceholder) || text.Contains(UserNamePlaceholder))
{
var actor = @event.Payload.Actor;
private static string TimestampTime(EnrichedEvent @event)
{
return @event.Timestamp.ToDateTimeUtc().ToString("yyy-MM-dd-hh-mm-ss", CultureInfo.InvariantCulture);
}
if (actor.Type.Equals("client", StringComparison.OrdinalIgnoreCase))
{
var displayText = actor.ToString();
private static string AppId(EnrichedEvent @event)
{
return @event.AppId.Id.ToString();
}
sb.Replace(UserEmailPlaceholder, displayText);
sb.Replace(UserNamePlaceholder, displayText);
}
else
{
var user = await FindUserAsync(actor);
private static string AppName(EnrichedEvent @event)
{
return @event.AppId.Name;
}
if (user != null)
{
sb.Replace(UserEmailPlaceholder, user.Email);
sb.Replace(UserNamePlaceholder, user.DisplayName());
}
else
{
sb.Replace(UserEmailPlaceholder, Undefined);
sb.Replace(UserNamePlaceholder, Undefined);
}
}
private static string SchemaId(EnrichedEvent @event)
{
if (@event is EnrichedSchemaEvent schemaEvent)
{
return schemaEvent.SchemaId.Id.ToString();
}
return Undefined;
}
private static void FormatContentAction(Envelope<AppEvent> @event, StringBuilder sb)
private static string SchemaName(EnrichedEvent @event)
{
switch (@event.Payload)
if (@event is EnrichedSchemaEvent schemaEvent)
{
case ContentCreated contentCreated:
sb.Replace(ContentActionPlaceholder, "created");
break;
case ContentUpdated contentUpdated:
sb.Replace(ContentActionPlaceholder, "updated");
break;
return schemaEvent.SchemaId.Name;
}
case ContentStatusChanged contentStatusChanged:
sb.Replace(ContentActionPlaceholder, $"set to {contentStatusChanged.Status.ToString().ToLowerInvariant()}");
break;
return Undefined;
}
case ContentDeleted contentDeleted:
sb.Replace(ContentActionPlaceholder, "deleted");
break;
private static string ContentAction(EnrichedEvent @event)
{
if (@event is EnrichedContentEvent contentEvent)
{
return contentEvent.Type.ToString().ToLowerInvariant();
}
return Undefined;
}
private static string ReplaceData(NamedContentData data, string text)
private string ContentUrl(EnrichedEvent @event)
{
return ContentDataPlaceholder.Replace(text, match =>
if (@event is EnrichedContentEvent contentEvent)
{
var captures = match.Groups[2].Captures;
var path = new string[captures.Count];
return urlGenerator.GenerateContentUIUrl(contentEvent.AppId, contentEvent.SchemaId, contentEvent.Id);
}
for (var i = 0; i < path.Length; i++)
{
path[i] = captures[i].Value;
}
return Undefined;
}
if (!data.TryGetValue(path[0], out var field))
private static string UserName(EnrichedEvent @event)
{
if (@event.Actor != null)
{
if (@event.Actor.Type.Equals("client", StringComparison.OrdinalIgnoreCase))
{
return Undefined;
return @event.Actor.ToString();
}
if (!field.TryGetValue(path[1], out var value))
if (@event.User != null)
{
return Undefined;
return @event.User.DisplayName();
}
}
for (var j = 2; j < path.Length; j++)
{
if (value is JObject obj && obj.TryGetValue(path[j], out value))
{
continue;
}
if (value is JArray arr && int.TryParse(path[j], out var idx) && idx >= 0 && idx < arr.Count)
{
value = arr[idx];
}
else
{
return Undefined;
}
}
return Undefined;
}
if (value == null || value.Type == JTokenType.Null || value.Type == JTokenType.Undefined)
private static string UserEmail(EnrichedEvent @event)
{
if (@event.Actor != null)
{
if (@event.Actor.Type.Equals("client", StringComparison.OrdinalIgnoreCase))
{
return Undefined;
return @event.Actor.ToString();
}
if (value is JValue jValue && jValue != null)
if (@event.User != null)
{
return jValue.Value.ToString();
return @event.User.Email;
}
}
return value?.ToString(Formatting.Indented) ?? Undefined;
});
return Undefined;
}
private Task<IUser> FindUserAsync(RefToken actor)
private static string CalculateData(NamedContentData data, Match match)
{
var key = $"RuleEventFormatter_Users_${actor.Identifier}";
var captures = match.Groups[2].Captures;
var path = new string[captures.Count];
for (var i = 0; i < path.Length; i++)
{
path[i] = captures[i].Value;
}
if (!data.TryGetValue(path[0], out var field))
{
return Undefined;
}
return memoryCache.GetOrCreateAsync(key, async x =>
if (!field.TryGetValue(path[1], out var value))
{
x.AbsoluteExpirationRelativeToNow = UserCacheDuration;
return Undefined;
}
try
for (var j = 2; j < path.Length; j++)
{
if (value is JObject obj && obj.TryGetValue(path[j], out value))
{
return await userResolver.FindByIdOrEmailAsync(actor.Identifier);
continue;
}
catch
if (value is JArray arr && int.TryParse(path[j], out var idx) && idx >= 0 && idx < arr.Count)
{
return null;
value = arr[idx];
}
});
else
{
return Undefined;
}
}
if (value == null || value.Type == JTokenType.Null || value.Type == JTokenType.Undefined)
{
return Undefined;
}
if (value is JValue jValue && jValue != null)
{
return jValue.Value.ToString();
}
return value?.ToString(Formatting.Indented) ?? Undefined;
}
}
}

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

@ -26,17 +26,20 @@ namespace Squidex.Domain.Apps.Core.HandleRules
private readonly Dictionary<Type, IRuleActionHandler> ruleActionHandlers;
private readonly Dictionary<Type, IRuleTriggerHandler> ruleTriggerHandlers;
private readonly TypeNameRegistry typeNameRegistry;
private readonly IEventEnricher eventEnricher;
private readonly IClock clock;
public RuleService(
IEnumerable<IRuleTriggerHandler> ruleTriggerHandlers,
IEnumerable<IRuleActionHandler> ruleActionHandlers,
IEventEnricher eventEnricher,
IClock clock,
TypeNameRegistry typeNameRegistry)
{
Guard.NotNull(ruleTriggerHandlers, nameof(ruleTriggerHandlers));
Guard.NotNull(ruleActionHandlers, nameof(ruleActionHandlers));
Guard.NotNull(typeNameRegistry, nameof(typeNameRegistry));
Guard.NotNull(eventEnricher, nameof(eventEnricher));
Guard.NotNull(clock, nameof(clock));
this.typeNameRegistry = typeNameRegistry;
@ -44,6 +47,8 @@ namespace Squidex.Domain.Apps.Core.HandleRules
this.ruleTriggerHandlers = ruleTriggerHandlers.ToDictionary(x => x.TriggerType);
this.ruleActionHandlers = ruleActionHandlers.ToDictionary(x => x.ActionType);
this.eventEnricher = eventEnricher;
this.clock = clock;
}
@ -76,41 +81,38 @@ namespace Squidex.Domain.Apps.Core.HandleRules
return null;
}
var eventName = CreateEventName(appEvent);
var now = clock.GetCurrentInstant();
var actionName = typeNameRegistry.GetName(actionType);
var actionData = await actionHandler.CreateJobAsync(appEventEnvelope, eventName, rule.Action);
var eventTime =
@event.Headers.Contains(CommonHeaders.Timestamp) ?
@event.Headers.Timestamp() :
now;
var aggregateId =
@event.Headers.Contains(CommonHeaders.AggregateId) ?
@event.Headers.AggregateId() :
Guid.NewGuid();
var expires = eventTime.Plus(Constants.ExpirationTime);
if (expires < now)
{
return null;
}
var enrichedEvent = await eventEnricher.EnrichAsync(appEventEnvelope);
var actionName = typeNameRegistry.GetName(actionType);
var actionData = await actionHandler.CreateJobAsync(enrichedEvent, rule.Action);
var job = new RuleJob
{
JobId = Guid.NewGuid(),
ActionName = actionName,
ActionData = actionData.Data,
AggregateId = aggregateId,
AggregateId = enrichedEvent.AggregateId,
AppId = appEvent.AppId.Id,
Created = now,
EventName = eventName,
Expires = eventTime.Plus(Constants.ExpirationTime),
EventName = enrichedEvent.Name,
Expires = expires,
Description = actionData.Description
};
if (job.Expires < now)
{
return null;
}
return job;
}
@ -152,22 +154,5 @@ namespace Squidex.Domain.Apps.Core.HandleRules
return (ex.ToString(), RuleResult.Failed, TimeSpan.Zero);
}
}
private string CreateEventName(AppEvent appEvent)
{
var eventName = typeNameRegistry.GetName(appEvent.GetType());
if (appEvent is SchemaEvent schemaEvent)
{
if (eventName.StartsWith(ContentPrefix, StringComparison.Ordinal))
{
eventName = eventName.Substring(ContentPrefix.Length);
}
return $"{schemaEvent.SchemaId.Name.ToPascalCase()}{eventName}";
}
return eventName;
}
}
}

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

@ -17,7 +17,11 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Triggers
{
protected override bool Triggers(Envelope<AppEvent> @event, ContentChangedTrigger trigger)
{
if (trigger.HandleAll && @event.Payload is ContentEvent)
if (trigger.HandleAll &&
@event.Payload is ContentEvent &&
!(@event.Payload is ContentChangesPublished) &&
!(@event.Payload is ContentChangesDiscarded) &&
!(@event.Payload is ContentUpdateProposed))
{
return true;
}
@ -44,10 +48,48 @@ namespace Squidex.Domain.Apps.Core.HandleRules.Triggers
private static bool MatchsType(ContentChangedTriggerSchema schema, SchemaEvent @event)
{
return
(schema.SendCreate && @event is ContentCreated) ||
(schema.SendUpdate && @event is ContentUpdated) ||
(schema.SendDelete && @event is ContentDeleted) ||
(schema.SendPublish && @event is ContentStatusChanged statusChanged && statusChanged.Status == Status.Published);
IsArchived(schema, @event) ||
IsCreate(schema, @event) ||
IsDelete(schema, @event) ||
IsPublished(schema, @event) ||
IsRestored(schema, @event) ||
IsUpdate(schema, @event) ||
IsUnpublished(schema, @event);
}
private static bool IsPublished(ContentChangedTriggerSchema schema, SchemaEvent @event)
{
return schema.SendPublish && @event is ContentStatusChanged statusChanged && statusChanged.Change == StatusChange.Published;
}
private static bool IsRestored(ContentChangedTriggerSchema schema, SchemaEvent @event)
{
return schema.SendRestore && @event is ContentStatusChanged statusChanged && statusChanged.Change == StatusChange.Restored;
}
private static bool IsArchived(ContentChangedTriggerSchema schema, SchemaEvent @event)
{
return schema.SendArchived && @event is ContentStatusChanged statusChanged && statusChanged.Change == StatusChange.Archived;
}
private static bool IsUnpublished(ContentChangedTriggerSchema schema, SchemaEvent @event)
{
return schema.SendUnpublish && @event is ContentStatusChanged statusChanged && statusChanged.Change == StatusChange.Unpublished;
}
private static bool IsCreate(ContentChangedTriggerSchema schema, SchemaEvent @event)
{
return schema.SendCreate && @event is ContentCreated;
}
private static bool IsUpdate(ContentChangedTriggerSchema schema, SchemaEvent @event)
{
return schema.SendUpdate && @event is ContentUpdated || schema.SendUpdate && @event is ContentChangesPublished;
}
private static bool IsDelete(ContentChangedTriggerSchema schema, SchemaEvent @event)
{
return (schema.SendDelete && @event is ContentDeleted);
}
}
}

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

@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<TargetFramework>netcoreapp2.1</TargetFramework>
<RootNamespace>Squidex.Domain.Apps.Core</RootNamespace>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">

2
src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj

@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<TargetFramework>netcoreapp2.1</TargetFramework>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>full</DebugType>

1
src/Squidex.Domain.Apps.Entities/AppProvider.cs

@ -31,6 +31,7 @@ namespace Squidex.Domain.Apps.Entities
Guard.NotNull(localCache, nameof(localCache));
this.grainFactory = grainFactory;
this.localCache = localCache;
}

12
src/Squidex.Domain.Apps.Entities/Apps/AppGrain.cs

@ -26,7 +26,7 @@ using Squidex.Shared.Users;
namespace Squidex.Domain.Apps.Entities.Apps
{
public class AppGrain : SquidexDomainObjectGrain<AppState>, IAppGrain
public sealed class AppGrain : SquidexDomainObjectGrain<AppState>, IAppGrain
{
private readonly InitialPatterns initialPatterns;
private readonly IAppProvider appProvider;
@ -72,14 +72,14 @@ namespace Squidex.Domain.Apps.Entities.Apps
});
case AssignContributor assigneContributor:
return UpdateReturnAsync(assigneContributor, async c =>
return UpdateReturnAsync(assigneContributor, (Func<AssignContributor, Task<object>>)(async c =>
{
await GuardAppContributors.CanAssign(Snapshot.Contributors, c, userResolver, appPlansProvider.GetPlan(Snapshot.Plan?.PlanId));
AssignContributor(c);
return EntityCreatedResult.Create(c.ContributorId, NewVersion);
});
return EntityCreatedResult.Create(c.ContributorId, (long)base.Version);
}));
case RemoveContributor removeContributor:
return UpdateAsync(removeContributor, c =>
@ -334,9 +334,9 @@ namespace Squidex.Domain.Apps.Entities.Apps
return new AppContributorAssigned { ContributorId = actor.Identifier, Permission = AppContributorPermission.Owner };
}
public override void ApplyEvent(Envelope<IEvent> @event)
protected override AppState OnEvent(Envelope<IEvent> @event)
{
ApplySnapshot(Snapshot.Apply(@event));
return Snapshot.Apply(@event);
}
public Task<J<IAppEntity>> GetStateAsync()

22
src/Squidex.Domain.Apps.Entities/Assets/AssetGrain.cs

@ -22,7 +22,7 @@ using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Assets
{
public class AssetGrain : SquidexDomainObjectGrain<AssetState>, IAssetGrain
public sealed class AssetGrain : SquidexDomainObjectGrainLogSnapshots<AssetState>, IAssetGrain
{
public AssetGrain(IStore<Guid> store, ISemanticLog log)
: base(store, log)
@ -34,23 +34,23 @@ namespace Squidex.Domain.Apps.Entities.Assets
switch (command)
{
case CreateAsset createRule:
return CreateReturnAsync(createRule, c =>
return CreateReturnAsync(createRule, (Func<CreateAsset, object>)(c =>
{
GuardAsset.CanCreate(c);
Create(c);
return new AssetSavedResult(NewVersion, Snapshot.FileVersion);
});
return new AssetSavedResult((long)base.Version, Snapshot.FileVersion);
}));
case UpdateAsset updateRule:
return UpdateReturnAsync(updateRule, c =>
return UpdateReturnAsync(updateRule, (Func<UpdateAsset, object>)(c =>
{
GuardAsset.CanUpdate(c);
Update(c);
return new AssetSavedResult(NewVersion, Snapshot.FileVersion);
});
return new AssetSavedResult((long)base.Version, Snapshot.FileVersion);
}));
case RenameAsset renameAsset:
return UpdateAsync(renameAsset, c =>
{
@ -135,14 +135,14 @@ namespace Squidex.Domain.Apps.Entities.Assets
}
}
public override void ApplyEvent(Envelope<IEvent> @event)
protected override AssetState OnEvent(Envelope<IEvent> @event)
{
ApplySnapshot(Snapshot.Apply(@event));
return Snapshot.Apply(@event);
}
public Task<J<IAssetEntity>> GetStateAsync()
public Task<J<IAssetEntity>> GetStateAsync(long version = EtagVersion.Any)
{
return J.AsTask<IAssetEntity>(Snapshot);
return J.AsTask<IAssetEntity>(GetSnapshot(version));
}
}
}

4
src/Squidex.Domain.Apps.Entities/Assets/IAssetGrain.cs

@ -5,11 +5,15 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Orleans;
namespace Squidex.Domain.Apps.Entities.Assets
{
public interface IAssetGrain : IDomainObjectGrain
{
Task<J<IAssetEntity>> GetStateAsync(long version = EtagVersion.Any);
}
}

60
src/Squidex.Domain.Apps.Entities/Contents/ContentGrain.cs

@ -20,12 +20,13 @@ using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Reflection;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Contents
{
public class ContentGrain : SquidexDomainObjectGrain<ContentState>, IContentGrain
public sealed class ContentGrain : SquidexDomainObjectGrainLogSnapshots<ContentState>, IContentGrain
{
private readonly IAppProvider appProvider;
private readonly IAssetRepository assetRepository;
@ -59,7 +60,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
switch (command)
{
case CreateContent createContent:
return CreateReturnAsync(createContent, async c =>
return CreateReturnAsync(createContent, (Func<CreateContent, Task<object>>)(async c =>
{
var ctx = await CreateContext(c.AppId.Id, c.SchemaId.Id, () => "Failed to create content.");
@ -76,8 +77,8 @@ namespace Squidex.Domain.Apps.Entities.Contents
Create(c);
return EntityCreatedResult.Create(c.Data, NewVersion);
});
return EntityCreatedResult.Create(c.Data, (long)base.Version);
}));
case UpdateContent updateContent:
return UpdateReturnAsync(updateContent, c =>
@ -116,9 +117,28 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
else
{
await ctx.ExecuteScriptAsync(x => x.ScriptChange, c.Status, c, Snapshot.Data);
ChangeStatus(c);
var reason = StatusChange.Published;
if (c.Status == Status.Published)
{
reason = StatusChange.Published;
}
else if (c.Status == Status.Archived)
{
reason = StatusChange.Archived;
}
else if (Snapshot.Status == Status.Published)
{
reason = StatusChange.Unpublished;
}
else
{
reason = StatusChange.Restored;
}
await ctx.ExecuteScriptAsync(x => x.ScriptChange, reason, c, Snapshot.Data);
ChangeStatus(c, reason);
}
}
}
@ -196,7 +216,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
return new ContentDataChangedResult(newData, NewVersion);
return new ContentDataChangedResult(newData, Version);
}
public void Create(CreateContent command)
@ -205,7 +225,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
if (command.Publish)
{
RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Status = Status.Published }));
RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Status = Status.Published, Change = StatusChange.Published }));
}
}
@ -244,9 +264,9 @@ namespace Squidex.Domain.Apps.Entities.Contents
RaiseEvent(SimpleMapper.Map(command, new ContentStatusScheduled { DueTime = command.DueTime.Value }));
}
public void ChangeStatus(ChangeContentStatus command)
public void ChangeStatus(ChangeContentStatus command, StatusChange reason)
{
RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged()));
RaiseEvent(SimpleMapper.Map(command, new ContentStatusChanged { Change = reason }));
}
private void RaiseEvent(SchemaEvent @event)
@ -272,22 +292,24 @@ namespace Squidex.Domain.Apps.Entities.Contents
}
}
public override void ApplyEvent(Envelope<IEvent> @event)
protected override ContentState OnEvent(Envelope<IEvent> @event)
{
ApplySnapshot(Snapshot.Apply(@event));
return Snapshot.Apply(@event);
}
private async Task<ContentOperationContext> CreateContext(Guid appId, Guid schemaId, Func<string> message)
{
var operationContext =
await ContentOperationContext.CreateAsync(appId, schemaId,
appProvider,
assetRepository,
contentRepository,
scriptEngine,
message);
await ContentOperationContext.CreateAsync(
appId, schemaId,
appProvider, assetRepository, contentRepository, scriptEngine, message);
return operationContext;
}
public Task<J<IContentEntity>> GetStateAsync(long version = EtagVersion.Any)
{
return J.AsTask<IContentEntity>(GetSnapshot(version));
}
}
}

33
src/Squidex.Domain.Apps.Entities/Contents/ContentVersionLoader.cs

@ -7,52 +7,37 @@
using System;
using System.Threading.Tasks;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Domain.Apps.Entities.Contents.State;
using Orleans;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Contents
{
public sealed class ContentVersionLoader : IContentVersionLoader
{
private readonly IStore<Guid> store;
private readonly FieldRegistry registry;
private readonly IGrainFactory grainFactory;
public ContentVersionLoader(IStore<Guid> store, FieldRegistry registry)
public ContentVersionLoader(IGrainFactory grainFactory)
{
Guard.NotNull(store, nameof(store));
Guard.NotNull(registry, nameof(registry));
Guard.NotNull(grainFactory, nameof(grainFactory));
this.store = store;
this.registry = registry;
this.grainFactory = grainFactory;
}
public async Task<IContentEntity> LoadAsync(Guid id, long version)
{
using (Profiler.TraceMethod<ContentVersionLoader>())
{
var content = new ContentState();
var persistence = store.WithEventSourcing<ContentGrain, Guid>(id, e =>
{
if (content.Version < version)
{
content = content.Apply(e);
content.Version++;
}
});
var grain = grainFactory.GetGrain<IContentGrain>(id);
await persistence.ReadAsync();
var content = await grain.GetStateAsync(version);
if (content.Version != version)
if (content.Value == null || content.Value.Version != version)
{
throw new DomainObjectNotFoundException(id.ToString(), typeof(IContentEntity));
}
return content;
return content.Value;
}
}
}

4
src/Squidex.Domain.Apps.Entities/Contents/IContentGrain.cs

@ -5,11 +5,15 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Threading.Tasks;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Orleans;
namespace Squidex.Domain.Apps.Entities.Contents
{
public interface IContentGrain : IDomainObjectGrain
{
Task<J<IContentEntity>> GetStateAsync(long version = EtagVersion.Any);
}
}

4
src/Squidex.Domain.Apps.Entities/Contents/State/ContentState.cs

@ -32,10 +32,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.State
public NamedContentData DataDraft { get; set; }
[JsonProperty]
public Status Status { get; set; }
public ScheduleJob ScheduleJob { get; set; }
[JsonProperty]
public ScheduleJob ScheduleJob { get; set; }
public Status Status { get; set; }
[JsonProperty]
public bool IsPending { get; set; }

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

@ -0,0 +1,184 @@
// ==========================================================================
// 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;
try
{
return await userResolver.FindByIdOrEmailAsync(actor.Identifier);
}
catch
{
return null;
}
});
}
}
}

22
src/Squidex.Domain.Apps.Entities/Rules/Guards/RuleActionValidator.cs

@ -107,6 +107,28 @@ namespace Squidex.Domain.Apps.Entities.Rules.Guards
return Task.FromResult<IEnumerable<ValidationError>>(errors);
}
public Task<IEnumerable<ValidationError>> Visit(MediumAction action)
{
var errors = new List<ValidationError>();
if (string.IsNullOrWhiteSpace(action.AccessToken))
{
errors.Add(new ValidationError("Access token is required.", nameof(action.AccessToken)));
}
if (string.IsNullOrWhiteSpace(action.Content))
{
errors.Add(new ValidationError("Content is required.", nameof(action.Content)));
}
if (string.IsNullOrWhiteSpace(action.Title))
{
errors.Add(new ValidationError("Title is required.", nameof(action.Title)));
}
return Task.FromResult<IEnumerable<ValidationError>>(errors);
}
public Task<IEnumerable<ValidationError>> Visit(SlackAction action)
{
var errors = new List<ValidationError>();

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

@ -20,7 +20,7 @@ namespace Squidex.Domain.Apps.Entities.Rules
{
public sealed class RuleEnqueuer : IEventConsumer
{
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(2);
private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(10);
private readonly IRuleEventRepository ruleEventRepository;
private readonly IAppProvider appProvider;
private readonly IMemoryCache cache;

4
src/Squidex.Domain.Apps.Entities/Rules/RuleGrain.cs

@ -123,9 +123,9 @@ namespace Squidex.Domain.Apps.Entities.Rules
}
}
public override void ApplyEvent(Envelope<IEvent> @event)
protected override RuleState OnEvent(Envelope<IEvent> @event)
{
ApplySnapshot(Snapshot.Apply(@event));
return Snapshot.Apply(@event);
}
public Task<J<IRuleEntity>> GetStateAsync()

2
src/Squidex.Domain.Apps.Entities/Schemas/Commands/CreateSchemaNestedField.cs

@ -5,8 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Schemas;
namespace Squidex.Domain.Apps.Entities.Schemas.Commands
{
public sealed class CreateSchemaNestedField : CreateSchemaFieldBase

12
src/Squidex.Domain.Apps.Entities/Schemas/SchemaGrain.cs

@ -24,7 +24,7 @@ using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities.Schemas
{
public class SchemaGrain : SquidexDomainObjectGrain<SchemaState>, ISchemaGrain
public sealed class SchemaGrain : SquidexDomainObjectGrain<SchemaState>, ISchemaGrain
{
private readonly IAppProvider appProvider;
private readonly FieldRegistry registry;
@ -47,7 +47,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas
switch (command)
{
case AddField addField:
return UpdateReturnAsync(addField, c =>
return UpdateReturnAsync(addField, (Func<AddField, object>)(c =>
{
GuardSchemaField.CanAdd(Snapshot.SchemaDef, c);
@ -64,8 +64,8 @@ namespace Squidex.Domain.Apps.Entities.Schemas
id = ((IArrayField)Snapshot.SchemaDef.FieldsById[c.ParentFieldId.Value]).FieldsByName[c.Name].Id;
}
return EntityCreatedResult.Create(id, NewVersion);
});
return EntityCreatedResult.Create(id, (long)base.Version);
}));
case CreateSchema createSchema:
return CreateAsync(createSchema, async c =>
@ -360,9 +360,9 @@ namespace Squidex.Domain.Apps.Entities.Schemas
}
}
public override void ApplyEvent(Envelope<IEvent> @event)
protected override SchemaState OnEvent(Envelope<IEvent> @event)
{
ApplySnapshot(Snapshot.Apply(@event, registry));
return Snapshot.Apply(@event, registry);
}
public Task<J<ISchemaEntity>> GetStateAsync()

2
src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj

@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<TargetFramework>netcoreapp2.1</TargetFramework>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>full</DebugType>

34
src/Squidex.Domain.Apps.Entities/SquidexDomainObjectGrainLogSnapshots.cs

@ -0,0 +1,34 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Squidex.Domain.Apps.Events;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.States;
namespace Squidex.Domain.Apps.Entities
{
public abstract class SquidexDomainObjectGrainLogSnapshots<T> : LogSnapshotDomainObjectGrain<T> where T : IDomainState, new()
{
protected SquidexDomainObjectGrainLogSnapshots(IStore<Guid> store, ISemanticLog log)
: base(store, log)
{
}
public override void RaiseEvent(Envelope<IEvent> @event)
{
if (@event.Payload is AppEvent appEvent)
{
@event.SetAppId(appEvent.AppId.Id);
}
base.RaiseEvent(@event);
}
}
}

2
src/Squidex.Domain.Apps.Events/Contents/ContentStatusChanged.cs

@ -13,6 +13,8 @@ namespace Squidex.Domain.Apps.Events.Contents
[EventType(nameof(ContentStatusChanged))]
public sealed class ContentStatusChanged : ContentEvent
{
public StatusChange? Change { get; set; }
public Status Status { get; set; }
}
}

2
src/Squidex.Domain.Apps.Events/Schemas/SchemaCreatedNestedField.cs

@ -5,8 +5,6 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using Squidex.Domain.Apps.Core.Schemas;
namespace Squidex.Domain.Apps.Events.Schemas
{
public sealed class SchemaCreatedNestedField : SchemaCreatedFieldBase

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

@ -6,220 +6,63 @@
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.States;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Infrastructure.Commands
{
public abstract class DomainObjectGrain<T> : GrainOfGuid, IDomainObjectGrain where T : IDomainState, new()
public abstract class DomainObjectGrain<T> : DomainObjectGrainBase<T> where T : IDomainState, new()
{
private readonly List<Envelope<IEvent>> uncomittedEvents = new List<Envelope<IEvent>>();
private readonly IStore<Guid> store;
private readonly ISemanticLog log;
private Guid id;
private T snapshot = new T { Version = EtagVersion.Empty };
private IPersistence<T> persistence;
public Guid Id
{
get { return id; }
}
public long Version
{
get { return snapshot.Version; }
}
public long NewVersion
{
get { return snapshot.Version + uncomittedEvents.Count; }
}
public T Snapshot
public override T Snapshot
{
get { return snapshot; }
}
protected DomainObjectGrain(IStore<Guid> store, ISemanticLog log)
: base(log)
{
Guard.NotNull(store, nameof(store));
Guard.NotNull(log, nameof(log));
this.store = store;
this.log = log;
}
public override async Task OnActivateAsync(Guid key)
{
using (log.MeasureInformation(w => w
.WriteProperty("action", "ActivateDomainObject")
.WriteProperty("domainObjectType", GetType().Name)
.WriteProperty("domainObjectKey", key.ToString())))
{
id = key;
persistence = store.WithSnapshotsAndEventSourcing<T, Guid>(GetType(), id, ApplySnapshot, ApplyEvent);
await persistence.ReadAsync();
}
}
public void RaiseEvent(IEvent @event)
{
RaiseEvent(Envelope.Create(@event));
}
public virtual void RaiseEvent(Envelope<IEvent> @event)
protected sealed override void ApplyEvent(Envelope<IEvent> @event)
{
Guard.NotNull(@event, nameof(@event));
@event.SetAggregateId(id);
ApplyEvent(@event);
uncomittedEvents.Add(@event);
}
var newVersion = Version + 1;
public IReadOnlyList<Envelope<IEvent>> GetUncomittedEvents()
{
return uncomittedEvents;
}
var snapshotNew = OnEvent(@event);
public void ClearUncommittedEvents()
{
uncomittedEvents.Clear();
snapshot = OnEvent(@event);
snapshot.Version = newVersion;
}
public virtual void ApplySnapshot(T newSnapshot)
protected sealed override void RestorePreviousSnapshot(T previousSnapshot, long previousVersion)
{
snapshot = newSnapshot;
snapshot = previousSnapshot;
}
public virtual void ApplyEvent(Envelope<IEvent> @event)
protected sealed override Task ReadAsync(Type type, Guid id)
{
}
persistence = store.WithSnapshotsAndEventSourcing<T, Guid>(GetType(), id, x => snapshot = x, ApplyEvent);
protected Task<object> CreateReturnAsync<TCommand>(TCommand command, Func<TCommand, Task<object>> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler, false);
}
protected Task<object> CreateReturnAsync<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToAsync(), false);
}
protected Task<object> CreateAsync<TCommand>(TCommand command, Func<TCommand, Task> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler.ToDefault<TCommand, object>(), false);
}
protected Task<object> CreateAsync<TCommand>(TCommand command, Action<TCommand> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToDefault<TCommand, object>()?.ToAsync(), false);
return persistence.ReadAsync();
}
protected Task<object> UpdateReturnAsync<TCommand>(TCommand command, Func<TCommand, Task<object>> handler) where TCommand : class, IAggregateCommand
protected sealed override async Task WriteAsync(Envelope<IEvent>[] events, long previousVersion)
{
return InvokeAsync(command, handler, true);
}
protected Task<object> UpdateReturnAsync<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToAsync(), true);
}
protected Task<object> UpdateAsync<TCommand>(TCommand command, Func<TCommand, Task> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToDefault<TCommand, object>(), true);
}
protected Task<object> UpdateAsync<TCommand>(TCommand command, Action<TCommand> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToDefault<TCommand, object>()?.ToAsync(), true);
}
private async Task<object> InvokeAsync<TCommand>(TCommand command, Func<TCommand, Task<object>> handler, bool isUpdate) where TCommand : class, IAggregateCommand
{
Guard.NotNull(command, nameof(command));
if (command.ExpectedVersion != EtagVersion.Any && command.ExpectedVersion != Version)
if (events.Length > 0)
{
throw new DomainObjectVersionException(id.ToString(), GetType(), Version, command.ExpectedVersion);
await persistence.WriteEventsAsync(events);
await persistence.WriteSnapshotAsync(Snapshot);
}
if (isUpdate && Version < 0)
{
try
{
DeactivateOnIdle();
}
catch (InvalidOperationException)
{
}
throw new DomainObjectNotFoundException(id.ToString(), GetType());
}
if (!isUpdate && Version >= 0)
{
throw new DomainException("Object has already been created.");
}
var previousSnapshot = snapshot;
try
{
var result = await handler(command);
var events = uncomittedEvents.ToArray();
if (events.Length > 0)
{
snapshot.Version = NewVersion;
await persistence.WriteEventsAsync(events);
await persistence.WriteSnapshotAsync(snapshot);
}
if (result == null)
{
if (isUpdate)
{
result = new EntitySavedResult(Version);
}
else
{
result = EntityCreatedResult.Create(id, Version);
}
}
return result;
}
catch
{
snapshot = previousSnapshot;
throw;
}
finally
{
uncomittedEvents.Clear();
}
}
public async Task<J<object>> ExecuteAsync(J<IAggregateCommand> command)
{
var result = await ExecuteAsync(command.Value);
return result.AsJ();
}
protected abstract Task<object> ExecuteAsync(IAggregateCommand command);
protected abstract T OnEvent(Envelope<IEvent> @event);
}
}
}

202
src/Squidex.Infrastructure/Commands/DomainObjectGrainBase.cs

@ -0,0 +1,202 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.Tasks;
namespace Squidex.Infrastructure.Commands
{
public abstract class DomainObjectGrainBase<T> : GrainOfGuid, IDomainObjectGrain where T : IDomainState, new()
{
private readonly List<Envelope<IEvent>> uncomittedEvents = new List<Envelope<IEvent>>();
private readonly ISemanticLog log;
private Guid id;
public Guid Id
{
get { return id; }
}
public long Version
{
get { return Snapshot.Version; }
}
public abstract T Snapshot { get; }
protected DomainObjectGrainBase(ISemanticLog log)
{
Guard.NotNull(log, nameof(log));
this.log = log;
}
public sealed override async Task OnActivateAsync(Guid key)
{
using (log.MeasureInformation(w => w
.WriteProperty("action", "ActivateDomainObject")
.WriteProperty("domainObjectType", GetType().Name)
.WriteProperty("domainObjectKey", key.ToString())))
{
id = key;
await ReadAsync(GetType(), id);
}
}
public void RaiseEvent(IEvent @event)
{
RaiseEvent(Envelope.Create(@event));
}
public virtual void RaiseEvent(Envelope<IEvent> @event)
{
Guard.NotNull(@event, nameof(@event));
@event.SetAggregateId(id);
ApplyEvent(@event);
uncomittedEvents.Add(@event);
}
public IReadOnlyList<Envelope<IEvent>> GetUncomittedEvents()
{
return uncomittedEvents;
}
public void ClearUncommittedEvents()
{
uncomittedEvents.Clear();
}
protected Task<object> CreateReturnAsync<TCommand>(TCommand command, Func<TCommand, Task<object>> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler, false);
}
protected Task<object> CreateReturnAsync<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToAsync(), false);
}
protected Task<object> CreateAsync<TCommand>(TCommand command, Func<TCommand, Task> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler.ToDefault<TCommand, object>(), false);
}
protected Task<object> CreateAsync<TCommand>(TCommand command, Action<TCommand> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToDefault<TCommand, object>()?.ToAsync(), false);
}
protected Task<object> UpdateReturnAsync<TCommand>(TCommand command, Func<TCommand, Task<object>> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler, true);
}
protected Task<object> UpdateReturnAsync<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToAsync(), true);
}
protected Task<object> UpdateAsync<TCommand>(TCommand command, Func<TCommand, Task> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToDefault<TCommand, object>(), true);
}
protected Task<object> UpdateAsync<TCommand>(TCommand command, Action<TCommand> handler) where TCommand : class, IAggregateCommand
{
return InvokeAsync(command, handler?.ToDefault<TCommand, object>()?.ToAsync(), true);
}
private async Task<object> InvokeAsync<TCommand>(TCommand command, Func<TCommand, Task<object>> handler, bool isUpdate) where TCommand : class, IAggregateCommand
{
Guard.NotNull(command, nameof(command));
if (command.ExpectedVersion != EtagVersion.Any && command.ExpectedVersion != Version)
{
throw new DomainObjectVersionException(id.ToString(), GetType(), Version, command.ExpectedVersion);
}
if (isUpdate && Version < 0)
{
try
{
DeactivateOnIdle();
}
catch (InvalidOperationException)
{
}
throw new DomainObjectNotFoundException(id.ToString(), GetType());
}
if (!isUpdate && Version >= 0)
{
throw new DomainException("Object has already been created.");
}
var previousSnapshot = Snapshot;
var previousVersion = Version;
try
{
var result = await handler(command);
var events = uncomittedEvents.ToArray();
await WriteAsync(events, previousVersion);
if (result == null)
{
if (isUpdate)
{
result = new EntitySavedResult(Version);
}
else
{
result = EntityCreatedResult.Create(id, Version);
}
}
return result;
}
catch
{
RestorePreviousSnapshot(previousSnapshot, previousVersion);
throw;
}
finally
{
uncomittedEvents.Clear();
}
}
protected abstract void RestorePreviousSnapshot(T previousSnapshot, long previousVersion);
protected abstract void ApplyEvent(Envelope<IEvent> @event);
protected abstract Task ReadAsync(Type type, Guid id);
protected abstract Task WriteAsync(Envelope<IEvent>[] events, long previousVersion);
public async Task<J<object>> ExecuteAsync(J<IAggregateCommand> command)
{
var result = await ExecuteAsync(command.Value);
return result.AsJ();
}
protected abstract Task<object> ExecuteAsync(IAggregateCommand command);
}
}

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

@ -0,0 +1,93 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.States;
namespace Squidex.Infrastructure.Commands
{
public abstract class LogSnapshotDomainObjectGrain<T> : DomainObjectGrainBase<T> where T : IDomainState, new()
{
private readonly IStore<Guid> store;
private readonly List<T> snapshots = new List<T> { new T { Version = EtagVersion.Empty } };
private IPersistence persistence;
public override T Snapshot
{
get { return snapshots.Last(); }
}
protected LogSnapshotDomainObjectGrain(IStore<Guid> store, ISemanticLog log)
: base(log)
{
Guard.NotNull(log, nameof(log));
this.store = store;
}
public T GetSnapshot(long version)
{
if (version == EtagVersion.Any)
{
return Snapshot;
}
if (version == EtagVersion.Empty)
{
return snapshots[0];
}
if (version >= 0 && version < snapshots.Count - 1)
{
return snapshots[(int)version + 1];
}
return default(T);
}
protected sealed override void ApplyEvent(Envelope<IEvent> @event)
{
var snapshot = OnEvent(@event);
snapshot.Version = Version + 1;
snapshots.Add(snapshot);
}
protected sealed override Task ReadAsync(Type type, Guid id)
{
persistence = store.WithEventSourcing<Guid>(type, id, ApplyEvent);
return persistence.ReadAsync();
}
protected sealed override async Task WriteAsync(Envelope<IEvent>[] events, long previousVersion)
{
if (events.Length > 0)
{
var persistedSnapshots = store.GetSnapshotStore<T>();
await persistence.WriteEventsAsync(events);
await persistedSnapshots.WriteAsync(Id, Snapshot, previousVersion, previousVersion + events.Length);
}
}
protected sealed override void RestorePreviousSnapshot(T previousSnapshot, long previousVersion)
{
while (snapshots.Count > previousVersion + 2)
{
snapshots.RemoveAt(snapshots.Count - 1);
}
}
protected abstract T OnEvent(Envelope<IEvent> @event);
}
}

12
src/Squidex.Infrastructure/Http/DumpFormatter.cs

@ -15,7 +15,17 @@ namespace Squidex.Infrastructure.Http
{
public static class DumpFormatter
{
public static string BuildDump(HttpRequestMessage request, HttpResponseMessage response, string requestBody, string responseBody, TimeSpan elapsed, bool isTimeout)
public static string BuildDump(HttpRequestMessage request, HttpResponseMessage response, string responseBody)
{
return BuildDump(request, response, null, responseBody, TimeSpan.Zero, false);
}
public static string BuildDump(HttpRequestMessage request, HttpResponseMessage response, string requestBody, string responseBody)
{
return BuildDump(request, response, requestBody, responseBody, TimeSpan.Zero, false);
}
public static string BuildDump(HttpRequestMessage request, HttpResponseMessage response, string requestBody, string responseBody, TimeSpan elapsed, bool isTimeout = false)
{
var writer = new StringBuilder();

2
src/Squidex.Infrastructure/Orleans/GrainOfGuid.cs

@ -14,7 +14,7 @@ namespace Squidex.Infrastructure.Orleans
{
public abstract class GrainOfGuid : Grain
{
public override Task OnActivateAsync()
public sealed override Task OnActivateAsync()
{
return OnActivateAsync(this.GetPrimaryKey());
}

2
src/Squidex.Infrastructure/Orleans/GrainOfString.cs

@ -13,7 +13,7 @@ namespace Squidex.Infrastructure.Orleans
{
public abstract class GrainOfString : Grain
{
public override Task OnActivateAsync()
public sealed override Task OnActivateAsync()
{
return OnActivateAsync(this.GetPrimaryKeyString());
}

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

@ -5,24 +5,9 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Threading.Tasks;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Infrastructure.States
{
public interface IPersistence : IPersistence<object>
{
}
public interface IPersistence<TState>
{
long Version { get; }
Task WriteEventsAsync(IEnumerable<Envelope<IEvent>> @events);
Task WriteSnapshotAsync(TState state);
Task ReadAsync(long expectedVersion = EtagVersion.Any);
}
}

24
src/Squidex.Infrastructure/States/IPersistence{TState}.cs

@ -0,0 +1,24 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Threading.Tasks;
using Squidex.Infrastructure.EventSourcing;
namespace Squidex.Infrastructure.States
{
public interface IPersistence<TState>
{
long Version { get; }
Task WriteEventsAsync(IEnumerable<Envelope<IEvent>> @events);
Task WriteSnapshotAsync(TState state);
Task ReadAsync(long expectedVersion = EtagVersion.Any);
}
}

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

@ -19,6 +19,8 @@ namespace Squidex.Infrastructure.States
IPersistence<TState> WithSnapshotsAndEventSourcing<TState>(Type owner, TKey key, Func<TState, Task> applySnapshot, Func<Envelope<IEvent>, Task> applyEvent);
ISnapshotStore<TState, TKey> GetSnapshotStore<TState>();
Task ClearSnapshotsAsync<TState>();
}
}

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

@ -44,7 +44,7 @@ namespace Squidex.Infrastructure.States
{
Guard.NotNull(key, nameof(key));
var snapshotStore = (ISnapshotStore<object, TKey>)services.GetService(typeof(ISnapshotStore<object, TKey>));
var snapshotStore = GetSnapshotStore<object>();
return new Persistence<TKey>(key, owner, eventStore, eventDataFormatter, snapshotStore, streamNameResolver, applyEvent);
}
@ -53,16 +53,19 @@ namespace Squidex.Infrastructure.States
{
Guard.NotNull(key, nameof(key));
var snapshotStore = (ISnapshotStore<TState, TKey>)services.GetService(typeof(ISnapshotStore<TState, TKey>));
var snapshotStore = GetSnapshotStore<TState>();
return new Persistence<TState, TKey>(key, owner, eventStore, eventDataFormatter, snapshotStore, streamNameResolver, mode, applySnapshot, applyEvent);
}
public Task ClearSnapshotsAsync<TState>()
{
var snapshotStore = (ISnapshotStore<TState, TKey>)services.GetService(typeof(ISnapshotStore<TState, TKey>));
return GetSnapshotStore<TState>().ClearAsync();
}
return snapshotStore.ClearAsync();
public ISnapshotStore<TState, TKey> GetSnapshotStore<TState>()
{
return (ISnapshotStore<TState, TKey>)services.GetService(typeof(ISnapshotStore<TState, TKey>));
}
}
}

1
src/Squidex.Infrastructure/Validate.cs

@ -7,7 +7,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace Squidex.Infrastructure

2
src/Squidex.Infrastructure/ValidationException.cs

@ -21,7 +21,7 @@ namespace Squidex.Infrastructure
public IReadOnlyList<ValidationError> Errors
{
get { return errors; }
get { return errors ?? FallbackErrors; }
}
public string Summary { get; }

57
src/Squidex/Areas/Api/Controllers/Rules/Models/Actions/MediumActionDto.cs

@ -0,0 +1,57 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.ComponentModel.DataAnnotations;
using NJsonSchema.Annotations;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Actions;
using Squidex.Infrastructure.Reflection;
namespace Squidex.Areas.Api.Controllers.Rules.Models.Actions
{
[JsonSchema("Medium")]
public class MediumActionDto : RuleActionDto
{
/// <summary>
/// The self issued access token.
/// </summary>
[Required]
public string AccessToken { get; set; }
/// <summary>
/// The optional comma separated list of tags.
/// </summary>
public string Tags { get; set; }
/// <summary>
/// The title, used for the url.
/// </summary>
[Required]
public string Title { get; set; }
/// <summary>
/// The content, either html or markdown.
/// </summary>
[Required]
public string Content { get; set; }
/// <summary>
/// The original home of this content, if it was originally published elsewhere.
/// </summary>
public string CanonicalUrl { get; set; }
/// <summary>
/// Indicates whether the content is markdown or html.
/// </summary>
public bool IsHtml { get; set; }
public override RuleAction ToAction()
{
return SimpleMapper.Map(this, new MediumAction());
}
}
}

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

@ -45,6 +45,11 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models.Converters
return SimpleMapper.Map(action, new FastlyActionDto());
}
public RuleActionDto Visit(MediumAction action)
{
return SimpleMapper.Map(action, new MediumActionDto());
}
public RuleActionDto Visit(SlackAction action)
{
return SimpleMapper.Map(action, new SlackActionDto());

15
src/Squidex/Areas/Api/Controllers/Rules/Models/Triggers/ContentChangedTriggerSchemaDto.cs

@ -35,5 +35,20 @@ namespace Squidex.Areas.Api.Controllers.Rules.Models.Triggers
/// Determines whether to handle the event when a content is published.
/// </summary>
public bool SendPublish { get; set; }
/// <summary>
/// Determines whether to handle the event when a content is unpublished.
/// </summary>
public bool SendUnpublish { get; set; }
/// <summary>
/// Determines whether to handle the event when a content is archived.
/// </summary>
public bool SendArchived { get; set; }
/// <summary>
/// Determines whether to handle the event when a content is restored.
/// </summary>
public bool SendRestore { get; set; }
}
}

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

@ -18,6 +18,9 @@ namespace Squidex.Config.Domain
{
public static void AddMyRuleServices(this IServiceCollection services)
{
services.AddSingletonAs<EventEnricher>()
.As<IEventEnricher>();
services.AddSingletonAs<AssetChangedTriggerHandler>()
.As<IRuleTriggerHandler>();
@ -36,6 +39,9 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<FastlyActionHandler>()
.As<IRuleActionHandler>();
services.AddSingletonAs<MediumActionHandler>()
.As<IRuleActionHandler>();
services.AddSingletonAs<SlackActionHandler>()
.As<IRuleActionHandler>();

2
src/Squidex/Pipeline/ApiExceptionFilterAttribute.cs

@ -54,7 +54,7 @@ namespace Squidex.Pipeline
private static IActionResult OnValidationException(ValidationException ex)
{
return ErrorResult(400, new ErrorDto { Message = ex.Summary, Details = ex.Errors.Select(e => e.Message).ToArray() });
return ErrorResult(400, new ErrorDto { Message = ex.Summary, Details = ex.Errors?.Select(e => e.Message).ToArray() });
}
private static IActionResult ErrorResult(int statusCode, ErrorDto error)

1
src/Squidex/Program.cs

@ -6,7 +6,6 @@
// ==========================================================================
using System.IO;
using Microsoft.AspNetCore;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;

2
src/Squidex/WebStartup.cs

@ -5,9 +5,7 @@
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Squidex.Areas.Api;

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

@ -9,6 +9,7 @@ export * from './pages/rules/actions/algolia-action.component';
export * from './pages/rules/actions/azure-queue-action.component';
export * from './pages/rules/actions/elastic-search-action.component';
export * from './pages/rules/actions/fastly-action.component';
export * from './pages/rules/actions/medium-action.component';
export * from './pages/rules/actions/slack-action.component';
export * from './pages/rules/actions/webhook-action.component';
export * from './pages/rules/triggers/asset-changed-trigger.component';

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

@ -21,6 +21,7 @@ import {
ContentChangedTriggerComponent,
ElasticSearchActionComponent,
FastlyActionComponent,
MediumActionComponent,
RuleEventBadgeClassPipe,
RuleEventsPageComponent,
RulesPageComponent,
@ -62,6 +63,7 @@ const routes: Routes = [
ContentChangedTriggerComponent,
ElasticSearchActionComponent,
FastlyActionComponent,
MediumActionComponent,
RuleEventBadgeClassPipe,
RuleEventsPageComponent,
RulesPageComponent,

84
src/Squidex/app/features/rules/pages/rules/actions/medium-action.component.html

@ -0,0 +1,84 @@
<h3 class="wizard-title">Post to Medium</h3>
<div [formGroup]="actionForm" class="form-horizontal">
<div class="form-group row">
<label class="col col-3 col-form-label" for="accessToken">Access Token</label>
<div class="col col-9">
<sqx-control-errors for="accessToken" [submitted]="actionFormSubmitted"></sqx-control-errors>
<input type="text" class="form-control" id="accessToken" formControlName="accessToken" />
<small class="form-text text-muted">
The self issued access token. Can be created under <a target="_blank" href="https://medium.com/me/settings">https://medium.com/me/settings</a>.
</small>
</div>
</div>
<div class="form-group row">
<label class="col col-3 col-form-label" for="title">Title</label>
<div class="col col-9">
<sqx-control-errors for="title" [submitted]="actionFormSubmitted"></sqx-control-errors>
<input type="text" class="form-control" id="title" formControlName="title" />
<small class="form-text text-muted">
The title of the post. Note that this title is used for SEO and when rendering the post as a listing.
</small>
</div>
</div>
<div class="form-group row">
<label class="col col-3 col-form-label" for="content">Content</label>
<div class="col col-9">
<sqx-control-errors for="content" [submitted]="actionFormSubmitted"></sqx-control-errors>
<textarea class="form-control" id="content" formControlName="content"></textarea>
<small class="form-text text-muted">
The body of the post, in a valid, semantic, HTML fragment, or Markdown.
</small>
</div>
</div>
<div class="form-group row">
<div class="col col-9 offset-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isHtml" formControlName="isHtml" />
<label class="form-check-label" for="isHtml">
Is Html content
</label>
</div>
</div>
</div>
<div class="form-group row">
<label class="col col-3 col-form-label" for="canonicalUrl">Canonical URL</label>
<div class="col col-9">
<sqx-control-errors for="canonicalUrl" [submitted]="actionFormSubmitted"></sqx-control-errors>
<input type="text" class="form-control" id="canonicalUrl" formControlName="canonicalUrl" />
<small class="form-text text-muted">
The original home of this content, if it was originally published elsewhere.
</small>
</div>
</div>
<div class="form-group row">
<label class="col col-3 col-form-label" for="tags">Tags</label>
<div class="col col-9">
<sqx-control-errors for="tags" [submitted]="actionFormSubmitted"></sqx-control-errors>
<input type="text" class="form-control" id="tags" formControlName="tags" />
<small class="form-text text-muted">
Comma-separated list of tags.
</small>
</div>
</div>
</div>

6
src/Squidex/app/features/rules/pages/rules/actions/medium-action.component.scss

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

51
src/Squidex/app/features/rules/pages/rules/actions/medium-action.component.ts

@ -0,0 +1,51 @@
/*
* 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';
@Component({
selector: 'sqx-medium-action',
styleUrls: ['./medium-action.component.scss'],
templateUrl: './medium-action.component.html'
})
export class MediumActionComponent implements OnInit {
@Input()
public action: any;
@Input()
public actionForm: FormGroup;
@Input()
public actionFormSubmitted = false;
public ngOnInit() {
this.actionForm.setControl('accessToken',
new FormControl(this.action.accessToken || '', [
Validators.required
]));
this.actionForm.setControl('title',
new FormControl(this.action.title || '', [
Validators.required
]));
this.actionForm.setControl('content',
new FormControl(this.action.content || '', [
Validators.required
]));
this.actionForm.setControl('canonicalUrl',
new FormControl(this.action.canonicalUrl || ''));
this.actionForm.setControl('tags',
new FormControl(this.action.tags || ''));
this.actionForm.setControl('isHtml',
new FormControl(this.action.isHtml || false));
}
}

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

@ -21,16 +21,18 @@
</ng-container>
<ng-container content>
<ng-container *ngIf="step === 1">
<span *ngFor="let trigger of ruleTriggers | sqxKeys" class="rule-element rule-element-{{trigger}}" (click)="selectTriggerType(trigger)">
<span class="rule-element-icon">
<i class="icon-trigger-{{trigger}}"></i>
</span>
<span class="rule-element-text">
{{ruleTriggers[trigger].name}}
</span>
</span>
</ng-container>
<div class="row no-gutters" *ngIf="step === 1">
<div *ngFor="let trigger of ruleTriggers | sqxKeys" class="col-12 col-md-6 col-lg-4">
<div class="rule-element rule-element-{{trigger}} " (click)="selectTriggerType(trigger)">
<span class="rule-element-icon">
<i class="icon-trigger-{{trigger}}"></i>
</span>
<span class="rule-element-text">
{{ruleTriggers[trigger].name}}
</span>
</div>
</div>
</div>
<ng-container *ngIf="step === 2 && schemas">
<form [formGroup]="triggerForm.form" (submit)="saveTrigger()">
@ -54,16 +56,18 @@
</form>
</ng-container>
<ng-container *ngIf="step === 3">
<span *ngFor="let action of ruleActions | sqxKeys" class="rule-element rule-element-{{action}}" (click)="selectActionType(action)">
<span class="rule-element-icon">
<i class="icon-action-{{action}}"></i>
</span>
<span class="rule-element-text">
{{ruleActions[action].name}}
</span>
</span>
</ng-container>
<div class="row no-gutters" *ngIf="step === 3">
<div *ngFor="let action of ruleActions | sqxKeys" class="col-12 col-md-6 col-lg-4">
<div class="rule-element rule-element-{{action}} " (click)="selectActionType(action)">
<span class="rule-element-icon">
<i class="icon-action-{{action}}"></i>
</span>
<span class="rule-element-text">
{{ruleActions[action].name}}
</span>
</div>
</div>
</div>
<ng-container *ngIf="step === 4">
<form [formGroup]="actionForm.form" (submit)="saveAction()">
@ -96,6 +100,13 @@
[actionFormSubmitted]="actionForm.submitted | async">
</sqx-fastly-action>
</ng-container>
<ng-container *ngSwitchCase="'Medium'">
<sqx-medium-action
[action]="action"
[actionForm]="actionForm.form"
[actionFormSubmitted]="actionForm.submitted | async">
</sqx-medium-action>
</ng-container>
<ng-container *ngSwitchCase="'Slack'">
<sqx-slack-action
[action]="action"

2
src/Squidex/app/features/rules/pages/rules/rule-wizard.component.scss

@ -2,5 +2,5 @@
@import '_mixins';
.rule-element {
margin-right: .5rem;
margin: .25rem;
}

4
src/Squidex/app/features/rules/pages/rules/rules-page.component.scss

@ -7,4 +7,8 @@ sqx-toggle {
.rule-element {
display: block;
}
h3 {
margin: 0;
}

21
src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.html

@ -10,6 +10,9 @@
<col style="width: 40px" />
<col style="width: 40px" />
<col style="width: 40px" />
<col style="width: 40px" />
<col style="width: 40px" />
<col style="width: 40px" />
</colgroup>
<tr>
@ -31,6 +34,15 @@
<th class="text-center">
<div class="rotated-label">Published</div>
</th>
<th class="text-center">
<div class="rotated-label">Unpublished</div>
</th>
<th class="text-center">
<div class="rotated-label">Archived</div>
</th>
<th class="text-center">
<div class="rotated-label">Restored</div>
</th>
<th></th>
</tr>
@ -53,6 +65,15 @@
<td class="text-center" title="Published">
<input type="checkbox" [ngModel]="schema.sendPublish" (ngModelChange)="toggle(schema, 'sendPublish')" />
</td>
<td class="text-center" title="Unpublished">
<input type="checkbox" [ngModel]="schema.sendUnpublish" (ngModelChange)="toggle(schema, 'sendUnpublish')" />
</td>
<td class="text-center" title="Archived">
<input type="checkbox" [ngModel]="schema.sendArchived" (ngModelChange)="toggle(schema, 'sendArchived')" />
</td>
<td class="text-center" title="Restored">
<input type="checkbox" [ngModel]="schema.sendRestored" (ngModelChange)="toggle(schema, 'sendRestored')" />
</td>
<td class="text-center">
<button type="button" class="btn btn-link btn-secondary" (click)="removeSchema(schema)">
<i class="icon-close"></i>

26
src/Squidex/app/features/rules/pages/rules/triggers/content-changed-trigger.component.ts

@ -21,6 +21,9 @@ export interface TriggerSchemaForm {
sendUpdate: boolean;
sendDelete: boolean;
sendPublish: boolean;
sendUnpublish: boolean;
sendArchive: boolean;
sendRestore: boolean;
}
@Component({
@ -71,7 +74,10 @@ export class ContentChangedTriggerComponent implements OnInit {
sendCreate: triggerSchema.sendCreate,
sendUpdate: triggerSchema.sendUpdate,
sendDelete: triggerSchema.sendDelete,
sendPublish: triggerSchema.sendPublish
sendPublish: triggerSchema.sendPublish,
sendUnpublish: triggerSchema.sendUnpublish,
sendArchive: triggerSchema.sendArchive,
sendRestore: triggerSchema.sendRestore
});
} else {
return null;
@ -104,7 +110,10 @@ export class ContentChangedTriggerComponent implements OnInit {
sendCreate: false,
sendUpdate: false,
sendDelete: false,
sendPublish: false
sendPublish: false,
sendUnpublish: false,
sendArchive: false,
sendRestore: false
})).sortByStringAsc(x => x.schema.name);
this.updateValue();
@ -137,7 +146,10 @@ export class ContentChangedTriggerComponent implements OnInit {
sendCreate: s.sendCreate,
sendUpdate: s.sendUpdate,
sendDelete: s.sendDelete,
sendPublish: s.sendPublish
sendPublish: s.sendPublish,
sendUnpublish: s.sendUnpublish,
sendArchive: s.sendArchive,
sendRestore: s.sendRestore
};
});
@ -150,6 +162,9 @@ export class ContentChangedTriggerComponent implements OnInit {
schemaForm.sendUpdate = value;
schemaForm.sendDelete = value;
schemaForm.sendPublish = value;
schemaForm.sendUnpublish = value;
schemaForm.sendArchive = value;
schemaForm.sendRestore = value;
return schemaForm;
}
@ -159,7 +174,10 @@ export class ContentChangedTriggerComponent implements OnInit {
schemaForm.sendCreate &&
schemaForm.sendUpdate &&
schemaForm.sendDelete &&
schemaForm.sendPublish;
schemaForm.sendPublish &&
schemaForm.sendUnpublish &&
schemaForm.sendArchive &&
schemaForm.sendRestore;
return schemaForm;
}

3
src/Squidex/app/shared/services/rules.service.ts

@ -43,6 +43,9 @@ export const ruleActions: any = {
'Fastly': {
name: 'Purge fastly Cache'
},
'Medium': {
name: 'Post to Medium'
},
'Slack': {
name: 'Send to Slack'
},

2
src/Squidex/app/shared/state/rules.state.ts

@ -158,7 +158,7 @@ const updateTrigger = (rule: RuleDto, trigger: any, user: string, version: Versi
const updateAction = (rule: RuleDto, action: any, user: string, version: Version, now?: DateTime) =>
rule.with({
action,
actionType: action.triggerType,
actionType: action.actionType,
lastModified: now || DateTime.now(),
lastModifiedBy: user,
version

7
src/Squidex/app/theme/_rules.scss

@ -10,6 +10,7 @@ $action-algolia: #0d9bf9;
$action-slack: #5c3a58;
$action-azure: #55b3ff;
$action-fastly: #e23335;
$action-medium: #00ab6c;
// sass-lint:disable class-name-format
@ -37,12 +38,10 @@ $action-fastly: #e23335;
@include transition(background-color .4s ease);
color: $color-dark-foreground;
cursor: pointer;
display: inline-block;
line-height: 2.8rem;
font-size: 1rem;
font-weight: normal;
padding-right: .8rem;
width: 15rem;
}
&-icon {
@ -81,6 +80,10 @@ $action-fastly: #e23335;
@include build-element($action-fastly);
}
.rule-element-Medium {
@include build-element($action-medium);
}
.rule-element-Slack {
@include build-element($action-slack);
}

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

@ -8,19 +8,14 @@
using System;
using System.Collections.Generic;
using System.Security.Claims;
using System.Threading.Tasks;
using FakeItEasy;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using NodaTime;
using Squidex.Domain.Apps.Core.Contents;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Contents;
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents;
using Squidex.Infrastructure;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Shared.Identity;
using Squidex.Shared.Users;
using Xunit;
@ -30,8 +25,6 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
public class RuleEventFormatterTests
{
private readonly JsonSerializer serializer = JsonSerializer.CreateDefault();
private readonly MemoryCache memoryCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
private readonly IUserResolver userResolver = A.Fake<IUserResolver>();
private readonly IUser user = A.Fake<IUser>();
private readonly IRuleUrlGenerator urlGenerator = A.Fake<IRuleUrlGenerator>();
private readonly NamedId<Guid> appId = NamedId.Of(Guid.NewGuid(), "my-app");
@ -47,137 +40,124 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
A.CallTo(() => user.Claims)
.Returns(new List<Claim> { new Claim(SquidexClaimTypes.SquidexDisplayName, "me") });
sut = new RuleEventFormatter(serializer, urlGenerator, memoryCache, userResolver);
sut = new RuleEventFormatter(serializer, urlGenerator);
}
[Fact]
public void Should_serialize_object_to_json()
{
var result = sut.ToRouteData(new { Value = 1 });
var result = sut.ToPayload(new { Value = 1 });
Assert.True(result is JObject);
}
[Fact]
public void Should_create_route_data()
public void Should_create_payload()
{
var @event = new ContentCreated { AppId = appId };
var @event = new EnrichedContentEvent { AppId = appId };
var result = sut.ToRouteData(AsEnvelope(@event));
var result = sut.ToPayload(@event);
Assert.True(result is JObject);
}
[Fact]
public void Should_create_route_data_from_event()
public void Should_create_envelope_data_from_event()
{
var @event = new ContentCreated { AppId = appId };
var @event = new EnrichedContentEvent { AppId = appId, Name = "MyEventName" };
var result = sut.ToRouteData(AsEnvelope(@event), "MyEventName");
var result = sut.ToEnvelope(@event);
Assert.Equal("MyEventName", result["type"]);
}
[Fact]
public async Task Should_replace_app_information_from_event()
public void Should_replace_app_information_from_event()
{
var @event = new ContentCreated { AppId = appId };
var @event = new EnrichedContentEvent { AppId = appId };
var result = await sut.FormatStringAsync("Name $APP_NAME has id $APP_ID", AsEnvelope(@event));
var result = sut.Format("Name $APP_NAME has id $APP_ID", @event);
Assert.Equal($"Name my-app has id {appId.Id}", result);
}
[Fact]
public async Task Should_replace_schema_information_from_event()
public void Should_replace_schema_information_from_event()
{
var @event = new ContentCreated { SchemaId = schemaId };
var @event = new EnrichedContentEvent { SchemaId = schemaId };
var result = await sut.FormatStringAsync("Name $SCHEMA_NAME has id $SCHEMA_ID", AsEnvelope(@event));
var result = sut.Format("Name $SCHEMA_NAME has id $SCHEMA_ID", @event);
Assert.Equal($"Name my-schema has id {schemaId.Id}", result);
}
[Fact]
public async Task Should_replace_timestamp_information_from_event()
public void Should_replace_timestamp_information_from_event()
{
var now = DateTime.UtcNow;
var envelope = AsEnvelope(new ContentCreated()).SetTimestamp(Instant.FromDateTimeUtc(now));
var envelope = new EnrichedContentEvent { Timestamp = Instant.FromDateTimeUtc(now) };
var result = await sut.FormatStringAsync("Date: $TIMESTAMP_DATE, Full: $TIMESTAMP_DATETIME", envelope);
var result = sut.Format("Date: $TIMESTAMP_DATE, Full: $TIMESTAMP_DATETIME", envelope);
Assert.Equal($"Date: {now:yyyy-MM-dd}, Full: {now:yyyy-MM-dd-hh-mm-ss}", result);
}
[Fact]
public async Task Should_format_email_and_display_name_from_user()
public void Should_format_email_and_display_name_from_user()
{
A.CallTo(() => userResolver.FindByIdOrEmailAsync("123"))
.Returns(user);
var @event = new EnrichedContentEvent { User = user, Actor = new RefToken("subject", "123") };
var @event = new ContentCreated { Actor = new RefToken("subject", "123") };
var result = await sut.FormatStringAsync("From $USER_NAME ($USER_EMAIL)", AsEnvelope(@event));
var result = sut.Format("From $USER_NAME ($USER_EMAIL)", @event);
Assert.Equal($"From me (me@email.com)", result);
}
[Fact]
public async Task Should_return_undefined_if_user_is_not_found()
{
A.CallTo(() => userResolver.FindByIdOrEmailAsync("123"))
.Returns(Task.FromResult<IUser>(null));
var @event = new ContentCreated { Actor = new RefToken("subject", "123") };
var result = await sut.FormatStringAsync("From $USER_NAME ($USER_EMAIL)", AsEnvelope(@event));
Assert.Equal($"From UNDEFINED (UNDEFINED)", result);
}
[Fact]
public async Task Should_return_undefined_if_user_failed_to_resolve()
public void Should_return_undefined_if_user_is_not_found()
{
A.CallTo(() => userResolver.FindByIdOrEmailAsync("123"))
.Throws(new InvalidOperationException());
var @event = new ContentCreated { Actor = new RefToken("subject", "123") };
var @event = new EnrichedContentEvent { Actor = new RefToken("subject", "123") };
var result = await sut.FormatStringAsync("From $USER_NAME ($USER_EMAIL)", AsEnvelope(@event));
var result = sut.Format("From $USER_NAME ($USER_EMAIL)", @event);
Assert.Equal($"From UNDEFINED (UNDEFINED)", result);
}
[Fact]
public async Task Should_format_email_and_display_name_from_client()
public void Should_format_email_and_display_name_from_client()
{
var @event = new ContentCreated { Actor = new RefToken("client", "android") };
var @event = new EnrichedContentEvent { Actor = new RefToken("client", "android") };
var result = await sut.FormatStringAsync("From $USER_NAME ($USER_EMAIL)", AsEnvelope(@event));
var result = sut.Format("From $USER_NAME ($USER_EMAIL)", @event);
Assert.Equal($"From client:android (client:android)", result);
}
[Fact]
public async Task Should_replace_content_url_from_event()
public void Should_replace_content_url_from_event()
{
var url = "http://content";
A.CallTo(() => urlGenerator.GenerateContentUIUrl(appId, schemaId, contentId))
.Returns(url);
var @event = new ContentCreated { AppId = appId, ContentId = contentId, SchemaId = schemaId };
var @event = new EnrichedContentEvent { AppId = appId, Id = contentId, SchemaId = schemaId };
var result = await sut.FormatStringAsync("Go to $CONTENT_URL", AsEnvelope(@event));
var result = sut.Format("Go to $CONTENT_URL", @event);
Assert.Equal($"Go to {url}", result);
}
[Fact]
public async Task Should_return_undefined_when_field_not_found()
public void Should_format_content_url_when_not_found()
{
Assert.Equal("UNDEFINED", sut.Format("$CONTENT_URL", new EnrichedAssetEvent()));
}
[Fact]
public void Should_return_undefined_when_field_not_found()
{
var @event = new ContentCreated
var @event = new EnrichedContentEvent
{
Data =
new NamedContentData()
@ -186,15 +166,15 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
.AddValue("iv", "Berlin"))
};
var result = await sut.FormatStringAsync("$CONTENT_DATA.country.iv", AsEnvelope(@event));
var result = sut.Format("$CONTENT_DATA.country.iv", @event);
Assert.Equal("UNDEFINED", result);
}
[Fact]
public async Task Should_return_undefined_when_partition_not_found()
public void Should_return_undefined_when_partition_not_found()
{
var @event = new ContentCreated
var @event = new EnrichedContentEvent
{
Data =
new NamedContentData()
@ -203,15 +183,15 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
.AddValue("iv", "Berlin"))
};
var result = await sut.FormatStringAsync("$CONTENT_DATA.city.de", AsEnvelope(@event));
var result = sut.Format("$CONTENT_DATA.city.de", @event);
Assert.Equal("UNDEFINED", result);
}
[Fact]
public async Task Should_return_undefined_when_array_item_not_found()
public void Should_return_undefined_when_array_item_not_found()
{
var @event = new ContentCreated
var @event = new EnrichedContentEvent
{
Data =
new NamedContentData()
@ -220,15 +200,15 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
.AddValue("iv", new JArray()))
};
var result = await sut.FormatStringAsync("$CONTENT_DATA.city.de.10", AsEnvelope(@event));
var result = sut.Format("$CONTENT_DATA.city.de.10", @event);
Assert.Equal("UNDEFINED", result);
}
[Fact]
public async Task Should_return_undefined_when_property_not_found()
public void Should_return_undefined_when_property_not_found()
{
var @event = new ContentCreated
var @event = new EnrichedContentEvent
{
Data =
new NamedContentData()
@ -238,15 +218,15 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
new JProperty("name", "Berlin"))))
};
var result = await sut.FormatStringAsync("$CONTENT_DATA.city.de.Name", AsEnvelope(@event));
var result = sut.Format("$CONTENT_DATA.city.de.Name", @event);
Assert.Equal("UNDEFINED", result);
}
[Fact]
public async Task Should_return_plain_value_when_found()
public void Should_return_plain_value_when_found()
{
var @event = new ContentCreated
var @event = new EnrichedContentEvent
{
Data =
new NamedContentData()
@ -255,15 +235,15 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
.AddValue("iv", "Berlin"))
};
var result = await sut.FormatStringAsync("$CONTENT_DATA.city.iv", AsEnvelope(@event));
var result = sut.Format("$CONTENT_DATA.city.iv", @event);
Assert.Equal("Berlin", result);
}
[Fact]
public async Task Should_return_plain_value_when_found_from_update_event()
public void Should_return_plain_value_when_found_from_update_event()
{
var @event = new ContentUpdated
var @event = new EnrichedContentEvent
{
Data =
new NamedContentData()
@ -272,15 +252,15 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
.AddValue("iv", "Berlin"))
};
var result = await sut.FormatStringAsync("$CONTENT_DATA.city.iv", AsEnvelope(@event));
var result = sut.Format("$CONTENT_DATA.city.iv", @event);
Assert.Equal("Berlin", result);
}
[Fact]
public async Task Should_return_undefined_when_null()
public void Should_return_undefined_when_null()
{
var @event = new ContentCreated
var @event = new EnrichedContentEvent
{
Data =
new NamedContentData()
@ -289,15 +269,15 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
.AddValue("iv", JValue.CreateNull()))
};
var result = await sut.FormatStringAsync("$CONTENT_DATA.city.iv", AsEnvelope(@event));
var result = sut.Format("$CONTENT_DATA.city.iv", @event);
Assert.Equal("UNDEFINED", result);
}
[Fact]
public async Task Should_return_undefined_when_undefined()
public void Should_return_undefined_when_undefined()
{
var @event = new ContentCreated
var @event = new EnrichedContentEvent
{
Data =
new NamedContentData()
@ -306,15 +286,15 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
.AddValue("iv", JValue.CreateUndefined()))
};
var result = await sut.FormatStringAsync("$CONTENT_DATA.city.iv", AsEnvelope(@event));
var result = sut.Format("$CONTENT_DATA.city.iv", @event);
Assert.Equal("UNDEFINED", result);
}
[Fact]
public async Task Should_return_string_when_object()
public void Should_return_string_when_object()
{
var @event = new ContentCreated
var @event = new EnrichedContentEvent
{
Data =
new NamedContentData()
@ -324,15 +304,15 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
new JProperty("name", "Berlin"))))
};
var result = await sut.FormatStringAsync("$CONTENT_DATA.city.iv", AsEnvelope(@event));
var result = sut.Format("$CONTENT_DATA.city.iv", @event);
Assert.Equal(JObject.FromObject(new { name = "Berlin" }).ToString(Formatting.Indented), result);
}
[Fact]
public async Task Should_return_plain_value_from_array_when_found()
public void Should_return_plain_value_from_array_when_found()
{
var @event = new ContentCreated
var @event = new EnrichedContentEvent
{
Data =
new NamedContentData()
@ -342,15 +322,15 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
"Berlin")))
};
var result = await sut.FormatStringAsync("$CONTENT_DATA.city.iv.0", AsEnvelope(@event));
var result = sut.Format("$CONTENT_DATA.city.iv.0", @event);
Assert.Equal("Berlin", result);
}
[Fact]
public async Task Should_return_plain_value_from_object_when_found()
public void Should_return_plain_value_from_object_when_found()
{
var @event = new ContentCreated
var @event = new EnrichedContentEvent
{
Data =
new NamedContentData()
@ -360,24 +340,21 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
new JProperty("name", "Berlin"))))
};
var result = await sut.FormatStringAsync("$CONTENT_DATA.city.iv.name", AsEnvelope(@event));
var result = sut.Format("$CONTENT_DATA.city.iv.name", @event);
Assert.Equal("Berlin", result);
}
[Fact]
public async Task Should_format_content_actions_when_found()
public void Should_format_content_actions_when_found()
{
Assert.Equal("created", await sut.FormatStringAsync("$CONTENT_ACTION", AsEnvelope(new ContentCreated())));
Assert.Equal("updated", await sut.FormatStringAsync("$CONTENT_ACTION", AsEnvelope(new ContentUpdated())));
Assert.Equal("deleted", await sut.FormatStringAsync("$CONTENT_ACTION", AsEnvelope(new ContentDeleted())));
Assert.Equal("set to archived", await sut.FormatStringAsync("$CONTENT_ACTION", AsEnvelope(new ContentStatusChanged { Status = Status.Archived })));
Assert.Equal("created", sut.Format("$CONTENT_ACTION", new EnrichedContentEvent { Type = EnrichedContentEventType.Created }));
}
private static Envelope<AppEvent> AsEnvelope<T>(T @event) where T : AppEvent
[Fact]
public void Should_format_content_actions_when_not_found()
{
return Envelope.Create<AppEvent>(@event).To<AppEvent>();
Assert.Equal("UNDEFINED", sut.Format("$CONTENT_ACTION", new EnrichedAssetEvent()));
}
}
}

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

@ -11,6 +11,7 @@ using FakeItEasy;
using Newtonsoft.Json.Linq;
using NodaTime;
using Squidex.Domain.Apps.Core.HandleRules;
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules;
using Squidex.Domain.Apps.Core.Rules.Actions;
using Squidex.Domain.Apps.Core.Rules.Triggers;
@ -28,6 +29,7 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
{
private readonly IRuleTriggerHandler ruleTriggerHandler = A.Fake<IRuleTriggerHandler>();
private readonly IRuleActionHandler ruleActionHandler = A.Fake<IRuleActionHandler>();
private readonly IEventEnricher eventEnricher = A.Fake<IEventEnricher>();
private readonly IClock clock = A.Fake<IClock>();
private readonly TypeNameRegistry typeNameRegistry = new TypeNameRegistry();
private readonly RuleService sut;
@ -57,13 +59,16 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
typeNameRegistry.Map(typeof(ContentCreated));
typeNameRegistry.Map(typeof(WebhookAction));
A.CallTo(() => eventEnricher.EnrichAsync(A<Envelope<AppEvent>>.Ignored))
.Returns(new EnrichedContentEvent());
A.CallTo(() => ruleActionHandler.ActionType)
.Returns(typeof(WebhookAction));
A.CallTo(() => ruleTriggerHandler.TriggerType)
.Returns(typeof(ContentChangedTrigger));
sut = new RuleService(new[] { ruleTriggerHandler }, new[] { ruleActionHandler }, clock, typeNameRegistry);
sut = new RuleService(new[] { ruleTriggerHandler }, new[] { ruleActionHandler }, eventEnricher, clock, typeNameRegistry);
}
[Fact]
@ -128,15 +133,13 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
var actionData = new JObject();
var actionDescription = "MyDescription";
var eventName = "MySchemaCreatedEvent";
A.CallTo(() => clock.GetCurrentInstant())
.Returns(now);
A.CallTo(() => ruleTriggerHandler.Triggers(A<Envelope<AppEvent>>.Ignored, ruleConfig.Trigger))
.Returns(true);
A.CallTo(() => ruleActionHandler.CreateJobAsync(A<Envelope<AppEvent>>.Ignored, eventName, ruleConfig.Action))
A.CallTo(() => ruleActionHandler.CreateJobAsync(A<EnrichedEvent>.Ignored, ruleConfig.Action))
.Returns((actionDescription, actionData));
var job = await sut.CreateJobAsync(ruleConfig, ruleEnvelope);
@ -160,21 +163,17 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules
var actionData = new JObject();
var actionDescription = "MyDescription";
var eventName = "MySchemaCreatedEvent";
A.CallTo(() => clock.GetCurrentInstant())
.Returns(now);
A.CallTo(() => ruleTriggerHandler.Triggers(A<Envelope<AppEvent>>.Ignored, ruleConfig.Trigger))
.Returns(true);
A.CallTo(() => ruleActionHandler.CreateJobAsync(A<Envelope<AppEvent>>.Ignored, eventName, ruleConfig.Action))
A.CallTo(() => ruleActionHandler.CreateJobAsync(A<EnrichedEvent>.Ignored, ruleConfig.Action))
.Returns((actionDescription, actionData));
var job = await sut.CreateJobAsync(ruleConfig, ruleEnvelope);
Assert.Equal(eventName, job.EventName);
Assert.Equal(actionData, job.ActionData);
Assert.Equal(actionName, job.ActionName);
Assert.Equal(actionDescription, job.Description);

43
tests/Squidex.Domain.Apps.Core.Tests/Operations/HandleRules/Triggers/ContentChangedTriggerTests.cs

@ -32,19 +32,23 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
public static IEnumerable<object[]> TestData = new[]
{
new object[] { 0, 1, 1, 1, 1, new RuleCreated() },
new object[] { 0, 1, 1, 1, 1, new ContentCreated { SchemaId = SchemaNonMatch } },
new object[] { 1, 1, 0, 0, 0, new ContentCreated { SchemaId = SchemaMatch } },
new object[] { 0, 0, 0, 0, 0, new ContentCreated { SchemaId = SchemaMatch } },
new object[] { 1, 0, 1, 0, 0, new ContentUpdated { SchemaId = SchemaMatch } },
new object[] { 0, 0, 0, 0, 0, new ContentUpdated { SchemaId = SchemaMatch } },
new object[] { 1, 0, 0, 1, 0, new ContentDeleted { SchemaId = SchemaMatch } },
new object[] { 0, 0, 0, 0, 0, new ContentDeleted { SchemaId = SchemaMatch } },
new object[] { 1, 0, 0, 0, 1, new ContentStatusChanged { SchemaId = SchemaMatch, Status = Status.Published } },
new object[] { 0, 0, 0, 0, 0, new ContentStatusChanged { SchemaId = SchemaMatch, Status = Status.Published } },
new object[] { 0, 1, 1, 1, 1, new ContentStatusChanged { SchemaId = SchemaMatch, Status = Status.Archived } },
new object[] { 0, 1, 1, 1, 1, new ContentStatusChanged { SchemaId = SchemaMatch, Status = Status.Draft } },
new object[] { 0, 1, 1, 1, 1, new SchemaCreated { SchemaId = SchemaNonMatch } }
new object[] { 0, 1, 1, 1, 1, 0, 0, 0, new RuleCreated() },
new object[] { 0, 1, 1, 1, 1, 0, 0, 0, new ContentCreated { SchemaId = SchemaNonMatch } },
new object[] { 1, 1, 0, 0, 0, 0, 0, 0, new ContentCreated { SchemaId = SchemaMatch } },
new object[] { 0, 0, 0, 0, 0, 0, 0, 0, new ContentCreated { SchemaId = SchemaMatch } },
new object[] { 1, 0, 1, 0, 0, 0, 0, 0, new ContentUpdated { SchemaId = SchemaMatch } },
new object[] { 0, 0, 0, 0, 0, 0, 0, 0, new ContentUpdated { SchemaId = SchemaMatch } },
new object[] { 1, 0, 0, 1, 0, 0, 0, 0, new ContentDeleted { SchemaId = SchemaMatch } },
new object[] { 0, 0, 0, 0, 0, 0, 0, 0, new ContentDeleted { SchemaId = SchemaMatch } },
new object[] { 1, 1, 1, 1, 0, 0, 1, 0, new ContentStatusChanged { SchemaId = SchemaMatch, Change = StatusChange.Archived } },
new object[] { 0, 1, 1, 1, 0, 0, 0, 0, new ContentStatusChanged { SchemaId = SchemaMatch, Change = StatusChange.Archived } },
new object[] { 1, 0, 0, 0, 0, 0, 0, 1, new ContentStatusChanged { SchemaId = SchemaMatch, Change = StatusChange.Restored } },
new object[] { 0, 0, 0, 0, 0, 0, 0, 0, new ContentStatusChanged { SchemaId = SchemaMatch, Change = StatusChange.Restored } },
new object[] { 1, 0, 0, 0, 1, 0, 0, 0, new ContentStatusChanged { SchemaId = SchemaMatch, Change = StatusChange.Published } },
new object[] { 0, 0, 0, 0, 0, 0, 0, 0, new ContentStatusChanged { SchemaId = SchemaMatch, Change = StatusChange.Published } },
new object[] { 1, 1, 1, 1, 0, 1, 0, 0, new ContentStatusChanged { SchemaId = SchemaMatch, Change = StatusChange.Unpublished } },
new object[] { 0, 1, 1, 1, 0, 0, 0, 0, new ContentStatusChanged { SchemaId = SchemaMatch, Change = StatusChange.Unpublished } },
new object[] { 0, 1, 1, 1, 1, 0, 0, 0, new SchemaCreated { SchemaId = SchemaNonMatch } }
};
[Fact]
@ -69,7 +73,15 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
[Theory]
[MemberData(nameof(TestData))]
public void Should_return_result_depending_on_event(int expected, int sendCreate, int sendUpdate, int sendDelete, int sendPublish, AppEvent @event)
public void Should_return_result_depending_on_event(int expected,
int sendCreate,
int sendUpdate,
int sendDelete,
int sendPublish,
int sendUnpublish,
int sendArchive,
int sendRestore,
AppEvent @event)
{
var trigger = new ContentChangedTrigger
{
@ -80,6 +92,9 @@ namespace Squidex.Domain.Apps.Core.Operations.HandleRules.Triggers
SendUpdate = sendUpdate == 1,
SendDelete = sendDelete == 1,
SendPublish = sendPublish == 1,
SendUnpublish = sendUnpublish == 1,
SendArchived = sendArchive == 1,
SendRestore = sendRestore == 1,
SchemaId = SchemaMatch.Id
})
};

77
tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentGrainTests.cs

@ -144,7 +144,7 @@ namespace Squidex.Domain.Apps.Entities.Contents
LastEvents
.ShouldHaveSameEvents(
CreateContentEvent(new ContentCreated { Data = data }),
CreateContentEvent(new ContentStatusChanged { Status = Status.Published })
CreateContentEvent(new ContentStatusChanged { Status = Status.Published, Change = StatusChange.Published })
);
A.CallTo(() => scriptEngine.ExecuteAndTransform(A<ScriptContext>.Ignored, "<create-script>"))
@ -312,7 +312,75 @@ namespace Squidex.Domain.Apps.Entities.Contents
LastEvents
.ShouldHaveSameEvents(
CreateContentEvent(new ContentStatusChanged { Status = Status.Published })
CreateContentEvent(new ContentStatusChanged { Status = Status.Published, Change = StatusChange.Published })
);
A.CallTo(() => scriptEngine.Execute(A<ScriptContext>.Ignored, "<change-script>"))
.MustHaveHappened();
}
[Fact]
public async Task ChangedStatus_should_create_events_and_update_state_when_archived()
{
var command = new ChangeContentStatus { Status = Status.Archived };
await ExecuteCreateAsync();
var result = await sut.ExecuteAsync(CreateContentCommand(command));
result.ShouldBeEquivalent(new EntitySavedResult(1));
Assert.Equal(Status.Archived, sut.Snapshot.Status);
LastEvents
.ShouldHaveSameEvents(
CreateContentEvent(new ContentStatusChanged { Status = Status.Archived, Change = StatusChange.Archived })
);
A.CallTo(() => scriptEngine.Execute(A<ScriptContext>.Ignored, "<change-script>"))
.MustHaveHappened();
}
[Fact]
public async Task ChangedStatus_should_create_events_and_update_state_when_unpublished()
{
var command = new ChangeContentStatus { Status = Status.Draft };
await ExecuteCreateAsync();
await ExecutePublishAsync();
var result = await sut.ExecuteAsync(CreateContentCommand(command));
result.ShouldBeEquivalent(new EntitySavedResult(2));
Assert.Equal(Status.Draft, sut.Snapshot.Status);
LastEvents
.ShouldHaveSameEvents(
CreateContentEvent(new ContentStatusChanged { Status = Status.Draft, Change = StatusChange.Unpublished })
);
A.CallTo(() => scriptEngine.Execute(A<ScriptContext>.Ignored, "<change-script>"))
.MustHaveHappened();
}
[Fact]
public async Task ChangedStatus_should_create_events_and_update_state_when_restored()
{
var command = new ChangeContentStatus { Status = Status.Draft };
await ExecuteCreateAsync();
await ExecuteArchiveAsync();
var result = await sut.ExecuteAsync(CreateContentCommand(command));
result.ShouldBeEquivalent(new EntitySavedResult(2));
Assert.Equal(Status.Draft, sut.Snapshot.Status);
LastEvents
.ShouldHaveSameEvents(
CreateContentEvent(new ContentStatusChanged { Status = Status.Draft, Change = StatusChange.Restored })
);
A.CallTo(() => scriptEngine.Execute(A<ScriptContext>.Ignored, "<change-script>"))
@ -459,6 +527,11 @@ namespace Squidex.Domain.Apps.Entities.Contents
return sut.ExecuteAsync(CreateContentCommand(new DeleteContent()));
}
private Task ExecuteArchiveAsync()
{
return sut.ExecuteAsync(CreateContentCommand(new ChangeContentStatus { Status = Status.Archived }));
}
private Task ExecutePublishAsync()
{
return sut.ExecuteAsync(CreateContentCommand(new ChangeContentStatus { Status = Status.Published }));

74
tests/Squidex.Domain.Apps.Entities.Tests/Contents/ContentVersionLoaderTests.cs

@ -0,0 +1,74 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using FakeItEasy;
using Orleans;
using Squidex.Domain.Apps.Core.Schemas;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Orleans;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents
{
public class ContentVersionLoaderTests
{
private readonly IGrainFactory grainFactory = A.Fake<IGrainFactory>();
private readonly IContentGrain grain = A.Fake<IContentGrain>();
private readonly FieldRegistry fieldRegistry = new FieldRegistry(new TypeNameRegistry());
private readonly Guid id = Guid.NewGuid();
private readonly ContentVersionLoader sut;
public ContentVersionLoaderTests()
{
A.CallTo(() => grainFactory.GetGrain<IContentGrain>(id, null))
.Returns(grain);
sut = new ContentVersionLoader(grainFactory);
}
[Fact]
public async Task Should_throw_exception_if_no_state_returned()
{
A.CallTo(() => grain.GetStateAsync(10))
.Returns(new J<IContentEntity>(null));
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.LoadAsync(id, 10));
}
[Fact]
public async Task Should_throw_exception_if_state_has_other_version()
{
var entity = A.Fake<IContentEntity>();
A.CallTo(() => entity.Version)
.Returns(5);
A.CallTo(() => grain.GetStateAsync(10))
.Returns(J.Of(entity));
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.LoadAsync(id, 10));
}
[Fact]
public async Task Should_return_content_from_state()
{
var entity = A.Fake<IContentEntity>();
A.CallTo(() => entity.Version)
.Returns(10);
A.CallTo(() => grain.GetStateAsync(10))
.Returns(J.Of(entity));
var result = await sut.LoadAsync(id, 10);
Assert.Same(entity, result);
}
}
}

71
tests/Squidex.Domain.Apps.Entities.Tests/Rules/Guards/Actions/MediumActionTests.cs

@ -0,0 +1,71 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Collections.Generic;
using System.Threading.Tasks;
using FluentAssertions;
using Squidex.Domain.Apps.Core.Rules.Actions;
using Squidex.Infrastructure;
using Xunit;
namespace Squidex.Domain.Apps.Entities.Rules.Guards.Actions
{
public class MediumActionTests
{
[Fact]
public async Task Should_add_error_if_access_token_is_null()
{
var action = new MediumAction { AccessToken = null, Title = "title", Content = "content" };
var errors = await RuleActionValidator.ValidateAsync(action);
errors.Should().BeEquivalentTo(
new List<ValidationError>
{
new ValidationError("Access token is required.", "AccessToken")
});
}
[Fact]
public async Task Should_add_error_if_title_null()
{
var action = new MediumAction { AccessToken = "token", Title = null, Content = "content" };
var errors = await RuleActionValidator.ValidateAsync(action);
errors.Should().BeEquivalentTo(
new List<ValidationError>
{
new ValidationError("Title is required.", "Title")
});
}
[Fact]
public async Task Should_add_error_if_content_is_null()
{
var action = new MediumAction { AccessToken = "token", Title = "title", Content = null };
var errors = await RuleActionValidator.ValidateAsync(action);
errors.Should().BeEquivalentTo(
new List<ValidationError>
{
new ValidationError("Content is required.", "Content")
});
}
[Fact]
public async Task Should_not_add_error_if_values_are_valid()
{
var action = new MediumAction { AccessToken = "token", Title = "title", Content = "content" };
var errors = await RuleActionValidator.ValidateAsync(action);
Assert.Empty(errors);
}
}
}

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

@ -24,7 +24,8 @@ namespace Squidex.Domain.Apps.Entities.TestHelpers
public abstract class HandlerTestBase<T, TState> where T : IDomainObjectGrain
{
private readonly IStore<Guid> store = A.Fake<IStore<Guid>>();
private readonly IPersistence<TState> persistence = A.Fake<IPersistence<TState>>();
private readonly IPersistence<TState> persistence1 = A.Fake<IPersistence<TState>>();
private readonly IPersistence persistence2 = A.Fake<IPersistence>();
protected RefToken User { get; } = new RefToken("subject", Guid.NewGuid().ToString());
@ -58,9 +59,18 @@ 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))
.Returns(persistence);
.Returns(persistence1);
A.CallTo(() => persistence.WriteEventsAsync(A<IEnumerable<Envelope<IEvent>>>.Ignored))
A.CallTo(() => store.WithEventSourcing(A<Type>.Ignored, Id, A<Func<Envelope<IEvent>, Task>>.Ignored))
.Returns(persistence2);
A.CallTo(() => persistence1.WriteEventsAsync(A<IEnumerable<Envelope<IEvent>>>.Ignored))
.Invokes(new Action<IEnumerable<Envelope<IEvent>>>(events =>
{
LastEvents = events;
}));
A.CallTo(() => persistence2.WriteEventsAsync(A<IEnumerable<Envelope<IEvent>>>.Ignored))
.Invokes(new Action<IEnumerable<Envelope<IEvent>>>(events =>
{
LastEvents = events;

66
tests/Squidex.Infrastructure.Tests/Commands/DomainObjectGrainFormatterTests.cs

@ -0,0 +1,66 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System.Reflection;
using FakeItEasy;
using Orleans;
using Squidex.Infrastructure.TestHelpers;
using Xunit;
namespace Squidex.Infrastructure.Commands
{
public class DomainObjectGrainFormatterTests
{
private readonly IGrainCallContext context = A.Fake<IGrainCallContext>();
[Fact]
public void Should_return_fallback_if_no_method_is_defined()
{
A.CallTo(() => context.InterfaceMethod)
.Returns(null);
var result = DomainObjectGrainFormatter.Format(context);
Assert.Equal("Unknown", result);
}
[Fact]
public void Should_return_method_name_if_not_domain_object_method()
{
var methodInfo = A.Fake<MethodInfo>();
A.CallTo(() => methodInfo.Name)
.Returns("Calculate");
A.CallTo(() => context.InterfaceMethod)
.Returns(methodInfo);
var result = DomainObjectGrainFormatter.Format(context);
Assert.Equal("Calculate", result);
}
[Fact]
public void Should_return_nice_method_name_if_domain_object_execute()
{
var methodInfo = A.Fake<MethodInfo>();
A.CallTo(() => methodInfo.Name)
.Returns(nameof(IDomainObjectGrain.ExecuteAsync));
A.CallTo(() => context.Arguments)
.Returns(new object[] { new MyCommand() });
A.CallTo(() => context.InterfaceMethod)
.Returns(methodInfo);
var result = DomainObjectGrainFormatter.Format(context);
Assert.Equal("ExecuteAsync(MyCommand)", result);
}
}
}

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

@ -101,12 +101,9 @@ namespace Squidex.Infrastructure.Commands
return Task.FromResult<object>(null);
}
public override void ApplyEvent(Envelope<IEvent> @event)
protected override MyDomainState OnEvent(Envelope<IEvent> @event)
{
if (@event.Payload is ValueChanged valueChanged)
{
ApplySnapshot(new MyDomainState { Value = valueChanged.Value });
}
return new MyDomainState { Value = ((ValueChanged)@event.Payload).Value };
}
}
@ -129,9 +126,9 @@ namespace Squidex.Infrastructure.Commands
{
await SetupEmptyAsync();
var result = await sut.ExecuteAsync(C(new CreateAuto { Value = 5 }));
var result = await sut.ExecuteAsync(C(new CreateAuto { Value = 4 }));
A.CallTo(() => persistence.WriteSnapshotAsync(A<MyDomainState>.That.Matches(x => x.Value == 5)))
A.CallTo(() => persistence.WriteSnapshotAsync(A<MyDomainState>.That.Matches(x => x.Value == 4)))
.MustHaveHappened();
A.CallTo(() => persistence.WriteEventsAsync(A<IEnumerable<Envelope<IEvent>>>.That.Matches(x => x.Count() == 1)))
.MustHaveHappened();
@ -139,7 +136,9 @@ namespace Squidex.Infrastructure.Commands
Assert.True(result.Value is EntityCreatedResult<Guid>);
Assert.Empty(sut.GetUncomittedEvents());
Assert.Equal(5, sut.Snapshot.Value);
Assert.Equal(4, sut.Snapshot.Value);
Assert.Equal(0, sut.Snapshot.Version);
}
[Fact]
@ -147,9 +146,9 @@ namespace Squidex.Infrastructure.Commands
{
await SetupCreatedAsync();
var result = await sut.ExecuteAsync(C(new UpdateAuto { Value = 5 }));
var result = await sut.ExecuteAsync(C(new UpdateAuto { Value = 8 }));
A.CallTo(() => persistence.WriteSnapshotAsync(A<MyDomainState>.That.Matches(x => x.Value == 5)))
A.CallTo(() => persistence.WriteSnapshotAsync(A<MyDomainState>.That.Matches(x => x.Value == 8)))
.MustHaveHappened();
A.CallTo(() => persistence.WriteEventsAsync(A<IEnumerable<Envelope<IEvent>>>.That.Matches(x => x.Count() == 1)))
.MustHaveHappened();
@ -157,7 +156,9 @@ namespace Squidex.Infrastructure.Commands
Assert.True(result.Value is EntitySavedResult);
Assert.Empty(sut.GetUncomittedEvents());
Assert.Equal(5, sut.Snapshot.Value);
Assert.Equal(8, sut.Snapshot.Value);
Assert.Equal(1, sut.Snapshot.Version);
}
[Fact]
@ -215,7 +216,9 @@ namespace Squidex.Infrastructure.Commands
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.ExecuteAsync(C(new CreateAuto())));
Assert.Empty(sut.GetUncomittedEvents());
Assert.Equal(0, sut.Snapshot.Value);
Assert.Equal(0, sut.Snapshot.Value);
Assert.Equal(-1, sut.Snapshot.Version);
}
[Fact]
@ -229,14 +232,16 @@ namespace Squidex.Infrastructure.Commands
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.ExecuteAsync(C(new UpdateAuto())));
Assert.Empty(sut.GetUncomittedEvents());
Assert.Equal(0, sut.Snapshot.Value);
Assert.Equal(4, sut.Snapshot.Value);
Assert.Equal(0, sut.Snapshot.Version);
}
private async Task SetupCreatedAsync()
{
await sut.OnActivateAsync(id);
await sut.ExecuteAsync(C(new CreateAuto()));
await sut.ExecuteAsync(C(new CreateAuto { Value = 4 }));
}
private static J<IAggregateCommand> C(IAggregateCommand command)

300
tests/Squidex.Infrastructure.Tests/Commands/LogSnapshotDomainObjectGrainTests.cs

@ -0,0 +1,300 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschränkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FakeItEasy;
using FluentAssertions;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.Orleans;
using Squidex.Infrastructure.States;
using Squidex.Infrastructure.TestHelpers;
using Xunit;
namespace Squidex.Infrastructure.Commands
{
public class LogSnapshotDomainObjectGrainTests
{
private readonly IStore<Guid> store = A.Fake<IStore<Guid>>();
private readonly ISnapshotStore<MyDomainState, Guid> snapshotStore = A.Fake<ISnapshotStore<MyDomainState, Guid>>();
private readonly IPersistence persistence = A.Fake<IPersistence>();
private readonly Guid id = Guid.NewGuid();
private readonly MyDomainObject sut;
public sealed class ValueChanged : IEvent
{
public int Value { get; set; }
}
public sealed class CreateAuto : MyCommand
{
public int Value { get; set; }
}
public sealed class CreateCustom : MyCommand
{
public int Value { get; set; }
}
public sealed class UpdateAuto : MyCommand
{
public int Value { get; set; }
}
public sealed class UpdateCustom : MyCommand
{
public int Value { get; set; }
}
public sealed class MyDomainObject : LogSnapshotDomainObjectGrain<MyDomainState>
{
public MyDomainObject(IStore<Guid> store)
: base(store, A.Dummy<ISemanticLog>())
{
}
protected override Task<object> ExecuteAsync(IAggregateCommand command)
{
switch (command)
{
case CreateAuto createAuto:
return CreateAsync(createAuto, c =>
{
RaiseEvent(new ValueChanged { Value = c.Value });
});
case CreateCustom createCustom:
return CreateReturnAsync(createCustom, c =>
{
RaiseEvent(new ValueChanged { Value = c.Value });
return "CREATED";
});
case UpdateAuto updateAuto:
return UpdateAsync(updateAuto, c =>
{
RaiseEvent(new ValueChanged { Value = c.Value });
});
case UpdateCustom updateCustom:
return UpdateReturnAsync(updateCustom, c =>
{
RaiseEvent(new ValueChanged { Value = c.Value });
return "UPDATED";
});
}
return Task.FromResult<object>(null);
}
protected override MyDomainState OnEvent(Envelope<IEvent> @event)
{
return new MyDomainState { Value = ((ValueChanged)@event.Payload).Value };
}
}
public LogSnapshotDomainObjectGrainTests()
{
A.CallTo(() => store.WithEventSourcing(typeof(MyDomainObject), id, A<Func<Envelope<IEvent>, Task>>.Ignored))
.Returns(persistence);
A.CallTo(() => store.GetSnapshotStore<MyDomainState>())
.Returns(snapshotStore);
sut = new MyDomainObject(store);
}
[Fact]
public async Task Should_get_latestet_version_when_requesting_state_with_any()
{
await SetupUpdatedAsync();
var result = sut.GetSnapshot(EtagVersion.Any);
result.Should().BeEquivalentTo(new MyDomainState { Value = 8, Version = 1 });
}
[Fact]
public async Task Should_get_empty_version_when_requesting_state_with_empty_version()
{
await SetupUpdatedAsync();
var result = sut.GetSnapshot(EtagVersion.Empty);
result.Should().BeEquivalentTo(new MyDomainState { Value = 0, Version = -1 });
}
[Fact]
public async Task Should_get_specific_version_when_requesting_state_with_specific_version()
{
await SetupUpdatedAsync();
sut.GetSnapshot(0).Should().BeEquivalentTo(new MyDomainState { Value = 4, Version = 0 });
sut.GetSnapshot(1).Should().BeEquivalentTo(new MyDomainState { Value = 8, Version = 1 });
}
[Fact]
public async Task Should_get_null_state_when_requesting_state_with_invalid_version()
{
await SetupUpdatedAsync();
Assert.Null(sut.GetSnapshot(-3));
Assert.Null(sut.GetSnapshot(2));
}
[Fact]
public void Should_instantiate()
{
Assert.Equal(EtagVersion.Empty, sut.Version);
}
[Fact]
public async Task Should_write_state_and_events_when_created()
{
await SetupEmptyAsync();
var result = await sut.ExecuteAsync(C(new CreateAuto { Value = 4 }));
A.CallTo(() => snapshotStore.WriteAsync(id, A<MyDomainState>.That.Matches(x => x.Value == 4), -1, 0))
.MustHaveHappened();
A.CallTo(() => persistence.WriteEventsAsync(A<IEnumerable<Envelope<IEvent>>>.That.Matches(x => x.Count() == 1)))
.MustHaveHappened();
Assert.True(result.Value is EntityCreatedResult<Guid>);
Assert.Empty(sut.GetUncomittedEvents());
Assert.Equal(4, sut.Snapshot.Value);
Assert.Equal(0, sut.Snapshot.Version);
}
[Fact]
public async Task Should_write_state_and_events_when_updated()
{
await SetupCreatedAsync();
var result = await sut.ExecuteAsync(C(new UpdateAuto { Value = 8 }));
A.CallTo(() => snapshotStore.WriteAsync(id, A<MyDomainState>.That.Matches(x => x.Value == 8), 0, 1))
.MustHaveHappened();
A.CallTo(() => persistence.WriteEventsAsync(A<IEnumerable<Envelope<IEvent>>>.That.Matches(x => x.Count() == 1)))
.MustHaveHappened();
Assert.True(result.Value is EntitySavedResult);
Assert.Empty(sut.GetUncomittedEvents());
Assert.Equal(8, sut.Snapshot.Value);
Assert.Equal(1, sut.Snapshot.Version);
}
[Fact]
public async Task Should_throw_exception_when_already_created()
{
await SetupCreatedAsync();
await Assert.ThrowsAsync<DomainException>(() => sut.ExecuteAsync(C(new CreateAuto())));
}
[Fact]
public async Task Should_throw_exception_when_not_created()
{
await SetupEmptyAsync();
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.ExecuteAsync(C(new UpdateAuto())));
}
[Fact]
public async Task Should_return_custom_result_on_create()
{
await SetupEmptyAsync();
var result = await sut.ExecuteAsync(C(new CreateCustom()));
Assert.Equal("CREATED", result.Value);
}
[Fact]
public async Task Should_return_custom_result_on_update()
{
await SetupCreatedAsync();
var result = await sut.ExecuteAsync(C(new UpdateCustom()));
Assert.Equal("UPDATED", result.Value);
}
[Fact]
public async Task Should_throw_exception_when_other_verison_expected()
{
await SetupCreatedAsync();
await Assert.ThrowsAsync<DomainObjectVersionException>(() => sut.ExecuteAsync(C(new UpdateCustom { ExpectedVersion = 3 })));
}
[Fact]
public async Task Should_reset_state_when_writing_snapshot_for_create_failed()
{
await SetupEmptyAsync();
A.CallTo(() => snapshotStore.WriteAsync(A<Guid>.Ignored, A<MyDomainState>.Ignored, -1, 0))
.Throws(new InvalidOperationException());
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.ExecuteAsync(C(new CreateAuto())));
Assert.Empty(sut.GetUncomittedEvents());
Assert.Equal(0, sut.Snapshot.Value);
Assert.Equal(-1, sut.Snapshot.Version);
}
[Fact]
public async Task Should_reset_state_when_writing_snapshot_for_update_failed()
{
await SetupCreatedAsync();
A.CallTo(() => snapshotStore.WriteAsync(A<Guid>.Ignored, A<MyDomainState>.Ignored, 0, 1))
.Throws(new InvalidOperationException());
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.ExecuteAsync(C(new UpdateAuto())));
Assert.Empty(sut.GetUncomittedEvents());
Assert.Equal(4, sut.Snapshot.Value);
Assert.Equal(0, sut.Snapshot.Version);
}
private async Task SetupCreatedAsync()
{
await sut.OnActivateAsync(id);
await sut.ExecuteAsync(C(new CreateAuto { Value = 4 }));
}
private async Task SetupUpdatedAsync()
{
await SetupCreatedAsync();
await sut.ExecuteAsync(C(new UpdateAuto { Value = 8 }));
}
private async Task SetupEmptyAsync()
{
await sut.OnActivateAsync(id);
}
private static J<IAggregateCommand> C(IAggregateCommand command)
{
return command.AsJ();
}
}
}

91
tests/Squidex.Infrastructure.Tests/TestHelpers/MyDomainObject.cs

@ -0,0 +1,91 @@
// ==========================================================================
// Squidex Headless CMS
// ==========================================================================
// Copyright (c) Squidex UG (haftungsbeschraenkt)
// All rights reserved. Licensed under the MIT license.
// ==========================================================================
using System;
using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.States;
namespace Squidex.Infrastructure.TestHelpers
{
public sealed class MyDomainObject : DomainObjectGrain<MyDomainState>
{
public sealed class ValueChanged : IEvent
{
public int Value { get; set; }
}
public sealed class CreateAuto : MyCommand
{
public int Value { get; set; }
}
public sealed class CreateCustom : MyCommand
{
public int Value { get; set; }
}
public sealed class UpdateAuto : MyCommand
{
public int Value { get; set; }
}
public sealed class UpdateCustom : MyCommand
{
public int Value { get; set; }
}
public MyDomainObject(IStore<Guid> store)
: base(store, A.Dummy<ISemanticLog>())
{
}
protected override Task<object> ExecuteAsync(IAggregateCommand command)
{
switch (command)
{
case CreateAuto createAuto:
return CreateAsync(createAuto, c =>
{
RaiseEvent(new ValueChanged { Value = c.Value });
});
case CreateCustom createCustom:
return CreateReturnAsync(createCustom, c =>
{
RaiseEvent(new ValueChanged { Value = c.Value });
return "CREATED";
});
case UpdateAuto updateAuto:
return UpdateAsync(updateAuto, c =>
{
RaiseEvent(new ValueChanged { Value = c.Value });
});
case UpdateCustom updateCustom:
return UpdateReturnAsync(updateCustom, c =>
{
RaiseEvent(new ValueChanged { Value = c.Value });
return "UPDATED";
});
}
return Task.FromResult<object>(null);
}
protected override MyDomainState OnEvent(Envelope<IEvent> @event)
{
return new MyDomainState { Value = ((ValueChanged)@event.Payload).Value };
}
}
}

4
tests/Squidex.Infrastructure.Tests/TestHelpers/MyDomainState.cs

@ -9,8 +9,10 @@ using Squidex.Infrastructure.Commands;
namespace Squidex.Infrastructure.TestHelpers
{
public class MyDomainState : IDomainState
public sealed class MyDomainState : IDomainState
{
public long Version { get; set; }
public int Value { get; set; }
}
}

6
tests/Squidex.Infrastructure.Tests/TestHelpers/MyGrain.cs

@ -9,6 +9,7 @@ using System;
using System.Threading.Tasks;
using FakeItEasy;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.Log;
using Squidex.Infrastructure.States;
@ -25,5 +26,10 @@ namespace Squidex.Infrastructure.TestHelpers
{
return Task.FromResult<object>(null);
}
protected override MyDomainState OnEvent(Envelope<IEvent> @event)
{
return Snapshot;
}
}
}

4
tools/Migrate_01/Migrate_01.csproj

@ -1,6 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<TargetFramework>netcoreapp2.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Squidex.Domain.Apps.Core.Model\Squidex.Domain.Apps.Core.Model.csproj" />

Loading…
Cancel
Save