diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..aa951a9
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,30 @@
+# Default: Git decides per-file based on heuristics, but normalises text on commit.
+* text=auto
+
+# Pin LF across the board — matches the existing state of every file in the
+# repo today, including the .sln. Prevents CRLF drift when contributors edit
+# from Windows without an EOL-aware editor.
+*.cs text eol=lf
+*.csproj text eol=lf
+*.sln text eol=lf
+*.json text eol=lf
+*.md text eol=lf
+*.yml text eol=lf
+*.yaml text eol=lf
+*.xml text eol=lf
+*.props text eol=lf
+*.targets text eol=lf
+*.editorconfig text eol=lf
+*.gitattributes text eol=lf
+
+# Binary — never touch.
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.gif binary
+*.ico binary
+*.snk binary
+*.nupkg binary
+*.dll binary
+*.exe binary
+*.pdb binary
diff --git a/Calinga.NET.Tests/Calinga.NET.Tests.csproj b/Calinga.NET.Tests/Calinga.NET.Tests.csproj
index b32ace7..c857592 100644
--- a/Calinga.NET.Tests/Calinga.NET.Tests.csproj
+++ b/Calinga.NET.Tests/Calinga.NET.Tests.csproj
@@ -18,9 +18,9 @@
-
-
-
+
+
+
@@ -29,7 +29,7 @@
-
+
diff --git a/Calinga.NET.Tests/CalingaServiceTests.cs b/Calinga.NET.Tests/CalingaServiceTests.cs
index 0b1a49d..a3e84b0 100644
--- a/Calinga.NET.Tests/CalingaServiceTests.cs
+++ b/Calinga.NET.Tests/CalingaServiceTests.cs
@@ -291,7 +291,8 @@ public async Task GetReferenceLanguage_ShouldThrow_WhenUseCacheOnlyIsTrueAndNoRe
Func getReferenceLanguage = async () => await service.GetReferenceLanguage();
// Assert
- await getReferenceLanguage.Should().ThrowAsync();
+ var assertion = await getReferenceLanguage.Should().ThrowAsync();
+ assertion.WithInnerException();
_consumerHttpClient.Verify(x => x.GetLanguagesAsync(), Times.Never);
}
@@ -418,16 +419,20 @@ public async Task GetTranslationsAsync_ShouldNotFetchFromHttpClient_WhenUseCache
[TestMethod]
public async Task GetReferenceLanguage_ShouldThrow_WhenNoReferenceLanguageFound()
{
- // Arrange
+ // Arrange — non-empty language list with no reference flag. FetchLanguagesAsync succeeds,
+ // so there is no inner LanguagesNotAvailableException — only the outer translations failure.
var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, _testCalingaServiceSettings);
_cachingService.Setup(x => x.GetLanguages()).ReturnsAsync(new CachedLanguageListResponse(new List(), false));
- _consumerHttpClient.Setup(x => x.GetLanguagesAsync()).ReturnsAsync(new List());
+ _consumerHttpClient.Setup(x => x.GetLanguagesAsync()).ReturnsAsync(new List
+ {
+ new Language { Name = TestData.Language_DE, IsReference = false }
+ });
// Act
Func getReferenceLanguage = async () => await service.GetReferenceLanguage();
// Assert
- await getReferenceLanguage.Should().ThrowAsync();
+ await getReferenceLanguage.Should().ThrowAsync();
}
[TestMethod]
@@ -447,6 +452,28 @@ public async Task GetTranslationsAsync_ShouldThrow_WhenTranslationsNotAvailableA
await getTranslations.Should().ThrowAsync();
}
+ [TestMethod]
+ public async Task GetTranslationsAsync_ShouldThrowTranslationsNotAvailable_WhenLanguageListUnavailableDuringFallback()
+ {
+ // Arrange — UseCacheOnly with empty caches forces FetchLanguagesAsync to throw
+ // LanguagesNotAvailableException. Callers of GetTranslationsAsync expect a
+ // TranslationsNotAvailableException, with the language failure as the inner cause.
+ var settings = CreateSettings();
+ settings.UseCacheOnly = true;
+ settings.FallbackToReferenceLanguage = true;
+ var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, settings, _logger.Object);
+ _cachingService.Setup(x => x.GetTranslations(TestData.Language_DE, settings.IncludeDrafts))
+ .ReturnsAsync(new CacheResponse(TestData.EmptyTranslations, false));
+ _cachingService.Setup(x => x.GetLanguages()).ReturnsAsync(CachedLanguageListResponse.Empty);
+
+ // Act
+ Func getTranslations = async () => await service.GetTranslationsAsync(TestData.Language_DE);
+
+ // Assert
+ var assertion = await getTranslations.Should().ThrowAsync();
+ assertion.WithInnerException();
+ }
+
[TestMethod]
public async Task GetTranslationsAsync_ShouldThrow_WhenFallbackToReferenceLanguageIsFalseOrReferenceLanguageIsSame()
{
@@ -523,5 +550,283 @@ await act.Should().ThrowAsync()
_consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny()), Times.Never);
_cachingService.Verify(x => x.GetTranslations(It.IsAny(), It.IsAny()), Times.Never);
}
+
+ #region Keyed GetTranslationsAsync
+
+ [TestMethod]
+ public async Task GetTranslationsAsync_WithKeyList_WarmCache_StillPostsToServer()
+ {
+ // Arrange — warm cache must not rescue the call; keyed requests always go to the server.
+ var serverSubset = new Dictionary { { TestData.Key_1, "server value for key 1" } };
+ _consumerHttpClient
+ .Setup(x => x.GetTranslationsAsync(TestData.Language_DE, It.IsAny>()))
+ .ReturnsAsync(serverSubset);
+ var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, _testCalingaServiceSettings, _logger.Object);
+
+ // Act
+ var result = await service.GetTranslationsAsync(TestData.Language_DE, new[] { TestData.Key_1 });
+
+ // Assert
+ result.Should().BeEquivalentTo(serverSubset);
+ _consumerHttpClient.Verify(x => x.GetTranslationsAsync(TestData.Language_DE, It.IsAny>()), Times.Once);
+ _cachingService.Verify(x => x.GetTranslations(It.IsAny(), It.IsAny()), Times.Never);
+ _cachingService.Verify(x => x.StoreTranslationsAsync(It.IsAny(), It.IsAny>()), Times.Never);
+ }
+
+ [TestMethod]
+ public async Task GetTranslationsAsync_WithKeyList_MissingFromServer_Omitted()
+ {
+ // Arrange — server omits Key_2 from its response; client surfaces that as a missing entry.
+ var serverSubset = new Dictionary
+ {
+ { TestData.Key_1, "from server 1" }
+ // Key_2 intentionally omitted.
+ };
+ _consumerHttpClient
+ .Setup(x => x.GetTranslationsAsync(TestData.Language_DE, It.IsAny>()))
+ .ReturnsAsync(serverSubset);
+ var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, _testCalingaServiceSettings, _logger.Object);
+
+ // Act
+ var result = await service.GetTranslationsAsync(TestData.Language_DE, new[] { TestData.Key_1, TestData.Key_2 });
+
+ // Assert
+ result.Should().HaveCount(1);
+ result.Should().ContainKey(TestData.Key_1);
+ result.Should().NotContainKey(TestData.Key_2);
+ }
+
+ [TestMethod]
+ public async Task GetTranslationsAsync_WithKeyList_ColdCache_CallsKeyedHttp_NotStored()
+ {
+ // Arrange
+ var serverSubset = new Dictionary { { TestData.Key_1, "server value for key 1" } };
+ _consumerHttpClient
+ .Setup(x => x.GetTranslationsAsync(TestData.Language_DE, It.IsAny>()))
+ .ReturnsAsync(serverSubset);
+ var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, _testCalingaServiceSettings, _logger.Object);
+
+ // Act
+ var result = await service.GetTranslationsAsync(TestData.Language_DE, new[] { TestData.Key_1 });
+
+ // Assert
+ result.Should().BeEquivalentTo(serverSubset);
+ _consumerHttpClient.Verify(x => x.GetTranslationsAsync(TestData.Language_DE, It.IsAny>()), Times.Once);
+ _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny()), Times.Never);
+ _cachingService.Verify(x => x.GetTranslations(It.IsAny(), It.IsAny()), Times.Never);
+ _cachingService.Verify(x => x.StoreTranslationsAsync(It.IsAny(), It.IsAny>()), Times.Never);
+ }
+
+ [TestMethod]
+ public async Task GetTranslationsAsync_WithKeyList_ColdCache_ReturnsServerSubset()
+ {
+ // Arrange
+ var serverSubset = new Dictionary
+ {
+ { TestData.Key_1, "from server 1" }
+ };
+ _consumerHttpClient
+ .Setup(x => x.GetTranslationsAsync(TestData.Language_DE, It.IsAny>()))
+ .ReturnsAsync(serverSubset);
+ var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, _testCalingaServiceSettings, _logger.Object);
+
+ // Act
+ var result = await service.GetTranslationsAsync(TestData.Language_DE, new[] { TestData.Key_1, TestData.Key_2 });
+
+ // Assert
+ result.Should().HaveCount(1);
+ result.Should().ContainKey(TestData.Key_1);
+ result.Should().NotContainKey(TestData.Key_2);
+ }
+
+ [TestMethod]
+ public async Task GetTranslationsAsync_WithKeyList_UseCacheOnly_ColdCache_ThrowsInvalidOperation()
+ {
+ // Arrange
+ var settings = CreateSettings();
+ settings.UseCacheOnly = true;
+ var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, settings, _logger.Object);
+
+ // Act
+ Func act = async () => await service.GetTranslationsAsync(TestData.Language_DE, new[] { TestData.Key_1 });
+
+ // Assert
+ await act.Should().ThrowAsync();
+ _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny(), It.IsAny>()), Times.Never);
+ _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny()), Times.Never);
+ _consumerHttpClient.Verify(x => x.GetLanguagesAsync(), Times.Never);
+ _cachingService.Verify(x => x.GetTranslations(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [TestMethod]
+ public async Task GetTranslationsAsync_WithKeyList_UseCacheOnly_WarmCache_ThrowsInvalidOperation()
+ {
+ // Arrange — populate the cache, then call the keyed overload under UseCacheOnly.
+ // The UseCacheOnly check must reject the call before any cache lookup happens,
+ // even if the cache holds the requested key. A future change that consulted the
+ // cache before checking UseCacheOnly would silently make this call succeed —
+ // the warm cache is what makes that regression observable.
+ var settings = CreateSettings();
+ settings.UseCacheOnly = true;
+ _cachingService
+ .Setup(x => x.GetTranslations(TestData.Language_DE, It.IsAny()))
+ .ReturnsAsync(TestData.Cache_Translations_De);
+ var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, settings, _logger.Object);
+
+ // Act
+ Func act = async () => await service.GetTranslationsAsync(TestData.Language_DE, new[] { TestData.Key_1 });
+
+ // Assert
+ await act.Should().ThrowAsync();
+ _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny(), It.IsAny>()), Times.Never);
+ _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny()), Times.Never);
+ _cachingService.Verify(x => x.GetTranslations(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [TestMethod]
+ public async Task GetTranslationsAsync_WithKeyList_NullKeys_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, _testCalingaServiceSettings, _logger.Object);
+
+ // Act
+ Func act = async () => await service.GetTranslationsAsync(TestData.Language_DE, (IEnumerable)null!);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ [TestMethod]
+ public async Task GetTranslationsAsync_WithKeyList_EmptyKeys_ReturnsEmpty_NoHttp_NoCacheAccess()
+ {
+ // Arrange
+ var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, _testCalingaServiceSettings, _logger.Object);
+
+ // Act
+ var result = await service.GetTranslationsAsync(TestData.Language_DE, Array.Empty());
+
+ // Assert
+ result.Should().BeEmpty();
+ _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny(), It.IsAny>()), Times.Never);
+ _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny()), Times.Never);
+ _cachingService.Verify(x => x.GetTranslations(It.IsAny(), It.IsAny()), Times.Never);
+ _cachingService.Verify(x => x.StoreTranslationsAsync(It.IsAny(), It.IsAny>()), Times.Never);
+ }
+
+ [TestMethod]
+ public async Task GetTranslationsAsync_WithKeyList_EmptyKeys_UseCacheOnly_ThrowsInvalidOperation()
+ {
+ // Arrange — UseCacheOnly is incompatible with the keyed overload regardless of whether the key
+ // collection is empty. The UseCacheOnly check runs before the empty-keys short-circuit.
+ var settings = CreateSettings();
+ settings.UseCacheOnly = true;
+ var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, settings, _logger.Object);
+
+ // Act
+ Func act = async () => await service.GetTranslationsAsync(TestData.Language_DE, Array.Empty());
+
+ // Assert
+ await act.Should().ThrowAsync();
+ _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny(), It.IsAny>()), Times.Never);
+ _consumerHttpClient.Verify(x => x.GetTranslationsAsync(It.IsAny()), Times.Never);
+ _cachingService.Verify(x => x.GetTranslations(It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [TestMethod]
+ public async Task GetTranslationsAsync_WithKeyList_IsDevMode_EchoesKeys()
+ {
+ // Arrange — DevMode echoes the keys returned by the server as their own values.
+ var settings = CreateSettings(isDevMode: true);
+ var serverSubset = new Dictionary { { TestData.Key_1, "some translation" } };
+ _consumerHttpClient
+ .Setup(x => x.GetTranslationsAsync(TestData.Language_DE, It.IsAny>()))
+ .ReturnsAsync(serverSubset);
+ var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, settings, _logger.Object);
+
+ // Act
+ var result = await service.GetTranslationsAsync(TestData.Language_DE, new[] { TestData.Key_1 });
+
+ // Assert
+ result.Should().ContainKey(TestData.Key_1).WhoseValue.Should().Be(TestData.Key_1);
+ }
+
+ [TestMethod]
+ public async Task GetTranslationsAsync_WithKeyList_IsDevMode_AllKeysPresent_EchoesAll()
+ {
+ // Arrange — every requested key is present in the server response.
+ // DevMode echoes each key as its own value; no exception.
+ var settings = CreateSettings(isDevMode: true);
+ var serverSubset = new Dictionary
+ {
+ { TestData.Key_1, "translation 1" },
+ { TestData.Key_2, "translation 2" }
+ };
+ _consumerHttpClient
+ .Setup(x => x.GetTranslationsAsync(TestData.Language_DE, It.IsAny>()))
+ .ReturnsAsync(serverSubset);
+ var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, settings, _logger.Object);
+
+ // Act
+ var result = await service.GetTranslationsAsync(TestData.Language_DE, new[] { TestData.Key_1, TestData.Key_2 });
+
+ // Assert
+ result.Should().HaveCount(2);
+ result.Should().ContainKey(TestData.Key_1).WhoseValue.Should().Be(TestData.Key_1);
+ result.Should().ContainKey(TestData.Key_2).WhoseValue.Should().Be(TestData.Key_2);
+ }
+
+ [TestMethod]
+ public async Task GetTranslationsAsync_WithKeyList_IsDevMode_ServerOmitsKey_ThrowsKeysNotFound()
+ {
+ // Arrange — caller asks for two keys; server returns only one.
+ // DevMode must throw KeysNotFoundException listing the missing key(s)
+ // so typos and unknown keys surface at integration time rather than as
+ // silent omissions at runtime.
+ var settings = CreateSettings(isDevMode: true);
+ var serverSubset = new Dictionary
+ {
+ { TestData.Key_1, "some translation" }
+ // Key_2 intentionally omitted by the server.
+ };
+ _consumerHttpClient
+ .Setup(x => x.GetTranslationsAsync(TestData.Language_DE, It.IsAny>()))
+ .ReturnsAsync(serverSubset);
+ var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, settings, _logger.Object);
+
+ // Act
+ Func act = async () => await service.GetTranslationsAsync(TestData.Language_DE, new[] { TestData.Key_1, TestData.Key_2 });
+
+ // Assert
+ var assertion = await act.Should().ThrowAsync();
+ assertion.Which.MissingKeys.Should().ContainSingle().Which.Should().Be(TestData.Key_2);
+ assertion.Which.Message.Should().Contain(TestData.Key_2);
+ }
+
+ [TestMethod]
+ public async Task GetTranslationsAsync_WithKeyList_NotDevMode_ServerOmitsKey_StillSilentlyOmits()
+ {
+ // Arrange — outside DevMode, the existing "silently omit" contract stays.
+ // The validation behaviour is DevMode-only.
+ var settings = CreateSettings(isDevMode: false);
+ var serverSubset = new Dictionary
+ {
+ { TestData.Key_1, "some translation" }
+ // Key_2 intentionally omitted by the server.
+ };
+ _consumerHttpClient
+ .Setup(x => x.GetTranslationsAsync(TestData.Language_DE, It.IsAny>()))
+ .ReturnsAsync(serverSubset);
+ var service = new CalingaService(_cachingService.Object, _consumerHttpClient.Object, settings, _logger.Object);
+
+ // Act
+ var result = await service.GetTranslationsAsync(TestData.Language_DE, new[] { TestData.Key_1, TestData.Key_2 });
+
+ // Assert
+ result.Should().HaveCount(1);
+ result.Should().ContainKey(TestData.Key_1);
+ result.Should().NotContainKey(TestData.Key_2);
+ }
+
+ #endregion Keyed GetTranslationsAsync
}
}
diff --git a/Calinga.NET.Tests/Context/TestContext.cs b/Calinga.NET.Tests/Context/TestContext.cs
index 7e86532..00827c2 100644
--- a/Calinga.NET.Tests/Context/TestContext.cs
+++ b/Calinga.NET.Tests/Context/TestContext.cs
@@ -9,7 +9,7 @@
using Moq;
using Moq.Protected;
-using Newtonsoft.Json;
+using System.Text.Json;
using Calinga.NET.Caching;
using Calinga.NET.Infrastructure;
@@ -120,7 +120,7 @@ private HttpClient BuildHttpClientMock()
return new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
- Content = new StringContent(JsonConvert.SerializeObject(translations))
+ Content = new StringContent(JsonSerializer.Serialize(translations))
};
}
catch (Exception)
diff --git a/Calinga.NET.Tests/FileCachingServiceTests.cs b/Calinga.NET.Tests/FileCachingServiceTests.cs
index 3acfc23..b692f44 100644
--- a/Calinga.NET.Tests/FileCachingServiceTests.cs
+++ b/Calinga.NET.Tests/FileCachingServiceTests.cs
@@ -10,7 +10,7 @@
using Calinga.NET.Infrastructure.Exceptions;
using FluentAssertions;
using Moq;
-using Newtonsoft.Json;
+using System.Text.Json;
namespace Calinga.NET.Tests
{
@@ -48,7 +48,7 @@ public async Task StoreTranslationsAsync_CreatesFileWithValidJson()
var tempFilePath = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.json.temp");
_fileSystem.Setup(fs => fs.CreateDirectory(It.IsAny()));
_fileSystem.Setup(fs => fs.WriteAllTextAsync(tempFilePath, It.IsAny())).Returns(Task.CompletedTask);
- _fileSystem.Setup(fs => fs.ReadAllTextAsync(tempFilePath)).ReturnsAsync(JsonConvert.SerializeObject(translations));
+ _fileSystem.Setup(fs => fs.ReadAllTextAsync(tempFilePath)).ReturnsAsync(JsonSerializer.Serialize(translations));
_fileSystem.Setup(fs => fs.FileExists(path)).Returns(false);
_fileSystem.Setup(fs => fs.ReplaceFile(tempFilePath, path, null));
@@ -105,7 +105,7 @@ public async Task StoreTranslationsAsync_OverwritesExistingFile()
var prevFilePath = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.json.prev");
_fileSystem.Setup(fs => fs.CreateDirectory(It.IsAny()));
_fileSystem.Setup(fs => fs.WriteAllTextAsync(tempFilePath, It.IsAny())).Returns(Task.CompletedTask);
- _fileSystem.Setup(fs => fs.ReadAllTextAsync(tempFilePath)).ReturnsAsync(JsonConvert.SerializeObject(translations));
+ _fileSystem.Setup(fs => fs.ReadAllTextAsync(tempFilePath)).ReturnsAsync(JsonSerializer.Serialize(translations));
_fileSystem.Setup(fs => fs.FileExists(path)).Returns(true);
_fileSystem.Setup(fs => fs.ReplaceFile(tempFilePath, path, null));
@@ -128,7 +128,7 @@ public async Task StoreTranslationsAsync_HandlesEmptyTranslations()
var tempFilePath = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.json.temp");
_fileSystem.Setup(fs => fs.CreateDirectory(It.IsAny()));
_fileSystem.Setup(fs => fs.WriteAllTextAsync(tempFilePath, It.IsAny())).Returns(Task.CompletedTask);
- _fileSystem.Setup(fs => fs.ReadAllTextAsync(tempFilePath)).ReturnsAsync(JsonConvert.SerializeObject(translations));
+ _fileSystem.Setup(fs => fs.ReadAllTextAsync(tempFilePath)).ReturnsAsync(JsonSerializer.Serialize(translations));
_fileSystem.Setup(fs => fs.FileExists(path)).Returns(false);
_fileSystem.Setup(fs => fs.ReplaceFile(tempFilePath, path, null));
@@ -165,7 +165,7 @@ public async Task GetTranslations_FileExists_ReturnsValidTranslations()
var path = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.json");
var translations = new Dictionary { { "key1", "value1" } };
_fileSystem.Setup(fs => fs.FileExists(path)).Returns(true);
- _fileSystem.Setup(fs => fs.ReadAllTextAsync(path)).ReturnsAsync(JsonConvert.SerializeObject(translations));
+ _fileSystem.Setup(fs => fs.ReadAllTextAsync(path)).ReturnsAsync(JsonSerializer.Serialize(translations));
// Act
var result = await _service.GetTranslations(language, false);
@@ -210,7 +210,7 @@ public async Task GetLanguages_FileExists_ReturnsValidLanguages()
var path = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "Languages.json");
var languages = new List { new Language { Name = "en" } };
_fileSystem.Setup(fs => fs.FileExists(path)).Returns(true);
- _fileSystem.Setup(fs => fs.ReadAllTextAsync(path)).ReturnsAsync(JsonConvert.SerializeObject(languages));
+ _fileSystem.Setup(fs => fs.ReadAllTextAsync(path)).ReturnsAsync(JsonSerializer.Serialize(languages));
// Act
var result = await _service.GetLanguages();
@@ -272,7 +272,7 @@ public async Task StoreLanguagesAsync_CreatesFileWithValidJson()
"Languages.json.temp");
_fileSystem.Setup(fs => fs.CreateDirectory(It.IsAny()));
_fileSystem.Setup(fs => fs.WriteAllTextAsync(tempFilePath, It.IsAny())).Returns(Task.CompletedTask);
- _fileSystem.Setup(fs => fs.ReadAllTextAsync(tempFilePath)).ReturnsAsync(JsonConvert.SerializeObject(languages));
+ _fileSystem.Setup(fs => fs.ReadAllTextAsync(tempFilePath)).ReturnsAsync(JsonSerializer.Serialize(languages));
_fileSystem.Setup(fs => fs.FileExists(path)).Returns(false);
_fileSystem.Setup(fs => fs.ReplaceFile(tempFilePath, path, null));
@@ -363,7 +363,7 @@ public async Task GetTranslations_InvalidJson_ThrowsExceptionAndLogsWarning()
_fileSystem.Setup(fs => fs.ReadAllTextAsync(path)).ReturnsAsync("{invalid json}");
// Act & Assert
- await Assert.ThrowsExceptionAsync(() => _service.GetTranslations(language, false));
+ await Assert.ThrowsExceptionAsync(() => _service.GetTranslations(language, false));
}
[TestMethod]
@@ -375,7 +375,7 @@ public async Task GetLanguages_InvalidJson_ThrowsExceptionAndLogsWarning()
_fileSystem.Setup(fs => fs.ReadAllTextAsync(path)).ReturnsAsync("{invalid json}");
// Act & Assert
- await Assert.ThrowsExceptionAsync(() => _service.GetLanguages());
+ await Assert.ThrowsExceptionAsync(() => _service.GetLanguages());
}
[TestMethod]
@@ -432,7 +432,7 @@ public async Task StoreTranslationsAsync_LogsInfoOnSuccess()
var tempFilePath = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.json.temp");
_fileSystem.Setup(fs => fs.CreateDirectory(It.IsAny()));
_fileSystem.Setup(fs => fs.WriteAllTextAsync(tempFilePath, It.IsAny())).Returns(Task.CompletedTask);
- _fileSystem.Setup(fs => fs.ReadAllTextAsync(tempFilePath)).ReturnsAsync(JsonConvert.SerializeObject(translations));
+ _fileSystem.Setup(fs => fs.ReadAllTextAsync(tempFilePath)).ReturnsAsync(JsonSerializer.Serialize(translations));
_fileSystem.Setup(fs => fs.FileExists(path)).Returns(false);
_fileSystem.Setup(fs => fs.ReplaceFile(tempFilePath, path, null));
@@ -526,7 +526,7 @@ public async Task StoreTranslationsAsync_ReplaceFileThrowsIOException_DeletesTem
var tempFilePath = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.json.temp");
_fileSystem.Setup(fs => fs.CreateDirectory(It.IsAny()));
_fileSystem.Setup(fs => fs.WriteAllTextAsync(tempFilePath, It.IsAny())).Returns(Task.CompletedTask);
- _fileSystem.Setup(fs => fs.ReadAllTextAsync(tempFilePath)).ReturnsAsync(JsonConvert.SerializeObject(translations));
+ _fileSystem.Setup(fs => fs.ReadAllTextAsync(tempFilePath)).ReturnsAsync(JsonSerializer.Serialize(translations));
_fileSystem.Setup(fs => fs.FileExists(path)).Returns(false);
_fileSystem.Setup(fs => fs.ReplaceFile(tempFilePath, path, null)).Throws();
_fileSystem.Setup(fs => fs.FileExists(tempFilePath)).Returns(true);
@@ -548,7 +548,7 @@ public async Task StoreLanguagesAsync_ReplaceFileThrowsIOException_DeletesTempFi
var tempFilePath = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "Languages.json.temp");
_fileSystem.Setup(fs => fs.CreateDirectory(It.IsAny()));
_fileSystem.Setup(fs => fs.WriteAllTextAsync(tempFilePath, It.IsAny())).Returns(Task.CompletedTask);
- _fileSystem.Setup(fs => fs.ReadAllTextAsync(tempFilePath)).ReturnsAsync(JsonConvert.SerializeObject(languages));
+ _fileSystem.Setup(fs => fs.ReadAllTextAsync(tempFilePath)).ReturnsAsync(JsonSerializer.Serialize(languages));
_fileSystem.Setup(fs => fs.FileExists(path)).Returns(false);
_fileSystem.Setup(fs => fs.ReplaceFile(tempFilePath, path, null)).Throws();
_fileSystem.Setup(fs => fs.FileExists(tempFilePath)).Returns(true);
@@ -697,5 +697,55 @@ public async Task StoreLanguagesAsync_AndStoreTranslationsAsync_ShouldNotThrow_W
}
#endregion
+
+ #region Newtonsoft → System.Text.Json compatibility
+
+ [TestMethod]
+ public async Task GetTranslations_ReadsNewtonsoftEraFile_Successfully()
+ {
+ // Arrange — exact byte shape Newtonsoft 13 produced for Dictionary:
+ // compact, double-quoted keys/values, no whitespace, no BOM. Pinning a literal here
+ // (instead of round-tripping through System.Text.Json) is the whole point — proves
+ // existing on-disk caches written by 2.1.x are still readable after the JSON-library
+ // swap in 2.2.0.
+ const string newtonsoftEraJson = "{\"key1\":\"value1\",\"key2\":\"value2\"}";
+ var language = "EN";
+ var path = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "EN.json");
+ _fileSystem.Setup(fs => fs.FileExists(path)).Returns(true);
+ _fileSystem.Setup(fs => fs.ReadAllTextAsync(path)).ReturnsAsync(newtonsoftEraJson);
+
+ // Act
+ var result = await _service.GetTranslations(language, false);
+
+ // Assert
+ result.FoundInCache.Should().BeTrue();
+ result.Result.Should().HaveCount(2);
+ result.Result["key1"].Should().Be("value1");
+ result.Result["key2"].Should().Be("value2");
+ }
+
+ [TestMethod]
+ public async Task GetLanguages_ReadsNewtonsoftEraFile_Successfully()
+ {
+ // Arrange — Newtonsoft 13 default for List: PascalCase property names,
+ // no whitespace, double-quoted strings, JSON booleans lowercase. Same rationale as
+ // the translations test — pin a literal so any future serializer-options change
+ // (e.g. JsonNamingPolicy.CamelCase) surfaces as a failing test, not a broken cache.
+ const string newtonsoftEraJson = "[{\"Name\":\"en\",\"IsReference\":true},{\"Name\":\"de\",\"IsReference\":false}]";
+ var path = Path.Combine(_settings.CacheDirectory, _settings.Organization, _settings.Team, _settings.Project, "Languages.json");
+ _fileSystem.Setup(fs => fs.FileExists(path)).Returns(true);
+ _fileSystem.Setup(fs => fs.ReadAllTextAsync(path)).ReturnsAsync(newtonsoftEraJson);
+
+ // Act
+ var result = await _service.GetLanguages();
+
+ // Assert
+ result.FoundInCache.Should().BeTrue();
+ result.Result.Should().HaveCount(2);
+ result.Result.Should().ContainSingle(l => l.Name == "en" && l.IsReference);
+ result.Result.Should().ContainSingle(l => l.Name == "de" && !l.IsReference);
+ }
+
+ #endregion
}
}
diff --git a/Calinga.NET.Tests/Infrastructure/ConsumerHttpClientTest.cs b/Calinga.NET.Tests/Infrastructure/ConsumerHttpClientTest.cs
index e807b3b..b7d829f 100644
--- a/Calinga.NET.Tests/Infrastructure/ConsumerHttpClientTest.cs
+++ b/Calinga.NET.Tests/Infrastructure/ConsumerHttpClientTest.cs
@@ -1,11 +1,15 @@
using System;
using System.Collections.Generic;
+using System.Linq;
+using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Calinga.NET.Caching;
using Calinga.NET.Infrastructure;
+using Calinga.NET.Infrastructure.Exceptions;
using FluentAssertions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System.Text.Json;
using RichardSzalay.MockHttp;
namespace Calinga.NET.Tests.Infrastructure
@@ -29,7 +33,7 @@ public async Task GetLanguages_ShouldReturnLanguageList_WhenResponseContainsVali
mockMessageHandler
.When($"https://api.calinga.io/v3/{_settings.Organization}/{_settings.Team}/{_settings.Project}/languages*")
.Respond("application/json",
- "[ { 'name': 'en', 'tag': '', 'isReference': true }, { 'name': 'en-GB', 'tag': '', 'isReference': false }, { 'name': 'en-GB', 'tag': 'Intranet', 'isReference': false } ]");
+ @"[ { ""name"": ""en"", ""tag"": """", ""isReference"": true }, { ""name"": ""en-GB"", ""tag"": """", ""isReference"": false }, { ""name"": ""en-GB"", ""tag"": ""Intranet"", ""isReference"": false } ]");
var sut = new ConsumerHttpClient(_settings, new HttpClient(mockMessageHandler));
@@ -45,6 +49,163 @@ public async Task GetLanguages_ShouldReturnLanguageList_WhenResponseContainsVali
});
}
+ [TestMethod]
+ public async Task GetTranslationsAsync_WithKeyList_UsesPost_ToV3LanguagesUrl()
+ {
+ // Arrange
+ var expectedUrl = $"{_settings.ConsumerApiBaseUrl}/{_settings.Organization}/{_settings.Team}/{_settings.Project}/languages/de";
+ var mockMessageHandler = new MockHttpMessageHandler();
+ mockMessageHandler
+ .Expect(HttpMethod.Post, expectedUrl)
+ .Respond("application/json", "{}");
+ var sut = new ConsumerHttpClient(_settings, new HttpClient(mockMessageHandler));
+
+ // Act
+ await sut.GetTranslationsAsync("de", new[] { "k1" }).ConfigureAwait(false);
+
+ // Assert
+ mockMessageHandler.VerifyNoOutstandingExpectation();
+ }
+
+ [TestMethod]
+ public async Task GetTranslationsAsync_WithKeyList_SendsJsonBody_WithKeyNames()
+ {
+ // Arrange
+ var expectedUrl = $"{_settings.ConsumerApiBaseUrl}/{_settings.Organization}/{_settings.Team}/{_settings.Project}/languages/de";
+ var mockMessageHandler = new MockHttpMessageHandler();
+ mockMessageHandler
+ .Expect(HttpMethod.Post, expectedUrl)
+ .With(request =>
+ {
+ if (request.Content == null) return false;
+ if (request.Content.Headers.ContentType?.MediaType != "application/json") return false;
+ var body = request.Content.ReadAsStringAsync().GetAwaiter().GetResult();
+ using var parsed = JsonDocument.Parse(body);
+ if (!parsed.RootElement.TryGetProperty("keyNames", out var keyNamesElement)) return false;
+ var keyNames = keyNamesElement.EnumerateArray().Select(e => e.GetString()).ToList();
+ return keyNames.SequenceEqual(new[] { "k1", "k2" });
+ })
+ .Respond("application/json", "{}");
+ var sut = new ConsumerHttpClient(_settings, new HttpClient(mockMessageHandler));
+
+ // Act
+ await sut.GetTranslationsAsync("de", new[] { "k1", "k2" }).ConfigureAwait(false);
+
+ // Assert
+ mockMessageHandler.VerifyNoOutstandingExpectation();
+ }
+
+ [TestMethod]
+ public async Task GetTranslationsAsync_WithKeyList_IncludeDrafts_AddsQueryString()
+ {
+ // Arrange
+ var settings = CreateSettings();
+ settings.IncludeDrafts = true;
+ var expectedUrl =
+ $"{settings.ConsumerApiBaseUrl}/{settings.Organization}/{settings.Team}/{settings.Project}/languages/de?includeDrafts=True";
+ var mockMessageHandler = new MockHttpMessageHandler();
+ mockMessageHandler
+ .Expect(HttpMethod.Post, expectedUrl)
+ .Respond("application/json", "{}");
+ var sut = new ConsumerHttpClient(settings, new HttpClient(mockMessageHandler));
+
+ // Act
+ await sut.GetTranslationsAsync("de", new[] { "k1" }).ConfigureAwait(false);
+
+ // Assert
+ mockMessageHandler.VerifyNoOutstandingExpectation();
+ }
+
+ [TestMethod]
+ public async Task GetTranslationsAsync_WithKeyList_On404_ThrowsTranslationsNotFound()
+ {
+ // Arrange
+ var mockMessageHandler = new MockHttpMessageHandler();
+ mockMessageHandler
+ .When(HttpMethod.Post, "*")
+ .Respond(HttpStatusCode.NotFound);
+ var sut = new ConsumerHttpClient(_settings, new HttpClient(mockMessageHandler));
+
+ // Act
+ Func act = async () => await sut.GetTranslationsAsync("de", new[] { "k1" }).ConfigureAwait(false);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ [TestMethod]
+ public async Task GetTranslationsAsync_WithKeyList_On401_ThrowsAuthorizationFailed()
+ {
+ // Arrange
+ var mockMessageHandler = new MockHttpMessageHandler();
+ mockMessageHandler
+ .When(HttpMethod.Post, "*")
+ .Respond(HttpStatusCode.Unauthorized);
+ var sut = new ConsumerHttpClient(_settings, new HttpClient(mockMessageHandler));
+
+ // Act
+ Func act = async () => await sut.GetTranslationsAsync("de", new[] { "k1" }).ConfigureAwait(false);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ [TestMethod]
+ public async Task GetTranslationsAsync_WithKeyList_On500_ThrowsTranslationsNotAvailable()
+ {
+ // Arrange
+ var mockMessageHandler = new MockHttpMessageHandler();
+ mockMessageHandler
+ .When(HttpMethod.Post, "*")
+ .Respond(HttpStatusCode.InternalServerError);
+ var sut = new ConsumerHttpClient(_settings, new HttpClient(mockMessageHandler));
+
+ // Act
+ Func act = async () => await sut.GetTranslationsAsync("de", new[] { "k1" }).ConfigureAwait(false);
+
+ // Assert
+ await act.Should().ThrowAsync();
+ }
+
+ [TestMethod]
+ public async Task GetTranslationsAsync_WithKeyList_OnNullJsonBody_ReturnsEmptyDictionary()
+ {
+ // Arrange — the API responds 200 OK with the literal JSON value "null".
+ // System.Text.Json deserialises that to a CLR null; the client must surface
+ // an empty dictionary instead of letting null propagate to callers.
+ var mockMessageHandler = new MockHttpMessageHandler();
+ mockMessageHandler
+ .When(HttpMethod.Post, "*")
+ .Respond("application/json", "null");
+ var sut = new ConsumerHttpClient(_settings, new HttpClient(mockMessageHandler));
+
+ // Act
+ var result = await sut.GetTranslationsAsync("de", new[] { "k1" }).ConfigureAwait(false);
+
+ // Assert
+ result.Should().NotBeNull();
+ result.Should().BeEmpty();
+ }
+
+ [TestMethod]
+ public async Task GetTranslationsAsync_OnNullJsonBody_ReturnsEmptyDictionary()
+ {
+ // Arrange — same null-body scenario for the existing GET overload, since both
+ // paths share the CreateTranslationsDictionary helper.
+ var mockMessageHandler = new MockHttpMessageHandler();
+ mockMessageHandler
+ .When(HttpMethod.Get, "*")
+ .Respond("application/json", "null");
+ var sut = new ConsumerHttpClient(_settings, new HttpClient(mockMessageHandler));
+
+ // Act
+ var result = await sut.GetTranslationsAsync("de").ConfigureAwait(false);
+
+ // Assert
+ result.Should().NotBeNull();
+ result.Should().BeEmpty();
+ }
+
private static CalingaServiceSettings CreateSettings(bool isDevMode = false)
{
return new CalingaServiceSettings
diff --git a/Calinga.NET.Tests/TestData.cs b/Calinga.NET.Tests/TestData.cs
index 9c45644..5940778 100644
--- a/Calinga.NET.Tests/TestData.cs
+++ b/Calinga.NET.Tests/TestData.cs
@@ -13,7 +13,7 @@ internal static class TestData
internal const string Key_1 = "UnitTest_Key1";
internal const string Key_2 = "UnitTest_Key2";
internal const string Translation_Key_1 = "translation for key 1";
- internal const string Translation_Key_2 = "translation for key 1";
+ internal const string Translation_Key_2 = "translation for key 2";
internal static CacheResponse Cache_Translations_De = new CacheResponse(Translations_De, true);
internal static CacheResponse Cache_Translations_En = new CacheResponse(Translations_En, true);
diff --git a/Calinga.NET/Caching/FileCachingService.cs b/Calinga.NET/Caching/FileCachingService.cs
index f8adedd..e2d5679 100644
--- a/Calinga.NET/Caching/FileCachingService.cs
+++ b/Calinga.NET/Caching/FileCachingService.cs
@@ -6,7 +6,7 @@
using System.Threading.Tasks;
using Calinga.NET.Infrastructure;
using Calinga.NET.Infrastructure.Exceptions;
-using Newtonsoft.Json;
+using System.Text.Json;
using static System.FormattableString;
namespace Calinga.NET.Caching
@@ -41,7 +41,7 @@ public async Task GetTranslations(string languageName, bool inclu
var fileContent = await _fileSystem.ReadAllTextAsync(path).ConfigureAwait(false);
var dict = string.IsNullOrWhiteSpace(fileContent)
? new Dictionary()
- : JsonConvert.DeserializeObject>(fileContent) ?? new Dictionary();
+ : JsonSerializer.Deserialize>(fileContent) ?? new Dictionary();
return new CacheResponse(dict, true);
}
@@ -66,7 +66,7 @@ public async Task GetLanguages()
var fileContent = await _fileSystem.ReadAllTextAsync(path).ConfigureAwait(false);
var list = string.IsNullOrWhiteSpace(fileContent)
? new List()
- : JsonConvert.DeserializeObject>(fileContent) ?? new List();
+ : JsonSerializer.Deserialize>(fileContent) ?? new List();
return new CachedLanguageListResponse(list, true);
}
@@ -123,9 +123,9 @@ public async Task StoreTranslationsAsync(string language, IReadOnlyDictionary>(tempFileContent);
+ JsonSerializer.Deserialize>(tempFileContent);
if (_fileSystem.FileExists(path))
{
@@ -172,9 +172,9 @@ public async Task StoreLanguagesAsync(IEnumerable languageList)
_fileSystem.CreateDirectory(_filePath);
try
{
- await _fileSystem.WriteAllTextAsync(tempFilePath, JsonConvert.SerializeObject(languageList)).ConfigureAwait(false);
+ await _fileSystem.WriteAllTextAsync(tempFilePath, JsonSerializer.Serialize(languageList)).ConfigureAwait(false);
var tempFileContent = await _fileSystem.ReadAllTextAsync(tempFilePath).ConfigureAwait(false);
- JsonConvert.DeserializeObject>(tempFileContent);
+ JsonSerializer.Deserialize>(tempFileContent);
if (_fileSystem.FileExists(path))
{
diff --git a/Calinga.NET/Calinga.NET.csproj b/Calinga.NET/Calinga.NET.csproj
index a3e238c..a149aaf 100644
--- a/Calinga.NET/Calinga.NET.csproj
+++ b/Calinga.NET/Calinga.NET.csproj
@@ -5,30 +5,50 @@
8.0
enable
Calinga.NET
+ conplement AG
Library to integrate Calinga in .NET projects
- 2.1.4
+ calinga;localization;translations;i18n
+ README.md
+ https://github.com/conplementAG/Calinga.NET
+ https://github.com/conplementAG/Calinga.NET.git
+ git
+ 2.2.0
-## Bug Fixes
-- Fixed cache backfill: In-memory cache is now repopulated when data is retrieved from file cache after expiration
-- Fixed thread safety in InMemoryCachingService: Now uses ConcurrentDictionary and proper locking for multi-threaded environments
-- Fixed missing locking in FileCachingService.StoreLanguagesAsync: Concurrent writes no longer corrupt the languages cache file
-- Fixed file sharing: ReadAllTextAsync now allows concurrent reads (FileShare.Read)
-- Fixed ClearCache: Now properly clears both translations and languages from in-memory cache
-
-## Improvements
-- Resolved all nullable reference type warnings (CS8618)
-- Added comprehensive thread-safety tests for caching services
+## New Features
+- Added `GetTranslationsAsync(string language, IEnumerable<string> keys)` to fetch a subset of translations for a given language without downloading the full dictionary.
+- Every call requesting a specific list of keys issues a POST to the Consumer API (`POST {ConsumerApiBaseUrl}/{org}/{team}/{project}/languages/{language}`) with a JSON body `{ "keyNames": [...] }`. The cache is never consulted and never written for keyed calls, so the result is always server-fresh.
+- Keys absent from the server response are silently omitted from the result in normal mode. In DevMode (`IsDevMode = true`), the keyed overload validates the server response and throws `KeysNotFoundException` if any requested key is missing, with the missing keys listed in both the message and the `MissingKeys` property.
+- `UseCacheOnly = true` is incompatible with the keyed overload and throws `InvalidOperationException` for any key collection (including an empty one) — keyed requests always require HTTP and cannot be served from the cache.
+- When `UseCacheOnly` is false, passing an empty key collection returns an empty dictionary immediately (no HTTP call, no cache access).
+- When in "DevMode" and requesting a list of keys, a KeysNotFoundException gets thrown when keys were missing from the server response
+
+## API contract decisions
+- Filtering for a list of keys while having useCacheOnly enabled throws an InvalidOperationException
+
+## Documentation
+- Package now ships its README on the NuGet listing and links to the GitHub source repository
+
+ ## Further Changes
+ - The dependency on Newtonsoft was removed and replaced by System.Text.Json
+ - `GetReferenceLanguage()` now throws `TranslationsNotAvailableException` (previously `LanguagesNotAvailableException`). The reference language exists to drive translation fallback, so a failure to determine it is reported as a translations failure. The original `LanguagesNotAvailableException` is preserved as the inner exception when the language list itself was unavailable.
+ - `GetTranslationsAsync(string language)` no longer leaks `LanguagesNotAvailableException` out of the reference-language fallback path — callers consistently see `TranslationsNotAvailableException`.
+ - Translations responses with a `null` JSON body no longer crash with `NullReferenceException`; the call now returns an empty dictionary.
+ - `Guard.IsNotNullOrWhiteSpace` (used to validate `language`/`key` arguments on every public API entry point) now correctly rejects whitespace-only strings — including tabs, newlines, and other Unicode whitespace — and is null-safe. Callers passing such values previously slipped past validation or hit a `NullReferenceException`; they now see `ArgumentNullException` immediately.
-
+
+
+
+
+
diff --git a/Calinga.NET/CalingaService.cs b/Calinga.NET/CalingaService.cs
index 785f0ac..759aa28 100644
--- a/Calinga.NET/CalingaService.cs
+++ b/Calinga.NET/CalingaService.cs
@@ -166,6 +166,17 @@ public async Task TranslateAsync(string key, string language)
/// The language code.
/// If true, bypasses the cache and fetches from the API. Do not use in combination with "UseCacheOnly"
/// A dictionary of translation keys and values.
+ ///
+ /// Thrown when is true while
+ /// is true.
+ ///
+ ///
+ /// Thrown when translations cannot be retrieved from cache or API and either
+ /// is false or the reference-language
+ /// fallback could not produce translations either. When the failure originates from the language list
+ /// being unavailable during fallback, the underlying is
+ /// preserved as the inner exception.
+ ///
public async Task> GetTranslationsAsync(string language, bool invalidateCache)
{
Guard.IsNotNullOrWhiteSpace(language);
@@ -184,15 +195,21 @@ public async Task> GetTranslationsAsync(stri
translations = await TryGetFromApi(language).ConfigureAwait(false);
if (translations != null)
return translations;
-
+
+ if (!_settings.FallbackToReferenceLanguage)
+ {
+ throw new TranslationsNotAvailableException(
+ $"Translation not found, path: {_settings.Organization}, {_settings.Team}, {_settings.Project}, {language}");
+ }
+
var referenceLanguage = await GetReferenceLanguage().ConfigureAwait(false);
-
- if (!_settings.FallbackToReferenceLanguage || referenceLanguage == language)
+
+ if (referenceLanguage == language)
{
throw new TranslationsNotAvailableException(
$"Translation not found, path: {_settings.Organization}, {_settings.Team}, {_settings.Project}, {language}");
}
-
+
_logger.Warn("Translations not found, trying to fetch reference language");
language = referenceLanguage;
}
@@ -203,11 +220,80 @@ public async Task> GetTranslationsAsync(stri
///
/// The language code.
/// A dictionary of translation keys and values.
+ ///
+ /// Thrown when translations cannot be retrieved from cache or API and either
+ /// is false or the reference-language
+ /// fallback could not produce translations either. When the failure originates from the language list
+ /// being unavailable during fallback, the underlying is
+ /// preserved as the inner exception.
+ ///
public async Task> GetTranslationsAsync(string language)
{
return await GetTranslationsAsync(language, false);
}
-
+
+ ///
+ /// Gets the subset of translations for the specified keys by issuing a POST to the Consumer API.
+ /// The cache is never consulted and never written — every call returns server-fresh data.
+ ///
+ /// The current state of never using the cache and always using server-fresh data is currently in testing and
+ /// can be subject to change.
+ ///
+ /// In normal mode, keys absent from the server response are silently omitted.
+ /// In , the server response is validated:
+ /// if any requested key is missing, a is thrown so
+ /// developers see typos and unknown keys at integration time rather than at runtime.
+ ///
+ /// The language code.
+ /// The translation keys to fetch.
+ /// A dictionary containing only the requested keys that were found on the server.
+ /// Thrown when is null.
+ ///
+ /// Thrown when is true. Keyed calls always
+ /// require HTTP; they cannot be served from the cache, so this setting is incompatible with
+ /// the keyed overload regardless of whether the key collection is empty or not.
+ ///
+ ///
+ /// Thrown when is true and the server response
+ /// does not include every requested key. The exception's
+ /// property exposes the missing keys for diagnostic purposes.
+ ///
+ public async Task> GetTranslationsAsync(string language, IEnumerable keys)
+ {
+ Guard.IsNotNullOrWhiteSpace(language);
+ if (keys == null) throw new ArgumentNullException(nameof(keys));
+
+ if (_settings.UseCacheOnly)
+ {
+ throw new InvalidOperationException(
+ $"Keyed translations cannot be fetched while {nameof(CalingaServiceSettings.UseCacheOnly)} is true; the keyed overload always requires HTTP. Path: {_settings.Organization}, {_settings.Team}, {_settings.Project}, {language}");
+ }
+
+ var keySet = new HashSet(keys, StringComparer.Ordinal);
+
+ if (keySet.Count == 0)
+ {
+ return new Dictionary();
+ }
+
+ _logger.Info($"Fetching filtered translations for language {language} ({keySet.Count} key(s)) from consumer API");
+ var subset = await _consumerHttpClient.GetTranslationsAsync(language, keySet).ConfigureAwait(false);
+
+ if (_settings.IsDevMode)
+ {
+ var missingKeys = keySet.Where(k => !subset.ContainsKey(k)).ToList();
+ if (missingKeys.Count > 0)
+ {
+ throw new KeysNotFoundException(
+ missingKeys,
+ $"DevMode: {missingKeys.Count} of {keySet.Count} requested key(s) not found on server. Missing: {string.Join(", ", missingKeys)}. Path: {_settings.Organization}, {_settings.Team}, {_settings.Project}, {language}");
+ }
+ return subset.ToDictionary(k => k.Key, k => k.Key);
+ }
+
+ return subset;
+ }
+
private async Task?> TryGetFromCache(string language, bool invalidateCache)
{
if (invalidateCache)
@@ -267,24 +353,39 @@ public async Task> GetLanguagesAsync()
/// Gets the reference language for the current project.
///
/// The reference language code.
+ ///
+ /// Thrown when the reference language cannot be determined — either because the language list is
+ /// unavailable (inner exception is ) or because the
+ /// list contains no language flagged as reference. Reported as a translations failure because the
+ /// reference language exists to drive translation fallback.
+ ///
public async Task GetReferenceLanguage()
{
if (!string.IsNullOrWhiteSpace(_referenceLanguage))
return _referenceLanguage!;
- var languages = (await FetchLanguagesAsync().ConfigureAwait(false))
- .ToArray();
+ Language[] languages;
+ try
+ {
+ languages = (await FetchLanguagesAsync().ConfigureAwait(false)).ToArray();
+ }
+ catch (LanguagesNotAvailableException ex)
+ {
+ throw new TranslationsNotAvailableException(
+ $"Reference language could not be determined, path: {_settings.Organization}, {_settings.Team}, {_settings.Project}", ex);
+ }
if (languages.All(l => !l.IsReference))
{
- throw new LanguagesNotAvailableException("Reference language not found");
+ throw new TranslationsNotAvailableException(
+ $"No reference language found, path: {_settings.Organization}, {_settings.Team}, {_settings.Project}");
}
_referenceLanguage = languages.Single(l => l.IsReference).Name;
return _referenceLanguage;
}
-
+
///
/// Clears the translation and language cache.
///
diff --git a/Calinga.NET/Guard.cs b/Calinga.NET/Guard.cs
index 8c30af9..5948cc6 100644
--- a/Calinga.NET/Guard.cs
+++ b/Calinga.NET/Guard.cs
@@ -6,7 +6,7 @@ public static class Guard
{
public static void IsNotNullOrWhiteSpace(string parameter)
{
- if (string.IsNullOrEmpty(parameter)) throw new ArgumentNullException($"Parameter {parameter} cannot be null or empty.");
+ if (string.IsNullOrWhiteSpace(parameter)) throw new ArgumentNullException($"Parameter cannot be null, empty, or whitespace.");
}
public static void IsNotNull(object parameter, string name)
diff --git a/Calinga.NET/ICalingaService.cs b/Calinga.NET/ICalingaService.cs
index a7b06d1..ad6ee75 100644
--- a/Calinga.NET/ICalingaService.cs
+++ b/Calinga.NET/ICalingaService.cs
@@ -3,12 +3,13 @@
namespace Calinga.NET
{
- public interface ICalingaService
+ public interface ICalingaService
{
Task TranslateAsync(string key, string language);
Task> GetTranslationsAsync(string language);
Task> GetTranslationsAsync(string language, bool invalidateCache);
+ Task> GetTranslationsAsync(string language, IEnumerable keys);
Task> GetLanguagesAsync();
diff --git a/Calinga.NET/Infrastructure/CalingaServiceSettings.cs b/Calinga.NET/Infrastructure/CalingaServiceSettings.cs
index b51e3a6..1621f61 100644
--- a/Calinga.NET/Infrastructure/CalingaServiceSettings.cs
+++ b/Calinga.NET/Infrastructure/CalingaServiceSettings.cs
@@ -12,6 +12,15 @@ public class CalingaServiceSettings
public bool IncludeDrafts { get; set; }
+ ///
+ /// When true, translation lookups still hit the server (or cache) to determine which keys exist,
+ /// but the returned values are the keys themselves rather than the translations. Use during UI
+ /// development to verify which translation key renders where without depending on translated content.
+ /// For the keyed GetTranslationsAsync overload, the server response is also validated:
+ /// if any requested key is missing on the server, a
+ /// is thrown listing the missing keys, so typos and unknown keys surface at integration time
+ /// rather than as silent omissions at runtime.
+ ///
public bool IsDevMode { get; set; }
///
diff --git a/Calinga.NET/Infrastructure/ConsumerHttpClient.cs b/Calinga.NET/Infrastructure/ConsumerHttpClient.cs
index d382243..d6148c2 100644
--- a/Calinga.NET/Infrastructure/ConsumerHttpClient.cs
+++ b/Calinga.NET/Infrastructure/ConsumerHttpClient.cs
@@ -6,7 +6,7 @@
using System.Threading.Tasks;
using Calinga.NET.Caching;
using Calinga.NET.Infrastructure.Exceptions;
-using Newtonsoft.Json;
+using System.Text.Json;
using static System.FormattableString;
namespace Calinga.NET.Infrastructure
@@ -21,7 +21,6 @@ public class ConsumerHttpClient : IConsumerHttpClient
public ConsumerHttpClient(CalingaServiceSettings settings)
: this(settings, new HttpClient())
{
- _settings = settings;
}
public ConsumerHttpClient(CalingaServiceSettings settings, HttpClient httpClient)
@@ -72,6 +71,36 @@ public async Task> GetTranslationsAsync(stri
return CreateTranslationsDictionary(body);
}
+ public async Task> GetTranslationsAsync(string language, IEnumerable keys)
+ {
+ var queryParameter = _settings.IncludeDrafts ? Invariant($"?includeDrafts={_settings.IncludeDrafts}") : string.Empty;
+ var url = Invariant(
+ $"{_settings.ConsumerApiBaseUrl}/{_settings.Organization}/{_settings.Team}/{_settings.Project}/languages/{language}{queryParameter}");
+
+ var requestBody = JsonSerializer.Serialize(new { keyNames = keys });
+ using var content = new StringContent(requestBody, System.Text.Encoding.UTF8, "application/json");
+
+ var response = await _httpClient.PostAsync(url, content).ConfigureAwait(false);
+
+ switch (response.StatusCode)
+ {
+ case HttpStatusCode.Unauthorized:
+ throw new AuthorizationFailedException();
+ case HttpStatusCode.NotFound:
+ throw new TranslationsNotFoundException(
+ $"Translations not found for Organization = '{_settings.Organization}', Team = '{_settings.Team}', Project = '{_settings.Project}', Language = '{language}'");
+ }
+
+ if (!response.IsSuccessStatusCode)
+ {
+ throw new TranslationsNotAvailableException("Failed to fetch filtered translations");
+ }
+
+ var responseBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+
+ return CreateTranslationsDictionary(responseBody);
+ }
+
public async Task> GetLanguagesAsync()
{
try
@@ -99,23 +128,23 @@ private async Task GetResponseBody(string url)
private static Dictionary CreateTranslationsDictionary(string json)
{
- return JsonConvert.DeserializeObject>(json)!;
+ return JsonSerializer.Deserialize>(json)
+ ?? new Dictionary();
}
private static IEnumerable DeserializeLanguages(string json)
{
- return JsonConvert.DeserializeObject>>(json)!
- .Select(l =>
+ using var doc = JsonDocument.Parse(json);
+ return doc.RootElement.EnumerateArray().Select(l =>
+ {
+ var languageTag = l.GetProperty("tag").GetString();
+ var languageName = l.GetProperty("name").GetString();
+ return new Language
{
- var languageTag = l["tag"];
- var isRefernece = l["isReference"];
-
- return new Language
- {
- Name = string.IsNullOrEmpty(languageTag) ? l["name"] : $"{l["name"]}~{languageTag}",
- IsReference = Convert.ToBoolean(isRefernece)
- };
- });
+ Name = string.IsNullOrEmpty(languageTag) ? languageName! : $"{languageName}~{languageTag}",
+ IsReference = l.GetProperty("isReference").GetBoolean()
+ };
+ }).ToList();
}
private void EnsureApiTokenHeaderIsSet()
diff --git a/Calinga.NET/Infrastructure/Exceptions/KeysNotFoundException.cs b/Calinga.NET/Infrastructure/Exceptions/KeysNotFoundException.cs
new file mode 100644
index 0000000..875e555
--- /dev/null
+++ b/Calinga.NET/Infrastructure/Exceptions/KeysNotFoundException.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Collections.Generic;
+
+namespace Calinga.NET.Infrastructure.Exceptions
+{
+ [Serializable]
+ public class KeysNotFoundException : Exception
+ {
+ public IReadOnlyCollection MissingKeys { get; }
+
+ public KeysNotFoundException()
+ {
+ MissingKeys = Array.Empty();
+ }
+
+ public KeysNotFoundException(string message) : base(message)
+ {
+ MissingKeys = Array.Empty();
+ }
+
+ public KeysNotFoundException(string message, Exception innerException) : base(message, innerException)
+ {
+ MissingKeys = Array.Empty();
+ }
+
+ public KeysNotFoundException(IReadOnlyCollection missingKeys, string message) : base(message)
+ {
+ MissingKeys = missingKeys;
+ }
+ }
+}
diff --git a/Calinga.NET/Infrastructure/IConsumerHttpClient.cs b/Calinga.NET/Infrastructure/IConsumerHttpClient.cs
index fffe9a2..d87ab4a 100644
--- a/Calinga.NET/Infrastructure/IConsumerHttpClient.cs
+++ b/Calinga.NET/Infrastructure/IConsumerHttpClient.cs
@@ -8,6 +8,8 @@ public interface IConsumerHttpClient
{
Task> GetTranslationsAsync(string language);
+ Task> GetTranslationsAsync(string language, IEnumerable keys);
+
Task> GetLanguagesAsync();
}
}
\ No newline at end of file
diff --git a/README.md b/README.md
index b1c0b6a..bd250b3 100644
--- a/README.md
+++ b/README.md
@@ -36,7 +36,7 @@ Package to connect and use the calinga service in .NET applications
- `Team`: The name of your team.
- `Project`: The name of your project.
- `ApiToken`: The API token used for authentication.
-- `IsDevMode`: A boolean indicating if the service is in development mode. When `true`, it returns keys instead of actual translations.
+- `IsDevMode`: When `true`, the service returns each translation key as its own value instead of the translated text. Use during UI development to verify which translation key renders where. The keyed `GetTranslationsAsync(language, keys)` overload additionally validates the server response: if any requested key is missing on the server, the call throws `KeysNotFoundException` listing the missing keys, so typos and unknown keys surface at integration time rather than as silent omissions at runtime.
- `IncludeDrafts`: A boolean indicating if draft translations should be included.
- `CacheDirectory`: The directory where cache files are stored. Only needed for the default caching implementation.
- `MemoryCacheExpirationIntervalInSeconds`: The expiration interval for the in-memory cache in seconds. Only needed for the default caching implementation.
@@ -83,3 +83,30 @@ To fetch translations for languages with language tag you must provide the langu
e.g. `de-AT~Intranet`.
Calls to `GetLanguagesAsync()` will also return languages in this format.
+
+## Fetching a subset of keys
+
+When you only need a few translation keys and do not want to pay the cost of downloading the full language dictionary, use the overload that accepts a collection of key names:
+
+```csharp
+var keys = new[] { "dashboard.title", "dashboard.subtitle" };
+var translations = await calingaService.GetTranslationsAsync("de", keys);
+```
+
+- Every keyed call **POSTs** to the Consumer API with a JSON body `{ "keyNames": [...] }` and returns only the translations the server responded with. The cache is never consulted and never written for keyed calls, so the result is always server-fresh.
+- In normal mode, keys absent from the server response are silently omitted from the result (no exception).
+- In DevMode (`IsDevMode = true`), the server response is validated: if any requested key is missing, the call throws `KeysNotFoundException`. The exception's `MissingKeys` property exposes the missing keys, and the message lists them too, so devs can fix typos and unknown keys immediately.
+- Passing `keys: null` throws `ArgumentNullException`.
+- `UseCacheOnly = true` is incompatible with the keyed overload — passing any key collection (including an empty one) while `UseCacheOnly` is set throws `InvalidOperationException`, because keyed calls always require HTTP and cannot be served from the cache.
+- When `UseCacheOnly` is false, passing an empty collection returns an empty dictionary immediately — no HTTP call, no cache access.
+
+### Transport summary
+
+| Call | HTTP method | Path | Cache read | Cache write |
+|------|-------------|------|------------|-------------|
+| `GetTranslationsAsync(language)` | `GET` | `{ConsumerApiBaseUrl}/{org}/{team}/{project}/languages/{language}` | Yes | Full dictionary stored |
+| `GetTranslationsAsync(language, keys)` with non-empty keys (and `UseCacheOnly = false`) | `POST` | `{ConsumerApiBaseUrl}/{org}/{team}/{project}/languages/{language}` with body `{ "keyNames": [...] }` | No | Not stored |
+| `GetTranslationsAsync(language, keys)` with empty keys (and `UseCacheOnly = false`) | none | — | No | — |
+| `GetTranslationsAsync(language, keys)` with any keys while `UseCacheOnly = true` | — | — | — | Throws `InvalidOperationException` |
+
+Both calls share the existing `ConsumerApiBaseUrl` setting — no additional URL configuration is required.