Skip to content

Commit bc8e4db

Browse files
committed
feat: implement CachedGetRequestClient for resilient HTTP requests with user-context aware caching and automated error mapping
1 parent 2a99f7a commit bc8e4db

4 files changed

Lines changed: 181 additions & 12 deletions

File tree

source/AAS.TwinEngine.DataEngine.UnitTests/ApplicationLogic/Services/AasRepository/AasRepositoryServiceTests.cs

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77
using AAS.TwinEngine.DataEngine.DomainModel.AasRepository;
88
using AAS.TwinEngine.DataEngine.DomainModel.Plugin;
99
using AAS.TwinEngine.DataEngine.DomainModel.Shared;
10+
using AAS.TwinEngine.DataEngine.ServiceConfiguration.Config;
1011

1112
using AasCore.Aas3_1;
1213

1314
using Microsoft.Extensions.Logging;
15+
using Microsoft.Extensions.Options;
1416

1517
using NSubstitute;
1618
using NSubstitute.ExceptionExtensions;
@@ -23,10 +25,27 @@ public class AasRepositoryServiceTests
2325
private readonly IPluginDataHandler _pluginDataHandler = Substitute.For<IPluginDataHandler>();
2426
private readonly IPluginManifestConflictHandler _pluginManifestConflictHandler = Substitute.For<IPluginManifestConflictHandler>();
2527
private readonly ILogger<AasRepositoryService> _logger = Substitute.For<ILogger<AasRepositoryService>>();
28+
private readonly IOptions<TemplateManagementConfig> _templateManagementConfig = Substitute.For<IOptions<TemplateManagementConfig>>();
2629
private readonly AasRepositoryService _sut;
2730
private const string AasIdentifier = "test-id";
2831

29-
public AasRepositoryServiceTests() => _sut = new AasRepositoryService(_logger, _templateService, _pluginDataHandler, _pluginManifestConflictHandler);
32+
public AasRepositoryServiceTests()
33+
{
34+
_templateManagementConfig.Value.Returns(new TemplateManagementConfig
35+
{
36+
AasTemplateRepository = new ServiceInstance
37+
{
38+
ConcurrentOperationsLimit = 10
39+
}
40+
});
41+
42+
_sut = new AasRepositoryService(
43+
_logger,
44+
_templateService,
45+
_pluginDataHandler,
46+
_pluginManifestConflictHandler,
47+
_templateManagementConfig);
48+
}
3049

3150
[Fact]
3251
public async Task GetShellByIdAsync_ShouldReturnShellWithAssetInformation()
@@ -331,6 +350,52 @@ await _pluginDataHandler.Received(1)
331350
.GetDataForShellsByAssetIdsAsync(manifests, Arg.Is<ShellSearchFilter>(f => f != null && f.IdShort == targetIdShort), cancellationToken);
332351
}
333352

353+
[Fact]
354+
public async Task GetShellsByFiltersAsync_ShouldBuildShellsInParallelAndSkipFailures()
355+
{
356+
// Arrange
357+
var cancellationToken = CancellationToken.None;
358+
var manifests = new List<PluginManifest>();
359+
_pluginManifestConflictHandler.Manifests.Returns(manifests);
360+
361+
var metadataItems = new List<ShellDescriptorMetaData>
362+
{
363+
new() { Id = "aas-1", SpecificAssetIds = [] },
364+
new() { Id = "aas-2", SpecificAssetIds = [] },
365+
new() { Id = "aas-3", SpecificAssetIds = [] }
366+
};
367+
368+
_pluginDataHandler
369+
.GetDataForAllShellDescriptorsAsync(null, null, manifests, cancellationToken)
370+
.Returns(new ShellDescriptorsMetaData
371+
{
372+
ShellDescriptors = metadataItems,
373+
PagingMetaData = new PagingMetaData()
374+
});
375+
376+
_templateService.GetShellTemplateAsync("aas-1", cancellationToken)
377+
.Returns(new AssetAdministrationShell("aas-1", new AssetInformation(AssetKind.Instance)));
378+
379+
_templateService.GetShellTemplateAsync("aas-2", cancellationToken)
380+
.Throws(new Exception("Template loading failed"));
381+
382+
_templateService.GetShellTemplateAsync("aas-3", cancellationToken)
383+
.Returns(new AssetAdministrationShell("aas-3", new AssetInformation(AssetKind.Instance)));
384+
385+
// Act
386+
var result = await _sut.GetShellsByFiltersAsync(filter: null, limit: null, cursor: null, cancellationToken);
387+
388+
// Assert
389+
Assert.NotNull(result);
390+
Assert.Equal(2, result.Result.Count);
391+
Assert.Contains(result.Result, s => s.Id == "aas-1");
392+
Assert.Contains(result.Result, s => s.Id == "aas-3");
393+
394+
await _templateService.Received(1).GetShellTemplateAsync("aas-1", cancellationToken);
395+
await _templateService.Received(1).GetShellTemplateAsync("aas-2", cancellationToken);
396+
await _templateService.Received(1).GetShellTemplateAsync("aas-3", cancellationToken);
397+
}
398+
334399
private static AssetAdministrationShell CreateShellTemplate()
335400
=> new(
336401
id: "urn:uuid:123e4567-e89b-12d3-a456-426614174000",

source/AAS.TwinEngine.DataEngine.UnitTests/Infrastructure/Http/Clients/Caching/CachedGetRequestClientTests.cs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,74 @@ public async Task GetStringAsync_PropagatesCancellationToken()
328328
Assert.Equal(cancellationToken, capturedToken);
329329
}
330330

