From 03aca355dc19938c8ccecbf547e1152317a40033 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Mon, 29 Aug 2022 13:25:19 +0200 Subject: [PATCH] Subscriptions (#913) * Update deps * Temp * First version. * More tests * Simplified tests. * Improve tests. * More fixes. * Permissions checks. --- .../Squidex.Extensions.csproj | 2 +- .../FieldDescriptions.Designer.cs | 4 +- .../FieldDescriptions.resx | 4 +- ...Squidex.Domain.Apps.Core.Operations.csproj | 1 + .../Subscriptions/AppSubscription.cs | 22 ++ .../Subscriptions/AssetSubscription.cs | 75 +++++ .../Subscriptions/ContentSubscription.cs | 90 ++++++ .../Subscriptions/EventMessageEvaluator.cs | 87 ++++++ .../Subscriptions/EventMessageWrapper.cs | 50 ++++ .../ISubscriptionEventCreator.cs | 21 ++ .../Subscriptions/SubscriptionPublisher.cs | 62 +++++ ...quidex.Domain.Apps.Entities.MongoDb.csproj | 2 +- .../Assets/AssetChangedTriggerHandler.cs | 17 +- .../Contents/ContentChangedTriggerHandler.cs | 17 +- .../GraphQL/CachingGraphQLResolver.cs | 9 +- .../Contents/GraphQL/GraphQLOptions.cs | 2 + ...nsGraphType.cs => ApplicationMutations.cs} | 4 +- ...riesGraphType.cs => ApplicationQueries.cs} | 4 +- .../GraphQL/Types/ApplicationSubscriptions.cs | 39 +++ .../GraphQL/Types/Assets/AssetActions.cs | 34 ++- .../GraphQL/Types/Assets/AssetGraphType.cs | 2 +- .../Types/Assets/AssetsResultGraphType.cs | 2 +- .../Assets/EnrichedAssetEventGraphType.cs | 263 ++++++++++++++++++ .../Contents/GraphQL/Types/Builder.cs | 14 +- .../GraphQL/Types/Contents/ContentActions.cs | 141 ++++++---- .../Contents/EnrichedContentEventGraphType.cs | 134 +++++++++ .../Types/Primitives/EntitySavedGraphType.cs | 2 +- .../Contents/GraphQL/Types/Resolvers.cs | 133 ++++++--- .../Contents/GraphQL/Types/Scalars.cs | 9 + .../GraphQL/Types/SharedObjectGraphType.cs | 26 ++ .../Contents/GraphQL/Types/SharedTypes.cs | 5 + .../Contents/GraphQL/Types/UserGraphType.cs | 2 +- .../Contents/Queries/QueryExecutionContext.cs | 2 +- .../Squidex.Domain.Apps.Entities.csproj | 4 +- .../Squidex.Domain.Apps.Events.csproj | 2 - .../Squidex.Domain.Users.MongoDb.csproj | 2 +- .../Squidex.Infrastructure.MongoDb.csproj | 4 +- .../Consume/EventConsumerProcessor.cs | 11 +- .../Consume/EventConsumerState.cs | 2 +- .../EventSourcing/IEventConsumer.cs | 2 + .../Squidex.Infrastructure.csproj | 13 +- .../GraphQL/DynamicUserContextBuilder.cs | 6 +- .../src/Squidex.Web/GraphQL/GraphQLRunner.cs | 12 +- .../src/Squidex.Web/Pipeline/AppResolver.cs | 2 +- backend/src/Squidex.Web/Squidex.Web.csproj | 6 +- .../Config/Domain/InfrastructureServices.cs | 3 - .../src/Squidex/Config/Domain/RuleServices.cs | 5 +- .../Config/Messaging/MessagingServices.cs | 50 +++- backend/src/Squidex/Config/Web/WebServices.cs | 5 +- backend/src/Squidex/Squidex.csproj | 28 +- backend/src/Squidex/Startup.cs | 2 + .../Model/Apps/AppClientJsonTests.cs | 6 +- .../Subscriptions/AssetSubscriptionTests.cs | 111 ++++++++ .../Subscriptions/ContentSubscriptionTests.cs | 134 +++++++++ .../EventMessageEvaluatorTests.cs | 98 +++++++ .../Subscriptions/EventMessageWrapperTests.cs | 67 +++++ .../SubscriptionPublisherTests.cs | 106 +++++++ .../Apps/Indexes/AppsIndexTests.cs | 4 +- .../Contents/GraphQL/GraphQLMutationTests.cs | 155 +++++------ .../Contents/GraphQL/GraphQLQueriesTests.cs | 29 -- .../GraphQL/GraphQLSubscriptionTests.cs | 202 ++++++++++++++ .../Contents/GraphQL/GraphQLTestBase.cs | 134 +++++++-- .../Schemas/Indexes/SchemasIndexTests.cs | 4 +- .../Squidex.Domain.Apps.Entities.Tests.csproj | 5 +- .../Consume/EventConsumerProcessorTests.cs | 21 ++ .../Security/PermissionSetTests.cs | 11 + frontend/package-lock.json | 38 +++ frontend/package.json | 1 + .../pages/graphql/graphql-page.component.ts | 33 ++- frontend/src/app/shared/internal.ts | 1 - frontend/src/app/shared/module.ts | 3 +- .../shared/services/graphql.service.spec.ts | 46 --- .../app/shared/services/graphql.service.ts | 26 -- 73 files changed, 2259 insertions(+), 416 deletions(-) create mode 100644 backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/AppSubscription.cs create mode 100644 backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/AssetSubscription.cs create mode 100644 backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/ContentSubscription.cs create mode 100644 backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/EventMessageEvaluator.cs create mode 100644 backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/EventMessageWrapper.cs create mode 100644 backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/ISubscriptionEventCreator.cs create mode 100644 backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/SubscriptionPublisher.cs rename backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/{AppMutationsGraphType.cs => ApplicationMutations.cs} (96%) rename backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/{AppQueriesGraphType.cs => ApplicationQueries.cs} (94%) create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ApplicationSubscriptions.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/EnrichedAssetEventGraphType.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/EnrichedContentEventGraphType.cs create mode 100644 backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedObjectGraphType.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/AssetSubscriptionTests.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/ContentSubscriptionTests.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/EventMessageEvaluatorTests.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/EventMessageWrapperTests.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/SubscriptionPublisherTests.cs create mode 100644 backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLSubscriptionTests.cs delete mode 100644 frontend/src/app/shared/services/graphql.service.spec.ts delete mode 100644 frontend/src/app/shared/services/graphql.service.ts diff --git a/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj b/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj index d8cd42fb7..5febbf4b7 100644 --- a/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj +++ b/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj @@ -28,7 +28,7 @@ - + diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs index 68ca51d23..a7754d8df 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs @@ -826,7 +826,7 @@ namespace Squidex.Domain.Apps.Core { } /// - /// Looks up a localized string similar to Optional number of contents to skip.. + /// Looks up a localized string similar to Optional number of items to skip.. /// public static string QuerySkip { get { @@ -835,7 +835,7 @@ namespace Squidex.Domain.Apps.Core { } /// - /// Looks up a localized string similar to Optional number of contents to take.. + /// Looks up a localized string similar to Optional number of items to take.. /// public static string QueryTop { get { diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx index 25ca0e595..b8a267523 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx +++ b/backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx @@ -373,10 +373,10 @@ Optional OData full text search. - Optional number of contents to skip. + Optional number of items to skip. - Optional number of contents to take. + Optional number of items to take. The optional version of the content to retrieve an older instance (not cached). diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj b/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj index 02651950e..b8f65e7e4 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj @@ -28,6 +28,7 @@ + diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/AppSubscription.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/AppSubscription.cs new file mode 100644 index 000000000..74476816a --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/AppSubscription.cs @@ -0,0 +1,22 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure; +using Squidex.Infrastructure.Security; +using Squidex.Messaging.Subscriptions; + +namespace Squidex.Domain.Apps.Core.Subscriptions +{ + public abstract class AppSubscription : ISubscription + { + public DomainId AppId { get; set; } + + public PermissionSet Permissions { get; set; } + + public abstract ValueTask ShouldHandle(object message); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/AssetSubscription.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/AssetSubscription.cs new file mode 100644 index 000000000..d13e7c0ac --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/AssetSubscription.cs @@ -0,0 +1,75 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; +using Squidex.Domain.Apps.Events.Assets; +using Squidex.Shared; + +namespace Squidex.Domain.Apps.Core.Subscriptions +{ + public sealed class AssetSubscription : AppSubscription + { + public EnrichedAssetEventType? Type { get; set; } + + public override ValueTask ShouldHandle(object message) + { + return new ValueTask(ShouldHandleCore(message)); + } + + private bool ShouldHandleCore(object message) + { + switch (message) + { + case EnrichedAssetEvent enrichedAssetEvent: + return ShouldHandle(enrichedAssetEvent); + case AssetEvent assetEvent: + return ShouldHandle(assetEvent); + default: + return false; + } + } + + private bool ShouldHandle(EnrichedAssetEvent @event) + { + return CheckType(@event) && CheckPermission(@event.AppId.Name); + } + + private bool ShouldHandle(AssetEvent @event) + { + return CheckType(@event) && CheckPermission(@event.AppId.Name); + } + + private bool CheckType(EnrichedAssetEvent @event) + { + return Type == null || Type.Value == @event.Type; + } + + private bool CheckType(AssetEvent @event) + { + switch (Type) + { + case EnrichedAssetEventType.Created: + return @event is AssetCreated; + case EnrichedAssetEventType.Deleted: + return @event is AssetDeleted; + case EnrichedAssetEventType.Annotated: + return @event is AssetAnnotated; + case EnrichedAssetEventType.Updated: + return @event is AssetUpdated; + default: + return true; + } + } + + private bool CheckPermission(string appName) + { + var permission = PermissionIds.ForApp(PermissionIds.AppAssetsRead, appName); + + return Permissions.Includes(permission); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/ContentSubscription.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/ContentSubscription.cs new file mode 100644 index 000000000..0eef9fb21 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/ContentSubscription.cs @@ -0,0 +1,90 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; +using Squidex.Domain.Apps.Events.Contents; +using Squidex.Shared; + +namespace Squidex.Domain.Apps.Core.Subscriptions +{ + public sealed class ContentSubscription : AppSubscription + { + public string? SchemaName { get; set; } + + public EnrichedContentEventType? Type { get; set; } + + public override ValueTask ShouldHandle(object message) + { + return new ValueTask(ShouldHandleCore(message)); + } + + private bool ShouldHandleCore(object message) + { + switch (message) + { + case EnrichedContentEvent enrichedContentEvent: + return ShouldHandle(enrichedContentEvent); + case ContentEvent contentEvent: + return ShouldHandle(contentEvent); + default: + return false; + } + } + + private bool ShouldHandle(EnrichedContentEvent @event) + { + var schemaName = @event.SchemaId.Name; + + return CheckSchema(schemaName) && CheckType(@event) && CheckPermission(@event.AppId.Name, schemaName); + } + + private bool ShouldHandle(ContentEvent @event) + { + var schemaName = @event.SchemaId.Name; + + return CheckSchema(schemaName) && CheckType(@event) && CheckPermission(@event.AppId.Name, schemaName); + } + + private bool CheckSchema(string schemaName) + { + return string.IsNullOrWhiteSpace(SchemaName) || schemaName == SchemaName; + } + + private bool CheckType(EnrichedContentEvent @event) + { + return Type == null || Type.Value == @event.Type; + } + + private bool CheckType(ContentEvent @event) + { + switch (Type) + { + case EnrichedContentEventType.Created: + return @event is ContentCreated; + case EnrichedContentEventType.Deleted: + return @event is ContentDeleted; + case EnrichedContentEventType.Published: + return @event is ContentStatusChanged { Change: Contents.StatusChange.Published }; + case EnrichedContentEventType.Unpublished: + return @event is ContentStatusChanged { Change: Contents.StatusChange.Unpublished }; + case EnrichedContentEventType.StatusChanged: + return @event is ContentStatusChanged { Change: Contents.StatusChange.Change }; + case EnrichedContentEventType.Updated: + return @event is ContentUpdated; + default: + return true; + } + } + + private bool CheckPermission(string appName, string schemaName) + { + var permission = PermissionIds.ForApp(PermissionIds.AppContentsRead, appName, schemaName); + + return Permissions.Allows(permission); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/EventMessageEvaluator.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/EventMessageEvaluator.cs new file mode 100644 index 000000000..b9efecf77 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/EventMessageEvaluator.cs @@ -0,0 +1,87 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Events; +using Squidex.Infrastructure; +using Squidex.Messaging.Subscriptions; + +namespace Squidex.Domain.Apps.Core.Subscriptions +{ + public sealed class EventMessageEvaluator : IMessageEvaluator + { + private readonly Dictionary> subscriptions = new Dictionary>(); + private readonly ReaderWriterLockSlim readerWriterLock = new ReaderWriterLockSlim(); + + public async ValueTask> GetSubscriptionsAsync(object message) + { + if (message is not AppEvent appEvent) + { + return Enumerable.Empty(); + } + + readerWriterLock.EnterReadLock(); + try + { + List? result = null; + + if (subscriptions.TryGetValue(appEvent.AppId.Id, out var appSubscriptions)) + { + foreach (var (id, subscription) in appSubscriptions) + { + if (await subscription.ShouldHandle(appEvent)) + { + result ??= new List(); + result.Add(id); + } + } + } + + return result ?? Enumerable.Empty(); + } + finally + { + readerWriterLock.ExitReadLock(); + } + } + + public void SubscriptionAdded(Guid id, ISubscription subscription) + { + if (subscription is not AppSubscription appSubscription) + { + return; + } + + readerWriterLock.EnterWriteLock(); + try + { + subscriptions.GetOrAddNew(appSubscription.AppId)[id] = appSubscription; + } + finally + { + readerWriterLock.ExitWriteLock(); + } + } + + public void SubscriptionRemoved(Guid id, ISubscription subscription) + { + if (subscription is not AppSubscription appSubscription) + { + return; + } + + readerWriterLock.EnterWriteLock(); + try + { + subscriptions.GetOrAddDefault(appSubscription.AppId)?.Remove(id); + } + finally + { + readerWriterLock.ExitWriteLock(); + } + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/EventMessageWrapper.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/EventMessageWrapper.cs new file mode 100644 index 000000000..65264fd14 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/EventMessageWrapper.cs @@ -0,0 +1,50 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; +using Squidex.Domain.Apps.Events; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Messaging.Subscriptions; + +namespace Squidex.Domain.Apps.Core.Subscriptions +{ + public sealed class EventMessageWrapper : IPayloadWrapper + { + private readonly IEnumerable subscriptionEventCreators; + + public Envelope Event { get; } + + object IPayloadWrapper.Message => Event.Payload; + + public EventMessageWrapper(Envelope @event, IEnumerable subscriptionEventCreators) + { + Event = @event; + + this.subscriptionEventCreators = subscriptionEventCreators; + } + + public async ValueTask CreatePayloadAsync() + { + foreach (var creator in subscriptionEventCreators) + { + if (!creator.Handles(Event.Payload)) + { + continue; + } + + var result = await creator.CreateEnrichedEventsAsync(Event, default); + + if (result != null) + { + return result; + } + } + + return null!; + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/ISubscriptionEventCreator.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/ISubscriptionEventCreator.cs new file mode 100644 index 000000000..e8f0a8d7b --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/ISubscriptionEventCreator.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; +using Squidex.Domain.Apps.Events; +using Squidex.Infrastructure.EventSourcing; + +namespace Squidex.Domain.Apps.Core.Subscriptions +{ + public interface ISubscriptionEventCreator + { + bool Handles(AppEvent @event); + + ValueTask CreateEnrichedEventsAsync(Envelope @event, + CancellationToken ct); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/SubscriptionPublisher.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/SubscriptionPublisher.cs new file mode 100644 index 000000000..c5f2b458c --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/SubscriptionPublisher.cs @@ -0,0 +1,62 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Events; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Messaging.Subscriptions; + +namespace Squidex.Domain.Apps.Core.Subscriptions +{ + public sealed class SubscriptionPublisher : IEventConsumer + { + private readonly ISubscriptionService subscriptionService; + private readonly IEnumerable subscriptionEventCreators; + + public string Name + { + get => "Subscriptions"; + } + + public string EventsFilter + { + get => "^(content-|asset-)"; + } + + public bool StartLatest + { + get => true; + } + + public bool CanClear + { + get => false; + } + + public SubscriptionPublisher(ISubscriptionService subscriptionService, IEnumerable subscriptionEventCreators) + { + this.subscriptionService = subscriptionService; + this.subscriptionEventCreators = subscriptionEventCreators; + } + + public bool Handles(StoredEvent @event) + { + return subscriptionService.HasSubscriptions; + } + + public Task On(Envelope @event) + { + if (@event.Payload is not AppEvent) + { + return Task.CompletedTask; + } + + var wrapper = new EventMessageWrapper(@event.To(), subscriptionEventCreators); + + return subscriptionService.PublishAsync(wrapper); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj index 082fe7847..3dd9668eb 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj @@ -23,7 +23,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs index 1267b81f3..7ffc9dbb5 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs @@ -10,6 +10,7 @@ using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Core.Subscriptions; using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Assets; @@ -18,7 +19,7 @@ using Squidex.Infrastructure.Reflection; namespace Squidex.Domain.Apps.Entities.Assets { - public sealed class AssetChangedTriggerHandler : IRuleTriggerHandler + public sealed class AssetChangedTriggerHandler : IRuleTriggerHandler, ISubscriptionEventCreator { private readonly IScriptEngine scriptEngine; private readonly IAssetLoader assetLoader; @@ -66,6 +67,18 @@ namespace Squidex.Domain.Apps.Entities.Assets public async IAsyncEnumerable CreateEnrichedEventsAsync(Envelope @event, RuleContext context, [EnumeratorCancellation] CancellationToken ct) + { + yield return await CreateEnrichedEventsCoreAsync(@event, ct); + } + + public async ValueTask CreateEnrichedEventsAsync(Envelope @event, + CancellationToken ct) + { + return await CreateEnrichedEventsCoreAsync(@event, ct); + } + + private async ValueTask CreateEnrichedEventsCoreAsync(Envelope @event, + CancellationToken ct) { var assetEvent = (AssetEvent)@event.Payload; @@ -105,7 +118,7 @@ namespace Squidex.Domain.Apps.Entities.Assets break; } - yield return result; + return result; } public bool Trigger(EnrichedEvent @event, RuleContext context) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs index bf5b50189..eeda6457a 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs @@ -11,6 +11,7 @@ using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Core.Subscriptions; using Squidex.Domain.Apps.Entities.Contents.Repositories; using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events.Contents; @@ -23,7 +24,7 @@ using Squidex.Text; namespace Squidex.Domain.Apps.Entities.Contents { - public sealed class ContentChangedTriggerHandler : IRuleTriggerHandler + public sealed class ContentChangedTriggerHandler : IRuleTriggerHandler, ISubscriptionEventCreator { private readonly IScriptEngine scriptEngine; private readonly IContentLoader contentLoader; @@ -76,6 +77,18 @@ namespace Squidex.Domain.Apps.Entities.Contents public async IAsyncEnumerable CreateEnrichedEventsAsync(Envelope @event, RuleContext context, [EnumeratorCancellation] CancellationToken ct) + { + yield return await CreateEnrichedEventsCoreAsync(@event, ct); + } + + public async ValueTask CreateEnrichedEventsAsync(Envelope @event, + CancellationToken ct) + { + return await CreateEnrichedEventsCoreAsync(@event, ct); + } + + private async ValueTask CreateEnrichedEventsCoreAsync(Envelope @event, + CancellationToken ct) { var contentEvent = (ContentEvent)@event.Payload; @@ -136,7 +149,7 @@ namespace Squidex.Domain.Apps.Entities.Contents } } - yield return result; + return result; } public string? GetName(AppEvent @event) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLResolver.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLResolver.cs index 8d0bc1f55..d0a2fd499 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLResolver.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLResolver.cs @@ -33,6 +33,8 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL private sealed record CacheEntry(GraphQLSchema Model, string Hash, Instant Created); + public float SortOrder => 0; + public IServiceProvider Services { get => serviceProvider; @@ -86,11 +88,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL private async Task CreateModelAsync(IAppEntity app) { - var schemas = await serviceProvider.GetRequiredService().GetSchemasAsync(app.Id); + var schemasList = await serviceProvider.GetRequiredService().GetSchemasAsync(app.Id); + var schemasKey = await schemasHash.ComputeHashAsync(app, schemasList); - var hash = await schemasHash.ComputeHashAsync(app, schemas); + var now = SystemClock.Instance.GetCurrentInstant(); - return new CacheEntry(new Builder(app).BuildSchema(schemas), hash, SystemClock.Instance.GetCurrentInstant()); + return new CacheEntry(new Builder(app, options).BuildSchema(schemasList), schemasKey, now); } private static object CreateCacheKey(DomainId appId, string etag) diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLOptions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLOptions.cs index de436341c..fe28c574f 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLOptions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLOptions.cs @@ -10,5 +10,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL public sealed class GraphQLOptions { public int CacheDuration { get; set; } = 10 * 60; + + public bool EnableSubscriptions { get; set; } = true; } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppMutationsGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ApplicationMutations.cs similarity index 96% rename from backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppMutationsGraphType.cs rename to backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ApplicationMutations.cs index b15115bbe..3e7050d12 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppMutationsGraphType.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ApplicationMutations.cs @@ -11,9 +11,9 @@ using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Primitives; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { - internal sealed class AppMutationsGraphType : ObjectGraphType + internal sealed class ApplicationMutations : ObjectGraphType { - public AppMutationsGraphType(Builder builder, IEnumerable schemas) + public ApplicationMutations(Builder builder, IEnumerable schemas) { foreach (var schemaInfo in schemas.Where(x => x.Fields.Count > 0)) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ApplicationQueries.cs similarity index 94% rename from backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs rename to backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ApplicationQueries.cs index 140fe7ae4..74b08f29b 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ApplicationQueries.cs @@ -10,9 +10,9 @@ using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { - internal sealed class AppQueriesGraphType : ObjectGraphType + internal sealed class ApplicationQueries : ObjectGraphType { - public AppQueriesGraphType(Builder builder, IEnumerable schemaInfos) + public ApplicationQueries(Builder builder, IEnumerable schemaInfos) { AddField(SharedTypes.FindAsset); AddField(SharedTypes.QueryAssets); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ApplicationSubscriptions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ApplicationSubscriptions.cs new file mode 100644 index 000000000..1d5e65ef0 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ApplicationSubscriptions.cs @@ -0,0 +1,39 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using GraphQL.Types; +using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets; +using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + internal sealed class ApplicationSubscriptions : ObjectGraphType + { + public ApplicationSubscriptions() + { + AddField(new FieldType + { + Name = $"assetChanges", + Arguments = AssetActions.Subscription.Arguments, + ResolvedType = SharedTypes.EnrichedAssetEvent, + Resolver = null, + StreamResolver = AssetActions.Subscription.Resolver, + Description = "Subscribe to asset events." + }); + + AddField(new FieldType + { + Name = $"contentChanges", + Arguments = ContentActions.Subscription.Arguments, + ResolvedType = SharedTypes.EnrichedContentEvent, + Resolver = null, + StreamResolver = ContentActions.Subscription.Resolver, + Description = "Subscribe to content events." + }); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetActions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetActions.cs index 9423c149e..bc4dd77e7 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetActions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetActions.cs @@ -9,8 +9,12 @@ using GraphQL; using GraphQL.Resolvers; using GraphQL.Types; using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; +using Squidex.Domain.Apps.Core.Subscriptions; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Infrastructure; +using Squidex.Messaging.Subscriptions; +using Squidex.Shared; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets { @@ -70,25 +74,25 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets new QueryArgument(Scalars.Int) { Name = "top", - Description = "Optional number of assets to take.", + Description = FieldDescriptions.QueryTop, DefaultValue = null }, new QueryArgument(Scalars.Int) { Name = "skip", - Description = "Optional number of assets to skip.", + Description = FieldDescriptions.QuerySkip, DefaultValue = 0 }, new QueryArgument(Scalars.String) { Name = "filter", - Description = "Optional OData filter.", + Description = FieldDescriptions.QueryFilter, DefaultValue = null }, new QueryArgument(Scalars.String) { Name = "orderby", - Description = "Optional OData order definition.", + Description = FieldDescriptions.QueryOrderBy, DefaultValue = null } }; @@ -113,5 +117,27 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets fieldContext.CancellationToken); }); } + + public static class Subscription + { + public static readonly QueryArguments Arguments = new QueryArguments + { + new QueryArgument(Scalars.EnrichedAssetEventType) + { + Name = "type", + Description = FieldDescriptions.EventType, + DefaultValue = null + } + }; + + public static readonly ISourceStreamResolver Resolver = Resolvers.Stream(PermissionIds.AppAssetsRead, c => + { + return new AssetSubscription + { + // Primary filter for the event types. + Type = c.GetArgument("type") + }; + }); + } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetGraphType.cs index 441a86d92..197b40364 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetGraphType.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetGraphType.cs @@ -16,7 +16,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets { - internal sealed class AssetGraphType : ObjectGraphType + internal sealed class AssetGraphType : SharedObjectGraphType { public AssetGraphType() { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetsResultGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetsResultGraphType.cs index 1dcc50cb2..0b6a16363 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetsResultGraphType.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetsResultGraphType.cs @@ -13,7 +13,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets { - internal sealed class AssetsResultGraphType : ObjectGraphType> + internal sealed class AssetsResultGraphType : SharedObjectGraphType> { public AssetsResultGraphType(IGraphType assetsList) { diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/EnrichedAssetEventGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/EnrichedAssetEventGraphType.cs new file mode 100644 index 000000000..b91334011 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/EnrichedAssetEventGraphType.cs @@ -0,0 +1,263 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using GraphQL; +using GraphQL.Resolvers; +using GraphQL.Types; +using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.Assets; +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; +using Squidex.Infrastructure; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets +{ + internal sealed class EnrichedAssetEventGraphType : SharedObjectGraphType + { + public EnrichedAssetEventGraphType() + { + // The name is used for equal comparison. Therefore it is important to treat it as readonly. + Name = "EnrichedAssetEvent"; + + AddField(new FieldType + { + Name = "type", + ResolvedType = Scalars.EnrichedAssetEventType, + Resolver = Resolve(x => x.Type), + Description = FieldDescriptions.EventType + }); + + AddField(new FieldType + { + Name = "id", + ResolvedType = Scalars.NonNullString, + Resolver = Resolve(x => x.Id.ToString()), + Description = FieldDescriptions.EntityId + }); + + AddField(new FieldType + { + Name = "version", + ResolvedType = Scalars.NonNullInt, + Resolver = Resolve(x => x.Version), + Description = FieldDescriptions.EntityVersion + }); + + AddField(new FieldType + { + Name = "created", + ResolvedType = Scalars.NonNullDateTime, + Resolver = Resolve(x => x.Created.ToDateTimeUtc()), + Description = FieldDescriptions.EntityCreated + }); + + AddField(new FieldType + { + Name = "createdBy", + ResolvedType = Scalars.NonNullString, + Resolver = Resolve(x => x.CreatedBy.ToString()), + Description = FieldDescriptions.EntityCreatedBy + }); + + AddField(new FieldType + { + Name = "createdByUser", + ResolvedType = UserGraphType.NonNull, + Resolver = Resolve(x => x.CreatedBy), + Description = FieldDescriptions.EntityCreatedBy + }); + + AddField(new FieldType + { + Name = "lastModified", + ResolvedType = Scalars.NonNullDateTime, + Resolver = Resolve(x => x.LastModified.ToDateTimeUtc()), + Description = FieldDescriptions.EntityLastModified + }); + + AddField(new FieldType + { + Name = "lastModifiedBy", + ResolvedType = Scalars.NonNullString, + Resolver = Resolve(x => x.LastModifiedBy.ToString()), + Description = FieldDescriptions.EntityLastModifiedBy + }); + + AddField(new FieldType + { + Name = "lastModifiedByUser", + ResolvedType = UserGraphType.NonNull, + Resolver = Resolve(x => x.LastModifiedBy), + Description = FieldDescriptions.EntityLastModifiedBy + }); + + AddField(new FieldType + { + Name = "mimeType", + ResolvedType = Scalars.NonNullString, + Resolver = Resolve(x => x.MimeType), + Description = FieldDescriptions.AssetMimeType + }); + + AddField(new FieldType + { + Name = "url", + ResolvedType = Scalars.NonNullString, + Resolver = Url, + Description = FieldDescriptions.AssetUrl + }); + + AddField(new FieldType + { + Name = "thumbnailUrl", + ResolvedType = Scalars.String, + Resolver = ThumbnailUrl, + Description = FieldDescriptions.AssetThumbnailUrl + }); + + AddField(new FieldType + { + Name = "fileName", + ResolvedType = Scalars.NonNullString, + Resolver = Resolve(x => x.FileName), + Description = FieldDescriptions.AssetFileName + }); + + AddField(new FieldType + { + Name = "fileHash", + ResolvedType = Scalars.NonNullString, + Resolver = Resolve(x => x.FileHash), + Description = FieldDescriptions.AssetFileHash + }); + + AddField(new FieldType + { + Name = "fileType", + ResolvedType = Scalars.NonNullString, + Resolver = Resolve(x => x.FileName.FileType()), + Description = FieldDescriptions.AssetFileType + }); + + AddField(new FieldType + { + Name = "fileSize", + ResolvedType = Scalars.NonNullInt, + Resolver = Resolve(x => x.FileSize), + Description = FieldDescriptions.AssetFileSize + }); + + AddField(new FieldType + { + Name = "fileVersion", + ResolvedType = Scalars.NonNullInt, + Resolver = Resolve(x => x.FileVersion), + Description = FieldDescriptions.AssetFileVersion + }); + + AddField(new FieldType + { + Name = "slug", + ResolvedType = Scalars.NonNullString, + Resolver = Resolve(x => x.Slug), + Description = FieldDescriptions.AssetSlug + }); + + AddField(new FieldType + { + Name = "isProtected", + ResolvedType = Scalars.NonNullBoolean, + Resolver = Resolve(x => x.IsProtected), + Description = FieldDescriptions.AssetIsProtected + }); + + AddField(new FieldType + { + Name = "isImage", + ResolvedType = Scalars.NonNullBoolean, + Resolver = Resolve(x => x.AssetType == AssetType.Image), + Description = FieldDescriptions.AssetIsImage, + DeprecationReason = "Use 'type' field instead." + }); + + AddField(new FieldType + { + Name = "assetType", + ResolvedType = Scalars.NonNullAssetType, + Resolver = Resolve(x => x.AssetType), + Description = FieldDescriptions.AssetType + }); + + AddField(new FieldType + { + Name = "pixelWidth", + ResolvedType = Scalars.Int, + Resolver = Resolve(x => x.Metadata.GetPixelWidth()), + Description = FieldDescriptions.AssetPixelWidth, + DeprecationReason = "Use 'metadata' field instead." + }); + + AddField(new FieldType + { + Name = "pixelHeight", + ResolvedType = Scalars.Int, + Resolver = Resolve(x => x.Metadata.GetPixelHeight()), + Description = FieldDescriptions.AssetPixelHeight, + DeprecationReason = "Use 'metadata' field instead." + }); + + AddField(new FieldType + { + Name = "metadata", + Arguments = AssetActions.Metadata.Arguments, + ResolvedType = Scalars.JsonNoop, + Resolver = AssetActions.Metadata.Resolver, + Description = FieldDescriptions.AssetMetadata + }); + + AddField(new FieldType + { + Name = "sourceUrl", + ResolvedType = Scalars.NonNullString, + Resolver = SourceUrl, + Description = FieldDescriptions.AssetSourceUrl + }); + + Description = "An asset event"; + } + + private static readonly IFieldResolver Url = Resolve((asset, _, context) => + { + var urlGenerator = context.Resolve(); + + return urlGenerator.AssetContent(asset.AppId, asset.Id.ToString()); + }); + + private static readonly IFieldResolver SourceUrl = Resolve((asset, _, context) => + { + var urlGenerator = context.Resolve(); + + return urlGenerator.AssetSource(asset.AppId, asset.Id, asset.FileVersion); + }); + + private static readonly IFieldResolver ThumbnailUrl = Resolve((asset, _, context) => + { + var urlGenerator = context.Resolve(); + + return urlGenerator.AssetThumbnail(asset.AppId, asset.Id.ToString(), asset.AssetType); + }); + + private static IFieldResolver Resolve(Func resolver) + { + return Resolvers.Sync(resolver); + } + + private static IFieldResolver Resolve(Func resolver) + { + return Resolvers.Sync(resolver); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs index bbfd1936d..b675053d1 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs @@ -29,6 +29,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types private readonly FieldInputVisitor fieldInputVisitor; private readonly PartitionResolver partitionResolver; private readonly List allSchemas = new List(); + private readonly GraphQLOptions options; static Builder() { @@ -40,12 +41,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types public IInterfaceGraphType ComponentInterface { get; } = new ComponentInterfaceGraphType(); - public Builder(IAppEntity app) + public Builder(IAppEntity app, GraphQLOptions options) { partitionResolver = app.PartitionResolver(); fieldVisitor = new FieldVisitor(this); fieldInputVisitor = new FieldInputVisitor(this); + + this.options = options; } public GraphQLSchema BuildSchema(IEnumerable schemas) @@ -73,7 +76,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types var newSchema = new GraphQLSchema { - Query = new AppQueriesGraphType(this, schemaInfos) + Query = new ApplicationQueries(this, schemaInfos) }; newSchema.RegisterType(ComponentInterface); @@ -83,7 +86,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types if (schemaInfos.Any()) { - var mutations = new AppMutationsGraphType(this, schemaInfos); + var mutations = new ApplicationMutations(this, schemaInfos); if (mutations.Fields.Count > 0) { @@ -91,6 +94,11 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types } } + if (options.EnableSubscriptions) + { + newSchema.Subscription = new ApplicationSubscriptions(); + } + foreach (var (schemaInfo, contentType) in contentTypes) { contentType.Initialize(this, schemaInfo, schemaInfos); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs index 3d35390d9..26d95fa30 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs @@ -11,10 +11,10 @@ using GraphQL.Types; using NodaTime; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; +using Squidex.Domain.Apps.Core.Subscriptions; using Squidex.Domain.Apps.Entities.Contents.Commands; using Squidex.Infrastructure; -using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Translations; using Squidex.Shared; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents @@ -226,22 +226,19 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents }; } - public static readonly IFieldResolver Resolver = ResolveAsync(PermissionIds.AppContentsCreate, c => + public static readonly IFieldResolver Resolver = ContentCommand(PermissionIds.AppContentsCreate, c => { - var contentId = c.GetArgument("id"); - var contentData = c.GetArgument("data")!; - var contentStatus = c.GetArgument("status"); - - var command = new CreateContent { Data = contentData }; - - if (!string.IsNullOrWhiteSpace(contentId)) + var command = new CreateContent { - command.ContentId = DomainId.Create(contentId); - } + // The data is converted from input args. + Data = c.GetArgument("data") + }; + + var status = c.GetArgument("status"); - if (!string.IsNullOrWhiteSpace(contentStatus)) + if (!string.IsNullOrWhiteSpace(status)) { - command.Status = new Status(contentStatus); + command.Status = new Status(status); } else if (c.GetArgument("publish")) { @@ -297,18 +294,22 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents }; } - public static readonly IFieldResolver Resolver = ResolveAsync(PermissionIds.AppContentsUpsert, c => + public static readonly IFieldResolver Resolver = ContentCommand(PermissionIds.AppContentsUpsert, c => { - var contentId = c.GetArgument("id"); - var contentData = c.GetArgument("data")!; - var contentStatus = c.GetArgument("status"); - var patch = c.GetArgument("patch"); + var command = new UpsertContent + { + // The data is converted from input args. + Data = c.GetArgument("data"), - var command = new UpsertContent { ContentId = contentId, Data = contentData, Patch = patch }; + // True, to make a path, if the content exits. + Patch = c.GetArgument("patch"), + }; + + var status = c.GetArgument("status"); - if (!string.IsNullOrWhiteSpace(contentStatus)) + if (!string.IsNullOrWhiteSpace(status)) { - command.Status = new Status(contentStatus); + command.Status = new Status(status); } else if (c.GetArgument("publish")) { @@ -346,12 +347,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents }; } - public static readonly IFieldResolver Resolver = ResolveAsync(PermissionIds.AppContentsUpdateOwn, c => + public static readonly IFieldResolver Resolver = ContentCommand(PermissionIds.AppContentsUpdateOwn, c => { - var contentId = c.GetArgument("id"); - var contentData = c.GetArgument("data")!; - - return new UpdateContent { ContentId = contentId, Data = contentData }; + return new PatchContent + { + // The data is converted from input args. + Data = c.GetArgument("data")! + }; }); } @@ -382,12 +384,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents }; } - public static readonly IFieldResolver Resolver = ResolveAsync(PermissionIds.AppContentsUpdateOwn, c => + public static readonly IFieldResolver Resolver = ContentCommand(PermissionIds.AppContentsUpdateOwn, c => { - var contentId = c.GetArgument("id"); - var contentData = c.GetArgument("data")!; - - return new PatchContent { ContentId = contentId, Data = contentData }; + return new PatchContent + { + // The data is converted from input args. + Data = c.GetArgument("data")! + }; }); } @@ -421,13 +424,16 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents } }; - public static readonly IFieldResolver Resolver = ResolveAsync(PermissionIds.AppContentsChangeStatusOwn, c => + public static readonly IFieldResolver Resolver = ContentCommand(PermissionIds.AppContentsChangeStatusOwn, c => { - var contentId = c.GetArgument("id"); - var contentStatus = c.GetArgument("status"); - var contentDueTime = c.GetArgument("dueTime"); + return new ChangeContentStatus + { + // Main parameter to set the status. + Status = c.GetArgument("status"), - return new ChangeContentStatus { ContentId = contentId, Status = contentStatus, DueTime = contentDueTime }; + // This is an optional field to delay the status change. + DueTime = c.GetArgument("dueTime"), + }; }); } @@ -449,40 +455,59 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents } }; - public static readonly IFieldResolver Resolver = ResolveAsync(PermissionIds.AppContentsDeleteOwn, c => + public static readonly IFieldResolver Resolver = ContentCommand(PermissionIds.AppContentsDeleteOwn, c => { - var contentId = c.GetArgument("id"); - - return new DeleteContent { ContentId = contentId }; + return new DeleteContent(); }); } - private static IFieldResolver ResolveAsync(string permissionId, Func action) + public static class Subscription { - return Resolvers.Async(async (source, fieldContext, context) => + public static readonly QueryArguments Arguments = new QueryArguments { - var schemaId = fieldContext.FieldDefinition.SchemaNamedId(); - - CheckPermission(permissionId, context, schemaId); - - var contentCommand = action(fieldContext); - - contentCommand.SchemaId = schemaId; - contentCommand.ExpectedVersion = fieldContext.GetArgument("expectedVersion", EtagVersion.Any); + new QueryArgument(Scalars.EnrichedContentEventType) + { + Name = "type", + Description = FieldDescriptions.EventType, + DefaultValue = null + }, + new QueryArgument(Scalars.String) + { + Name = "schemaName", + Description = FieldDescriptions.ContentSchemaName, + DefaultValue = null + } + }; - var commandBus = context.Resolve(); - var commandContext = await commandBus.PublishAsync(contentCommand, fieldContext.CancellationToken); + public static readonly ISourceStreamResolver Resolver = Resolvers.Stream(PermissionIds.AppContentsRead, c => + { + return new ContentSubscription + { + // Primary filter for the event types. + Type = c.GetArgument("type"), - return commandContext.PlainResult!; + // The name of the schema is used instead of the ID for a simpler API. + SchemaName = c.GetArgument("schemaName") + }; }); } - private static void CheckPermission(string permissionId, GraphQLExecutionContext context, NamedId schemaId) + private static IFieldResolver ContentCommand(string permissionId, Func creator) { - if (!context.Context.Allows(permissionId, schemaId.Name)) + return Resolvers.Command(permissionId, c => { - throw new DomainForbiddenException(T.Get("common.errorNoPermission")); - } + var command = creator(c); + + var contentId = c.GetArgument("id"); + + if (!string.IsNullOrWhiteSpace(contentId)) + { + // Same parameter for all commands. + command.ContentId = DomainId.Create(contentId); + } + + return command; + }); } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/EnrichedContentEventGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/EnrichedContentEventGraphType.cs new file mode 100644 index 000000000..53f862561 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/EnrichedContentEventGraphType.cs @@ -0,0 +1,134 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using GraphQL.Resolvers; +using GraphQL.Types; +using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents +{ + internal sealed class EnrichedContentEventGraphType : SharedObjectGraphType + { + public EnrichedContentEventGraphType() + { + // The name is used for equal comparison. Therefore it is important to treat it as readonly. + Name = "EnrichedContentEvent"; + + AddField(new FieldType + { + Name = "type", + ResolvedType = Scalars.EnrichedContentEventType, + Resolver = Resolve(x => x.Type), + Description = FieldDescriptions.EventType + }); + + AddField(new FieldType + { + Name = "id", + ResolvedType = Scalars.NonNullString, + Resolver = Resolve(x => x.Id.ToString()), + Description = FieldDescriptions.EntityId + }); + + AddField(new FieldType + { + Name = "version", + ResolvedType = Scalars.NonNullInt, + Resolver = Resolve(x => x.Version), + Description = FieldDescriptions.EntityVersion + }); + + AddField(new FieldType + { + Name = "created", + ResolvedType = Scalars.NonNullDateTime, + Resolver = Resolve(x => x.Created.ToDateTimeUtc()), + Description = FieldDescriptions.EntityCreated + }); + + AddField(new FieldType + { + Name = "createdBy", + ResolvedType = Scalars.NonNullString, + Resolver = Resolve(x => x.CreatedBy.ToString()), + Description = FieldDescriptions.EntityCreatedBy + }); + + AddField(new FieldType + { + Name = "createdByUser", + ResolvedType = UserGraphType.NonNull, + Resolver = Resolve(x => x.CreatedBy), + Description = FieldDescriptions.EntityCreatedBy + }); + + AddField(new FieldType + { + Name = "lastModified", + ResolvedType = Scalars.NonNullDateTime, + Resolver = Resolve(x => x.LastModified.ToDateTimeUtc()), + Description = FieldDescriptions.EntityLastModified + }); + + AddField(new FieldType + { + Name = "lastModifiedBy", + ResolvedType = Scalars.NonNullString, + Resolver = Resolve(x => x.LastModifiedBy.ToString()), + Description = FieldDescriptions.EntityLastModifiedBy + }); + + AddField(new FieldType + { + Name = "lastModifiedByUser", + ResolvedType = UserGraphType.NonNull, + Resolver = Resolve(x => x.LastModifiedBy), + Description = FieldDescriptions.EntityLastModifiedBy + }); + + AddField(new FieldType + { + Name = "status", + ResolvedType = Scalars.NonNullString, + Resolver = Resolve(x => x.Status.ToString()), + Description = FieldDescriptions.ContentStatus + }); + + AddField(new FieldType + { + Name = "newStatus", + ResolvedType = Scalars.String, + Resolver = Resolve(x => x.NewStatus?.ToString()), + Description = FieldDescriptions.ContentNewStatus + }); + + AddField(new FieldType + { + Name = "data", + ResolvedType = Scalars.JsonNoop, + Resolver = Resolve(x => x.Data), + Description = FieldDescriptions.ContentData + }); + + AddField(new FieldType + { + Name = "dataOld", + ResolvedType = Scalars.JsonNoop, + Resolver = Resolve(x => x.DataOld), + Description = FieldDescriptions.ContentDataOld + }); + + Description = "An content event"; + } + + private static IFieldResolver Resolve(Func resolver) + { + return Resolvers.Sync(resolver); + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Primitives/EntitySavedGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Primitives/EntitySavedGraphType.cs index e0b666722..5f551cfa4 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Primitives/EntitySavedGraphType.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Primitives/EntitySavedGraphType.cs @@ -11,7 +11,7 @@ using Squidex.Infrastructure.Commands; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Primitives { - internal sealed class EntitySavedGraphType : ObjectGraphType + internal sealed class EntitySavedGraphType : SharedObjectGraphType { public static readonly IGraphType Nullable = new EntitySavedGraphType(); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Resolvers.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Resolvers.cs index 535427e39..96131df3b 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Resolvers.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Resolvers.cs @@ -8,8 +8,14 @@ using GraphQL; using GraphQL.Resolvers; using Microsoft.Extensions.Logging; +using Squidex.Domain.Apps.Core.Subscriptions; using Squidex.Infrastructure; +using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Security; +using Squidex.Infrastructure.Translations; using Squidex.Infrastructure.Validation; +using Squidex.Messaging.Subscriptions; +using Squidex.Shared; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { @@ -35,24 +41,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types return new AsyncResolver(resolver); } - private sealed class SyncResolver : IFieldResolver + private abstract class BaseResolver where T : TOut { - private readonly Func resolver; - - public SyncResolver(Func resolver) - { - this.resolver = resolver; - } - - public ValueTask ResolveAsync(IResolveFieldContext context) + protected async ValueTask ResolveWithErrorHandlingAsync(IResolveFieldContext context) { var executionContext = (GraphQLExecutionContext)context.UserContext!; - try { - var result = resolver((TSource)context.Source!, context, executionContext); - - return new ValueTask(result); + return await ResolveCoreAsync(context, executionContext); } catch (ValidationException ex) { @@ -70,9 +66,31 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types throw; } } + + protected abstract ValueTask ResolveCoreAsync(IResolveFieldContext context, GraphQLExecutionContext executionContext); + } + + private sealed class SyncResolver : BaseResolver, IFieldResolver + { + private readonly Func resolver; + + public SyncResolver(Func resolver) + { + this.resolver = resolver; + } + + protected override ValueTask ResolveCoreAsync(IResolveFieldContext context, GraphQLExecutionContext executionContext) + { + return new ValueTask(resolver((TSource)context.Source!, context, executionContext)); + } + + public ValueTask ResolveAsync(IResolveFieldContext context) + { + return ResolveWithErrorHandlingAsync(context); + } } - private sealed class AsyncResolver : IFieldResolver + private sealed class AsyncResolver : BaseResolver, IFieldResolver { private readonly Func> resolver; @@ -81,32 +99,85 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types this.resolver = resolver; } - public async ValueTask ResolveAsync(IResolveFieldContext context) + protected override async ValueTask ResolveCoreAsync(IResolveFieldContext context, GraphQLExecutionContext executionContext) { - var executionContext = (GraphQLExecutionContext)context.UserContext!; + return await resolver((TSource)context.Source!, context, executionContext); + } - try - { - var result = await resolver((TSource)context.Source!, context, executionContext); + public ValueTask ResolveAsync(IResolveFieldContext context) + { + return ResolveWithErrorHandlingAsync(context); + } + } - return result; - } - catch (ValidationException ex) + private sealed class SyncStreamResolver : BaseResolver, IObservable>, ISourceStreamResolver + { + private readonly Func> resolver; + + public SyncStreamResolver(Func> resolver) + { + this.resolver = resolver; + } + + protected override ValueTask> ResolveCoreAsync(IResolveFieldContext context, GraphQLExecutionContext executionContext) + { + return new ValueTask>(resolver(context, executionContext)); + } + + public ValueTask> ResolveAsync(IResolveFieldContext context) + { + return ResolveWithErrorHandlingAsync(context); + } + } + + public static IFieldResolver Command(string permissionId, Func action) + { + return new AsyncResolver(async (source, fieldContext, context) => + { + var schemaId = fieldContext.FieldDefinition.SchemaNamedId(); + + if (!context.Context.Allows(permissionId, schemaId?.Name ?? Permission.Any)) { - throw new ExecutionError(ex.Message); + throw new DomainForbiddenException(T.Get("common.errorNoPermission")); } - catch (DomainException ex) + + var command = action(fieldContext); + + // The app identifier is set from the http context. + if (command is ISchemaCommand schemaCommand && schemaId != null) { - throw new ExecutionError(ex.Message); + schemaCommand.SchemaId = schemaId; } - catch (Exception ex) - { - var logFactory = executionContext.Resolve(); - logFactory.CreateLogger("GraphQL").LogError(ex, "Failed to resolve field {field}.", context.FieldDefinition.Name); - throw; + command.ExpectedVersion = fieldContext.GetArgument("expectedVersion", EtagVersion.Any); + + var commandContext = + await context.Resolve() + .PublishAsync(command, fieldContext.CancellationToken); + + return commandContext.PlainResult!; + }); + } + + public static ISourceStreamResolver Stream(string permissionId, Func action) + { + return new SyncStreamResolver((fieldContext, context) => + { + if (!context.Context.UserPermissions.Includes(PermissionIds.ForApp(permissionId, context.Context.App.Name))) + { + throw new DomainForbiddenException(T.Get("common.errorNoPermission")); } - } + + var subscription = action(fieldContext); + + // The app id is taken from the URL so we cannot get events from other apps. + subscription.AppId = context.Context.App.Id; + + // We also check the subscriptions on the source server. + subscription.Permissions = context.Context.UserPermissions; + + return context.Resolve().Subscribe(subscription); + }); } } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Scalars.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Scalars.cs index cdfb26a0f..5c51f37e3 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Scalars.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Scalars.cs @@ -7,6 +7,7 @@ using GraphQL.Types; using Squidex.Domain.Apps.Core.Assets; +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Primitives; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types @@ -33,6 +34,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types public static readonly IGraphType AssetType = new EnumerationGraphType(); + public static readonly IGraphType EnrichedAssetEventType = new EnumerationGraphType(); + + public static readonly IGraphType EnrichedContentEventType = new EnumerationGraphType(); + public static readonly IGraphType NonNullInt = new NonNullGraphType(Int); public static readonly IGraphType NonNullLong = new NonNullGraphType(Long); @@ -48,5 +53,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types public static readonly IGraphType NonNullDateTime = new NonNullGraphType(DateTime); public static readonly IGraphType NonNullAssetType = new NonNullGraphType(AssetType); + + public static readonly IGraphType NonNullEnrichedAssetEventType = new NonNullGraphType(EnrichedAssetEventType); + + public static readonly IGraphType NonNullEnrichedContentEventType = new NonNullGraphType(EnrichedContentEventType); } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedObjectGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedObjectGraphType.cs new file mode 100644 index 000000000..8549d390e --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedObjectGraphType.cs @@ -0,0 +1,26 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using GraphQL.Types; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types +{ + internal abstract class SharedObjectGraphType : ObjectGraphType + { + public override void Initialize(ISchema schema) + { + try + { + base.Initialize(schema); + } + catch (InvalidOperationException) + { + return; + } + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedTypes.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedTypes.cs index 8b7515191..964ff4481 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedTypes.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedTypes.cs @@ -7,6 +7,7 @@ using GraphQL.Types; using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets; +using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents; using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Directives; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types @@ -19,6 +20,10 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types public static readonly IGraphType AssetsResult = new AssetsResultGraphType(AssetsList); + public static readonly IGraphType EnrichedAssetEvent = new EnrichedAssetEventGraphType(); + + public static readonly IGraphType EnrichedContentEvent = new EnrichedContentEventGraphType(); + public static readonly CacheDirective MemoryCacheDirective = new CacheDirective(); public static readonly FieldType FindAsset = new FieldType diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/UserGraphType.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/UserGraphType.cs index 6c45fe0c0..a8df74947 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/UserGraphType.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/UserGraphType.cs @@ -13,7 +13,7 @@ using Squidex.Shared.Users; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types { - internal sealed class UserGraphType : ObjectGraphType + internal sealed class UserGraphType : SharedObjectGraphType { public static readonly IGraphType Nullable = new UserGraphType(); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs index 3b8709881..d63c134a5 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs @@ -11,7 +11,7 @@ using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Contents.Queries { - public abstract class QueryExecutionContext : Dictionary + public abstract class QueryExecutionContext : Dictionary { private readonly SemaphoreSlim maxRequests = new SemaphoreSlim(10); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj b/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj index 466b733e9..27d2888f5 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj +++ b/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj @@ -20,8 +20,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj b/backend/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj index a952ea208..ec123723c 100644 --- a/backend/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj +++ b/backend/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj @@ -18,10 +18,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - diff --git a/backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj b/backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj index 77b6d2ced..30e7febdc 100644 --- a/backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj +++ b/backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj @@ -24,7 +24,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj b/backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj index e44c6ee93..d5fac74ef 100644 --- a/backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj +++ b/backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj @@ -18,8 +18,8 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/backend/src/Squidex.Infrastructure/EventSourcing/Consume/EventConsumerProcessor.cs b/backend/src/Squidex.Infrastructure/EventSourcing/Consume/EventConsumerProcessor.cs index 58d6667a5..aa3147bda 100644 --- a/backend/src/Squidex.Infrastructure/EventSourcing/Consume/EventConsumerProcessor.cs +++ b/backend/src/Squidex.Infrastructure/EventSourcing/Consume/EventConsumerProcessor.cs @@ -43,10 +43,17 @@ namespace Squidex.Infrastructure.EventSourcing.Consume state = new SimpleState(persistenceFactory, GetType(), eventConsumer.Name); } - public virtual Task InitializeAsync( + public virtual async Task InitializeAsync( CancellationToken ct) { - return state.LoadAsync(ct); + await state.LoadAsync(ct); + + if (eventConsumer.StartLatest && string.IsNullOrWhiteSpace(State.Position)) + { + var latest = await eventStore.QueryAllReverseAsync(eventConsumer.EventsFilter, default, 1, ct).FirstOrDefaultAsync(ct); + + State = State.Handled(latest?.EventPosition, 0); + } } public virtual async Task CompleteAsync() diff --git a/backend/src/Squidex.Infrastructure/EventSourcing/Consume/EventConsumerState.cs b/backend/src/Squidex.Infrastructure/EventSourcing/Consume/EventConsumerState.cs index ccbabfcf1..334109806 100644 --- a/backend/src/Squidex.Infrastructure/EventSourcing/Consume/EventConsumerState.cs +++ b/backend/src/Squidex.Infrastructure/EventSourcing/Consume/EventConsumerState.cs @@ -30,7 +30,7 @@ namespace Squidex.Infrastructure.EventSourcing.Consume { } - public EventConsumerState Handled(string position, int offset = 1) + public EventConsumerState Handled(string? position, int offset = 1) { return new EventConsumerState(position, Count + offset); } diff --git a/backend/src/Squidex.Infrastructure/EventSourcing/IEventConsumer.cs b/backend/src/Squidex.Infrastructure/EventSourcing/IEventConsumer.cs index 0292f95fe..6cddd2e7a 100644 --- a/backend/src/Squidex.Infrastructure/EventSourcing/IEventConsumer.cs +++ b/backend/src/Squidex.Infrastructure/EventSourcing/IEventConsumer.cs @@ -17,6 +17,8 @@ namespace Squidex.Infrastructure.EventSourcing string EventsFilter => ".*"; + bool StartLatest => false; + bool CanClear => true; bool Handles(StoredEvent @event) diff --git a/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj b/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj index 07cdd4819..682998398 100644 --- a/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj +++ b/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj @@ -21,14 +21,15 @@ + - - - - - - + + + + + + diff --git a/backend/src/Squidex.Web/GraphQL/DynamicUserContextBuilder.cs b/backend/src/Squidex.Web/GraphQL/DynamicUserContextBuilder.cs index 374bbb17e..968592ebd 100644 --- a/backend/src/Squidex.Web/GraphQL/DynamicUserContextBuilder.cs +++ b/backend/src/Squidex.Web/GraphQL/DynamicUserContextBuilder.cs @@ -17,11 +17,11 @@ namespace Squidex.Web.GraphQL { private readonly ObjectFactory factory = ActivatorUtilities.CreateFactory(typeof(GraphQLExecutionContext), new[] { typeof(Context) }); - public Task> BuildUserContext(HttpContext httpContext) + public ValueTask?> BuildUserContextAsync(HttpContext context, object? payload) { - var executionContext = (GraphQLExecutionContext)factory(httpContext.RequestServices, new object[] { httpContext.Context() }); + var executionContext = (GraphQLExecutionContext)factory(context.RequestServices, new object[] { context.Context() }); - return Task.FromResult>(executionContext); + return new ValueTask?>(executionContext); } } } diff --git a/backend/src/Squidex.Web/GraphQL/GraphQLRunner.cs b/backend/src/Squidex.Web/GraphQL/GraphQLRunner.cs index d8897df5b..6e76e176f 100644 --- a/backend/src/Squidex.Web/GraphQL/GraphQLRunner.cs +++ b/backend/src/Squidex.Web/GraphQL/GraphQLRunner.cs @@ -5,9 +5,9 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using GraphQL; using GraphQL.Server.Transports.AspNetCore; using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; namespace Squidex.Web.GraphQL { @@ -15,14 +15,18 @@ namespace Squidex.Web.GraphQL { private readonly GraphQLHttpMiddleware middleware; - public GraphQLRunner(IGraphQLTextSerializer deserializer) + public GraphQLRunner(IServiceProvider serviceProvider) { - middleware = new GraphQLHttpMiddleware(deserializer); + RequestDelegate next = x => Task.CompletedTask; + + var options = new GraphQLHttpMiddlewareOptions(); + + middleware = ActivatorUtilities.CreateInstance>(serviceProvider, next, options); } public Task InvokeAsync(HttpContext context) { - return middleware.InvokeAsync(context, x => Task.CompletedTask); + return middleware.InvokeAsync(context); } } } diff --git a/backend/src/Squidex.Web/Pipeline/AppResolver.cs b/backend/src/Squidex.Web/Pipeline/AppResolver.cs index 8ecb0c1d0..a6653298d 100644 --- a/backend/src/Squidex.Web/Pipeline/AppResolver.cs +++ b/backend/src/Squidex.Web/Pipeline/AppResolver.cs @@ -45,7 +45,7 @@ namespace Squidex.Web.Pipeline var isFrontend = user.IsInClient(DefaultClients.Frontend); - var app = await appProvider.GetAppAsync(appName, !isFrontend, context.HttpContext.RequestAborted); + var app = await appProvider.GetAppAsync(appName, !isFrontend, default); if (app == null) { diff --git a/backend/src/Squidex.Web/Squidex.Web.csproj b/backend/src/Squidex.Web/Squidex.Web.csproj index 6949953a6..998fe87b5 100644 --- a/backend/src/Squidex.Web/Squidex.Web.csproj +++ b/backend/src/Squidex.Web/Squidex.Web.csproj @@ -13,9 +13,9 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Squidex/Config/Domain/InfrastructureServices.cs b/backend/src/Squidex/Config/Domain/InfrastructureServices.cs index 024570a68..d155983ad 100644 --- a/backend/src/Squidex/Config/Domain/InfrastructureServices.cs +++ b/backend/src/Squidex/Config/Domain/InfrastructureServices.cs @@ -104,9 +104,6 @@ namespace Squidex.Config.Domain services.AddSingletonAs() .As(); - - services.AddSingletonAs() - .As(); } public static void AddSquidexUsageTracking(this IServiceCollection services, IConfiguration config) diff --git a/backend/src/Squidex/Config/Domain/RuleServices.cs b/backend/src/Squidex/Config/Domain/RuleServices.cs index b3a332c70..b8557c43e 100644 --- a/backend/src/Squidex/Config/Domain/RuleServices.cs +++ b/backend/src/Squidex/Config/Domain/RuleServices.cs @@ -9,6 +9,7 @@ using Squidex.Areas.Api.Controllers.Rules.Models; using Squidex.Domain.Apps.Core.HandleRules; using Squidex.Domain.Apps.Core.HandleRules.Extensions; using Squidex.Domain.Apps.Core.Scripting; +using Squidex.Domain.Apps.Core.Subscriptions; using Squidex.Domain.Apps.Core.Templates; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Comments; @@ -34,13 +35,13 @@ namespace Squidex.Config.Domain .As(); services.AddSingletonAs() - .As(); + .As().As(); services.AddSingletonAs() .As(); services.AddSingletonAs() - .As(); + .As().As(); services.AddSingletonAs() .As(); diff --git a/backend/src/Squidex/Config/Messaging/MessagingServices.cs b/backend/src/Squidex/Config/Messaging/MessagingServices.cs index 9db3817f1..07299bc36 100644 --- a/backend/src/Squidex/Config/Messaging/MessagingServices.cs +++ b/backend/src/Squidex/Config/Messaging/MessagingServices.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.Text.Json; +using Squidex.Domain.Apps.Core.Subscriptions; using Squidex.Domain.Apps.Entities.Apps.Plans; using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Backup; @@ -13,10 +14,13 @@ using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Rules; using Squidex.Domain.Apps.Entities.Rules.Runner; using Squidex.Domain.Apps.Entities.Rules.UsageTracking; +using Squidex.Infrastructure.EventSourcing; using Squidex.Infrastructure.EventSourcing.Consume; using Squidex.Messaging; using Squidex.Messaging.Implementation; +using Squidex.Messaging.Implementation.Null; using Squidex.Messaging.Implementation.Scheduler; +using Squidex.Messaging.Subscriptions; namespace Squidex.Config.Messaging { @@ -24,9 +28,14 @@ namespace Squidex.Config.Messaging { public static void AddSquidexMessaging(this IServiceCollection services, IConfiguration config) { - var worker = config.GetValue("clustering:worker"); - - if (worker) + var channelBackupRestore = new ChannelName("backup.restore"); + var channelBackupStart = new ChannelName("backup.start"); + var channelFallback = new ChannelName("default"); + var channelRules = new ChannelName("rules.run"); + var isCaching = config.GetValue("caching:replicated:enable"); + var isWorker = config.GetValue("clustering:worker"); + + if (isWorker) { services.AddSingletonAs() .AsSelf(); @@ -53,34 +62,51 @@ namespace Squidex.Config.Messaging .AsSelf().As(); } - services.AddSingleton(c => + services.AddSingleton(c => new SystemTextJsonTransportSerializer(c.GetRequiredService())); + services.AddSingletonAs() + .As(); + + services.AddSingletonAs() + .As(); + + services.AddReplicatedCacheMessaging(isCaching, options => + { + options.TransportSelector = (transport, _) => transport.First(x => x is NullTransport != isCaching); + }); + + services.Configure(options => + { + options.SendMessagesToSelf = false; + }); + + services.AddMessagingSubscriptions(); services.AddMessagingTransport(config); services.AddMessaging(options => { - options.Routing.Add(m => m is RuleRunnerRun, "rules.run"); - options.Routing.Add(m => m is BackupStart, "backup.start"); - options.Routing.Add(m => m is BackupRestore, "backup.restore"); - options.Routing.Add(_ => true, "default"); + options.Routing.Add(m => m is RuleRunnerRun, channelRules); + options.Routing.Add(m => m is BackupStart, channelBackupStart); + options.Routing.Add(m => m is BackupRestore, channelBackupRestore); + options.Routing.AddFallback(channelFallback); }); - services.AddMessaging("default", worker, options => + services.AddMessaging(channelFallback, isWorker, options => { options.Scheduler = InlineScheduler.Instance; }); - services.AddMessaging("backup.start", worker, options => + services.AddMessaging(channelBackupStart, isWorker, options => { options.Scheduler = new ParallelScheduler(4); }); - services.AddMessaging("backup.restore", worker, options => + services.AddMessaging(channelBackupRestore, isWorker, options => { options.Scheduler = InlineScheduler.Instance; }); - services.AddMessaging("rules.run", worker, options => + services.AddMessaging(channelRules, isWorker, options => { options.Scheduler = new ParallelScheduler(4); }); diff --git a/backend/src/Squidex/Config/Web/WebServices.cs b/backend/src/Squidex/Config/Web/WebServices.cs index 4f39e35dd..4c1d9d375 100644 --- a/backend/src/Squidex/Config/Web/WebServices.cs +++ b/backend/src/Squidex/Config/Web/WebServices.cs @@ -6,11 +6,8 @@ // ========================================================================== using GraphQL; -using GraphQL.DataLoader; using GraphQL.DI; -using GraphQL.MicrosoftDI; using GraphQL.Server.Transports.AspNetCore; -using GraphQL.SystemTextJson; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -101,7 +98,7 @@ namespace Squidex.Config.Web { services.AddGraphQL(builder => { - builder.AddApolloTracing(); + builder.UseApolloTracing(); builder.AddSchema(); builder.AddSystemTextJson(); builder.AddDataLoader(); diff --git a/backend/src/Squidex/Squidex.csproj b/backend/src/Squidex/Squidex.csproj index 5da42afef..035d64168 100644 --- a/backend/src/Squidex/Squidex.csproj +++ b/backend/src/Squidex/Squidex.csproj @@ -35,9 +35,9 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -56,7 +56,7 @@ - + @@ -69,17 +69,19 @@ - - - - - - - - + + + + + + + + - + + + diff --git a/backend/src/Squidex/Startup.cs b/backend/src/Squidex/Startup.cs index c24527b3c..d71b8eed0 100644 --- a/backend/src/Squidex/Startup.cs +++ b/backend/src/Squidex/Startup.cs @@ -76,6 +76,8 @@ namespace Squidex public void Configure(IApplicationBuilder app) { + app.UseWebSockets(); + app.UseCookiePolicy(); app.UseDefaultPathBase(); diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientJsonTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientJsonTests.cs index 50621811c..d0657ba27 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientJsonTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientJsonTests.cs @@ -22,14 +22,10 @@ namespace Squidex.Domain.Apps.Core.Model.Apps clients = clients.Add("2", "my-secret"); clients = clients.Add("3", "my-secret"); clients = clients.Add("4", "my-secret"); - clients = clients.Update("3", role: Role.Editor); - - clients = clients.Update("3", name: "My Client 3"); clients = clients.Update("2", name: "My Client 2"); - + clients = clients.Update("3", name: "My Client 3"); clients = clients.Update("1", allowAnonymous: true, apiCallsLimit: 3); - clients = clients.Revoke("4"); var serialized = clients.SerializeAndDeserialize(); diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/AssetSubscriptionTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/AssetSubscriptionTests.cs new file mode 100644 index 000000000..60793f9e9 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/AssetSubscriptionTests.cs @@ -0,0 +1,111 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; +using Squidex.Domain.Apps.Core.Subscriptions; +using Squidex.Domain.Apps.Events.Apps; +using Squidex.Domain.Apps.Events.Assets; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Security; +using Squidex.Shared; +using Xunit; + +namespace Squidex.Domain.Apps.Core.Operations.Subscriptions +{ + public class AssetSubscriptionTests + { + private readonly NamedId appId = NamedId.Of(DomainId.NewGuid(), "my-app"); + + [Fact] + public async Task Should_return_true_for_enriched_asset_event() + { + var sut = WithPermission(new AssetSubscription()); + + var @event = Enrich(new EnrichedAssetEvent()); + + Assert.True(await sut.ShouldHandle(@event)); + } + + [Fact] + public async Task Should_return_false_for_wrong_event() + { + var sut = WithPermission(new AssetSubscription()); + + var @event = new AppCreated(); + + Assert.False(await sut.ShouldHandle(@event)); + } + + [Fact] + public async Task Should_return_true_for_asset_event() + { + var sut = WithPermission(new AssetSubscription()); + + var @event = Enrich(new AssetCreated()); + + Assert.True(await sut.ShouldHandle(@event)); + } + + [Fact] + public async Task Should_return_true_for_asset_event_with_correct_type() + { + var sut = WithPermission(new AssetSubscription { Type = EnrichedAssetEventType.Created }); + + var @event = Enrich(new AssetCreated()); + + Assert.True(await sut.ShouldHandle(@event)); + } + + [Fact] + public async Task Should_return_false_for_asset_event_with_wrong_type() + { + var sut = WithPermission(new AssetSubscription { Type = EnrichedAssetEventType.Deleted }); + + var @event = Enrich(new AssetCreated()); + + Assert.False(await sut.ShouldHandle(@event)); + } + + [Fact] + public async Task Should_return_false_for_asset_event_invalid_permissions() + { + var sut = WithPermission(new AssetSubscription(), PermissionIds.AppCommentsCreate); + + var @event = Enrich(new AssetCreated()); + + Assert.False(await sut.ShouldHandle(@event)); + } + + private object Enrich(EnrichedAssetEvent source) + { + source.AppId = appId; + + return source; + } + + private object Enrich(AssetEvent source) + { + source.AppId = appId; + + return source; + } + + private AssetSubscription WithPermission(AssetSubscription subscription, string? permissionId = null) + { + subscription.AppId = appId.Id; + + permissionId ??= PermissionIds.AppAssetsRead; + + var permission = PermissionIds.ForApp(permissionId, appId.Name); + var permissions = new PermissionSet(permission); + + subscription.Permissions = permissions; + + return subscription; + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/ContentSubscriptionTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/ContentSubscriptionTests.cs new file mode 100644 index 000000000..3afe8c674 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/ContentSubscriptionTests.cs @@ -0,0 +1,134 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; +using Squidex.Domain.Apps.Core.Subscriptions; +using Squidex.Domain.Apps.Events.Apps; +using Squidex.Domain.Apps.Events.Contents; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Security; +using Squidex.Shared; +using Xunit; + +namespace Squidex.Domain.Apps.Core.Operations.Subscriptions +{ + public class ContentSubscriptionTests + { + private readonly NamedId appId = NamedId.Of(DomainId.NewGuid(), "my-app"); + private readonly NamedId schemaId = NamedId.Of(DomainId.NewGuid(), "my-schema"); + + [Fact] + public async Task Should_return_true_for_enriched_content_event() + { + var sut = WithPermission(new ContentSubscription()); + + var @event = Enrich(new EnrichedContentEvent()); + + Assert.True(await sut.ShouldHandle(@event)); + } + + [Fact] + public async Task Should_return_false_for_wrong_event() + { + var sut = WithPermission(new ContentSubscription()); + + var @event = new AppCreated { AppId = appId }; + + Assert.False(await sut.ShouldHandle(@event)); + } + + [Fact] + public async Task Should_return_true_for_content_event() + { + var sut = WithPermission(new ContentSubscription()); + + var @event = Enrich(new ContentCreated()); + + Assert.True(await sut.ShouldHandle(@event)); + } + + [Fact] + public async Task Should_return_true_for_content_event_with_correct_type() + { + var sut = WithPermission(new ContentSubscription { Type = EnrichedContentEventType.Created }); + + var @event = Enrich(new ContentCreated()); + + Assert.True(await sut.ShouldHandle(@event)); + } + + [Fact] + public async Task Should_return_false_for_content_event_with_wrong_type() + { + var sut = WithPermission(new ContentSubscription { Type = EnrichedContentEventType.Deleted }); + + var @event = Enrich(new ContentCreated()); + + Assert.False(await sut.ShouldHandle(@event)); + } + + [Fact] + public async Task Should_return_true_for_content_event_with_correct_schema() + { + var sut = WithPermission(new ContentSubscription { SchemaName = schemaId.Name }); + + var @event = Enrich(new ContentCreated()); + + Assert.True(await sut.ShouldHandle(@event)); + } + + [Fact] + public async Task Should_return_false_for_content_event_with_wrong_schema() + { + var sut = WithPermission(new ContentSubscription { SchemaName = "wrong-schema" }); + + var @event = Enrich(new ContentCreated()); + + Assert.False(await sut.ShouldHandle(@event)); + } + + [Fact] + public async Task Should_return_false_for_content_event_invalid_permissions() + { + var sut = WithPermission(new ContentSubscription(), PermissionIds.AppCommentsCreate); + + var @event = Enrich(new ContentCreated()); + + Assert.False(await sut.ShouldHandle(@event)); + } + + private object Enrich(EnrichedContentEvent source) + { + source.AppId = appId; + source.SchemaId = schemaId; + + return source; + } + + private object Enrich(ContentEvent source) + { + source.AppId = appId; + source.SchemaId = schemaId; + + return source; + } + + private ContentSubscription WithPermission(ContentSubscription subscription, string? permissionId = null) + { + subscription.AppId = appId.Id; + + permissionId ??= PermissionIds.AppContentsRead; + + var permission = PermissionIds.ForApp(permissionId, appId.Name, schemaId.Name); + var permissions = new PermissionSet(permission); + + subscription.Permissions = permissions; + + return subscription; + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/EventMessageEvaluatorTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/EventMessageEvaluatorTests.cs new file mode 100644 index 000000000..18bcadc9a --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/EventMessageEvaluatorTests.cs @@ -0,0 +1,98 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Subscriptions; +using Squidex.Domain.Apps.Events; +using Squidex.Domain.Apps.Events.Apps; +using Squidex.Domain.Apps.Events.Assets; +using Squidex.Domain.Apps.Events.Contents; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Security; +using Squidex.Shared; +using Xunit; + +namespace Squidex.Domain.Apps.Core.Operations.Subscriptions +{ + public class EventMessageEvaluatorTests + { + private readonly NamedId appId = NamedId.Of(DomainId.NewGuid(), "my-app"); + private readonly NamedId schemaId = NamedId.Of(DomainId.NewGuid(), "my-schema"); + private readonly EventMessageEvaluator sut = new EventMessageEvaluator(); + + [Fact] + public async Task Should_return_empty_list_when_nothing_registered() + { + var assetEvent = new ContentCreated { AppId = NamedId.Of(DomainId.NewGuid(), "my-app2") }; + + var subscriptions = await sut.GetSubscriptionsAsync(assetEvent); + + Assert.Empty(subscriptions); + } + + [Fact] + public async Task Should_return_matching_subscriptions() + { + var contentSubscriptionId = Guid.NewGuid(); + var contentSubscription = WithPermission(new ContentSubscription(), PermissionIds.AppContentsRead); + + var assetSubscriptionId = Guid.NewGuid(); + var assetSubscription = WithPermission(new AssetSubscription(), PermissionIds.AppAssetsRead); + + sut.SubscriptionAdded(contentSubscriptionId, contentSubscription); + sut.SubscriptionAdded(assetSubscriptionId, assetSubscription); + + Assert.Equal(new[] { contentSubscriptionId }, + await sut.GetSubscriptionsAsync(Enrich(new ContentCreated()))); + + Assert.Equal(new[] { assetSubscriptionId }, + await sut.GetSubscriptionsAsync(Enrich(new AssetCreated()))); + + Assert.Empty( + await sut.GetSubscriptionsAsync(Enrich(new AppCreated()))); + + Assert.Empty( + await sut.GetSubscriptionsAsync(new ContentCreated { AppId = NamedId.Of(DomainId.NewGuid(), "my-app2") })); + + sut.SubscriptionRemoved(contentSubscriptionId, contentSubscription); + sut.SubscriptionRemoved(assetSubscriptionId, assetSubscription); + + Assert.Empty( + await sut.GetSubscriptionsAsync(Enrich(new ContentCreated()))); + + Assert.Empty( + await sut.GetSubscriptionsAsync(Enrich(new AssetCreated()))); + } + + private object Enrich(ContentEvent source) + { + source.SchemaId = schemaId; + source.AppId = appId; + + return source; + } + + private object Enrich(AppEvent source) + { + source.Actor = null!; + source.AppId = appId; + + return source; + } + + private AppSubscription WithPermission(AppSubscription subscription, string permissionId) + { + subscription.AppId = appId.Id; + + var permission = PermissionIds.ForApp(permissionId, appId.Name, schemaId.Name); + var permissions = new PermissionSet(permission); + + subscription.Permissions = permissions; + + return subscription; + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/EventMessageWrapperTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/EventMessageWrapperTests.cs new file mode 100644 index 000000000..0e478cc42 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/EventMessageWrapperTests.cs @@ -0,0 +1,67 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using FakeItEasy; +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; +using Squidex.Domain.Apps.Core.Subscriptions; +using Squidex.Domain.Apps.Events; +using Squidex.Domain.Apps.Events.Apps; +using Squidex.Infrastructure.EventSourcing; +using Xunit; + +namespace Squidex.Domain.Apps.Core.Operations.Subscriptions +{ + public class EventMessageWrapperTests + { + private readonly ISubscriptionEventCreator creator1 = A.Fake(); + private readonly ISubscriptionEventCreator creator2 = A.Fake(); + + [Fact] + public async Task Should_return_event_from_first_creator() + { + var enrichedEvent = new EnrichedContentEvent(); + + var envelope = Envelope.Create(new AppCreated()); + + A.CallTo(() => creator1.Handles(envelope.Payload)) + .Returns(true); + + A.CallTo(() => creator1.CreateEnrichedEventsAsync(envelope, default)) + .Returns(null!); + + A.CallTo(() => creator2.Handles(envelope.Payload)) + .Returns(true); + + A.CallTo(() => creator2.CreateEnrichedEventsAsync(envelope, default)) + .Returns(enrichedEvent); + + var sut = new EventMessageWrapper(envelope, new[] { creator1, creator2 }); + + var result = await sut.CreatePayloadAsync(); + + Assert.Same(enrichedEvent, result); + } + + [Fact] + public async Task Should_not_invoke_creator_if_it_does_not_handle_event() + { + var enrichedEvent = new EnrichedContentEvent(); + + var envelope = Envelope.Create(new AppCreated()); + + A.CallTo(() => creator1.Handles(envelope.Payload)) + .Returns(false); + + var sut = new EventMessageWrapper(envelope, new[] { creator1 }); + + Assert.Null(await sut.CreatePayloadAsync()); + + A.CallTo(() => creator1.CreateEnrichedEventsAsync(A>._, A._)) + .MustNotHaveHappened(); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/SubscriptionPublisherTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/SubscriptionPublisherTests.cs new file mode 100644 index 000000000..3be7f422e --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/SubscriptionPublisherTests.cs @@ -0,0 +1,106 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using FakeItEasy; +using Squidex.Domain.Apps.Core.Subscriptions; +using Squidex.Domain.Apps.Events.Apps; +using Squidex.Infrastructure.EventSourcing; +using Squidex.Messaging.Subscriptions; +using Xunit; + +namespace Squidex.Domain.Apps.Core.Operations.Subscriptions +{ + public class SubscriptionPublisherTests + { + private readonly ISubscriptionService subscriptionService = A.Fake(); + private readonly SubscriptionPublisher sut; + + private sealed class MyEvent : IEvent + { + } + + public SubscriptionPublisherTests() + { + sut = new SubscriptionPublisher(subscriptionService, Enumerable.Empty()); + } + + [Fact] + public void Should_return_content_and_asset_filter_for_events_filter() + { + IEventConsumer consumer = sut; + + Assert.Equal("^(content-|asset-)", consumer.EventsFilter); + } + + [Fact] + public async Task Should_do_nothing_on_clear() + { + IEventConsumer consumer = sut; + + await consumer.ClearAsync(); + } + + [Fact] + public void Should_return_custom_name_for_name() + { + IEventConsumer consumer = sut; + + Assert.Equal("Subscriptions", consumer.Name); + } + + [Fact] + public void Should_not_support_clear() + { + IEventConsumer consumer = sut; + + Assert.False(consumer.CanClear); + } + + [Fact] + public void Should_start_from_latest() + { + IEventConsumer consumer = sut; + + Assert.True(consumer.StartLatest); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Should_handle_events_when_subscription_exists(bool hasSubscriptions) + { + A.CallTo(() => subscriptionService.HasSubscriptions) + .Returns(hasSubscriptions); + + IEventConsumer consumer = sut; + + Assert.Equal(hasSubscriptions, consumer.Handles(null!)); + } + + [Fact] + public async Task Should_not_publish_if_not_app_event() + { + var envelope = Envelope.Create(new MyEvent()); + + await sut.On(envelope); + + A.CallTo(subscriptionService) + .MustNotHaveHappened(); + } + + [Fact] + public async Task Should_publish_app_event() + { + var envelope = Envelope.Create(new AppCreated()); + + await sut.On(envelope); + + A.CallTo(subscriptionService).Where(x => x.Method.Name.StartsWith("Publish")) + .MustHaveHappened(); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsIndexTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsIndexTests.cs index c46c0983e..5f7200593 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsIndexTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsIndexTests.cs @@ -7,7 +7,6 @@ using FakeItEasy; using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Squidex.Caching; using Squidex.Domain.Apps.Core.TestHelpers; @@ -19,6 +18,7 @@ using Squidex.Infrastructure.Security; using Squidex.Infrastructure.States; using Squidex.Infrastructure.TestHelpers; using Squidex.Infrastructure.Validation; +using Squidex.Messaging; using Xunit; namespace Squidex.Domain.Apps.Entities.Apps.Indexes @@ -41,7 +41,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes ct = cts.Token; var replicatedCache = - new ReplicatedCache(new MemoryCache(Options.Create(new MemoryCacheOptions())), new SimplePubSub(A.Fake>()), + new ReplicatedCache(new MemoryCache(Options.Create(new MemoryCacheOptions())), A.Fake(), Options.Create(new ReplicatedCacheOptions { Enable = true })); sut = new AppsIndex(appRepository, replicatedCache, state.PersistenceFactory); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLMutationTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLMutationTests.cs index 3d8dacae9..e26d96894 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLMutationTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLMutationTests.cs @@ -6,7 +6,6 @@ // ========================================================================== using System.Text.Json; -using System.Text.RegularExpressions; using FakeItEasy; using GraphQL; using NodaTime.Text; @@ -44,7 +43,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL } }"; - var result = await ExecuteAsync(new ExecutionOptions { Query = query }, PermissionIds.AppContentsReadOwn); + var result = await ExecuteAsync(new ExecutionOptions { Query = query }); var expected = new { @@ -82,13 +81,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var query = CreateQuery(@" mutation { createMySchemaContent(data: , publish: true) { - + } - }"); + }", contentId, content); commandContext.Complete(content); - var result = await ExecuteAsync(new ExecutionOptions { Query = query }, PermissionIds.AppContentsCreate); + var permission = PermissionIds.AppContentsCreate; + + var result = await ExecuteAsync(new ExecutionOptions { Query = query }, permission); var expected = new { @@ -116,13 +117,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var query = CreateQuery(@" mutation { createMySchemaContent(data: , id: '123', publish: true) { - + } - }"); + }", contentId, content); commandContext.Complete(content); - var result = await ExecuteAsync(new ExecutionOptions { Query = query }, PermissionIds.AppContentsCreate); + var permission = PermissionIds.AppContentsCreate; + + var result = await ExecuteAsync(new ExecutionOptions { Query = query }, permission); var expected = new { @@ -151,13 +154,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var query = CreateQuery(@" mutation OP($data: MySchemaDataInputDto!) { createMySchemaContent(data: $data, publish: true) { - + } - }"); + }", contentId, content); commandContext.Complete(content); - var result = await ExecuteAsync(new ExecutionOptions { Query = query, Variables = GetInput() }, PermissionIds.AppContentsCreate); + var permission = PermissionIds.AppContentsCreate; + + var result = await ExecuteAsync(new ExecutionOptions { Query = query, Variables = GetInput() }, permission); var expected = new { @@ -187,9 +192,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL updateMySchemaContent(id: '', data: { myNumber: { iv: 42 } }) { id } - }"); + }", contentId, content); - var result = await ExecuteAsync(new ExecutionOptions { Query = query }, PermissionIds.AppContentsReadOwn); + var result = await ExecuteAsync(new ExecutionOptions { Query = query }); var expected = new { @@ -227,13 +232,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var query = CreateQuery(@" mutation { updateMySchemaContent(id: '', data: , expectedVersion: 10) { - + } - }"); + }", contentId, content); commandContext.Complete(content); - var result = await ExecuteAsync(new ExecutionOptions { Query = query }, PermissionIds.AppContentsUpdateOwn); + var permission = PermissionIds.AppContentsUpdateOwn; + + var result = await ExecuteAsync(new ExecutionOptions { Query = query }, permission); var expected = new { @@ -261,13 +268,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var query = CreateQuery(@" mutation OP($data: MySchemaDataInputDto!) { updateMySchemaContent(id: '', data: $data, expectedVersion: 10) { - + } - }"); + }", contentId, content); commandContext.Complete(content); - var result = await ExecuteAsync(new ExecutionOptions { Query = query, Variables = GetInput() }, PermissionIds.AppContentsUpdateOwn); + var permission = PermissionIds.AppContentsUpdateOwn; + + var result = await ExecuteAsync(new ExecutionOptions { Query = query, Variables = GetInput() }, permission); var expected = new { @@ -297,9 +306,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL upsertMySchemaContent(id: '', data: { myNumber: { iv: 42 } }) { id } - }"); + }", contentId, content); - var result = await ExecuteAsync(new ExecutionOptions { Query = query }, PermissionIds.AppContentsReadOwn); + var result = await ExecuteAsync(new ExecutionOptions { Query = query }); var expected = new { @@ -337,13 +346,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var query = CreateQuery(@" mutation { upsertMySchemaContent(id: '', data: , publish: true, expectedVersion: 10) { - + } - }"); + }", contentId, content); commandContext.Complete(content); - var result = await ExecuteAsync(new ExecutionOptions { Query = query }, PermissionIds.AppContentsUpsert); + var permission = PermissionIds.AppContentsUpsert; + + var result = await ExecuteAsync(new ExecutionOptions { Query = query }, permission); var expected = new { @@ -372,13 +383,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var query = CreateQuery(@" mutation OP($data: MySchemaDataInputDto!) { upsertMySchemaContent(id: '', data: $data, publish: true, expectedVersion: 10) { - + } - }"); + }", contentId, content); commandContext.Complete(content); - var result = await ExecuteAsync(new ExecutionOptions { Query = query, Variables = GetInput() }, PermissionIds.AppContentsUpsert); + var permission = PermissionIds.AppContentsUpsert; + + var result = await ExecuteAsync(new ExecutionOptions { Query = query, Variables = GetInput() }, permission); var expected = new { @@ -409,9 +422,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL patchMySchemaContent(id: '', data: { myNumber: { iv: 42 } }) { id } - }"); + }", contentId, content); - var result = await ExecuteAsync(new ExecutionOptions { Query = query }, PermissionIds.AppContentsReadOwn); + var result = await ExecuteAsync(new ExecutionOptions { Query = query }); var expected = new { @@ -449,13 +462,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var query = CreateQuery(@" mutation { patchMySchemaContent(id: '', data: , expectedVersion: 10) { - + } - }"); + }", contentId, content); commandContext.Complete(content); - var result = await ExecuteAsync(new ExecutionOptions { Query = query }, PermissionIds.AppContentsUpdateOwn); + var permission = PermissionIds.AppContentsUpdateOwn; + + var result = await ExecuteAsync(new ExecutionOptions { Query = query }, permission); var expected = new { @@ -483,13 +498,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var query = CreateQuery(@" mutation OP($data: MySchemaDataInputDto!) { patchMySchemaContent(id: '', data: $data, expectedVersion: 10) { - + } - }"); + }", contentId, content); commandContext.Complete(content); - var result = await ExecuteAsync(new ExecutionOptions { Query = query, Variables = GetInput() }, PermissionIds.AppContentsUpdateOwn); + var permission = PermissionIds.AppContentsUpdateOwn; + + var result = await ExecuteAsync(new ExecutionOptions { Query = query, Variables = GetInput() }, permission); var expected = new { @@ -519,9 +536,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL changeMySchemaContent(id: '', status: 'Published') { id } - }"); + }", contentId, content); - var result = await ExecuteAsync(new ExecutionOptions { Query = query }, PermissionIds.AppContentsReadOwn); + var result = await ExecuteAsync(new ExecutionOptions { Query = query }); var expected = new { @@ -561,13 +578,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var query = CreateQuery(@" mutation { changeMySchemaContent(id: '', status: 'Published', dueTime: '2021-12-12T11:10:09Z', expectedVersion: 10) { - + } - }"); + }", contentId, content); commandContext.Complete(content); - var result = await ExecuteAsync(new ExecutionOptions { Query = query }, PermissionIds.AppContentsChangeStatusOwn); + var permission = PermissionIds.AppContentsChangeStatusOwn; + + var result = await ExecuteAsync(new ExecutionOptions { Query = query }, permission); var expected = new { @@ -596,13 +615,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var query = CreateQuery(@" mutation { changeMySchemaContent(id: '', status: 'Published', expectedVersion: 10) { - + } - }"); + }", contentId, content); commandContext.Complete(content); - var result = await ExecuteAsync(new ExecutionOptions { Query = query }, PermissionIds.AppContentsChangeStatusOwn); + var permission = PermissionIds.AppContentsChangeStatusOwn; + + var result = await ExecuteAsync(new ExecutionOptions { Query = query }, permission); var expected = new { @@ -631,13 +652,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var query = CreateQuery(@" mutation { changeMySchemaContent(id: '', status: 'Published', dueTime: null, expectedVersion: 10) { - + } - }"); + }", contentId, content); commandContext.Complete(content); - var result = await ExecuteAsync(new ExecutionOptions { Query = query }, PermissionIds.AppContentsChangeStatusOwn); + var permission = PermissionIds.AppContentsChangeStatusOwn; + + var result = await ExecuteAsync(new ExecutionOptions { Query = query }, permission); var expected = new { @@ -668,9 +691,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL deleteMySchemaContent(id: '') { version } - }"); + }", contentId, content); - var result = await ExecuteAsync(new ExecutionOptions { Query = query }, PermissionIds.AppContentsReadOwn); + var result = await ExecuteAsync(new ExecutionOptions { Query = query }); var expected = new { @@ -710,11 +733,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL deleteMySchemaContent(id: '', expectedVersion: 10) { version } - }"); + }", contentId, content); commandContext.Complete(CommandResult.Empty(contentId, 13, 12)); - var result = await ExecuteAsync(new ExecutionOptions { Query = query }, PermissionIds.AppContentsDeleteOwn); + var permission = PermissionIds.AppContentsDeleteOwn; + + var result = await ExecuteAsync(new ExecutionOptions { Query = query }, permission); var expected = new { @@ -738,36 +763,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL .MustHaveHappened(); } - private string CreateQuery(string query) - { - query = query - .Replace("'", "\"", StringComparison.Ordinal) - .Replace("`", "\"", StringComparison.Ordinal) - .Replace("", contentId.ToString(), StringComparison.Ordinal) - .Replace("", TestContent.AllFields, StringComparison.Ordinal); - - if (query.Contains("", StringComparison.Ordinal)) - { - var data = TestContent.Input(content, TestSchemas.Ref1.Id, TestSchemas.Ref2.Id); - - var dataJson = TestUtils.DefaultSerializer.Serialize(data, true); - - // Use Properties without quotes. - dataJson = Regex.Replace(dataJson, "\"([^\"]+)\":", x => x.Groups[1].Value + ":"); - - // Use pure integer numbers. - dataJson = dataJson.Replace(".0", string.Empty, StringComparison.Ordinal); - - // Use enum values whithout quotes. - dataJson = dataJson.Replace("\"EnumA\"", "EnumA", StringComparison.Ordinal); - dataJson = dataJson.Replace("\"EnumB\"", "EnumB", StringComparison.Ordinal); - - query = query.Replace("", dataJson, StringComparison.Ordinal); - } - - return query; - } - private Inputs GetInput() { var input = new diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs index 312545f66..c62818625 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs @@ -1148,34 +1148,5 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL Assert.Contains("\"errors\"", json, StringComparison.Ordinal); } - - private static string CreateQuery(string query, DomainId id = default) - { - return query - .Replace("'", "\"", StringComparison.Ordinal) - .Replace("`", "\"", StringComparison.Ordinal) - .Replace("", id.ToString(), StringComparison.Ordinal) - .Replace("", TestAsset.AllFields, StringComparison.Ordinal) - .Replace("", TestContent.AllFields, StringComparison.Ordinal) - .Replace("", TestContent.AllFlatFields, StringComparison.Ordinal); - } - - private Context MatchsAssetContext() - { - return A.That.Matches(x => - x.App == TestApp.Default && - x.ShouldSkipCleanup() && - x.ShouldSkipContentEnrichment() && - x.User == requestContext.User); - } - - private Context MatchsContentContext() - { - return A.That.Matches(x => - x.App == TestApp.Default && - x.ShouldSkipCleanup() && - x.ShouldSkipContentEnrichment() && - x.User == requestContext.User); - } } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLSubscriptionTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLSubscriptionTests.cs new file mode 100644 index 000000000..2622fb9c0 --- /dev/null +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLSubscriptionTests.cs @@ -0,0 +1,202 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Reactive.Linq; +using FakeItEasy; +using GraphQL; +using Squidex.Domain.Apps.Core.Contents; +using Squidex.Domain.Apps.Core.Rules.EnrichedEvents; +using Squidex.Domain.Apps.Core.Subscriptions; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Security; +using Squidex.Shared; +using Xunit; + +namespace Squidex.Domain.Apps.Entities.Contents.GraphQL +{ + public class GraphQLSubscriptionTests : GraphQLTestBase + { + [Fact] + public async Task Should_subscribe_to_assets() + { + var id = DomainId.NewGuid(); + + var query = CreateQuery(@" + subscription { + assetChanges { + id, + fileName, + fileSize + } + }"); + + var stream = + Observable.Return( + new EnrichedAssetEvent + { + Id = id, + FileName = "image.png", + FileSize = 1024 + }); + + A.CallTo(() => subscriptionService.Subscribe(A._)) + .Returns(stream); + + var permission = PermissionIds.ForApp(PermissionIds.AppAssetsRead, TestApp.Default.Name); + + var result = await ExecuteAsync(new ExecutionOptions { Query = query }, permission.Id); + + var expected = new + { + data = new + { + assetChanges = new + { + id, + fileName = "image.png", + fileSize = 1024 + } + } + }; + + AssertResult(expected, result); + } + + [Fact] + public async Task Should_return_error_if_user_has_no_permissions_for_assets() + { + var query = CreateQuery(@" + subscription { + assetChanges { + id, + fileName, + fileSize + } + }"); + + var result = await ExecuteAsync(new ExecutionOptions { Query = query }); + + var expected = new + { + errors = new[] + { + new + { + message = "You do not have the necessary permission.", + locations = new[] + { + new + { + line = 3, + column = 19 + } + }, + path = new[] + { + "assetChanges" + } + } + }, + data = (object?)null + }; + + AssertResult(expected, result); + } + + [Fact] + public async Task Should_subscribe_to_contents() + { + var id = DomainId.NewGuid(); + + var query = CreateQuery(@" + subscription { + contentChanges { + id, + data + } + }"); + + var stream = + Observable.Return( + new EnrichedContentEvent + { + Id = id, + Data = new ContentData() + .AddField("field", + new ContentFieldData() + .AddInvariant(42)) + }); + + A.CallTo(() => subscriptionService.Subscribe(A._)) + .Returns(stream); + + var permission = PermissionIds.ForApp(PermissionIds.AppContentsRead, TestApp.Default.Name, "random-schema"); + + var result = await ExecuteAsync(new ExecutionOptions { Query = query }, permission.Id); + + var expected = new + { + data = new + { + contentChanges = new + { + id, + data = new + { + field = new + { + iv = 42 + } + } + } + } + }; + + AssertResult(expected, result); + } + + [Fact] + public async Task Should_return_error_if_user_has_no_permissions_for_contents() + { + var query = CreateQuery(@" + subscription { + contentChanges { + id, + data + } + }"); + + var result = await ExecuteAsync(new ExecutionOptions { Query = query }); + + var expected = new + { + errors = new[] + { + new + { + message = "You do not have the necessary permission.", + locations = new[] + { + new + { + line = 3, + column = 19 + } + }, + path = new[] + { + "contentChanges" + } + } + }, + data = (object?)null + }; + + AssertResult(expected, result); + } + } +} diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs index 858edfe84..2a428f234 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs @@ -5,25 +5,27 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Reactive.Linq; +using System.Reactive.Threading.Tasks; +using System.Text.RegularExpressions; using FakeItEasy; using GraphQL; using GraphQL.DataLoader; using GraphQL.Execution; using GraphQL.SystemTextJson; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Squidex.Caching; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.ExtractReferenceIds; using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Entities.Assets; -using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Primitives; using Squidex.Domain.Apps.Entities.Contents.TestData; using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.TestHelpers; +using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; +using Squidex.Infrastructure.Tasks; +using Squidex.Messaging.Subscriptions; using Squidex.Shared; using Squidex.Shared.Users; using Xunit; @@ -32,17 +34,18 @@ using Xunit; namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { - public class GraphQLTestBase : IClassFixture + public abstract class GraphQLTestBase : IClassFixture { protected readonly GraphQLSerializer serializer = new GraphQLSerializer(TestUtils.DefaultOptions()); protected readonly IAssetQueryService assetQuery = A.Fake(); protected readonly ICommandBus commandBus = A.Fake(); protected readonly IContentQueryService contentQuery = A.Fake(); + protected readonly ISubscriptionService subscriptionService = A.Fake(); protected readonly IUserResolver userResolver = A.Fake(); protected readonly Context requestContext; - private CachingGraphQLResolver sut; + private CachingGraphQLResolver? sut; - public GraphQLTestBase() + protected GraphQLTestBase() { A.CallTo(() => userResolver.QueryManyAsync(A._, default)) .ReturnsLazily(x => @@ -65,40 +68,60 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL Assert.Equal(isonOutputExpected, jsonOutputResult); } - protected Task ExecuteAsync(ExecutionOptions options, string? permissionId = null) + protected Task ExecuteAsync(ExecutionOptions options) { - var context = requestContext; - - if (permissionId != null) - { - var permission = PermissionIds.ForApp(permissionId, TestApp.Default.Name, TestSchemas.DefaultId.Name).Id; - - context = new Context(Mocks.FrontendUser(permission: permission), TestApp.Default); - } + return ExecuteCoreAsync(options, requestContext); + } - return ExcecuteAsync(options, context); + protected Task ExecuteAsync(ExecutionOptions options, string permissionId) + { + return ExecuteCoreAsync(options, BuildContext(permissionId)); } - private async Task ExcecuteAsync(ExecutionOptions options, Context context) + protected async Task ExecuteCoreAsync(ExecutionOptions options, Context context) { + // Use a shared instance to test caching. sut ??= CreateSut(TestSchemas.Default, TestSchemas.Ref1, TestSchemas.Ref2); - options.UserContext = ActivatorUtilities.CreateInstance(sut.Services, context)!; + // Provide the context to the test if services need to be resolved. + var graphQLContext = ActivatorUtilities.CreateInstance(sut.Services, context)!; + + options.UserContext = graphQLContext; + // Register data loader and other listeners. foreach (var listener in sut.Services.GetRequiredService>()) { options.Listeners.Add(listener); } + // Enrich the context with the schema. await sut.ExecuteAsync(options, x => Task.FromResult(null!)); - return await new DocumentExecuter().ExecuteAsync(options); + var result = await new DocumentExecuter().ExecuteAsync(options); + + if (result.Streams?.Count > 0 && result.Errors?.Any() != true) + { + // Resolve the first stream result with a timeout. + var stream = result.Streams.First(); + + using (var cts = new CancellationTokenSource(5000)) + { + result = await stream.Value.FirstAsync().ToTask().WithCancellation(cts.Token); + } + } + + return result; } - protected CachingGraphQLResolver CreateSut(params ISchemaEntity[] schemas) + private static Context BuildContext(string permissionId) { - var cache = new BackgroundCache(new MemoryCache(Options.Create(new MemoryCacheOptions()))); + var permission = PermissionIds.ForApp(permissionId, TestApp.Default.Name, TestSchemas.DefaultId.Name).Id; + return new Context(Mocks.FrontendUser(permission: permission), TestApp.Default); + } + + protected CachingGraphQLResolver CreateSut(params ISchemaEntity[] schemas) + { var appProvider = A.Fake(); A.CallTo(() => appProvider.GetSchemasAsync(TestApp.Default.Id, default)) @@ -106,8 +129,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL var serviceProvider = new ServiceCollection() + .AddLogging() .AddMemoryCache() - .AddTransient() + .AddBackgroundCache() .Configure(x => { x.CanCache = true; @@ -116,6 +140,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL { x.CanCache = true; }) + .AddSingleton() .AddSingleton() .AddSingleton() .AddSingleton() - .AddSingleton(A.Fake()) + .AddSingleton( + A.Fake()) + .AddSingleton( + A.Fake()) .AddSingleton(appProvider) .AddSingleton(assetQuery) .AddSingleton(commandBus) .AddSingleton(contentQuery) + .AddSingleton(subscriptionService) .AddSingleton(userResolver) - .AddSingleton() - .AddSingleton() - .AddSingleton() - .AddSingleton() .BuildServiceProvider(); - var schemasHash = A.Fake(); + return ActivatorUtilities.CreateInstance(serviceProvider); + } + + protected static string CreateQuery(string query, DomainId id = default, IEnrichedContentEntity? content = null) + { + query = query + .Replace("'", "\"", StringComparison.Ordinal) + .Replace("`", "\"", StringComparison.Ordinal) + .Replace("", TestAsset.AllFields, StringComparison.Ordinal) + .Replace("", TestContent.AllFields, StringComparison.Ordinal) + .Replace("", TestContent.AllFlatFields, StringComparison.Ordinal); + + if (id != default) + { + query = query.Replace("", id.ToString(), StringComparison.Ordinal); + } + + if (query.Contains("", StringComparison.Ordinal) && content != null) + { + var data = TestContent.Input(content, TestSchemas.Ref1.Id, TestSchemas.Ref2.Id); + + // Json is not the same as the input format of graphql, therefore we need to convert it. + var dataJson = TestUtils.DefaultSerializer.Serialize(data, true); + + // Use properties without quotes. + dataJson = Regex.Replace(dataJson, "\"([^\"]+)\":", x => $"{x.Groups[1].Value}:"); - return new CachingGraphQLResolver(cache, schemasHash, serviceProvider, Options.Create(new GraphQLOptions())); + // Use enum values whithout quotes. + dataJson = Regex.Replace(dataJson, "\"Enum([A-Za-z]+)\"", x => $"Enum{x.Groups[1].Value}"); + + query = query.Replace("", dataJson, StringComparison.Ordinal); + } + + return query; + } + + protected Context MatchsAssetContext() + { + return A.That.Matches(x => + x.App == TestApp.Default && + x.ShouldSkipCleanup() && + x.ShouldSkipContentEnrichment() && + x.User == requestContext.User); + } + + protected Context MatchsContentContext() + { + return A.That.Matches(x => + x.App == TestApp.Default && + x.ShouldSkipCleanup() && + x.ShouldSkipContentEnrichment() && + x.User == requestContext.User); } } } diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasIndexTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasIndexTests.cs index 82d97fcf3..cb7ffe859 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasIndexTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasIndexTests.cs @@ -7,7 +7,6 @@ using FakeItEasy; using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Squidex.Caching; using Squidex.Domain.Apps.Core.Schemas; @@ -18,6 +17,7 @@ using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.States; using Squidex.Infrastructure.TestHelpers; using Squidex.Infrastructure.Validation; +using Squidex.Messaging; using Xunit; namespace Squidex.Domain.Apps.Entities.Schemas.Indexes @@ -40,7 +40,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes ct = cts.Token; var replicatedCache = - new ReplicatedCache(new MemoryCache(Options.Create(new MemoryCacheOptions())), new SimplePubSub(A.Fake>()), + new ReplicatedCache(new MemoryCache(Options.Create(new MemoryCacheOptions())), A.Fake(), Options.Create(new ReplicatedCacheOptions { Enable = true })); sut = new SchemasIndex(schemaRepository, replicatedCache, state.PersistenceFactory); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj index fa7db82fe..a2c454bdd 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj @@ -23,8 +23,8 @@ - - + + all @@ -37,6 +37,7 @@ + diff --git a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/Consume/EventConsumerProcessorTests.cs b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/Consume/EventConsumerProcessorTests.cs index 9492b4759..8446d77e6 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/Consume/EventConsumerProcessorTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/EventSourcing/Consume/EventConsumerProcessorTests.cs @@ -99,6 +99,27 @@ namespace Squidex.Infrastructure.EventSourcing.Consume A.Fake>()); } + [Fact] + public async Task Should_query_position_if_consumer_should_start_from_latest() + { + state.Snapshot = new EventConsumerState(); + + A.CallTo(() => eventConsumer.StartLatest) + .Returns(true); + + A.CallTo(() => eventConsumer.EventsFilter) + .Returns("my-filter"); + + var latestPosition = "LATEST"; + + A.CallTo(() => eventStore.QueryAllReverseAsync("my-filter", default, 1, A._)) + .Returns(Enumerable.Repeat(new StoredEvent("Stream", latestPosition, 1, eventData), 1).ToAsyncEnumerable()); + + await sut.InitializeAsync(default); + + AssertGrainState(isStopped: false, position: latestPosition); + } + [Fact] public async Task Should_not_subscribe_to_event_store_if_stopped_in_db() { diff --git a/backend/tests/Squidex.Infrastructure.Tests/Security/PermissionSetTests.cs b/backend/tests/Squidex.Infrastructure.Tests/Security/PermissionSetTests.cs index 333d24e6d..f0815acbc 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Security/PermissionSetTests.cs +++ b/backend/tests/Squidex.Infrastructure.Tests/Security/PermissionSetTests.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.Collections; +using Squidex.Infrastructure.TestHelpers; using Xunit; namespace Squidex.Infrastructure.Security @@ -131,5 +132,15 @@ namespace Squidex.Infrastructure.Security Assert.True(sut.Includes(new Permission("admin"))); } + + [Fact] + public void Should_serialize_and_deserialize() + { + var permissions = new PermissionSet("a", "b", "c"); + + var serialized = permissions.SerializeAndDeserialize(); + + Assert.Equal(permissions, serialized); + } } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d7f9875f8..5d445b68c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -22,6 +22,7 @@ "@angular/router": "13.1.1", "@babel/runtime": "^7.16.7", "@egjs/hammerjs": "2.0.17", + "@graphiql/toolkit": "^0.6.1", "ace-builds": "1.4.13", "angular-gridster2": "11.2.0", "angular-mentions": "1.4.0", @@ -4086,6 +4087,27 @@ "integrity": "sha512-82cpyJyKRoQoRi+14ibCeGPu0CwypgtBAdBhq1WfvagpCZNKqwXbKwXllYSMG91DhmG4jt9gN8eP6lGOtozuaw==", "dev": true }, + "node_modules/@graphiql/toolkit": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@graphiql/toolkit/-/toolkit-0.6.1.tgz", + "integrity": "sha512-rRjbHko6aSg1RWGr3yOJQqEV1tKe8yw9mDSr/18B+eDhVLQ30yyKk2NznFUT9NmIDzWFGR2pH/0lbBhHKmUCqw==", + "dependencies": { + "@n1ru4l/push-pull-async-iterable-iterator": "^3.1.0", + "meros": "^1.1.4" + }, + "peerDependencies": { + "graphql": "^15.5.0 || ^16.0.0", + "graphql-ws": ">= 4.5.0" + } + }, + "node_modules/@graphiql/toolkit/node_modules/@n1ru4l/push-pull-async-iterable-iterator": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@n1ru4l/push-pull-async-iterable-iterator/-/push-pull-async-iterable-iterator-3.2.0.tgz", + "integrity": "sha512-3fkKj25kEjsfObL6IlKPAlHYPq/oYwUkkQ03zsTTiDjD7vg/RxjdiLeCydqtxHZP0JgsXL3D/X5oAkMGzuUp/Q==", + "engines": { + "node": ">=12" + } + }, "node_modules/@graphql-tools/batch-execute": { "version": "8.3.1", "resolved": "https://registry.npmjs.org/@graphql-tools/batch-execute/-/batch-execute-8.3.1.tgz", @@ -39543,6 +39565,22 @@ "integrity": "sha512-82cpyJyKRoQoRi+14ibCeGPu0CwypgtBAdBhq1WfvagpCZNKqwXbKwXllYSMG91DhmG4jt9gN8eP6lGOtozuaw==", "dev": true }, + "@graphiql/toolkit": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@graphiql/toolkit/-/toolkit-0.6.1.tgz", + "integrity": "sha512-rRjbHko6aSg1RWGr3yOJQqEV1tKe8yw9mDSr/18B+eDhVLQ30yyKk2NznFUT9NmIDzWFGR2pH/0lbBhHKmUCqw==", + "requires": { + "@n1ru4l/push-pull-async-iterable-iterator": "^3.1.0", + "meros": "^1.1.4" + }, + "dependencies": { + "@n1ru4l/push-pull-async-iterable-iterator": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@n1ru4l/push-pull-async-iterable-iterator/-/push-pull-async-iterable-iterator-3.2.0.tgz", + "integrity": "sha512-3fkKj25kEjsfObL6IlKPAlHYPq/oYwUkkQ03zsTTiDjD7vg/RxjdiLeCydqtxHZP0JgsXL3D/X5oAkMGzuUp/Q==" + } + } + }, "@graphql-tools/batch-execute": { "version": "8.3.1", "resolved": "https://registry.npmjs.org/@graphql-tools/batch-execute/-/batch-execute-8.3.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index c6d12bb8a..8bae3802f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -29,6 +29,7 @@ "@angular/router": "13.1.1", "@babel/runtime": "^7.16.7", "@egjs/hammerjs": "2.0.17", + "@graphiql/toolkit": "^0.6.1", "ace-builds": "1.4.13", "angular-gridster2": "11.2.0", "angular-mentions": "1.4.0", diff --git a/frontend/src/app/features/api/pages/graphql/graphql-page.component.ts b/frontend/src/app/features/api/pages/graphql/graphql-page.component.ts index 4c5674e26..799db89ff 100644 --- a/frontend/src/app/features/api/pages/graphql/graphql-page.component.ts +++ b/frontend/src/app/features/api/pages/graphql/graphql-page.component.ts @@ -6,12 +6,11 @@ */ import { AfterViewInit, Component, ElementRef, ViewChild } from '@angular/core'; +import { createGraphiQLFetcher } from '@graphiql/toolkit'; import GraphiQL from 'graphiql'; import * as React from 'react'; import * as ReactDOM from 'react-dom'; -import { firstValueFrom, of } from 'rxjs'; -import { catchError } from 'rxjs/operators'; -import { AppsState, GraphQlService } from '@app/shared'; +import { ApiUrlConfig, AppsState, AuthService } from '@app/shared'; @Component({ selector: 'sqx-graphql-page', @@ -24,23 +23,33 @@ export class GraphQLPageComponent implements AfterViewInit { constructor( private readonly appsState: AppsState, - private readonly graphQlService: GraphQlService, + private readonly apiUrl: ApiUrlConfig, + private readonly authService: AuthService, ) { } public ngAfterViewInit() { + const url = this.apiUrl.buildUrl(`api/content/${this.appsState.appName}/graphql`); + + const subscriptionUrl = + url + .replace('http://', 'ws://') + .replace('https://', 'wss://') + + `?access_token=${this.authService.user?.accessToken}`; + + const fetcher = createGraphiQLFetcher({ + url, + subscriptionUrl, + headers: { + Authorization: `Bearer ${this.authService.user?.accessToken}`, + }, + }); + ReactDOM.render( React.createElement(GraphiQL, { - fetcher: (params: any) => { - return firstValueFrom(this.request(params)); - }, + fetcher, }), this.graphiQLContainer.nativeElement, ); } - - private request(params: any) { - return this.graphQlService.query(this.appsState.appName, params).pipe( - catchError(response => of(response.error))); - } } diff --git a/frontend/src/app/shared/internal.ts b/frontend/src/app/shared/internal.ts index 082151f55..bcaa8ea96 100644 --- a/frontend/src/app/shared/internal.ts +++ b/frontend/src/app/shared/internal.ts @@ -17,7 +17,6 @@ export * from './services/clients.service'; export * from './services/comments.service'; export * from './services/contents.service'; export * from './services/contributors.service'; -export * from './services/graphql.service'; export * from './services/help.service'; export * from './services/history.service'; export * from './services/languages.service'; diff --git a/frontend/src/app/shared/module.ts b/frontend/src/app/shared/module.ts index 7f2aa4c6f..224f28eb9 100644 --- a/frontend/src/app/shared/module.ts +++ b/frontend/src/app/shared/module.ts @@ -12,7 +12,7 @@ import { RouterModule } from '@angular/router'; import { MentionModule } from 'angular-mentions'; import { NgxDocViewerModule } from 'ngx-doc-viewer'; import { SqxFrameworkModule } from '@app/framework'; -import { AppFormComponent, AppLanguagesService, AppMustExistGuard, AppsService, AppsState, AssetComponent, AssetDialogComponent, AssetFolderComponent, AssetFolderDialogComponent, AssetFolderDropdownComponent, AssetFolderDropdownItemComponent, AssetHistoryComponent, AssetPathComponent, AssetPreviewUrlPipe, AssetScriptsState, AssetsListComponent, AssetsSelectorComponent, AssetsService, AssetsState, AssetTextEditorComponent, AssetUploaderComponent, AssetUploaderState, AssetUrlPipe, AuthInterceptor, AuthService, AutoSaveService, BackupsService, BackupsState, ClientsService, ClientsState, CommentComponent, CommentsComponent, CommentsService, ContentListCellDirective, ContentListCellResizeDirective, ContentListFieldComponent, ContentListHeaderComponent, ContentListWidthDirective, ContentMustExistGuard, ContentsColumnsPipe, ContentSelectorComponent, ContentSelectorItemComponent, ContentsService, ContentsState, ContentStatusComponent, ContentValueComponent, ContentValueEditorComponent, ContributorsService, ContributorsState, FileIconPipe, FilterComparisonComponent, FilterLogicalComponent, FilterNodeComponent, FilterOperatorPipe, GeolocationEditorComponent, GraphQlService, HelpComponent, HelpMarkdownPipe, HelpService, HistoryComponent, HistoryListComponent, HistoryMessagePipe, HistoryService, ImageCropperComponent, ImageFocusPointComponent, LanguagesService, LanguagesState, LoadAppsGuard, LoadLanguagesGuard, LoadSchemasGuard, MarkdownEditorComponent, MustBeAuthenticatedGuard, MustBeNotAuthenticatedGuard, NewsService, NotifoComponent, PlansService, PlansState, PreviewableType, QueryComponent, QueryListComponent, QueryPathComponent, ReferenceInputComponent, RichEditorComponent, RolesService, RolesState, RuleEventsState, RuleMustExistGuard, RuleSimulatorState, RulesService, RulesState, SavedQueriesComponent, SchemaCategoryComponent, SchemaMustExistGuard, SchemaMustExistPublishedGuard, SchemaMustNotBeSingletonGuard, SchemasService, SchemasState, SchemaTagSource, SearchFormComponent, SearchService, SortingComponent, StockPhotoService, TableHeaderComponent, TemplatesService, TemplatesState, TranslationsService, UIService, UIState, UnsetAppGuard, UsagesService, UserDtoPicture, UserIdPicturePipe, UserNamePipe, UserNameRefPipe, UserPicturePipe, UserPictureRefPipe, UsersProviderService, UsersService, WatchingUsersComponent, WorkflowsService, WorkflowsState } from './declarations'; +import { AppFormComponent, AppLanguagesService, AppMustExistGuard, AppsService, AppsState, AssetComponent, AssetDialogComponent, AssetFolderComponent, AssetFolderDialogComponent, AssetFolderDropdownComponent, AssetFolderDropdownItemComponent, AssetHistoryComponent, AssetPathComponent, AssetPreviewUrlPipe, AssetScriptsState, AssetsListComponent, AssetsSelectorComponent, AssetsService, AssetsState, AssetTextEditorComponent, AssetUploaderComponent, AssetUploaderState, AssetUrlPipe, AuthInterceptor, AuthService, AutoSaveService, BackupsService, BackupsState, ClientsService, ClientsState, CommentComponent, CommentsComponent, CommentsService, ContentListCellDirective, ContentListCellResizeDirective, ContentListFieldComponent, ContentListHeaderComponent, ContentListWidthDirective, ContentMustExistGuard, ContentsColumnsPipe, ContentSelectorComponent, ContentSelectorItemComponent, ContentsService, ContentsState, ContentStatusComponent, ContentValueComponent, ContentValueEditorComponent, ContributorsService, ContributorsState, FileIconPipe, FilterComparisonComponent, FilterLogicalComponent, FilterNodeComponent, FilterOperatorPipe, GeolocationEditorComponent, HelpComponent, HelpMarkdownPipe, HelpService, HistoryComponent, HistoryListComponent, HistoryMessagePipe, HistoryService, ImageCropperComponent, ImageFocusPointComponent, LanguagesService, LanguagesState, LoadAppsGuard, LoadLanguagesGuard, LoadSchemasGuard, MarkdownEditorComponent, MustBeAuthenticatedGuard, MustBeNotAuthenticatedGuard, NewsService, NotifoComponent, PlansService, PlansState, PreviewableType, QueryComponent, QueryListComponent, QueryPathComponent, ReferenceInputComponent, RichEditorComponent, RolesService, RolesState, RuleEventsState, RuleMustExistGuard, RuleSimulatorState, RulesService, RulesState, SavedQueriesComponent, SchemaCategoryComponent, SchemaMustExistGuard, SchemaMustExistPublishedGuard, SchemaMustNotBeSingletonGuard, SchemasService, SchemasState, SchemaTagSource, SearchFormComponent, SearchService, SortingComponent, StockPhotoService, TableHeaderComponent, TemplatesService, TemplatesState, TranslationsService, UIService, UIState, UnsetAppGuard, UsagesService, UserDtoPicture, UserIdPicturePipe, UserNamePipe, UserNameRefPipe, UserPicturePipe, UserPictureRefPipe, UsersProviderService, UsersService, WatchingUsersComponent, WorkflowsService, WorkflowsState } from './declarations'; @NgModule({ imports: [ @@ -164,7 +164,6 @@ export class SqxSharedModule { ContentsState, ContributorsService, ContributorsState, - GraphQlService, HelpService, HistoryService, LanguagesService, diff --git a/frontend/src/app/shared/services/graphql.service.spec.ts b/frontend/src/app/shared/services/graphql.service.spec.ts deleted file mode 100644 index b2fbdfbde..000000000 --- a/frontend/src/app/shared/services/graphql.service.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Squidex Headless CMS - * - * @license - * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. - */ - -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; -import { inject, TestBed } from '@angular/core/testing'; -import { ApiUrlConfig, GraphQlService } from '@app/shared/internal'; - -describe('GraphQlService', () => { - beforeEach(() => { - TestBed.configureTestingModule({ - imports: [ - HttpClientTestingModule, - ], - providers: [ - GraphQlService, - { provide: ApiUrlConfig, useValue: new ApiUrlConfig('http://service/p/') }, - ], - }); - }); - - afterEach(inject([HttpTestingController], (httpMock: HttpTestingController) => { - httpMock.verify(); - })); - - it('should make get request to get history events', - inject([GraphQlService, HttpTestingController], (graphQlService: GraphQlService, httpMock: HttpTestingController) => { - let graphQlResult: any = null; - - graphQlService.query('my-app', {}).subscribe(result => { - graphQlResult = result; - }); - - const req = httpMock.expectOne('http://service/p/api/content/my-app/graphql'); - - expect(req.request.method).toEqual('POST'); - expect(req.request.headers.get('If-Match')).toBeNull(); - - req.flush({ result: true }); - - expect(graphQlResult).toEqual({ result: true }); - })); -}); diff --git a/frontend/src/app/shared/services/graphql.service.ts b/frontend/src/app/shared/services/graphql.service.ts deleted file mode 100644 index 953fac14f..000000000 --- a/frontend/src/app/shared/services/graphql.service.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Squidex Headless CMS - * - * @license - * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. - */ - -import { HttpClient } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { Observable } from 'rxjs'; -import { ApiUrlConfig } from '@app/framework'; - -@Injectable() -export class GraphQlService { - constructor( - private readonly http: HttpClient, - private readonly apiUrl: ApiUrlConfig, - ) { - } - - public query(appName: string, params: any): Observable { - const url = this.apiUrl.buildUrl(`api/content/${appName}/graphql`); - - return this.http.post(url, params); - } -}