mirror of https://github.com/Squidex/squidex.git
committed by
GitHub
93 changed files with 2671 additions and 915 deletions
@ -0,0 +1,17 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Core.Contents |
|||
{ |
|||
public enum StatusChange |
|||
{ |
|||
Archived, |
|||
Published, |
|||
Restored, |
|||
Unpublished |
|||
} |
|||
} |
|||
@ -0,0 +1,32 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.Rules.Actions |
|||
{ |
|||
[TypeName(nameof(MediumAction))] |
|||
public sealed class MediumAction : RuleAction |
|||
{ |
|||
public string AccessToken { get; set; } |
|||
|
|||
public string Tags { get; set; } |
|||
|
|||
public string Title { get; set; } |
|||
|
|||
public string CanonicalUrl { get; set; } |
|||
|
|||
public string Content { get; set; } |
|||
|
|||
public bool IsHtml { get; set; } |
|||
|
|||
public override T Accept<T>(IRuleActionVisitor<T> visitor) |
|||
{ |
|||
return visitor.Visit(this); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,145 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
#pragma warning disable SA1649 // File name must match first type name
|
|||
|
|||
using System; |
|||
using System.Net.Http; |
|||
using System.Text; |
|||
using System.Threading.Tasks; |
|||
using Newtonsoft.Json; |
|||
using Newtonsoft.Json.Linq; |
|||
using Squidex.Domain.Apps.Core.HandleRules.Actions.Utils; |
|||
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; |
|||
using Squidex.Domain.Apps.Core.Rules.Actions; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Http; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.HandleRules.Actions |
|||
{ |
|||
public sealed class MediumJob |
|||
{ |
|||
public string RequestBody { get; set; } |
|||
|
|||
public string AccessToken { get; set; } |
|||
} |
|||
|
|||
public sealed class MediumActionHandler : RuleActionHandler<MediumAction, MediumJob> |
|||
{ |
|||
private const string Description = "Post to medium"; |
|||
|
|||
private readonly RuleEventFormatter formatter; |
|||
private readonly ClientPool<string, HttpClient> clients; |
|||
|
|||
public MediumActionHandler(RuleEventFormatter formatter) |
|||
{ |
|||
Guard.NotNull(formatter, nameof(formatter)); |
|||
|
|||
this.formatter = formatter; |
|||
|
|||
clients = new ClientPool<string, HttpClient>(key => |
|||
{ |
|||
var client = new HttpClient |
|||
{ |
|||
Timeout = TimeSpan.FromSeconds(4) |
|||
}; |
|||
|
|||
client.DefaultRequestHeaders.Add("Accept", "application/json"); |
|||
client.DefaultRequestHeaders.Add("Accept-Charset", "utf-8"); |
|||
client.DefaultRequestHeaders.Add("User-Agent", "Squidex Headless CMS"); |
|||
|
|||
return client; |
|||
}); |
|||
} |
|||
|
|||
protected override (string Description, MediumJob Data) CreateJob(EnrichedEvent @event, MediumAction action) |
|||
{ |
|||
var requestBody = |
|||
new JObject( |
|||
new JProperty("title", formatter.Format(action.Title, @event)), |
|||
new JProperty("contentFormat", action.IsHtml ? "html" : "markdown"), |
|||
new JProperty("content", formatter.Format(action.Content, @event)), |
|||
new JProperty("canonicalUrl", formatter.Format(action.CanonicalUrl, @event)), |
|||
new JProperty("tags", ParseTags(@event, action))); |
|||
|
|||
var ruleJob = new MediumJob { AccessToken = action.AccessToken, RequestBody = requestBody.ToString(Formatting.Indented) }; |
|||
|
|||
return (Description, ruleJob); |
|||
} |
|||
|
|||
private JArray ParseTags(EnrichedEvent @event, MediumAction action) |
|||
{ |
|||
if (string.IsNullOrWhiteSpace(action.Tags)) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
string[] tags; |
|||
try |
|||
{ |
|||
var jsonTags = formatter.Format(action.Tags, @event); |
|||
|
|||
tags = JsonConvert.DeserializeObject<string[]>(jsonTags); |
|||
} |
|||
catch |
|||
{ |
|||
tags = action.Tags.Split(','); |
|||
} |
|||
|
|||
return new JArray(tags); |
|||
} |
|||
|
|||
protected override async Task<(string Dump, Exception Exception)> ExecuteJobAsync(MediumJob job) |
|||
{ |
|||
var httpClient = clients.GetClient(string.Empty); |
|||
|
|||
string id; |
|||
|
|||
HttpResponseMessage response = null; |
|||
|
|||
var meRequest = BuildMeRequest(job); |
|||
try |
|||
{ |
|||
response = await httpClient.SendAsync(meRequest); |
|||
|
|||
var responseString = await response.Content.ReadAsStringAsync(); |
|||
var responseJson = JToken.Parse(responseString); |
|||
|
|||
id = responseJson["data"]["id"].ToString(); |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
var requestDump = DumpFormatter.BuildDump(meRequest, response, ex.ToString()); |
|||
|
|||
return (requestDump, ex); |
|||
} |
|||
|
|||
return await httpClient.OneWayRequestAsync(BuildPostRequest(job, id), job.RequestBody); |
|||
} |
|||
|
|||
private static HttpRequestMessage BuildPostRequest(MediumJob job, string id) |
|||
{ |
|||
var request = new HttpRequestMessage(HttpMethod.Post, $"https://api.medium.com/v1/users/{id}/posts") |
|||
{ |
|||
Content = new StringContent(job.RequestBody, Encoding.UTF8, "application/json") |
|||
}; |
|||
|
|||
request.Headers.Add("Authorization", $"Bearer {job.AccessToken}"); |
|||
|
|||
return request; |
|||
} |
|||
|
|||
private static HttpRequestMessage BuildMeRequest(MediumJob job) |
|||
{ |
|||
var request = new HttpRequestMessage(HttpMethod.Get, "https://api.medium.com/v1/me"); |
|||
|
|||
request.Headers.Add("Authorization", $"Bearer {job.AccessToken}"); |
|||
|
|||
return request; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,45 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Net.Http; |
|||
using System.Threading.Tasks; |
|||
using Squidex.Infrastructure.Http; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.HandleRules.Actions.Utils |
|||
{ |
|||
public static class HttpHelper |
|||
{ |
|||
public static async Task<(string Dump, Exception Exception)> OneWayRequestAsync(this HttpClient client, HttpRequestMessage request, string requestBody = null) |
|||
{ |
|||
HttpResponseMessage response = null; |
|||
try |
|||
{ |
|||
response = await client.SendAsync(request); |
|||
|
|||
var responseString = await response.Content.ReadAsStringAsync(); |
|||
|
|||
var requestDump = DumpFormatter.BuildDump(request, response, requestBody, responseString); |
|||
|
|||
Exception ex = null; |
|||
|
|||
if (!response.IsSuccessStatusCode) |
|||
{ |
|||
ex = new HttpRequestException($"Response code does not indicate success: {(int)response.StatusCode} ({response.StatusCode})."); |
|||
} |
|||
|
|||
return (requestDump, ex); |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
var requestDump = DumpFormatter.BuildDump(request, response, requestBody, ex.ToString()); |
|||
|
|||
return (requestDump, ex); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,47 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using NodaTime; |
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents |
|||
{ |
|||
public sealed class EnrichedAssetEvent : EnrichedEvent |
|||
{ |
|||
public EnrichedAssetEventType Type { get; set; } |
|||
|
|||
public Guid Id { get; set; } |
|||
|
|||
public Instant Created { get; set; } |
|||
|
|||
public Instant LastModified { get; set; } |
|||
|
|||
public RefToken CreatedBy { get; set; } |
|||
|
|||
public RefToken LastModifiedBy { get; set; } |
|||
|
|||
public string MimeType { get; set; } |
|||
|
|||
public string FileName { get; set; } |
|||
|
|||
public long FileVersion { get; set; } |
|||
|
|||
public long FileSize { get; set; } |
|||
|
|||
public bool IsImage { get; set; } |
|||
|
|||
public int? PixelWidth { get; set; } |
|||
|
|||
public int? PixelHeight { get; set; } |
|||
|
|||
public override Guid AggregateId |
|||
{ |
|||
get { return Id; } |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents |
|||
{ |
|||
public enum EnrichedAssetEventType |
|||
{ |
|||
Created, |
|||
Deleted, |
|||
Renamed, |
|||
Updated |
|||
} |
|||
} |
|||
@ -0,0 +1,38 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using NodaTime; |
|||
using Squidex.Domain.Apps.Core.Contents; |
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents |
|||
{ |
|||
public sealed class EnrichedContentEvent : EnrichedSchemaEvent |
|||
{ |
|||
public EnrichedContentEventType Type { get; set; } |
|||
|
|||
public Guid Id { get; set; } |
|||
|
|||
public Instant Created { get; set; } |
|||
|
|||
public Instant LastModified { get; set; } |
|||
|
|||
public RefToken CreatedBy { get; set; } |
|||
|
|||
public RefToken LastModifiedBy { get; set; } |
|||
|
|||
public NamedContentData Data { get; set; } |
|||
|
|||
public Status Status { get; set; } |
|||
|
|||
public override Guid AggregateId |
|||
{ |
|||
get { return Id; } |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,20 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents |
|||
{ |
|||
public enum EnrichedContentEventType |
|||
{ |
|||
Archived, |
|||
Created, |
|||
Deleted, |
|||
Published, |
|||
Restored, |
|||
Unpublished, |
|||
Updated |
|||
} |
|||
} |
|||
@ -0,0 +1,35 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using Newtonsoft.Json; |
|||
using NodaTime; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Shared.Users; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents |
|||
{ |
|||
public abstract class EnrichedEvent |
|||
{ |
|||
public NamedId<Guid> AppId { get; set; } |
|||
|
|||
public RefToken Actor { get; set; } |
|||
|
|||
public Instant Timestamp { get; set; } |
|||
|
|||
public long Version { get; set; } |
|||
|
|||
[JsonIgnore] |
|||
public abstract Guid AggregateId { get; } |
|||
|
|||
[JsonIgnore] |
|||
public string Name { get; set; } |
|||
|
|||
[JsonIgnore] |
|||
public IUser User { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,17 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using Squidex.Infrastructure; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents |
|||
{ |
|||
public abstract class EnrichedSchemaEvent : EnrichedEvent |
|||
{ |
|||
public NamedId<Guid> SchemaId { get; set; } |
|||
} |
|||
} |
|||
@ -0,0 +1,19 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Threading.Tasks; |
|||
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; |
|||
using Squidex.Domain.Apps.Events; |
|||
using Squidex.Infrastructure.EventSourcing; |
|||
|
|||
namespace Squidex.Domain.Apps.Core.HandleRules |
|||
{ |
|||
public interface IEventEnricher |
|||
{ |
|||
Task<EnrichedEvent> EnrichAsync(Envelope<AppEvent> @event); |
|||
} |
|||
} |
|||
@ -0,0 +1,184 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using Microsoft.Extensions.Caching.Memory; |
|||
using Orleans; |
|||
using Squidex.Domain.Apps.Core.Contents; |
|||
using Squidex.Domain.Apps.Core.HandleRules; |
|||
using Squidex.Domain.Apps.Core.HandleRules.EnrichedEvents; |
|||
using Squidex.Domain.Apps.Entities.Assets; |
|||
using Squidex.Domain.Apps.Entities.Contents; |
|||
using Squidex.Domain.Apps.Events; |
|||
using Squidex.Domain.Apps.Events.Assets; |
|||
using Squidex.Domain.Apps.Events.Contents; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.EventSourcing; |
|||
using Squidex.Infrastructure.Reflection; |
|||
using Squidex.Shared.Users; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Rules |
|||
{ |
|||
public sealed class EventEnricher : IEventEnricher |
|||
{ |
|||
private static readonly TimeSpan UserCacheDuration = TimeSpan.FromMinutes(10); |
|||
private readonly IGrainFactory grainFactory; |
|||
private readonly IMemoryCache userCache; |
|||
private readonly IUserResolver userResolver; |
|||
|
|||
public EventEnricher(IGrainFactory grainFactory, IMemoryCache userCache, IUserResolver userResolver) |
|||
{ |
|||
Guard.NotNull(grainFactory, nameof(grainFactory)); |
|||
Guard.NotNull(userCache, nameof(userCache)); |
|||
Guard.NotNull(userResolver, nameof(userResolver)); |
|||
|
|||
this.grainFactory = grainFactory; |
|||
this.userCache = userCache; |
|||
this.userResolver = userResolver; |
|||
} |
|||
|
|||
public async Task<EnrichedEvent> EnrichAsync(Envelope<AppEvent> @event) |
|||
{ |
|||
Guard.NotNull(@event, nameof(@event)); |
|||
|
|||
if (@event.Payload is ContentEvent contentEvent) |
|||
{ |
|||
var result = new EnrichedContentEvent(); |
|||
|
|||
await Task.WhenAll( |
|||
EnrichContentAsync(result, contentEvent, @event), |
|||
EnrichDefaultAsync(result, @event)); |
|||
|
|||
return result; |
|||
} |
|||
|
|||
if (@event.Payload is AssetEvent assetEvent) |
|||
{ |
|||
var result = new EnrichedAssetEvent(); |
|||
|
|||
await Task.WhenAll( |
|||
EnrichAssetAsync(result, assetEvent, @event), |
|||
EnrichDefaultAsync(result, @event)); |
|||
|
|||
return result; |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
private async Task EnrichAssetAsync(EnrichedAssetEvent result, AssetEvent assetEvent, Envelope<AppEvent> @event) |
|||
{ |
|||
var asset = |
|||
(await grainFactory |
|||
.GetGrain<IAssetGrain>(assetEvent.AssetId) |
|||
.GetStateAsync(@event.Headers.EventStreamNumber())).Value; |
|||
|
|||
SimpleMapper.Map(asset, result); |
|||
|
|||
switch (assetEvent) |
|||
{ |
|||
case AssetCreated _: |
|||
result.Type = EnrichedAssetEventType.Created; |
|||
break; |
|||
case AssetRenamed _: |
|||
result.Type = EnrichedAssetEventType.Renamed; |
|||
break; |
|||
case AssetUpdated _: |
|||
result.Type = EnrichedAssetEventType.Updated; |
|||
break; |
|||
case AssetDeleted _: |
|||
result.Type = EnrichedAssetEventType.Deleted; |
|||
break; |
|||
} |
|||
|
|||
result.Name = $"Asset{result.Type}"; |
|||
} |
|||
|
|||
private async Task EnrichContentAsync(EnrichedContentEvent result, ContentEvent contentEvent, Envelope<AppEvent> @event) |
|||
{ |
|||
var content = |
|||
(await grainFactory |
|||
.GetGrain<IContentGrain>(contentEvent.ContentId) |
|||
.GetStateAsync(@event.Headers.EventStreamNumber())).Value; |
|||
|
|||
SimpleMapper.Map(content, result); |
|||
|
|||
result.Data = content.Data ?? content.DataDraft; |
|||
|
|||
switch (contentEvent) |
|||
{ |
|||
case ContentCreated _: |
|||
result.Type = EnrichedContentEventType.Created; |
|||
break; |
|||
case ContentDeleted _: |
|||
result.Type = EnrichedContentEventType.Deleted; |
|||
break; |
|||
case ContentChangesPublished _: |
|||
case ContentUpdated _: |
|||
result.Type = EnrichedContentEventType.Updated; |
|||
break; |
|||
case ContentStatusChanged contentStatusChanged: |
|||
switch (contentStatusChanged.Change) |
|||
{ |
|||
case StatusChange.Published: |
|||
result.Type = EnrichedContentEventType.Published; |
|||
break; |
|||
case StatusChange.Unpublished: |
|||
result.Type = EnrichedContentEventType.Unpublished; |
|||
break; |
|||
case StatusChange.Archived: |
|||
result.Type = EnrichedContentEventType.Archived; |
|||
break; |
|||
case StatusChange.Restored: |
|||
result.Type = EnrichedContentEventType.Restored; |
|||
break; |
|||
} |
|||
|
|||
break; |
|||
} |
|||
|
|||
result.Name = $"{content.SchemaId.Name.ToPascalCase()}{result.Type}"; |
|||
} |
|||
|
|||
private async Task EnrichDefaultAsync(EnrichedEvent result, Envelope<AppEvent> @event) |
|||
{ |
|||
result.Timestamp = @event.Headers.Timestamp(); |
|||
|
|||
if (@event.Payload is SquidexEvent squidexEvent) |
|||
{ |
|||
result.Actor = squidexEvent.Actor; |
|||
} |
|||
|
|||
if (@event.Payload is AppEvent appEvent) |
|||
{ |
|||
result.AppId = appEvent.AppId; |
|||
} |
|||
|
|||
result.User = await FindUserAsync(result.Actor); |
|||
} |
|||
|
|||
private Task<IUser> FindUserAsync(RefToken actor) |
|||
{ |
|||
var key = $"EventEnrichers_Users_${actor.Identifier}"; |
|||
|
|||
return userCache.GetOrCreateAsync(key, async x => |
|||
{ |
|||
x.AbsoluteExpirationRelativeToNow = UserCacheDuration; |
|||
|
|||
try |
|||
{ |
|||
return await userResolver.FindByIdOrEmailAsync(actor.Identifier); |
|||
} |
|||
catch |
|||
{ |
|||
return null; |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,34 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using Squidex.Domain.Apps.Events; |
|||
using Squidex.Infrastructure.Commands; |
|||
using Squidex.Infrastructure.EventSourcing; |
|||
using Squidex.Infrastructure.Log; |
|||
using Squidex.Infrastructure.States; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities |
|||
{ |
|||
public abstract class SquidexDomainObjectGrainLogSnapshots<T> : LogSnapshotDomainObjectGrain<T> where T : IDomainState, new() |
|||
{ |
|||
protected SquidexDomainObjectGrainLogSnapshots(IStore<Guid> store, ISemanticLog log) |
|||
: base(store, log) |
|||
{ |
|||
} |
|||
|
|||
public override void RaiseEvent(Envelope<IEvent> @event) |
|||
{ |
|||
if (@event.Payload is AppEvent appEvent) |
|||
{ |
|||
@event.SetAppId(appEvent.AppId.Id); |
|||
} |
|||
|
|||
base.RaiseEvent(@event); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,202 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using Squidex.Infrastructure.EventSourcing; |
|||
using Squidex.Infrastructure.Log; |
|||
using Squidex.Infrastructure.Orleans; |
|||
using Squidex.Infrastructure.Tasks; |
|||
|
|||
namespace Squidex.Infrastructure.Commands |
|||
{ |
|||
public abstract class DomainObjectGrainBase<T> : GrainOfGuid, IDomainObjectGrain where T : IDomainState, new() |
|||
{ |
|||
private readonly List<Envelope<IEvent>> uncomittedEvents = new List<Envelope<IEvent>>(); |
|||
private readonly ISemanticLog log; |
|||
private Guid id; |
|||
|
|||
public Guid Id |
|||
{ |
|||
get { return id; } |
|||
} |
|||
|
|||
public long Version |
|||
{ |
|||
get { return Snapshot.Version; } |
|||
} |
|||
|
|||
public abstract T Snapshot { get; } |
|||
|
|||
protected DomainObjectGrainBase(ISemanticLog log) |
|||
{ |
|||
Guard.NotNull(log, nameof(log)); |
|||
|
|||
this.log = log; |
|||
} |
|||
|
|||
public sealed override async Task OnActivateAsync(Guid key) |
|||
{ |
|||
using (log.MeasureInformation(w => w |
|||
.WriteProperty("action", "ActivateDomainObject") |
|||
.WriteProperty("domainObjectType", GetType().Name) |
|||
.WriteProperty("domainObjectKey", key.ToString()))) |
|||
{ |
|||
id = key; |
|||
|
|||
await ReadAsync(GetType(), id); |
|||
} |
|||
} |
|||
|
|||
public void RaiseEvent(IEvent @event) |
|||
{ |
|||
RaiseEvent(Envelope.Create(@event)); |
|||
} |
|||
|
|||
public virtual void RaiseEvent(Envelope<IEvent> @event) |
|||
{ |
|||
Guard.NotNull(@event, nameof(@event)); |
|||
|
|||
@event.SetAggregateId(id); |
|||
|
|||
ApplyEvent(@event); |
|||
|
|||
uncomittedEvents.Add(@event); |
|||
} |
|||
|
|||
public IReadOnlyList<Envelope<IEvent>> GetUncomittedEvents() |
|||
{ |
|||
return uncomittedEvents; |
|||
} |
|||
|
|||
public void ClearUncommittedEvents() |
|||
{ |
|||
uncomittedEvents.Clear(); |
|||
} |
|||
|
|||
protected Task<object> CreateReturnAsync<TCommand>(TCommand command, Func<TCommand, Task<object>> handler) where TCommand : class, IAggregateCommand |
|||
{ |
|||
return InvokeAsync(command, handler, false); |
|||
} |
|||
|
|||
protected Task<object> CreateReturnAsync<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand |
|||
{ |
|||
return InvokeAsync(command, handler?.ToAsync(), false); |
|||
} |
|||
|
|||
protected Task<object> CreateAsync<TCommand>(TCommand command, Func<TCommand, Task> handler) where TCommand : class, IAggregateCommand |
|||
{ |
|||
return InvokeAsync(command, handler.ToDefault<TCommand, object>(), false); |
|||
} |
|||
|
|||
protected Task<object> CreateAsync<TCommand>(TCommand command, Action<TCommand> handler) where TCommand : class, IAggregateCommand |
|||
{ |
|||
return InvokeAsync(command, handler?.ToDefault<TCommand, object>()?.ToAsync(), false); |
|||
} |
|||
|
|||
protected Task<object> UpdateReturnAsync<TCommand>(TCommand command, Func<TCommand, Task<object>> handler) where TCommand : class, IAggregateCommand |
|||
{ |
|||
return InvokeAsync(command, handler, true); |
|||
} |
|||
|
|||
protected Task<object> UpdateReturnAsync<TCommand>(TCommand command, Func<TCommand, object> handler) where TCommand : class, IAggregateCommand |
|||
{ |
|||
return InvokeAsync(command, handler?.ToAsync(), true); |
|||
} |
|||
|
|||
protected Task<object> UpdateAsync<TCommand>(TCommand command, Func<TCommand, Task> handler) where TCommand : class, IAggregateCommand |
|||
{ |
|||
return InvokeAsync(command, handler?.ToDefault<TCommand, object>(), true); |
|||
} |
|||
|
|||
protected Task<object> UpdateAsync<TCommand>(TCommand command, Action<TCommand> handler) where TCommand : class, IAggregateCommand |
|||
{ |
|||
return InvokeAsync(command, handler?.ToDefault<TCommand, object>()?.ToAsync(), true); |
|||
} |
|||
|
|||
private async Task<object> InvokeAsync<TCommand>(TCommand command, Func<TCommand, Task<object>> handler, bool isUpdate) where TCommand : class, IAggregateCommand |
|||
{ |
|||
Guard.NotNull(command, nameof(command)); |
|||
|
|||
if (command.ExpectedVersion != EtagVersion.Any && command.ExpectedVersion != Version) |
|||
{ |
|||
throw new DomainObjectVersionException(id.ToString(), GetType(), Version, command.ExpectedVersion); |
|||
} |
|||
|
|||
if (isUpdate && Version < 0) |
|||
{ |
|||
try |
|||
{ |
|||
DeactivateOnIdle(); |
|||
} |
|||
catch (InvalidOperationException) |
|||
{ |
|||
} |
|||
|
|||
throw new DomainObjectNotFoundException(id.ToString(), GetType()); |
|||
} |
|||
|
|||
if (!isUpdate && Version >= 0) |
|||
{ |
|||
throw new DomainException("Object has already been created."); |
|||
} |
|||
|
|||
var previousSnapshot = Snapshot; |
|||
var previousVersion = Version; |
|||
try |
|||
{ |
|||
var result = await handler(command); |
|||
|
|||
var events = uncomittedEvents.ToArray(); |
|||
|
|||
await WriteAsync(events, previousVersion); |
|||
|
|||
if (result == null) |
|||
{ |
|||
if (isUpdate) |
|||
{ |
|||
result = new EntitySavedResult(Version); |
|||
} |
|||
else |
|||
{ |
|||
result = EntityCreatedResult.Create(id, Version); |
|||
} |
|||
} |
|||
|
|||
return result; |
|||
} |
|||
catch |
|||
{ |
|||
RestorePreviousSnapshot(previousSnapshot, previousVersion); |
|||
|
|||
throw; |
|||
} |
|||
finally |
|||
{ |
|||
uncomittedEvents.Clear(); |
|||
} |
|||
} |
|||
|
|||
protected abstract void RestorePreviousSnapshot(T previousSnapshot, long previousVersion); |
|||
|
|||
protected abstract void ApplyEvent(Envelope<IEvent> @event); |
|||
|
|||
protected abstract Task ReadAsync(Type type, Guid id); |
|||
|
|||
protected abstract Task WriteAsync(Envelope<IEvent>[] events, long previousVersion); |
|||
|
|||
public async Task<J<object>> ExecuteAsync(J<IAggregateCommand> command) |
|||
{ |
|||
var result = await ExecuteAsync(command.Value); |
|||
|
|||
return result.AsJ(); |
|||
} |
|||
|
|||
protected abstract Task<object> ExecuteAsync(IAggregateCommand command); |
|||
} |
|||
} |
|||
@ -0,0 +1,93 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using Squidex.Infrastructure.EventSourcing; |
|||
using Squidex.Infrastructure.Log; |
|||
using Squidex.Infrastructure.States; |
|||
|
|||
namespace Squidex.Infrastructure.Commands |
|||
{ |
|||
public abstract class LogSnapshotDomainObjectGrain<T> : DomainObjectGrainBase<T> where T : IDomainState, new() |
|||
{ |
|||
private readonly IStore<Guid> store; |
|||
private readonly List<T> snapshots = new List<T> { new T { Version = EtagVersion.Empty } }; |
|||
private IPersistence persistence; |
|||
|
|||
public override T Snapshot |
|||
{ |
|||
get { return snapshots.Last(); } |
|||
} |
|||
|
|||
protected LogSnapshotDomainObjectGrain(IStore<Guid> store, ISemanticLog log) |
|||
: base(log) |
|||
{ |
|||
Guard.NotNull(log, nameof(log)); |
|||
|
|||
this.store = store; |
|||
} |
|||
|
|||
public T GetSnapshot(long version) |
|||
{ |
|||
if (version == EtagVersion.Any) |
|||
{ |
|||
return Snapshot; |
|||
} |
|||
|
|||
if (version == EtagVersion.Empty) |
|||
{ |
|||
return snapshots[0]; |
|||
} |
|||
|
|||
if (version >= 0 && version < snapshots.Count - 1) |
|||
{ |
|||
return snapshots[(int)version + 1]; |
|||
} |
|||
|
|||
return default(T); |
|||
} |
|||
|
|||
protected sealed override void ApplyEvent(Envelope<IEvent> @event) |
|||
{ |
|||
var snapshot = OnEvent(@event); |
|||
|
|||
snapshot.Version = Version + 1; |
|||
snapshots.Add(snapshot); |
|||
} |
|||
|
|||
protected sealed override Task ReadAsync(Type type, Guid id) |
|||
{ |
|||
persistence = store.WithEventSourcing<Guid>(type, id, ApplyEvent); |
|||
|
|||
return persistence.ReadAsync(); |
|||
} |
|||
|
|||
protected sealed override async Task WriteAsync(Envelope<IEvent>[] events, long previousVersion) |
|||
{ |
|||
if (events.Length > 0) |
|||
{ |
|||
var persistedSnapshots = store.GetSnapshotStore<T>(); |
|||
|
|||
await persistence.WriteEventsAsync(events); |
|||
await persistedSnapshots.WriteAsync(Id, Snapshot, previousVersion, previousVersion + events.Length); |
|||
} |
|||
} |
|||
|
|||
protected sealed override void RestorePreviousSnapshot(T previousSnapshot, long previousVersion) |
|||
{ |
|||
while (snapshots.Count > previousVersion + 2) |
|||
{ |
|||
snapshots.RemoveAt(snapshots.Count - 1); |
|||
} |
|||
} |
|||
|
|||
protected abstract T OnEvent(Envelope<IEvent> @event); |
|||
} |
|||
} |
|||
@ -0,0 +1,24 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using Squidex.Infrastructure.EventSourcing; |
|||
|
|||
namespace Squidex.Infrastructure.States |
|||
{ |
|||
public interface IPersistence<TState> |
|||
{ |
|||
long Version { get; } |
|||
|
|||
Task WriteEventsAsync(IEnumerable<Envelope<IEvent>> @events); |
|||
|
|||
Task WriteSnapshotAsync(TState state); |
|||
|
|||
Task ReadAsync(long expectedVersion = EtagVersion.Any); |
|||
} |
|||
} |
|||
@ -0,0 +1,57 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.ComponentModel.DataAnnotations; |
|||
using NJsonSchema.Annotations; |
|||
using Squidex.Domain.Apps.Core.Rules; |
|||
using Squidex.Domain.Apps.Core.Rules.Actions; |
|||
using Squidex.Infrastructure.Reflection; |
|||
|
|||
namespace Squidex.Areas.Api.Controllers.Rules.Models.Actions |
|||
{ |
|||
[JsonSchema("Medium")] |
|||
public class MediumActionDto : RuleActionDto |
|||
{ |
|||
/// <summary>
|
|||
/// The self issued access token.
|
|||
/// </summary>
|
|||
[Required] |
|||
public string AccessToken { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The optional comma separated list of tags.
|
|||
/// </summary>
|
|||
public string Tags { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The title, used for the url.
|
|||
/// </summary>
|
|||
[Required] |
|||
public string Title { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The content, either html or markdown.
|
|||
/// </summary>
|
|||
[Required] |
|||
public string Content { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// The original home of this content, if it was originally published elsewhere.
|
|||
/// </summary>
|
|||
public string CanonicalUrl { get; set; } |
|||
|
|||
/// <summary>
|
|||
/// Indicates whether the content is markdown or html.
|
|||
/// </summary>
|
|||
public bool IsHtml { get; set; } |
|||
|
|||
public override RuleAction ToAction() |
|||
{ |
|||
return SimpleMapper.Map(this, new MediumAction()); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,84 @@ |
|||
<h3 class="wizard-title">Post to Medium</h3> |
|||
|
|||
<div [formGroup]="actionForm" class="form-horizontal"> |
|||
<div class="form-group row"> |
|||
<label class="col col-3 col-form-label" for="accessToken">Access Token</label> |
|||
|
|||
<div class="col col-9"> |
|||
<sqx-control-errors for="accessToken" [submitted]="actionFormSubmitted"></sqx-control-errors> |
|||
|
|||
<input type="text" class="form-control" id="accessToken" formControlName="accessToken" /> |
|||
|
|||
<small class="form-text text-muted"> |
|||
The self issued access token. Can be created under <a target="_blank" href="https://medium.com/me/settings">https://medium.com/me/settings</a>. |
|||
</small> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="form-group row"> |
|||
<label class="col col-3 col-form-label" for="title">Title</label> |
|||
|
|||
<div class="col col-9"> |
|||
<sqx-control-errors for="title" [submitted]="actionFormSubmitted"></sqx-control-errors> |
|||
|
|||
<input type="text" class="form-control" id="title" formControlName="title" /> |
|||
|
|||
<small class="form-text text-muted"> |
|||
The title of the post. Note that this title is used for SEO and when rendering the post as a listing. |
|||
</small> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="form-group row"> |
|||
<label class="col col-3 col-form-label" for="content">Content</label> |
|||
|
|||
<div class="col col-9"> |
|||
<sqx-control-errors for="content" [submitted]="actionFormSubmitted"></sqx-control-errors> |
|||
|
|||
<textarea class="form-control" id="content" formControlName="content"></textarea> |
|||
|
|||
<small class="form-text text-muted"> |
|||
The body of the post, in a valid, semantic, HTML fragment, or Markdown. |
|||
</small> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="form-group row"> |
|||
<div class="col col-9 offset-3"> |
|||
<div class="form-check"> |
|||
<input class="form-check-input" type="checkbox" id="isHtml" formControlName="isHtml" /> |
|||
<label class="form-check-label" for="isHtml"> |
|||
Is Html content |
|||
</label> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="form-group row"> |
|||
<label class="col col-3 col-form-label" for="canonicalUrl">Canonical URL</label> |
|||
|
|||
<div class="col col-9"> |
|||
<sqx-control-errors for="canonicalUrl" [submitted]="actionFormSubmitted"></sqx-control-errors> |
|||
|
|||
<input type="text" class="form-control" id="canonicalUrl" formControlName="canonicalUrl" /> |
|||
|
|||
<small class="form-text text-muted"> |
|||
The original home of this content, if it was originally published elsewhere. |
|||
</small> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="form-group row"> |
|||
<label class="col col-3 col-form-label" for="tags">Tags</label> |
|||
|
|||
<div class="col col-9"> |
|||
<sqx-control-errors for="tags" [submitted]="actionFormSubmitted"></sqx-control-errors> |
|||
|
|||
<input type="text" class="form-control" id="tags" formControlName="tags" /> |
|||
|
|||
<small class="form-text text-muted"> |
|||
Comma-separated list of tags. |
|||
</small> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
@ -0,0 +1,6 @@ |
|||
@import '_vars'; |
|||
@import '_mixins'; |
|||
|
|||
textarea { |
|||
height: 150px; |
|||
} |
|||
@ -0,0 +1,51 @@ |
|||
/* |
|||
* Squidex Headless CMS |
|||
* |
|||
* @license |
|||
* Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. |
|||
*/ |
|||
|
|||
import { Component, Input, OnInit } from '@angular/core'; |
|||
import { FormControl, FormGroup, Validators } from '@angular/forms'; |
|||
|
|||
@Component({ |
|||
selector: 'sqx-medium-action', |
|||
styleUrls: ['./medium-action.component.scss'], |
|||
templateUrl: './medium-action.component.html' |
|||
}) |
|||
export class MediumActionComponent implements OnInit { |
|||
@Input() |
|||
public action: any; |
|||
|
|||
@Input() |
|||
public actionForm: FormGroup; |
|||
|
|||
@Input() |
|||
public actionFormSubmitted = false; |
|||
|
|||
public ngOnInit() { |
|||
this.actionForm.setControl('accessToken', |
|||
new FormControl(this.action.accessToken || '', [ |
|||
Validators.required |
|||
])); |
|||
|
|||
this.actionForm.setControl('title', |
|||
new FormControl(this.action.title || '', [ |
|||
Validators.required |
|||
])); |
|||
|
|||
this.actionForm.setControl('content', |
|||
new FormControl(this.action.content || '', [ |
|||
Validators.required |
|||
])); |
|||
|
|||
this.actionForm.setControl('canonicalUrl', |
|||
new FormControl(this.action.canonicalUrl || '')); |
|||
|
|||
this.actionForm.setControl('tags', |
|||
new FormControl(this.action.tags || '')); |
|||
|
|||
this.actionForm.setControl('isHtml', |
|||
new FormControl(this.action.isHtml || false)); |
|||
} |
|||
} |
|||
@ -0,0 +1,74 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using FakeItEasy; |
|||
using Orleans; |
|||
using Squidex.Domain.Apps.Core.Schemas; |
|||
using Squidex.Infrastructure; |
|||
using Squidex.Infrastructure.Orleans; |
|||
using Xunit; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Contents |
|||
{ |
|||
public class ContentVersionLoaderTests |
|||
{ |
|||
private readonly IGrainFactory grainFactory = A.Fake<IGrainFactory>(); |
|||
private readonly IContentGrain grain = A.Fake<IContentGrain>(); |
|||
private readonly FieldRegistry fieldRegistry = new FieldRegistry(new TypeNameRegistry()); |
|||
private readonly Guid id = Guid.NewGuid(); |
|||
private readonly ContentVersionLoader sut; |
|||
|
|||
public ContentVersionLoaderTests() |
|||
{ |
|||
A.CallTo(() => grainFactory.GetGrain<IContentGrain>(id, null)) |
|||
.Returns(grain); |
|||
|
|||
sut = new ContentVersionLoader(grainFactory); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_throw_exception_if_no_state_returned() |
|||
{ |
|||
A.CallTo(() => grain.GetStateAsync(10)) |
|||
.Returns(new J<IContentEntity>(null)); |
|||
|
|||
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.LoadAsync(id, 10)); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_throw_exception_if_state_has_other_version() |
|||
{ |
|||
var entity = A.Fake<IContentEntity>(); |
|||
|
|||
A.CallTo(() => entity.Version) |
|||
.Returns(5); |
|||
|
|||
A.CallTo(() => grain.GetStateAsync(10)) |
|||
.Returns(J.Of(entity)); |
|||
|
|||
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.LoadAsync(id, 10)); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_return_content_from_state() |
|||
{ |
|||
var entity = A.Fake<IContentEntity>(); |
|||
|
|||
A.CallTo(() => entity.Version) |
|||
.Returns(10); |
|||
|
|||
A.CallTo(() => grain.GetStateAsync(10)) |
|||
.Returns(J.Of(entity)); |
|||
|
|||
var result = await sut.LoadAsync(id, 10); |
|||
|
|||
Assert.Same(entity, result); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,71 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Collections.Generic; |
|||
using System.Threading.Tasks; |
|||
using FluentAssertions; |
|||
using Squidex.Domain.Apps.Core.Rules.Actions; |
|||
using Squidex.Infrastructure; |
|||
using Xunit; |
|||
|
|||
namespace Squidex.Domain.Apps.Entities.Rules.Guards.Actions |
|||
{ |
|||
public class MediumActionTests |
|||
{ |
|||
[Fact] |
|||
public async Task Should_add_error_if_access_token_is_null() |
|||
{ |
|||
var action = new MediumAction { AccessToken = null, Title = "title", Content = "content" }; |
|||
|
|||
var errors = await RuleActionValidator.ValidateAsync(action); |
|||
|
|||
errors.Should().BeEquivalentTo( |
|||
new List<ValidationError> |
|||
{ |
|||
new ValidationError("Access token is required.", "AccessToken") |
|||
}); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_add_error_if_title_null() |
|||
{ |
|||
var action = new MediumAction { AccessToken = "token", Title = null, Content = "content" }; |
|||
|
|||
var errors = await RuleActionValidator.ValidateAsync(action); |
|||
|
|||
errors.Should().BeEquivalentTo( |
|||
new List<ValidationError> |
|||
{ |
|||
new ValidationError("Title is required.", "Title") |
|||
}); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_add_error_if_content_is_null() |
|||
{ |
|||
var action = new MediumAction { AccessToken = "token", Title = "title", Content = null }; |
|||
|
|||
var errors = await RuleActionValidator.ValidateAsync(action); |
|||
|
|||
errors.Should().BeEquivalentTo( |
|||
new List<ValidationError> |
|||
{ |
|||
new ValidationError("Content is required.", "Content") |
|||
}); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_not_add_error_if_values_are_valid() |
|||
{ |
|||
var action = new MediumAction { AccessToken = "token", Title = "title", Content = "content" }; |
|||
|
|||
var errors = await RuleActionValidator.ValidateAsync(action); |
|||
|
|||
Assert.Empty(errors); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,66 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System.Reflection; |
|||
using FakeItEasy; |
|||
using Orleans; |
|||
using Squidex.Infrastructure.TestHelpers; |
|||
using Xunit; |
|||
|
|||
namespace Squidex.Infrastructure.Commands |
|||
{ |
|||
public class DomainObjectGrainFormatterTests |
|||
{ |
|||
private readonly IGrainCallContext context = A.Fake<IGrainCallContext>(); |
|||
|
|||
[Fact] |
|||
public void Should_return_fallback_if_no_method_is_defined() |
|||
{ |
|||
A.CallTo(() => context.InterfaceMethod) |
|||
.Returns(null); |
|||
|
|||
var result = DomainObjectGrainFormatter.Format(context); |
|||
|
|||
Assert.Equal("Unknown", result); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_return_method_name_if_not_domain_object_method() |
|||
{ |
|||
var methodInfo = A.Fake<MethodInfo>(); |
|||
|
|||
A.CallTo(() => methodInfo.Name) |
|||
.Returns("Calculate"); |
|||
|
|||
A.CallTo(() => context.InterfaceMethod) |
|||
.Returns(methodInfo); |
|||
|
|||
var result = DomainObjectGrainFormatter.Format(context); |
|||
|
|||
Assert.Equal("Calculate", result); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_return_nice_method_name_if_domain_object_execute() |
|||
{ |
|||
var methodInfo = A.Fake<MethodInfo>(); |
|||
|
|||
A.CallTo(() => methodInfo.Name) |
|||
.Returns(nameof(IDomainObjectGrain.ExecuteAsync)); |
|||
|
|||
A.CallTo(() => context.Arguments) |
|||
.Returns(new object[] { new MyCommand() }); |
|||
|
|||
A.CallTo(() => context.InterfaceMethod) |
|||
.Returns(methodInfo); |
|||
|
|||
var result = DomainObjectGrainFormatter.Format(context); |
|||
|
|||
Assert.Equal("ExecuteAsync(MyCommand)", result); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,300 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschränkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using FakeItEasy; |
|||
using FluentAssertions; |
|||
using Squidex.Infrastructure.EventSourcing; |
|||
using Squidex.Infrastructure.Log; |
|||
using Squidex.Infrastructure.Orleans; |
|||
using Squidex.Infrastructure.States; |
|||
using Squidex.Infrastructure.TestHelpers; |
|||
using Xunit; |
|||
|
|||
namespace Squidex.Infrastructure.Commands |
|||
{ |
|||
public class LogSnapshotDomainObjectGrainTests |
|||
{ |
|||
private readonly IStore<Guid> store = A.Fake<IStore<Guid>>(); |
|||
private readonly ISnapshotStore<MyDomainState, Guid> snapshotStore = A.Fake<ISnapshotStore<MyDomainState, Guid>>(); |
|||
private readonly IPersistence persistence = A.Fake<IPersistence>(); |
|||
private readonly Guid id = Guid.NewGuid(); |
|||
private readonly MyDomainObject sut; |
|||
|
|||
public sealed class ValueChanged : IEvent |
|||
{ |
|||
public int Value { get; set; } |
|||
} |
|||
|
|||
public sealed class CreateAuto : MyCommand |
|||
{ |
|||
public int Value { get; set; } |
|||
} |
|||
|
|||
public sealed class CreateCustom : MyCommand |
|||
{ |
|||
public int Value { get; set; } |
|||
} |
|||
|
|||
public sealed class UpdateAuto : MyCommand |
|||
{ |
|||
public int Value { get; set; } |
|||
} |
|||
|
|||
public sealed class UpdateCustom : MyCommand |
|||
{ |
|||
public int Value { get; set; } |
|||
} |
|||
|
|||
public sealed class MyDomainObject : LogSnapshotDomainObjectGrain<MyDomainState> |
|||
{ |
|||
public MyDomainObject(IStore<Guid> store) |
|||
: base(store, A.Dummy<ISemanticLog>()) |
|||
{ |
|||
} |
|||
|
|||
protected override Task<object> ExecuteAsync(IAggregateCommand command) |
|||
{ |
|||
switch (command) |
|||
{ |
|||
case CreateAuto createAuto: |
|||
return CreateAsync(createAuto, c => |
|||
{ |
|||
RaiseEvent(new ValueChanged { Value = c.Value }); |
|||
}); |
|||
|
|||
case CreateCustom createCustom: |
|||
return CreateReturnAsync(createCustom, c => |
|||
{ |
|||
RaiseEvent(new ValueChanged { Value = c.Value }); |
|||
|
|||
return "CREATED"; |
|||
}); |
|||
|
|||
case UpdateAuto updateAuto: |
|||
return UpdateAsync(updateAuto, c => |
|||
{ |
|||
RaiseEvent(new ValueChanged { Value = c.Value }); |
|||
}); |
|||
|
|||
case UpdateCustom updateCustom: |
|||
return UpdateReturnAsync(updateCustom, c => |
|||
{ |
|||
RaiseEvent(new ValueChanged { Value = c.Value }); |
|||
|
|||
return "UPDATED"; |
|||
}); |
|||
} |
|||
|
|||
return Task.FromResult<object>(null); |
|||
} |
|||
|
|||
protected override MyDomainState OnEvent(Envelope<IEvent> @event) |
|||
{ |
|||
return new MyDomainState { Value = ((ValueChanged)@event.Payload).Value }; |
|||
} |
|||
} |
|||
|
|||
public LogSnapshotDomainObjectGrainTests() |
|||
{ |
|||
A.CallTo(() => store.WithEventSourcing(typeof(MyDomainObject), id, A<Func<Envelope<IEvent>, Task>>.Ignored)) |
|||
.Returns(persistence); |
|||
|
|||
A.CallTo(() => store.GetSnapshotStore<MyDomainState>()) |
|||
.Returns(snapshotStore); |
|||
|
|||
sut = new MyDomainObject(store); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_get_latestet_version_when_requesting_state_with_any() |
|||
{ |
|||
await SetupUpdatedAsync(); |
|||
|
|||
var result = sut.GetSnapshot(EtagVersion.Any); |
|||
|
|||
result.Should().BeEquivalentTo(new MyDomainState { Value = 8, Version = 1 }); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_get_empty_version_when_requesting_state_with_empty_version() |
|||
{ |
|||
await SetupUpdatedAsync(); |
|||
|
|||
var result = sut.GetSnapshot(EtagVersion.Empty); |
|||
|
|||
result.Should().BeEquivalentTo(new MyDomainState { Value = 0, Version = -1 }); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_get_specific_version_when_requesting_state_with_specific_version() |
|||
{ |
|||
await SetupUpdatedAsync(); |
|||
|
|||
sut.GetSnapshot(0).Should().BeEquivalentTo(new MyDomainState { Value = 4, Version = 0 }); |
|||
sut.GetSnapshot(1).Should().BeEquivalentTo(new MyDomainState { Value = 8, Version = 1 }); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_get_null_state_when_requesting_state_with_invalid_version() |
|||
{ |
|||
await SetupUpdatedAsync(); |
|||
|
|||
Assert.Null(sut.GetSnapshot(-3)); |
|||
Assert.Null(sut.GetSnapshot(2)); |
|||
} |
|||
|
|||
[Fact] |
|||
public void Should_instantiate() |
|||
{ |
|||
Assert.Equal(EtagVersion.Empty, sut.Version); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_write_state_and_events_when_created() |
|||
{ |
|||
await SetupEmptyAsync(); |
|||
|
|||
var result = await sut.ExecuteAsync(C(new CreateAuto { Value = 4 })); |
|||
|
|||
A.CallTo(() => snapshotStore.WriteAsync(id, A<MyDomainState>.That.Matches(x => x.Value == 4), -1, 0)) |
|||
.MustHaveHappened(); |
|||
A.CallTo(() => persistence.WriteEventsAsync(A<IEnumerable<Envelope<IEvent>>>.That.Matches(x => x.Count() == 1))) |
|||
.MustHaveHappened(); |
|||
|
|||
Assert.True(result.Value is EntityCreatedResult<Guid>); |
|||
|
|||
Assert.Empty(sut.GetUncomittedEvents()); |
|||
|
|||
Assert.Equal(4, sut.Snapshot.Value); |
|||
Assert.Equal(0, sut.Snapshot.Version); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_write_state_and_events_when_updated() |
|||
{ |
|||
await SetupCreatedAsync(); |
|||
|
|||
var result = await sut.ExecuteAsync(C(new UpdateAuto { Value = 8 })); |
|||
|
|||
A.CallTo(() => snapshotStore.WriteAsync(id, A<MyDomainState>.That.Matches(x => x.Value == 8), 0, 1)) |
|||
.MustHaveHappened(); |
|||
A.CallTo(() => persistence.WriteEventsAsync(A<IEnumerable<Envelope<IEvent>>>.That.Matches(x => x.Count() == 1))) |
|||
.MustHaveHappened(); |
|||
|
|||
Assert.True(result.Value is EntitySavedResult); |
|||
|
|||
Assert.Empty(sut.GetUncomittedEvents()); |
|||
|
|||
Assert.Equal(8, sut.Snapshot.Value); |
|||
Assert.Equal(1, sut.Snapshot.Version); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_throw_exception_when_already_created() |
|||
{ |
|||
await SetupCreatedAsync(); |
|||
|
|||
await Assert.ThrowsAsync<DomainException>(() => sut.ExecuteAsync(C(new CreateAuto()))); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_throw_exception_when_not_created() |
|||
{ |
|||
await SetupEmptyAsync(); |
|||
|
|||
await Assert.ThrowsAsync<DomainObjectNotFoundException>(() => sut.ExecuteAsync(C(new UpdateAuto()))); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_return_custom_result_on_create() |
|||
{ |
|||
await SetupEmptyAsync(); |
|||
|
|||
var result = await sut.ExecuteAsync(C(new CreateCustom())); |
|||
|
|||
Assert.Equal("CREATED", result.Value); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_return_custom_result_on_update() |
|||
{ |
|||
await SetupCreatedAsync(); |
|||
|
|||
var result = await sut.ExecuteAsync(C(new UpdateCustom())); |
|||
|
|||
Assert.Equal("UPDATED", result.Value); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_throw_exception_when_other_verison_expected() |
|||
{ |
|||
await SetupCreatedAsync(); |
|||
|
|||
await Assert.ThrowsAsync<DomainObjectVersionException>(() => sut.ExecuteAsync(C(new UpdateCustom { ExpectedVersion = 3 }))); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_reset_state_when_writing_snapshot_for_create_failed() |
|||
{ |
|||
await SetupEmptyAsync(); |
|||
|
|||
A.CallTo(() => snapshotStore.WriteAsync(A<Guid>.Ignored, A<MyDomainState>.Ignored, -1, 0)) |
|||
.Throws(new InvalidOperationException()); |
|||
|
|||
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.ExecuteAsync(C(new CreateAuto()))); |
|||
|
|||
Assert.Empty(sut.GetUncomittedEvents()); |
|||
|
|||
Assert.Equal(0, sut.Snapshot.Value); |
|||
Assert.Equal(-1, sut.Snapshot.Version); |
|||
} |
|||
|
|||
[Fact] |
|||
public async Task Should_reset_state_when_writing_snapshot_for_update_failed() |
|||
{ |
|||
await SetupCreatedAsync(); |
|||
|
|||
A.CallTo(() => snapshotStore.WriteAsync(A<Guid>.Ignored, A<MyDomainState>.Ignored, 0, 1)) |
|||
.Throws(new InvalidOperationException()); |
|||
|
|||
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.ExecuteAsync(C(new UpdateAuto()))); |
|||
|
|||
Assert.Empty(sut.GetUncomittedEvents()); |
|||
|
|||
Assert.Equal(4, sut.Snapshot.Value); |
|||
Assert.Equal(0, sut.Snapshot.Version); |
|||
} |
|||
|
|||
private async Task SetupCreatedAsync() |
|||
{ |
|||
await sut.OnActivateAsync(id); |
|||
|
|||
await sut.ExecuteAsync(C(new CreateAuto { Value = 4 })); |
|||
} |
|||
|
|||
private async Task SetupUpdatedAsync() |
|||
{ |
|||
await SetupCreatedAsync(); |
|||
|
|||
await sut.ExecuteAsync(C(new UpdateAuto { Value = 8 })); |
|||
} |
|||
|
|||
private async Task SetupEmptyAsync() |
|||
{ |
|||
await sut.OnActivateAsync(id); |
|||
} |
|||
|
|||
private static J<IAggregateCommand> C(IAggregateCommand command) |
|||
{ |
|||
return command.AsJ(); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,91 @@ |
|||
// ==========================================================================
|
|||
// Squidex Headless CMS
|
|||
// ==========================================================================
|
|||
// Copyright (c) Squidex UG (haftungsbeschraenkt)
|
|||
// All rights reserved. Licensed under the MIT license.
|
|||
// ==========================================================================
|
|||
|
|||
using System; |
|||
using System.Threading.Tasks; |
|||
using FakeItEasy; |
|||
using Squidex.Infrastructure.Commands; |
|||
using Squidex.Infrastructure.EventSourcing; |
|||
using Squidex.Infrastructure.Log; |
|||
using Squidex.Infrastructure.States; |
|||
|
|||
namespace Squidex.Infrastructure.TestHelpers |
|||
{ |
|||
public sealed class MyDomainObject : DomainObjectGrain<MyDomainState> |
|||
{ |
|||
public sealed class ValueChanged : IEvent |
|||
{ |
|||
public int Value { get; set; } |
|||
} |
|||
|
|||
public sealed class CreateAuto : MyCommand |
|||
{ |
|||
public int Value { get; set; } |
|||
} |
|||
|
|||
public sealed class CreateCustom : MyCommand |
|||
{ |
|||
public int Value { get; set; } |
|||
} |
|||
|
|||
public sealed class UpdateAuto : MyCommand |
|||
{ |
|||
public int Value { get; set; } |
|||
} |
|||
|
|||
public sealed class UpdateCustom : MyCommand |
|||
{ |
|||
public int Value { get; set; } |
|||
} |
|||
|
|||
public MyDomainObject(IStore<Guid> store) |
|||
: base(store, A.Dummy<ISemanticLog>()) |
|||
{ |
|||
} |
|||
|
|||
protected override Task<object> ExecuteAsync(IAggregateCommand command) |
|||
{ |
|||
switch (command) |
|||
{ |
|||
case CreateAuto createAuto: |
|||
return CreateAsync(createAuto, c => |
|||
{ |
|||
RaiseEvent(new ValueChanged { Value = c.Value }); |
|||
}); |
|||
|
|||
case CreateCustom createCustom: |
|||
return CreateReturnAsync(createCustom, c => |
|||
{ |
|||
RaiseEvent(new ValueChanged { Value = c.Value }); |
|||
|
|||
return "CREATED"; |
|||
}); |
|||
|
|||
case UpdateAuto updateAuto: |
|||
return UpdateAsync(updateAuto, c => |
|||
{ |
|||
RaiseEvent(new ValueChanged { Value = c.Value }); |
|||
}); |
|||
|
|||
case UpdateCustom updateCustom: |
|||
return UpdateReturnAsync(updateCustom, c => |
|||
{ |
|||
RaiseEvent(new ValueChanged { Value = c.Value }); |
|||
|
|||
return "UPDATED"; |
|||
}); |
|||
} |
|||
|
|||
return Task.FromResult<object>(null); |
|||
} |
|||
|
|||
protected override MyDomainState OnEvent(Envelope<IEvent> @event) |
|||
{ |
|||
return new MyDomainState { Value = ((ValueChanged)@event.Payload).Value }; |
|||
} |
|||
} |
|||
} |
|||
Loading…
Reference in new issue