Skip to content

Commit

Permalink
Create webhook messages when events are saved (#1753)
Browse files Browse the repository at this point in the history
  • Loading branch information
gunndabad authored Dec 17, 2024
1 parent c3e30cc commit 04f899e
Show file tree
Hide file tree
Showing 41 changed files with 255 additions and 59 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ public async Task<ApiResult<SetCpdInductionStatusResult>> HandleAsync(SetCpdIndu

if (updatedEvent is not null)
{
dbContext.AddEvent(updatedEvent);
await dbContext.AddEventAndBroadcastAsync(updatedEvent);
}

await dbContext.SaveChangesAsync();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public async Task<ApiResult<SetWelshInductionStatusResult>> HandleAsync(SetWelsh

if (updatedEvent is not null)
{
dbContext.AddEvent(updatedEvent);
await dbContext.AddEventAndBroadcastAsync(updatedEvent);
}

await dbContext.SaveChangesAsync();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ public async Task<IActionResult> OnPostAsync()
};
dbContext.SupportTasks.Add(supportTask);

dbContext.AddEvent(new SupportTaskCreatedEvent()
await dbContext.AddEventAndBroadcastAsync(new SupportTaskCreatedEvent()
{
EventId = Guid.NewGuid(),
CreatedUtc = clock.UtcNow,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,6 @@ public static class CacheKeys
public static object GetSubjectTitleKey(string title) => $"subjects_{title}";

public static object PersonInfo(Guid personId) => $"person_info:{personId}";

public static object EnabledWebhookEndpoints() => "webhook_endpoints";
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Metadata.Conventions;
using Microsoft.Extensions.DependencyInjection;
using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure;
using OpenIddict.EntityFrameworkCore.Models;
using TeachingRecordSystem.Core.DataStore.Postgres.Models;
using TeachingRecordSystem.Core.Infrastructure.EntityFramework;
using TeachingRecordSystem.Core.Services.Webhooks;
using Establishment = TeachingRecordSystem.Core.DataStore.Postgres.Models.Establishment;
using User = TeachingRecordSystem.Core.DataStore.Postgres.Models.User;

namespace TeachingRecordSystem.Core.DataStore.Postgres;

public class TrsDbContext : DbContext
{
public TrsDbContext(DbContextOptions<TrsDbContext> options)
private readonly IServiceProvider? _serviceProvider;

public TrsDbContext(DbContextOptions<TrsDbContext> options, IServiceProvider serviceProvider)
: base(options)
{
_serviceProvider = serviceProvider;
}

private TrsDbContext(DbContextOptions<TrsDbContext> options)
: base(options)
{
}
Expand Down Expand Up @@ -121,9 +131,19 @@ public static void ConfigureOptions(DbContextOptionsBuilder optionsBuilder, stri
});
}

public void AddEvent(EventBase @event, DateTime? inserted = null)
public async Task AddEventAndBroadcastAsync(EventBase @event)
{
Events.Add(Event.FromEventBase(@event, inserted: null));

_ = _serviceProvider ?? throw new InvalidOperationException("No ServiceProvider on DbContext.");
var webhookMessageFactory = _serviceProvider.GetRequiredService<WebhookMessageFactory>();
var messages = await webhookMessageFactory.CreateMessagesAsync(this, @event, _serviceProvider);
WebhookMessages.AddRange(messages);
}

public void AddEventWithoutBroadcast(EventBase @event)
{
Events.Add(Event.FromEventBase(@event, inserted));
Events.Add(Event.FromEventBase(@event, inserted: null));
}

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ public TrsDbContext CreateDbContext(string[] args)

var connectionString = configuration.GetPostgresConnectionString();

var optionsBuilder = new DbContextOptionsBuilder<TrsDbContext>();
TrsDbContext.ConfigureOptions(optionsBuilder, connectionString);

return new TrsDbContext(optionsBuilder.Options);
return TrsDbContext.Create(connectionString);
}
}
11 changes: 11 additions & 0 deletions TeachingRecordSystem/src/TeachingRecordSystem.Core/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
using Microsoft.ApplicationInsights.Extensibility;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Npgsql;
using Serilog;
using Serilog.Formatting.Compact;
using TeachingRecordSystem.Core.DataStore.Postgres;
using TeachingRecordSystem.Core.Jobs.Scheduling;
using TeachingRecordSystem.Core.Services.Webhooks;

namespace TeachingRecordSystem.Core;

Expand Down Expand Up @@ -72,6 +74,15 @@ public static IHostApplicationBuilder AddHangfire(this IHostApplicationBuilder b
return builder;
}

public static IHostApplicationBuilder AddWebhookMessageFactory(this IHostApplicationBuilder builder)
{
builder.Services.AddSingleton<WebhookMessageFactory>();
builder.Services.AddSingleton<EventMapperRegistry>();
builder.Services.TryAddSingleton<PersonInfoCache>();

return builder;
}

public static void ConfigureSerilog(
this LoggerConfiguration config,
IHostEnvironment environment,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public async Task ExecuteAsync(Guid eytsAwardedEmailsJobId, Guid personId)
await _notificationSender.SendEmailAsync(EytsAwardedEmailConfirmationTemplateId, item.EmailAddress, item.Personalization);
item.EmailSent = true;

_dbContext.AddEvent(new EytsAwardedEmailSentEvent
_dbContext.AddEventWithoutBroadcast(new EytsAwardedEmailSentEvent
{
EventId = Guid.NewGuid(),
EytsAwardedEmailsJobId = eytsAwardedEmailsJobId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public async Task ExecuteAsync(Guid inductionCompletedEmailsJobId, Guid personId
await _notificationSender.SendEmailAsync(InductionCompletedEmailConfirmationTemplateId, item.EmailAddress, item.Personalization);
item.EmailSent = true;

_dbContext.AddEvent(new InductionCompletedEmailSentEvent
_dbContext.AddEventWithoutBroadcast(new InductionCompletedEmailSentEvent
{
EventId = Guid.NewGuid(),
InductionCompletedEmailsJobId = inductionCompletedEmailsJobId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public async Task ExecuteAsync(Guid internationalQtsAwardedEmailsJobId, Guid per
await _notificationSender.SendEmailAsync(InternationalQtsAwardedEmailConfirmationTemplateId, item.EmailAddress, item.Personalization);
item.EmailSent = true;

_dbContext.AddEvent(new InternationalQtsAwardedEmailSentEvent
_dbContext.AddEventWithoutBroadcast(new InternationalQtsAwardedEmailSentEvent
{
EventId = Guid.NewGuid(),
InternationalQtsAwardedEmailsJobId = internationalQtsAwardedEmailsJobId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public async Task ExecuteAsync(Guid qtsAwardedEmailsJobId, Guid personId)
await _notificationSender.SendEmailAsync(QtsAwardedEmailConfirmationTemplateId, item.EmailAddress, item.Personalization);
item.EmailSent = true;

_dbContext.AddEvent(new QtsAwardedEmailSentEvent
_dbContext.AddEventWithoutBroadcast(new QtsAwardedEmailSentEvent
{
EventId = Guid.NewGuid(),
QtsAwardedEmailsJobId = qtsAwardedEmailsJobId,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
using System.Diagnostics.CodeAnalysis;
using TeachingRecordSystem.Core.ApiSchema.V3;

namespace TeachingRecordSystem.Core.Services.Webhooks;

public class EventMapperRegistry
{
private sealed record MapperKey(Type EventType, string CloudEventType, string ApiVersion);

private sealed record MapperValue(Type MapperType, Type DataType);

private readonly Dictionary<MapperKey, MapperValue> _mappers = DiscoverMappers();

public Type? GetMapperType(Type eventType, string cloudEventType, string apiVersion, [MaybeNull] out Type dataType)
{
if (_mappers.TryGetValue(new(eventType, cloudEventType, apiVersion), out var result))
{
dataType = result.DataType;
return result.MapperType;
}

dataType = null;
return null;
}

private static Dictionary<MapperKey, MapperValue> DiscoverMappers()
{
var mapperTypes = typeof(EventMapperRegistry).Assembly.GetTypes()
.Where(t => t.IsPublic && !t.IsAbstract && t.GetInterfaces().Any(i =>
i.IsGenericType && i.GetGenericTypeDefinition().IsAssignableTo(typeof(IEventMapper<,>))));

var mappers = new Dictionary<MapperKey, MapperValue>();

foreach (var type in mapperTypes)
{
var mapperTypeArgs = type.GetInterface(typeof(IEventMapper<,>).Name)!.GetGenericArguments();
var eventType = mapperTypeArgs[0];
var dataType = mapperTypeArgs[1];

var cloudEventType = (string)dataType.GetProperty("CloudEventType")!.GetValue(null)!;

var version = type.Namespace!.Split('.').SkipWhile(ns => ns != "V3").Skip(1).First().TrimStart('V');

mappers.Add(new MapperKey(eventType, cloudEventType, version), new MapperValue(type, dataType));
}

return mappers;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using TeachingRecordSystem.Core.ApiSchema.V3;
using TeachingRecordSystem.Core.DataStore.Postgres;
using TeachingRecordSystem.Core.DataStore.Postgres.Models;
using TeachingRecordSystem.Core.Infrastructure.Json;

namespace TeachingRecordSystem.Core.Services.Webhooks;

public class WebhookMessageFactory(EventMapperRegistry eventMapperRegistry, IClock clock, IMemoryCache memoryCache)
{
private static readonly TimeSpan _webhookEndpointsCacheDuration = TimeSpan.FromMinutes(1);

private static readonly JsonSerializerOptions _serializerOptions =
new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver()
{
Modifiers =
{
Modifiers.OptionProperties
}
}
};

public async Task<IEnumerable<WebhookMessage>> CreateMessagesAsync(
TrsDbContext dbContext,
EventBase @event,
IServiceProvider serviceProvider)
{
var endpoints = await memoryCache.GetOrCreateAsync(
CacheKeys.EnabledWebhookEndpoints(),
async e =>
{
e.SetAbsoluteExpiration(_webhookEndpointsCacheDuration);
return await dbContext.WebhookEndpoints.AsNoTracking().Where(e => e.Enabled).ToArrayAsync();
});

var endpointCloudEventTypeVersions = endpoints!
.SelectMany(e =>
e.CloudEventTypes.Select(t => (Version: e.ApiVersion, CloudEventType: t, e.WebhookEndpointId)))
.GroupBy(t => (t.Version, t.CloudEventType), t => t.WebhookEndpointId)
.ToDictionary(g => g.Key, g => g.AsEnumerable());

var messages = new List<WebhookMessage>();

foreach (var (version, cloudEventType) in endpointCloudEventTypeVersions.Keys)
{
var mapperType = eventMapperRegistry.GetMapperType(@event.GetType(), cloudEventType, version, out var dataType);
if (mapperType is null)
{
continue;
}

var payload = await MapEventAsync(mapperType, dataType!);
if (payload is null)
{
continue;
}

var serializedPayload = JsonSerializer.SerializeToElement(payload, _serializerOptions);

messages.AddRange(endpointCloudEventTypeVersions[(version, cloudEventType)].Select(epId =>
{
var id = Guid.NewGuid();

return new WebhookMessage
{
WebhookMessageId = id,
WebhookEndpointId = epId,
CloudEventId = id.ToString(),
CloudEventType = cloudEventType,
Timestamp = clock.UtcNow,
ApiVersion = version,
Data = serializedPayload,
NextDeliveryAttempt = clock.UtcNow,
Delivered = null,
DeliveryAttempts = [],
DeliveryErrors = []
};
}));
}

return messages;

Task<object?> MapEventAsync(Type mapperType, Type dataType)
{
var mapper = ActivatorUtilities.CreateInstance(serviceProvider, mapperType);

var eventType = @event.GetType();

var wrappedMapper = (IEventMapper)ActivatorUtilities.CreateInstance(
serviceProvider,
typeof(WrappedMapper<,>).MakeGenericType(eventType, dataType),
mapper);

return wrappedMapper.MapEventAsync(@event);
}
}

private interface IEventMapper
{
Task<object?> MapEventAsync(EventBase @event);
}

private class WrappedMapper<TEvent, TData>(IEventMapper<TEvent, TData> innerMapper) : IEventMapper
where TEvent : EventBase
where TData : IWebhookMessageData
{
public async Task<object?> MapEventAsync(EventBase @event) =>
await innerMapper.MapEventAsync((TEvent)@event);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public static IHostApplicationBuilder AddServiceDefaults(
builder.AddDatabase();
builder.AddHangfire();
builder.AddBackgroundWorkScheduler();
builder.AddWebhookMessageFactory();

builder.Services.AddHealthChecks().AddNpgSql(sp => sp.GetRequiredService<NpgsqlDataSource>());
builder.Services.AddDatabaseDeveloperPageExceptionFilter();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ public async Task<IActionResult> OnPostAsync()
out var createdEvent);

dbContext.Alerts.Add(alert);
dbContext.AddEvent(createdEvent);
await dbContext.AddEventAndBroadcastAsync(createdEvent);
await dbContext.SaveChangesAsync();

await JourneyInstance!.CompleteAsync();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public async Task<IActionResult> OnPostAsync()
Changes = AlertUpdatedEventChanges.EndDate
};

dbContext.AddEvent(updatedEvent);
await dbContext.AddEventAndBroadcastAsync(updatedEvent);

await dbContext.SaveChangesAsync();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ public async Task<IActionResult> OnPostAsync()
clock.UtcNow,
out var deletedEvent);

dbContext.AddEvent(deletedEvent);
await dbContext.AddEventAndBroadcastAsync(deletedEvent);
await dbContext.SaveChangesAsync();

await JourneyInstance!.CompleteAsync();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public async Task<IActionResult> OnPostAsync()

if (updatedEvent is not null)
{
dbContext.AddEvent(updatedEvent);
await dbContext.AddEventAndBroadcastAsync(updatedEvent);
await dbContext.SaveChangesAsync();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public async Task<IActionResult> OnPostAsync()

if (updatedEvent is not null)
{
dbContext.AddEvent(updatedEvent);
await dbContext.AddEventAndBroadcastAsync(updatedEvent);
await dbContext.SaveChangesAsync();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ public async Task<IActionResult> OnPostAsync()

if (updatedEvent is not null)
{
dbContext.AddEvent(updatedEvent);
await dbContext.AddEventAndBroadcastAsync(updatedEvent);
await dbContext.SaveChangesAsync();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ public async Task<IActionResult> OnPostAsync()

if (updatedEvent is not null)
{
dbContext.AddEvent(updatedEvent);
await dbContext.AddEventAndBroadcastAsync(updatedEvent);
await dbContext.SaveChangesAsync();
}

Expand Down
Loading

0 comments on commit 04f899e

Please sign in to comment.