diff --git a/.github/workflows/api-deploy-PROD.yml b/.github/workflows/api-deploy-PROD.yml index 06d7ba5..30152bd 100644 --- a/.github/workflows/api-deploy-PROD.yml +++ b/.github/workflows/api-deploy-PROD.yml @@ -23,7 +23,7 @@ jobs: secrets: inherit with: project-name: Api - dotnet-version: '6.0.x' + dotnet-version: '8.0.x' service-name: ${{ needs.fetch-vars.outputs.service_name }} service-path: ${{ needs.fetch-vars.outputs.service_path }} environment: PROD diff --git a/.github/workflows/api-deploy-staging.yml b/.github/workflows/api-deploy-staging.yml index 1448cf8..8f366a3 100644 --- a/.github/workflows/api-deploy-staging.yml +++ b/.github/workflows/api-deploy-staging.yml @@ -23,7 +23,7 @@ jobs: secrets: inherit with: project-name: Api - dotnet-version: '6.0.x' + dotnet-version: '8.0.x' service-name: ${{ needs.fetch-vars.outputs.service_name }} service-path: ${{ needs.fetch-vars.outputs.service_path }} environment: staging diff --git a/Api/Api.csproj b/Api/Api.csproj index 7b562c0..672f7ae 100644 --- a/Api/Api.csproj +++ b/Api/Api.csproj @@ -1,24 +1,20 @@ - - net6.0 + net8.0 enable enable 15373697-caf3-4a5a-b976-46077b7bff45 Linux ..\docker-compose.dcproj - - - + - - + \ No newline at end of file diff --git a/Api/Dockerfile b/Api/Dockerfile index 78e89ad..ba75a10 100644 --- a/Api/Dockerfile +++ b/Api/Dockerfile @@ -1,10 +1,10 @@ #See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. -FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base WORKDIR /app EXPOSE 80 -FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src COPY ["Api/Api.csproj", "Api/"] RUN dotnet restore "Api/Api.csproj" diff --git a/Api/Program.cs b/Api/Program.cs index be5eb25..fcecba0 100644 --- a/Api/Program.cs +++ b/Api/Program.cs @@ -10,14 +10,19 @@ using Core.Events; using Core.StringObjectConverters; using FluentValidation; +using Infrastructure; 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; var builder = WebApplication.CreateBuilder(args); -var Configuration = builder.Configuration; +var configuration = builder.Configuration; +configuration.AddEnvironmentVariables("YND_"); + string DevCORSAllowAll = "AllowAllForDev"; var services = builder.Services; @@ -81,28 +86,36 @@ } }); }); -var mongoDbSettings = Configuration.GetSection("MongoDB"); +var mongoDbSettings = configuration.GetSection("MongoDB"); services.InitializeDatabase(mongoDbSettings.GetValue("ConnectionString"), mongoDbSettings.GetValue("DatabaseName")); builder.Services.AddTransient(x => new MySqlConnection(builder.Configuration.GetSection("MySQL:ConnectionString").Value)); -services.AddScoped(); -services.AddScoped(); -services.AddScoped(); -services.AddScoped(); -services.AddScoped(); -services.AddScoped(); -services.AddScoped(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); +services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddSingleton(); services.AddSingleton(); + +//Validation services.AddValidatorsFromAssemblyContaining(); + services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(typeof(ExactNameSearchedAdapter).Assembly)); +// Twitter integration configuration +services.AddSingleton>(); +services.AddTwitterClient(configuration); +services.AddHostedService(); + var app = builder.Build(); diff --git a/Api/appsettings.json b/Api/appsettings.json index b878027..8773081 100644 --- a/Api/appsettings.json +++ b/Api/appsettings.json @@ -6,6 +6,13 @@ "Application.Services.BasicAuthenticationHandler": "Warning" } }, - "AllowedHosts": "*" - + "AllowedHosts": "*", + "Twitter": { + "AccessToken": "your-access-token", + "AccessTokenSecret": "your-access-token-secret", + "ConsumerKey": "your-consumer-key", + "ConsumerSecret": "your-consumer-secret", + "NameUrlPrefix": "https://www.yorubaname.com/entries", + "TweetTemplate": "New name entry: {name}, {meaning}. More here: {link}" + } } diff --git a/Application/Application.csproj b/Application/Application.csproj index 6a16eb9..b8025e4 100644 --- a/Application/Application.csproj +++ b/Application/Application.csproj @@ -6,6 +6,12 @@ enable + + + + + + diff --git a/Application/EventHandlers/DeletedNameCachingHandler.cs b/Application/EventHandlers/DeletedNameCachingHandler.cs new file mode 100644 index 0000000..79df525 --- /dev/null +++ b/Application/EventHandlers/DeletedNameCachingHandler.cs @@ -0,0 +1,38 @@ +using Application.Events; +using Core.Cache; +using MediatR; +using Microsoft.Extensions.Logging; + +namespace Application.EventHandlers +{ + public class DeletedNameCachingHandler : INotificationHandler + { + private readonly IRecentIndexesCache _recentIndexesCache; + private readonly IRecentSearchesCache _recentSearchesCache; + private readonly ILogger _logger; + + public DeletedNameCachingHandler( + IRecentIndexesCache recentIndexesCache, + IRecentSearchesCache recentSearchesCache, + ILogger logger + ) + { + _recentIndexesCache = recentIndexesCache; + _recentSearchesCache = recentSearchesCache; + _logger = logger; + } + + public async Task Handle(NameDeletedAdapter notification, CancellationToken cancellationToken) + { + try + { + await _recentIndexesCache.Remove(notification.Name); + await _recentSearchesCache.Remove(notification.Name); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred while removing deleted name '{name}' from cache.", notification.Name); + } + } + } +} \ No newline at end of file diff --git a/Application/EventHandlers/NameDeletedEventHandler.cs b/Application/EventHandlers/NameDeletedEventHandler.cs deleted file mode 100644 index acc228b..0000000 --- a/Application/EventHandlers/NameDeletedEventHandler.cs +++ /dev/null @@ -1,38 +0,0 @@ -using Application.Events; -using Application.Services; -using Core.Cache; -using MediatR; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; - -namespace Application.EventHandlers -{ - - public class NameDeletedEventHandler : INotificationHandler - { - private IRecentIndexesCache _recentIndexesCache; - private IRecentSearchesCache _recentSearchesCache; - - public NameDeletedEventHandler(IRecentIndexesCache recentIndexesCache, IRecentSearchesCache recentSearchesCache) - { - _recentIndexesCache = recentIndexesCache; - _recentSearchesCache = recentSearchesCache; - } - - public async Task Handle(NameDeletedAdapter notification, CancellationToken cancellationToken) - { - try - { - await _recentIndexesCache.Remove(notification.Name); - await _recentSearchesCache.Remove(notification.Name); - } - catch (Exception ex) - { - //TODO log this - } - } - } -} \ No newline at end of file diff --git a/Application/EventHandlers/NameIndexedEventHandler.cs b/Application/EventHandlers/NameIndexedEventHandler.cs index 6312318..2f83e3a 100644 --- a/Application/EventHandlers/NameIndexedEventHandler.cs +++ b/Application/EventHandlers/NameIndexedEventHandler.cs @@ -7,15 +7,20 @@ namespace Application.EventHandlers public class NameIndexedEventHandler : INotificationHandler { public IRecentIndexesCache _recentIndexesCache; + private readonly IMediator _mediator; - public NameIndexedEventHandler(IRecentIndexesCache recentIndexesCache) + public NameIndexedEventHandler( + IRecentIndexesCache recentIndexesCache, + IMediator mediator) { _recentIndexesCache = recentIndexesCache; + _mediator = mediator; } public async Task Handle(NameIndexedAdapter notification, CancellationToken cancellationToken) { await _recentIndexesCache.Stack(notification.Name); + await _mediator.Publish(new PostPublishedNameCommand(notification.Name), cancellationToken); } } } diff --git a/Application/EventHandlers/PostPublishedNameCommandHandler.cs b/Application/EventHandlers/PostPublishedNameCommandHandler.cs new file mode 100644 index 0000000..7f8b3e5 --- /dev/null +++ b/Application/EventHandlers/PostPublishedNameCommandHandler.cs @@ -0,0 +1,25 @@ +using Application.Events; +using MediatR; +using System.Collections.Concurrent; + +namespace Application.EventHandlers +{ + public class PostPublishedNameCommandHandler : INotificationHandler + { + private readonly ConcurrentQueue _nameQueue; + + public PostPublishedNameCommandHandler(ConcurrentQueue nameQueue) + { + _nameQueue = nameQueue; + } + + public 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; + } + } +} diff --git a/Application/Events/PostPublishedNameCommand.cs b/Application/Events/PostPublishedNameCommand.cs new file mode 100644 index 0000000..62f21d7 --- /dev/null +++ b/Application/Events/PostPublishedNameCommand.cs @@ -0,0 +1,8 @@ +using MediatR; + +namespace Application.Events +{ + public record PostPublishedNameCommand(string Name) : INotification + { + } +} diff --git a/Application/Services/EventPubService.cs b/Application/Services/EventPubService.cs index c101410..f97828c 100644 --- a/Application/Services/EventPubService.cs +++ b/Application/Services/EventPubService.cs @@ -13,7 +13,6 @@ public EventPubService(IMediator mediator) _mediator = mediator; } - public async Task PublishEvent(T theEvent) { var adapterClassName = typeof(T).Name + "Adapter"; @@ -24,7 +23,7 @@ public async Task PublishEvent(T theEvent) throw new InvalidOperationException("Adapter type not found for " + typeof(T).FullName); } - var adapterEvent = Activator.CreateInstance(adapterType, theEvent); + var adapterEvent = Activator.CreateInstance(adapterType, theEvent)!; await _mediator.Publish(adapterEvent); } } diff --git a/Infrastructure.MongoDB/DependencyInjection.cs b/Infrastructure.MongoDB/DependencyInjection.cs index f118bf9..58b8204 100644 --- a/Infrastructure.MongoDB/DependencyInjection.cs +++ b/Infrastructure.MongoDB/DependencyInjection.cs @@ -10,14 +10,14 @@ public static class DependencyInjection public static void InitializeDatabase(this IServiceCollection services, string connectionString, string databaseName) { services.AddSingleton(s => new MongoClient(connectionString)); - services.AddScoped(s => s.GetRequiredService().GetDatabase(databaseName)); + services.AddSingleton(s => s.GetRequiredService().GetDatabase(databaseName)); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } } } diff --git a/Infrastructure/Configuration/TwitterConfig.cs b/Infrastructure/Configuration/TwitterConfig.cs new file mode 100644 index 0000000..cea16ec --- /dev/null +++ b/Infrastructure/Configuration/TwitterConfig.cs @@ -0,0 +1,13 @@ +namespace Infrastructure.Configuration +{ + public record TwitterConfig( + string ConsumerKey, + string ConsumerSecret, + string AccessToken, + string AccessTokenSecret, + string NameUrlPrefix, + string TweetTemplate) + { + public TwitterConfig() : this("", "", "", "", "", "") { } + } +} diff --git a/Infrastructure/DependencyInjection.cs b/Infrastructure/DependencyInjection.cs new file mode 100644 index 0000000..032deea --- /dev/null +++ b/Infrastructure/DependencyInjection.cs @@ -0,0 +1,32 @@ +using Infrastructure.Configuration; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Tweetinvi; + +namespace Infrastructure +{ + public static class DependencyInjection + { + private const string ConfigSectionName = "Twitter"; + + public static IServiceCollection AddTwitterClient(this IServiceCollection services, IConfiguration configuration) + { + var config = configuration.GetRequiredSection(ConfigSectionName); + + services.Configure(config); + + services.AddSingleton(provider => + { + var twitterConfig = config.Get()!; + return new TwitterClient( + twitterConfig.ConsumerKey, + twitterConfig.ConsumerSecret, + twitterConfig.AccessToken, + twitterConfig.AccessTokenSecret + ); + }); + + return services; + } + } +} diff --git a/Infrastructure/Infrastructure.csproj b/Infrastructure/Infrastructure.csproj new file mode 100644 index 0000000..b2ef52c --- /dev/null +++ b/Infrastructure/Infrastructure.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + diff --git a/Infrastructure/Services/NamePostingService.cs b/Infrastructure/Services/NamePostingService.cs new file mode 100644 index 0000000..9d4b95f --- /dev/null +++ b/Infrastructure/Services/NamePostingService.cs @@ -0,0 +1,74 @@ +using Application.Domain; +using Application.Events; +using Infrastructure.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Collections.Concurrent; +using Tweetinvi; + +namespace Infrastructure.Services +{ + public class NamePostingService( + ConcurrentQueue nameQueue, + ITwitterClient 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 TweetsV2Poster _twitterApiClient = new (twitterApiClient); + private readonly ILogger _logger = logger; + private readonly NameEntryService _nameEntryService = nameEntryService; + private readonly TwitterConfig _twitterConfig = twitterConfig.Value; + private const int TweetIntervalMs = 3 * 60 * 1000; // 3 minutes + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + if (!_nameQueue.TryDequeue(out var indexedName)) + { + await Task.Delay(TweetIntervalMs, stoppingToken); + continue; + } + + string? tweetText = await BuildTweet(indexedName.Name); + + if (string.IsNullOrWhiteSpace(tweetText)) + { + _logger.LogWarning(TweetComposeFailure, indexedName.Name); + continue; + } + + try + { + 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); + _nameQueue.Enqueue(indexedName); + } + + await Task.Delay(TweetIntervalMs, 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/TweetsV2Poster.cs b/Infrastructure/TweetsV2Poster.cs new file mode 100644 index 0000000..7d6637a --- /dev/null +++ b/Infrastructure/TweetsV2Poster.cs @@ -0,0 +1,87 @@ +using System.Text; +using Tweetinvi.Core.Web; +using Tweetinvi.Models; +using Tweetinvi; +using Newtonsoft.Json; + +namespace Infrastructure +{ + public class TweetsV2Poster + { + // ----------------- Fields ---------------- + + private readonly ITwitterClient _client; + + // ----------------- Constructor ---------------- + + public TweetsV2Poster(ITwitterClient client) + { + _client = client; + } + + public async Task PostTweet(string text) + { + var result = await _client.Execute.AdvanceRequestAsync( + (ITwitterRequest request) => + { + var jsonBody = _client.Json.Serialize(new TweetV2PostRequest + { + Text = text + }); + + var content = new StringContent(jsonBody, Encoding.UTF8, "application/json"); + + request.Query.Url = "https://api.twitter.com/2/tweets"; + request.Query.HttpMethod = Tweetinvi.Models.HttpMethod.POST; + request.Query.HttpContent = content; + } + ); + + if (!result.Response.IsSuccessStatusCode) + { + throw new Exception($"Error when posting tweet:{Environment.NewLine}{result.Content}"); + } + + return _client.Json.Deserialize(result.Content); + } + + /// + /// There are a lot more fields according to: + /// https://developer.twitter.com/en/docs/twitter-api/tweets/manage-tweets/api-reference/post-tweets + /// but these are the ones we care about for our use case. + /// + private class TweetV2PostRequest + { + [JsonProperty("text")] + public string Text { get; set; } = string.Empty; + } + } + + public record TweetV2PostData + { + [JsonProperty("id")] + public string Id { get; init; } + [JsonProperty("text")] + public string Text { get; init; } + + public TweetV2PostData(string id, string text) + { + Id = id; + Text = text; + } + } + + public record TweetV2PostResponse + { + [JsonProperty("data")] + public TweetV2PostData Data { get; init; } + + public string? Id => Data?.Id; + public string? Text => Data?.Text; + + public TweetV2PostResponse(TweetV2PostData data) + { + Data = data; + } + } +} diff --git a/Test/Test.csproj b/Test/Test.csproj index b319d04..711091c 100644 --- a/Test/Test.csproj +++ b/Test/Test.csproj @@ -1,36 +1,24 @@ - - - net6.0 - enable - - false - - - - - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - - - - - - - - - - - - + + net8.0 + enable + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + \ No newline at end of file diff --git a/Website/Dockerfile b/Website/Dockerfile index 78fb695..03cf50f 100644 --- a/Website/Dockerfile +++ b/Website/Dockerfile @@ -10,8 +10,6 @@ FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ["Website/Website.csproj", "Website/"] -COPY ["Application/Application.csproj", "Application/"] -COPY ["Core/Core.csproj", "Core/"] RUN dotnet restore "./Website/Website.csproj" COPY . . WORKDIR "/src/Website" diff --git a/YorubaNameDictionary.sln b/YorubaNameDictionary.sln index 4da7efc..6bbaa85 100644 --- a/YorubaNameDictionary.sln +++ b/YorubaNameDictionary.sln @@ -27,6 +27,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution README.md = README.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure", "Infrastructure\Infrastructure.csproj", "{78930A71-3759-46B3-9429-80C4D4933D12}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -61,6 +63,10 @@ Global {A69D2C16-E0AD-4911-8D4F-42369452BAB2}.Debug|Any CPU.Build.0 = Debug|Any CPU {A69D2C16-E0AD-4911-8D4F-42369452BAB2}.Release|Any CPU.ActiveCfg = Release|Any CPU {A69D2C16-E0AD-4911-8D4F-42369452BAB2}.Release|Any CPU.Build.0 = Release|Any CPU + {78930A71-3759-46B3-9429-80C4D4933D12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {78930A71-3759-46B3-9429-80C4D4933D12}.Debug|Any CPU.Build.0 = Debug|Any CPU + {78930A71-3759-46B3-9429-80C4D4933D12}.Release|Any CPU.ActiveCfg = Release|Any CPU + {78930A71-3759-46B3-9429-80C4D4933D12}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/docker-compose.override.yml b/docker-compose.override.yml index 6f9b93d..b594ccd 100644 --- a/docker-compose.override.yml +++ b/docker-compose.override.yml @@ -14,6 +14,10 @@ services: environment: - ASPNETCORE_ENVIRONMENT=Development - ASPNETCORE_URLS=http://+:80 + - Twitter__ConsumerKey=${YND_Twitter__ConsumerKey} + - Twitter__ConsumerSecret=${YND_Twitter__ConsumerSecret} + - Twitter__AccessToken=${YND_Twitter__AccessToken} + - Twitter__AccessTokenSecret=${YND_Twitter__AccessTokenSecret} ports: - "51515:80" volumes: