diff --git a/Api/Program.cs b/Api/Program.cs index fcecba0..1128f4a 100644 --- a/Api/Program.cs +++ b/Api/Program.cs @@ -10,14 +10,14 @@ using Core.Events; using Core.StringObjectConverters; using FluentValidation; -using Infrastructure; +using Infrastructure.Twitter; using Infrastructure.MongoDB; -using Infrastructure.Services; using Microsoft.AspNetCore.Authentication; using Microsoft.OpenApi.Models; using MySqlConnector; -using System.Collections.Concurrent; using System.Text.Json.Serialization; +using Hangfire; +using Infrastructure.Hangfire; var builder = WebApplication.CreateBuilder(args); var configuration = builder.Configuration; @@ -86,7 +86,7 @@ } }); }); -var mongoDbSettings = configuration.GetSection("MongoDB"); +var mongoDbSettings = configuration.GetRequiredSection("MongoDB"); services.InitializeDatabase(mongoDbSettings.GetValue("ConnectionString"), mongoDbSettings.GetValue("DatabaseName")); builder.Services.AddTransient(x => @@ -112,9 +112,11 @@ services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(ExactNameSearchedAdapter).Assembly)); // Twitter integration configuration -services.AddSingleton>(); +services.AddSingleton(); services.AddTwitterClient(configuration); -services.AddHostedService(); + +builder.Services.AddMemoryCache(); +builder.Services.SetupHangfire(configuration.GetRequiredSection("MongoDB:ConnectionString").Value!); var app = builder.Build(); @@ -135,4 +137,6 @@ app.MapControllers(); +app.UseHangfireDashboard("/backJobMonitor"); + app.Run(); diff --git a/Api/appsettings.Development.json b/Api/appsettings.Development.json index 6ae5b70..63e58ce 100644 --- a/Api/appsettings.Development.json +++ b/Api/appsettings.Development.json @@ -14,6 +14,6 @@ }, "Twitter": { "TweetTemplate": "{name}: \"{meaning}\" {link}", - "TweetIntervalSeconds": 5 + "TweetIntervalSeconds": 60 } } diff --git a/Application/Application.csproj b/Application/Application.csproj index b8025e4..1310f6a 100644 --- a/Application/Application.csproj +++ b/Application/Application.csproj @@ -1,29 +1,26 @@  - - net6.0 + net8.0 enable enable - - - - - - + + + + \ No newline at end of file diff --git a/Application/EventHandlers/NameIndexedEventHandler.cs b/Application/EventHandlers/NameIndexedEventHandler.cs index 2f83e3a..dc09fc1 100644 --- a/Application/EventHandlers/NameIndexedEventHandler.cs +++ b/Application/EventHandlers/NameIndexedEventHandler.cs @@ -20,7 +20,7 @@ public NameIndexedEventHandler( public async Task Handle(NameIndexedAdapter notification, CancellationToken cancellationToken) { await _recentIndexesCache.Stack(notification.Name); - await _mediator.Publish(new PostPublishedNameCommand(notification.Name), cancellationToken); + await _mediator.Publish(new PostPublishedNameCommand(notification.Name, notification.Meaning), cancellationToken); } } } diff --git a/Application/EventHandlers/PostPublishedNameCommandHandler.cs b/Application/EventHandlers/PostPublishedNameCommandHandler.cs index 7f8b3e5..7e03e90 100644 --- a/Application/EventHandlers/PostPublishedNameCommandHandler.cs +++ b/Application/EventHandlers/PostPublishedNameCommandHandler.cs @@ -1,25 +1,23 @@ using Application.Events; +using Application.Services; using MediatR; -using System.Collections.Concurrent; namespace Application.EventHandlers { public class PostPublishedNameCommandHandler : INotificationHandler { - private readonly ConcurrentQueue _nameQueue; + private readonly ITwitterService _twitterService; - public PostPublishedNameCommandHandler(ConcurrentQueue nameQueue) + public PostPublishedNameCommandHandler( + ITwitterService twitterService) { - _nameQueue = nameQueue; + _twitterService = twitterService; + } - public Task Handle(PostPublishedNameCommand notification, CancellationToken cancellationToken) + public async Task Handle(PostPublishedNameCommand notification, CancellationToken cancellationToken) { - // Enqueue the indexed name for processing by the BackgroundService - _nameQueue.Enqueue(notification); - - // Return a completed task, so it doesn't block the main thread - return Task.CompletedTask; + await _twitterService.PostNewNameAsync(notification.Name, notification.Meaning, cancellationToken); } } } diff --git a/Application/Events/NameIndexedAdapter.cs b/Application/Events/NameIndexedAdapter.cs index b0d2be4..10c9f84 100644 --- a/Application/Events/NameIndexedAdapter.cs +++ b/Application/Events/NameIndexedAdapter.cs @@ -5,7 +5,7 @@ namespace Application.Events; public record NameIndexedAdapter : NameIndexed, INotification { - public NameIndexedAdapter(NameIndexed theEvent) : base(theEvent.Name) + public NameIndexedAdapter(NameIndexed theEvent) : base(theEvent.Name, theEvent.Meaning) { } } \ No newline at end of file diff --git a/Application/Events/PostPublishedNameCommand.cs b/Application/Events/PostPublishedNameCommand.cs index 62f21d7..4fb426f 100644 --- a/Application/Events/PostPublishedNameCommand.cs +++ b/Application/Events/PostPublishedNameCommand.cs @@ -2,7 +2,7 @@ namespace Application.Events { - public record PostPublishedNameCommand(string Name) : INotification + public record PostPublishedNameCommand(string Name, string Meaning) : INotification { } } diff --git a/Application/Services/BasicAuthHandler.cs b/Application/Services/BasicAuthHandler.cs index eb2bad1..f54c1fc 100644 --- a/Application/Services/BasicAuthHandler.cs +++ b/Application/Services/BasicAuthHandler.cs @@ -20,9 +20,8 @@ public BasicAuthenticationHandler( IUserRepository userRepository, IOptionsMonitor options, ILoggerFactory logger, - UrlEncoder encoder, - ISystemClock clock) - : base(options, logger, encoder, clock) + UrlEncoder encoder) + : base(options, logger, encoder) { _userRepository = userRepository; _logger = logger.CreateLogger(); @@ -30,12 +29,12 @@ public BasicAuthenticationHandler( protected override async Task HandleAuthenticateAsync() { - if (!Request.Headers.ContainsKey("Authorization")) + if (!Request.Headers.TryGetValue("Authorization", out Microsoft.Extensions.Primitives.StringValues value)) return await Task.FromResult(AuthenticateResult.Fail("Missing Authorization Header")); try { - (string username, string password) = DecodeBasicAuthToken(Request.Headers["Authorization"]); + (string username, string password) = DecodeBasicAuthToken(value!); var matchingUser = await AuthenticateUser(username, password); if (matchingUser == null) diff --git a/Application/Services/ITwitterService.cs b/Application/Services/ITwitterService.cs new file mode 100644 index 0000000..00b3125 --- /dev/null +++ b/Application/Services/ITwitterService.cs @@ -0,0 +1,7 @@ +namespace Application.Services +{ + public interface ITwitterService + { + Task PostNewNameAsync(string name, string meaning, CancellationToken cancellationToken); + } +} diff --git a/Application/Services/NameEntryService.cs b/Application/Services/NameEntryService.cs index b1c8c12..a014b2f 100644 --- a/Application/Services/NameEntryService.cs +++ b/Application/Services/NameEntryService.cs @@ -114,7 +114,7 @@ public async Task PublishName(NameEntry nameEntry, string username) await _nameEntryRepository.Update(originalName, nameEntry); // TODO Later: Use the outbox pattern to enforce event publishing after the DB update (https://www.youtube.com/watch?v=032SfEBFIJs&t=913s). - await _eventPubService.PublishEvent(new NameIndexed(nameEntry.Name)); + await _eventPubService.PublishEvent(new NameIndexed(nameEntry.Name, nameEntry.Meaning)); } public async Task UpdateNameWithUnpublish(NameEntry nameEntry) diff --git a/Core/Core.csproj b/Core/Core.csproj index 132c02c..a6b9113 100644 --- a/Core/Core.csproj +++ b/Core/Core.csproj @@ -1,9 +1,7 @@ - - net6.0 + net8.0 enable enable - - + \ No newline at end of file diff --git a/Core/Events/NameIndexed.cs b/Core/Events/NameIndexed.cs index 705469a..52f6a65 100644 --- a/Core/Events/NameIndexed.cs +++ b/Core/Events/NameIndexed.cs @@ -1,12 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Core.Events +namespace Core.Events { - public record NameIndexed(string Name) + public record NameIndexed(string Name, string Meaning) { } } diff --git a/Infrastructure.MongoDB/Infrastructure.MongoDB.csproj b/Infrastructure.MongoDB/Infrastructure.MongoDB.csproj index 08c7018..fedebeb 100644 --- a/Infrastructure.MongoDB/Infrastructure.MongoDB.csproj +++ b/Infrastructure.MongoDB/Infrastructure.MongoDB.csproj @@ -1,18 +1,14 @@ - - net6.0 + net8.0 enable enable - - + - - - + \ No newline at end of file diff --git a/Infrastructure.MongoDB/Repositories/NameEntryRepository.cs b/Infrastructure.MongoDB/Repositories/NameEntryRepository.cs index 515dfff..73b5ee1 100644 --- a/Infrastructure.MongoDB/Repositories/NameEntryRepository.cs +++ b/Infrastructure.MongoDB/Repositories/NameEntryRepository.cs @@ -21,6 +21,20 @@ public NameEntryRepository( { _nameEntryCollection = database.GetCollection("NameEntries"); _eventPubService = eventPubService; + + CreateIndexes(); + } + + private void CreateIndexes() + { + var indexKeys = Builders.IndexKeys.Ascending(x => x.Name); + var indexOptions = new CreateIndexOptions + { + Unique = true, + Name = "IX_NameEntries_Name_Unique", + Background = true + }; + _nameEntryCollection.Indexes.CreateOne(new CreateIndexModel(indexKeys, indexOptions)); } public async Task FindById(string id) diff --git a/Infrastructure/Hangfire/DependencyInjection.cs b/Infrastructure/Hangfire/DependencyInjection.cs new file mode 100644 index 0000000..d9e7642 --- /dev/null +++ b/Infrastructure/Hangfire/DependencyInjection.cs @@ -0,0 +1,51 @@ +using Hangfire; +using Hangfire.Mongo; +using MongoDB.Driver; +using Hangfire.Mongo.Migration.Strategies; +using Hangfire.Mongo.Migration.Strategies.Backup; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.AspNetCore.Builder; +using Api.Utilities; + +namespace Infrastructure.Hangfire +{ + public static class DependencyInjection + { + public static IServiceCollection SetupHangfire(this IServiceCollection services, string mongoConnectionString) + { + var mongoUrlBuilder = new MongoUrlBuilder(mongoConnectionString); + var mongoClient = new MongoClient(mongoUrlBuilder.ToMongoUrl()); + + services.AddHangfire(configuration => configuration + .SetDataCompatibilityLevel(CompatibilityLevel.Version_180) + .UseSimpleAssemblyNameTypeSerializer() + .UseRecommendedSerializerSettings() + .UseMongoStorage(mongoClient, mongoUrlBuilder.DatabaseName, new MongoStorageOptions + { + MigrationOptions = new MongoMigrationOptions + { + MigrationStrategy = new MigrateMongoMigrationStrategy(), + BackupStrategy = new CollectionMongoBackupStrategy() + }, + Prefix = "hangfire.mongo", + CheckConnection = true, + CheckQueuedJobsStrategy = CheckQueuedJobsStrategy.TailNotificationsCollection + }) + ); + + services.AddHangfireServer(serverOptions => + { + serverOptions.ServerName = "Hangfire.Mongo server 1"; + }); + return services; + } + + public static void UseHangfireDashboard(this IApplicationBuilder app, string dashboardPath) + { + app.UseHangfireDashboard(dashboardPath, new DashboardOptions + { + Authorization = [new HangfireAuthFilter()] + }); + } + } +} diff --git a/Infrastructure/Hangfire/HangfireAuthFilter.cs b/Infrastructure/Hangfire/HangfireAuthFilter.cs new file mode 100644 index 0000000..ad7fd43 --- /dev/null +++ b/Infrastructure/Hangfire/HangfireAuthFilter.cs @@ -0,0 +1,12 @@ +using Hangfire.Dashboard; + +namespace Api.Utilities +{ + public class HangfireAuthFilter : IDashboardAuthorizationFilter + { + public bool Authorize(DashboardContext context) + { + return context.GetHttpContext().Request.Host.Host == "localhost"; + } + } +} diff --git a/Infrastructure/Infrastructure.csproj b/Infrastructure/Infrastructure.csproj index b2ef52c..7b37e79 100644 --- a/Infrastructure/Infrastructure.csproj +++ b/Infrastructure/Infrastructure.csproj @@ -7,6 +7,8 @@ + + diff --git a/Infrastructure/Services/NamePostingService.cs b/Infrastructure/Services/NamePostingService.cs deleted file mode 100644 index 772e574..0000000 --- a/Infrastructure/Services/NamePostingService.cs +++ /dev/null @@ -1,76 +0,0 @@ -using Application.Domain; -using Application.Events; -using Infrastructure.Configuration; -using Infrastructure.Twitter; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using System.Collections.Concurrent; - -namespace Infrastructure.Services -{ - public class NamePostingService( - ConcurrentQueue nameQueue, - ITwitterClientV2 twitterApiClient, - ILogger logger, - NameEntryService nameEntryService, - IOptions twitterConfig) : BackgroundService - { - private const string TweetComposeFailure = "Failed to build tweet for name: {name}. It was not found in the database."; - private readonly ConcurrentQueue _nameQueue = nameQueue; - private readonly ITwitterClientV2 _twitterApiClient = twitterApiClient; - private readonly ILogger _logger = logger; - private readonly NameEntryService _nameEntryService = nameEntryService; - private readonly TwitterConfig _twitterConfig = twitterConfig.Value; - private readonly PeriodicTimer _postingTimer = new (TimeSpan.FromSeconds(twitterConfig.Value.TweetIntervalSeconds)); - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - do - { - PostPublishedNameCommand? indexedName = null; - try - { - if (!_nameQueue.TryDequeue(out indexedName)) - { - continue; - } - - string? tweetText = await BuildTweet(indexedName.Name); - - if (string.IsNullOrWhiteSpace(tweetText)) - { - _logger.LogWarning(TweetComposeFailure, indexedName.Name); - continue; - } - - var tweet = await _twitterApiClient.PostTweet(tweetText); - if (tweet != null) - { - _logger.LogInformation("Tweeted name: {name} successfully with ID: {tweetId}", indexedName.Name, tweet.Id); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Failed to tweet name: `{name}` to Twitter.", indexedName!.Name); - } - } while (!stoppingToken.IsCancellationRequested && await _postingTimer.WaitForNextTickAsync(stoppingToken)); - } - - public override async Task StopAsync(CancellationToken stoppingToken) - { - _postingTimer.Dispose(); - await base.StopAsync(stoppingToken); - } - - private async Task BuildTweet(string name) - { - string link = $"{_twitterConfig.NameUrlPrefix}/{name}"; - var nameEntry = await _nameEntryService.LoadName(name); - return nameEntry == null ? null : _twitterConfig.TweetTemplate - .Replace("{name}", nameEntry.Name) - .Replace("{meaning}", nameEntry.Meaning.TrimEnd('.')) - .Replace("{link}", link); - } - } -} diff --git a/Infrastructure/DependencyInjection.cs b/Infrastructure/Twitter/DependencyInjection.cs similarity index 90% rename from Infrastructure/DependencyInjection.cs rename to Infrastructure/Twitter/DependencyInjection.cs index 9b95dbb..8c7ba3e 100644 --- a/Infrastructure/DependencyInjection.cs +++ b/Infrastructure/Twitter/DependencyInjection.cs @@ -1,12 +1,11 @@ using Infrastructure.Configuration; -using Infrastructure.Twitter; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Tweetinvi; -namespace Infrastructure +namespace Infrastructure.Twitter { - public static class DependencyInjection + public static partial class DependencyInjection { private const string ConfigSectionName = "Twitter"; @@ -26,7 +25,7 @@ public static IServiceCollection AddTwitterClient(this IServiceCollection servic twitterConfig.AccessTokenSecret ); }); - + services.AddSingleton(); return services; } diff --git a/Infrastructure/Twitter/TwitterService.cs b/Infrastructure/Twitter/TwitterService.cs new file mode 100644 index 0000000..0b6eb53 --- /dev/null +++ b/Infrastructure/Twitter/TwitterService.cs @@ -0,0 +1,84 @@ +using Application.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Diagnostics; +using Microsoft.Extensions.Caching.Memory; +using Hangfire; +using Infrastructure.Configuration; + +namespace Infrastructure.Twitter +{ + public class TwitterService( + ITwitterClientV2 twitterApiClient, + ILogger logger, + IOptions twitterConfig, + IMemoryCache cache, + IBackgroundJobClientV2 backgroundJobClient) : ITwitterService + { + private readonly ITwitterClientV2 _twitterApiClient = twitterApiClient; + private readonly ILogger _logger = logger; + private readonly TwitterConfig _twitterConfig = twitterConfig.Value; + private readonly IMemoryCache _memoryCache = cache; + private readonly IBackgroundJobClientV2 _backgroundJobClient = backgroundJobClient; + private static readonly SemaphoreSlim _semaphore; + private const string LastTweetPublishedKey = "LastTweetPublished"; + + static TwitterService() + { + _semaphore = new(1, 1); + } + + private string BuildNameTweet(string name, string meaning) + { + string link = $"{_twitterConfig.NameUrlPrefix}/{name}"; + return _twitterConfig.TweetTemplate + .Replace("{name}", name) + .Replace("{meaning}", meaning.TrimEnd('.')) + .Replace("{link}", link); + } + + public async Task PostNewNameAsync(string name, string meaning, CancellationToken cancellationToken) + { + var theTweet = BuildNameTweet(name, meaning); + await PostTweetAsync(theTweet, cancellationToken); + } + + private async Task PostTweetAsync(string tweetText, CancellationToken cancellationToken) + { + await _semaphore.WaitAsync(cancellationToken); // We want to be scheduling only one tweet at a time. + try + { + var foundLastPublished = _memoryCache.TryGetValue(LastTweetPublishedKey, out DateTimeOffset lastTweetPublished); + var nextTweetTime = lastTweetPublished.AddSeconds(_twitterConfig.TweetIntervalSeconds); + + if (foundLastPublished && nextTweetTime > DateTimeOffset.Now) + { + _backgroundJobClient.Schedule(() => SendTweetAsync(tweetText), nextTweetTime); + } + else + { + nextTweetTime = DateTimeOffset.Now; + _backgroundJobClient.Enqueue(() => SendTweetAsync(tweetText)); + } + + _memoryCache.Set(LastTweetPublishedKey, nextTweetTime); + } + finally + { + _semaphore.Release(); + } + } + + public async Task SendTweetAsync(string tweetText) + { + if (!Debugger.IsAttached) // To prevent tweets from getting posted while testing. Could be better, but... + { + var tweet = await _twitterApiClient.PostTweet(tweetText); + if (tweet != null) + { + _logger.LogInformation("Tweet was posted successfully with ID: {tweetId}", tweet.Id); + } + } + } + } +}