Skip to content

Commit 233e143

Browse files
committed
Add OTA client support to JsonLocalization NuGet package
1 parent ee5a7d5 commit 233e143

10 files changed

Lines changed: 1096 additions & 5 deletions

LocalizationManager.JsonLocalization/JsonLocalizationOptions.cs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Globalization;
55
using System.Reflection;
66
using LocalizationManager.JsonLocalization.Core;
7+
using LocalizationManager.JsonLocalization.Ota;
78

89
namespace LocalizationManager.JsonLocalization;
910

@@ -60,6 +61,54 @@ public class JsonLocalizationOptions
6061
/// </summary>
6162
public bool UseNestedKeys { get; set; } = true;
6263

64+
/// <summary>
65+
/// OTA (Over-The-Air) localization options.
66+
/// When configured, translations are fetched from LRM Cloud at runtime.
67+
/// </summary>
68+
public OtaOptions? Ota { get; set; }
69+
70+
/// <summary>
71+
/// Configures OTA (Over-The-Air) localization with LRM Cloud.
72+
/// Translations are fetched from the cloud at runtime and updated periodically.
73+
/// </summary>
74+
/// <param name="endpoint">The LRM Cloud endpoint URL (default: https://lrm-cloud.com)</param>
75+
/// <param name="apiKey">The API key for authentication (must start with lrm_)</param>
76+
/// <param name="project">Project path: @username/project for user projects, or org/project for organizations</param>
77+
/// <returns>This options instance for chaining.</returns>
78+
/// <example>
79+
/// <code>
80+
/// services.AddJsonLocalization(options => {
81+
/// options.UseOta(
82+
/// endpoint: "https://lrm-cloud.com",
83+
/// apiKey: "lrm_your_api_key",
84+
/// project: "@username/my-project"
85+
/// );
86+
/// });
87+
/// </code>
88+
/// </example>
89+
public JsonLocalizationOptions UseOta(string endpoint, string apiKey, string project)
90+
{
91+
Ota = new OtaOptions
92+
{
93+
Endpoint = endpoint,
94+
ApiKey = apiKey,
95+
Project = project
96+
};
97+
return this;
98+
}
99+
100+
/// <summary>
101+
/// Configures OTA (Over-The-Air) localization with detailed options.
102+
/// </summary>
103+
/// <param name="configure">Action to configure OTA options.</param>
104+
/// <returns>This options instance for chaining.</returns>
105+
public JsonLocalizationOptions UseOta(Action<OtaOptions> configure)
106+
{
107+
Ota = new OtaOptions();
108+
configure(Ota);
109+
return this;
110+
}
111+
63112
/// <summary>
64113
/// Gets the JSON format configuration based on these options.
65114
/// </summary>

LocalizationManager.JsonLocalization/JsonStringLocalizerFactory.cs

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
using System.Collections.Concurrent;
55
using System.Reflection;
6+
using LocalizationManager.JsonLocalization.Core;
67
using Microsoft.Extensions.Localization;
78
using Microsoft.Extensions.Options;
89

@@ -13,7 +14,10 @@ namespace LocalizationManager.JsonLocalization;
1314
/// </summary>
1415
public class JsonStringLocalizerFactory : IStringLocalizerFactory
1516
{
16-
private readonly JsonLocalizationOptions _options;
17+
private readonly JsonLocalizationOptions? _options;
18+
private readonly IResourceLoader? _customLoader;
19+
private readonly string? _customBaseName;
20+
private readonly JsonFormatConfiguration? _customConfig;
1721
private readonly ConcurrentDictionary<string, JsonLocalizer> _localizerCache = new();
1822

1923
/// <summary>
@@ -24,6 +28,19 @@ public JsonStringLocalizerFactory(IOptions<JsonLocalizationOptions> options)
2428
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
2529
}
2630

31+
/// <summary>
32+
/// Creates a new JsonStringLocalizerFactory with a custom resource loader (for OTA support).
33+
/// </summary>
34+
/// <param name="loader">The resource loader to use.</param>
35+
/// <param name="baseName">Base name for resources.</param>
36+
/// <param name="config">JSON format configuration.</param>
37+
public JsonStringLocalizerFactory(IResourceLoader loader, string baseName, JsonFormatConfiguration config)
38+
{
39+
_customLoader = loader ?? throw new ArgumentNullException(nameof(loader));
40+
_customBaseName = baseName ?? "strings";
41+
_customConfig = config ?? new JsonFormatConfiguration();
42+
}
43+
2744
/// <inheritdoc />
2845
public IStringLocalizer Create(Type resourceSource)
2946
{
@@ -56,7 +73,7 @@ public IStringLocalizer Create(string baseName, string location)
5673
}
5774
}
5875

