From 9fde04e48a61f37e222843c461dc2f23ca24a79c Mon Sep 17 00:00:00 2001 From: Temirkhan Amanzhanov Date: Sun, 8 Mar 2026 01:53:08 +0100 Subject: [PATCH] Add Redis client --- AffirmationGenerator.Client/package.json | 2 +- .../Queries/GetAffirmationQueryTests.cs | 12 +++---- .../GetRemainingAffirmationsQueryTests.cs | 4 +-- .../AffirmationGenerator.Server.csproj | 3 ++ .../Application/DiConfig.cs | 1 - .../Queries/GetAffirmationQuery.cs | 4 +-- .../Queries/GetRemainingAffirmationsQuery.cs | 2 +- .../Affirmation/AffirmationService.cs | 34 +++++++++---------- .../Affirmation/IAffirmationService.cs | 4 +-- .../Core/Extensions/TimeSpanExtensions.cs | 9 +++++ .../Infrastructure/DiConfig.cs | 19 +++++++++-- .../Infrastructure/Redis/IRedisClient.cs | 8 +++++ .../Infrastructure/Redis/RedisClient.cs | 19 +++++++++++ .../Redis/RedisClientOptions.cs | 6 ++++ AffirmationGenerator.Server/appsettings.json | 3 ++ 15 files changed, 95 insertions(+), 35 deletions(-) create mode 100644 AffirmationGenerator.Server/Core/Extensions/TimeSpanExtensions.cs create mode 100644 AffirmationGenerator.Server/Infrastructure/Redis/IRedisClient.cs create mode 100644 AffirmationGenerator.Server/Infrastructure/Redis/RedisClient.cs create mode 100644 AffirmationGenerator.Server/Infrastructure/Redis/RedisClientOptions.cs diff --git a/AffirmationGenerator.Client/package.json b/AffirmationGenerator.Client/package.json index fdba447..0fef06d 100644 --- a/AffirmationGenerator.Client/package.json +++ b/AffirmationGenerator.Client/package.json @@ -1,7 +1,7 @@ { "name": "affirmation-generator-client", "private": true, - "version": "1.1.1", + "version": "1.4.0", "packageManager": "pnpm@10.28.2", "type": "module", "scripts": { diff --git a/AffirmationGenerator.Server.Tests/Application/Queries/GetAffirmationQueryTests.cs b/AffirmationGenerator.Server.Tests/Application/Queries/GetAffirmationQueryTests.cs index 7296963..7c028e3 100644 --- a/AffirmationGenerator.Server.Tests/Application/Queries/GetAffirmationQueryTests.cs +++ b/AffirmationGenerator.Server.Tests/Application/Queries/GetAffirmationQueryTests.cs @@ -37,7 +37,7 @@ public async Task Handle_WhenLanguageIsEnglish_ShouldReturnUntranslatedAffirmati const string affirmationText = "Good day!"; const int maxRequestsPerDay = 10; - _affirmationService.Get().Returns(affirmationText); + _affirmationService.GetAffirmation().Returns(affirmationText); var getAffirmationRequest = new GetAffirmationRequest { TargetLanguage = AffirmationLanguage.English }; @@ -53,7 +53,7 @@ public async Task Handle_WhenLanguageIsEnglish_ShouldReturnUntranslatedAffirmati response.Text.ShouldBe(affirmationText); response.RemainingCount.ShouldBeLessThan(maxRequestsPerDay); - await _affirmationService.Received(1).Get(); + await _affirmationService.Received(1).GetAffirmation(); _languageCodeMapper.Received(1).Map(AffirmationLanguage.English); await _translatorClient.DidNotReceiveWithAnyArgs().Translate(Arg.Any(), Arg.Any(), Arg.Any()); } @@ -66,7 +66,7 @@ public async Task Handle_WhenLanguageIsGerman_ShouldReturnTranslatedAffirmationI const string affirmationTextInGerman = "Guten Tag!"; const int maxRequestsPerDay = 10; - _affirmationService.Get().Returns(affirmationText); + _affirmationService.GetAffirmation().Returns(affirmationText); var getAffirmationRequest = new GetAffirmationRequest { TargetLanguage = AffirmationLanguage.German }; @@ -83,7 +83,7 @@ public async Task Handle_WhenLanguageIsGerman_ShouldReturnTranslatedAffirmationI response.Text.ShouldBe(affirmationTextInGerman); response.RemainingCount.ShouldBeLessThan(maxRequestsPerDay); - await _affirmationService.Received(1).Get(); + await _affirmationService.Received(1).GetAffirmation(); _languageCodeMapper.Received(1).Map(AffirmationLanguage.German); await _translatorClient.Received(1).Translate(affirmationText, LanguageCode.English, LanguageCode.German); } @@ -92,7 +92,7 @@ public async Task Handle_WhenLanguageIsGerman_ShouldReturnTranslatedAffirmationI public async Task Handle_WhenNoAffirmation_ShouldReturnError() { // Arrange - _affirmationService.Get().Returns(Result.Error(new AffirmationNotFound())); + _affirmationService.GetAffirmation().Returns(Result.Error(new AffirmationNotFound())); var getAffirmationRequest = new GetAffirmationRequest { TargetLanguage = AffirmationLanguage.German }; @@ -102,7 +102,7 @@ public async Task Handle_WhenNoAffirmation_ShouldReturnError() // Assert result.ShouldBeError().ShouldBeOfType(); - await _affirmationService.Received(1).Get(); + await _affirmationService.Received(1).GetAffirmation(); _languageCodeMapper.DidNotReceiveWithAnyArgs().Map(Arg.Any()); await _translatorClient.DidNotReceiveWithAnyArgs().Translate(Arg.Any(), Arg.Any(), Arg.Any()); } diff --git a/AffirmationGenerator.Server.Tests/Application/Queries/GetRemainingAffirmationsQueryTests.cs b/AffirmationGenerator.Server.Tests/Application/Queries/GetRemainingAffirmationsQueryTests.cs index 1e77171..ca06842 100644 --- a/AffirmationGenerator.Server.Tests/Application/Queries/GetRemainingAffirmationsQueryTests.cs +++ b/AffirmationGenerator.Server.Tests/Application/Queries/GetRemainingAffirmationsQueryTests.cs @@ -27,7 +27,7 @@ public void SetUp() public async Task Handle_ShouldReturnRemainingAffirmationsCount(int maxRequestsPerDay) { // Arrange - _affirmationService.Count().Returns(maxRequestsPerDay); + _affirmationService.GetRemainingAffirmationsCount().Returns(maxRequestsPerDay); // Act var result = await _query.Handle(); @@ -37,6 +37,6 @@ public async Task Handle_ShouldReturnRemainingAffirmationsCount(int maxRequestsP response.RemainingCount.ShouldBe(maxRequestsPerDay); response.RemainingCount.ShouldBeGreaterThanOrEqualTo(0); - await _affirmationService.Received(1).Count(); + await _affirmationService.Received(1).GetRemainingAffirmationsCount(); } } diff --git a/AffirmationGenerator.Server/AffirmationGenerator.Server.csproj b/AffirmationGenerator.Server/AffirmationGenerator.Server.csproj index 328b9a6..849c382 100644 --- a/AffirmationGenerator.Server/AffirmationGenerator.Server.csproj +++ b/AffirmationGenerator.Server/AffirmationGenerator.Server.csproj @@ -28,6 +28,9 @@ 10.0.1 + + 2.11.8 + 10.1.4 diff --git a/AffirmationGenerator.Server/Application/DiConfig.cs b/AffirmationGenerator.Server/Application/DiConfig.cs index 53da9da..dc42906 100644 --- a/AffirmationGenerator.Server/Application/DiConfig.cs +++ b/AffirmationGenerator.Server/Application/DiConfig.cs @@ -15,7 +15,6 @@ public IServiceCollection AddApplication(IConfiguration configuration) services.Configure(configurationSection.GetSection(nameof(ClientOptions))); services.AddHttpContextAccessor(); - services.AddMemoryCache(); services.AddScoped(); services.AddScoped(); diff --git a/AffirmationGenerator.Server/Application/Queries/GetAffirmationQuery.cs b/AffirmationGenerator.Server/Application/Queries/GetAffirmationQuery.cs index adc6602..7b36461 100644 --- a/AffirmationGenerator.Server/Application/Queries/GetAffirmationQuery.cs +++ b/AffirmationGenerator.Server/Application/Queries/GetAffirmationQuery.cs @@ -17,7 +17,7 @@ ILanguageCodeMapper languageCodeMapper { public async Task> Handle(GetAffirmationRequest request) => await ( - from affirmation in affirmationService.Get() + from affirmation in affirmationService.GetAffirmation() from targetLanguageCode in languageCodeMapper.Map(request.TargetLanguage) from translatedAffirmation in Translate(affirmation, targetLanguageCode) select ToResponse(request.TargetLanguage, translatedAffirmation) @@ -40,6 +40,6 @@ private async Task ToResponse(AffirmationLanguage targetLan { TargetLanguage = targetLanguage, Text = affirmation, - RemainingCount = await affirmationService.Count(), + RemainingCount = await affirmationService.GetRemainingAffirmationsCount(), }; } diff --git a/AffirmationGenerator.Server/Application/Queries/GetRemainingAffirmationsQuery.cs b/AffirmationGenerator.Server/Application/Queries/GetRemainingAffirmationsQuery.cs index be1eaa6..495bb31 100644 --- a/AffirmationGenerator.Server/Application/Queries/GetRemainingAffirmationsQuery.cs +++ b/AffirmationGenerator.Server/Application/Queries/GetRemainingAffirmationsQuery.cs @@ -8,7 +8,7 @@ public sealed class GetRemainingAffirmationsQuery(IAffirmationService affirmatio { public async Task> Handle() { - var remainingCount = await affirmationService.Count(); + var remainingCount = await affirmationService.GetRemainingAffirmationsCount(); return new RemainingAffirmationsResponse { RemainingCount = remainingCount }; } } diff --git a/AffirmationGenerator.Server/Application/Services/Affirmation/AffirmationService.cs b/AffirmationGenerator.Server/Application/Services/Affirmation/AffirmationService.cs index acd5781..28f73ff 100644 --- a/AffirmationGenerator.Server/Application/Services/Affirmation/AffirmationService.cs +++ b/AffirmationGenerator.Server/Application/Services/Affirmation/AffirmationService.cs @@ -1,8 +1,9 @@ using AffirmationGenerator.Server.Application.Extensions; using AffirmationGenerator.Server.Core; +using AffirmationGenerator.Server.Core.Extensions; using AffirmationGenerator.Server.Domain; using AffirmationGenerator.Server.Infrastructure.Affirmation; -using Microsoft.Extensions.Caching.Memory; +using AffirmationGenerator.Server.Infrastructure.Redis; using Microsoft.Extensions.Options; namespace AffirmationGenerator.Server.Application.Services.Affirmation; @@ -10,7 +11,7 @@ namespace AffirmationGenerator.Server.Application.Services.Affirmation; public sealed class AffirmationService( ILogger logger, IAffirmationClient affirmationClient, - IMemoryCache memoryCache, + IRedisClient redisClient, IHttpContextAccessor httpContextAccessor, IOptions clientOptions ) : IAffirmationService @@ -21,11 +22,9 @@ IOptions clientOptions private string CacheKey => $"{ClientIpAddress}"; - private static TimeSpan OneDay => TimeSpan.FromDays(1); - - public async Task> Get() + public async Task> GetAffirmation() { - var remainingAffirmations = await Count(); + var remainingAffirmations = await GetRemainingAffirmationsCount(); var affirmationResponse = await affirmationClient.GetAffirmation(); var affirmation = affirmationResponse.Affirmation ?? string.Empty; @@ -36,21 +35,20 @@ public async Task> Get() return Result.Error(new AffirmationNotFound()); } - SetCount(remainingAffirmations); + await SetRemainingAffirmationsCount(remainingAffirmations); return Result.Success(affirmation); } - public async Task Count() + public async Task GetRemainingAffirmationsCount() { - var remainingCount = await memoryCache.GetOrCreateAsync( - CacheKey, - entry => - { - entry.SetAbsoluteExpiration(OneDay); - return Task.FromResult(ClientOptions.MaxRequestsPerDay); - } - ); + var cachedValue = await redisClient.GetString(CacheKey); + + if (int.TryParse(cachedValue, out var remainingCount) == false) + { + remainingCount = ClientOptions.MaxRequestsPerDay; + await redisClient.SetString(CacheKey, $"{remainingCount}", TimeSpan.OneDay); + } if (logger.IsEnabled(LogLevel.Information)) logger.LogInformation("{RemainingCount} affirmations remain for user {ClientIpAddress}", remainingCount, ClientIpAddress); @@ -58,7 +56,7 @@ public async Task Count() return remainingCount; } - private void SetCount(int count) + private async Task SetRemainingAffirmationsCount(int count) { if (count <= 0) return; @@ -68,6 +66,6 @@ private void SetCount(int count) if (count <= 0) count = 0; - memoryCache.Set(CacheKey, count, OneDay); + await redisClient.SetString(CacheKey, $"{count}", TimeSpan.OneDay); } } diff --git a/AffirmationGenerator.Server/Application/Services/Affirmation/IAffirmationService.cs b/AffirmationGenerator.Server/Application/Services/Affirmation/IAffirmationService.cs index e1bd19a..2ff11b7 100644 --- a/AffirmationGenerator.Server/Application/Services/Affirmation/IAffirmationService.cs +++ b/AffirmationGenerator.Server/Application/Services/Affirmation/IAffirmationService.cs @@ -4,7 +4,7 @@ namespace AffirmationGenerator.Server.Application.Services.Affirmation; public interface IAffirmationService { - Task> Get(); + Task> GetAffirmation(); - Task Count(); + Task GetRemainingAffirmationsCount(); } diff --git a/AffirmationGenerator.Server/Core/Extensions/TimeSpanExtensions.cs b/AffirmationGenerator.Server/Core/Extensions/TimeSpanExtensions.cs new file mode 100644 index 0000000..399ab71 --- /dev/null +++ b/AffirmationGenerator.Server/Core/Extensions/TimeSpanExtensions.cs @@ -0,0 +1,9 @@ +namespace AffirmationGenerator.Server.Core.Extensions; + +public static class TimeSpanExtensions +{ + extension(TimeSpan) + { + public static TimeSpan OneDay => TimeSpan.FromDays(1); + } +} diff --git a/AffirmationGenerator.Server/Infrastructure/DiConfig.cs b/AffirmationGenerator.Server/Infrastructure/DiConfig.cs index 91573c4..3f325c3 100644 --- a/AffirmationGenerator.Server/Infrastructure/DiConfig.cs +++ b/AffirmationGenerator.Server/Infrastructure/DiConfig.cs @@ -1,7 +1,9 @@ using AffirmationGenerator.Server.Infrastructure.Affirmation; using AffirmationGenerator.Server.Infrastructure.DeepL; +using AffirmationGenerator.Server.Infrastructure.Redis; using Microsoft.Extensions.Options; using Refit; +using StackExchange.Redis; namespace AffirmationGenerator.Server.Infrastructure; @@ -12,8 +14,10 @@ public static class DiConfig public IServiceCollection AddInfrastructure(IConfiguration configuration) { var configurationSection = configuration.GetSection("Infrastructure"); - - return services.AddDeepLTranslatorClient(configurationSection).AddAffirmationClient(configurationSection); + return services + .AddDeepLTranslatorClient(configurationSection) + .AddAffirmationClient(configurationSection) + .AddRedis(configurationSection); } private IServiceCollection AddDeepLTranslatorClient(IConfiguration configuration) @@ -36,6 +40,17 @@ private IServiceCollection AddAffirmationClient(IConfiguration configuration) httpClient.BaseAddress = new Uri(baseUrl); } ); + return services; + } + + private IServiceCollection AddRedis(IConfiguration configuration) + { + var connectionString = + configuration.GetSection(nameof(RedisClientOptions)).GetValue(nameof(RedisClientOptions.ConnectionString)) + ?? string.Empty; + + services.AddSingleton(ConnectionMultiplexer.Connect(connectionString)); + services.AddScoped(); return services; } diff --git a/AffirmationGenerator.Server/Infrastructure/Redis/IRedisClient.cs b/AffirmationGenerator.Server/Infrastructure/Redis/IRedisClient.cs new file mode 100644 index 0000000..03e20a3 --- /dev/null +++ b/AffirmationGenerator.Server/Infrastructure/Redis/IRedisClient.cs @@ -0,0 +1,8 @@ +namespace AffirmationGenerator.Server.Infrastructure.Redis; + +public interface IRedisClient +{ + Task GetString(string key); + + Task SetString(string key, string value, TimeSpan expiration); +} diff --git a/AffirmationGenerator.Server/Infrastructure/Redis/RedisClient.cs b/AffirmationGenerator.Server/Infrastructure/Redis/RedisClient.cs new file mode 100644 index 0000000..879d903 --- /dev/null +++ b/AffirmationGenerator.Server/Infrastructure/Redis/RedisClient.cs @@ -0,0 +1,19 @@ +using StackExchange.Redis; + +namespace AffirmationGenerator.Server.Infrastructure.Redis; + +public sealed class RedisClient(IConnectionMultiplexer redis) : IRedisClient +{ + private IDatabase Database => redis.GetDatabase(); + + public async Task GetString(string key) + { + var value = await Database.StringGetAsync(GetPrefixKey(key)); + return value.HasValue == false ? null : value.ToString(); + } + + public async Task SetString(string key, string value, TimeSpan expiration) => + await Database.StringSetAsync(GetPrefixKey(key), value, expiration); + + private static string GetPrefixKey(string key) => $"{nameof(RedisClient)}:{key}"; +} diff --git a/AffirmationGenerator.Server/Infrastructure/Redis/RedisClientOptions.cs b/AffirmationGenerator.Server/Infrastructure/Redis/RedisClientOptions.cs new file mode 100644 index 0000000..62301e4 --- /dev/null +++ b/AffirmationGenerator.Server/Infrastructure/Redis/RedisClientOptions.cs @@ -0,0 +1,6 @@ +namespace AffirmationGenerator.Server.Infrastructure.Redis; + +public sealed record RedisClientOptions +{ + public required string ConnectionString { get; init; } +} diff --git a/AffirmationGenerator.Server/appsettings.json b/AffirmationGenerator.Server/appsettings.json index 181181c..35cf268 100644 --- a/AffirmationGenerator.Server/appsettings.json +++ b/AffirmationGenerator.Server/appsettings.json @@ -21,6 +21,9 @@ }, "AffirmationClientOptions": { "BaseUrl": "https://www.affirmations.dev" + }, + "RedisClientOptions": { + "ConnectionString": "" } } }