diff --git a/FictionArchive.Service.SchedulerService/Dockerfile b/FictionArchive.Service.SchedulerService/Dockerfile
new file mode 100644
index 0000000..fd21bd5
--- /dev/null
+++ b/FictionArchive.Service.SchedulerService/Dockerfile
@@ -0,0 +1,23 @@
+FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
+USER $APP_UID
+WORKDIR /app
+EXPOSE 8080
+EXPOSE 8081
+
+FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
+ARG BUILD_CONFIGURATION=Release
+WORKDIR /src
+COPY ["FictionArchive.Service.SchedulerService/FictionArchive.Service.SchedulerService.csproj", "FictionArchive.Service.SchedulerService/"]
+RUN dotnet restore "FictionArchive.Service.SchedulerService/FictionArchive.Service.SchedulerService.csproj"
+COPY . .
+WORKDIR "/src/FictionArchive.Service.SchedulerService"
+RUN dotnet build "./FictionArchive.Service.SchedulerService.csproj" -c $BUILD_CONFIGURATION -o /app/build
+
+FROM build AS publish
+ARG BUILD_CONFIGURATION=Release
+RUN dotnet publish "./FictionArchive.Service.SchedulerService.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
+
+FROM base AS final
+WORKDIR /app
+COPY --from=publish /app/publish .
+ENTRYPOINT ["dotnet", "FictionArchive.Service.SchedulerService.dll"]
diff --git a/FictionArchive.Service.SchedulerService/FictionArchive.Service.SchedulerService.csproj b/FictionArchive.Service.SchedulerService/FictionArchive.Service.SchedulerService.csproj
new file mode 100644
index 0000000..ccdea73
--- /dev/null
+++ b/FictionArchive.Service.SchedulerService/FictionArchive.Service.SchedulerService.csproj
@@ -0,0 +1,26 @@
+
+
+
+ net8.0
+ enable
+ enable
+ Linux
+
+
+
+
+ .dockerignore
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FictionArchive.Service.SchedulerService/GraphQL/Mutation.cs b/FictionArchive.Service.SchedulerService/GraphQL/Mutation.cs
new file mode 100644
index 0000000..e5118b5
--- /dev/null
+++ b/FictionArchive.Service.SchedulerService/GraphQL/Mutation.cs
@@ -0,0 +1,35 @@
+using System.Data;
+using FictionArchive.Service.SchedulerService.Models;
+using FictionArchive.Service.SchedulerService.Services;
+using HotChocolate.Types;
+using Quartz;
+
+namespace FictionArchive.Service.SchedulerService.GraphQL;
+
+public class Mutation
+{
+ [Error]
+ [Error]
+ public async Task ScheduleEventJob(string key, string description, string eventType, string eventData, string cronSchedule, JobManagerService jobManager)
+ {
+ return await jobManager.ScheduleEventJob(key, description, eventType, eventData, cronSchedule);
+ }
+
+ [Error]
+ public async Task RunJob(string jobKey, JobManagerService jobManager)
+ {
+ return await jobManager.TriggerJob(jobKey);
+ }
+
+ [Error]
+ public async Task DeleteJob(string jobKey, JobManagerService jobManager)
+ {
+ bool deleted = await jobManager.DeleteJob(jobKey);
+ if (!deleted)
+ {
+ throw new KeyNotFoundException($"Job with key {jobKey} was not found");
+ }
+
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/FictionArchive.Service.SchedulerService/GraphQL/Query.cs b/FictionArchive.Service.SchedulerService/GraphQL/Query.cs
new file mode 100644
index 0000000..2594569
--- /dev/null
+++ b/FictionArchive.Service.SchedulerService/GraphQL/Query.cs
@@ -0,0 +1,15 @@
+using FictionArchive.Service.SchedulerService.Models;
+using FictionArchive.Service.SchedulerService.Services;
+using HotChocolate;
+using Quartz;
+using Quartz.Impl.Matchers;
+
+namespace FictionArchive.Service.SchedulerService.GraphQL;
+
+public class Query
+{
+ public async Task> GetJobs(JobManagerService jobManager)
+ {
+ return await jobManager.GetScheduledJobs();
+ }
+}
\ No newline at end of file
diff --git a/FictionArchive.Service.SchedulerService/Models/JobTemplates/EventJobTemplate.cs b/FictionArchive.Service.SchedulerService/Models/JobTemplates/EventJobTemplate.cs
new file mode 100644
index 0000000..fa4e8fd
--- /dev/null
+++ b/FictionArchive.Service.SchedulerService/Models/JobTemplates/EventJobTemplate.cs
@@ -0,0 +1,35 @@
+using FictionArchive.Service.Shared.Services.EventBus;
+using Newtonsoft.Json;
+using Quartz;
+
+namespace FictionArchive.Service.SchedulerService.Models.JobTemplates;
+
+public class EventJobTemplate : IJob
+{
+ private readonly IEventBus _eventBus;
+ private readonly ILogger _logger;
+
+ public const string EventTypeParameter = "RoutingKey";
+ public const string EventDataParameter = "MessageData";
+
+ public EventJobTemplate(IEventBus eventBus, ILogger logger)
+ {
+ _eventBus = eventBus;
+ _logger = logger;
+ }
+
+ public async Task Execute(IJobExecutionContext context)
+ {
+ try
+ {
+ var eventData = context.MergedJobDataMap.GetString(EventDataParameter);
+ var eventType = context.MergedJobDataMap.GetString(EventTypeParameter);
+ var eventObject = JsonConvert.DeserializeObject(eventData);
+ await _eventBus.Publish(eventObject, eventType);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "An error occurred while running an event job.");
+ }
+ }
+}
\ No newline at end of file
diff --git a/FictionArchive.Service.SchedulerService/Models/SchedulerJob.cs b/FictionArchive.Service.SchedulerService/Models/SchedulerJob.cs
new file mode 100644
index 0000000..2921ee4
--- /dev/null
+++ b/FictionArchive.Service.SchedulerService/Models/SchedulerJob.cs
@@ -0,0 +1,12 @@
+using Quartz;
+
+namespace FictionArchive.Service.SchedulerService.Models;
+
+public class SchedulerJob
+{
+ public JobKey JobKey { get; set; }
+ public string Description { get; set; }
+ public string JobTypeName { get; set; }
+ public List CronSchedule { get; set; }
+ public Dictionary JobData { get; set; }
+}
\ No newline at end of file
diff --git a/FictionArchive.Service.SchedulerService/Program.cs b/FictionArchive.Service.SchedulerService/Program.cs
new file mode 100644
index 0000000..69406f4
--- /dev/null
+++ b/FictionArchive.Service.SchedulerService/Program.cs
@@ -0,0 +1,52 @@
+using FictionArchive.Service.SchedulerService.GraphQL;
+using FictionArchive.Service.SchedulerService.Services;
+using FictionArchive.Service.Shared.Extensions;
+using FictionArchive.Service.Shared.Services.EventBus.Implementations;
+using Quartz;
+
+namespace FictionArchive.Service.SchedulerService;
+
+public class Program
+{
+ public static void Main(string[] args)
+ {
+ var builder = WebApplication.CreateBuilder(args);
+
+ // Services
+ builder.Services.AddDefaultGraphQl();
+ builder.Services.AddHealthChecks();
+ builder.Services.AddTransient();
+
+ #region Event Bus
+
+ builder.Services.AddRabbitMQ(opt =>
+ {
+ builder.Configuration.GetSection("RabbitMQ").Bind(opt);
+ });
+
+ #endregion
+
+ #region Quartz
+
+ builder.Services.AddQuartz(opt =>
+ {
+ opt.UseMicrosoftDependencyInjectionJobFactory();
+ });
+ builder.Services.AddQuartzHostedService(opt =>
+ {
+ opt.WaitForJobsToComplete = true;
+ });
+
+ #endregion
+
+ var app = builder.Build();
+
+ app.UseHttpsRedirection();
+
+ app.MapHealthChecks("/healthz");
+
+ app.MapGraphQL();
+
+ app.Run();
+ }
+}
\ No newline at end of file
diff --git a/FictionArchive.Service.SchedulerService/Properties/launchSettings.json b/FictionArchive.Service.SchedulerService/Properties/launchSettings.json
new file mode 100644
index 0000000..b430b15
--- /dev/null
+++ b/FictionArchive.Service.SchedulerService/Properties/launchSettings.json
@@ -0,0 +1,39 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:61312",
+ "sslPort": 44365
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5213",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "launchUrl": "graphql",
+ "applicationUrl": "https://localhost:7145;http://localhost:5213",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/FictionArchive.Service.SchedulerService/Services/JobManagerService.cs b/FictionArchive.Service.SchedulerService/Services/JobManagerService.cs
new file mode 100644
index 0000000..1e37f75
--- /dev/null
+++ b/FictionArchive.Service.SchedulerService/Services/JobManagerService.cs
@@ -0,0 +1,99 @@
+using System.Data;
+using FictionArchive.Service.SchedulerService.Models;
+using FictionArchive.Service.SchedulerService.Models.JobTemplates;
+using FictionArchive.Service.Shared.Services.EventBus;
+using Quartz;
+using Quartz.Impl.Matchers;
+
+namespace FictionArchive.Service.SchedulerService.Services;
+
+public class JobManagerService
+{
+ private readonly ILogger _logger;
+ private readonly ISchedulerFactory _schedulerFactory;
+
+ public JobManagerService(ILogger logger, ISchedulerFactory schedulerFactory)
+ {
+ _logger = logger;
+ _schedulerFactory = schedulerFactory;
+ }
+
+ public async Task> GetScheduledJobs()
+ {
+ var scheduler = await _schedulerFactory.GetScheduler();
+ var groups = await scheduler.GetJobGroupNames();
+ var result = new List<(IJobDetail Job, IReadOnlyCollection Triggers)>();
+
+ foreach (var group in groups)
+ {
+ var jobKeys = await scheduler.GetJobKeys(GroupMatcher.GroupEquals(group));
+ foreach (var jobKey in jobKeys)
+ {
+ var jobDetail = await scheduler.GetJobDetail(jobKey);
+ var triggers = await scheduler.GetTriggersOfJob(jobKey);
+
+ result.Add((jobDetail, triggers));
+ }
+ }
+
+ return result.Select(tuple => new SchedulerJob()
+ {
+ JobKey = tuple.Job.Key,
+ Description = tuple.Job.Description,
+ JobTypeName = tuple.Job.JobType.FullName,
+ JobData = tuple.Job.JobDataMap.ToDictionary(kv => kv.Key, kv => kv.Value.ToString()),
+ CronSchedule = tuple.Triggers.Where(trigger => trigger is ICronTrigger).Select(trigger => (trigger as ICronTrigger).CronExpressionString).ToList()
+ }).ToList();
+ }
+
+ public async Task ScheduleEventJob(string? jobKey, string? description, string eventType, string eventData, string cronSchedule)
+ {
+ var scheduler = await _schedulerFactory.GetScheduler();
+
+ if (await scheduler.GetJobDetail(new JobKey(jobKey)) != null)
+ {
+ throw new DuplicateNameException("A job with the same key already exists.");
+ }
+
+ jobKey ??= Guid.NewGuid().ToString();
+ var jobData = new JobDataMap
+ {
+ { EventJobTemplate.EventTypeParameter, eventType },
+ { EventJobTemplate.EventDataParameter, eventData }
+ };
+ var job = JobBuilder.Create()
+ .WithIdentity(jobKey)
+ .WithDescription(description ?? $"Fires off an event on a set schedule")
+ .SetJobData(jobData)
+ .Build();
+ var trigger = TriggerBuilder.Create()
+ .WithIdentity(jobKey)
+ .WithCronSchedule(cronSchedule)
+ .StartNow()
+ .Build();
+
+ await scheduler.ScheduleJob(job, trigger);
+
+ return new SchedulerJob()
+ {
+ CronSchedule = new List { cronSchedule },
+ Description = description,
+ JobKey = new JobKey(jobKey),
+ JobTypeName = typeof(EventJobTemplate).FullName,
+ JobData = jobData.ToDictionary(kv => kv.Key, kv => kv.Value.ToString())
+ };
+ }
+
+ public async Task TriggerJob(string jobKey)
+ {
+ var scheduler = await _schedulerFactory.GetScheduler();
+ await scheduler.TriggerJob(new JobKey(jobKey));
+ return true;
+ }
+
+ public async Task DeleteJob(string jobKey)
+ {
+ var scheduler = await _schedulerFactory.GetScheduler();
+ return await scheduler.DeleteJob(new JobKey(jobKey));
+ }
+}
\ No newline at end of file
diff --git a/FictionArchive.Service.SchedulerService/appsettings.Development.json b/FictionArchive.Service.SchedulerService/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/FictionArchive.Service.SchedulerService/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/FictionArchive.Service.SchedulerService/appsettings.json b/FictionArchive.Service.SchedulerService/appsettings.json
new file mode 100644
index 0000000..250543c
--- /dev/null
+++ b/FictionArchive.Service.SchedulerService/appsettings.json
@@ -0,0 +1,13 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "RabbitMQ": {
+ "ConnectionString": "amqp://localhost",
+ "ClientIdentifier": "SchedulerService"
+ },
+ "AllowedHosts": "*"
+}
diff --git a/FictionArchive.Service.Shared/Extensions/GraphQLExtensions.cs b/FictionArchive.Service.Shared/Extensions/GraphQLExtensions.cs
index 3ab030c..b51551a 100644
--- a/FictionArchive.Service.Shared/Extensions/GraphQLExtensions.cs
+++ b/FictionArchive.Service.Shared/Extensions/GraphQLExtensions.cs
@@ -13,6 +13,7 @@ public static class GraphQLExtensions
.AddQueryType()
.AddMutationType()
.AddDiagnosticEventListener()
+ .AddErrorFilter()
.AddType()
.AddType()
.AddMutationConventions(applyToAllMutations: true)
diff --git a/FictionArchive.Service.Shared/Services/EventBus/IEventBus.cs b/FictionArchive.Service.Shared/Services/EventBus/IEventBus.cs
index d3433c1..46c7e75 100644
--- a/FictionArchive.Service.Shared/Services/EventBus/IEventBus.cs
+++ b/FictionArchive.Service.Shared/Services/EventBus/IEventBus.cs
@@ -3,4 +3,5 @@ namespace FictionArchive.Service.Shared.Services.EventBus;
public interface IEventBus
{
Task Publish(TEvent integrationEvent) where TEvent : IntegrationEvent;
+ Task Publish(object integrationEvent, string eventType);
}
\ No newline at end of file
diff --git a/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQEventBus.cs b/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQEventBus.cs
index cdf83d8..6650e18 100644
--- a/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQEventBus.cs
+++ b/FictionArchive.Service.Shared/Services/EventBus/Implementations/RabbitMQEventBus.cs
@@ -36,15 +36,20 @@ public class RabbitMQEventBus : IEventBus, IHostedService
public async Task Publish(TEvent integrationEvent) where TEvent : IntegrationEvent
{
var routingKey = typeof(TEvent).Name;
- var channel = await _connectionProvider.GetDefaultChannelAsync();
// Set integration event values
integrationEvent.CreatedAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
integrationEvent.EventId = Guid.NewGuid();
+ await Publish(integrationEvent, routingKey);
+ }
+
+ public async Task Publish(object integrationEvent, string eventType)
+ {
+ var channel = await _connectionProvider.GetDefaultChannelAsync();
var body = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(integrationEvent));
- await channel.BasicPublishAsync(ExchangeName, routingKey, true, body);
- _logger.LogInformation("Published event {EventName}", routingKey);
+ await channel.BasicPublishAsync(ExchangeName, eventType, true, body);
+ _logger.LogInformation("Published event {EventName}", eventType);
}
public async Task StartAsync(CancellationToken cancellationToken)
diff --git a/FictionArchive.Service.Shared/Services/GraphQL/LoggingErrorFilter.cs b/FictionArchive.Service.Shared/Services/GraphQL/LoggingErrorFilter.cs
new file mode 100644
index 0000000..aa03776
--- /dev/null
+++ b/FictionArchive.Service.Shared/Services/GraphQL/LoggingErrorFilter.cs
@@ -0,0 +1,23 @@
+using Microsoft.Extensions.Logging;
+
+namespace FictionArchive.Service.Shared.Services.GraphQL;
+
+public class LoggingErrorFilter : IErrorFilter
+{
+ private readonly ILogger _logger;
+
+ public LoggingErrorFilter(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public IError OnError(IError error)
+ {
+ if (error.Exception != null)
+ {
+ _logger.LogError(error.Exception, "Unexpected GraphQL error occurred");
+ }
+
+ return error;
+ }
+}
\ No newline at end of file
diff --git a/FictionArchive.sln b/FictionArchive.sln
index a22fd12..aa199ef 100644
--- a/FictionArchive.sln
+++ b/FictionArchive.sln
@@ -10,6 +10,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.Tran
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.Shared", "FictionArchive.Service.Shared\FictionArchive.Service.Shared.csproj", "{82638874-304C-43E6-8EFA-8AD4C41C4435}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.SchedulerService", "FictionArchive.Service.SchedulerService\FictionArchive.Service.SchedulerService.csproj", "{6813A8AD-A071-4F86-B227-BC4A5BCD7F3C}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -36,5 +38,9 @@ Global
{82638874-304C-43E6-8EFA-8AD4C41C4435}.Debug|Any CPU.Build.0 = Debug|Any CPU
{82638874-304C-43E6-8EFA-8AD4C41C4435}.Release|Any CPU.ActiveCfg = Release|Any CPU
{82638874-304C-43E6-8EFA-8AD4C41C4435}.Release|Any CPU.Build.0 = Release|Any CPU
+ {6813A8AD-A071-4F86-B227-BC4A5BCD7F3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6813A8AD-A071-4F86-B227-BC4A5BCD7F3C}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6813A8AD-A071-4F86-B227-BC4A5BCD7F3C}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6813A8AD-A071-4F86-B227-BC4A5BCD7F3C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal