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. 141
      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. 133
      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. 134
      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.Http" Version="6.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging" Version="6.0.0" />
<PackageReference Include="Microsoft.OData.Core" Version="7.11.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.Console" Version="1.3.0" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.3.0" /> <PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.3.0" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.0.0-rc8" /> <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> /// <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> /// </summary>
public static string QuerySkip { public static string QuerySkip {
get { get {
@ -835,7 +835,7 @@ namespace Squidex.Domain.Apps.Core {
} }
/// <summary> /// <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> /// </summary>
public static string QueryTop { public static string QueryTop {
get { get {

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

@ -373,10 +373,10 @@
<value>Optional OData full text search.</value> <value>Optional OData full text search.</value>
</data> </data>
<data name="QuerySkip" xml:space="preserve"> <data name="QuerySkip" xml:space="preserve">
<value>Optional number of contents to skip.</value> <value>Optional number of items to skip.</value>
</data> </data>
<data name="QueryTop" xml:space="preserve"> <data name="QueryTop" xml:space="preserve">
<value>Optional number of contents to take.</value> <value>Optional number of items to take.</value>
</data> </data>
<data name="QueryVersion" xml:space="preserve"> <data name="QueryVersion" xml:space="preserve">
<value>The optional version of the content to retrieve an older instance (not cached).</value> <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="Microsoft.Extensions.Http" Version="6.0.0" />
<PackageReference Include="NJsonSchema" Version="10.7.2" /> <PackageReference Include="NJsonSchema" Version="10.7.2" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <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="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="6.0.0" /> <PackageReference Include="System.Collections.Immutable" Version="6.0.0" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" /> <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> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </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="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" /> <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.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Core.Subscriptions;
using Squidex.Domain.Apps.Entities.Assets.Repositories; using Squidex.Domain.Apps.Entities.Assets.Repositories;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Assets; using Squidex.Domain.Apps.Events.Assets;
@ -18,7 +19,7 @@ using Squidex.Infrastructure.Reflection;
namespace Squidex.Domain.Apps.Entities.Assets namespace Squidex.Domain.Apps.Entities.Assets
{ {
public sealed class AssetChangedTriggerHandler : IRuleTriggerHandler public sealed class AssetChangedTriggerHandler : IRuleTriggerHandler, ISubscriptionEventCreator
{ {
private readonly IScriptEngine scriptEngine; private readonly IScriptEngine scriptEngine;
private readonly IAssetLoader assetLoader; private readonly IAssetLoader assetLoader;
@ -66,6 +67,18 @@ namespace Squidex.Domain.Apps.Entities.Assets
public async IAsyncEnumerable<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RuleContext context, public async IAsyncEnumerable<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RuleContext context,
[EnumeratorCancellation] CancellationToken ct) [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; var assetEvent = (AssetEvent)@event.Payload;
@ -105,7 +118,7 @@ namespace Squidex.Domain.Apps.Entities.Assets
break; break;
} }
yield return result; return result;
} }
public bool Trigger(EnrichedEvent @event, RuleContext context) 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.EnrichedEvents;
using Squidex.Domain.Apps.Core.Rules.Triggers; using Squidex.Domain.Apps.Core.Rules.Triggers;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Core.Subscriptions;
using Squidex.Domain.Apps.Entities.Contents.Repositories; using Squidex.Domain.Apps.Entities.Contents.Repositories;
using Squidex.Domain.Apps.Events; using Squidex.Domain.Apps.Events;
using Squidex.Domain.Apps.Events.Contents; using Squidex.Domain.Apps.Events.Contents;
@ -23,7 +24,7 @@ using Squidex.Text;
namespace Squidex.Domain.Apps.Entities.Contents namespace Squidex.Domain.Apps.Entities.Contents
{ {
public sealed class ContentChangedTriggerHandler : IRuleTriggerHandler public sealed class ContentChangedTriggerHandler : IRuleTriggerHandler, ISubscriptionEventCreator
{ {
private readonly IScriptEngine scriptEngine; private readonly IScriptEngine scriptEngine;
private readonly IContentLoader contentLoader; private readonly IContentLoader contentLoader;
@ -76,6 +77,18 @@ namespace Squidex.Domain.Apps.Entities.Contents
public async IAsyncEnumerable<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RuleContext context, public async IAsyncEnumerable<EnrichedEvent> CreateEnrichedEventsAsync(Envelope<AppEvent> @event, RuleContext context,
[EnumeratorCancellation] CancellationToken ct) [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; 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) 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); private sealed record CacheEntry(GraphQLSchema Model, string Hash, Instant Created);
public float SortOrder => 0;
public IServiceProvider Services public IServiceProvider Services
{ {
get => serviceProvider; get => serviceProvider;
@ -86,11 +88,12 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
private async Task<CacheEntry> CreateModelAsync(IAppEntity app) 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) 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 sealed class GraphQLOptions
{ {
public int CacheDuration { get; set; } = 10 * 60; 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 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)) 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 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.FindAsset);
AddField(SharedTypes.QueryAssets); 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.Resolvers;
using GraphQL.Types; using GraphQL.Types;
using Squidex.Domain.Apps.Core; 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.Domain.Apps.Entities.Assets;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Messaging.Subscriptions;
using Squidex.Shared;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets 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) new QueryArgument(Scalars.Int)
{ {
Name = "top", Name = "top",
Description = "Optional number of assets to take.", Description = FieldDescriptions.QueryTop,
DefaultValue = null DefaultValue = null
}, },
new QueryArgument(Scalars.Int) new QueryArgument(Scalars.Int)
{ {
Name = "skip", Name = "skip",
Description = "Optional number of assets to skip.", Description = FieldDescriptions.QuerySkip,
DefaultValue = 0 DefaultValue = 0
}, },
new QueryArgument(Scalars.String) new QueryArgument(Scalars.String)
{ {
Name = "filter", Name = "filter",
Description = "Optional OData filter.", Description = FieldDescriptions.QueryFilter,
DefaultValue = null DefaultValue = null
}, },
new QueryArgument(Scalars.String) new QueryArgument(Scalars.String)
{ {
Name = "orderby", Name = "orderby",
Description = "Optional OData order definition.", Description = FieldDescriptions.QueryOrderBy,
DefaultValue = null DefaultValue = null
} }
}; };
@ -113,5 +117,27 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets
fieldContext.CancellationToken); 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 namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets
{ {
internal sealed class AssetGraphType : ObjectGraphType<IEnrichedAssetEntity> internal sealed class AssetGraphType : SharedObjectGraphType<IEnrichedAssetEntity>
{ {
public AssetGraphType() 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 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) 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 FieldInputVisitor fieldInputVisitor;
private readonly PartitionResolver partitionResolver; private readonly PartitionResolver partitionResolver;
private readonly List<SchemaInfo> allSchemas = new List<SchemaInfo>(); private readonly List<SchemaInfo> allSchemas = new List<SchemaInfo>();
private readonly GraphQLOptions options;
static Builder() static Builder()
{ {
@ -40,12 +41,14 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
public IInterfaceGraphType ComponentInterface { get; } = new ComponentInterfaceGraphType(); public IInterfaceGraphType ComponentInterface { get; } = new ComponentInterfaceGraphType();
public Builder(IAppEntity app) public Builder(IAppEntity app, GraphQLOptions options)
{ {
partitionResolver = app.PartitionResolver(); partitionResolver = app.PartitionResolver();
fieldVisitor = new FieldVisitor(this); fieldVisitor = new FieldVisitor(this);
fieldInputVisitor = new FieldInputVisitor(this); fieldInputVisitor = new FieldInputVisitor(this);
this.options = options;
} }
public GraphQLSchema BuildSchema(IEnumerable<ISchemaEntity> schemas) public GraphQLSchema BuildSchema(IEnumerable<ISchemaEntity> schemas)
@ -73,7 +76,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
var newSchema = new GraphQLSchema var newSchema = new GraphQLSchema
{ {
Query = new AppQueriesGraphType(this, schemaInfos) Query = new ApplicationQueries(this, schemaInfos)
}; };
newSchema.RegisterType(ComponentInterface); newSchema.RegisterType(ComponentInterface);
@ -83,7 +86,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
if (schemaInfos.Any()) if (schemaInfos.Any())
{ {
var mutations = new AppMutationsGraphType(this, schemaInfos); var mutations = new ApplicationMutations(this, schemaInfos);
if (mutations.Fields.Count > 0) 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) foreach (var (schemaInfo, contentType) in contentTypes)
{ {
contentType.Initialize(this, schemaInfo, schemaInfos); contentType.Initialize(this, schemaInfo, schemaInfos);

141
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Contents/ContentActions.cs

@ -11,10 +11,10 @@ using GraphQL.Types;
using NodaTime; using NodaTime;
using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.Contents; 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.Domain.Apps.Entities.Contents.Commands;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Translations;
using Squidex.Shared; using Squidex.Shared;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Contents 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 command = new CreateContent
var contentData = c.GetArgument<ContentData>("data")!;
var contentStatus = c.GetArgument<string?>("status");
var command = new CreateContent { Data = contentData };
if (!string.IsNullOrWhiteSpace(contentId))
{ {
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")) 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 contentId = c.GetArgument<DomainId>("id"); var command = new UpsertContent
var contentData = c.GetArgument<ContentData>("data")!; {
var contentStatus = c.GetArgument<string?>("status"); // The data is converted from input args.
var patch = c.GetArgument<bool>("patch"); Data = c.GetArgument<ContentData>("data"),
var command = new UpsertContent { ContentId = contentId, Data = contentData, Patch = patch }; // True, to make a path, if the content exits.
Patch = c.GetArgument<bool>("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")) 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"); return new PatchContent
var contentData = c.GetArgument<ContentData>("data")!; {
// The data is converted from input args.
return new UpdateContent { ContentId = contentId, Data = contentData }; 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"); return new PatchContent
var contentData = c.GetArgument<ContentData>("data")!; {
// The data is converted from input args.
return new PatchContent { ContentId = contentId, Data = contentData }; 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"); return new ChangeContentStatus
var contentStatus = c.GetArgument<Status>("status"); {
var contentDueTime = c.GetArgument<Instant?>("dueTime"); // 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();
return new DeleteContent { ContentId = contentId };
}); });
} }
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(); new QueryArgument(Scalars.EnrichedContentEventType)
{
CheckPermission(permissionId, context, schemaId); Name = "type",
Description = FieldDescriptions.EventType,
var contentCommand = action(fieldContext); DefaultValue = null
},
contentCommand.SchemaId = schemaId; new QueryArgument(Scalars.String)
contentCommand.ExpectedVersion = fieldContext.GetArgument("expectedVersion", EtagVersion.Any); {
Name = "schemaName",
Description = FieldDescriptions.ContentSchemaName,
DefaultValue = null
}
};
var commandBus = context.Resolve<ICommandBus>(); public static readonly ISourceStreamResolver Resolver = Resolvers.Stream(PermissionIds.AppContentsRead, c =>
var commandContext = await commandBus.PublishAsync(contentCommand, fieldContext.CancellationToken); {
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 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(); public static readonly IGraphType Nullable = new EntitySavedGraphType();

133
backend/src/Squidex.Domain.Apps.Entities/Contents/GraphQL/Types/Resolvers.cs

@ -8,8 +8,14 @@
using GraphQL; using GraphQL;
using GraphQL.Resolvers; using GraphQL.Resolvers;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Squidex.Domain.Apps.Core.Subscriptions;
using Squidex.Infrastructure; using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Security;
using Squidex.Infrastructure.Translations;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
using Squidex.Messaging.Subscriptions;
using Squidex.Shared;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types 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); 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; protected async ValueTask<TOut> ResolveWithErrorHandlingAsync(IResolveFieldContext context)
public SyncResolver(Func<TSource, IResolveFieldContext, GraphQLExecutionContext, T> resolver)
{
this.resolver = resolver;
}
public ValueTask<object?> ResolveAsync(IResolveFieldContext context)
{ {
var executionContext = (GraphQLExecutionContext)context.UserContext!; var executionContext = (GraphQLExecutionContext)context.UserContext!;
try try
{ {
var result = resolver((TSource)context.Source!, context, executionContext); return await ResolveCoreAsync(context, executionContext);
return new ValueTask<object?>(result);
} }
catch (ValidationException ex) catch (ValidationException ex)
{ {
@ -70,9 +66,31 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
throw; 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; private readonly Func<TSource, IResolveFieldContext, GraphQLExecutionContext, Task<T>> resolver;
@ -81,32 +99,85 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types
this.resolver = resolver; 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
} {
catch (ValidationException ex) private readonly Func<IResolveFieldContext, GraphQLExecutionContext, IObservable<object?>> resolver;
public SyncStreamResolver(Func<IResolveFieldContext, GraphQLExecutionContext, IObservable<object?>> resolver)
{
this.resolver = resolver;
}
protected override ValueTask<IObservable<object?>> ResolveCoreAsync(IResolveFieldContext context, GraphQLExecutionContext executionContext)
{
return new ValueTask<IObservable<object?>>(resolver(context, executionContext));
}
public ValueTask<IObservable<object?>> ResolveAsync(IResolveFieldContext context)
{
return ResolveWithErrorHandlingAsync(context);
}
}
public static IFieldResolver Command(string permissionId, Func<IResolveFieldContext, ICommand> action)
{
return new AsyncResolver<object, object>(async (source, fieldContext, context) =>
{
var schemaId = fieldContext.FieldDefinition.SchemaNamedId();
if (!context.Context.Allows(permissionId, schemaId?.Name ?? Permission.Any))
{ {
throw new ExecutionError(ex.Message); throw new DomainForbiddenException(T.Get("common.errorNoPermission"));
} }
catch (DomainException ex)
var command = action(fieldContext);
// The app identifier is set from the http context.
if (command is ISchemaCommand schemaCommand && schemaId != null)
{ {
throw new ExecutionError(ex.Message); schemaCommand.SchemaId = schemaId;
} }
catch (Exception ex)
{
var logFactory = executionContext.Resolve<ILoggerFactory>();
logFactory.CreateLogger("GraphQL").LogError(ex, "Failed to resolve field {field}.", context.FieldDefinition.Name); command.ExpectedVersion = fieldContext.GetArgument("expectedVersion", EtagVersion.Any);
throw;
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 GraphQL.Types;
using Squidex.Domain.Apps.Core.Assets; using Squidex.Domain.Apps.Core.Assets;
using Squidex.Domain.Apps.Core.Rules.EnrichedEvents;
using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Primitives; using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Primitives;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types 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 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 NonNullInt = new NonNullGraphType(Int);
public static readonly IGraphType NonNullLong = new NonNullGraphType(Long); 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 NonNullDateTime = new NonNullGraphType(DateTime);
public static readonly IGraphType NonNullAssetType = new NonNullGraphType(AssetType); 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 GraphQL.Types;
using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Assets; 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; using Squidex.Domain.Apps.Entities.Contents.GraphQL.Types.Directives;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL.Types 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 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 CacheDirective MemoryCacheDirective = new CacheDirective();
public static readonly FieldType FindAsset = new FieldType 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 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(); 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 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); 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>
<ItemGroup> <ItemGroup>
<PackageReference Include="CsvHelper" Version="27.2.1" /> <PackageReference Include="CsvHelper" Version="27.2.1" />
<PackageReference Include="GraphQL" Version="5.3.0" /> <PackageReference Include="GraphQL" Version="7.0.2" />
<PackageReference Include="GraphQL.DataLoader" Version="5.3.0" /> <PackageReference Include="GraphQL.DataLoader" Version="7.0.2" />
<PackageReference Include="Meziantou.Analyzer" Version="1.0.702"> <PackageReference Include="Meziantou.Analyzer" Version="1.0.702">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <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> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="NodaTime" Version="3.1.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<AdditionalFiles Include="..\..\stylecop.json" Link="stylecop.json" /> <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> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" /> <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="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Security.Principal.Windows" Version="5.0.0" /> <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> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="MongoDB.Driver" Version="2.16.1" /> <PackageReference Include="MongoDB.Driver" Version="2.17.1" />
<PackageReference Include="MongoDB.Driver.GridFS" Version="2.16.1" /> <PackageReference Include="MongoDB.Driver.GridFS" Version="2.17.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Threading.Tasks.Dataflow" Version="6.0.0" /> <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); state = new SimpleState<EventConsumerState>(persistenceFactory, GetType(), eventConsumer.Name);
} }
public virtual Task InitializeAsync( public virtual async Task InitializeAsync(
CancellationToken ct) 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() 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); return new EventConsumerState(position, Count + offset);
} }

2
backend/src/Squidex.Infrastructure/EventSourcing/IEventConsumer.cs

@ -17,6 +17,8 @@ namespace Squidex.Infrastructure.EventSourcing
string EventsFilter => ".*"; string EventsFilter => ".*";
bool StartLatest => false;
bool CanClear => true; bool CanClear => true;
bool Handles(StoredEvent @event) 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.Extensions.Diagnostics.HealthChecks.Abstractions" Version="6.0.5" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.2.0" /> <PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.2.0" />
<PackageReference Include="Microsoft.OData.Core" Version="7.11.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="OpenTelemetry.Api" Version="1.3.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets" Version="3.6.0" /> <PackageReference Include="Squidex.Assets" Version="4.11.0" />
<PackageReference Include="Squidex.Caching" Version="1.9.0" /> <PackageReference Include="Squidex.Caching" Version="4.11.0" />
<PackageReference Include="Squidex.Hosting.Abstractions" Version="2.13.0" /> <PackageReference Include="Squidex.Hosting.Abstractions" Version="4.11.0" />
<PackageReference Include="Squidex.Log" Version="1.6.0" /> <PackageReference Include="Squidex.Log" Version="4.11.0" />
<PackageReference Include="Squidex.Messaging" Version="2.1.0" /> <PackageReference Include="Squidex.Messaging" Version="4.11.0" />
<PackageReference Include="Squidex.Text" Version="1.7.0" /> <PackageReference Include="Squidex.Text" Version="4.11.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="6.0.0" /> <PackageReference Include="System.Collections.Immutable" Version="6.0.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.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) }); 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. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using GraphQL;
using GraphQL.Server.Transports.AspNetCore; using GraphQL.Server.Transports.AspNetCore;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
namespace Squidex.Web.GraphQL namespace Squidex.Web.GraphQL
{ {
@ -15,14 +15,18 @@ namespace Squidex.Web.GraphQL
{ {
private readonly GraphQLHttpMiddleware<DummySchema> middleware; 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) 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 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) if (app == null)
{ {

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

@ -13,9 +13,9 @@
<FrameworkReference Include="Microsoft.AspNetCore.App" /> <FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="GraphQL" Version="5.3.0" /> <PackageReference Include="GraphQL" Version="7.0.2" />
<PackageReference Include="GraphQL.SystemTextJson" Version="5.3.0" /> <PackageReference Include="GraphQL.SystemTextJson" Version="7.0.2" />
<PackageReference Include="GraphQL.Server.Transports.AspNetCore" Version="6.1.0" /> <PackageReference Include="GraphQL.Server.Transports.AspNetCore" Version="7.0.0" />
<PackageReference Include="Meziantou.Analyzer" Version="1.0.702"> <PackageReference Include="Meziantou.Analyzer" Version="1.0.702">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <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>() services.AddSingletonAs<UserFluidExtension>()
.As<IFluidExtension>(); .As<IFluidExtension>();
services.AddSingletonAs<SimplePubSub>()
.As<IPubSub>();
} }
public static void AddSquidexUsageTracking(this IServiceCollection services, IConfiguration config) 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;
using Squidex.Domain.Apps.Core.HandleRules.Extensions; using Squidex.Domain.Apps.Core.HandleRules.Extensions;
using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Scripting;
using Squidex.Domain.Apps.Core.Subscriptions;
using Squidex.Domain.Apps.Core.Templates; using Squidex.Domain.Apps.Core.Templates;
using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Comments; using Squidex.Domain.Apps.Entities.Comments;
@ -34,13 +35,13 @@ namespace Squidex.Config.Domain
.As<IEventEnricher>(); .As<IEventEnricher>();
services.AddSingletonAs<AssetChangedTriggerHandler>() services.AddSingletonAs<AssetChangedTriggerHandler>()
.As<IRuleTriggerHandler>(); .As<IRuleTriggerHandler>().As<ISubscriptionEventCreator>();
services.AddSingletonAs<CommentTriggerHandler>() services.AddSingletonAs<CommentTriggerHandler>()
.As<IRuleTriggerHandler>(); .As<IRuleTriggerHandler>();
services.AddSingletonAs<ContentChangedTriggerHandler>() services.AddSingletonAs<ContentChangedTriggerHandler>()
.As<IRuleTriggerHandler>(); .As<IRuleTriggerHandler>().As<ISubscriptionEventCreator>();
services.AddSingletonAs<AssetsFluidExtension>() services.AddSingletonAs<AssetsFluidExtension>()
.As<IFluidExtension>(); .As<IFluidExtension>();

50
backend/src/Squidex/Config/Messaging/MessagingServices.cs

@ -6,6 +6,7 @@
// ========================================================================== // ==========================================================================
using System.Text.Json; using System.Text.Json;
using Squidex.Domain.Apps.Core.Subscriptions;
using Squidex.Domain.Apps.Entities.Apps.Plans; using Squidex.Domain.Apps.Entities.Apps.Plans;
using Squidex.Domain.Apps.Entities.Assets; using Squidex.Domain.Apps.Entities.Assets;
using Squidex.Domain.Apps.Entities.Backup; 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;
using Squidex.Domain.Apps.Entities.Rules.Runner; using Squidex.Domain.Apps.Entities.Rules.Runner;
using Squidex.Domain.Apps.Entities.Rules.UsageTracking; using Squidex.Domain.Apps.Entities.Rules.UsageTracking;
using Squidex.Infrastructure.EventSourcing;
using Squidex.Infrastructure.EventSourcing.Consume; using Squidex.Infrastructure.EventSourcing.Consume;
using Squidex.Messaging; using Squidex.Messaging;
using Squidex.Messaging.Implementation; using Squidex.Messaging.Implementation;
using Squidex.Messaging.Implementation.Null;
using Squidex.Messaging.Implementation.Scheduler; using Squidex.Messaging.Implementation.Scheduler;
using Squidex.Messaging.Subscriptions;
namespace Squidex.Config.Messaging namespace Squidex.Config.Messaging
{ {
@ -24,9 +28,14 @@ namespace Squidex.Config.Messaging
{ {
public static void AddSquidexMessaging(this IServiceCollection services, IConfiguration config) public static void AddSquidexMessaging(this IServiceCollection services, IConfiguration config)
{ {
var worker = config.GetValue<bool>("clustering:worker"); var channelBackupRestore = new ChannelName("backup.restore");
var channelBackupStart = new ChannelName("backup.start");
if (worker) 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>() services.AddSingletonAs<AssetCleanupProcess>()
.AsSelf(); .AsSelf();
@ -53,34 +62,51 @@ namespace Squidex.Config.Messaging
.AsSelf().As<IMessageHandler>(); .AsSelf().As<IMessageHandler>();
} }
services.AddSingleton<ITransportSerializer>(c => services.AddSingleton<IMessagingSerializer>(c =>
new SystemTextJsonTransportSerializer(c.GetRequiredService<JsonSerializerOptions>())); 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.AddMessagingTransport(config);
services.AddMessaging(options => services.AddMessaging(options =>
{ {
options.Routing.Add(m => m is RuleRunnerRun, "rules.run"); options.Routing.Add(m => m is RuleRunnerRun, channelRules);
options.Routing.Add(m => m is BackupStart, "backup.start"); options.Routing.Add(m => m is BackupStart, channelBackupStart);
options.Routing.Add(m => m is BackupRestore, "backup.restore"); options.Routing.Add(m => m is BackupRestore, channelBackupRestore);
options.Routing.Add(_ => true, "default"); options.Routing.AddFallback(channelFallback);
}); });
services.AddMessaging("default", worker, options => services.AddMessaging(channelFallback, isWorker, options =>
{ {
options.Scheduler = InlineScheduler.Instance; options.Scheduler = InlineScheduler.Instance;
}); });
services.AddMessaging("backup.start", worker, options => services.AddMessaging(channelBackupStart, isWorker, options =>
{ {
options.Scheduler = new ParallelScheduler(4); options.Scheduler = new ParallelScheduler(4);
}); });
services.AddMessaging("backup.restore", worker, options => services.AddMessaging(channelBackupRestore, isWorker, options =>
{ {
options.Scheduler = InlineScheduler.Instance; options.Scheduler = InlineScheduler.Instance;
}); });
services.AddMessaging("rules.run", worker, options => services.AddMessaging(channelRules, isWorker, options =>
{ {
options.Scheduler = new ParallelScheduler(4); options.Scheduler = new ParallelScheduler(4);
}); });

5
backend/src/Squidex/Config/Web/WebServices.cs

@ -6,11 +6,8 @@
// ========================================================================== // ==========================================================================
using GraphQL; using GraphQL;
using GraphQL.DataLoader;
using GraphQL.DI; using GraphQL.DI;
using GraphQL.MicrosoftDI;
using GraphQL.Server.Transports.AspNetCore; using GraphQL.Server.Transports.AspNetCore;
using GraphQL.SystemTextJson;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure; using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding;
@ -101,7 +98,7 @@ namespace Squidex.Config.Web
{ {
services.AddGraphQL(builder => services.AddGraphQL(builder =>
{ {
builder.AddApolloTracing(); builder.UseApolloTracing();
builder.AddSchema<DummySchema>(); builder.AddSchema<DummySchema>();
builder.AddSystemTextJson(); builder.AddSystemTextJson();
builder.AddDataLoader(); builder.AddDataLoader();

28
backend/src/Squidex/Squidex.csproj

@ -35,9 +35,9 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="AspNet.Security.OAuth.GitHub" Version="6.0.6" /> <PackageReference Include="AspNet.Security.OAuth.GitHub" Version="6.0.6" />
<PackageReference Include="GraphQL" Version="5.3.0" /> <PackageReference Include="GraphQL" Version="7.0.2" />
<PackageReference Include="GraphQL.MicrosoftDI" Version="5.3.0" /> <PackageReference Include="GraphQL.MicrosoftDI" Version="7.0.2" />
<PackageReference Include="GraphQL.SystemTextJson" Version="5.3.0" /> <PackageReference Include="GraphQL.SystemTextJson" Version="7.0.2" />
<PackageReference Include="Meziantou.Analyzer" Version="1.0.702"> <PackageReference Include="Meziantou.Analyzer" Version="1.0.702">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@ -56,7 +56,7 @@
<PackageReference Include="Microsoft.IdentityModel.Protocols" Version="6.14.1" /> <PackageReference Include="Microsoft.IdentityModel.Protocols" Version="6.14.1" />
<PackageReference Include="Microsoft.OData.Core" Version="7.11.0" /> <PackageReference Include="Microsoft.OData.Core" Version="7.11.0" />
<PackageReference Include="Namotion.Reflection" Version="2.0.10" /> <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="MongoDB.Driver.Core.Extensions.OpenTelemetry" Version="1.0.0" />
<PackageReference Include="Namotion.Reflection" Version="2.0.10" ExcludeAssets="all" /> <PackageReference Include="Namotion.Reflection" Version="2.0.10" ExcludeAssets="all" />
<PackageReference Include="NetTopologySuite.IO.GeoJSON4STJ" Version="2.1.1" /> <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="OpenTelemetry.Instrumentation.Http" Version="1.0.0-rc7" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" /> <PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="ReportGenerator" Version="5.1.9" PrivateAssets="all" /> <PackageReference Include="ReportGenerator" Version="5.1.9" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets.Azure" Version="3.6.0" /> <PackageReference Include="Squidex.Assets.Azure" Version="4.11.0" />
<PackageReference Include="Squidex.Assets.GoogleCloud" Version="3.6.0" /> <PackageReference Include="Squidex.Assets.GoogleCloud" Version="4.11.0" />
<PackageReference Include="Squidex.Assets.FTP" Version="3.6.0" /> <PackageReference Include="Squidex.Assets.FTP" Version="4.11.0" />
<PackageReference Include="Squidex.Assets.ImageMagick" Version="3.6.0" /> <PackageReference Include="Squidex.Assets.ImageMagick" Version="4.11.0" />
<PackageReference Include="Squidex.Assets.ImageSharp" Version="3.6.0" /> <PackageReference Include="Squidex.Assets.ImageSharp" Version="4.11.0" />
<PackageReference Include="Squidex.Assets.Mongo" Version="3.6.0" /> <PackageReference Include="Squidex.Assets.Mongo" Version="4.11.0" />
<PackageReference Include="Squidex.Assets.S3" Version="3.6.0" /> <PackageReference Include="Squidex.Assets.S3" Version="4.11.0" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="3.6.0" /> <PackageReference Include="Squidex.Assets.TusAdapter" Version="4.11.0" />
<PackageReference Include="Squidex.Caching.Orleans" Version="1.9.0" /> <PackageReference Include="Squidex.Caching.Orleans" Version="1.9.0" />
<PackageReference Include="Squidex.ClientLibrary" Version="8.27.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.Namotion.Reflection" Version="2.0.10" />
<PackageReference Include="Squidex.OpenIddict.MongoDb" Version="4.0.1-dev" /> <PackageReference Include="Squidex.OpenIddict.MongoDb" Version="4.0.1-dev" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <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) public void Configure(IApplicationBuilder app)
{ {
app.UseWebSockets();
app.UseCookiePolicy(); app.UseCookiePolicy();
app.UseDefaultPathBase(); 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("2", "my-secret");
clients = clients.Add("3", "my-secret"); clients = clients.Add("3", "my-secret");
clients = clients.Add("4", "my-secret"); clients = clients.Add("4", "my-secret");
clients = clients.Update("3", role: Role.Editor); 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("2", name: "My Client 2");
clients = clients.Update("3", name: "My Client 3");
clients = clients.Update("1", allowAnonymous: true, apiCallsLimit: 3); clients = clients.Update("1", allowAnonymous: true, apiCallsLimit: 3);
clients = clients.Revoke("4"); clients = clients.Revoke("4");
var serialized = clients.SerializeAndDeserialize(); 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 FakeItEasy;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Squidex.Caching; using Squidex.Caching;
using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Core.TestHelpers;
@ -19,6 +18,7 @@ using Squidex.Infrastructure.Security;
using Squidex.Infrastructure.States; using Squidex.Infrastructure.States;
using Squidex.Infrastructure.TestHelpers; using Squidex.Infrastructure.TestHelpers;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
using Squidex.Messaging;
using Xunit; using Xunit;
namespace Squidex.Domain.Apps.Entities.Apps.Indexes namespace Squidex.Domain.Apps.Entities.Apps.Indexes
@ -41,7 +41,7 @@ namespace Squidex.Domain.Apps.Entities.Apps.Indexes
ct = cts.Token; ct = cts.Token;
var replicatedCache = 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 })); Options.Create(new ReplicatedCacheOptions { Enable = true }));
sut = new AppsIndex(appRepository, replicatedCache, state.PersistenceFactory); 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.Json;
using System.Text.RegularExpressions;
using FakeItEasy; using FakeItEasy;
using GraphQL; using GraphQL;
using NodaTime.Text; 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 var expected = new
{ {
@ -82,13 +81,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var query = CreateQuery(@" var query = CreateQuery(@"
mutation { mutation {
createMySchemaContent(data: <DATA>, publish: true) { createMySchemaContent(data: <DATA>, publish: true) {
<FIELDS> <FIELDS_CONTENT>
} }
}"); }", contentId, content);
commandContext.Complete(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 var expected = new
{ {
@ -116,13 +117,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var query = CreateQuery(@" var query = CreateQuery(@"
mutation { mutation {
createMySchemaContent(data: <DATA>, id: '123', publish: true) { createMySchemaContent(data: <DATA>, id: '123', publish: true) {
<FIELDS> <FIELDS_CONTENT>
} }
}"); }", contentId, content);
commandContext.Complete(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 var expected = new
{ {
@ -151,13 +154,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var query = CreateQuery(@" var query = CreateQuery(@"
mutation OP($data: MySchemaDataInputDto!) { mutation OP($data: MySchemaDataInputDto!) {
createMySchemaContent(data: $data, publish: true) { createMySchemaContent(data: $data, publish: true) {
<FIELDS> <FIELDS_CONTENT>
} }
}"); }", contentId, content);
commandContext.Complete(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 var expected = new
{ {
@ -187,9 +192,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
updateMySchemaContent(id: '<ID>', data: { myNumber: { iv: 42 } }) { updateMySchemaContent(id: '<ID>', data: { myNumber: { iv: 42 } }) {
id id
} }
}"); }", contentId, content);
var result = await ExecuteAsync(new ExecutionOptions { Query = query }, PermissionIds.AppContentsReadOwn); var result = await ExecuteAsync(new ExecutionOptions { Query = query });
var expected = new var expected = new
{ {
@ -227,13 +232,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var query = CreateQuery(@" var query = CreateQuery(@"
mutation { mutation {
updateMySchemaContent(id: '<ID>', data: <DATA>, expectedVersion: 10) { updateMySchemaContent(id: '<ID>', data: <DATA>, expectedVersion: 10) {
<FIELDS> <FIELDS_CONTENT>
} }
}"); }", contentId, content);
commandContext.Complete(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 var expected = new
{ {
@ -261,13 +268,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var query = CreateQuery(@" var query = CreateQuery(@"
mutation OP($data: MySchemaDataInputDto!) { mutation OP($data: MySchemaDataInputDto!) {
updateMySchemaContent(id: '<ID>', data: $data, expectedVersion: 10) { updateMySchemaContent(id: '<ID>', data: $data, expectedVersion: 10) {
<FIELDS> <FIELDS_CONTENT>
} }
}"); }", contentId, content);
commandContext.Complete(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 var expected = new
{ {
@ -297,9 +306,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
upsertMySchemaContent(id: '<ID>', data: { myNumber: { iv: 42 } }) { upsertMySchemaContent(id: '<ID>', data: { myNumber: { iv: 42 } }) {
id id
} }
}"); }", contentId, content);
var result = await ExecuteAsync(new ExecutionOptions { Query = query }, PermissionIds.AppContentsReadOwn); var result = await ExecuteAsync(new ExecutionOptions { Query = query });
var expected = new var expected = new
{ {
@ -337,13 +346,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var query = CreateQuery(@" var query = CreateQuery(@"
mutation { mutation {
upsertMySchemaContent(id: '<ID>', data: <DATA>, publish: true, expectedVersion: 10) { upsertMySchemaContent(id: '<ID>', data: <DATA>, publish: true, expectedVersion: 10) {
<FIELDS> <FIELDS_CONTENT>
} }
}"); }", contentId, content);
commandContext.Complete(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 var expected = new
{ {
@ -372,13 +383,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var query = CreateQuery(@" var query = CreateQuery(@"
mutation OP($data: MySchemaDataInputDto!) { mutation OP($data: MySchemaDataInputDto!) {
upsertMySchemaContent(id: '<ID>', data: $data, publish: true, expectedVersion: 10) { upsertMySchemaContent(id: '<ID>', data: $data, publish: true, expectedVersion: 10) {
<FIELDS> <FIELDS_CONTENT>
} }
}"); }", contentId, content);
commandContext.Complete(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 var expected = new
{ {
@ -409,9 +422,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
patchMySchemaContent(id: '<ID>', data: { myNumber: { iv: 42 } }) { patchMySchemaContent(id: '<ID>', data: { myNumber: { iv: 42 } }) {
id id
} }
}"); }", contentId, content);
var result = await ExecuteAsync(new ExecutionOptions { Query = query }, PermissionIds.AppContentsReadOwn); var result = await ExecuteAsync(new ExecutionOptions { Query = query });
var expected = new var expected = new
{ {
@ -449,13 +462,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var query = CreateQuery(@" var query = CreateQuery(@"
mutation { mutation {
patchMySchemaContent(id: '<ID>', data: <DATA>, expectedVersion: 10) { patchMySchemaContent(id: '<ID>', data: <DATA>, expectedVersion: 10) {
<FIELDS> <FIELDS_CONTENT>
} }
}"); }", contentId, content);
commandContext.Complete(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 var expected = new
{ {
@ -483,13 +498,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var query = CreateQuery(@" var query = CreateQuery(@"
mutation OP($data: MySchemaDataInputDto!) { mutation OP($data: MySchemaDataInputDto!) {
patchMySchemaContent(id: '<ID>', data: $data, expectedVersion: 10) { patchMySchemaContent(id: '<ID>', data: $data, expectedVersion: 10) {
<FIELDS> <FIELDS_CONTENT>
} }
}"); }", contentId, content);
commandContext.Complete(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 var expected = new
{ {
@ -519,9 +536,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
changeMySchemaContent(id: '<ID>', status: 'Published') { changeMySchemaContent(id: '<ID>', status: 'Published') {
id id
} }
}"); }", contentId, content);
var result = await ExecuteAsync(new ExecutionOptions { Query = query }, PermissionIds.AppContentsReadOwn); var result = await ExecuteAsync(new ExecutionOptions { Query = query });
var expected = new var expected = new
{ {
@ -561,13 +578,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var query = CreateQuery(@" var query = CreateQuery(@"
mutation { mutation {
changeMySchemaContent(id: '<ID>', status: 'Published', dueTime: '2021-12-12T11:10:09Z', expectedVersion: 10) { changeMySchemaContent(id: '<ID>', status: 'Published', dueTime: '2021-12-12T11:10:09Z', expectedVersion: 10) {
<FIELDS> <FIELDS_CONTENT>
} }
}"); }", contentId, content);
commandContext.Complete(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 var expected = new
{ {
@ -596,13 +615,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var query = CreateQuery(@" var query = CreateQuery(@"
mutation { mutation {
changeMySchemaContent(id: '<ID>', status: 'Published', expectedVersion: 10) { changeMySchemaContent(id: '<ID>', status: 'Published', expectedVersion: 10) {
<FIELDS> <FIELDS_CONTENT>
} }
}"); }", contentId, content);
commandContext.Complete(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 var expected = new
{ {
@ -631,13 +652,15 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var query = CreateQuery(@" var query = CreateQuery(@"
mutation { mutation {
changeMySchemaContent(id: '<ID>', status: 'Published', dueTime: null, expectedVersion: 10) { changeMySchemaContent(id: '<ID>', status: 'Published', dueTime: null, expectedVersion: 10) {
<FIELDS> <FIELDS_CONTENT>
} }
}"); }", contentId, content);
commandContext.Complete(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 var expected = new
{ {
@ -668,9 +691,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
deleteMySchemaContent(id: '<ID>') { deleteMySchemaContent(id: '<ID>') {
version version
} }
}"); }", contentId, content);
var result = await ExecuteAsync(new ExecutionOptions { Query = query }, PermissionIds.AppContentsReadOwn); var result = await ExecuteAsync(new ExecutionOptions { Query = query });
var expected = new var expected = new
{ {
@ -710,11 +733,13 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
deleteMySchemaContent(id: '<ID>', expectedVersion: 10) { deleteMySchemaContent(id: '<ID>', expectedVersion: 10) {
version version
} }
}"); }", contentId, content);
commandContext.Complete(CommandResult.Empty(contentId, 13, 12)); 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 var expected = new
{ {
@ -738,36 +763,6 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
.MustHaveHappened(); .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() private Inputs GetInput()
{ {
var input = new 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); 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);
}
}
}

134
backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/GraphQL/GraphQLTestBase.cs

@ -5,25 +5,27 @@
// All rights reserved. Licensed under the MIT license. // All rights reserved. Licensed under the MIT license.
// ========================================================================== // ==========================================================================
using System.Reactive.Linq;
using System.Reactive.Threading.Tasks;
using System.Text.RegularExpressions;
using FakeItEasy; using FakeItEasy;
using GraphQL; using GraphQL;
using GraphQL.DataLoader; using GraphQL.DataLoader;
using GraphQL.Execution; using GraphQL.Execution;
using GraphQL.SystemTextJson; using GraphQL.SystemTextJson;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Squidex.Caching;
using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core;
using Squidex.Domain.Apps.Core.ExtractReferenceIds; using Squidex.Domain.Apps.Core.ExtractReferenceIds;
using Squidex.Domain.Apps.Core.TestHelpers; using Squidex.Domain.Apps.Core.TestHelpers;
using Squidex.Domain.Apps.Entities.Assets; 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.Contents.TestData;
using Squidex.Domain.Apps.Entities.Schemas; using Squidex.Domain.Apps.Entities.Schemas;
using Squidex.Domain.Apps.Entities.TestHelpers; using Squidex.Domain.Apps.Entities.TestHelpers;
using Squidex.Infrastructure;
using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.Tasks;
using Squidex.Messaging.Subscriptions;
using Squidex.Shared; using Squidex.Shared;
using Squidex.Shared.Users; using Squidex.Shared.Users;
using Xunit; using Xunit;
@ -32,17 +34,18 @@ using Xunit;
namespace Squidex.Domain.Apps.Entities.Contents.GraphQL 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 GraphQLSerializer serializer = new GraphQLSerializer(TestUtils.DefaultOptions());
protected readonly IAssetQueryService assetQuery = A.Fake<IAssetQueryService>(); protected readonly IAssetQueryService assetQuery = A.Fake<IAssetQueryService>();
protected readonly ICommandBus commandBus = A.Fake<ICommandBus>(); protected readonly ICommandBus commandBus = A.Fake<ICommandBus>();
protected readonly IContentQueryService contentQuery = A.Fake<IContentQueryService>(); protected readonly IContentQueryService contentQuery = A.Fake<IContentQueryService>();
protected readonly ISubscriptionService subscriptionService = A.Fake<ISubscriptionService>();
protected readonly IUserResolver userResolver = A.Fake<IUserResolver>(); protected readonly IUserResolver userResolver = A.Fake<IUserResolver>();
protected readonly Context requestContext; protected readonly Context requestContext;
private CachingGraphQLResolver sut; private CachingGraphQLResolver? sut;
public GraphQLTestBase() protected GraphQLTestBase()
{ {
A.CallTo(() => userResolver.QueryManyAsync(A<string[]>._, default)) A.CallTo(() => userResolver.QueryManyAsync(A<string[]>._, default))
.ReturnsLazily(x => .ReturnsLazily(x =>
@ -65,40 +68,60 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
Assert.Equal(isonOutputExpected, jsonOutputResult); Assert.Equal(isonOutputExpected, jsonOutputResult);
} }
protected Task<ExecutionResult> ExecuteAsync(ExecutionOptions options, string? permissionId = null) protected Task<ExecutionResult> ExecuteAsync(ExecutionOptions options)
{ {
var context = requestContext; return ExecuteCoreAsync(options, 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 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); 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>>()) foreach (var listener in sut.Services.GetRequiredService<IEnumerable<IDocumentExecutionListener>>())
{ {
options.Listeners.Add(listener); options.Listeners.Add(listener);
} }
// Enrich the context with the schema.
await sut.ExecuteAsync(options, x => Task.FromResult<ExecutionResult>(null!)); 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>(); var appProvider = A.Fake<IAppProvider>();
A.CallTo(() => appProvider.GetSchemasAsync(TestApp.Default.Id, default)) A.CallTo(() => appProvider.GetSchemasAsync(TestApp.Default.Id, default))
@ -106,8 +129,9 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
var serviceProvider = var serviceProvider =
new ServiceCollection() new ServiceCollection()
.AddLogging()
.AddMemoryCache() .AddMemoryCache()
.AddTransient<GraphQLExecutionContext>() .AddBackgroundCache()
.Configure<AssetOptions>(x => .Configure<AssetOptions>(x =>
{ {
x.CanCache = true; x.CanCache = true;
@ -116,6 +140,7 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
{ {
x.CanCache = true; x.CanCache = true;
}) })
.AddSingleton<StringReferenceExtractor>()
.AddSingleton<IDocumentExecutionListener, .AddSingleton<IDocumentExecutionListener,
DataLoaderDocumentListener>() DataLoaderDocumentListener>()
.AddSingleton<IDataLoaderContextAccessor, .AddSingleton<IDataLoaderContextAccessor,
@ -126,21 +151,70 @@ namespace Squidex.Domain.Apps.Entities.Contents.GraphQL
ContentCache>() ContentCache>()
.AddSingleton<IUrlGenerator, .AddSingleton<IUrlGenerator,
FakeUrlGenerator>() FakeUrlGenerator>()
.AddSingleton(A.Fake<ILoggerFactory>()) .AddSingleton(
A.Fake<ILoggerFactory>())
.AddSingleton(
A.Fake<ISchemasHash>())
.AddSingleton(appProvider) .AddSingleton(appProvider)
.AddSingleton(assetQuery) .AddSingleton(assetQuery)
.AddSingleton(commandBus) .AddSingleton(commandBus)
.AddSingleton(contentQuery) .AddSingleton(contentQuery)
.AddSingleton(subscriptionService)
.AddSingleton(userResolver) .AddSingleton(userResolver)
.AddSingleton<InstantGraphType>()
.AddSingleton<JsonGraphType>()
.AddSingleton<JsonNoopGraphType>()
.AddSingleton<StringReferenceExtractor>()
.BuildServiceProvider(); .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 FakeItEasy;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Squidex.Caching; using Squidex.Caching;
using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Schemas;
@ -18,6 +17,7 @@ using Squidex.Infrastructure.Commands;
using Squidex.Infrastructure.States; using Squidex.Infrastructure.States;
using Squidex.Infrastructure.TestHelpers; using Squidex.Infrastructure.TestHelpers;
using Squidex.Infrastructure.Validation; using Squidex.Infrastructure.Validation;
using Squidex.Messaging;
using Xunit; using Xunit;
namespace Squidex.Domain.Apps.Entities.Schemas.Indexes namespace Squidex.Domain.Apps.Entities.Schemas.Indexes
@ -40,7 +40,7 @@ namespace Squidex.Domain.Apps.Entities.Schemas.Indexes
ct = cts.Token; ct = cts.Token;
var replicatedCache = 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 })); Options.Create(new ReplicatedCacheOptions { Enable = true }));
sut = new SchemasIndex(schemaRepository, replicatedCache, state.PersistenceFactory); 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> <ItemGroup>
<PackageReference Include="FakeItEasy" Version="7.3.1" /> <PackageReference Include="FakeItEasy" Version="7.3.1" />
<PackageReference Include="FluentAssertions" Version="6.7.0" /> <PackageReference Include="FluentAssertions" Version="6.7.0" />
<PackageReference Include="GraphQL" Version="5.3.0" /> <PackageReference Include="GraphQL" Version="7.0.2" />
<PackageReference Include="GraphQL.SystemTextJson" Version="5.3.0" /> <PackageReference Include="GraphQL.SystemTextJson" Version="7.0.2" />
<PackageReference Include="Lorem.Universal.Net" Version="4.0.80" /> <PackageReference Include="Lorem.Universal.Net" Version="4.0.80" />
<PackageReference Include="Meziantou.Analyzer" Version="1.0.702"> <PackageReference Include="Meziantou.Analyzer" Version="1.0.702">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
@ -37,6 +37,7 @@
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" /> <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.0.0" />
<PackageReference Include="Squidex.Caching.Orleans" Version="1.9.0" /> <PackageReference Include="Squidex.Caching.Orleans" Version="1.9.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" /> <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="System.ValueTuple" Version="4.5.0" />
<PackageReference Include="xunit" Version="2.4.1" /> <PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5"> <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>>()); 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] [Fact]
public async Task Should_not_subscribe_to_event_store_if_stopped_in_db() 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 System.Collections;
using Squidex.Infrastructure.TestHelpers;
using Xunit; using Xunit;
namespace Squidex.Infrastructure.Security namespace Squidex.Infrastructure.Security
@ -131,5 +132,15 @@ namespace Squidex.Infrastructure.Security
Assert.True(sut.Includes(new Permission("admin"))); 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", "@angular/router": "13.1.1",
"@babel/runtime": "^7.16.7", "@babel/runtime": "^7.16.7",
"@egjs/hammerjs": "2.0.17", "@egjs/hammerjs": "2.0.17",
"@graphiql/toolkit": "^0.6.1",
"ace-builds": "1.4.13", "ace-builds": "1.4.13",
"angular-gridster2": "11.2.0", "angular-gridster2": "11.2.0",
"angular-mentions": "1.4.0", "angular-mentions": "1.4.0",
@ -4086,6 +4087,27 @@
"integrity": "sha512-82cpyJyKRoQoRi+14ibCeGPu0CwypgtBAdBhq1WfvagpCZNKqwXbKwXllYSMG91DhmG4jt9gN8eP6lGOtozuaw==", "integrity": "sha512-82cpyJyKRoQoRi+14ibCeGPu0CwypgtBAdBhq1WfvagpCZNKqwXbKwXllYSMG91DhmG4jt9gN8eP6lGOtozuaw==",
"dev": true "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": { "node_modules/@graphql-tools/batch-execute": {
"version": "8.3.1", "version": "8.3.1",
"resolved": "https://registry.npmjs.org/@graphql-tools/batch-execute/-/batch-execute-8.3.1.tgz", "resolved": "https://registry.npmjs.org/@graphql-tools/batch-execute/-/batch-execute-8.3.1.tgz",
@ -39543,6 +39565,22 @@
"integrity": "sha512-82cpyJyKRoQoRi+14ibCeGPu0CwypgtBAdBhq1WfvagpCZNKqwXbKwXllYSMG91DhmG4jt9gN8eP6lGOtozuaw==", "integrity": "sha512-82cpyJyKRoQoRi+14ibCeGPu0CwypgtBAdBhq1WfvagpCZNKqwXbKwXllYSMG91DhmG4jt9gN8eP6lGOtozuaw==",
"dev": true "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": { "@graphql-tools/batch-execute": {
"version": "8.3.1", "version": "8.3.1",
"resolved": "https://registry.npmjs.org/@graphql-tools/batch-execute/-/batch-execute-8.3.1.tgz", "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", "@angular/router": "13.1.1",
"@babel/runtime": "^7.16.7", "@babel/runtime": "^7.16.7",
"@egjs/hammerjs": "2.0.17", "@egjs/hammerjs": "2.0.17",
"@graphiql/toolkit": "^0.6.1",
"ace-builds": "1.4.13", "ace-builds": "1.4.13",
"angular-gridster2": "11.2.0", "angular-gridster2": "11.2.0",
"angular-mentions": "1.4.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 { AfterViewInit, Component, ElementRef, ViewChild } from '@angular/core';
import { createGraphiQLFetcher } from '@graphiql/toolkit';
import GraphiQL from 'graphiql'; import GraphiQL from 'graphiql';
import * as React from 'react'; import * as React from 'react';
import * as ReactDOM from 'react-dom'; import * as ReactDOM from 'react-dom';
import { firstValueFrom, of } from 'rxjs'; import { ApiUrlConfig, AppsState, AuthService } from '@app/shared';
import { catchError } from 'rxjs/operators';
import { AppsState, GraphQlService } from '@app/shared';
@Component({ @Component({
selector: 'sqx-graphql-page', selector: 'sqx-graphql-page',
@ -24,23 +23,33 @@ export class GraphQLPageComponent implements AfterViewInit {
constructor( constructor(
private readonly appsState: AppsState, private readonly appsState: AppsState,
private readonly graphQlService: GraphQlService, private readonly apiUrl: ApiUrlConfig,
private readonly authService: AuthService,
) { ) {
} }
public ngAfterViewInit() { 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( ReactDOM.render(
React.createElement(GraphiQL, { React.createElement(GraphiQL, {
fetcher: (params: any) => { fetcher,
return firstValueFrom(this.request(params));
},
}), }),
this.graphiQLContainer.nativeElement, 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/comments.service';
export * from './services/contents.service'; export * from './services/contents.service';
export * from './services/contributors.service'; export * from './services/contributors.service';
export * from './services/graphql.service';
export * from './services/help.service'; export * from './services/help.service';
export * from './services/history.service'; export * from './services/history.service';
export * from './services/languages.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 { MentionModule } from 'angular-mentions';
import { NgxDocViewerModule } from 'ngx-doc-viewer'; import { NgxDocViewerModule } from 'ngx-doc-viewer';
import { SqxFrameworkModule } from '@app/framework'; 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({ @NgModule({
imports: [ imports: [
@ -164,7 +164,6 @@ export class SqxSharedModule {
ContentsState, ContentsState,
ContributorsService, ContributorsService,
ContributorsState, ContributorsState,
GraphQlService,
HelpService, HelpService,
HistoryService, HistoryService,
LanguagesService, 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