diff --git a/src/Squidex.Domain.Apps.Write/Contents/ContentVersionLoader.cs b/src/Squidex.Domain.Apps.Write/Contents/ContentVersionLoader.cs new file mode 100644 index 000000000..95e7936af --- /dev/null +++ b/src/Squidex.Domain.Apps.Write/Contents/ContentVersionLoader.cs @@ -0,0 +1,86 @@ +// ========================================================================== +// ContentVersionLoader.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Linq; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Events.Contents; +using Squidex.Infrastructure; +using Squidex.Infrastructure.CQRS.Events; + +namespace Squidex.Domain.Apps.Write.Contents +{ + public sealed class ContentVersionLoader : IContentVersionLoader + { + private readonly IStreamNameResolver nameResolver; + private readonly IEventStore eventStore; + private readonly EventDataFormatter formatter; + + public ContentVersionLoader(IEventStore eventStore, IStreamNameResolver nameResolver, EventDataFormatter formatter) + { + Guard.NotNull(formatter, nameof(formatter)); + Guard.NotNull(eventStore, nameof(eventStore)); + Guard.NotNull(nameResolver, nameof(nameResolver)); + + this.formatter = formatter; + this.eventStore = eventStore; + this.nameResolver = nameResolver; + } + + public async Task LoadAsync(Guid appId, Guid id, long version) + { + var streamName = nameResolver.GetStreamName(typeof(ContentDomainObject), id); + + var events = await eventStore.GetEventsAsync(streamName); + + if (events.Count == 0 || events.Count < version - 1) + { + throw new DomainObjectNotFoundException(id.ToString(), typeof(ContentDomainObject)); + } + + NamedContentData contentData = null; + + foreach (var storedEvent in events.Where(x => x.EventStreamNumber <= version)) + { + var envelope = ParseKnownEvent(storedEvent); + + if (envelope != null) + { + if (envelope.Payload is ContentCreated contentCreated) + { + if (contentCreated.AppId.Id != appId) + { + throw new DomainObjectNotFoundException(id.ToString(), typeof(ContentDomainObject)); + } + + contentData = contentCreated.Data; + } + else if (envelope.Payload is ContentUpdated contentUpdated) + { + contentData = contentUpdated.Data; + } + } + } + + return contentData; + } + + private Envelope ParseKnownEvent(StoredEvent storedEvent) + { + try + { + return formatter.Parse(storedEvent.Data); + } + catch (TypeNameNotFoundException) + { + return null; + } + } + } +} diff --git a/src/Squidex.Domain.Apps.Write/Contents/IContentVersionLoader.cs b/src/Squidex.Domain.Apps.Write/Contents/IContentVersionLoader.cs new file mode 100644 index 000000000..e3968fbfc --- /dev/null +++ b/src/Squidex.Domain.Apps.Write/Contents/IContentVersionLoader.cs @@ -0,0 +1,19 @@ +// ========================================================================== +// IContentVersionLoader.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Threading.Tasks; +using Squidex.Domain.Apps.Core.Contents; + +namespace Squidex.Domain.Apps.Write.Contents +{ + public interface IContentVersionLoader + { + Task LoadAsync(Guid appId, Guid id, long version); + } +} diff --git a/src/Squidex.Infrastructure/CQRS/Commands/DefaultDomainObjectRepository.cs b/src/Squidex.Infrastructure/CQRS/Commands/DefaultDomainObjectRepository.cs index 43d3844a5..c3a87f4c1 100644 --- a/src/Squidex.Infrastructure/CQRS/Commands/DefaultDomainObjectRepository.cs +++ b/src/Squidex.Infrastructure/CQRS/Commands/DefaultDomainObjectRepository.cs @@ -44,7 +44,7 @@ namespace Squidex.Infrastructure.CQRS.Commands foreach (var storedEvent in events) { - var envelope = ParseKnownCommand(storedEvent); + var envelope = ParseKnownEvent(storedEvent); if (envelope != null) { @@ -79,7 +79,7 @@ namespace Squidex.Infrastructure.CQRS.Commands } } - private Envelope ParseKnownCommand(StoredEvent storedEvent) + private Envelope ParseKnownEvent(StoredEvent storedEvent) { try { diff --git a/src/Squidex/Config/Domain/WriteModule.cs b/src/Squidex/Config/Domain/WriteModule.cs index 41dbb272f..a77d008a5 100644 --- a/src/Squidex/Config/Domain/WriteModule.cs +++ b/src/Squidex/Config/Domain/WriteModule.cs @@ -55,6 +55,10 @@ namespace Squidex.Config.Domain .As() .SingleInstance(); + builder.RegisterType() + .As() + .SingleInstance(); + builder.RegisterType() .AsSelf() .SingleInstance(); diff --git a/src/Squidex/Controllers/ContentApi/ContentsController.cs b/src/Squidex/Controllers/ContentApi/ContentsController.cs index 5ac516a9b..fa7db3ca7 100644 --- a/src/Squidex/Controllers/ContentApi/ContentsController.cs +++ b/src/Squidex/Controllers/ContentApi/ContentsController.cs @@ -31,12 +31,17 @@ namespace Squidex.Controllers.ContentApi public sealed class ContentsController : ControllerBase { private readonly IContentQueryService contentQuery; + private readonly IContentVersionLoader contentVersionLoader; private readonly IGraphQLService graphQl; - public ContentsController(ICommandBus commandBus, IContentQueryService contentQuery, IGraphQLService graphQl) + public ContentsController(ICommandBus commandBus, + IContentQueryService contentQuery, + IContentVersionLoader contentVersionLoader, + IGraphQLService graphQl) : base(commandBus) { this.contentQuery = contentQuery; + this.contentVersionLoader = contentVersionLoader; this.graphQl = graphQl; } @@ -124,6 +129,21 @@ namespace Squidex.Controllers.ContentApi return Ok(response); } + [MustBeAppReader] + [HttpGet] + [Route("content/{app}/{name}/{id}/{version}")] + [ApiCosts(1)] + public async Task GetContentVersion(string name, Guid id, int version) + { + var contentData = await contentVersionLoader.LoadAsync(App.Id, id, version); + + var response = contentData; + + Response.Headers["ETag"] = new StringValues(version.ToString()); + + return Ok(response); + } + [MustBeAppEditor] [HttpPost] [Route("content/{app}/{name}/")] diff --git a/tests/Squidex.Domain.Apps.Write.Tests/Contents/ContentVersionLoaderTests.cs b/tests/Squidex.Domain.Apps.Write.Tests/Contents/ContentVersionLoaderTests.cs new file mode 100644 index 000000000..ef94132b6 --- /dev/null +++ b/tests/Squidex.Domain.Apps.Write.Tests/Contents/ContentVersionLoaderTests.cs @@ -0,0 +1,146 @@ +// ========================================================================== +// ContentVersionLoaderTests.cs +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex Group +// All rights reserved. +// ========================================================================== + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using FakeItEasy; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Events.Contents; +using Squidex.Infrastructure; +using Squidex.Infrastructure.CQRS.Events; +using Xunit; + +namespace Squidex.Domain.Apps.Write.Contents +{ + public class ContentVersionLoaderTests + { + private readonly IEventStore eventStore = A.Fake(); + private readonly IStreamNameResolver nameResolver = A.Fake(); + private readonly EventDataFormatter formatter = A.Fake(); + private readonly Guid id = Guid.NewGuid(); + private readonly Guid appId = Guid.NewGuid(); + private readonly string streamName = Guid.NewGuid().ToString(); + private readonly ContentVersionLoader sut; + + public ContentVersionLoaderTests() + { + A.CallTo(() => nameResolver.GetStreamName(typeof(ContentDomainObject), id)) + .Returns(streamName); + + sut = new ContentVersionLoader(eventStore, nameResolver, formatter); + } + + [Fact] + public async Task Should_throw_exception_when_event_store_returns_no_events() + { + A.CallTo(() => eventStore.GetEventsAsync(streamName)) + .Returns(new List()); + + await Assert.ThrowsAsync(() => sut.LoadAsync(appId, id, -1)); + } + + [Fact] + public async Task Should_throw_exception_when_version_not_found() + { + var events = new List + { + new StoredEvent("0", 0, new EventData()), + new StoredEvent("1", 1, new EventData()), + new StoredEvent("2", 2, new EventData()) + }; + + A.CallTo(() => eventStore.GetEventsAsync(streamName)) + .Returns(new List()); + + await Assert.ThrowsAsync(() => sut.LoadAsync(appId, id, 3)); + } + + [Fact] + public async Task Should_throw_exception_when_content_is_from_another_event() + { + var eventData1 = new EventData(); + + var event1 = new ContentCreated { Data = new NamedContentData(), AppId = new NamedId(Guid.NewGuid(), "my-app") }; + + var events = new List + { + new StoredEvent("0", 0, eventData1) + }; + + A.CallTo(() => eventStore.GetEventsAsync(streamName)) + .Returns(events); + + A.CallTo(() => formatter.Parse(eventData1)) + .Returns(new Envelope(event1)); + + await Assert.ThrowsAsync(() => sut.LoadAsync(appId, id, 0)); + } + + [Fact] + public async Task Should_load_content_from_created_event() + { + var eventData1 = new EventData(); + var eventData2 = new EventData(); + + var event1 = new ContentCreated { Data = new NamedContentData(), AppId = new NamedId(appId, "my-app") }; + var event2 = new ContentPublished(); + + var events = new List + { + new StoredEvent("0", 0, eventData1), + new StoredEvent("1", 1, eventData2) + }; + + A.CallTo(() => eventStore.GetEventsAsync(streamName)) + .Returns(events); + + A.CallTo(() => formatter.Parse(eventData1)) + .Returns(new Envelope(event1)); + A.CallTo(() => formatter.Parse(eventData2)) + .Returns(new Envelope(event2)); + + var data = await sut.LoadAsync(appId, id, 3); + + Assert.Same(event1.Data, data); + } + + [Fact] + public async Task Should_load_content_from_correct_version() + { + var eventData1 = new EventData(); + var eventData2 = new EventData(); + var eventData3 = new EventData(); + + var event1 = new ContentCreated { Data = new NamedContentData(), AppId = new NamedId(appId, "my-app") }; + var event2 = new ContentUpdated { Data = new NamedContentData() }; + var event3 = new ContentUpdated { Data = new NamedContentData() }; + + var events = new List + { + new StoredEvent("0", 0, eventData1), + new StoredEvent("1", 1, eventData2), + new StoredEvent("2", 2, eventData3) + }; + + A.CallTo(() => eventStore.GetEventsAsync(streamName)) + .Returns(events); + + A.CallTo(() => formatter.Parse(eventData1)) + .Returns(new Envelope(event1)); + A.CallTo(() => formatter.Parse(eventData2)) + .Returns(new Envelope(event2)); + A.CallTo(() => formatter.Parse(eventData3)) + .Returns(new Envelope(event3)); + + var data = await sut.LoadAsync(appId, id, 1); + + Assert.Equal(event2.Data, data); + } + } +} diff --git a/tests/Squidex.Infrastructure.Tests/CQRS/Commands/DefaultDomainObjectRepositoryTests.cs b/tests/Squidex.Infrastructure.Tests/CQRS/Commands/DefaultDomainObjectRepositoryTests.cs index 784d00c02..44608bc6a 100644 --- a/tests/Squidex.Infrastructure.Tests/CQRS/Commands/DefaultDomainObjectRepositoryTests.cs +++ b/tests/Squidex.Infrastructure.Tests/CQRS/Commands/DefaultDomainObjectRepositoryTests.cs @@ -20,8 +20,8 @@ namespace Squidex.Infrastructure.CQRS.Commands { private readonly IDomainObjectFactory factory = A.Fake(); private readonly IEventStore eventStore = A.Fake(); - private readonly IStreamNameResolver streamNameResolver = A.Fake(); - private readonly EventDataFormatter eventDataFormatter = A.Fake(); + private readonly IStreamNameResolver nameResolver = A.Fake(); + private readonly EventDataFormatter formatter = A.Fake(); private readonly string streamName = Guid.NewGuid().ToString(); private readonly Guid aggregateId = Guid.NewGuid(); private readonly MyDomainObject domainObject; @@ -31,13 +31,13 @@ namespace Squidex.Infrastructure.CQRS.Commands { domainObject = new MyDomainObject(aggregateId, 123); - A.CallTo(() => streamNameResolver.GetStreamName(A.Ignored, aggregateId)) + A.CallTo(() => nameResolver.GetStreamName(A.Ignored, aggregateId)) .Returns(streamName); A.CallTo(() => factory.CreateNew(aggregateId)) .Returns(domainObject); - sut = new DefaultDomainObjectRepository(eventStore, streamNameResolver, eventDataFormatter); + sut = new DefaultDomainObjectRepository(eventStore, nameResolver, formatter); } public sealed class MyEvent : IEvent @@ -96,9 +96,9 @@ namespace Squidex.Infrastructure.CQRS.Commands A.CallTo(() => eventStore.GetEventsAsync(streamName)) .Returns(events); - A.CallTo(() => eventDataFormatter.Parse(eventData1)) + A.CallTo(() => formatter.Parse(eventData1)) .Returns(new Envelope(event1)); - A.CallTo(() => eventDataFormatter.Parse(eventData2)) + A.CallTo(() => formatter.Parse(eventData2)) .Returns(new Envelope(event2)); await sut.LoadAsync(domainObject); @@ -124,9 +124,9 @@ namespace Squidex.Infrastructure.CQRS.Commands A.CallTo(() => eventStore.GetEventsAsync(streamName)) .Returns(events); - A.CallTo(() => eventDataFormatter.Parse(eventData1)) + A.CallTo(() => formatter.Parse(eventData1)) .Returns(new Envelope(event1)); - A.CallTo(() => eventDataFormatter.Parse(eventData2)) + A.CallTo(() => formatter.Parse(eventData2)) .Returns(new Envelope(event2)); await Assert.ThrowsAsync(() => sut.LoadAsync(domainObject, 200)); @@ -143,9 +143,9 @@ namespace Squidex.Infrastructure.CQRS.Commands var eventData1 = new EventData(); var eventData2 = new EventData(); - A.CallTo(() => eventDataFormatter.ToEventData(A>.That.Matches(e => e.Payload == event1), commitId)) + A.CallTo(() => formatter.ToEventData(A>.That.Matches(e => e.Payload == event1), commitId)) .Returns(eventData1); - A.CallTo(() => eventDataFormatter.ToEventData(A>.That.Matches(e => e.Payload == event2), commitId)) + A.CallTo(() => formatter.ToEventData(A>.That.Matches(e => e.Payload == event2), commitId)) .Returns(eventData2); A.CallTo(() => eventStore.AppendEventsAsync(commitId, streamName, 123, A>.That.Matches(e => e.Count == 2))) @@ -170,9 +170,9 @@ namespace Squidex.Infrastructure.CQRS.Commands var eventData1 = new EventData(); var eventData2 = new EventData(); - A.CallTo(() => eventDataFormatter.ToEventData(A>.That.Matches(e => e.Payload == event1), commitId)) + A.CallTo(() => formatter.ToEventData(A>.That.Matches(e => e.Payload == event1), commitId)) .Returns(eventData1); - A.CallTo(() => eventDataFormatter.ToEventData(A>.That.Matches(e => e.Payload == event2), commitId)) + A.CallTo(() => formatter.ToEventData(A>.That.Matches(e => e.Payload == event2), commitId)) .Returns(eventData2); A.CallTo(() => eventStore.AppendEventsAsync(commitId, streamName, 123, A>.That.Matches(e => e.Count == 2)))