Browse Source

Subscriptions (#913)

* Update deps

* Temp

* First version.

* More tests

* Simplified tests.

* Improve tests.

* More fixes.

* Permissions checks.
pull/916/head
Sebastian Stehle 3 years ago
committed by GitHub
parent
commit
03aca355dc
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj
  2. 4
      backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs
  3. 4
      backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx
  4. 1
      backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
  5. 22
      backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/AppSubscription.cs
  6. 75
      backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/AssetSubscription.cs
  7. 90
      backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/ContentSubscription.cs
  8. 87
      backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/EventMessageEvaluator.cs
  9. 50
      backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/EventMessageWrapper.cs
  10. 21
      backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/ISubscriptionEventCreator.cs
  11. 62
      backend/src/Squidex.Domain.Apps.Core.Operations/Subscriptions/SubscriptionPublisher.cs
  12. 2
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj
  13. 17
      backend/src/Squidex.Domain.Apps.Entities/Assets/AssetChangedTriggerHandler.cs
  14. 17
      backend/src/Squidex.Domain.Apps.Entities/Contents/ContentChangedTriggerHandler.cs
  15. 9
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/CachingGraphQLResolver.cs
  16. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/GraphQLOptions.cs
  17. 4
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ApplicationMutations.cs
  18. 4
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ApplicationQueries.cs
  19. 39
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/ApplicationSubscriptions.cs
  20. 34
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetActions.cs
  21. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetGraphType.cs
  22. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/AssetsResultGraphType.cs
  23. 263
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Assets/EnrichedAssetEventGraphType.cs
  24. 14
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Builder.cs
  25. 139
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs
  26. 134
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/EnrichedContentEventGraphType.cs
  27. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Primitives/EntitySavedGraphType.cs
  28. 125
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Resolvers.cs
  29. 9
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Scalars.cs
  30. 26
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedObjectGraphType.cs
  31. 5
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/SharedTypes.cs
  32. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/UserGraphType.cs
  33. 2
      backend/src/Squidex.Domain.Apps.Entities/Contents/Queries/QueryExecutionContext.cs
  34. 4
      backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj
  35. 2
      backend/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj
  36. 2
      backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj
  37. 4
      backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj
  38. 11
      backend/src/Squidex.Infrastructure/EventSourcing/Consume/EventConsumerProcessor.cs
  39. 2
      backend/src/Squidex.Infrastructure/EventSourcing/Consume/EventConsumerState.cs
  40. 2
      backend/src/Squidex.Infrastructure/EventSourcing/IEventConsumer.cs
  41. 13
      backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  42. 6
      backend/src/Squidex.Web/GraphQL/DynamicUserContextBuilder.cs
  43. 12
      backend/src/Squidex.Web/GraphQL/GraphQLRunner.cs
  44. 2
      backend/src/Squidex.Web/Pipeline/AppResolver.cs
  45. 6
      backend/src/Squidex.Web/Squidex.Web.csproj
  46. 3
      backend/src/Squidex/Config/Domain/InfrastructureServices.cs
  47. 5
      backend/src/Squidex/Config/Domain/RuleServices.cs
  48. 50
      backend/src/Squidex/Config/Messaging/MessagingServices.cs
  49. 5
      backend/src/Squidex/Config/Web/WebServices.cs
  50. 28
      backend/src/Squidex/Squidex.csproj
  51. 2
      backend/src/Squidex/Startup.cs
  52. 6
      backend/tests/Squidex.Domain.Apps.Core.Tests/Model/Apps/AppClientJsonTests.cs
  53. 111
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/AssetSubscriptionTests.cs
  54. 134
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/ContentSubscriptionTests.cs
  55. 98
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/EventMessageEvaluatorTests.cs
  56. 67
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/EventMessageWrapperTests.cs
  57. 106
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/SubscriptionPublisherTests.cs
  58. 4
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Apps/Indexes/AppsIndexTests.cs
  59. 155
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLMutationTests.cs
  60. 29
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLQueriesTests.cs
  61. 202
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLSubscriptionTests.cs
  62. 132
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs
  63. 4
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Schemas/Indexes/SchemasIndexTests.cs
  64. 5
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj
  65. 21
      backend/tests/Squidex.Infrastructure.Tests/EventSourcing/Consume/EventConsumerProcessorTests.cs
  66. 11
      backend/tests/Squidex.Infrastructure.Tests/Security/PermissionSetTests.cs
  67. 38
      frontend/package-lock.json
  68. 1
      frontend/package.json
  69. 33
      frontend/src/app/features/api/pages/graphql/graphql-page.component.ts
  70. 1
      frontend/src/app/shared/internal.ts
  71. 3
      frontend/src/app/shared/module.ts
  72. 46
      frontend/src/app/shared/services/graphql.service.spec.ts
  73. 26
      frontend/src/app/shared/services/graphql.service.ts

2
backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj

@ -28,7 +28,7 @@
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" />
<PackageReference Include="Microsoft.OData.Core" Version="7.11.0" />
<PackageReference Include="NodaTime" Version="3.1.0" />
<PackageReference Include="NodaTime" Version="3.1.2" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.3.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.3.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.0.0-rc8" />

4
backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.Designer.cs

@ -826,7 +826,7 @@ namespace Squidex.Domain.Apps.Core {
}
/// <summary>
/// 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..
/// </summary>
public static string QuerySkip {
get {
@ -835,7 +835,7 @@ namespace Squidex.Domain.Apps.Core {
}
/// <summary>
/// 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..
/// </summary>
public static string QueryTop {
get {

4
backend/src/Squidex.Domain.Apps.Core.Model/FieldDescriptions.resx

@ -373,10 +373,10 @@
<value>Optional OData full text search.</value>
</data>
<data name="QuerySkip" xml:space="preserve">
<value>Optional number of contents to skip.</value>
<value>Optional number of items to skip.</value>
</data>
<data name="QueryTop" xml:space="preserve">
<value>Optional number of contents to take.</value>
<value>Optional number of items to take.</value>
</data>
<data name="QueryVersion" xml:space="preserve">
<value>The optional version of the content to retrieve an older instance (not cached).</value>

1
backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj

@ -28,6 +28,7 @@
<PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" />
<PackageReference Include="NJsonSchema" Version="10.7.2" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="4.11.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="6.0.0" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />

22
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<bool> ShouldHandle(object message);
}
}

75
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<bool> ShouldHandle(object message)
{
return new ValueTask<bool>(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);
}
}
}

90
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<bool> ShouldHandle(object message)
{
return new ValueTask<bool>(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);
}
}
}

87
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<DomainId, Dictionary<Guid, AppSubscription>> subscriptions = new Dictionary<DomainId, Dictionary<Guid, AppSubscription>>();
private readonly ReaderWriterLockSlim readerWriterLock = new ReaderWriterLockSlim();
public async ValueTask<IEnumerable<Guid>> GetSubscriptionsAsync(object message)
{
if (message is not AppEvent appEvent)
{
return Enumerable.Empty<Guid>();
}
readerWriterLock.EnterReadLock();
try
{
List<Guid>? 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<Guid>();
result.Add(id);
}
}
}
return result ?? Enumerable.Empty<Guid>();
}
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();
}
}
}
}

50
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<ISubscriptionEventCreator> subscriptionEventCreators;
public Envelope<AppEvent> Event { get; }
object IPayloadWrapper.Message => Event.Payload;
public EventMessageWrapper(Envelope<AppEvent> @event, IEnumerable<ISubscriptionEventCreator> subscriptionEventCreators)
{
Event = @event;
this.subscriptionEventCreators = subscriptionEventCreators;
}
public async ValueTask<object> 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!;
}
}
}

21
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<EnrichedEvent?> CreateEnrichedEventsAsync(Envelope<AppEvent> @event,
CancellationToken ct);
}
}

62
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<ISubscriptionEventCreator> 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<ISubscriptionEventCreator> subscriptionEventCreators)
{
this.subscriptionService = subscriptionService;
this.subscriptionEventCreators = subscriptionEventCreators;
}
public bool Handles(StoredEvent @event)
{
return subscriptionService.HasSubscriptions;
}
public Task On(Envelope<IEvent> @event)
{
if (@event.Payload is not AppEvent)
{
return Task.CompletedTask;
}
var wrapper = new EventMessageWrapper(@event.To<AppEvent>(), subscriptionEventCreators);
return subscriptionService.PublishAsync(wrapper);
}
}
}

2
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj

@ -23,7 +23,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MongoDB.Driver" Version="2.16.1" />
<PackageReference Include="MongoDB.Driver" Version="2.17.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />

17
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<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RuleContext context,
[EnumeratorCancellation] CancellationToken ct)
{
yield return await CreateEnrichedEventsCoreAsync(@event, ct);
}
public async ValueTask<EnrichedEvent?> CreateEnrichedEventsAsync(Envelope<AppEvent> @event,
CancellationToken ct)
{
return await CreateEnrichedEventsCoreAsync(@event, ct);
}
private async ValueTask<EnrichedEvent> CreateEnrichedEventsCoreAsync(Envelope<AppEvent> @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)

17
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<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RuleContext context,
[EnumeratorCancellation] CancellationToken ct)
{
yield return await CreateEnrichedEventsCoreAsync(@event, ct);
}
public async ValueTask<EnrichedEvent?> CreateEnrichedEventsAsync(Envelope<AppEvent> @event,
CancellationToken ct)
{
return await CreateEnrichedEventsCoreAsync(@event, ct);
}
private async ValueTask<EnrichedEvent> CreateEnrichedEventsCoreAsync(Envelope<AppEvent> @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)

9
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<CacheEntry> CreateModelAsync(IAppEntity app)
{
var schemas = await serviceProvider.GetRequiredService<IAppProvider>().GetSchemasAsync(app.Id);
var schemasList = await serviceProvider.GetRequiredService<IAppProvider>().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)

2
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;
}
}

4
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppMutationsGraphType.cs → 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<SchemaInfo> schemas)
public ApplicationMutations(Builder builder, IEnumerable<SchemaInfo> schemas)
{
foreach (var schemaInfo in schemas.Where(x => x.Fields.Count > 0))
{

4
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/AppQueriesGraphType.cs → 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<SchemaInfo> schemaInfos)
public ApplicationQueries(Builder builder, IEnumerable<SchemaInfo> schemaInfos)
{
AddField(SharedTypes.FindAsset);
AddField(SharedTypes.QueryAssets);

39
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."
});
}
}
}

34
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<EnrichedAssetEventType?>("type")
};
});
}
}
}

2
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<IEnrichedAssetEntity>
internal sealed class AssetGraphType : SharedObjectGraphType<IEnrichedAssetEntity>
{
public AssetGraphType()
{

2
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<IResultList<IAssetEntity>>
internal sealed class AssetsResultGraphType : SharedObjectGraphType<IResultList<IAssetEntity>>
{
public AssetsResultGraphType(IGraphType assetsList)
{

263
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<EnrichedAssetEvent>
{
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<IUrlGenerator>();
return urlGenerator.AssetContent(asset.AppId, asset.Id.ToString());
});
private static readonly IFieldResolver SourceUrl = Resolve((asset, _, context) =>
{
var urlGenerator = context.Resolve<IUrlGenerator>();
return urlGenerator.AssetSource(asset.AppId, asset.Id, asset.FileVersion);
});
private static readonly IFieldResolver ThumbnailUrl = Resolve((asset, _, context) =>
{
var urlGenerator = context.Resolve<IUrlGenerator>();
return urlGenerator.AssetThumbnail(asset.AppId, asset.Id.ToString(), asset.AssetType);
});
private static IFieldResolver Resolve<T>(Func<EnrichedAssetEvent, IResolveFieldContext, GraphQLExecutionContext, T> resolver)
{
return Resolvers.Sync(resolver);
}
private static IFieldResolver Resolve<T>(Func<EnrichedAssetEvent, T> resolver)
{
return Resolvers.Sync(resolver);
}
}
}

14
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<SchemaInfo> allSchemas = new List<SchemaInfo>();
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<ISchemaEntity> 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);

139
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<string?>("id");
var contentData = c.GetArgument<ContentData>("data")!;
var contentStatus = c.GetArgument<string?>("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<ContentData>("data")
};
var status = c.GetArgument<string?>("status");
if (!string.IsNullOrWhiteSpace(contentStatus))
if (!string.IsNullOrWhiteSpace(status))
{
command.Status = new Status(contentStatus);
command.Status = new Status(status);
}
else if (c.GetArgument<bool>("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 command = new UpsertContent
{
var contentId = c.GetArgument<DomainId>("id");
var contentData = c.GetArgument<ContentData>("data")!;
var contentStatus = c.GetArgument<string?>("status");
var patch = c.GetArgument<bool>("patch");
// The data is converted from input args.
Data = c.GetArgument<ContentData>("data"),
// True, to make a path, if the content exits.
Patch = c.GetArgument<bool>("patch"),
};
var command = new UpsertContent { ContentId = contentId, Data = contentData, Patch = patch };
var status = c.GetArgument<string?>("status");
if (!string.IsNullOrWhiteSpace(contentStatus))
if (!string.IsNullOrWhiteSpace(status))
{
command.Status = new Status(contentStatus);
command.Status = new Status(status);
}
else if (c.GetArgument<bool>("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<DomainId>("id");
var contentData = c.GetArgument<ContentData>("data")!;
return new UpdateContent { ContentId = contentId, Data = contentData };
return new PatchContent
{
// The data is converted from input args.
Data = c.GetArgument<ContentData>("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<DomainId>("id");
var contentData = c.GetArgument<ContentData>("data")!;
return new PatchContent { ContentId = contentId, Data = contentData };
return new PatchContent
{
// The data is converted from input args.
Data = c.GetArgument<ContentData>("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<DomainId>("id");
var contentStatus = c.GetArgument<Status>("status");
var contentDueTime = c.GetArgument<Instant?>("dueTime");
return new ChangeContentStatus
{
// Main parameter to set the status.
Status = c.GetArgument<Status>("status"),
return new ChangeContentStatus { ContentId = contentId, Status = contentStatus, DueTime = contentDueTime };
// This is an optional field to delay the status change.
DueTime = c.GetArgument<Instant?>("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<DomainId>("id");
return new DeleteContent { ContentId = contentId };
return new DeleteContent();
});
}
private static IFieldResolver ResolveAsync(string permissionId, Func<IResolveFieldContext, ContentCommand> action)
public static class Subscription
{
return Resolvers.Async<object, object>(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<ICommandBus>();
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<EnrichedContentEventType?>("type"),
return commandContext.PlainResult!;
// The name of the schema is used instead of the ID for a simpler API.
SchemaName = c.GetArgument<string?>("schemaName")
};
});
}
private static void CheckPermission(string permissionId, GraphQLExecutionContext context, NamedId<DomainId> schemaId)
private static IFieldResolver ContentCommand(string permissionId, Func<IResolveFieldContext, ContentCommand> 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<string?>("id");
if (!string.IsNullOrWhiteSpace(contentId))
{
// Same parameter for all commands.
command.ContentId = DomainId.Create(contentId);
}
return command;
});
}
}
}

134
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<EnrichedContentEvent>
{
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<T>(Func<EnrichedContentEvent, T> resolver)
{
return Resolvers.Sync(resolver);
}
}
}

2
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<CommandResult>
internal sealed class EntitySavedGraphType : SharedObjectGraphType<CommandResult>
{
public static readonly IGraphType Nullable = new EntitySavedGraphType();

125
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<TSource, T>(resolver);
}
private sealed class SyncResolver<TSource, T> : IFieldResolver
private abstract class BaseResolver<T, TOut> where T : TOut
{
private readonly Func<TSource, IResolveFieldContext, GraphQLExecutionContext, T> resolver;
public SyncResolver(Func<TSource, IResolveFieldContext, GraphQLExecutionContext, T> resolver)
{
this.resolver = resolver;
}
public ValueTask<object?> ResolveAsync(IResolveFieldContext context)
protected async ValueTask<TOut> ResolveWithErrorHandlingAsync(IResolveFieldContext context)
{
var executionContext = (GraphQLExecutionContext)context.UserContext!;
try
{
var result = resolver((TSource)context.Source!, context, executionContext);
return new ValueTask<object?>(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<T> ResolveCoreAsync(IResolveFieldContext context, GraphQLExecutionContext executionContext);
}
private sealed class SyncResolver<TSource, T> : BaseResolver<T, object?>, IFieldResolver
{
private readonly Func<TSource, IResolveFieldContext, GraphQLExecutionContext, T> resolver;
public SyncResolver(Func<TSource, IResolveFieldContext, GraphQLExecutionContext, T> resolver)
{
this.resolver = resolver;
}
protected override ValueTask<T> ResolveCoreAsync(IResolveFieldContext context, GraphQLExecutionContext executionContext)
{
return new ValueTask<T>(resolver((TSource)context.Source!, context, executionContext));
}
public ValueTask<object?> ResolveAsync(IResolveFieldContext context)
{
return ResolveWithErrorHandlingAsync(context);
}
}
private sealed class AsyncResolver<TSource, T> : IFieldResolver
private sealed class AsyncResolver<TSource, T> : BaseResolver<T, object?>, IFieldResolver
{
private readonly Func<TSource, IResolveFieldContext, GraphQLExecutionContext, Task<T>> resolver;
@ -81,32 +99,85 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
this.resolver = resolver;
}
public async ValueTask<object?> ResolveAsync(IResolveFieldContext context)
protected override async ValueTask<T> ResolveCoreAsync(IResolveFieldContext context, GraphQLExecutionContext executionContext)
{
var executionContext = (GraphQLExecutionContext)context.UserContext!;
return await resolver((TSource)context.Source!, context, executionContext);
}
try
public ValueTask<object?> ResolveAsync(IResolveFieldContext context)
{
var result = await resolver((TSource)context.Source!, context, executionContext);
return ResolveWithErrorHandlingAsync(context);
}
}
return result;
private sealed class SyncStreamResolver : BaseResolver<IObservable<object?>, IObservable<object?>>, ISourceStreamResolver
{
private readonly Func<IResolveFieldContext, GraphQLExecutionContext, IObservable<object?>> resolver;
public SyncStreamResolver(Func<IResolveFieldContext, GraphQLExecutionContext, IObservable<object?>> resolver)
{
this.resolver = resolver;
}
catch (ValidationException ex)
protected override ValueTask<IObservable<object?>> ResolveCoreAsync(IResolveFieldContext context, GraphQLExecutionContext executionContext)
{
throw new ExecutionError(ex.Message);
return new ValueTask<IObservable<object?>>(resolver(context, executionContext));
}
catch (DomainException ex)
public ValueTask<IObservable<object?>> ResolveAsync(IResolveFieldContext context)
{
throw new ExecutionError(ex.Message);
return ResolveWithErrorHandlingAsync(context);
}
catch (Exception ex)
}
public static IFieldResolver Command(string permissionId, Func<IResolveFieldContext, ICommand> action)
{
var logFactory = executionContext.Resolve<ILoggerFactory>();
return new AsyncResolver<object, object>(async (source, fieldContext, context) =>
{
var schemaId = fieldContext.FieldDefinition.SchemaNamedId();
logFactory.CreateLogger("GraphQL").LogError(ex, "Failed to resolve field {field}.", context.FieldDefinition.Name);
throw;
if (!context.Context.Allows(permissionId, schemaId?.Name ?? Permission.Any))
{
throw new DomainForbiddenException(T.Get("common.errorNoPermission"));
}
var command = action(fieldContext);
// The app identifier is set from the http context.
if (command is ISchemaCommand schemaCommand && schemaId != null)
{
schemaCommand.SchemaId = schemaId;
}
command.ExpectedVersion = fieldContext.GetArgument("expectedVersion", EtagVersion.Any);
var commandContext =
await context.Resolve<ICommandBus>()
.PublishAsync(command, fieldContext.CancellationToken);
return commandContext.PlainResult!;
});
}
public static ISourceStreamResolver Stream(string permissionId, Func<IResolveFieldContext, AppSubscription> 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<ISubscriptionService>().Subscribe<object>(subscription);
});
}
}
}

9
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<AssetType>();
public static readonly IGraphType EnrichedAssetEventType = new EnumerationGraphType<EnrichedAssetEventType>();
public static readonly IGraphType EnrichedContentEventType = new EnumerationGraphType<EnrichedContentEventType>();
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);
}
}

26
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<T> : ObjectGraphType<T>
{
public override void Initialize(ISchema schema)
{
try
{
base.Initialize(schema);
}
catch (InvalidOperationException)
{
return;
}
}
}
}

5
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

2
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<IUser>
internal sealed class UserGraphType : SharedObjectGraphType<IUser>
{
public static readonly IGraphType Nullable = new UserGraphType();

2
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<string, object>
public abstract class QueryExecutionContext : Dictionary<string, object?>
{
private readonly SemaphoreSlim maxRequests = new SemaphoreSlim(10);

4
backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj

@ -20,8 +20,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="27.2.1" />
<PackageReference Include="GraphQL" Version="5.3.0" />
<PackageReference Include="GraphQL.DataLoader" Version="5.3.0" />
<PackageReference Include="GraphQL" Version="7.0.2" />
<PackageReference Include="GraphQL.DataLoader" Version="7.0.2" />
<PackageReference Include="Meziantou.Analyzer" Version="1.0.702">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

2
backend/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj

@ -18,10 +18,8 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NodaTime" Version="3.1.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
</ItemGroup>
<ItemGroup>
<AdditionalFiles Include="..\..\stylecop.json" Link="stylecop.json" />

2
backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj

@ -24,7 +24,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="MongoDB.Driver" Version="2.16.1" />
<PackageReference Include="MongoDB.Driver" Version="2.17.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Security.Principal.Windows" Version="5.0.0" />

4
backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj

@ -18,8 +18,8 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="MongoDB.Driver" Version="2.16.1" />
<PackageReference Include="MongoDB.Driver.GridFS" Version="2.16.1" />
<PackageReference Include="MongoDB.Driver" Version="2.17.1" />
<PackageReference Include="MongoDB.Driver.GridFS" Version="2.17.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Threading.Tasks.Dataflow" Version="6.0.0" />

11
backend/src/Squidex.Infrastructure/EventSourcing/Consume/EventConsumerProcessor.cs

@ -43,10 +43,17 @@ namespace Squidex.Infrastructure.EventSourcing.Consume
state = new SimpleState<EventConsumerState>(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()

2
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);
}

2
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)

13
backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -21,14 +21,15 @@
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="6.0.5" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.2.0" />
<PackageReference Include="Microsoft.OData.Core" Version="7.11.0" />
<PackageReference Include="NodaTime" Version="3.1.2" />
<PackageReference Include="OpenTelemetry.Api" Version="1.3.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets" Version="3.6.0" />
<PackageReference Include="Squidex.Caching" Version="1.9.0" />
<PackageReference Include="Squidex.Hosting.Abstractions" Version="2.13.0" />
<PackageReference Include="Squidex.Log" Version="1.6.0" />
<PackageReference Include="Squidex.Messaging" Version="2.1.0" />
<PackageReference Include="Squidex.Text" Version="1.7.0" />
<PackageReference Include="Squidex.Assets" Version="4.11.0" />
<PackageReference Include="Squidex.Caching" Version="4.11.0" />
<PackageReference Include="Squidex.Hosting.Abstractions" Version="4.11.0" />
<PackageReference Include="Squidex.Log" Version="4.11.0" />
<PackageReference Include="Squidex.Messaging" Version="4.11.0" />
<PackageReference Include="Squidex.Text" Version="4.11.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="6.0.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />

6
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<IDictionary<string, object>> BuildUserContext(HttpContext httpContext)
public ValueTask<IDictionary<string, object?>?> 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<IDictionary<string, object>>(executionContext);
return new ValueTask<IDictionary<string, object?>?>(executionContext);
}
}
}

12
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<DummySchema> middleware;
public GraphQLRunner(IGraphQLTextSerializer deserializer)
public GraphQLRunner(IServiceProvider serviceProvider)
{
middleware = new GraphQLHttpMiddleware<DummySchema>(deserializer);
RequestDelegate next = x => Task.CompletedTask;
var options = new GraphQLHttpMiddlewareOptions();
middleware = ActivatorUtilities.CreateInstance<GraphQLHttpMiddleware<DummySchema>>(serviceProvider, next, options);
}
public Task InvokeAsync(HttpContext context)
{
return middleware.InvokeAsync(context, x => Task.CompletedTask);
return middleware.InvokeAsync(context);
}
}
}

2
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)
{

6
backend/src/Squidex.Web/Squidex.Web.csproj

@ -13,9 +13,9 @@
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="GraphQL" Version="5.3.0" />
<PackageReference Include="GraphQL.SystemTextJson" Version="5.3.0" />
<PackageReference Include="GraphQL.Server.Transports.AspNetCore" Version="6.1.0" />
<PackageReference Include="GraphQL" Version="7.0.2" />
<PackageReference Include="GraphQL.SystemTextJson" Version="7.0.2" />
<PackageReference Include="GraphQL.Server.Transports.AspNetCore" Version="7.0.0" />
<PackageReference Include="Meziantou.Analyzer" Version="1.0.702">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

3
backend/src/Squidex/Config/Domain/InfrastructureServices.cs

@ -104,9 +104,6 @@ namespace Squidex.Config.Domain
services.AddSingletonAs<UserFluidExtension>()
.As<IFluidExtension>();
services.AddSingletonAs<SimplePubSub>()
.As<IPubSub>();
}
public static void AddSquidexUsageTracking(this IServiceCollection services, IConfiguration config)

5
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<IEventEnricher>();
services.AddSingletonAs<AssetChangedTriggerHandler>()
.As<IRuleTriggerHandler>();
.As<IRuleTriggerHandler>().As<ISubscriptionEventCreator>();
services.AddSingletonAs<CommentTriggerHandler>()
.As<IRuleTriggerHandler>();
services.AddSingletonAs<ContentChangedTriggerHandler>()
.As<IRuleTriggerHandler>();
.As<IRuleTriggerHandler>().As<ISubscriptionEventCreator>();
services.AddSingletonAs<AssetsFluidExtension>()
.As<IFluidExtension>();

