Skip to content

Commit a2a67de

Browse files
authored
Merge pull request #66 from PTCInc/feat(api)-cache-ProductInfo
Feat(api) cache product info
2 parents 1884824 + 9d5fd71 commit a2a67de

6 files changed

Lines changed: 127 additions & 17 deletions

File tree

Kepware.Api.Test/ApiClient/GetProductInfo.cs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,16 @@ public async Task GetProductInfoAsync_ShouldReturnProductInfo_WhenApiRespondsSuc
3232
Assert.Equal(240, result.ProductVersionBuild);
3333
Assert.Equal(0, result.ProductVersionPatch);
3434

35+
// Also verify that the ProductInfo property on the client is populated correctly
36+
Assert.NotNull(_kepwareApiClient.ProductInfo);
37+
Assert.Equal("012", _kepwareApiClient.ProductInfo.ProductId);
38+
Assert.Equal("KEPServerEX", _kepwareApiClient.ProductInfo.ProductName);
39+
Assert.Equal("V6.17.240.0", _kepwareApiClient.ProductInfo.ProductVersion);
40+
Assert.Equal(6, _kepwareApiClient.ProductInfo.ProductVersionMajor);
41+
Assert.Equal(17, _kepwareApiClient.ProductInfo.ProductVersionMinor);
42+
Assert.Equal(240, _kepwareApiClient.ProductInfo.ProductVersionBuild);
43+
Assert.Equal(0, _kepwareApiClient.ProductInfo.ProductVersionPatch);
44+
3545
}
3646

3747
#region GetProductInfoAsync - SupportsJsonProjectLoadService
@@ -57,6 +67,10 @@ public async Task GetProductInfoAsync_ShouldReturnCorrect_SupportsJsonProjectLoa
5767
// Assert
5868
Assert.NotNull(result);
5969
Assert.Equal(expectedResult, result.SupportsJsonProjectLoadService);
70+
71+
// Also verify that the ProductInfo property on the client is populated correctly
72+
Assert.NotNull(_kepwareApiClient.ProductInfo);
73+
Assert.Equal(expectedResult, _kepwareApiClient.ProductInfo.SupportsJsonProjectLoadService);
6074
}
6175

6276
#endregion
@@ -99,6 +113,9 @@ public async Task GetProductInfoAsync_ShouldReturnNull_WhenApiReturnsError()
99113

100114
// Assert
101115
Assert.Null(result);
116+
117+
// ProductInfo property should also be null on error
118+
Assert.Null(_kepwareApiClient.ProductInfo);
102119
}
103120

104121
[Fact]
@@ -114,6 +131,9 @@ public async Task GetProductInfoAsync_ShouldReturnNull_WhenApiReturnsInvalidJson
114131

115132
// Assert
116133
Assert.Null(result);
134+
135+
// ProductInfo property should also be null on error
136+
Assert.Null(_kepwareApiClient.ProductInfo);
117137
}
118138

119139
[Fact]
@@ -128,6 +148,9 @@ public async Task GetProductInfoAsync_ShouldReturnNull_OnHttpRequestException()
128148

129149
// Assert
130150
Assert.Null(result);
151+
152+
// ProductInfo property should also be null on error
153+
Assert.Null(_kepwareApiClient.ProductInfo);
131154
}
132155
#endregion
133156
}

Kepware.Api.TestIntg/ApiClient/GetProductInfo.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ public async Task GetProductInfoAsync_ShouldReturnProductInfo_WhenApiRespondsSuc
3030
Assert.Equal(_productInfo.ProductVersionBuild, result.ProductVersionBuild);
3131
Assert.Equal(_productInfo.ProductVersionPatch, result.ProductVersionPatch);
3232

