diff --git a/Squidex.sln.DotSettings b/Squidex.sln.DotSettings
index 93845309b..cf928c49d 100644
--- a/Squidex.sln.DotSettings
+++ b/Squidex.sln.DotSettings
@@ -20,6 +20,7 @@
True
DO_NOT_SHOW
+ WARNING
DO_NOT_SHOW
DO_NOT_SHOW
DO_NOT_SHOW
diff --git a/src/Squidex.Domain.Apps.Core/Schemas/Field.cs b/src/Squidex.Domain.Apps.Core/Schemas/Field.cs
index 970ca5a5b..b4fbee356 100644
--- a/src/Squidex.Domain.Apps.Core/Schemas/Field.cs
+++ b/src/Squidex.Domain.Apps.Core/Schemas/Field.cs
@@ -163,7 +163,7 @@ namespace Squidex.Domain.Apps.Core.Schemas
{
partitionType.AddStructuralProperty(partitionItem.Key, edmValueType);
}
-
+
edmType.AddStructuralProperty(Name.EscapeEdmField(), new EdmComplexTypeReference(partitionType, false));
}
diff --git a/src/Squidex.Domain.Apps.Core/Webhooks/WebhookSchema.cs b/src/Squidex.Domain.Apps.Core/Webhooks/WebhookSchema.cs
new file mode 100644
index 000000000..222e69379
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Core/Webhooks/WebhookSchema.cs
@@ -0,0 +1,27 @@
+// ==========================================================================
+// WebhookSchema.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+
+namespace Squidex.Domain.Apps.Core.Webhooks
+{
+ public sealed class WebhookSchema
+ {
+ public Guid SchemaId { get; set; }
+
+ public bool SendCreate { get; set; }
+
+ public bool SendUpdate { get; set; }
+
+ public bool SendDelete { get; set; }
+
+ public bool SendPublish { get; set; }
+
+ public bool SendUnpublish { get; set; }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Events/Schemas/Old/WebhookAdded.cs b/src/Squidex.Domain.Apps.Events/Schemas/Old/WebhookAdded.cs
new file mode 100644
index 000000000..3cfa9e0d0
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Events/Schemas/Old/WebhookAdded.cs
@@ -0,0 +1,24 @@
+// ==========================================================================
+// WebhookAdded.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Events.Schemas.Old
+{
+ [TypeName("WebhookAddedEvent")]
+ [Obsolete]
+ public sealed class WebhookAdded : SchemaEvent
+ {
+ public Guid Id { get; set; }
+
+ public Uri Url { get; set; }
+
+ public string SharedSecret { get; set; }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Events/Schemas/Old/WebhookDeleted.cs b/src/Squidex.Domain.Apps.Events/Schemas/Old/WebhookDeleted.cs
new file mode 100644
index 000000000..367cc3fb5
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Events/Schemas/Old/WebhookDeleted.cs
@@ -0,0 +1,20 @@
+// ==========================================================================
+// WebhookDeleted.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Events.Schemas.Old
+{
+ [TypeName("WebhookDeletedEvent")]
+ [Obsolete]
+ public sealed class WebhookDeleted : SchemaEvent
+ {
+ public Guid Id { get; set; }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Events/Webhooks/WebhookCreated.cs b/src/Squidex.Domain.Apps.Events/Webhooks/WebhookCreated.cs
new file mode 100644
index 000000000..af886ca88
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Events/Webhooks/WebhookCreated.cs
@@ -0,0 +1,17 @@
+// ==========================================================================
+// WebhookCreated.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Events.Webhooks
+{
+ [TypeName("WebhookCreatedEvent")]
+ public sealed class WebhookCreated : WebhookEditEvent
+ {
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Events/Webhooks/WebhookDeleted.cs b/src/Squidex.Domain.Apps.Events/Webhooks/WebhookDeleted.cs
new file mode 100644
index 000000000..57b8c2e01
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Events/Webhooks/WebhookDeleted.cs
@@ -0,0 +1,17 @@
+// ==========================================================================
+// WebhookDeleted.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Events.Webhooks
+{
+ [TypeName("WebhookDeletedEvent")]
+ public sealed class WebhookDeleted : WebhookEvent
+ {
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Events/Webhooks/WebhookEditEvent.cs b/src/Squidex.Domain.Apps.Events/Webhooks/WebhookEditEvent.cs
new file mode 100644
index 000000000..02dde09b3
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Events/Webhooks/WebhookEditEvent.cs
@@ -0,0 +1,21 @@
+// ==========================================================================
+// WebhookEditEvent.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections.Generic;
+using Squidex.Domain.Apps.Core.Webhooks;
+
+namespace Squidex.Domain.Apps.Events.Webhooks
+{
+ public abstract class WebhookEditEvent : WebhookEvent
+ {
+ public Uri Url { get; set; }
+
+ public List Schemas { get; set; }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Events/Webhooks/WebhookEvent.cs b/src/Squidex.Domain.Apps.Events/Webhooks/WebhookEvent.cs
new file mode 100644
index 000000000..99fc08697
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Events/Webhooks/WebhookEvent.cs
@@ -0,0 +1,17 @@
+// ==========================================================================
+// WebhookEvent.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+
+namespace Squidex.Domain.Apps.Events.Webhooks
+{
+ public abstract class WebhookEvent : AppEvent
+ {
+ public Guid WebhookId { get; set; }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Events/Webhooks/WebhookUpdated.cs b/src/Squidex.Domain.Apps.Events/Webhooks/WebhookUpdated.cs
new file mode 100644
index 000000000..d05fd1a09
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Events/Webhooks/WebhookUpdated.cs
@@ -0,0 +1,17 @@
+// ==========================================================================
+// WebhookUpdated.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Events.Webhooks
+{
+ [TypeName("WebhookUpdatedEvent")]
+ public sealed class WebhookUpdated : WebhookEditEvent
+ {
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Read.MongoDb/Webhooks/MongoWebhookEntity.cs b/src/Squidex.Domain.Apps.Read.MongoDb/Webhooks/MongoWebhookEntity.cs
new file mode 100644
index 000000000..721424c79
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Read.MongoDb/Webhooks/MongoWebhookEntity.cs
@@ -0,0 +1,76 @@
+// ==========================================================================
+// MongoWebhookEntity.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections.Generic;
+using MongoDB.Bson.Serialization.Attributes;
+using Squidex.Domain.Apps.Core.Webhooks;
+using Squidex.Domain.Apps.Read.Webhooks;
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.MongoDb;
+
+// ReSharper disable CollectionNeverUpdated.Global
+
+namespace Squidex.Domain.Apps.Read.MongoDb.Webhooks
+{
+ public class MongoWebhookEntity : MongoEntity, IWebhookEntity
+ {
+ [BsonRequired]
+ [BsonElement]
+ public Uri Url { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public Guid AppId { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public long Version { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public RefToken CreatedBy { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public RefToken LastModifiedBy { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public string SharedSecret { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public long TotalSucceeded { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public long TotalFailed { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public long TotalTimedout { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public long TotalRequestTime { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public List Schemas { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public List SchemaIds { get; set; }
+
+ IEnumerable IWebhookEntity.Schemas
+ {
+ get { return Schemas; }
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Read.MongoDb/Webhooks/MongoWebhookEventEntity.cs b/src/Squidex.Domain.Apps.Read.MongoDb/Webhooks/MongoWebhookEventEntity.cs
new file mode 100644
index 000000000..18f212967
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Read.MongoDb/Webhooks/MongoWebhookEventEntity.cs
@@ -0,0 +1,79 @@
+// ==========================================================================
+// MongoWebhookEventEntity.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using MongoDB.Bson.Serialization.Attributes;
+using NodaTime;
+using Squidex.Domain.Apps.Read.Webhooks;
+using Squidex.Infrastructure.MongoDb;
+using Squidex.Infrastructure.Reflection;
+
+namespace Squidex.Domain.Apps.Read.MongoDb.Webhooks
+{
+ public sealed class MongoWebhookEventEntity : MongoEntity, IWebhookEventEntity
+ {
+ private WebhookJob job;
+
+ [BsonRequired]
+ [BsonElement]
+ public Guid AppId { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public long Version { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public Uri RequestUrl { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public string RequestBody { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public string RequestSignature { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public string EventName { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public string LastDump { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public Instant Expires { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public Instant? NextAttempt { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public int NumCalls { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public bool IsSending { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public WebhookResult Result { get; set; }
+
+ [BsonRequired]
+ [BsonElement]
+ public WebhookJobResult JobResult { get; set; }
+
+ public WebhookJob Job
+ {
+ get { return job ?? (job = SimpleMapper.Map(this, new WebhookJob())); }
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Read.MongoDb/Webhooks/MongoWebhookEventRepository.cs b/src/Squidex.Domain.Apps.Read.MongoDb/Webhooks/MongoWebhookEventRepository.cs
new file mode 100644
index 000000000..89f9dcf84
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Read.MongoDb/Webhooks/MongoWebhookEventRepository.cs
@@ -0,0 +1,116 @@
+// ==========================================================================
+// MongoWebhookEventRepository.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MongoDB.Driver;
+using NodaTime;
+using Squidex.Domain.Apps.Read.Webhooks;
+using Squidex.Domain.Apps.Read.Webhooks.Repositories;
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.MongoDb;
+using Squidex.Infrastructure.Reflection;
+
+namespace Squidex.Domain.Apps.Read.MongoDb.Webhooks
+{
+ public sealed class MongoWebhookEventRepository : MongoRepositoryBase, IWebhookEventRepository
+ {
+ private readonly IClock clock;
+
+ public MongoWebhookEventRepository(IMongoDatabase database, IClock clock)
+ : base(database)
+ {
+ Guard.NotNull(clock, nameof(clock));
+
+ this.clock = clock;
+ }
+
+ protected override string CollectionName()
+ {
+ return "WebhookEvents";
+ }
+
+ protected override async Task SetupCollectionAsync(IMongoCollection collection)
+ {
+ await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.NextAttempt).Descending(x => x.IsSending));
+ await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.AppId).Descending(x => x.Created));
+ await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.Expires), new CreateIndexOptions { ExpireAfter = TimeSpan.Zero });
+ }
+
+ public Task QueryPendingAsync(Func callback, CancellationToken cancellationToken = new CancellationToken())
+ {
+ var now = clock.GetCurrentInstant();
+
+ return Collection.Find(x => x.NextAttempt < now && !x.IsSending).ForEachAsync(callback, cancellationToken);
+ }
+
+ public async Task> QueryByAppAsync(Guid appId, int skip = 0, int take = 20)
+ {
+ var entities = await Collection.Find(x => x.AppId == appId).Skip(skip).Limit(take).SortByDescending(x => x.Created).ToListAsync();
+
+ return entities;
+ }
+
+ public async Task FindAsync(Guid id)
+ {
+ var entity = await Collection.Find(x => x.Id == id).FirstOrDefaultAsync();
+
+ return entity;
+ }
+
+ public async Task CountByAppAsync(Guid appId)
+ {
+ return (int)await Collection.CountAsync(x => x.AppId == appId);
+ }
+
+ public Task EnqueueAsync(Guid id, Instant nextAttempt)
+ {
+ return Collection.UpdateOneAsync(x => x.Id == id, Update.Set(x => x.NextAttempt, nextAttempt));
+ }
+
+ public Task TraceSendingAsync(Guid jobId)
+ {
+ return Collection.UpdateOneAsync(x => x.Id == jobId, Update.Set(x => x.IsSending, true));
+ }
+
+ public Task EnqueueAsync(WebhookJob job, Instant nextAttempt)
+ {
+ var entity = SimpleMapper.Map(job, new MongoWebhookEventEntity { Created = clock.GetCurrentInstant(), NextAttempt = nextAttempt });
+
+ return Collection.InsertOneIfNotExistsAsync(entity);
+ }
+
+ public Task TraceSentAsync(Guid jobId, string dump, WebhookResult result, TimeSpan elapsed, Instant? nextAttempt)
+ {
+ WebhookJobResult jobResult;
+
+ if (result != WebhookResult.Success && nextAttempt == null)
+ {
+ jobResult = WebhookJobResult.Failed;
+ }
+ else if (result != WebhookResult.Success && nextAttempt.HasValue)
+ {
+ jobResult = WebhookJobResult.Retry;
+ }
+ else
+ {
+ jobResult = WebhookJobResult.Success;
+ }
+
+ return Collection.UpdateOneAsync(x => x.Id == jobId,
+ Update.Set(x => x.Result, result)
+ .Set(x => x.LastDump, dump)
+ .Set(x => x.JobResult, jobResult)
+ .Set(x => x.IsSending, false)
+ .Set(x => x.NextAttempt, nextAttempt)
+ .Inc(x => x.NumCalls, 1));
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Read.MongoDb/Webhooks/MongoWebhookRepository.cs b/src/Squidex.Domain.Apps.Read.MongoDb/Webhooks/MongoWebhookRepository.cs
new file mode 100644
index 000000000..9156ae415
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Read.MongoDb/Webhooks/MongoWebhookRepository.cs
@@ -0,0 +1,108 @@
+// ==========================================================================
+// MongoWebhookRepository.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using MongoDB.Bson;
+using MongoDB.Driver;
+using Squidex.Domain.Apps.Read.Webhooks;
+using Squidex.Domain.Apps.Read.Webhooks.Repositories;
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.CQRS.Events;
+using Squidex.Infrastructure.MongoDb;
+
+// ReSharper disable SwitchStatementMissingSomeCases
+
+namespace Squidex.Domain.Apps.Read.MongoDb.Webhooks
+{
+ public partial class MongoWebhookRepository : MongoRepositoryBase, IWebhookRepository, IEventConsumer
+ {
+ private static readonly List EmptyWebhooks = new List();
+ private Dictionary> inMemoryWebhooks;
+ private readonly SemaphoreSlim lockObject = new SemaphoreSlim(1);
+
+ public MongoWebhookRepository(IMongoDatabase database)
+ : base(database)
+ {
+ }
+
+ protected override string CollectionName()
+ {
+ return "Projections_SchemaWebhooks";
+ }
+
+ protected override async Task SetupCollectionAsync(IMongoCollection collection)
+ {
+ await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.AppId));
+ await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.SchemaIds));
+ }
+
+ public async Task> QueryByAppAsync(Guid appId)
+ {
+ await EnsureWebooksLoadedAsync();
+
+ return inMemoryWebhooks.GetOrDefault(appId) ?? EmptyWebhooks;
+ }
+
+ public async Task TraceSentAsync(Guid webhookId, WebhookResult result, TimeSpan elapsed)
+ {
+ var webhookEntity =
+ await Collection.Find(x => x.Id == webhookId)
+ .FirstOrDefaultAsync();
+
+ if (webhookEntity != null)
+ {
+ switch (result)
+ {
+ case WebhookResult.Success:
+ webhookEntity.TotalSucceeded++;
+ break;
+ case WebhookResult.Failed:
+ webhookEntity.TotalFailed++;
+ break;
+ case WebhookResult.Timeout:
+ webhookEntity.TotalTimedout++;
+ break;
+ }
+
+ webhookEntity.TotalRequestTime += (long)elapsed.TotalMilliseconds;
+
+ await Collection.ReplaceOneAsync(x => x.Id == webhookId, webhookEntity);
+ }
+ }
+
+ private async Task EnsureWebooksLoadedAsync()
+ {
+ if (inMemoryWebhooks == null)
+ {
+ try
+ {
+ await lockObject.WaitAsync();
+
+ if (inMemoryWebhooks == null)
+ {
+ inMemoryWebhooks = new Dictionary>();
+
+ var webhooks = await Collection.Find(new BsonDocument()).ToListAsync();
+
+ foreach (var webhook in webhooks)
+ {
+ inMemoryWebhooks.GetOrAddNew(webhook.AppId).Add(webhook);
+ }
+ }
+ }
+ finally
+ {
+ lockObject.Release();
+ }
+ }
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Read.MongoDb/Webhooks/MongoWebhookRepository_EventHandling.cs b/src/Squidex.Domain.Apps.Read.MongoDb/Webhooks/MongoWebhookRepository_EventHandling.cs
new file mode 100644
index 000000000..bf6b87c46
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Read.MongoDb/Webhooks/MongoWebhookRepository_EventHandling.cs
@@ -0,0 +1,93 @@
+// ==========================================================================
+// MongoSchemaWebhookRepository_EventHandling.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System.Linq;
+using System.Threading.Tasks;
+using MongoDB.Driver;
+using Squidex.Domain.Apps.Events.Schemas;
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.CQRS.Events;
+using Squidex.Infrastructure.Dispatching;
+using Squidex.Infrastructure.Reflection;
+using Squidex.Domain.Apps.Events.Webhooks;
+using Squidex.Domain.Apps.Read.MongoDb.Utils;
+
+namespace Squidex.Domain.Apps.Read.MongoDb.Webhooks
+{
+ public partial class MongoWebhookRepository
+ {
+ public string Name
+ {
+ get { return GetType().Name; }
+ }
+
+ public string EventsFilter
+ {
+ get { return "(^webhook-)|(^schema-)"; }
+ }
+
+ public Task On(Envelope @event)
+ {
+ return this.DispatchActionAsync(@event.Payload, @event.Headers);
+ }
+
+ protected async Task On(WebhookCreated @event, EnvelopeHeaders headers)
+ {
+ await EnsureWebooksLoadedAsync();
+
+ await Collection.CreateAsync(@event, headers, w =>
+ {
+ SimpleMapper.Map(@event, w);
+
+ w.SchemaIds = w.Schemas.Select(x => x.SchemaId).ToList();
+
+ inMemoryWebhooks.GetOrAddNew(w.AppId).RemoveAll(x => x.Id == w.Id);
+ inMemoryWebhooks.GetOrAddNew(w.AppId).Add(w);
+ });
+ }
+
+ protected async Task On(WebhookUpdated @event, EnvelopeHeaders headers)
+ {
+ await EnsureWebooksLoadedAsync();
+
+ await Collection.UpdateAsync(@event, headers, w =>
+ {
+ SimpleMapper.Map(@event, w);
+
+ w.SchemaIds = w.Schemas.Select(x => x.SchemaId).ToList();
+
+ inMemoryWebhooks.GetOrAddNew(w.AppId).RemoveAll(x => x.Id == w.Id);
+ inMemoryWebhooks.GetOrAddNew(w.AppId).Add(w);
+ });
+ }
+
+ protected async Task On(SchemaDeleted @event, EnvelopeHeaders headers)
+ {
+ await EnsureWebooksLoadedAsync();
+
+ await Collection.UpdateAsync(@event, headers, w =>
+ {
+ w.Schemas.RemoveAll(s => s.SchemaId == @event.SchemaId.Id);
+
+ w.SchemaIds = w.Schemas.Select(x => x.SchemaId).ToList();
+
+ inMemoryWebhooks.GetOrAddNew(w.AppId).RemoveAll(x => x.Id == w.Id);
+ inMemoryWebhooks.GetOrAddNew(w.AppId).Add(w);
+ });
+ }
+
+ protected async Task On(WebhookDeleted @event, EnvelopeHeaders headers)
+ {
+ await EnsureWebooksLoadedAsync();
+
+ inMemoryWebhooks.GetOrAddNew(@event.AppId.Id).RemoveAll(x => x.Id == @event.WebhookId);
+
+ await Collection.DeleteManyAsync(x => x.Id == @event.WebhookId);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Read/Webhooks/IWebhookEntity.cs b/src/Squidex.Domain.Apps.Read/Webhooks/IWebhookEntity.cs
new file mode 100644
index 000000000..62ba20035
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Read/Webhooks/IWebhookEntity.cs
@@ -0,0 +1,31 @@
+// ==========================================================================
+// IWebhookEntity.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections.Generic;
+using Squidex.Domain.Apps.Core.Webhooks;
+
+namespace Squidex.Domain.Apps.Read.Webhooks
+{
+ public interface IWebhookEntity : IAppRefEntity, IEntityWithCreatedBy, IEntityWithLastModifiedBy, IEntityWithVersion
+ {
+ Uri Url { get; }
+
+ long TotalSucceeded { get; }
+
+ long TotalFailed { get; }
+
+ long TotalTimedout { get; }
+
+ long TotalRequestTime { get; }
+
+ string SharedSecret { get; }
+
+ IEnumerable Schemas { get; }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Read/Webhooks/IWebhookEventEntity.cs b/src/Squidex.Domain.Apps.Read/Webhooks/IWebhookEventEntity.cs
new file mode 100644
index 000000000..e35b23747
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Read/Webhooks/IWebhookEventEntity.cs
@@ -0,0 +1,27 @@
+// ==========================================================================
+// IWebhookEventEntity.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using NodaTime;
+
+namespace Squidex.Domain.Apps.Read.Webhooks
+{
+ public interface IWebhookEventEntity : IEntity
+ {
+ WebhookJob Job { get; }
+
+ Instant? NextAttempt { get; }
+
+ WebhookResult Result { get; }
+
+ WebhookJobResult JobResult { get; }
+
+ int NumCalls { get; }
+
+ string LastDump { get; }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Read/Webhooks/Repositories/IWebhookEventRepository.cs b/src/Squidex.Domain.Apps.Read/Webhooks/Repositories/IWebhookEventRepository.cs
new file mode 100644
index 000000000..26d6d0ffa
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Read/Webhooks/Repositories/IWebhookEventRepository.cs
@@ -0,0 +1,35 @@
+// ==========================================================================
+// IWebhookEventRepository.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using NodaTime;
+
+namespace Squidex.Domain.Apps.Read.Webhooks.Repositories
+{
+ public interface IWebhookEventRepository
+ {
+ Task EnqueueAsync(WebhookJob job, Instant nextAttempt);
+
+ Task EnqueueAsync(Guid id, Instant nextAttempt);
+
+ Task TraceSendingAsync(Guid jobId);
+
+ Task TraceSentAsync(Guid jobId, string dump, WebhookResult result, TimeSpan elapsed, Instant? nextCall);
+
+ Task QueryPendingAsync(Func callback, CancellationToken cancellationToken = default(CancellationToken));
+
+ Task CountByAppAsync(Guid appId);
+
+ Task> QueryByAppAsync(Guid appId, int skip = 0, int take = 20);
+
+ Task FindAsync(Guid id);
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Read/Webhooks/Repositories/IWebhookRepository.cs b/src/Squidex.Domain.Apps.Read/Webhooks/Repositories/IWebhookRepository.cs
new file mode 100644
index 000000000..fce28390b
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Read/Webhooks/Repositories/IWebhookRepository.cs
@@ -0,0 +1,21 @@
+// ==========================================================================
+// ISchemaWebhookRepository.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Squidex.Domain.Apps.Read.Webhooks.Repositories
+{
+ public interface IWebhookRepository
+ {
+ Task TraceSentAsync(Guid webhookId, WebhookResult result, TimeSpan elapsed);
+
+ Task> QueryByAppAsync(Guid appId);
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Read/Webhooks/WebhookDequeuer.cs b/src/Squidex.Domain.Apps.Read/Webhooks/WebhookDequeuer.cs
new file mode 100644
index 000000000..72b467252
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Read/Webhooks/WebhookDequeuer.cs
@@ -0,0 +1,164 @@
+// ==========================================================================
+// WebhookDequeuer.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Threading.Tasks.Dataflow;
+using NodaTime;
+using Squidex.Domain.Apps.Read.Webhooks.Repositories;
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.Log;
+using Squidex.Infrastructure.Timers;
+
+// ReSharper disable SwitchStatementMissingSomeCases
+// ReSharper disable MethodSupportsCancellation
+// ReSharper disable InvertIf
+
+namespace Squidex.Domain.Apps.Read.Webhooks
+{
+ public sealed class WebhookDequeuer : DisposableObjectBase, IExternalSystem
+ {
+ private readonly ActionBlock requestBlock;
+ private readonly TransformBlock blockBlock;
+ private readonly IWebhookEventRepository webhookEventRepository;
+ private readonly IWebhookRepository webhookRepository;
+ private readonly WebhookSender webhookSender;
+ private readonly CompletionTimer timer;
+ private readonly ISemanticLog log;
+ private readonly IClock clock;
+
+ public WebhookDequeuer(WebhookSender webhookSender,
+ IWebhookEventRepository webhookEventRepository,
+ IWebhookRepository webhookRepository,
+ IClock clock,
+ ISemanticLog log)
+ {
+ Guard.NotNull(webhookEventRepository, nameof(webhookEventRepository));
+ Guard.NotNull(webhookRepository, nameof(webhookRepository));
+ Guard.NotNull(webhookSender, nameof(webhookSender));
+ Guard.NotNull(clock, nameof(clock));
+ Guard.NotNull(log, nameof(log));
+
+ this.webhookEventRepository = webhookEventRepository;
+ this.webhookRepository = webhookRepository;
+ this.webhookSender = webhookSender;
+
+ this.clock = clock;
+
+ this.log = log;
+
+ requestBlock =
+ new ActionBlock(MakeRequestAsync,
+ new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 32, BoundedCapacity = 32 });
+
+ blockBlock =
+ new TransformBlock(x => BlockAsync(x),
+ new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 1, BoundedCapacity = 1 });
+
+ blockBlock.LinkTo(requestBlock, new DataflowLinkOptions { PropagateCompletion = true });
+
+ timer = new CompletionTimer(5000, QueryAsync);
+ }
+
+ protected override void DisposeObject(bool disposing)
+ {
+ if (disposing)
+ {
+ timer.StopAsync().Wait();
+
+ blockBlock.Complete();
+ requestBlock.Completion.Wait();
+ }
+ }
+
+ public void Connect()
+ {
+ }
+
+ public void Next()
+ {
+ timer.SkipCurrentDelay();
+ }
+
+ private async Task QueryAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ await webhookEventRepository.QueryPendingAsync(blockBlock.SendAsync, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ log.LogError(ex, w => w
+ .WriteProperty("action", "QueueWebhookEvents")
+ .WriteProperty("status", "Failed"));
+ }
+ }
+
+ private async Task BlockAsync(IWebhookEventEntity @event)
+ {
+ try
+ {
+ await webhookEventRepository.TraceSendingAsync(@event.Id);
+
+ return @event;
+ }
+ catch (Exception ex)
+ {
+ log.LogError(ex, w => w
+ .WriteProperty("action", "BlockWebhookEvent")
+ .WriteProperty("status", "Failed"));
+
+ throw;
+ }
+ }
+
+ private async Task MakeRequestAsync(IWebhookEventEntity @event)
+ {
+ try
+ {
+ var response = await webhookSender.SendAsync(@event.Job);
+
+ Instant? nextCall = null;
+
+ if (response.Result != WebhookResult.Success)
+ {
+ var now = clock.GetCurrentInstant();
+
+ switch (@event.NumCalls)
+ {
+ case 0:
+ nextCall = now.Plus(Duration.FromMinutes(5));
+ break;
+ case 1:
+ nextCall = now.Plus(Duration.FromHours(1));
+ break;
+ case 2:
+ nextCall = now.Plus(Duration.FromHours(5));
+ break;
+ case 3:
+ nextCall = now.Plus(Duration.FromHours(6));
+ break;
+ }
+ }
+
+ await Task.WhenAll(
+ webhookRepository.TraceSentAsync(@event.Job.WebhookId, response.Result, response.Elapsed),
+ webhookEventRepository.TraceSentAsync(@event.Id, response.Dump, response.Result, response.Elapsed, nextCall));
+ }
+ catch (Exception ex)
+ {
+ log.LogError(ex, w => w
+ .WriteProperty("action", "SendWebhookEvent")
+ .WriteProperty("status", "Failed"));
+
+ throw;
+ }
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Read/Webhooks/WebhookEnqueuer.cs b/src/Squidex.Domain.Apps.Read/Webhooks/WebhookEnqueuer.cs
new file mode 100644
index 000000000..92d055959
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Read/Webhooks/WebhookEnqueuer.cs
@@ -0,0 +1,140 @@
+// ==========================================================================
+// WebhookEnqueuer.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using NodaTime;
+using Squidex.Domain.Apps.Core.Webhooks;
+using Squidex.Domain.Apps.Events;
+using Squidex.Domain.Apps.Events.Contents;
+using Squidex.Domain.Apps.Read.Webhooks.Repositories;
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.CQRS.Events;
+using Squidex.Infrastructure.Tasks;
+
+namespace Squidex.Domain.Apps.Read.Webhooks
+{
+ public sealed class WebhookEnqueuer : IEventConsumer
+ {
+ private const string ContentPrefix = "Content";
+ private static readonly Duration ExpirationTime = Duration.FromDays(2);
+ private readonly IWebhookEventRepository webhookEventRepository;
+ private readonly IWebhookRepository webhookRepository;
+ private readonly IClock clock;
+ private readonly TypeNameRegistry typeNameRegistry;
+ private readonly JsonSerializer webhookSerializer;
+
+ public string Name
+ {
+ get { return GetType().Name; }
+ }
+
+ public string EventsFilter
+ {
+ get { return "^content-"; }
+ }
+
+ public WebhookEnqueuer(TypeNameRegistry typeNameRegistry,
+ IWebhookEventRepository webhookEventRepository,
+ IWebhookRepository webhookRepository,
+ IClock clock,
+ JsonSerializer webhookSerializer)
+ {
+ Guard.NotNull(webhookEventRepository, nameof(webhookEventRepository));
+ Guard.NotNull(webhookSerializer, nameof(webhookSerializer));
+ Guard.NotNull(webhookRepository, nameof(webhookRepository));
+ Guard.NotNull(typeNameRegistry, nameof(typeNameRegistry));
+ Guard.NotNull(clock, nameof(clock));
+
+ this.webhookEventRepository = webhookEventRepository;
+ this.webhookSerializer = webhookSerializer;
+ this.webhookRepository = webhookRepository;
+
+ this.clock = clock;
+
+ this.typeNameRegistry = typeNameRegistry;
+ }
+
+ public Task ClearAsync()
+ {
+ return TaskHelper.Done;
+ }
+
+ public async Task On(Envelope @event)
+ {
+ if (@event.Payload is ContentEvent contentEvent)
+ {
+ var eventType = typeNameRegistry.GetName(@event.Payload.GetType());
+
+ var webhooks = await webhookRepository.QueryByAppAsync(contentEvent.AppId.Id);
+
+ var matchingWebhooks = webhooks.Where(w => w.Schemas.Any(s => Matchs(s, contentEvent))).ToList();
+
+ if (matchingWebhooks.Count > 0)
+ {
+ var now = clock.GetCurrentInstant();
+
+ var eventPayload = CreatePayload(@event, eventType);
+ var eventName = $"{contentEvent.SchemaId.Name.ToPascalCase()}{CreateContentEventName(eventType)}";
+
+ foreach (var webhook in matchingWebhooks)
+ {
+ await EnqueueJobAsync(eventPayload, webhook, contentEvent, eventName, now);
+ }
+ }
+ }
+ }
+
+ private async Task EnqueueJobAsync(string payload, IWebhookEntity webhook, AppEvent contentEvent, string eventName, Instant now)
+ {
+ var signature = $"{payload}{webhook.SharedSecret}".Sha256Base64();
+
+ var job = new WebhookJob
+ {
+ Id = Guid.NewGuid(),
+ AppId = contentEvent.AppId.Id,
+ RequestUrl = webhook.Url,
+ RequestBody = payload,
+ RequestSignature = signature,
+ EventName = eventName,
+ Expires = now.Plus(ExpirationTime),
+ WebhookId = webhook.Id
+ };
+
+ await webhookEventRepository.EnqueueAsync(job, now);
+ }
+
+ private static bool Matchs(WebhookSchema webhookSchema, SchemaEvent @event)
+ {
+ return
+ (@event.SchemaId.Id == webhookSchema.SchemaId) &&
+ (@event is ContentCreated && webhookSchema.SendCreate ||
+ @event is ContentUpdated && webhookSchema.SendUpdate ||
+ @event is ContentDeleted && webhookSchema.SendDelete ||
+ @event is ContentPublished && webhookSchema.SendPublish ||
+ @event is ContentUnpublished && webhookSchema.SendUnpublish);
+ }
+
+ private string CreatePayload(Envelope @event, string eventType)
+ {
+ return new JObject(
+ new JProperty("type", eventType),
+ new JProperty("payload", JObject.FromObject(@event.Payload, webhookSerializer)),
+ new JProperty("timestamp", @event.Headers.Timestamp().ToString()))
+ .ToString(Formatting.Indented);
+ }
+
+ private static string CreateContentEventName(string eventType)
+ {
+ return eventType.StartsWith(ContentPrefix) ? eventType.Substring(ContentPrefix.Length) : eventType;
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Read/Webhooks/WebhookJob.cs b/src/Squidex.Domain.Apps.Read/Webhooks/WebhookJob.cs
new file mode 100644
index 000000000..8951c3da6
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Read/Webhooks/WebhookJob.cs
@@ -0,0 +1,32 @@
+// ==========================================================================
+// WebhookJob.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using NodaTime;
+
+namespace Squidex.Domain.Apps.Read.Webhooks
+{
+ public sealed class WebhookJob
+ {
+ public Guid Id { get; set; }
+
+ public Guid AppId { get; set; }
+
+ public Guid WebhookId { get; set; }
+
+ public Uri RequestUrl { get; set; }
+
+ public string RequestBody { get; set; }
+
+ public string RequestSignature { get; set; }
+
+ public string EventName { get; set; }
+
+ public Instant Expires { get; set; }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Read/Webhooks/WebhookJobResult.cs b/src/Squidex.Domain.Apps.Read/Webhooks/WebhookJobResult.cs
new file mode 100644
index 000000000..57d68fc19
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Read/Webhooks/WebhookJobResult.cs
@@ -0,0 +1,18 @@
+// ==========================================================================
+// WebhookJobResult.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Read.Webhooks
+{
+ public enum WebhookJobResult
+ {
+ Pending,
+ Success,
+ Retry,
+ Failed
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Read/Webhooks/WebhookResult.cs b/src/Squidex.Domain.Apps.Read/Webhooks/WebhookResult.cs
new file mode 100644
index 000000000..bc8584b5b
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Read/Webhooks/WebhookResult.cs
@@ -0,0 +1,18 @@
+// ==========================================================================
+// WebhookResult.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Read.Webhooks
+{
+ public enum WebhookResult
+ {
+ Pending,
+ Success,
+ Failed,
+ Timeout
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Read/Webhooks/WebhookSender.cs b/src/Squidex.Domain.Apps.Read/Webhooks/WebhookSender.cs
new file mode 100644
index 000000000..39b632d04
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Read/Webhooks/WebhookSender.cs
@@ -0,0 +1,89 @@
+// ==========================================================================
+// WebhookSender.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Diagnostics;
+using System.Net.Http;
+using System.Text;
+using System.Threading.Tasks;
+using Squidex.Infrastructure.Http;
+
+// ReSharper disable SuggestVarOrType_SimpleTypes
+
+namespace Squidex.Domain.Apps.Read.Webhooks
+{
+ public class WebhookSender
+ {
+ private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(2);
+
+ public virtual async Task<(string Dump, WebhookResult Result, TimeSpan Elapsed)> SendAsync(WebhookJob job)
+ {
+ HttpRequestMessage request = BuildRequest(job);
+ HttpResponseMessage response = null;
+
+ var isTimeout = false;
+
+ var watch = Stopwatch.StartNew();
+
+ try
+ {
+ using (var client = new HttpClient { Timeout = Timeout })
+ {
+ response = await client.SendAsync(request);
+ }
+ }
+ catch (TimeoutException)
+ {
+ isTimeout = true;
+ }
+ catch (OperationCanceledException)
+ {
+ isTimeout = true;
+ }
+ finally
+ {
+ watch.Stop();
+ }
+
+ var responseString = string.Empty;
+
+ if (response != null)
+ {
+ responseString = await response.Content.ReadAsStringAsync();
+ }
+
+ var dump = DumpFormatter.BuildDump(request, response, job.RequestBody, responseString, watch.Elapsed);
+
+ var result = WebhookResult.Failed;
+
+ if (isTimeout)
+ {
+ result = WebhookResult.Timeout;
+ }
+ else if (response?.IsSuccessStatusCode == true)
+ {
+ result = WebhookResult.Success;
+ }
+
+ return (dump, result, watch.Elapsed);
+ }
+
+ private static HttpRequestMessage BuildRequest(WebhookJob job)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Post, job.RequestUrl)
+ {
+ Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json")
+ };
+
+ request.Headers.Add("X-Signature", job.RequestSignature);
+ request.Headers.Add("User-Agent", "Squidex Webhook");
+
+ return request;
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Write/Webhooks/Commands/CreateWebhook.cs b/src/Squidex.Domain.Apps.Write/Webhooks/Commands/CreateWebhook.cs
new file mode 100644
index 000000000..d6a76ea84
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Write/Webhooks/Commands/CreateWebhook.cs
@@ -0,0 +1,23 @@
+// ==========================================================================
+// CreateWebhook.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Write.Webhooks.Commands
+{
+ public sealed class CreateWebhook : WebhookEditCommand
+ {
+ public string SharedSecret { get; } = RandomHash.New();
+
+ public CreateWebhook()
+ {
+ WebhookId = Guid.NewGuid();
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Write/Webhooks/Commands/DeleteWebhook.cs b/src/Squidex.Domain.Apps.Write/Webhooks/Commands/DeleteWebhook.cs
new file mode 100644
index 000000000..462532dfe
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Write/Webhooks/Commands/DeleteWebhook.cs
@@ -0,0 +1,14 @@
+// ==========================================================================
+// DeleteWebhook.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Write.Webhooks.Commands
+{
+ public sealed class DeleteWebhook : WebhookAggregateCommand
+ {
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Write/Webhooks/Commands/UpdateWebhook.cs b/src/Squidex.Domain.Apps.Write/Webhooks/Commands/UpdateWebhook.cs
new file mode 100644
index 000000000..22c33345f
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Write/Webhooks/Commands/UpdateWebhook.cs
@@ -0,0 +1,14 @@
+// ==========================================================================
+// UpdateWebhook.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+namespace Squidex.Domain.Apps.Write.Webhooks.Commands
+{
+ public sealed class UpdateWebhook : WebhookEditCommand
+ {
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Write/Webhooks/Commands/WebhookAggregateCommand.cs b/src/Squidex.Domain.Apps.Write/Webhooks/Commands/WebhookAggregateCommand.cs
new file mode 100644
index 000000000..c6ec59c88
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Write/Webhooks/Commands/WebhookAggregateCommand.cs
@@ -0,0 +1,25 @@
+// ==========================================================================
+// WebhookAggregateCommand.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using Squidex.Infrastructure.CQRS.Commands;
+
+// ReSharper disable MemberCanBeProtected.Global
+
+namespace Squidex.Domain.Apps.Write.Webhooks.Commands
+{
+ public abstract class WebhookAggregateCommand : AppCommand, IAggregateCommand
+ {
+ public Guid WebhookId { get; set; }
+
+ Guid IAggregateCommand.AggregateId
+ {
+ get { return WebhookId; }
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Write/Webhooks/Commands/WebhookEditCommand.cs b/src/Squidex.Domain.Apps.Write/Webhooks/Commands/WebhookEditCommand.cs
new file mode 100644
index 000000000..9936917ab
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Write/Webhooks/Commands/WebhookEditCommand.cs
@@ -0,0 +1,47 @@
+// ==========================================================================
+// WebhookEditCommand.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections.Generic;
+using Squidex.Domain.Apps.Core.Webhooks;
+using Squidex.Infrastructure;
+
+namespace Squidex.Domain.Apps.Write.Webhooks.Commands
+{
+ public abstract class WebhookEditCommand : WebhookAggregateCommand, IValidatable
+ {
+ private List schemas = new List();
+
+ public Uri Url { get; set; }
+
+ public List Schemas
+ {
+ get
+ {
+ return schemas ?? (schemas = new List());
+ }
+ set
+ {
+ schemas = value;
+ }
+ }
+
+ public virtual void Validate(IList errors)
+ {
+ if (Url == null || !Url.IsAbsoluteUri)
+ {
+ errors.Add(new ValidationError("Url must be specified and absolute", nameof(Url)));
+ }
+
+ if (Schemas == null)
+ {
+ errors.Add(new ValidationError("Schemas must be specified.", nameof(Schemas)));
+ }
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Write/Webhooks/WebhookCommandMiddleware.cs b/src/Squidex.Domain.Apps.Write/Webhooks/WebhookCommandMiddleware.cs
new file mode 100644
index 000000000..8d8fa71b3
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Write/Webhooks/WebhookCommandMiddleware.cs
@@ -0,0 +1,77 @@
+// ==========================================================================
+// WebhookCommandMiddleware.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using Squidex.Domain.Apps.Read.Schemas.Services;
+using Squidex.Domain.Apps.Write.Webhooks.Commands;
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.CQRS.Commands;
+using Squidex.Infrastructure.Dispatching;
+
+namespace Squidex.Domain.Apps.Write.Webhooks
+{
+ public class WebhookCommandMiddleware : ICommandMiddleware
+ {
+ private readonly IAggregateHandler handler;
+ private readonly ISchemaProvider schemas;
+
+ public WebhookCommandMiddleware(IAggregateHandler handler, ISchemaProvider schemas)
+ {
+ Guard.NotNull(handler, nameof(handler));
+ Guard.NotNull(schemas, nameof(schemas));
+
+ this.handler = handler;
+ this.schemas = schemas;
+ }
+
+ protected async Task On(CreateWebhook command, CommandContext context)
+ {
+ await ValidateAsync(command, () => "Failed to create webhook");
+
+ await handler.UpdateAsync(context, c => c.Create(command));
+ }
+
+ protected async Task On(UpdateWebhook command, CommandContext context)
+ {
+ await ValidateAsync(command, () => "Failed to update content");
+
+ await handler.UpdateAsync(context, c => c.Update(command));
+ }
+
+ protected Task On(DeleteWebhook command, CommandContext context)
+ {
+ return handler.UpdateAsync(context, c => c.Delete(command));
+ }
+
+ public async Task HandleAsync(CommandContext context, Func next)
+ {
+ if (!await this.DispatchActionAsync(context.Command, context))
+ {
+ await next();
+ }
+ }
+
+ private async Task ValidateAsync(WebhookEditCommand command, Func message)
+ {
+ var results = await Task.WhenAll(
+ command.Schemas.Select(async schema =>
+ await schemas.FindSchemaByIdAsync(schema.SchemaId) == null
+ ? new ValidationError($"Schema {schema.SchemaId} does not exist.")
+ : null));
+
+ var errors = results.Where(x => x != null).ToArray();
+
+ if (errors.Length > 0)
+ {
+ throw new ValidationException(message(), errors);
+ }
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Apps.Write/Webhooks/WebhookDomainObject.cs b/src/Squidex.Domain.Apps.Write/Webhooks/WebhookDomainObject.cs
new file mode 100644
index 000000000..139643a6c
--- /dev/null
+++ b/src/Squidex.Domain.Apps.Write/Webhooks/WebhookDomainObject.cs
@@ -0,0 +1,88 @@
+// ==========================================================================
+// WebhookDomainObject.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using Squidex.Domain.Apps.Events.Webhooks;
+using Squidex.Domain.Apps.Write.Webhooks.Commands;
+using Squidex.Infrastructure;
+using Squidex.Infrastructure.CQRS;
+using Squidex.Infrastructure.CQRS.Events;
+using Squidex.Infrastructure.Dispatching;
+using Squidex.Infrastructure.Reflection;
+
+namespace Squidex.Domain.Apps.Write.Webhooks
+{
+ public class WebhookDomainObject : DomainObjectBase
+ {
+ private bool isDeleted;
+ private bool isCreated;
+
+ public WebhookDomainObject(Guid id, int version)
+ : base(id, version)
+ {
+ }
+
+ protected void On(WebhookCreated @event)
+ {
+ isCreated = true;
+ }
+
+ protected void On(WebhookDeleted @event)
+ {
+ isDeleted = true;
+ }
+
+ public void Create(CreateWebhook command)
+ {
+ Guard.Valid(command, nameof(command), () => "Cannot create webhook");
+
+ VerifyNotCreated();
+
+ RaiseEvent(SimpleMapper.Map(command, new WebhookCreated()));
+ }
+
+ public void Update(UpdateWebhook command)
+ {
+ Guard.Valid(command, nameof(command), () => "Cannot update webhook");
+
+ VerifyCreatedAndNotDeleted();
+
+ RaiseEvent(SimpleMapper.Map(command, new WebhookUpdated()));
+ }
+
+ public void Delete(DeleteWebhook command)
+ {
+ Guard.NotNull(command, nameof(command));
+
+ VerifyCreatedAndNotDeleted();
+
+ RaiseEvent(SimpleMapper.Map(command, new WebhookDeleted()));
+ }
+
+ private void VerifyNotCreated()
+ {
+ if (isCreated)
+ {
+ throw new DomainException("Webhook has already been created.");
+ }
+ }
+
+ private void VerifyCreatedAndNotDeleted()
+ {
+ if (isDeleted || isCreated)
+ {
+ throw new DomainException("Webhook has already been deleted or not created yet.");
+ }
+ }
+
+ protected override void DispatchEvent(Envelope @event)
+ {
+ this.DispatchAction(@event.Payload);
+ }
+ }
+}
diff --git a/src/Squidex.Domain.Users/UserExtensions.cs b/src/Squidex.Domain.Users/UserExtensions.cs
index 95f49e480..3685cfe62 100644
--- a/src/Squidex.Domain.Users/UserExtensions.cs
+++ b/src/Squidex.Domain.Users/UserExtensions.cs
@@ -57,7 +57,7 @@ namespace Squidex.Domain.Users
{
var url = user.Claims.FirstOrDefault(x => x.Type == SquidexClaimTypes.SquidexPictureUrl)?.Value;
- if (url != null && !string.IsNullOrWhiteSpace(url) && Uri.IsWellFormedUriString(url, UriKind.Absolute) && url.Contains("gravatar"))
+ if (!string.IsNullOrWhiteSpace(url) && Uri.IsWellFormedUriString(url, UriKind.Absolute) && url.Contains("gravatar"))
{
if (url.Contains("?"))
{
diff --git a/src/Squidex/Config/Domain/ReadModule.cs b/src/Squidex/Config/Domain/ReadModule.cs
index b91030ef2..7744640aa 100644
--- a/src/Squidex/Config/Domain/ReadModule.cs
+++ b/src/Squidex/Config/Domain/ReadModule.cs
@@ -21,6 +21,7 @@ using Squidex.Domain.Apps.Read.History;
using Squidex.Domain.Apps.Read.Schemas;
using Squidex.Domain.Apps.Read.Schemas.Services;
using Squidex.Domain.Apps.Read.Schemas.Services.Implementations;
+using Squidex.Domain.Apps.Read.Webhooks;
using Squidex.Domain.Users;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Assets;
diff --git a/src/Squidex/Controllers/Api/Webhooks/Models/CreateWebhookDto.cs b/src/Squidex/Controllers/Api/Webhooks/Models/CreateWebhookDto.cs
index 9813f52ab..fca6289f7 100644
--- a/src/Squidex/Controllers/Api/Webhooks/Models/CreateWebhookDto.cs
+++ b/src/Squidex/Controllers/Api/Webhooks/Models/CreateWebhookDto.cs
@@ -10,6 +10,8 @@ using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
+// ReSharper disable CollectionNeverUpdated.Global
+
namespace Squidex.Controllers.Api.Webhooks.Models
{
public class CreateWebhookDto
diff --git a/src/Squidex/Controllers/Api/Webhooks/Models/UpdateWebhookDto.cs b/src/Squidex/Controllers/Api/Webhooks/Models/UpdateWebhookDto.cs
new file mode 100644
index 000000000..684600a18
--- /dev/null
+++ b/src/Squidex/Controllers/Api/Webhooks/Models/UpdateWebhookDto.cs
@@ -0,0 +1,29 @@
+// ==========================================================================
+// UpdateWebhookDto.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+
+namespace Squidex.Controllers.Api.Webhooks.Models
+{
+ public class UpdateWebhookDto
+ {
+ ///
+ /// The url of the webhook.
+ ///
+ [Required]
+ public Uri Url { get; set; }
+
+ ///
+ /// The schema settings.
+ ///
+ [Required]
+ public List Schemas { get; set; }
+ }
+}
diff --git a/src/Squidex/Controllers/Api/Webhooks/Models/WebhookSchemaDto.cs b/src/Squidex/Controllers/Api/Webhooks/Models/WebhookSchemaDto.cs
new file mode 100644
index 000000000..32c89468d
--- /dev/null
+++ b/src/Squidex/Controllers/Api/Webhooks/Models/WebhookSchemaDto.cs
@@ -0,0 +1,45 @@
+// ==========================================================================
+// WebhookSchemaDto.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+
+namespace Squidex.Controllers.Api.Webhooks.Models
+{
+ public class WebhookSchemaDto
+ {
+ ///
+ /// The id of the schema.
+ ///
+ public Guid SchemaId { get; set; }
+
+ ///
+ /// True, when to send a message for created events.
+ ///
+ public bool SendCreate { get; set; }
+
+ ///
+ /// True, when to send a message for updated events.
+ ///
+ public bool SendUpdate { get; set; }
+
+ ///
+ /// True, when to send a message for deleted events.
+ ///
+ public bool SendDelete { get; set; }
+
+ ///
+ /// True, when to send a message for published events.
+ ///
+ public bool SendPublish { get; set; }
+
+ ///
+ /// True, when to send a message for unpublished events.
+ ///
+ public bool SendUnpublish { get; set; }
+ }
+}
diff --git a/tests/Squidex.Domain.Apps.Read.Tests/Webhooks/WebhookDequeuerTests.cs b/tests/Squidex.Domain.Apps.Read.Tests/Webhooks/WebhookDequeuerTests.cs
new file mode 100644
index 000000000..c9eb3d399
--- /dev/null
+++ b/tests/Squidex.Domain.Apps.Read.Tests/Webhooks/WebhookDequeuerTests.cs
@@ -0,0 +1,130 @@
+// ==========================================================================
+// WebhookDequeuerTests.cs
+// Squidex Headless CMS
+// ==========================================================================
+// Copyright (c) Squidex Group
+// All rights reserved.
+// ==========================================================================
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using FakeItEasy;
+using NodaTime;
+using Squidex.Domain.Apps.Read.Webhooks.Repositories;
+using Squidex.Infrastructure.Log;
+using Xunit;
+
+// ReSharper disable MethodSupportsCancellation
+// ReSharper disable ImplicitlyCapturedClosure
+// ReSharper disable ConvertToConstant.Local
+
+namespace Squidex.Domain.Apps.Read.Webhooks
+{
+ public class WebhookDequeuerTests
+ {
+ private readonly IClock clock = A.Fake();
+ private readonly IWebhookRepository webhookRepository = A.Fake();
+ private readonly IWebhookEventRepository webhookEventRepository = A.Fake();
+ private readonly WebhookSender webhookSender = A.Fake();
+ private readonly Instant now = SystemClock.Instance.GetCurrentInstant();
+
+ public WebhookDequeuerTests()
+ {
+ A.CallTo(() => clock.GetCurrentInstant()).Returns(now);
+ }
+
+ [Fact]
+ public void Should_update_repositories_on_successful_requests()
+ {
+ var @event = CreateEvent(0);
+
+ var requestResult = WebhookResult.Success;
+ var requestTime = TimeSpan.FromMinutes(1);
+ var requestDump = "Dump";
+
+ SetupSender(@event, requestDump, requestResult, requestTime);
+ SetupPendingEvents(@event);
+
+ var sut = new WebhookDequeuer(
+ webhookSender,
+ webhookEventRepository,
+ webhookRepository,
+ clock, A.Fake());
+
+ sut.Next();
+ sut.Dispose();
+
+ VerifyRepositories(@event, requestDump, requestResult, requestTime, null);
+ }
+
+ [Theory]
+ [InlineData(0, 5)]
+ [InlineData(1, 60)]
+ [InlineData(2, 300)]
+ [InlineData(3, 360)]
+ public void Should_set_next_attempt_based_on_num_calls(int calls, int minutes)
+ {
+ var @event = CreateEvent(calls);
+
+ var requestResult = WebhookResult.Failed;
+ var requestTime = TimeSpan.FromMinutes(1);
+ var requestDump = "Dump";
+
+ SetupSender(@event, requestDump, requestResult, requestTime);
+ SetupPendingEvents(@event);
+
+ var sut = new WebhookDequeuer(
+ webhookSender,
+ webhookEventRepository,
+ webhookRepository,
+ clock, A.Fake());
+
+ sut.Next();
+ sut.Dispose();
+
+ VerifyRepositories(@event, requestDump, requestResult, requestTime, now.Plus(Duration.FromMinutes(minutes)));
+ }
+
+ private void SetupSender(IWebhookEventEntity @event, string requestDump, WebhookResult requestResult, TimeSpan requestTime)
+ {
+ A.CallTo(() => webhookSender.SendAsync(@event.Job))
+ .Returns(Task.FromResult((requestDump, requestResult, requestTime)));
+ }
+
+ private void SetupPendingEvents(IWebhookEventEntity @event)
+ {
+ A.CallTo(() => webhookEventRepository.QueryPendingAsync(A>.Ignored, A.Ignored))
+ .Invokes(async (Func callback, CancellationToken ct) =>
+ {
+ await callback(@event);
+ });
+ }
+
+ private void VerifyRepositories(IWebhookEventEntity @event, string requestDump, WebhookResult requestResult, TimeSpan requestTime, Instant? nextAttempt)
+ {
+ A.CallTo(() => webhookEventRepository.TraceSendingAsync(@event.Id))
+ .MustHaveHappened();
+
+ A.CallTo(() => webhookEventRepository.TraceSendingAsync(@event.Id))
+ .MustHaveHappened();
+
+ A.CallTo(() => webhookEventRepository.TraceSentAsync(@event.Id, requestDump, requestResult, requestTime, nextAttempt))
+ .MustHaveHappened();
+
+ A.CallTo(() => webhookRepository.TraceSentAsync(@event.Job.WebhookId, requestResult, requestTime))
+ .MustHaveHappened();
+ }
+
+ private static IWebhookEventEntity CreateEvent(int numCalls)
+ {
+ var @event = A.Fake();
+
+ A.CallTo(() => @event.Id).Returns(Guid.NewGuid());
+ A.CallTo(() => @event.Job).Returns(new WebhookJob { WebhookId = Guid.NewGuid() });
+ A.CallTo(() => @event.NumCalls).Returns(numCalls);
+
+ return @event;
+ }
+ }
+}
diff --git a/tests/Squidex.Domain.Apps.Read.Tests/Webhooks/WebhookEnqueuerTests.cs b/tests/Squidex.Domain.Apps.Read.Tests/Webhooks/WebhookEnqueuerTests.cs
new file mode 100644
index 000000000..f19b08977
--- /dev/null
+++ b/tests/Squidex.Domain.Apps.Read.Tests/Webhooks/WebhookEnqueuerTests.cs
@@ -0,0 +1,118 @@
+//// ==========================================================================
+//// WebhookEnqueuerTests.cs
+//// Squidex Headless CMS
+//// ==========================================================================
+//// Copyright (c) Squidex Group
+//// All rights reserved.
+//// ==========================================================================
+
+//using System;
+//using System.Collections.Generic;
+//using System.Threading.Tasks;
+//using FakeItEasy;
+//using Newtonsoft.Json;
+//using NodaTime;
+//using Squidex.Domain.Apps.Events.Contents;
+//using Squidex.Domain.Apps.Read.Webhooks.Repositories;
+//using Squidex.Infrastructure;
+//using Squidex.Infrastructure.CQRS.Events;
+//using Xunit;
+
+//// ReSharper disable MethodSupportsCancellation
+//// ReSharper disable ImplicitlyCapturedClosure
+//// ReSharper disable ConvertToConstant.Local
+
+//namespace Squidex.Domain.Apps.Read.Webhooks
+//{
+// public class WebhookEnqueuerTests
+// {
+// private readonly IClock clock = A.Fake();
+// private readonly IWebhookRepository webhookRepository = A.Fake();
+// private readonly IWebhookEventRepository webhookEventRepository = A.Fake();
+// private readonly TypeNameRegistry typeNameRegisty = new TypeNameRegistry();
+// private readonly Instant now = SystemClock.Instance.GetCurrentInstant();
+// private readonly WebhookEnqueuer sut;
+
+// public WebhookEnqueuerTests()
+// {
+// A.CallTo(() => clock.GetCurrentInstant()).Returns(now);
+
+// typeNameRegisty.Map(typeof(ContentCreated));
+
+// sut = new WebhookEnqueuer(
+// typeNameRegisty,
+// webhookEventRepository,
+// webhookRepository,
+// clock, new JsonSerializer());
+// }
+
+// [Fact]
+// public void Should_return_contents_filter_for_events_filter()
+// {
+// Assert.Equal("^content-", sut.EventsFilter);
+// }
+
+// [Fact]
+// public void Should_return_type_name_for_name()
+// {
+// Assert.Equal(typeof(WebhookEnqueuer).Name, sut.Name);
+// }
+
+// [Fact]
+// public Task Should_do_nothing_on_clear()
+// {
+// return sut.ClearAsync();
+// }
+
+// [Fact]
+// public async Task Should_update_repositories_on_successful_requests()
+// {
+// var appId = new NamedId(Guid.NewGuid(), "my-app");
+
+// var schemaId = new NamedId(Guid.NewGuid(), "my-schema");
+
+// var @event = Envelope.Create(new ContentCreated { AppId = appId, SchemaId = schemaId });
+
+// var webhook1 = CreateWebhook(1);
+// var webhook2 = CreateWebhook(2);
+
+// A.CallTo(() => webhookRepository.QueryByAppAsync(appId.Id))
+// .Returns(Task.FromResult>(new List { webhook1, webhook2 }));
+
+// await sut.On(@event);
+
+// A.CallTo(() => webhookEventRepository.EnqueueAsync(
+// A.That.Matches(webhookJob =>
+// !string.IsNullOrWhiteSpace(webhookJob.RequestSignature)
+// && !string.IsNullOrWhiteSpace(webhookJob.RequestBody)
+// && webhookJob.Id != Guid.Empty
+// && webhookJob.Expires == now.Plus(Duration.FromDays(2))
+// && webhookJob.AppId == appId.Id
+// && webhookJob.EventName == "MySchemaCreatedEvent"
+// && webhookJob.RequestUrl == webhook1.Url
+// && webhookJob.WebhookId == webhook1.Id), now)).MustHaveHappened();
+
+// A.CallTo(() => webhookEventRepository.EnqueueAsync(
+// A.That.Matches(webhookJob =>
+// !string.IsNullOrWhiteSpace(webhookJob.RequestSignature)
+// && !string.IsNullOrWhiteSpace(webhookJob.RequestBody)
+// && webhookJob.Id != Guid.Empty
+// && webhookJob.Expires == now.Plus(Duration.FromDays(2))
+// && webhookJob.AppId == appId.Id
+// && webhookJob.EventName == "MySchemaCreatedEvent"
+// && webhookJob.RequestUrl == webhook2.Url
+// && webhookJob.WebhookId == webhook2.Id), now)).MustHaveHappened();
+// }
+
+// private static ISchemaWebhookUrlEntity CreateWebhook(int offset)
+// {
+// var webhook = A.Dummy();
+
+// A.CallTo(() => webhook.Id).Returns(Guid.NewGuid());
+// A.CallTo(() => webhook.Url).Returns(new Uri($"http://domain{offset}.com"));
+// A.CallTo(() => webhook.SharedSecret).Returns($"secret{offset}");
+
+// return webhook;
+// }
+//}
+//}
diff --git a/tests/Squidex.Domain.Apps.Write.Tests/Schemas/SchemaDomainObjectTests.cs b/tests/Squidex.Domain.Apps.Write.Tests/Schemas/SchemaDomainObjectTests.cs
index 03f74f747..98301182b 100644
--- a/tests/Squidex.Domain.Apps.Write.Tests/Schemas/SchemaDomainObjectTests.cs
+++ b/tests/Squidex.Domain.Apps.Write.Tests/Schemas/SchemaDomainObjectTests.cs
@@ -6,7 +6,6 @@
// All rights reserved.
// ==========================================================================
-using System;
using System.Collections.Generic;
using System.Linq;
using Squidex.Domain.Apps.Core.Schemas;