50
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<bool>("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<bool>("caching:replicated:enable");
var isWorker = config.GetValue<bool>("clustering:worker");
if (isWorker)
{
services.AddSingletonAs<AssetCleanupProcess>()
.AsSelf();
@ -53,34 +62,51 @@ namespace Squidex.Config.Messaging
.AsSelf().As<IMessageHandler>();
}
services.AddSingleton<ITransportSerializer>(c =>
services.AddSingleton<IMessagingSerializer>(c =>
new SystemTextJsonTransportSerializer(c.GetRequiredService<JsonSerializerOptions>()));
services.AddSingletonAs<SubscriptionPublisher>()
.As<IEventConsumer>();
services.AddSingletonAs<EventMessageEvaluator>()
.As<IMessageEvaluator>();
services.AddReplicatedCacheMessaging(isCaching, options =>
{
options.TransportSelector = (transport, _) => transport.First(x => x is NullTransport != isCaching);
});
services.Configure<SubscriptionOptions>(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);
});

5
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<DummySchema>();
builder.AddSystemTextJson();
builder.AddDataLoader();

28
backend/src/Squidex/Squidex.csproj

@ -35,9 +35,9 @@
<ItemGroup>
<PackageReference Include="AspNet.Security.OAuth.GitHub" Version="6.0.6" />
<PackageReference Include="GraphQL" Version="5.3.0" />
<PackageReference Include="GraphQL.MicrosoftDI" Version="5.3.0" />
<PackageReference Include="GraphQL.SystemTextJson" Version="5.3.0" />
<PackageReference Include="GraphQL" Version="7.0.2" />
<PackageReference Include="GraphQL.MicrosoftDI" Version="7.0.2" />
<PackageReference Include="GraphQL.SystemTextJson" Version="7.0.2" />
<PackageReference Include="Meziantou.Analyzer" Version="1.0.702">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -56,7 +56,7 @@
<PackageReference Include="Microsoft.IdentityModel.Protocols" Version="6.14.1" />
<PackageReference Include="Microsoft.OData.Core" Version="7.11.0" />
<PackageReference Include="Namotion.Reflection" Version="2.0.10" />
<PackageReference Include="MongoDB.Driver" Version="2.16.1" />
<PackageReference Include="MongoDB.Driver" Version="2.17.1" />
<PackageReference Include="MongoDB.Driver.Core.Extensions.OpenTelemetry" Version="1.0.0" />
<PackageReference Include="Namotion.Reflection" Version="2.0.10" ExcludeAssets="all" />
<PackageReference Include="NetTopologySuite.IO.GeoJSON4STJ" Version="2.1.1" />
@ -69,17 +69,19 @@
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.0.0-rc7" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="ReportGenerator" Version="5.1.9" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets.Azure" Version="3.6.0" />
<PackageReference Include="Squidex.Assets.GoogleCloud" Version="3.6.0" />
<PackageReference Include="Squidex.Assets.FTP" Version="3.6.0" />
<PackageReference Include="Squidex.Assets.ImageMagick" Version="3.6.0" />
<PackageReference Include="Squidex.Assets.ImageSharp" Version="3.6.0" />
<PackageReference Include="Squidex.Assets.Mongo" Version="3.6.0" />
<PackageReference Include="Squidex.Assets.S3" Version="3.6.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="3.6.0" />
<PackageReference Include="Squidex.Assets.Azure" Version="4.11.0" />
<PackageReference Include="Squidex.Assets.GoogleCloud" Version="4.11.0" />
<PackageReference Include="Squidex.Assets.FTP" Version="4.11.0" />
<PackageReference Include="Squidex.Assets.ImageMagick" Version="4.11.0" />
<PackageReference Include="Squidex.Assets.ImageSharp" Version="4.11.0" />
<PackageReference Include="Squidex.Assets.Mongo" Version="4.11.0" />
<PackageReference Include="Squidex.Assets.S3" Version="4.11.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="4.11.0" />
<PackageReference Include="Squidex.Caching.Orleans" Version="1.9.0" />
<PackageReference Include="Squidex.ClientLibrary" Version="8.27.0" />
<PackageReference Include="Squidex.Hosting" Version="2.13.0" />
<PackageReference Include="Squidex.Hosting" Version="4.11.0" />
<PackageReference Include="Squidex.Messaging.All" Version="4.11.0" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="4.11.0" />
<PackageReference Include="Squidex.Namotion.Reflection" Version="2.0.10" />
<PackageReference Include="Squidex.OpenIddict.MongoDb" Version="4.0.1-dev" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />

2
backend/src/Squidex/Startup.cs

@ -76,6 +76,8 @@ namespace Squidex
public void Configure(IApplicationBuilder app)
{
app.UseWebSockets();
app.UseCookiePolicy();
app.UseDefaultPathBase();

6
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();

111
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<DomainId> 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;
}
}
}

134
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<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app");
private readonly NamedId<DomainId> 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;
}
}
}

98
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<DomainId> appId = NamedId.Of(DomainId.NewGuid(), "my-app");
private readonly NamedId<DomainId> 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;
}
}
}

67
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<ISubscriptionEventCreator>();
private readonly ISubscriptionEventCreator creator2 = A.Fake<ISubscriptionEventCreator>();
[Fact]
public async Task Should_return_event_from_first_creator()
{
var enrichedEvent = new EnrichedContentEvent();
var envelope = Envelope.Create<AppEvent>(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<AppEvent>(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<Envelope<AppEvent>>._, A<CancellationToken>._))
.MustNotHaveHappened();
}
}
}

106
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<ISubscriptionService>();
private readonly SubscriptionPublisher sut;
private sealed class MyEvent : IEvent
{
}
public SubscriptionPublisherTests()
{
sut = new SubscriptionPublisher(subscriptionService, Enumerable.Empty<ISubscriptionEventCreator>());
}
[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();
}
}
}

