mdan-cli 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,492 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+ using System.Linq;
4
+ using System.Net;
5
+ using System.Net.Http;
6
+ using System.Net.Http.Headers;
7
+ using System.Text;
8
+ using System.Text.Json;
9
+ using System.Threading;
10
+ using System.Threading.Tasks;
11
+ using Microsoft.Extensions.Caching.Memory;
12
+ using Microsoft.Extensions.Configuration;
13
+ using Microsoft.Extensions.Logging;
14
+ using Polly;
15
+ using Polly.CircuitBreaker;
16
+ using Polly.RateLimit;
17
+
18
+ namespace ExternalServices.Services
19
+ {
20
+ /// <summary>
21
+ /// Implémentation de base pour les services externes avec patterns avancés.
22
+ /// </summary>
23
+ public abstract class ServiceBase : IService
24
+ {
25
+ protected readonly HttpClient _httpClient;
26
+ protected readonly IConfiguration _configuration;
27
+ protected readonly ILogger _logger;
28
+ protected readonly IMemoryCache _cache;
29
+ protected readonly string _serviceName;
30
+ protected readonly string _baseUrl;
31
+ protected readonly string _apiKey;
32
+ protected readonly int _timeout;
33
+ protected readonly int _retryCount;
34
+ protected readonly int _retryDelay;
35
+ protected readonly bool _enableCircuitBreaker;
36
+ protected readonly int _circuitBreakerThreshold;
37
+ protected readonly bool _enableRateLimiting;
38
+ protected readonly int _rateLimitPerMinute;
39
+ protected readonly bool _enableCaching;
40
+ protected readonly int _cacheDurationMinutes;
41
+
42
+ protected string? _accessToken;
43
+ protected DateTime _tokenExpiry;
44
+ protected readonly SemaphoreSlim _rateLimitSemaphore;
45
+ protected readonly Queue<DateTime> _requestTimestamps;
46
+ protected AsyncCircuitBreakerPolicy? _circuitBreakerPolicy;
47
+ protected AsyncRetryPolicy? _retryPolicy;
48
+
49
+ protected ServiceStatus _status;
50
+
51
+ public string ServiceName => _serviceName;
52
+ public string BaseUrl => _baseUrl;
53
+
54
+ protected ServiceBase(
55
+ IConfiguration configuration,
56
+ string serviceName,
57
+ ILogger logger,
58
+ IMemoryCache? cache = null)
59
+ {
60
+ _configuration = configuration;
61
+ _serviceName = serviceName;
62
+ _logger = logger;
63
+ _cache = cache ?? new MemoryCache(new MemoryCacheOptions());
64
+ _rateLimitSemaphore = new SemaphoreSlim(1, 1);
65
+ _requestTimestamps = new Queue<DateTime>();
66
+ _status = new ServiceStatus();
67
+
68
+ var configSection = configuration.GetSection($"ExternalServices:{serviceName}");
69
+
70
+ _baseUrl = configSection["BaseUrl"] ?? throw new ArgumentException($"BaseUrl not configured for service {serviceName}");
71
+ _apiKey = configSection["ApiKey"] ?? string.Empty;
72
+ _timeout = configSection.GetValue<int>("Timeout", 30);
73
+ _retryCount = configSection.GetValue<int>("RetryCount", 3);
74
+ _retryDelay = configSection.GetValue<int>("RetryDelay", 1000);
75
+ _enableCircuitBreaker = configSection.GetValue<bool>("EnableCircuitBreaker", true);
76
+ _circuitBreakerThreshold = configSection.GetValue<int>("CircuitBreakerThreshold", 5);
77
+ _enableRateLimiting = configSection.GetValue<bool>("EnableRateLimiting", true);
78
+ _rateLimitPerMinute = configSection.GetValue<int>("RateLimitPerMinute", 60);
79
+ _enableCaching = configSection.GetValue<bool>("EnableCaching", true);
80
+ _cacheDurationMinutes = configSection.GetValue<int>("CacheDurationMinutes", 5);
81
+
82
+ _httpClient = new HttpClient
83
+ {
84
+ BaseAddress = new Uri(_baseUrl),
85
+ Timeout = TimeSpan.FromSeconds(_timeout)
86
+ };
87
+
88
+ _httpClient.DefaultRequestHeaders.Accept.Add(
89
+ new MediaTypeWithQualityHeaderValue("application/json"));
90
+
91
+ InitializePolicies();
92
+ }
93
+
94
+ private void InitializePolicies()
95
+ {
96
+ // Retry Policy
97
+ _retryPolicy = Policy
98
+ .Handle<HttpRequestException>()
99
+ .OrResult<HttpResponseMessage>(r =>
100
+ r.StatusCode == HttpStatusCode.ServiceUnavailable ||
101
+ r.StatusCode == HttpStatusCode.GatewayTimeout ||
102
+ r.StatusCode == HttpStatusCode.RequestTimeout)
103
+ .WaitAndRetryAsync(
104
+ _retryCount,
105
+ retryAttempt => TimeSpan.FromMilliseconds(_retryDelay * retryAttempt),
106
+ onRetry: (outcome, timespan, retryCount, context) =>
107
+ {
108
+ _logger.LogWarning(
109
+ "Retry {RetryCount} after {Delay}ms for {ServiceName}",
110
+ retryCount, timespan.TotalMilliseconds, _serviceName);
111
+ });
112
+
113
+ // Circuit Breaker Policy
114
+ if (_enableCircuitBreaker)
115
+ {
116
+ _circuitBreakerPolicy = Policy
117
+ .Handle<HttpRequestException>()
118
+ .OrResult<HttpResponseMessage>(r =>
119
+ r.StatusCode == HttpStatusCode.ServiceUnavailable ||
120
+ r.StatusCode == HttpStatusCode.GatewayTimeout)
121
+ .CircuitBreakerAsync(
122
+ exceptionsAllowedBeforeBreaking: _circuitBreakerThreshold,
123
+ durationOfBreak: TimeSpan.FromMinutes(1),
124
+ onBreak: (exception, breakDelay) =>
125
+ {
126
+ _status.IsCircuitOpen = true;
127
+ _logger.LogError(
128
+ "Circuit broken for {ServiceName} for {BreakDelay}ms",
129
+ _serviceName, breakDelay.TotalMilliseconds);
130
+ },
131
+ onReset: () =>
132
+ {
133
+ _status.IsCircuitOpen = false;
134
+ _status.ConsecutiveFailures = 0;
135
+ _logger.LogInformation("Circuit reset for {ServiceName}", _serviceName);
136
+ });
137
+ }
138
+ }
139
+
140
+ public virtual async Task AuthenticateAsync()
141
+ {
142
+ if (_accessToken != null && DateTime.UtcNow < _tokenExpiry)
143
+ {
144
+ return;
145
+ }
146
+
147
+ try
148
+ {
149
+ var authRequest = new
150
+ {
151
+ ApiKey = _apiKey
152
+ };
153
+
154
+ var response = await ExecuteWithRetryAsync(async () =>
155
+ {
156
+ var json = JsonSerializer.Serialize(authRequest);
157
+ var content = new StringContent(json, Encoding.UTF8, "application/json");
158
+ return await _httpClient.PostAsync("auth/token", content);
159
+ });
160
+
161
+ if (response.IsSuccessStatusCode)
162
+ {
163
+ var responseContent = await response.Content.ReadAsStringAsync();
164
+ var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent);
165
+
166
+ if (authResponse != null)
167
+ {
168
+ _accessToken = authResponse.AccessToken;
169
+ _tokenExpiry = DateTime.UtcNow.AddSeconds(authResponse.ExpiresIn);
170
+ _status.IsAuthenticated = true;
171
+ _logger.LogInformation("Authenticated successfully for {ServiceName}", _serviceName);
172
+ }
173
+ }
174
+ else
175
+ {
176
+ _logger.LogError("Authentication failed for {ServiceName}: {StatusCode}",
177
+ _serviceName, response.StatusCode);
178
+ }
179
+ }
180
+ catch (Exception ex)
181
+ {
182
+ _logger.LogError(ex, "Authentication error for {ServiceName}", _serviceName);
183
+ throw;
184
+ }
185
+ }
186
+
187
+ public async Task<bool> IsAuthenticatedAsync()
188
+ {
189
+ return _accessToken != null && DateTime.UtcNow < _tokenExpiry;
190
+ }
191
+
192
+ public async Task<ServiceResponse<T>> GetDataAsync<T>(string endpoint, Dictionary<string, string>? parameters = null)
193
+ {
194
+ return await ExecuteWithCircuitBreakerAsync(async () =>
195
+ {
196
+ await EnsureAuthenticatedAsync();
197
+ await ApplyRateLimitAsync();
198
+
199
+ var cacheKey = $"GET:{endpoint}:{GetCacheKey(parameters)}";
200
+
201
+ if (_enableCaching)
202
+ {
203
+ var cached = await GetFromCacheAsync<T>(cacheKey);
204
+ if (cached != null)
205
+ {
206
+ return cached;
207
+ }
208
+ }
209
+
210
+ var url = BuildUrl(endpoint, parameters);
211
+ var response = await ExecuteWithRetryAsync(async () =>
212
+ {
213
+ _httpClient.DefaultRequestHeaders.Authorization =
214
+ new AuthenticationHeaderValue("Bearer", _accessToken);
215
+ return await _httpClient.GetAsync(url);
216
+ });
217
+
218
+ var result = await ProcessResponseAsync<T>(response);
219
+
220
+ if (_enableCaching && result.Success)
221
+ {
222
+ await SetCacheAsync(cacheKey, result);
223
+ }
224
+
225
+ return result;
226
+ });
227
+ }
228
+
229
+ public async Task<ServiceResponse<T>> PostDataAsync<T>(string endpoint, object data)
230
+ {
231
+ return await ExecuteWithCircuitBreakerAsync(async () =>
232
+ {
233
+ await EnsureAuthenticatedAsync();
234
+ await ApplyRateLimitAsync();
235
+
236
+ var json = JsonSerializer.Serialize(data);
237
+ var content = new StringContent(json, Encoding.UTF8, "application/json");
238
+
239
+ var response = await ExecuteWithRetryAsync(async () =>
240
+ {
241
+ _httpClient.DefaultRequestHeaders.Authorization =
242
+ new AuthenticationHeaderValue("Bearer", _accessToken);
243
+ return await _httpClient.PostAsync(endpoint, content);
244
+ });
245
+
246
+ return await ProcessResponseAsync<T>(response);
247
+ });
248
+ }
249
+
250
+ public async Task<ServiceResponse<T>> PutDataAsync<T>(string endpoint, object data)
251
+ {
252
+ return await ExecuteWithCircuitBreakerAsync(async () =>
253
+ {
254
+ await EnsureAuthenticatedAsync();
255
+ await ApplyRateLimitAsync();
256
+
257
+ var json = JsonSerializer.Serialize(data);
258
+ var content = new StringContent(json, Encoding.UTF8, "application/json");
259
+
260
+ var response = await ExecuteWithRetryAsync(async () =>
261
+ {
262
+ _httpClient.DefaultRequestHeaders.Authorization =
263
+ new AuthenticationHeaderValue("Bearer", _accessToken);
264
+ return await _httpClient.PutAsync(endpoint, content);
265
+ });
266
+
267
+ return await ProcessResponseAsync<T>(response);
268
+ });
269
+ }
270
+
271
+ public async Task<ServiceResponse<T>> DeleteDataAsync<T>(string endpoint)
272
+ {
273
+ return await ExecuteWithCircuitBreakerAsync(async () =>
274
+ {
275
+ await EnsureAuthenticatedAsync();
276
+ await ApplyRateLimitAsync();
277
+
278
+ var response = await ExecuteWithRetryAsync(async () =>
279
+ {
280
+ _httpClient.DefaultRequestHeaders.Authorization =
281
+ new AuthenticationHeaderValue("Bearer", _accessToken);
282
+ return await _httpClient.DeleteAsync(endpoint);
283
+ });
284
+
285
+ return await ProcessResponseAsync<T>(response);
286
+ });
287
+ }
288
+
289
+ public async Task<bool> HealthCheckAsync()
290
+ {
291
+ try
292
+ {
293
+ var response = await _httpClient.GetAsync("health");
294
+ var isHealthy = response.IsSuccessStatusCode;
295
+
296
+ _status.IsHealthy = isHealthy;
297
+
298
+ if (isHealthy)
299
+ {
300
+ _status.LastSuccessfulCall = DateTime.UtcNow;
301
+ _status.ConsecutiveFailures = 0;
302
+ }
303
+ else
304
+ {
305
+ _status.LastFailedCall = DateTime.UtcNow;
306
+ _status.ConsecutiveFailures++;
307
+ }
308
+
309
+ return isHealthy;
310
+ }
311
+ catch (Exception ex)
312
+ {
313
+ _logger.LogError(ex, "Health check failed for {ServiceName}", _serviceName);
314
+ _status.IsHealthy = false;
315
+ _status.LastFailedCall = DateTime.UtcNow;
316
+ _status.ConsecutiveFailures++;
317
+ return false;
318
+ }
319
+ }
320
+
321
+ public async Task<ServiceStatus> GetStatusAsync()
322
+ {
323
+ await HealthCheckAsync();
324
+ return _status;
325
+ }
326
+
327
+ protected async Task EnsureAuthenticatedAsync()
328
+ {
329
+ if (!await IsAuthenticatedAsync())
330
+ {
331
+ await AuthenticateAsync();
332
+ }
333
+ }
334
+
335
+ protected async Task ApplyRateLimitAsync()
336
+ {
337
+ if (!_enableRateLimiting)
338
+ {
339
+ return;
340
+ }
341
+
342
+ await _rateLimitSemaphore.WaitAsync();
343
+ try
344
+ {
345
+ var now = DateTime.UtcNow;
346
+
347
+ // Remove timestamps older than 1 minute
348
+ while (_requestTimestamps.Count > 0 &&
349
+ (now - _requestTimestamps.Peek()).TotalMinutes > 1)
350
+ {
351
+ _requestTimestamps.Dequeue();
352
+ }
353
+
354
+ // Check if rate limit exceeded
355
+ if (_requestTimestamps.Count >= _rateLimitPerMinute)
356
+ {
357
+ var oldestRequest = _requestTimestamps.Peek();
358
+ var waitTime = TimeSpan.FromMinutes(1) - (now - oldestRequest);
359
+
360
+ if (waitTime.TotalMilliseconds > 0)
361
+ {
362
+ _logger.LogWarning(
363
+ "Rate limit reached for {ServiceName}, waiting {WaitTime}ms",
364
+ _serviceName, waitTime.TotalMilliseconds);
365
+ await Task.Delay(waitTime);
366
+ }
367
+ }
368
+
369
+ _requestTimestamps.Enqueue(now);
370
+ }
371
+ finally
372
+ {
373
+ _rateLimitSemaphore.Release();
374
+ }
375
+ }
376
+
377
+ protected async Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> action)
378
+ {
379
+ if (_retryPolicy != null)
380
+ {
381
+ return await _retryPolicy.ExecuteAsync(action);
382
+ }
383
+ return await action();
384
+ }
385
+
386
+ protected async Task<T> ExecuteWithCircuitBreakerAsync<T>(Func<Task<T>> action)
387
+ {
388
+ if (_circuitBreakerPolicy != null)
389
+ {
390
+ return await _circuitBreakerPolicy.ExecuteAsync(action);
391
+ }
392
+ return await action();
393
+ }
394
+
395
+ protected async Task<ServiceResponse<T>> ProcessResponseAsync<T>(HttpResponseMessage response)
396
+ {
397
+ _status.TotalCalls++;
398
+
399
+ var result = new ServiceResponse<T>
400
+ {
401
+ Timestamp = DateTime.UtcNow,
402
+ StatusCode = (int)response.StatusCode
403
+ };
404
+
405
+ try
406
+ {
407
+ var responseContent = await response.Content.ReadAsStringAsync();
408
+
409
+ if (response.IsSuccessStatusCode)
410
+ {
411
+ result.Data = JsonSerializer.Deserialize<T>(responseContent);
412
+ result.Success = true;
413
+ _status.SuccessfulCalls++;
414
+ _status.LastSuccessfulCall = DateTime.UtcNow;
415
+ _status.ConsecutiveFailures = 0;
416
+ }
417
+ else
418
+ {
419
+ result.Success = false;
420
+ result.ErrorMessage = responseContent;
421
+ result.ErrorCode = ((int)response.StatusCode).ToString();
422
+ _status.FailedCalls++;
423
+ _status.LastFailedCall = DateTime.UtcNow;
424
+ _status.ConsecutiveFailures++;
425
+
426
+ _logger.LogWarning(
427
+ "Request failed for {ServiceName}: {StatusCode} - {ErrorMessage}",
428
+ _serviceName, response.StatusCode, responseContent);
429
+ }
430
+ }
431
+ catch (Exception ex)
432
+ {
433
+ result.Success = false;
434
+ result.ErrorMessage = ex.Message;
435
+ _status.FailedCalls++;
436
+ _status.LastFailedCall = DateTime.UtcNow;
437
+ _status.ConsecutiveFailures++;
438
+
439
+ _logger.LogError(ex, "Error processing response for {ServiceName}", _serviceName);
440
+ }
441
+
442
+ return result;
443
+ }
444
+
445
+ protected string BuildUrl(string endpoint, Dictionary<string, string>? parameters)
446
+ {
447
+ if (parameters == null || parameters.Count == 0)
448
+ {
449
+ return endpoint;
450
+ }
451
+
452
+ var queryString = string.Join("&", parameters.Select(kvp =>
453
+ $"{WebUtility.UrlEncode(kvp.Key)}={WebUtility.UrlEncode(kvp.Value)}"));
454
+ return $"{endpoint}?{queryString}";
455
+ }
456
+
457
+ protected string GetCacheKey(Dictionary<string, string>? parameters)
458
+ {
459
+ if (parameters == null || parameters.Count == 0)
460
+ {
461
+ return "default";
462
+ }
463
+
464
+ return string.Join("|", parameters.OrderBy(kvp => kvp.Key)
465
+ .Select(kvp => $"{kvp.Key}={kvp.Value}"));
466
+ }
467
+
468
+ protected async Task<ServiceResponse<T>?> GetFromCacheAsync<T>(string cacheKey)
469
+ {
470
+ if (_cache.TryGetValue(cacheKey, out ServiceResponse<T>? cached))
471
+ {
472
+ _logger.LogDebug("Cache hit for {CacheKey}", cacheKey);
473
+ return cached;
474
+ }
475
+ return null;
476
+ }
477
+
478
+ protected async Task SetCacheAsync<T>(string cacheKey, ServiceResponse<T> value)
479
+ {
480
+ var expiry = TimeSpan.FromMinutes(_cacheDurationMinutes);
481
+ _cache.Set(cacheKey, value, expiry);
482
+ _logger.LogDebug("Cached response for {CacheKey} for {Expiry} minutes",
483
+ cacheKey, _cacheDurationMinutes);
484
+ }
485
+
486
+ protected class AuthResponse
487
+ {
488
+ public string AccessToken { get; set; } = string.Empty;
489
+ public int ExpiresIn { get; set; }
490
+ }
491
+ }
492
+ }