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: