mirror of https://github.com/Squidex/squidex.git
40 changed files with 1823 additions and 3 deletions
@ -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; } |
|||
} |
|||
} |
|||
@ -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; } |
|||
} |
|||
} |
|||
@ -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; } |
|||
} |
|||
} |
|||
@ -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 |
|||
{ |
|||
} |
|||
} |
|||
@ -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 |
|||
{ |
|||
} |
|||
} |
|||
@ -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<WebhookSchema> Schemas { get; set; } |
|||
} |
|||
} |
|||
@ -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; } |
|||
} |
|||
} |
|||
@ -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 |
|||
{ |
|||
} |
|||
} |
|||
@ -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<WebhookSchema> Schemas { get; set; } |
|||
|
|||
[BsonRequired] |
|||
[BsonElement] |
|||
public List<Guid> SchemaIds { get; set; } |
|||
|
|||
IEnumerable<WebhookSchema> IWebhookEntity.Schemas |
|||
{ |
|||
get { return Schemas; } |
|||
} |
|||
} |
|||
} |
|||
@ -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())); } |
|||
} |
|||
} |
|||
} |
|||
@ -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<MongoWebhookEventEntity>, 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<MongoWebhookEventEntity> 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<IWebhookEventEntity, Task> callback, CancellationToken cancellationToken = new CancellationToken()) |
|||
{ |
|||
var now = clock.GetCurrentInstant(); |
|||
|
|||
return Collection.Find(x => x.NextAttempt < now && !x.IsSending).ForEachAsync(callback, cancellationToken); |
|||
} |
|||
|
|||
public async Task<IReadOnlyList<IWebhookEventEntity>> 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<IWebhookEventEntity> FindAsync(Guid id) |
|||
{ |
|||
var entity = await Collection.Find(x => x.Id == id).FirstOrDefaultAsync(); |
|||
|
|||
return entity; |
|||
} |
|||
|
|||
public async Task<int> 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)); |
|||
} |
|||
} |
|||
} |
|||
@ -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<MongoWebhookEntity>, IWebhookRepository, IEventConsumer |
|||
{ |
|||
private static readonly List<IWebhookEntity> EmptyWebhooks = new List<IWebhookEntity>(); |
|||
private Dictionary<Guid, List<IWebhookEntity>> 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<MongoWebhookEntity> collection) |
|||
{ |
|||
await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.AppId)); |
|||
await collection.Indexes.CreateOneAsync(Index.Ascending(x => x.SchemaIds)); |
|||
} |
|||
|
|||
public async Task<IReadOnlyList<IWebhookEntity>> 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<Guid, List<IWebhookEntity>>(); |
|||
|
|||
var webhooks = await Collection.Find(new BsonDocument()).ToListAsync(); |
|||
|
|||
foreach (var webhook in webhooks) |
|||
{ |
|||
inMemoryWebhooks.GetOrAddNew(webhook.AppId).Add(webhook); |
|||
} |
|||
} |
|||
} |
|||
finally |
|||
{ |
|||
lockObject.Release(); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -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<IEvent> @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); |
|||
} |
|||
} |
|||
} |
|||
@ -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<WebhookSchema> Schemas { get; } |
|||
} |
|||
} |
|||
@ -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; } |
|||
} |
|||
} |
|||
@ -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<IWebhookEventEntity, Task> callback, CancellationToken cancellationToken = default(CancellationToken)); |
|||
|
|||
Task<int> CountByAppAsync(Guid appId); |
|||
|
|||
Task<IReadOnlyList<IWebhookEventEntity>> QueryByAppAsync(Guid appId, int skip = 0, int take = 20); |
|||
|
|||
Task<IWebhookEventEntity> FindAsync(Guid id); |
|||
} |
|||
} |
|||
@ -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<IReadOnlyList<IWebhookEntity>> QueryByAppAsync(Guid appId); |
|||
} |
|||
} |
|||
@ -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<IWebhookEventEntity> requestBlock; |
|||
private readonly TransformBlock<IWebhookEventEntity, IWebhookEventEntity> 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<IWebhookEventEntity>(MakeRequestAsync, |
|||
new ExecutionDataflowBlockOptions { MaxDegreeOfParallelism = 32, BoundedCapacity = 32 }); |
|||
|
|||
blockBlock = |
|||
new TransformBlock<IWebhookEventEntity, IWebhookEventEntity>(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<IWebhookEventEntity> 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; |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -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<IEvent> @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<IEvent> @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; |
|||
} |
|||
} |
|||
} |
|||
@ -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; } |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
} |
|||
@ -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 |
|||
} |
|||
} |
|||
@ -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; |
|||
} |
|||
} |
|||
} |
|||
@ -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(); |
|||
} |
|||
} |
|||
} |
|||
@ -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 |
|||
{ |
|||
} |
|||
} |
|||
@ -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 |
|||
{ |
|||
} |
|||
} |
|||
@ -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; } |
|||
} |
|||
} |
|||
} |
|||
@ -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<WebhookSchema> schemas = new List<WebhookSchema>(); |
|||
|
|||
public Uri Url { get; set; } |
|||
|
|||
public List<WebhookSchema> Schemas |
|||
{ |
|||
get |
|||
{ |
|||
return schemas ?? (schemas = new List<WebhookSchema>()); |
|||
} |
|||
set |
|||
{ |
|||
schemas = value; |
|||
} |
|||
} |
|||
|
|||
public virtual void Validate(IList<ValidationError> 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))); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -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<WebhookDomainObject>(context, c => c.Create(command)); |
|||
} |
|||
|
|||
protected async Task On(UpdateWebhook command, CommandContext context) |
|||
{ |
|||
await ValidateAsync(command, () => "Failed to update content"); |
|||
|
|||
await handler.UpdateAsync<WebhookDomainObject>(context, c => c.Update(command)); |
|||
} |
|||
|
|||
protected Task On(DeleteWebhook command, CommandContext context) |
|||
{ |
|||
return handler.UpdateAsync<WebhookDomainObject>(context, c => c.Delete(command)); |
|||
} |
|||
|
|||
public async Task HandleAsync(CommandContext context, Func<Task> next) |
|||
{ |
|||
if (!await this.DispatchActionAsync(context.Command, context)) |
|||
{ |
|||
await next(); |
|||
} |
|||
} |
|||
|
|||
private async Task ValidateAsync(WebhookEditCommand command, Func<string> 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); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -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<IEvent> @event) |
|||
{ |
|||
this.DispatchAction(@event.Payload); |
|||
} |
|||
} |
|||
} |
|||
@ -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 |
|||
{ |
|||
/// <summary>
|
|||
/// The url of the webhook.
|
|||
/// </summary>
|
|||
[Required] |
|||
public Uri Url { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The schema settings.
|
|||
/// </summary>
|
|||
[Required] |
|||
public List<WebhookSchemaDto> Schemas { get; set; } |
|||
} |
|||
} |
|||
@ -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 |
|||
{ |
|||
/// <summary>
|
|||
/// The id of the schema.
|
|||
/// </summary>
|
|||
public Guid SchemaId { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// True, when to send a message for created events.
|
|||
/// </summary>
|
|||
public bool SendCreate { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// True, when to send a message for updated events.
|
|||
/// </summary>
|
|||
public bool SendUpdate { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// True, when to send a message for deleted events.
|
|||
/// </summary>
|
|||
public bool SendDelete { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// True, when to send a message for published events.
|
|||
/// </summary>
|
|||
public bool SendPublish { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// True, when to send a message for unpublished events.
|
|||
/// </summary>
|
|||
public bool SendUnpublish { get; set; } |
|||
} |
|||
} |
|||
@ -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<IClock>(); |
|||
private readonly IWebhookRepository webhookRepository = A.Fake<IWebhookRepository>(); |
|||
private readonly IWebhookEventRepository webhookEventRepository = A.Fake<IWebhookEventRepository>(); |
|||
private readonly WebhookSender webhookSender = A.Fake<WebhookSender>(); |
|||
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<ISemanticLog>()); |
|||
|
|||
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<ISemanticLog>()); |
|||
|
|||
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<Func<IWebhookEventEntity, Task>>.Ignored, A<CancellationToken>.Ignored)) |
|||
.Invokes(async (Func<IWebhookEventEntity, Task> 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<IWebhookEventEntity>(); |
|||
|
|||
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; |
|||
} |
|||
} |
|||
} |
|||
@ -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<IClock>();
|
|||
// private readonly IWebhookRepository webhookRepository = A.Fake<IWebhookRepository>();
|
|||
// private readonly IWebhookEventRepository webhookEventRepository = A.Fake<IWebhookEventRepository>();
|
|||
// 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>(Guid.NewGuid(), "my-app");
|
|||
|
|||
// var schemaId = new NamedId<Guid>(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<IReadOnlyList<IWebhookEntity>>(new List<IWebhookEntity> { webhook1, webhook2 }));
|
|||
|
|||
// await sut.On(@event);
|
|||
|
|||
// A.CallTo(() => webhookEventRepository.EnqueueAsync(
|
|||
// A<WebhookJob>.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<WebhookJob>.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<ISchemaWebhookUrlEntity>();
|
|||
|
|||
// 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;
|
|||
// }
|
|||
//}
|
|||
//}
|
|||
Loading…
Reference in new issue