Skip to content
Open
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
07d60c1
add connect Cdn
SamSadfa Oct 22, 2024
c2fd979
Update src/Microsoft.Extensions.Configuration.AzureAppConfiguration/A…
samsadsam Oct 22, 2024
d16a344
make internal
SamSadfa Oct 22, 2024
c8b3535
Merge branch 'user/samisadfa/connect-cdn' of https://github.com/Azure…
SamSadfa Oct 22, 2024
f3d6b58
Merge branch 'main' into user/samisadfa/connect-cdn
SamSadfa Oct 31, 2024
a2e1c5d
add cdn client manager and api version policy
SamSadfa Oct 31, 2024
9b2bcc9
remove unused param
samsadsam Oct 31, 2024
bf85303
fix format
samsadsam Nov 4, 2024
7c4fddb
users cannot Connect/ConnectCdn at the same time
samsadsam Nov 4, 2024
d323ecd
fix assertion
samsadsam Nov 4, 2024
a2ff1b1
feedback
samsadsam Nov 7, 2024
5f16867
add cdn tracing
samsadsam Nov 7, 2024
65b81fc
merge preview
samsadsam Jan 23, 2025
0e2c621
merge with preview
samsadsam May 28, 2025
8fc28e8
remove api version policy
samsadsam May 28, 2025
2d73ebb
address comment
samsadsam May 28, 2025
ef09e70
add cdn cache busting policy + accessor for sentinel keys
samsadsam May 28, 2025
b8ca58e
now refresh with sentinel key works (tested)
samsadsam May 29, 2025
f1e4bed
move cdn client manager under cdn folder
samsadsam May 29, 2025
47a27ea
add comment
samsadsam May 29, 2025
491e948
add collection monitoring cdn cache busting support
samsadsam May 29, 2025
782dc33
change design to correct one
samsadsam May 30, 2025
dec256b
bug fix
samsadsam May 30, 2025
186288b
another bug fix
samsadsam May 30, 2025
dbfa697
iterate and implement new design
samsadsam Jun 3, 2025
cfc83ce
make code clearer
samsadsam Jun 3, 2025
3631731
nit
samsadsam Jun 3, 2025
2a7f79b
add purpose and simplify hash function
samsadsam Jun 3, 2025
d78de74
nit: cleanup and simplification
samsadsam Jun 3, 2025
43a83a0
nit: use nameof on exception message string
samsadsam Jun 3, 2025
7ff8e94
nit: Cdn.ConfigurationClientManager takes one cdn endpoint
samsadsam Jun 3, 2025
73be7e9
nit: _client -> _clientWrapper
samsadsam Jun 3, 2025
bd941f0
remove last newline, sort etags before
samsadsam Jun 4, 2025
b89f870
rename
samsadsam Jun 4, 2025
4f5108f
handle deleted sentinel kv case
samsadsam Jun 4, 2025
4fc69d3
redesign, no need to ensure state does not regress, eventual consiste…
samsadsam Jun 4, 2025
1d62185
disable load balancing and replica discovery for cdn scenario
samsadsam Jun 4, 2025
6528a36
nit: rename cdn classes accordingly
samsadsam Jun 5, 2025
a846f0f
nit: change HaveCollectionChanged to reflect its new role.
samsadsam Jun 5, 2025
c95e9ce
nit
samsadsam Jun 5, 2025
e1740c9
load balancing is not supported when cdn enabled
samsadsam Jun 5, 2025
29eed77
move load balacing check when cdn is enabled to source
samsadsam Jun 6, 2025
66eccf6
be clearer
samsadsam Jun 6, 2025
326d85b
address avani's comments
samsadsam Jun 6, 2025
984650e
adopt new pattern for tracing features
samsadsam Jun 6, 2025
e64307a
bug fix
samsadsam Jun 6, 2025
f19cb5d
address jimmy's refactor comment
samsadsam Jun 7, 2025
1803e6b
change implementation
samsadsam Jun 7, 2025
4a54b2e
nit
samsadsam Jun 7, 2025
e544048
nit: add new lines
samsadsam Jun 7, 2025
ad8c49f
Merge branch 'preview' into user/samisadfa/connect-cdn
samsadsam Jun 7, 2025
ad5ac9b
add refresh under cdn testing and fix bug
samsadsam Jun 7, 2025
33a9fb5
make tests more robust
samsadsam Jun 7, 2025
de1dc29
Merge branch 'user/samisadfa/connect-cdn' of https://github.com/Azure…
samsadsam Jun 7, 2025
1abb191
nit
samsadsam Jun 7, 2025
911f815
nits
samsadsam Jun 9, 2025
568a7e8
ensure test is inductive
samsadsam Jun 9, 2025
4880269
handle ConnectCdn with SetClientFactory scenario
samsadsam Jun 10, 2025
23acfff
nit: add ms license
samsadsam Jun 10, 2025
bd487dc
move all cdn related tests to cdn tests
samsadsam Jun 10, 2025
2c2a413
add parallel test
samsadsam Jun 10, 2025
d8f280a
fix bug
samsadsam Jun 10, 2025
ae0e448
tests: add delete sentinel key to test
samsadsam Jun 10, 2025
feaec53
done
samsadsam Jun 12, 2025
3f71dd4
done1
samsadsam Jun 12, 2025
c1d15aa
done2
samsadsam Jun 12, 2025
0292b78
Update src/Microsoft.Extensions.Configuration.AzureAppConfiguration/A…
samsadsam Jun 12, 2025
be6d888
done3
samsadsam Jun 12, 2025
cfb5cf6
Merge branch 'user/samisadfa/connect-cdn' of https://github.com/Azure…
samsadsam Jun 12, 2025
eef8180
donedone
samsadsam Jun 12, 2025
cae49ce
donedonedone
samsadsam Jun 23, 2025
39ddcae
remove auth header when connecting to cdn
samsadsam Jun 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,17 @@ internal IEnumerable<IKeyValueAdapter> Adapters
/// </summary>
internal IAzureClientFactory<ConfigurationClient> ClientFactory { get; private set; }

/// <summary>
/// Accessor for CDN cache busting context that manages ETag injection into requests.
/// When null, CDN cache busting is disabled. When not null, CDN cache busting is enabled.
/// </summary>
internal ICdnCacheBustingAccessor CdnCacheBustingAccessor { get; private set; }

/// <summary>
/// Gets a value indicating whether CDN is enabled.
/// </summary>
internal bool IsCdnEnabled => CdnCacheBustingAccessor != null;

/// <summary>
/// Initializes a new instance of the <see cref="AzureAppConfigurationOptions"/> class.
/// </summary>
Expand Down Expand Up @@ -339,6 +350,11 @@ public AzureAppConfigurationOptions Connect(string connectionString)
/// </param>
public AzureAppConfigurationOptions Connect(IEnumerable<string> connectionStrings)
{
if (IsCdnEnabled)
{
throw new InvalidOperationException("Cannot connect to both Azure App Configuration and CDN at the same time.");
}

if (connectionStrings == null || !connectionStrings.Any())
{
throw new ArgumentNullException(nameof(connectionStrings));
Expand All @@ -355,6 +371,35 @@ public AzureAppConfigurationOptions Connect(IEnumerable<string> connectionString
return this;
}

/// <summary>
/// Connect the provider to CDN endpoint.
/// </summary>
/// <param name="endpoint">The endpoint of the CDN instance to connect to.</param>
public AzureAppConfigurationOptions ConnectCdn(Uri endpoint)
{
if (IsCdnEnabled)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should make sure ReplicaDiscoveryEnabled is false when CDN is used.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't use this stuff when cdn is enabled

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean we should throw exception, when people set ReplicaDiscoveryEnabled = true.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do not care about this stuff when cdn is enabled. We don’t need to throw since we don’t use this variable, we completely ignore it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Look at CdnConfigurationClientManager vs ConfigurationClientManager

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jimmyca15 What if load balancing is enabled after calling ConnectCdn?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We would need to throw in the provider

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, need to add another check in src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationSource.cs when creating provider class.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, then we dont need the check in this method

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

{
throw new InvalidOperationException("Please call ConnectCdn() only once.");
}

if ((Credential != null) || (ConnectionStrings?.Any() ?? false))
{
throw new InvalidOperationException("Cannot connect to both Azure App Configuration and CDN at the same time.");
}

if (endpoint == null)
{
throw new ArgumentNullException(nameof(endpoint));
}

var result = Connect(new List<Uri>() { endpoint }, new EmptyTokenCredential());

CdnCacheBustingAccessor = new CdnCacheBustingAccessor();
ClientOptions.AddPolicy(new CdnCacheBustingPolicy(CdnCacheBustingAccessor), HttpPipelinePosition.PerCall);

return result;
}

/// <summary>
/// Connect the provider to Azure App Configuration using endpoint and token credentials.
/// </summary>
Expand Down Expand Up @@ -382,6 +427,11 @@ public AzureAppConfigurationOptions Connect(Uri endpoint, TokenCredential creden
/// <param name="credential">Token credential to use to connect.</param>
public AzureAppConfigurationOptions Connect(IEnumerable<Uri> endpoints, TokenCredential credential)
{
if (IsCdnEnabled)
{
throw new InvalidOperationException("Cannot connect to both Azure App Configuration and CDN at the same time.");
}

if (endpoints == null || !endpoints.Any())
{
throw new ArgumentNullException(nameof(endpoints));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
using System.Net.Http;
using System.Net.Sockets;
using System.Security;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
Expand All @@ -39,6 +40,11 @@ internal class AzureAppConfigurationProvider : ConfigurationProvider, IConfigura
private Dictionary<Uri, ConfigurationClientBackoffStatus> _configClientBackoffs = new Dictionary<Uri, ConfigurationClientBackoffStatus>();
private DateTimeOffset _nextCollectionRefreshTime;

#region Cdn
private string _configVersion = null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All we should need is a single etag. Doesn't matter if it's configuration or a feature flag.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about when feature flag collection changes and the config collection doesn’t scenario?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After looking, this optimization seems valid to me. No need to reload configuration if flags are changing.

private string _ffCollectionVersion = null;
#endregion

private readonly TimeSpan MinRefreshInterval;

// The most-recent time when the refresh operation attempted to load the initial configuration
Expand Down Expand Up @@ -280,8 +286,8 @@ public async Task RefreshAsync(CancellationToken cancellationToken)
List<KeyValueChange> keyValueChanges = null;
Dictionary<string, ConfigurationSetting> data = null;
Dictionary<string, ConfigurationSetting> ffCollectionData = null;
bool ffCollectionUpdated = false;
bool refreshAll = false;
string ffCollectionUpdatedChangedEtag = null;
string refreshAllChangedEtag = null;
StringBuilder logInfoBuilder = new StringBuilder();
StringBuilder logDebugBuilder = new StringBuilder();

Expand All @@ -294,8 +300,8 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) =>
keyValueChanges = new List<KeyValueChange>();
data = null;
ffCollectionData = null;
ffCollectionUpdated = false;
refreshAll = false;
ffCollectionUpdatedChangedEtag = null;
refreshAllChangedEtag = null;
logDebugBuilder.Clear();
logInfoBuilder.Clear();
Uri endpoint = _configClientManager.GetEndpointForClient(client);
Expand All @@ -305,7 +311,17 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) =>
// Get key value collection changes if RegisterAll was called
if (isRefreshDue)
{
refreshAll = await HaveCollectionsChanged(
if (_options.IsCdnEnabled)
{
if (_configVersion == null && _kvEtags.Count > 0)
{
_configVersion = CalculateHash(_kvEtags.SelectMany(kvp => kvp.Value.Select(mc => mc.IfNoneMatch.ToString())));
}

_options.CdnCacheBustingAccessor.CurrentToken = _configVersion;
}

refreshAllChangedEtag = await HaveCollectionsChanged(
_options.Selectors.Where(selector => !selector.IsFeatureFlagSelector),
_kvEtags,
client,
Expand All @@ -314,7 +330,17 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) =>
}
else
{
refreshAll = await RefreshIndividualKvWatchers(
if (_options.IsCdnEnabled)
{
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible for a deleted watched kv to have null etag. Then kvp.Value.ETag.ToString() will be a null reference exception.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks, added a fallback

if (_configVersion == null && _watchedIndividualKvs.Count > 0)
{
_configVersion = CalculateHash(_watchedIndividualKvs.Select(kvp => kvp.Value.ETag.ToString()));
}

_options.CdnCacheBustingAccessor.CurrentToken = _configVersion;
}

refreshAllChangedEtag = await RefreshIndividualKvWatchers(
client,
keyValueChanges,
refreshableIndividualKvWatchers,
Expand All @@ -324,22 +350,43 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) =>
cancellationToken).ConfigureAwait(false);
}

if (refreshAll)
if (refreshAllChangedEtag != null)
{
// Trigger a single load-all operation if a change was detected in one or more key-values with refreshAll: true,
// or if any key-value collection change was detected.
kvEtags = new Dictionary<KeyValueSelector, IEnumerable<MatchConditions>>();
ffEtags = new Dictionary<KeyValueSelector, IEnumerable<MatchConditions>>();
ffKeys = new HashSet<string>();

if (_options.IsCdnEnabled)
{
//
// Bust cdn cache
_options.CdnCacheBustingAccessor.CurrentToken = refreshAllChangedEtag;

// Reset versions so that next watch request will not use stale versions.
_configVersion = null;
_ffCollectionVersion = null;
}

data = await LoadSelected(client, kvEtags, ffEtags, _options.Selectors, ffKeys, cancellationToken).ConfigureAwait(false);
watchedIndividualKvs = await LoadKeyValuesRegisteredForRefresh(client, data, cancellationToken).ConfigureAwait(false);
logInfoBuilder.AppendLine(LogHelper.BuildConfigurationUpdatedMessage());
return;
}

// Get feature flag changes
ffCollectionUpdated = await HaveCollectionsChanged(
if (_options.IsCdnEnabled)
{
if (_ffCollectionVersion == null && _ffEtags.Count > 0)
{
_ffCollectionVersion = CalculateHash(_ffEtags.SelectMany(kvp => kvp.Value.Select(mc => mc.IfNoneMatch.ToString())));
}

_options.CdnCacheBustingAccessor.CurrentToken = _ffCollectionVersion;
}

ffCollectionUpdatedChangedEtag = await HaveCollectionsChanged(
refreshableFfWatchers.Select(watcher => new KeyValueSelector
{
KeyFilter = watcher.Key,
Expand All @@ -350,11 +397,20 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) =>
client,
cancellationToken).ConfigureAwait(false);

if (ffCollectionUpdated)
if (ffCollectionUpdatedChangedEtag != null)
{
ffEtags = new Dictionary<KeyValueSelector, IEnumerable<MatchConditions>>();
ffKeys = new HashSet<string>();

if (_options.IsCdnEnabled)
{
//
// Bust cdn cache
_options.CdnCacheBustingAccessor.CurrentToken = ffCollectionUpdatedChangedEtag;
// Reset ff collection version so that next ff watch request will not use stale version.
_ffCollectionVersion = null;
}

ffCollectionData = await LoadSelected(
client,
new Dictionary<KeyValueSelector, IEnumerable<MatchConditions>>(),
Expand All @@ -373,6 +429,9 @@ await ExecuteWithFailOverPolicyAsync(clients, async (client) =>
cancellationToken)
.ConfigureAwait(false);

bool refreshAll = !string.IsNullOrEmpty(refreshAllChangedEtag);
bool ffCollectionUpdated = !string.IsNullOrEmpty(ffCollectionUpdatedChangedEtag);

if (refreshAll)
{
_mappedData = await MapConfigurationSettings(data).ConfigureAwait(false);
Expand Down Expand Up @@ -940,7 +999,7 @@ private async Task<Dictionary<KeyValueIdentifier, ConfigurationSetting>> LoadKey
return watchedIndividualKvs;
}

private async Task<bool> RefreshIndividualKvWatchers(
private async Task<string> RefreshIndividualKvWatchers(
ConfigurationClient client,
List<KeyValueChange> keyValueChanges,
IEnumerable<KeyValueWatcher> refreshableIndividualKvWatchers,
Expand All @@ -963,7 +1022,7 @@ private async Task<bool> RefreshIndividualKvWatchers(
if (_watchedIndividualKvs.TryGetValue(watchedKeyLabel, out ConfigurationSetting watchedKv))
{
await TracingUtils.CallWithRequestTracing(_requestTracingEnabled, RequestType.Watch, _requestTracingOptions,
async () => change = await client.GetKeyValueChange(watchedKv, cancellationToken).ConfigureAwait(false)).ConfigureAwait(false);
async () => change = await client.GetKeyValueChange(watchedKv, makeConditionalRequest: !_options.IsCdnEnabled, cancellationToken).ConfigureAwait(false)).ConfigureAwait(false);
}
else
{
Expand Down Expand Up @@ -1000,7 +1059,14 @@ await CallWithRequestTracing(

if (kvWatcher.RefreshAll)
{
return true;
return change.Current.ETag.ToString();
}

if (_options.IsCdnEnabled)
{
//
// even if the change is not refresh all, we still need to reset stale version.
_configVersion = null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit confused as to what this is used for. It appears to be redundant with _options.CacheConsistencyTokenAccessor.Current

Copy link
Contributor Author

@samsadsam samsadsam Jun 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is _ffCollectionVersion as well

}
}
else
Expand All @@ -1009,7 +1075,7 @@ await CallWithRequestTracing(
}
}

return false;
return null;
}

private void SetData(IDictionary<string, string> data)
Expand Down Expand Up @@ -1065,7 +1131,8 @@ private void SetRequestTracingOptions()
IsKeyVaultConfigured = _options.IsKeyVaultConfigured,
IsKeyVaultRefreshConfigured = _options.IsKeyVaultRefreshConfigured,
FeatureFlagTracing = _options.FeatureFlagTracing,
IsLoadBalancingEnabled = _options.LoadBalancingEnabled
IsLoadBalancingEnabled = _options.LoadBalancingEnabled,
IsCdnEnabled = _options.IsCdnEnabled
};
}

Expand Down Expand Up @@ -1328,33 +1395,56 @@ private void UpdateClientBackoffStatus(Uri endpoint, bool successful)
_configClientBackoffs[endpoint] = clientBackoffStatus;
}

private async Task<bool> HaveCollectionsChanged(
private async Task<string> HaveCollectionsChanged(
IEnumerable<KeyValueSelector> selectors,
Dictionary<KeyValueSelector, IEnumerable<MatchConditions>> pageEtags,
ConfigurationClient client,
CancellationToken cancellationToken)
{
bool haveCollectionsChanged = false;
string changedEtag = null;

foreach (KeyValueSelector selector in selectors)
{
if (pageEtags.TryGetValue(selector, out IEnumerable<MatchConditions> matchConditions))
{
await TracingUtils.CallWithRequestTracing(_requestTracingEnabled, RequestType.Watch, _requestTracingOptions,
async () => haveCollectionsChanged = await client.HaveCollectionsChanged(
async () => changedEtag = await client.HaveCollectionsChanged(
selector,
matchConditions,
_options.ConfigurationSettingPageIterator,
makeConditionalRequest: !_options.IsCdnEnabled,
cancellationToken).ConfigureAwait(false)).ConfigureAwait(false);
}

if (haveCollectionsChanged)
if (changedEtag != null)
{
return true;
// If we have a changed ETag, we can stop checking further selectors
return changedEtag;
}
}

return haveCollectionsChanged;
return changedEtag;
}

private static string CalculateHash(IEnumerable<string> etags)
{
Debug.Assert(etags != null && etags.Any());

StringBuilder inputBuilder = new StringBuilder();

//
// purpose
inputBuilder.Append("etags\n");

foreach (string etag in etags)
{
inputBuilder.Append(etag);
inputBuilder.Append('\n');
}

using SHA256 sha256 = SHA256.Create();

return sha256.ComputeHash(Encoding.UTF8.GetBytes(inputBuilder.ToString())).ToBase64Url();
}

private async Task ProcessKeyValueChangesAsync(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,17 @@ public IConfigurationProvider Build(IConfigurationBuilder builder)
}
else
{
throw new ArgumentException($"Please call {nameof(AzureAppConfigurationOptions)}.{nameof(AzureAppConfigurationOptions.Connect)} to specify how to connect to Azure App Configuration.");
throw new ArgumentException($"Please call {nameof(AzureAppConfigurationOptions)}.{nameof(AzureAppConfigurationOptions.Connect)} or {nameof(AzureAppConfigurationOptions)}.{nameof(AzureAppConfigurationOptions.ConnectCdn)} to specify how to connect to Azure App Configuration.");
}

provider = new AzureAppConfigurationProvider(new ConfigurationClientManager(clientFactory, endpoints, options.ReplicaDiscoveryEnabled, options.LoadBalancingEnabled), options, _optional);
if (options.IsCdnEnabled)
{
provider = new AzureAppConfigurationProvider(new CdnConfigurationClientManager(clientFactory, endpoints), options, _optional);
}
else
{
provider = new AzureAppConfigurationProvider(new ConfigurationClientManager(clientFactory, endpoints, options.ReplicaDiscoveryEnabled, options.LoadBalancingEnabled), options, _optional);
}
}
catch (InvalidOperationException ex) // InvalidOperationException is thrown when any problems are found while configuring AzureAppConfigurationOptions or when SDK fails to create a configurationClient.
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
//
namespace Microsoft.Extensions.Configuration.AzureAppConfiguration
{
/// <summary>
/// Implementation of ICdnCacheBustingAccessor that manages the current token for cache busting.
/// </summary>
internal class CdnCacheBustingAccessor : ICdnCacheBustingAccessor
{
private string _currentToken;

/// <summary>
/// Gets or sets the current token value to be used for cache busting.
/// When null, CDN cache busting is disabled. When not null, the token will be injected into requests.
/// </summary>
public string CurrentToken
{
get => _currentToken;
set => _currentToken = value;
}
}
}
Loading