331+
[Theory]
332+
[InlineData("?isCacheEnable=false", true)]
333+
[InlineData("?isCacheEnable=true", false)]
334+
[InlineData("?isCacheEnable=invalid", false)]
335+
[InlineData("", false)]
336+
public async Task GetStringAsync_RespectsIsCacheEnabledQueryParameter(string queryString, bool expectBypass)
337+
{
338+
// Arrange
339+
const string RelativeUrl = "api/test";
340+
const string HttpClientName = "TestClient";
341+
const string ExpectedResponse = "Direct HTTP data";
342+
343+
var httpContext = new DefaultHttpContext();
344+
if (!string.IsNullOrEmpty(queryString))
345+
{
346+
httpContext.Request.QueryString = new QueryString(queryString);
347+
}
348+
_httpContextAccessor.HttpContext.Returns(httpContext);
349+
350+
if (expectBypass)
351+
{
352+
// If bypassed, cache is never called.
353+
_cache.GetOrCreateAsync<string>(
354+
Arg.Any<string>(),
355+
Arg.Any<Func<CancellationToken, ValueTask<string>>>(),
356+
Arg.Any<HybridCacheEntryOptions>(),
357+
Arg.Any<IEnumerable<string>>(),
358+
Arg.Any<CancellationToken>()
359+
).Returns(new ValueTask<string>("Cached data that should be bypassed"));
360+
}
361+
else
362+
{
363+
// If cache is enabled, SetupCacheHit will return direct response.
364+
SetupCacheHit(ExpectedResponse);
365+
}
366+
367+
using var httpResponse = new HttpResponseMessage(HttpStatusCode.OK)
368+
{
369+
Content = new StringContent(ExpectedResponse)
370+
};
371+
SetupHttpClient(HttpClientName, httpResponse);
372+
373+
// Act
374+
var result = await _sut.GetStringAsync(RelativeUrl, HttpClientName, 5, CancellationToken.None);
375+
376+
// Assert
377+
Assert.Equal(ExpectedResponse, result);
378+
379+
if (expectBypass)
380+
{
381+
// If bypassed, HTTP client must be called directly.
382+
_clientFactory.Received(1).CreateClient(HttpClientName);
383+
// And cache should not be queried.
384+
_cache.DidNotReceive().GetOrCreateAsync<string>(
385+
Arg.Any<string>(),
386+
Arg.Any<Func<CancellationToken, ValueTask<string>>>(),
387+
Arg.Any<HybridCacheEntryOptions>(),
388+
Arg.Any<IEnumerable<string>>(),
389+
Arg.Any<CancellationToken>()
390+
);
391+
}
392+
else
393+
{
394+
// If cache was not bypassed, the HTTP client should not be called since we mocked a cache hit.
395+
_clientFactory.DidNotReceive().CreateClient(Arg.Any<string>());
396+
}
397+
}
398+
331399
private static string ComputeHash(string input)
332400
{
333401
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));

source/AAS.TwinEngine.DataEngine/ApplicationLogic/Services/AasRepository/AasRepositoryService.cs

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55
using AAS.TwinEngine.DataEngine.DomainModel.AasRegistry;
66
using AAS.TwinEngine.DataEngine.DomainModel.AasRepository;
77
using AAS.TwinEngine.DataEngine.DomainModel.Shared;
8+
using AAS.TwinEngine.DataEngine.ServiceConfiguration.Config;
89

910
using AasCore.Aas3_1;
1011

12+
using Microsoft.Extensions.Options;
13+
1114
using UnauthorizedAccessException = AAS.TwinEngine.DataEngine.ApplicationLogic.Exceptions.Infrastructure.UnauthorizedAccessException;
1215