59-
assembly ??= _options.ResourceAssembly ?? Assembly.GetEntryAssembly();
76+
assembly ??= _options?.ResourceAssembly ?? Assembly.GetEntryAssembly();
6077

6178
return CreateLocalizer(baseName, assembly);
6279
}
@@ -70,9 +87,16 @@ private IStringLocalizer CreateLocalizer(string baseName, Assembly? assembly)
7087

7188
var localizer = _localizerCache.GetOrAdd(cacheKey, _ =>
7289
{
90+
// Use custom loader if provided (OTA mode)
91+
if (_customLoader != null)
92+
{
93+
return new JsonLocalizer(_customLoader, _customBaseName ?? baseName, _customConfig!);
94+
}
95+
96+
// Use options-based loader (standard mode)
7397
IResourceLoader loader;
7498

75-
if (_options.UseEmbeddedResources)
99+
if (_options!.UseEmbeddedResources)
76100
{
77101
var resourceAssembly = assembly ?? _options.ResourceAssembly ?? Assembly.GetEntryAssembly()!;
78102
loader = new EmbeddedResourceLoader(resourceAssembly, _options.ResourcesPath);
@@ -97,20 +121,26 @@ private IStringLocalizer CreateLocalizer(string baseName, Assembly? assembly)
97121
/// </summary>
98122
private string GetBaseName(Type resourceSource)
99123
{
124+
// Use custom base name if in OTA mode
125+
if (_customBaseName != null)
126+
{
127+
return _customBaseName;
128+
}
129+
100130
// Use the configured base name if the type doesn't have a custom one
101131
var typeName = resourceSource.Name;
102132

103133
// For generic IStringLocalizer (without type parameter), use the configured base name
104134
if (resourceSource == typeof(object))
105135
{
106-
return _options.BaseName;
136+
return _options!.BaseName;
107137
}
108138

109139
// Check for common patterns like "HomeController" -> use configured base name
110140
// This allows sharing a single JSON file across multiple controllers
111141
if (typeName.EndsWith("Controller") || typeName.EndsWith("Model") || typeName.EndsWith("ViewModel"))
112142
{
113-
return _options.BaseName;
143+
return _options!.BaseName;
114144
}
115145

116146
// Otherwise, use the type name as the base name

LocalizationManager.JsonLocalization/LocalizationManager.JsonLocalization.csproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@
2626
<NoWarn>$(NoWarn);CS1591</NoWarn>
2727
</PropertyGroup>
2828

29+
<!-- Expose internals to test project -->
30+
<ItemGroup>
31+
<InternalsVisibleTo Include="LocalizationManager.Tests" />
32+
</ItemGroup>
33+
2934
<ItemGroup>
3035
<None Include="README.md" Pack="true" PackagePath="\" />
3136
</ItemGroup>
@@ -35,6 +40,9 @@
3540
<PackageReference Include="Microsoft.Extensions.Localization.Abstractions" Version="8.0.0" />
3641
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.0" />
3742
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.0" />
43+
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
44+
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.0" />
45+
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
3846
</ItemGroup>
3947

4048
</Project>
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Copyright (c) 2025 Nikolaos Protopapas
2+
// Licensed under the MIT License
3+
4+
namespace LocalizationManager.JsonLocalization.Ota;
5+
6+
/// <summary>
7+
/// Simple circuit breaker implementation for OTA requests.
8+
/// </summary>
9+
public class CircuitBreaker
10+
{
11+
private readonly int _threshold;
12+
private readonly TimeSpan _timeout;
13+
private readonly object _lock = new();
14+
15+
private CircuitState _state = CircuitState.Closed;
16+
private int _failureCount;
17+
private DateTime _lastFailureTime;
18+
private DateTime _openedAt;
19+
20+
/// <summary>
21+
/// Creates a new circuit breaker.
22+
/// </summary>
23+
/// <param name="threshold">Number of failures before opening the circuit.</param>
24+
/// <param name="timeout">How long to wait before attempting to close the circuit.</param>
25+
public CircuitBreaker(int threshold = 5, TimeSpan? timeout = null)
26+
{
27+
_threshold = threshold;
28+
_timeout = timeout ?? TimeSpan.FromSeconds(30);
29+
}
30+
31+
/// <summary>
32+
/// Current state of the circuit breaker.
33+
/// Note: Accessing this property may cause a state transition from Open to HalfOpen
34+
/// if the timeout has elapsed. This allows the circuit to automatically attempt
35+
/// recovery without requiring explicit timer-based transitions.
36+
/// </summary>
37+
public CircuitState State
38+
{
39+
get
40+
{
41+
lock (_lock)
42+
{
43+
// Automatically transition from Open to HalfOpen after timeout expires.
44+
// This allows the next request to test if the service has recovered.
45+
if (_state == CircuitState.Open && DateTime.UtcNow - _openedAt >= _timeout)
46+
{
47+
_state = CircuitState.HalfOpen;
48+
}
49+
return _state;
50+
}
51+
}
52+
}
53+
54+
/// <summary>
55+
/// Whether requests should be allowed (circuit is not open).
56+
/// </summary>
57+
public bool IsAllowed => State != CircuitState.Open;
58+
59+
/// <summary>
60+
/// Records a successful operation.
61+
/// </summary>
62+
public void RecordSuccess()
63+
{
64+
lock (_lock)
65+
{
66+
_failureCount = 0;
67+
_state = CircuitState.Closed;
68+
}
69+
}
70+
71+
/// <summary>
72+
/// Records a failed operation.
73+
/// </summary>
74+
public void RecordFailure()
75+
{
76+
lock (_lock)
77+
{
78+
_failureCount++;
79+
_lastFailureTime = DateTime.UtcNow;
80+
81+
if (_state == CircuitState.HalfOpen)
82+
{
83+
// If we fail in half-open state, go back to open with longer timeout
84+
_state = CircuitState.Open;
85+
_openedAt = DateTime.UtcNow;
86+
}
87+
else if (_failureCount >= _threshold)
88+
{
89+
_state = CircuitState.Open;
90+
_openedAt = DateTime.UtcNow;
91+
}
92+
}
93+
}
94+
95+
/// <summary>
96+
/// Executes an operation with circuit breaker protection.
97+
/// </summary>
98+
public async Task<T?> ExecuteAsync<T>(Func<Task<T>> operation, T? fallback = default)
99+
{
100+
if (!IsAllowed)
101+
{
102+
return fallback;
103+
}
104+
105+
try
106+
{
107+
var result = await operation();
108+
RecordSuccess();
109+
return result;
110+
}
111+
catch
112+
{
113+
RecordFailure();
114+
return fallback;
115+
}
116+
}
117+
}
118+
119+
/// <summary>
120+
/// Circuit breaker states.
121+
/// </summary>
122+
public enum CircuitState
123+
{
124+
/// <summary>
125+
/// Circuit is closed, requests flow normally.
126+
/// </summary>
127+
Closed,
128+
129+
/// <summary>
130+
/// Circuit is open, requests are blocked.
131+
/// </summary>
132+
Open,
133+
134+
/// <summary>
135+
/// Circuit is testing, one request is allowed to check if service recovered.
136+
/// </summary>
137+
HalfOpen
138+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright (c) 2025 Nikolaos Protopapas
2+
// Licensed under the MIT License
3+
4+
using System.Text.Json.Serialization;
5+
6+
namespace LocalizationManager.JsonLocalization.Ota;
7+
8+
/// <summary>
9+
/// OTA bundle response from LRM Cloud.
10+
/// </summary>
11+
public class OtaBundle
12+
{
13+
/// <summary>
14+
/// Version timestamp (ISO 8601 format).
15+
/// </summary>
16+
[JsonPropertyName("version")]
17+
public string Version { get; set; } = "";
18+
19+
/// <summary>
20+
/// Project identifier.
21+
/// </summary>
22+
[JsonPropertyName("project")]
23+
public string Project { get; set; } = "";
24+
25+
/// <summary>
26+
/// Default/source language code.
27+
/// </summary>
28+
[JsonPropertyName("defaultLanguage")]
29+
public string DefaultLanguage { get; set; } = "";
30+
31+
/// <summary>
32+
/// List of available language codes.
33+
/// </summary>
34+
[JsonPropertyName("languages")]
35+
public List<string> Languages { get; set; } = new();
36+
37+
/// <summary>
38+
/// Keys deleted since the last sync (for delta updates).
39+
/// </summary>
40+
[JsonPropertyName("deleted")]
41+
public List<string> Deleted { get; set; } = new();
42+
43+
/// <summary>
44+
/// Translations organized by language code, then by key.
45+
/// Values can be strings or dictionaries (for plural forms).
46+
/// </summary>
47+
[JsonPropertyName("translations")]
48+
public Dictionary<string, Dictionary<string, object>> Translations { get; set; } = new();
49+
}
50+
51+
/// <summary>
52+
/// OTA version response for efficient polling.
53+
/// </summary>
54+
public class OtaVersion
55+
{
56+
/// <summary>
57+
/// Version timestamp (ISO 8601 format).
58+
/// </summary>
59+
[JsonPropertyName("version")]
60+
public string Version { get; set; } = "";
61+
}

0 commit comments

Comments
 (0)