From a70ab015500516ecc49fb0150bba33e31d8a26dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Schultz=20Madsen?= Date: Sat, 3 Oct 2026 11:22:36 +0200 Subject: [PATCH] refactor(translation): implement eFormApi.BasePn's ITranslationService (microting/eform-backendconfiguration-plugin#1384) The translation interface moves to eFormApi.BasePn 10.0.37 so plugins can translate server-side; the host keeps the implementation and the options, so the API key never leaves the host. - TranslationService implements the BasePn interface, reads the key from IOptions and exposes IsConfigured; the translation-possible endpoint uses it. Without a key it fails without calling Google. - As the interface promises, a network failure, timeout or unreadable response becomes an unsuccessful result instead of an exception. - One static HttpClient (10 s timeout, pooled connections recycled every 5 min) instead of a client per call; the service is now a singleton. - Bumps Microting.eFormApi.BasePn to 10.0.37. Co-Authored-By: Claude Opus 5.5 --- .../Services/TranslationServiceTests.cs | 4 +- .../TranslationServiceTests.cs | 22 +++++++ .../Abstractions/ITranslationService.cs | 11 ---- .../Controllers/TranslationController.cs | 14 ++--- .../Services/TranslationService.cs | 58 +++++++++++++++---- eFormAPI/eFormAPI.Web/Startup.cs | 3 +- eFormAPI/eFormAPI.Web/eFormAPI.Web.csproj | 2 +- 7 files changed, 79 insertions(+), 35 deletions(-) delete mode 100644 eFormAPI/eFormAPI.Web/Abstractions/ITranslationService.cs diff --git a/eFormAPI/eFormAPI.Web.Integration.Tests/Services/TranslationServiceTests.cs b/eFormAPI/eFormAPI.Web.Integration.Tests/Services/TranslationServiceTests.cs index 5c1b16c53f..458a9d454c 100644 --- a/eFormAPI/eFormAPI.Web.Integration.Tests/Services/TranslationServiceTests.cs +++ b/eFormAPI/eFormAPI.Web.Integration.Tests/Services/TranslationServiceTests.cs @@ -22,6 +22,8 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +using Microsoft.Extensions.Options; +using eFormAPI.Web.Infrastructure.Models; using NUnit.Framework; using eFormAPI.Web.Services; @@ -34,7 +36,7 @@ public class TranslationServiceTests : DbTestFixture public override void DoSetup() { - _translationService = new TranslationService(); + _translationService = new TranslationService(Options.Create(new GoogleTranslateOptions())); } [Test] diff --git a/eFormAPI/eFormAPI.Web.Tests/TranslationServiceTests.cs b/eFormAPI/eFormAPI.Web.Tests/TranslationServiceTests.cs index 12c0dc52a1..a912ae21e2 100644 --- a/eFormAPI/eFormAPI.Web.Tests/TranslationServiceTests.cs +++ b/eFormAPI/eFormAPI.Web.Tests/TranslationServiceTests.cs @@ -1,4 +1,7 @@ using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Extensions.Options; +using eFormAPI.Web.Infrastructure.Models; using NUnit.Framework; using eFormAPI.Web.Services; @@ -23,4 +26,23 @@ public void BuildTranslateRequest_NormalisesLocaleToIso_AndEncodesText() Assert.That(encoded, Does.Contain("q=Tank+4")); Assert.That(encoded, Does.Contain("target=de")); } + + [TestCase(null)] + [TestCase("")] + public async Task TranslateText_WithoutApiKey_IsNotConfigured_AndFailsWithoutCallingGoogle(string apiKey) + { + var service = new TranslationService(Options.Create(new GoogleTranslateOptions { ApiKey = apiKey })); + + Assert.That(service.IsConfigured, Is.False); + var result = await service.TranslateText("Tank 4", "da", "en-US"); + Assert.That(result.Success, Is.False); + } + + [Test] + public void IsConfigured_WithApiKey_IsTrue() + { + var service = new TranslationService(Options.Create(new GoogleTranslateOptions { ApiKey = "realkey" })); + + Assert.That(service.IsConfigured, Is.True); + } } diff --git a/eFormAPI/eFormAPI.Web/Abstractions/ITranslationService.cs b/eFormAPI/eFormAPI.Web/Abstractions/ITranslationService.cs deleted file mode 100644 index 49a4541693..0000000000 --- a/eFormAPI/eFormAPI.Web/Abstractions/ITranslationService.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Threading.Tasks; -using eFormAPI.Web.Infrastructure.Models; -using Microting.eFormApi.BasePn.Infrastructure.Models.API; - -namespace eFormAPI.Web.Abstractions; - -public interface ITranslationService -{ - Task> TranslateText(string sourceText, string sourceLanguageCode, string targetLanguageCode, string apiKey); - -} \ No newline at end of file diff --git a/eFormAPI/eFormAPI.Web/Controllers/TranslationController.cs b/eFormAPI/eFormAPI.Web/Controllers/TranslationController.cs index 81d8b06642..56754bff0e 100644 --- a/eFormAPI/eFormAPI.Web/Controllers/TranslationController.cs +++ b/eFormAPI/eFormAPI.Web/Controllers/TranslationController.cs @@ -1,31 +1,27 @@ using System.Threading.Tasks; -using eFormAPI.Web.Abstractions; -using eFormAPI.Web.Infrastructure.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Options; +using Microting.eFormApi.BasePn.Abstractions.Translation; using Microting.eFormApi.BasePn.Infrastructure.Models.API; namespace eFormAPI.Web.Controllers; [Authorize] -public class TranslationController(ITranslationService translationService, IOptions options) +public class TranslationController(ITranslationService translationService) : Controller { - private readonly string _apiKey = options.Value.ApiKey; - [HttpGet] [Route("api/get-translation")] public async Task> TranslateText(string sourceText, string sourceLanguageCode, string targetLanguageCode) { - return await translationService.TranslateText(sourceText, sourceLanguageCode, targetLanguageCode, _apiKey); + return await translationService.TranslateText(sourceText, sourceLanguageCode, targetLanguageCode); } [HttpGet] [Route("api/translation-possible")] public OperationDataResult TranslateTextPossible() { - return new OperationDataResult(true, !string.IsNullOrEmpty(_apiKey)); + return new OperationDataResult(true, translationService.IsConfigured); } -} \ No newline at end of file +} diff --git a/eFormAPI/eFormAPI.Web/Services/TranslationService.cs b/eFormAPI/eFormAPI.Web/Services/TranslationService.cs index 8f06bbfed9..0650ba4746 100644 --- a/eFormAPI/eFormAPI.Web/Services/TranslationService.cs +++ b/eFormAPI/eFormAPI.Web/Services/TranslationService.cs @@ -3,36 +3,70 @@ using System.Net.Http; using System.Text.Json; using System.Threading.Tasks; -using eFormAPI.Web.Abstractions; +using eFormAPI.Web.Infrastructure.Models; +using Microsoft.Extensions.Options; +using Microting.eFormApi.BasePn.Abstractions.Translation; using Microting.eFormApi.BasePn.Infrastructure.Models.API; namespace eFormAPI.Web.Services; -public class TranslationService : ITranslationService +/// +/// Google Translate backed . The interface lives in +/// eFormApi.BasePn so plugins can translate server-side (#1384 in the +/// backendconfiguration plugin); the options, and so the API key, stay in the host. +/// Registered as a singleton: it holds only the key and a shared HttpClient. +/// +public class TranslationService(IOptions options) : ITranslationService { - public TranslationService() - { + // One client for the process (a client per call exhausts sockets); the pooled + // connections are recycled so a DNS change is picked up. The timeout bounds a save + // that waits on a translation. + private static readonly HttpClient Client = + new(new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) }) + { + Timeout = TimeSpan.FromSeconds(10) + }; - } + private readonly string _apiKey = options.Value.ApiKey; + + public bool IsConfigured => !string.IsNullOrEmpty(_apiKey); public async Task> TranslateText(string sourceText, string sourceLanguageCode, - string targetLanguageCode, string apiKey) + string targetLanguageCode) { + if (!IsConfigured) + { + return new OperationDataResult(false, "Translate: no API key configured"); + } + // E2E hook: a sentinel key returns deterministic fake text instead of // calling Google. Gated behind ALLOW_FAKE_TRANSLATE (set only in CI) so // it can NEVER fire in production, even if API_KEY were the sentinel. - if (apiKey == "FAKE_TRANSLATE_E2E" + if (_apiKey == "FAKE_TRANSLATE_E2E" && Environment.GetEnvironmentVariable("ALLOW_FAKE_TRANSLATE") == "true") { return new OperationDataResult(true, "", $"[{targetLanguageCode}] {sourceText}"); } - var (apiUrl, form) = BuildTranslateRequest(sourceText, sourceLanguageCode, targetLanguageCode, apiKey); + var (apiUrl, form) = BuildTranslateRequest(sourceText, sourceLanguageCode, targetLanguageCode, _apiKey); - using HttpClient client = new HttpClient(); - HttpResponseMessage response = await client.PostAsync(apiUrl, new FormUrlEncodedContent(form)); - string responseBody = await response.Content.ReadAsStringAsync(); + // The interface promises an unsuccessful result instead of an exception, so a + // network failure or the timeout never fails the caller's save. + try + { + using var response = await Client.PostAsync(apiUrl, new FormUrlEncodedContent(form)); + var responseBody = await response.Content.ReadAsStringAsync(); + return ToResult(response, responseBody); + } + catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException) + { + Console.WriteLine($"Translate error: {e.Message}"); + return new OperationDataResult(false, $"Translate failed: {e.Message}"); + } + } + private static OperationDataResult ToResult(HttpResponseMessage response, string responseBody) + { if (response.IsSuccessStatusCode) { var responseObject = JsonSerializer.Deserialize(responseBody); @@ -68,4 +102,4 @@ public static (string url, Dictionary form) BuildTranslateReques }; return (url, form); } -} \ No newline at end of file +} diff --git a/eFormAPI/eFormAPI.Web/Startup.cs b/eFormAPI/eFormAPI.Web/Startup.cs index 4cc6dbac65..095e9a6593 100644 --- a/eFormAPI/eFormAPI.Web/Startup.cs +++ b/eFormAPI/eFormAPI.Web/Startup.cs @@ -60,6 +60,7 @@ namespace eFormAPI.Web; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Localization; using Microting.eFormApi.BasePn.Abstractions; +using Microting.eFormApi.BasePn.Abstractions.Translation; using Microting.eFormApi.BasePn.Infrastructure.Database.Entities; using Microting.eFormApi.BasePn.Infrastructure.Models.Application; using Microting.eFormApi.BasePn.Localization; @@ -448,7 +449,7 @@ private static void ConnectServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddSingleton(); services.AddScoped(); } diff --git a/eFormAPI/eFormAPI.Web/eFormAPI.Web.csproj b/eFormAPI/eFormAPI.Web/eFormAPI.Web.csproj index 5b3b636a02..8d6f5d8e60 100644 --- a/eFormAPI/eFormAPI.Web/eFormAPI.Web.csproj +++ b/eFormAPI/eFormAPI.Web/eFormAPI.Web.csproj @@ -57,7 +57,7 @@ - +