1316
namespace AAS.TwinEngine.DataEngine.ApplicationLogic.Services.AasRepository;
@@ -16,8 +19,10 @@ public class AasRepositoryService(
1619
ILogger<AasRepositoryService> logger,
1720
IAasRepositoryTemplateService templateService,
1821
IPluginDataHandler pluginDataHandler,
19-
IPluginManifestConflictHandler pluginManifestConflictHandler) : IAasRepositoryService
22+
IPluginManifestConflictHandler pluginManifestConflictHandler,
23+
IOptions<TemplateManagementConfig> templateManagementConfig) : IAasRepositoryService
2024
{
25+
private readonly int _concurrentOperationsLimit = templateManagementConfig.Value.AasTemplateRepository.ConcurrentOperationsLimit;
2126
public async Task<Shells> GetShellsByFiltersAsync(ShellSearchFilter? filter, int? limit, string? cursor, CancellationToken cancellationToken)
2227
{
2328
try
@@ -239,30 +244,38 @@ private void FillShellFromMetadata(IAssetAdministrationShell shell, ShellDescrip
239244

240245
private async Task<List<IAssetAdministrationShell>> BuildShellsAsync(IEnumerable<ShellDescriptorMetaData> metadataItems, CancellationToken cancellationToken)
241246
{
242-
var shells = new List<IAssetAdministrationShell>();
247+
using var semaphore = new SemaphoreSlim(_concurrentOperationsLimit, _concurrentOperationsLimit);
243248

244-
foreach (var metadata in metadataItems)
249+
var tasks = metadataItems.Select(async metadata =>
245250
{
246251
if (string.IsNullOrWhiteSpace(metadata.Id))
247252
{
248-
continue;
253+
return null;
249254
}
250255

256+
await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
251257
try
252258
{
253259
var shell = await templateService.GetShellTemplateAsync(metadata.Id, cancellationToken).ConfigureAwait(false);
254260

255261
FillShellFromMetadata(shell, metadata);
256262

257-
shells.Add(shell);
263+
return shell;
258264
}
259265
catch (Exception ex)
260266
{
261267
logger.LogWarning(ex, "Failed to build AAS for id {AasId}. Skipping.", metadata.Id);
268+
return null;
262269
}
263-
}
270+
finally
271+
{
272+
_ = semaphore.Release();
273+
}
274+
});
275+
276+
var results = await Task.WhenAll(tasks).ConfigureAwait(false);
264277

265-
return shells;
278+
return [.. results.Where(s => s is not null).Select(s => s!)];
266279
}
267280

268281
private static IList<IAssetAdministrationShell> FilterByExternalSubjectId(IList<IAssetAdministrationShell> shells, IList<SpecificAssetId>? filters)

source/AAS.TwinEngine.DataEngine/Infrastructure/Http/Clients/Caching/CachedGetRequestClient.cs

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
using System.Net;
1+
using System.Net;
22
using System.Security.Claims;
33
using System.Security.Cryptography;
44
using System.Text;
@@ -24,6 +24,12 @@ public sealed class CachedGetRequestClient(
2424
{
2525
public async Task<string> GetStringAsync(string relativeUrl, string httpClientName, int expirationTime, CancellationToken cancellationToken)
2626
{
27+
if (!IsCacheEnabled(httpContextAccessor))
28+
{
29+
logger.LogInformation("Cache bypassed because 'isCacheEnable=false' was specified.");
30+
return await FetchAsync(relativeUrl, httpClientName, cancellationToken).ConfigureAwait(false);
31+
}
32+
2733
var cacheKey = BuildCacheKey(httpContextAccessor, relativeUrl);
2834

2935
var entryOptions = new HybridCacheEntryOptions
@@ -39,12 +45,12 @@ public async Task<string> GetStringAsync(string relativeUrl, string httpClientNa
3945
cancellationToken: cancellationToken).ConfigureAwait(false);
4046
}
4147

42-
private async Task<string> FetchAsync(string relativeUrl, string httpClientName, CancellationToken cancellationToken)
48+
private async Task<string> FetchAsync(string url, string httpClientName, CancellationToken cancellationToken)
4349
{
44-
logger.LogInformation("Sending HTTP GET request to {Url}", LogSanitizerExtension.Sanitize(relativeUrl));
50+
logger.LogInformation("Sending HTTP GET request to {Url}", LogSanitizerExtension.Sanitize(url));
4551

4652
var httpClient = clientFactory.CreateClient(httpClientName);
47-
var relativeUri = new Uri(relativeUrl, UriKind.Relative);
53+
var relativeUri = new Uri(url, UriKind.Relative);
4854

4955
var response = await httpClient.GetAsync(relativeUri, cancellationToken).ConfigureAwait(false);
5056

@@ -101,4 +107,21 @@ private static string ComputeHash(string input)
101107
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input));
102108
return Convert.ToHexStringLower(bytes);
103109
}
110+
111+
private static bool IsCacheEnabled(IHttpContextAccessor httpContextAccessor)
112+
{
113+
var query = httpContextAccessor.HttpContext?.Request.Query;
114+
115+
if (query is null)
116+
{
117+
return true;
118+
}
119+
120+
if (!query.TryGetValue("isCacheEnable", out var value))
121+
{
122+
return true;
123+
}
124+
125+
return !bool.TryParse(value, out var enabled) || enabled;
126+
}
104127
}

0 commit comments

Comments
 (0)