Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions dotnet/HeimdallPower.Api.Client.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
</Configurations>
<Folder Name="/examples/">
<Project Path="examples/Api.Client.Examples/Api.Client.Examples.csproj" />
<Project Path="examples/Stream.Client.Examples/Stream.Client.Examples.csproj" />
</Folder>
<Folder Name="/tests/" />
<Folder Name="/tests/integration/">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using HeimdallPower.Api.Client.Stream;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;

namespace HeimdallPower.Api.Client.Extensions;

/// <summary>
/// Extension methods for adding the Heimdall Power stream client to the service collection.
/// </summary>
public static class HeimdallStreamClientExtensions
{
/// <summary>
/// Registers <see cref="IHeimdallStreamClient"/> as a singleton, configured via <paramref name="configureOptions"/>.
/// </summary>
/// <remarks>
/// The stream connection reconnects with exponential backoff internally, handled by <see cref="HeimdallStreamClient"/>.
/// </remarks>
/// <param name="services">The service collection to add the client to.</param>
/// <param name="configureOptions">Callback used to set the <see cref="HeimdallStreamClientOptions"/>, such as client credentials and optional proxy settings.</param>
/// <returns>The same service collection, for chaining.</returns>
public static IServiceCollection AddHeimdallPowerStreamClient(this IServiceCollection services, Action<HeimdallStreamClientOptions> configureOptions)
{
const string clientName = "HeimdallPowerStream";

services.Configure(configureOptions);

services.AddHttpClient(clientName)
.ConfigureHttpClient((_, client) =>
{
client.BaseAddress = new Uri("https://stream-api.heimdallcloud.com");
})
.ConfigurePrimaryHttpMessageHandler(sp =>
{
var options = sp.GetRequiredService<IOptions<HeimdallStreamClientOptions>>().Value;
return ProxyHandlerFactory.CreateHandler(options.Proxy) ?? new HttpClientHandler();
});

services.AddSingleton(sp =>
{
var options = sp.GetRequiredService<IOptions<HeimdallStreamClientOptions>>().Value;
var httpClientFactory = sp.GetRequiredService<IHttpClientFactory>();
var httpClient = httpClientFactory.CreateClient(clientName);
var proxyHandler = ProxyHandlerFactory.CreateHandler(options.Proxy);

return new HeimdallStreamClient(options.ClientId, options.ClientSecret, httpClient, options.ClientMetadata, proxyHandler, options.RetryPolicy);
});

services.AddSingleton<IHeimdallStreamClient>(sp => sp.GetRequiredService<HeimdallStreamClient>());

return services;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using HeimdallPower.Api.Client.Stream;

namespace HeimdallPower.Api.Client.Extensions;

/// <summary>
/// Options for configuring the Heimdall Power stream client.
/// </summary>
public class HeimdallStreamClientOptions
{
/// <summary>
/// The client ID for the Heimdall Power API.
/// </summary>
public required string ClientId { get; set; }

/// <summary>
/// The client secret for the Heimdall Power API.
/// </summary>
public required string ClientSecret { get; set; }

/// <summary>
/// Additional metadata to include in the request headers.
/// </summary>
public Dictionary<string, string>? ClientMetadata { get; set; }

/// <summary>
/// Optional proxy configuration. When set, all HTTP requests (stream connection and token acquisition)
/// are routed through the specified proxy.
/// </summary>
public ProxyOptions? Proxy { get; set; }

/// <summary>
/// Optional configuration for the reconnect backoff behavior used when the stream connection drops.
/// </summary>
public StreamConnectionRetryPolicyOptions? RetryPolicy { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
namespace HeimdallPower.Api.Client;

/// <summary>
/// Keeps an <see cref="HttpClient"/>'s auth and client headers fresh, coalescing concurrent
/// refresh attempts behind a single in-flight token request.
/// </summary>
internal sealed class AccessTokenHeaderRefresher(
IAccessTokenProvider accessTokenProvider,
HttpClient httpClient,
Dictionary<string, string>? clientMetadata = null)
{
private static readonly TimeSpan TokenExpirationBuffer = TimeSpan.FromMinutes(2);

private readonly SemaphoreSlim _tokenLock = new(1, 1);
private DateTimeOffset _tokenExpiresOn;

private bool IsTokenFresh => _tokenExpiresOn != default && DateTimeOffset.UtcNow.Add(TokenExpirationBuffer) <= _tokenExpiresOn;

/// <summary>Refreshes the token only if it is missing or within the expiration buffer.</summary>
public Task EnsureFreshTokenAsync(CancellationToken cancellationToken) =>
IsTokenFresh ? Task.CompletedTask : RefreshAsync(force: false, cancellationToken);

/// <summary>Refreshes the token unconditionally, e.g. after the server rejects it as unauthorized.</summary>
public Task ForceRefreshAsync(CancellationToken cancellationToken) => RefreshAsync(force: true, cancellationToken);

private async Task RefreshAsync(bool force, CancellationToken cancellationToken)
{
await _tokenLock.WaitAsync(TimeSpan.FromSeconds(30), cancellationToken);
Comment thread
aholmis marked this conversation as resolved.
try
{
if (!force && IsTokenFresh)
return; // Another caller already refreshed while we were waiting.

await accessTokenProvider.AcquireTokenAsync(cancellationToken);
_tokenExpiresOn = accessTokenProvider.GetTokenExpiry();

foreach (var header in accessTokenProvider.GetAccessHeaders())
{
httpClient.DefaultRequestHeaders.Remove(header.Key);
httpClient.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value);
}

foreach (var header in ClientHeaders.Build(clientMetadata))
{
if (header.Key.Equals("x-region", StringComparison.OrdinalIgnoreCase))
continue; // x-region comes from the token, not client metadata

httpClient.DefaultRequestHeaders.Remove(header.Key);
httpClient.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value);
}
}
finally
{
_tokenLock.Release();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
using System.Reflection;

namespace HeimdallPower.Api.Client;

/// <summary>
/// Builds the x-client-name/x-client-version (plus any caller-supplied metadata) headers sent by every SDK client.
/// </summary>
internal static class ClientHeaders
{
private const string ClientName = "dotnet-sdk";
private static readonly string AssemblyVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0";

public static Dictionary<string, string> Build(Dictionary<string, string>? clientMetadata)
{
var headers = new Dictionary<string, string>
{
{ "x-client-name", ClientName },
{ "x-client-version", AssemblyVersion },
};

if (clientMetadata != null)
{
foreach (var kvp in clientMetadata)
{
headers[kvp.Key] = kvp.Value; // Overwrite defaults if present
}
}

return headers;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,6 @@ namespace HeimdallPower.Api.Client;
/// </summary>
public class HeimdallApiClient : IHeimdallApiClient
{
private const string ApiUrl = "https://external-api.heimdallcloud.com";
private const string Policy = "B2C_1A_CLIENTCREDENTIALSFLOW";
private const string Instance = "https://hpadb2cprod.b2clogin.com";
private const string Domain = "hpadb2cprod.onmicrosoft.com";
private const string Scope = $"https://{Domain}/dc5758ae-4eea-416e-9e61-812914d9a49a/.default";
private const string Authority = $"{Instance}/tfp/{Domain}/{Policy}";
private readonly HeimdallApiHttpClient _heimdallApiClient;

/// <summary>
Expand All @@ -27,8 +21,8 @@ public class HeimdallApiClient : IHeimdallApiClient
/// </summary>
public HeimdallApiClient(string clientId, string clientSecret, HttpClient? httpClient = null, Dictionary<string, string>? clientMetadata = null, HttpMessageHandler? proxyHandler = null)
{
var accessTokenProvider = new AccessTokenProvider(clientId, clientSecret, Authority, Scope, proxyHandler);
_heimdallApiClient = new HeimdallApiHttpClient(accessTokenProvider, httpClient ?? new HttpClient { BaseAddress = new Uri(ApiUrl) }, clientMetadata);
var accessTokenProvider = new AccessTokenProvider(clientId, clientSecret, HeimdallApiEndpoints.Authority, HeimdallApiEndpoints.Scope, proxyHandler);
_heimdallApiClient = new HeimdallApiHttpClient(accessTokenProvider, httpClient ?? new HttpClient { BaseAddress = new Uri(HeimdallApiEndpoints.ApiUrl) }, clientMetadata);
}

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace HeimdallPower.Api.Client;

/// <summary>
/// Endpoint/authority constants shared by every client (REST, streaming) in this SDK.
/// </summary>
internal static class HeimdallApiEndpoints
{
public const string ApiUrl = "https://external-api.heimdallcloud.com";
public const string StreamUrl = "https://stream-api.heimdallcloud.com";
private const string Policy = "B2C_1A_CLIENTCREDENTIALSFLOW";
private const string Instance = "https://hpadb2cprod.b2clogin.com";
private const string Domain = "hpadb2cprod.onmicrosoft.com";
public const string Scope = $"https://{Domain}/dc5758ae-4eea-416e-9e61-812914d9a49a/.default";
public const string Authority = $"{Instance}/tfp/{Domain}/{Policy}";
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
using System.Net;
using System.Reflection;
using System.Text.Json;
using System.Text.RegularExpressions;

namespace HeimdallPower.Api.Client;

internal class HeimdallApiHttpClient(
IAccessTokenProvider accessTokenProvider,
HttpClient httpClient,
Dictionary<string, string>? clientMetadata = null)
internal class HeimdallApiHttpClient
{
private HttpClient HttpClient { get; } = httpClient;

private readonly SemaphoreSlim _tokenLock = new(1, 1);
private HttpClient HttpClient { get; }
private readonly AccessTokenHeaderRefresher _tokenRefresher;

private readonly JsonSerializerOptions _jsonSerializerOptions = new()
{
Expand All @@ -21,14 +16,17 @@ internal class HeimdallApiHttpClient(
WriteIndented = true
};

private DateTimeOffset _tokenExpiresOn;
private static readonly TimeSpan TokenExpirationBuffer = TimeSpan.FromMinutes(2);

private static readonly JsonSerializerOptions ProblemDetailsOptions = new()
{
PropertyNameCaseInsensitive = true
};

public HeimdallApiHttpClient(IAccessTokenProvider accessTokenProvider, HttpClient httpClient, Dictionary<string, string>? clientMetadata = null)
{
HttpClient = httpClient;
_tokenRefresher = new AccessTokenHeaderRefresher(accessTokenProvider, httpClient, clientMetadata);
}

public async Task<T> GetAsync<T>(string url, CancellationToken cancellationToken = default)
{
return await ExecuteWithAuthRetry(async () =>
Expand Down Expand Up @@ -92,82 +90,13 @@ private async Task<T> ExecuteWithAuthRetry<T>(Func<Task<T>> operationFunc, Cance
{
try
{
await UpdateAccessTokenIfExpired(cancellationToken);
await _tokenRefresher.EnsureFreshTokenAsync(cancellationToken);
return await operationFunc();
}
catch (UnauthorizedAccessException)
{
await RefreshAccessToken(cancellationToken);
await _tokenRefresher.ForceRefreshAsync(cancellationToken);
return await operationFunc();
}
}

private async Task UpdateAccessTokenIfExpired(CancellationToken cancellationToken)
{
if (_tokenExpiresOn == default || DateTimeOffset.UtcNow.Add(TokenExpirationBuffer) > _tokenExpiresOn)
{
await RefreshAccessToken(cancellationToken);
}
}

private static readonly string AssemblyVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0";
private const string ClientName = "dotnet-sdk";

/// <summary>
/// Builds the client headers to be sent with each request.
/// Includes client name, version, and any additional metadata provided.
/// </summary>
/// <returns></returns>
private Dictionary<string, string> BuildClientHeaders()
{
var headers = new Dictionary<string, string>
{
{ "x-client-name", ClientName },
{ "x-client-version", AssemblyVersion },
};

if (clientMetadata != null)
{
foreach (var kvp in clientMetadata)
{
headers[kvp.Key] = kvp.Value; // Overwrite defaults if present
}
}

return headers;
}

private async Task RefreshAccessToken(CancellationToken cancellationToken)
{
await _tokenLock.WaitAsync(TimeSpan.FromSeconds(30), cancellationToken);
try
{
// Check if another thread already refreshed while we were waiting
if (_tokenExpiresOn != default && DateTimeOffset.UtcNow.Add(TokenExpirationBuffer) <= _tokenExpiresOn)
return;

await accessTokenProvider.AcquireTokenAsync(cancellationToken);
_tokenExpiresOn = accessTokenProvider.GetTokenExpiry();

foreach (var header in accessTokenProvider.GetAccessHeaders())
{
HttpClient.DefaultRequestHeaders.Remove(header.Key);
HttpClient.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value);
}
foreach (var header in BuildClientHeaders())
{
if (header.Key.Equals("x-region", StringComparison.OrdinalIgnoreCase))
{
continue; // Skip adding x-region as this should be set from the token
}

HttpClient.DefaultRequestHeaders.Remove(header.Key);
HttpClient.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value);
}
}
finally
{
_tokenLock.Release();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace HeimdallPower.Api.Client.Stream.CapacityMonitoring.Lines;

/// <summary>
/// A Heimdall Dynamic Line Rating (DLR) reading for a span, as carried in <see cref="HeimdallEventEnvelope.Data"/>
/// when <see cref="HeimdallEventEnvelope.Metric"/> equals <see cref="EventName"/>.
/// </summary>
/// <param name="AtLineId">The ID of the line the span belongs to.</param>
/// <param name="AtSpanId">The ID of the span the reading applies to.</param>
/// <param name="Timestamp">The time the reading was taken.</param>
/// <param name="Value">The DLR value, in the unit specified by <see cref="HeimdallEventEnvelope.Unit"/>.</param>
/// <param name="IsFallback">Whether this is a fallback rating, used when a live DLR value is unavailable.</param>
public record HeimdallDlrEvent(
Guid AtLineId,
Guid AtSpanId,
DateTimeOffset Timestamp,
double Value,
bool IsFallback)
{
/// <summary>
/// The value of SSE 'event:' field for Heimdall DLR events.
/// </summary>
public const string EventName = "heimdall_dlr";
}
Loading