4
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<ILogger<SimplePubSub>>()),
new ReplicatedCache(new MemoryCache(Options.Create(new MemoryCacheOptions())), A.Fake<IMessageBus>(),
Options.Create(new ReplicatedCacheOptions { Enable = true }));
sut = new AppsIndex(appRepository, replicatedCache, state.PersistenceFactory);

155
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: <DATA>, publish: true) {
<FIELDS>
<FIELDS_CONTENT>
}
}");
}", 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: <DATA>, id: '123', publish: true) {
<FIELDS>
<FIELDS_CONTENT>
}
}");
}", 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) {
<FIELDS>
<FIELDS_CONTENT>
}
}");
}", 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: '<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: '<ID>', data: <DATA>, expectedVersion: 10) {
<FIELDS>
<FIELDS_CONTENT>
}
}");
}", 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: '<ID>', data: $data, expectedVersion: 10) {
<FIELDS>
<FIELDS_CONTENT>
}
}");
}", 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: '<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: '<ID>', data: <DATA>, publish: true, expectedVersion: 10) {
<FIELDS>
<FIELDS_CONTENT>
}
}");
}", 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: '<ID>', data: $data, publish: true, expectedVersion: 10) {
<FIELDS>
<FIELDS_CONTENT>
}
}");
}", 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: '<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: '<ID>', data: <DATA>, expectedVersion: 10) {
<FIELDS>
<FIELDS_CONTENT>
}
}");
}", 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: '<ID>', data: $data, expectedVersion: 10) {
<FIELDS>
<FIELDS_CONTENT>
}
}");
}", 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: '<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: '<ID>', status: 'Published', dueTime: '2021-12-12T11:10:09Z', expectedVersion: 10) {
<FIELDS>
<FIELDS_CONTENT>
}
}");
}", 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: '<ID>', status: 'Published', expectedVersion: 10) {
<FIELDS>
<FIELDS_CONTENT>
}
}");
}", 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: '<ID>', status: 'Published', dueTime: null, expectedVersion: 10) {
<FIELDS>
<FIELDS_CONTENT>
}
}");
}", 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: '<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: '<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("<ID>", contentId.ToString(), StringComparison.Ordinal)
.Replace("<FIELDS>", TestContent.AllFields, StringComparison.Ordinal);
if (query.Contains("<DATA>", 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("<DATA>", dataJson, StringComparison.Ordinal);
}
return query;
}
private Inputs GetInput()
{
var input = new

29
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>", id.ToString(), StringComparison.Ordinal)
.Replace("<FIELDS_ASSET>", TestAsset.AllFields, StringComparison.Ordinal)
.Replace("<FIELDS_CONTENT>", TestContent.AllFields, StringComparison.Ordinal)
.Replace("<FIELDS_CONTENT_FLAT>", TestContent.AllFlatFields, StringComparison.Ordinal);
}
private Context MatchsAssetContext()
{
return A<Context>.That.Matches(x =>
x.App == TestApp.Default &&
x.ShouldSkipCleanup() &&
x.ShouldSkipContentEnrichment() &&
x.User == requestContext.User);
}
private Context MatchsContentContext()
{
return A<Context>.That.Matches(x =>
x.App == TestApp.Default &&
x.ShouldSkipCleanup() &&
x.ShouldSkipContentEnrichment() &&
x.User == requestContext.User);
}
}
}

202
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<object>(
new EnrichedAssetEvent
{
Id = id,
FileName = "image.png",
FileSize = 1024
});
A.CallTo(() => subscriptionService.Subscribe<object>(A<AssetSubscription>._))
.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<object>(
new EnrichedContentEvent
{
Id = id,
Data = new ContentData()
.AddField("field",
new ContentFieldData()
.AddInvariant(42))
});
A.CallTo(() => subscriptionService.Subscribe<object>(A<ContentSubscription>._))
.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);
}
}
}