33+
// Also verify that the ProductInfo property on the client is updated
34+
Assert.NotNull(_kepwareApiClient.ProductInfo);
35+
Assert.Equal(_productInfo.ProductId, _kepwareApiClient.ProductInfo.ProductId);
36+
Assert.Equal(_productInfo.ProductName, _kepwareApiClient.ProductInfo.ProductName);
37+
Assert.Equal(_productInfo.ProductVersion, _kepwareApiClient.ProductInfo.ProductVersion);
38+
Assert.Equal(_productInfo.ProductVersionMajor, _kepwareApiClient.ProductInfo.ProductVersionMajor);
39+
Assert.Equal(_productInfo.ProductVersionMinor, _kepwareApiClient.ProductInfo.ProductVersionMinor);
40+
Assert.Equal(_productInfo.ProductVersionBuild, _kepwareApiClient.ProductInfo.ProductVersionBuild);
41+
Assert.Equal(_productInfo.ProductVersionPatch, _kepwareApiClient.ProductInfo.ProductVersionPatch);
3342
}
3443

3544
}

Kepware.Api.TestIntg/appsettings.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
"IntegrationTest": true,
44
"TestServer": {
55
"Host": "https://localhost",
6-
"Port": 57513,
6+
"Port": 57512,
77
"UserName": "Administrator",
8-
"Password": "Kepware400400400"
8+
"Password": ""
99
}
1010
}
1111
}

Kepware.Api/ClientHandler/GenericApiHandler.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -788,7 +788,7 @@ public async Task<bool> DeleteItemsAsync<T, K>(List<K> items, NamedEntity? owner
788788
}
789789
#endregion
790790

791-
#region private methods
791+
#region private / internal methods
792792

793793
#region deserialize
794794
protected Task<K?> DeserializeJsonAsync<K>(HttpResponseMessage httpResponse, CancellationToken cancellationToken = default)
@@ -825,6 +825,20 @@ public async Task<bool> DeleteItemsAsync<T, K>(List<K> items, NamedEntity? owner
825825
}
826826
#endregion
827827

828+
/// <summary>
829+
/// Clears any internal caches (supported drivers, supported channels/devices).
830+
/// Called when the underlying connection is lost so subsequent calls re-fetch data.
831+
/// </summary>
832+
internal void InvalidateCaches()
833+
{
834+
// drop cached drivers so next call re-loads from /doc endpoint
835+
m_cachedSupportedDrivers = null;
836+
837+
// clear cached channel/device property dictionaries
838+
m_cachedSupportedChannels.Clear();
839+
m_cachedSupportedDevices.Clear();
840+
}
841+
828842
#endregion
829843

830844
}

Kepware.Api/ClientHandler/ProjectApiHandler.cs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,23 @@ public async Task<Project> LoadProject(bool blnLoadFullProject = false, Cancella
230230
{
231231
Stopwatch stopwatch = Stopwatch.StartNew();
232232

233+
var project = await m_kepwareApiClient.GenericConfig.LoadEntityAsync<Project>(cancellationToken: cancellationToken).ConfigureAwait(false);
234+
235+
if (project == null)
236+
{
237+
m_logger.LogWarning("Failed to load project");
238+
project = new Project();
239+
}
240+
241+
// If not loading full project, return with just project properties.
242+
if (!blnLoadFullProject)
243+
{
244+
m_logger.LogInformation("Loaded project properties in {ElapsedMilliseconds} ms", stopwatch.ElapsedMilliseconds);
245+
return project;
246+
247+
}
248+
249+
233250
var productInfo = await m_kepwareApiClient.GetProductInfoAsync(cancellationToken).ConfigureAwait(false);
234251

235252
if (blnLoadFullProject && productInfo?.SupportsJsonProjectLoadService == true)
@@ -280,7 +297,7 @@ await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false
280297
}
281298
else
282299
{
283-
var project = await m_kepwareApiClient.GenericConfig.LoadEntityAsync<Project>(cancellationToken: cancellationToken).ConfigureAwait(false);
300+
project = await m_kepwareApiClient.GenericConfig.LoadEntityAsync<Project>(cancellationToken: cancellationToken).ConfigureAwait(false);
284301

285302
if (project == null)
286303
{

Kepware.Api/KepwareApiClient.cs

Lines changed: 60 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ public partial class KepwareApiClient : IKepwareDefaultValueProvider
3030
/// The value for an unknown client or hostname.
3131
/// </summary>
3232
public const string UNKNOWN = "Unknown";
33+
3334
private const string ENDPOINT_STATUS = "/config/v1/status";
3435
private const string ENDPOINT_DOC = "/config/v1/doc";
3536
private const string ENDPOINT_ABOUT = "/config/v1/about";
@@ -40,6 +41,7 @@ public partial class KepwareApiClient : IKepwareDefaultValueProvider
4041

4142
private bool? m_isConnected = null;
4243
private bool? m_hasValidCredentials = null;
44+
private ProductInfo? m_productInfo = null;
4345

4446
/// <summary>
4547
/// Gets the name of the client instance.
@@ -51,6 +53,14 @@ public partial class KepwareApiClient : IKepwareDefaultValueProvider
5153
/// </summary>
5254
public string ClientHostName => m_httpClient.BaseAddress?.Host ?? UNKNOWN;
5355

56+
/// <summary>
57+
/// Gets the product information of the connected Kepware server, which includes
58+
/// product name and version information. This caches the value during <see cref="KepwareApiClient.TestConnectionAsync(CancellationToken)"/>
59+
/// and <see cref="KepwareApiClient.GetProductInfoAsync(CancellationToken)"/> and cached for future use.
60+
/// It will return null if there is no cached value.
61+
/// </summary>
62+
public ProductInfo? ProductInfo => m_productInfo;
63+
5464
/// <summary>
5565
/// Gets the client options for the Kepware server connection.
5666
/// </summary>
@@ -141,7 +151,7 @@ public async Task<bool> TestConnectionAsync(CancellationToken cancellationToken
141151
if (!response.IsSuccessStatusCode)
142152
{
143153
m_logger.LogWarning("Failed to connect to {ClientName}-client at {BaseAddress}, Reason: {ReasonPhrase}", ClientName, m_httpClient.BaseAddress, response.ReasonPhrase);
144-
m_isConnected = null; // set connection state to null if we cannot connect
154+
ClearConnectionState(); // set connection state to null if we cannot connect
145155
return false; // connection failed
146156
}
147157

@@ -155,7 +165,7 @@ await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false
155165
if (status?.FirstOrDefault()?.Healthy == false)
156166
{
157167
m_logger.LogWarning("Failed to connect to {ClientName}-client at {BaseAddress}, Reason: {String}", ClientName, m_httpClient.BaseAddress, "Server Status Check Failed");
158-
m_isConnected = null; // set connection state to null if we cannot connect
168+
ClearConnectionState(); // set connection state to null if we cannot connect
159169
return false; // connection failed
160170
}
161171

@@ -167,12 +177,12 @@ await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false
167177

168178
// Inital connection attempt or a reconnection due to failure,
169179
// we need to check the product info and credentials
170-
var prodInfo = await GetProductInfoAsync(cancellationToken).ConfigureAwait(false);
180+
_ = await GetProductInfoAsync(cancellationToken).ConfigureAwait(false);
171181

172182
// If we cannot get the product info, we assume the connection is not healthy
173-
if (prodInfo == null)
183+
if (m_productInfo == null)
174184
{
175-
m_isConnected = null; // set connection state to null if we cannot get product info
185+
ClearConnectionState(); // set connection state to null if we cannot get product info
176186
return false;
177187
}
178188

@@ -182,12 +192,12 @@ await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false
182192
// If we do not have valid credentials, we assume the connection is not healthy
183193
if (m_hasValidCredentials != true)
184194
{
185-
m_isConnected = null; // set connection state to null if we cannot connect or credentials are invalid
195+
ClearConnectionState(); // set connection state to null if we cannot connect or credentials are invalid
186196
m_logger.LogWarning("Connection to {ClientName}-client at {BaseAddress} failed because credentials are invalid", ClientName, m_httpClient.BaseAddress);
187197
return false;
188198
}
189199

190-
m_logger.LogInformation("Successfully connected to {ClientName}-client: {ProductName} {ProductVersion} on {BaseAddress}", ClientName, prodInfo?.ProductName, prodInfo?.ProductVersion, m_httpClient.BaseAddress);
200+
m_logger.LogInformation("Successfully connected to {ClientName}-client: {ProductName} {ProductVersion} on {BaseAddress}", ClientName, m_productInfo?.ProductName, m_productInfo?.ProductVersion, m_httpClient.BaseAddress);
191201

192202
m_isConnected = true; // set connection state to true if we have a valid product info and credentials
193203
return m_isConnected.Value; // return true if we have a valid connection
@@ -205,21 +215,29 @@ await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false
205215

206216
/// <summary>
207217
/// Gets the product information from the Kepware server which includes product name and version information.
218+
/// Will update the client's product info property, which can be used in other calls to avoid calling the API multiple times for the same information.
208219
/// Uses the /config/v1/about endpoint
209220
/// </summary>
210221
/// <param name="cancellationToken">The cancellation token.</param>
211-
/// <returns>A task that represents the asynchronous operation. The task result contains the product information.</returns>
222+
/// <returns>A task that represents the asynchronous operation. The task result contains the product information. <see cref="Kepware.Api.Model.ProductInfo"/></returns>
212223
public async Task<ProductInfo?> GetProductInfoAsync(CancellationToken cancellationToken = default)
213224
{
225+
if (m_productInfo != null)
226+
{
227+
// return cached product info if we have it
228+
return m_productInfo;
229+
}
230+
214231
try
215232
{
216233
var response = await m_httpClient.GetAsync(ENDPOINT_ABOUT, cancellationToken).ConfigureAwait(false);
217234
if (response.IsSuccessStatusCode)
218235
{
219236
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
220-
var prodInfo = JsonSerializer.Deserialize(content, KepJsonContext.Default.ProductInfo);
221-
222-
return prodInfo;
237+
238+
// Set Product Info for the client if we have a valid response, so we can use it in other calls without needing to call the API again
239+
m_productInfo = JsonSerializer.Deserialize(content, KepJsonContext.Default.ProductInfo);
240+
return m_productInfo;
223241
}
224242
else
225243
{
@@ -235,6 +253,8 @@ await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false
235253
m_logger.LogWarning(jsonEx, "Failed to parse ProductInfo from {BaseAddress}", m_httpClient.BaseAddress);
236254
}
237255

256+
// If we cannot get the product info, we set it to null and return null
257+
m_productInfo = null;
238258
return null;
239259
}
240260

@@ -293,14 +313,41 @@ async Task<ReadOnlyDictionary<string, JsonElement>> IKepwareDefaultValueProvider
293313
}
294314
#endregion
295315

296-
#region internal
316+
#region Private / internal helper methods
317+
/// <summary>
318+
/// Clears all client-level connection state and optionally handler caches.
319+
/// Call this whenever the connection should be considered lost or stale.
320+
/// </summary>
321+
/// <param name="clearCredentials">If true also clears cached credential validation state.</param>
322+
private void ClearConnectionState(bool clearCredentials = true)
323+
{
324+
// Clear derived product info and connection flags
325+
m_productInfo = null;
326+
m_isConnected = null;
327+
328+
// Optionally clear credential status so next TestConnection re-evaluates
329+
if (clearCredentials)
330+
m_hasValidCredentials = null;
331+
332+
// Invalidate caches on handlers that keep them
333+
try
334+
{
335+
// GenericConfig may implement an InvalidateCaches method (see suggestion below)
336+
(GenericConfig as ClientHandler.GenericApiHandler)?.InvalidateCaches();
337+
}
338+
catch
339+
{
340+
// swallow - defensive: don't throw from a state-clear helper
341+
}
342+
}
343+
297344
/// <summary>
298345
/// Invoked by Handler, when they receice a http request exception
299346
/// </summary>
300347
/// <param name="httpEx"></param>
301348
internal void OnHttpRequestException(HttpRequestException httpEx)
302349
{
303-
m_isConnected = null;
350+
ClearConnectionState(false);
304351
}
305352
#endregion
306353
}

0 commit comments

Comments
 (0)