diff --git a/docs/schema/V1/schema.verified.graphql b/docs/schema/V1/schema.verified.graphql index 2a23d37fb..5cf445aab 100644 --- a/docs/schema/V1/schema.verified.graphql +++ b/docs/schema/V1/schema.verified.graphql @@ -1,5 +1,6 @@ schema { query: Queries + subscription: Subscriptions } interface DialogByIdError { @@ -201,6 +202,10 @@ type SeenLog { isCurrentEndUser: Boolean! } +type Subscriptions @authorize(policy: "enduser") { + dialogUpdated(dialogId: UUID!): UUID! +} + type Transmission { id: UUID! createdAt: DateTime! diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/DialogById/Subscriptions.cs b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/DialogById/Subscriptions.cs new file mode 100644 index 000000000..85298d33a --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.GraphQL/EndUser/DialogById/Subscriptions.cs @@ -0,0 +1,20 @@ +using Digdir.Domain.Dialogporten.GraphQL.Common.Authorization; +using HotChocolate.Authorization; +using Constants = Digdir.Domain.Dialogporten.Infrastructure.GraphQl.GraphQlSubscriptionConstants; + +// ReSharper disable ClassNeverInstantiated.Global + +namespace Digdir.Domain.Dialogporten.GraphQL.EndUser.DialogById; + +[Authorize(Policy = AuthorizationPolicy.EndUser)] +public sealed class Subscriptions +{ + [Subscribe] + [Topic($"{Constants.DialogUpdatedTopic}{{{nameof(dialogId)}}}")] + public Guid DialogUpdated(Guid dialogId, + [EventMessage] Guid eventMessage) + { + ArgumentNullException.ThrowIfNull(eventMessage); + return dialogId; + } +} diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/Program.cs b/src/Digdir.Domain.Dialogporten.GraphQL/Program.cs index bc73a3cf4..59f246397 100644 --- a/src/Digdir.Domain.Dialogporten.GraphQL/Program.cs +++ b/src/Digdir.Domain.Dialogporten.GraphQL/Program.cs @@ -76,10 +76,21 @@ static void BuildAndRun(string[] args) .AddApplicationInsightsTelemetry() .AddScoped() .AddValidatorsFromAssembly(thisAssembly, ServiceLifetime.Transient, includeInternalTypes: true) - .AddAzureAppConfiguration() + .AddAzureAppConfiguration(); - // Graph QL - .AddDialogportenGraphQl() + var infrastructureConfigurationSection = + builder.Configuration.GetSection(InfrastructureSettings.ConfigurationSectionName); + + builder.Services.AddOptions() + .Bind(infrastructureConfigurationSection) + .ValidateFluently() + .ValidateOnStart(); + + var infrastructureSettings = infrastructureConfigurationSection.Get() ?? throw new InvalidOperationException("Infrastructure settings must not be null."); + + // Graph QL + builder.Services + .AddDialogportenGraphQl(infrastructureSettings.Redis.ConnectionString) // Auth .AddDialogportenAuthentication(builder.Configuration) diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/ServiceCollectionExtensions.cs b/src/Digdir.Domain.Dialogporten.GraphQL/ServiceCollectionExtensions.cs index 9d7552ba0..0bdfeecdb 100644 --- a/src/Digdir.Domain.Dialogporten.GraphQL/ServiceCollectionExtensions.cs +++ b/src/Digdir.Domain.Dialogporten.GraphQL/ServiceCollectionExtensions.cs @@ -2,16 +2,25 @@ using Digdir.Domain.Dialogporten.GraphQL.EndUser.DialogById; using Digdir.Domain.Dialogporten.GraphQL.EndUser.SearchDialogs; using Digdir.Domain.Dialogporten.Infrastructure.Persistence; +using HotChocolate.Subscriptions; +using StackExchange.Redis; +using Constants = Digdir.Domain.Dialogporten.Infrastructure.GraphQl.GraphQlSubscriptionConstants; namespace Digdir.Domain.Dialogporten.GraphQL; public static class ServiceCollectionExtensions { - public static IServiceCollection AddDialogportenGraphQl( - this IServiceCollection services) + public static IServiceCollection AddDialogportenGraphQl(this IServiceCollection services, + string redisConnectionString) { return services .AddGraphQLServer() + .AddRedisSubscriptions(_ => ConnectionMultiplexer.Connect(redisConnectionString), + new SubscriptionOptions + { + TopicPrefix = Constants.SubscriptionTopicPrefix + }) + .AddSubscriptionType() .AddAuthorization() .RegisterDbContext() .AddDiagnosticEventListener() diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Digdir.Domain.Dialogporten.Infrastructure.csproj b/src/Digdir.Domain.Dialogporten.Infrastructure/Digdir.Domain.Dialogporten.Infrastructure.csproj index cc498524f..a1896bbf7 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Digdir.Domain.Dialogporten.Infrastructure.csproj +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Digdir.Domain.Dialogporten.Infrastructure.csproj @@ -2,6 +2,7 @@ + diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/DomainEvents/Outbox/ConvertDomainEventsToOutboxMessagesInterceptor.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/DomainEvents/Outbox/ConvertDomainEventsToOutboxMessagesInterceptor.cs index 6d897bdcb..3c41211f4 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/DomainEvents/Outbox/ConvertDomainEventsToOutboxMessagesInterceptor.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/DomainEvents/Outbox/ConvertDomainEventsToOutboxMessagesInterceptor.cs @@ -1,17 +1,25 @@ using Digdir.Domain.Dialogporten.Application.Common; +using Digdir.Domain.Dialogporten.Domain.Dialogs.Events; using Digdir.Domain.Dialogporten.Domain.Outboxes; using Digdir.Library.Entity.Abstractions.Features.EventPublisher; +using HotChocolate.Subscriptions; using Microsoft.EntityFrameworkCore.Diagnostics; +using Constants = Digdir.Domain.Dialogporten.Infrastructure.GraphQl.GraphQlSubscriptionConstants; namespace Digdir.Domain.Dialogporten.Infrastructure.DomainEvents.Outbox; internal sealed class ConvertDomainEventsToOutboxMessagesInterceptor : SaveChangesInterceptor { private readonly ITransactionTime _transactionTime; + private readonly ITopicEventSender _topicEventSender; + private List _domainEvents = []; - public ConvertDomainEventsToOutboxMessagesInterceptor(ITransactionTime transactionTime) + public ConvertDomainEventsToOutboxMessagesInterceptor( + ITransactionTime transactionTime, + ITopicEventSender topicEventSender) { _transactionTime = transactionTime ?? throw new ArgumentNullException(nameof(transactionTime)); + _topicEventSender = topicEventSender ?? throw new ArgumentNullException(nameof(topicEventSender)); } public override ValueTask> SavingChangesAsync( @@ -26,19 +34,19 @@ public override ValueTask> SavingChangesAsync( return base.SavingChangesAsync(eventData, result, cancellationToken); } - var domainEvents = dbContext.ChangeTracker.Entries() + _domainEvents = dbContext.ChangeTracker.Entries() .SelectMany(x => x.Entity is IEventPublisher publisher ? publisher.PopDomainEvents() : []) .ToList(); - foreach (var domainEvent in domainEvents) + foreach (var domainEvent in _domainEvents) { domainEvent.OccuredAt = _transactionTime.Value; } - var outboxMessages = domainEvents + var outboxMessages = _domainEvents .Select(OutboxMessage.Create) .ToList(); @@ -46,4 +54,25 @@ x.Entity is IEventPublisher publisher return base.SavingChangesAsync(eventData, result, cancellationToken); } + + public override async ValueTask SavedChangesAsync(SaveChangesCompletedEventData eventData, int result, + CancellationToken cancellationToken = default) + { + foreach (var domainEvent in _domainEvents) + { + switch (domainEvent) + { + case DialogUpdatedDomainEvent dialogUpdatedDomainEvent: + await _topicEventSender.SendAsync( + $"{Constants.DialogUpdatedTopic}{dialogUpdatedDomainEvent.DialogId}", + dialogUpdatedDomainEvent.DialogId, + cancellationToken); + break; + default: + break; + } + } + + return await base.SavedChangesAsync(eventData, result, cancellationToken); + } } diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/GraphQL/ServiceCollectionExtensions.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/GraphQL/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..fe2007412 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/GraphQL/ServiceCollectionExtensions.cs @@ -0,0 +1,38 @@ +using HotChocolate.Execution.Configuration; +using HotChocolate.Subscriptions; +using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; + +namespace Digdir.Domain.Dialogporten.Infrastructure.GraphQl; + +/// +/// This implementation is a workaround to allow the use of the AddRedisSubscriptions extension method +/// from HotChocolate.Subscriptions.Redis without having to take the entire HotChocolate library as a dependency. +/// +internal sealed class DummyRequestExecutorBuilder : IRequestExecutorBuilder +{ + public string Name => string.Empty; + public IServiceCollection Services { get; init; } = null!; +} + +public static class GraphQlSubscriptionConstants +{ + public const string SubscriptionTopicPrefix = "graphql_subscriptions_"; + public const string DialogUpdatedTopic = "dialogUpdated/"; +} + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddGraphQlRedisSubscriptions(this IServiceCollection services, + string redisConnectionString) + { + var dummyImplementation = new DummyRequestExecutorBuilder { Services = services }; + dummyImplementation.AddRedisSubscriptions(_ => ConnectionMultiplexer.Connect(redisConnectionString), + new SubscriptionOptions + { + TopicPrefix = GraphQlSubscriptionConstants.SubscriptionTopicPrefix + }); + + return services; + } +} diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/InfrastructureExtensions.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/InfrastructureExtensions.cs index 63b0fbddd..77deb03a7 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/InfrastructureExtensions.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/InfrastructureExtensions.cs @@ -26,6 +26,7 @@ using Digdir.Domain.Dialogporten.Infrastructure.Altinn.NameRegistry; using Digdir.Domain.Dialogporten.Infrastructure.Altinn.OrganizationRegistry; using Digdir.Domain.Dialogporten.Infrastructure.Altinn.ResourceRegistry; +using Digdir.Domain.Dialogporten.Infrastructure.GraphQl; using Digdir.Domain.Dialogporten.Infrastructure.Persistence.Configurations.Actors; using Digdir.Domain.Dialogporten.Infrastructure.Persistence.Repositories; using ZiggyCreatures.Caching.Fusion; @@ -69,6 +70,8 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi { services.AddStackExchangeRedisCache(opt => opt.Configuration = infrastructureSettings.Redis.ConnectionString); services.AddFusionCacheStackExchangeRedisBackplane(opt => opt.Configuration = infrastructureSettings.Redis.ConnectionString); + + services.AddGraphQlRedisSubscriptions(infrastructureSettings.Redis.ConnectionString); } else { diff --git a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Common/DialogApplication.cs b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Common/DialogApplication.cs index dba387620..ea381992e 100644 --- a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Common/DialogApplication.cs +++ b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Common/DialogApplication.cs @@ -14,6 +14,7 @@ using Digdir.Domain.Dialogporten.Infrastructure.Persistence; using Digdir.Library.Entity.Abstractions.Features.Lookup; using FluentAssertions; +using HotChocolate.Subscriptions; using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -72,6 +73,7 @@ public async Task InitializeAsync() .AddScoped(_ => CreateServiceOwnerNameRegistrySubstitute()) .AddScoped(_ => CreateNameRegistrySubstitute()) .AddScoped>(_ => CreateApplicationSettingsSubstitute()) + .AddScoped(_ => Substitute.For()) .AddScoped() .AddScoped() .AddSingleton() diff --git a/tests/Digdir.Domain.Dialogporten.GraphQl.Integration.Tests/Schema/SchemaSnapshotTests.cs b/tests/Digdir.Domain.Dialogporten.GraphQl.Integration.Tests/Schema/SchemaSnapshotTests.cs index cdf81df36..bc8bc6589 100644 --- a/tests/Digdir.Domain.Dialogporten.GraphQl.Integration.Tests/Schema/SchemaSnapshotTests.cs +++ b/tests/Digdir.Domain.Dialogporten.GraphQl.Integration.Tests/Schema/SchemaSnapshotTests.cs @@ -33,7 +33,7 @@ public async Task FailIfGraphQlSchemaSnapshotDoesNotMatch() var builder = WebApplication.CreateBuilder([]); builder.Services .AddSingleton(new TelemetryClient(telemetryConfig)) - .AddDialogportenGraphQl(); + .AddDialogportenGraphQl(string.Empty); var app = builder.Build(); var requestExecutor =