132
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<TranslationsFixture>
public abstract class GraphQLTestBase : IClassFixture<TranslationsFixture>
{
protected readonly GraphQLSerializer serializer = new GraphQLSerializer(TestUtils.DefaultOptions());
protected readonly IAssetQueryService assetQuery = A.Fake<IAssetQueryService>();
protected readonly ICommandBus commandBus = A.Fake<ICommandBus>();
protected readonly IContentQueryService contentQuery = A.Fake<IContentQueryService>();
protected readonly ISubscriptionService subscriptionService = A.Fake<ISubscriptionService>();
protected readonly IUserResolver userResolver = A.Fake<IUserResolver>();
protected readonly Context requestContext;
private CachingGraphQLResolver sut;
private CachingGraphQLResolver? sut;
public GraphQLTestBase()
protected GraphQLTestBase()
{
A.CallTo(() => userResolver.QueryManyAsync(A<string[]>._, default))
.ReturnsLazily(x =>
@ -65,40 +68,60 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
Assert.Equal(isonOutputExpected, jsonOutputResult);
}
protected Task<ExecutionResult> ExecuteAsync(ExecutionOptions options, string? permissionId = null)
protected Task<ExecutionResult> 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<ExecutionResult> ExecuteAsync(ExecutionOptions options, string permissionId)
{
return ExecuteCoreAsync(options, BuildContext(permissionId));
}
private async Task<ExecutionResult> ExcecuteAsync(ExecutionOptions options, Context context)
protected async Task<ExecutionResult> ExecuteCoreAsync(ExecutionOptions options, Context context)
{
// Use a shared instance to test caching.
sut ??= CreateSut(TestSchemas.Default, TestSchemas.Ref1, TestSchemas.Ref2);
options.UserContext = ActivatorUtilities.CreateInstance<GraphQLExecutionContext>(sut.Services, context)!;
// Provide the context to the test if services need to be resolved.
var graphQLContext = ActivatorUtilities.CreateInstance<GraphQLExecutionContext>(sut.Services, context)!;
options.UserContext = graphQLContext;
// Register data loader and other listeners.
foreach (var listener in sut.Services.GetRequiredService<IEnumerable<IDocumentExecutionListener>>())
{
options.Listeners.Add(listener);
}
// Enrich the context with the schema.
await sut.ExecuteAsync(options, x => Task.FromResult<ExecutionResult>(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<IAppProvider>();
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<GraphQLExecutionContext>()
.AddBackgroundCache()
.Configure<AssetOptions>(x =>
{
x.CanCache = true;
@ -116,6 +140,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
{
x.CanCache = true;
})
.AddSingleton<StringReferenceExtractor>()
.AddSingleton<IDocumentExecutionListener,
DataLoaderDocumentListener>()
.AddSingleton<IDataLoaderContextAccessor,
@ -126,21 +151,70 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
ContentCache>()
.AddSingleton<IUrlGenerator,
FakeUrlGenerator>()
.AddSingleton(A.Fake<ILoggerFactory>())
.AddSingleton(
A.Fake<ILoggerFactory>())
.AddSingleton(
A.Fake<ISchemasHash>())
.AddSingleton(appProvider)
.AddSingleton(assetQuery)
.AddSingleton(commandBus)
.AddSingleton(contentQuery)
.AddSingleton(subscriptionService)
.AddSingleton(userResolver)
.AddSingleton<InstantGraphType>()
.AddSingleton<JsonGraphType>()
.AddSingleton<JsonNoopGraphType>()
.AddSingleton<StringReferenceExtractor>()
.BuildServiceProvider();
var schemasHash = A.Fake<ISchemasHash>();
return ActivatorUtilities.CreateInstance<CachingGraphQLResolver>(serviceProvider);
}
protected static string CreateQuery(string query, DomainId id = default, IEnrichedContentEntity? content = null)
{
query = query
.Replace("'", "\"", StringComparison.Ordinal)
.Replace("`", "\"", StringComparison.Ordinal)
.Replace("<FIELDS_ASSET>", TestAsset.AllFields, StringComparison.Ordinal)
.Replace("<FIELDS_CONTENT>", TestContent.AllFields, StringComparison.Ordinal)
.Replace("<FIELDS_CONTENT_FLAT>", TestContent.AllFlatFields, StringComparison.Ordinal);
if (id != default)
{
query = query.Replace("<ID>", id.ToString(), StringComparison.Ordinal);
}
if (query.Contains("<DATA>", 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("<DATA>", dataJson, StringComparison.Ordinal);
}
return query;
}
protected Context MatchsAssetContext()
{
return A<Context>.That.Matches(x =>
x.App == TestApp.Default &&
x.ShouldSkipCleanup() &&
x.ShouldSkipContentEnrichment() &&
x.User == requestContext.User);
}
protected Context MatchsContentContext()
{
return A<Context>.That.Matches(x =>
x.App == TestApp.Default &&
x.ShouldSkipCleanup() &&
x.ShouldSkipContentEnrichment() &&
x.User == requestContext.User);
}
}
}

4
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<ILogger<SimplePubSub>>()),
new ReplicatedCache(new MemoryCache(Options.Create(new MemoryCacheOptions())), A.Fake<IMessageBus>(),
Options.Create(new ReplicatedCacheOptions { Enable = true }));
sut = new SchemasIndex(schemaRepository, replicatedCache, state.PersistenceFactory);

5
backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj

@ -23,8 +23,8 @@
<ItemGroup>
<PackageReference Include="FakeItEasy" Version="7.3.1" />
<PackageReference Include="FluentAssertions" Version="6.7.0" />
<PackageReference Include="GraphQL" Version="5.3.0" />
<PackageReference Include="GraphQL.SystemTextJson" Version="5.3.0" />
<PackageReference Include="GraphQL" Version="7.0.2" />
<PackageReference Include="GraphQL.SystemTextJson" Version="7.0.2" />
<PackageReference Include="Lorem.Universal.Net" Version="4.0.80" />
<PackageReference Include="Meziantou.Analyzer" Version="1.0.702">
<PrivateAssets>all</PrivateAssets>
@ -37,6 +37,7 @@
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" />
<PackageReference Include="Squidex.Caching.Orleans" Version="1.9.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Reactive.Linq" Version="5.0.0" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">

21
backend/tests/Squidex.Infrastructure.Tests/EventSourcing/Consume/EventConsumerProcessorTests.cs

@ -99,6 +99,27 @@ namespace Squidex.Infrastructure.EventSourcing.Consume
A.Fake<ILogger<EventConsumerProcessor>>());
}
[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<CancellationToken>._))
.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()
{

11
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);
}
}
}

38
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",

1
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",

33
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)));
}
}

1
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';

3
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,

46
frontend/src/app/shared/services/graphql.service.spec.ts

@ -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 });
}));
});

26
frontend/src/app/shared/services/graphql.service.ts

@ -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<any> {
const url = this.apiUrl.buildUrl(`api/content/${appName}/graphql`);
return this.http.post(url, params);
}
}
Loading…
Cancel
Save