Files
git.stella-ops.org/src/Cli/StellaOps.Cli/Services/NotifyClient.cs

782 lines
31 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using StellaOps.Auth.Abstractions;
using StellaOps.Auth.Client;
using StellaOps.Cli.Configuration;
using StellaOps.Cli.Extensions;
using StellaOps.Cli.Services.Models;
namespace StellaOps.Cli.Services;
/// <summary>
/// Client for Notify API operations.
/// Per CLI-PARITY-41-002.
/// </summary>
internal sealed class NotifyClient : INotifyClient
{
private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
private static readonly TimeSpan TokenRefreshSkew = TimeSpan.FromSeconds(30);
private readonly HttpClient httpClient;
private readonly StellaOpsCliOptions options;
private readonly ILogger<NotifyClient> logger;
private readonly IStellaOpsTokenClient? tokenClient;
private readonly object tokenSync = new();
private string? cachedAccessToken;
private DateTimeOffset cachedAccessTokenExpiresAt = DateTimeOffset.MinValue;
public NotifyClient(
HttpClient httpClient,
StellaOpsCliOptions options,
ILogger<NotifyClient> logger,
IStellaOpsTokenClient? tokenClient = null)
{
this.httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
this.options = options ?? throw new ArgumentNullException(nameof(options));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
this.tokenClient = tokenClient;
if (!string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
{
if (Uri.TryCreate(options.BackendUrl, UriKind.Absolute, out var baseUri))
{
httpClient.BaseAddress = baseUri;
}
}
}
public async Task<NotifyChannelListResponse> ListChannelsAsync(
NotifyChannelListRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var uri = BuildChannelListUri(request);
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to list notify channels (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new NotifyChannelListResponse();
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<NotifyChannelListResponse>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new NotifyChannelListResponse();
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while listing notify channels");
return new NotifyChannelListResponse();
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while listing notify channels");
return new NotifyChannelListResponse();
}
}
public async Task<NotifyChannelDetail?> GetChannelAsync(
string channelId,
string? tenant,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(channelId);
try
{
EnsureConfigured();
var uri = $"/api/v1/notify/channels/{Uri.EscapeDataString(channelId)}";
if (!string.IsNullOrWhiteSpace(tenant))
{
uri += $"?tenant={Uri.EscapeDataString(tenant)}";
}
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to get notify channel (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return await JsonSerializer
.DeserializeAsync<NotifyChannelDetail>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while getting notify channel");
return null;
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while getting notify channel");
return null;
}
}
public async Task<NotifyChannelTestResult> TestChannelAsync(
NotifyChannelTestRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var json = JsonSerializer.Serialize(request, SerializerOptions);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/notify/channels/{Uri.EscapeDataString(request.ChannelId)}/test")
{
Content = content
};
await AuthorizeRequestAsync(httpRequest, "notify.write", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to test notify channel (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new NotifyChannelTestResult
{
Success = false,
ChannelId = request.ChannelId,
ErrorMessage = $"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"
};
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<NotifyChannelTestResult>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new NotifyChannelTestResult { Success = false, ChannelId = request.ChannelId, ErrorMessage = "Empty response" };
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while testing notify channel");
return new NotifyChannelTestResult
{
Success = false,
ChannelId = request.ChannelId,
ErrorMessage = $"Connection error: {ex.Message}"
};
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while testing notify channel");
return new NotifyChannelTestResult
{
Success = false,
ChannelId = request.ChannelId,
ErrorMessage = "Request timed out"
};
}
}
public async Task<NotifyRuleListResponse> ListRulesAsync(
NotifyRuleListRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var queryParams = new List<string>();
if (!string.IsNullOrWhiteSpace(request.Tenant))
{
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
}
if (request.Enabled.HasValue)
{
queryParams.Add($"enabled={request.Enabled.Value.ToString().ToLowerInvariant()}");
}
if (!string.IsNullOrWhiteSpace(request.EventType))
{
queryParams.Add($"eventType={Uri.EscapeDataString(request.EventType)}");
}
if (!string.IsNullOrWhiteSpace(request.ChannelId))
{
queryParams.Add($"channelId={Uri.EscapeDataString(request.ChannelId)}");
}
if (request.Limit.HasValue)
{
queryParams.Add($"limit={request.Limit.Value}");
}
if (request.Offset.HasValue)
{
queryParams.Add($"offset={request.Offset.Value}");
}
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
var uri = $"/api/v1/notify/rules{query}";
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to list notify rules (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new NotifyRuleListResponse();
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<NotifyRuleListResponse>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new NotifyRuleListResponse();
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while listing notify rules");
return new NotifyRuleListResponse();
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while listing notify rules");
return new NotifyRuleListResponse();
}
}
public async Task<NotifyDeliveryListResponse> ListDeliveriesAsync(
NotifyDeliveryListRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var queryParams = new List<string>();
if (!string.IsNullOrWhiteSpace(request.Tenant))
{
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
}
if (!string.IsNullOrWhiteSpace(request.ChannelId))
{
queryParams.Add($"channelId={Uri.EscapeDataString(request.ChannelId)}");
}
if (!string.IsNullOrWhiteSpace(request.Status))
{
queryParams.Add($"status={Uri.EscapeDataString(request.Status)}");
}
if (!string.IsNullOrWhiteSpace(request.EventType))
{
queryParams.Add($"eventType={Uri.EscapeDataString(request.EventType)}");
}
if (request.Since.HasValue)
{
queryParams.Add($"since={Uri.EscapeDataString(request.Since.Value.ToString("O", CultureInfo.InvariantCulture))}");
}
if (request.Until.HasValue)
{
queryParams.Add($"until={Uri.EscapeDataString(request.Until.Value.ToString("O", CultureInfo.InvariantCulture))}");
}
if (request.Limit.HasValue)
{
queryParams.Add($"limit={request.Limit.Value}");
}
if (!string.IsNullOrWhiteSpace(request.Cursor))
{
queryParams.Add($"cursor={Uri.EscapeDataString(request.Cursor)}");
}
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
var uri = $"/api/v1/notify/deliveries{query}";
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to list notify deliveries (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new NotifyDeliveryListResponse();
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<NotifyDeliveryListResponse>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new NotifyDeliveryListResponse();
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while listing notify deliveries");
return new NotifyDeliveryListResponse();
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while listing notify deliveries");
return new NotifyDeliveryListResponse();
}
}
public async Task<NotifyDeliveryDetail?> GetDeliveryAsync(
string deliveryId,
string? tenant,
CancellationToken cancellationToken)
{
ArgumentException.ThrowIfNullOrWhiteSpace(deliveryId);
try
{
EnsureConfigured();
var uri = $"/api/v1/notify/deliveries/{Uri.EscapeDataString(deliveryId)}";
if (!string.IsNullOrWhiteSpace(tenant))
{
uri += $"?tenant={Uri.EscapeDataString(tenant)}";
}
using var httpRequest = new HttpRequestMessage(HttpMethod.Get, uri);
await AuthorizeRequestAsync(httpRequest, "notify.read", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
return null;
}
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to get notify delivery (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return null;
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
return await JsonSerializer
.DeserializeAsync<NotifyDeliveryDetail>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while getting notify delivery");
return null;
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while getting notify delivery");
return null;
}
}
public async Task<NotifyRetryResult> RetryDeliveryAsync(
NotifyRetryRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var json = JsonSerializer.Serialize(request, SerializerOptions);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/v1/notify/deliveries/{Uri.EscapeDataString(request.DeliveryId)}/retry")
{
Content = content
};
// Add idempotency key header if present
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
{
httpRequest.Headers.Add("Idempotency-Key", request.IdempotencyKey);
}
await AuthorizeRequestAsync(httpRequest, "notify.write", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to retry notify delivery (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new NotifyRetryResult
{
Success = false,
DeliveryId = request.DeliveryId,
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
};
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<NotifyRetryResult>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new NotifyRetryResult { Success = false, DeliveryId = request.DeliveryId, Errors = ["Empty response"] };
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while retrying notify delivery");
return new NotifyRetryResult
{
Success = false,
DeliveryId = request.DeliveryId,
Errors = [$"Connection error: {ex.Message}"]
};
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while retrying notify delivery");
return new NotifyRetryResult
{
Success = false,
DeliveryId = request.DeliveryId,
Errors = ["Request timed out"]
};
}
}
public async Task<NotifySendResult> SendAsync(
NotifySendRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var json = JsonSerializer.Serialize(request, SerializerOptions);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v1/notify/send")
{
Content = content
};
// Add idempotency key header if present
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
{
httpRequest.Headers.Add("Idempotency-Key", request.IdempotencyKey);
}
await AuthorizeRequestAsync(httpRequest, "notify.write", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to send notification (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new NotifySendResult
{
Success = false,
Errors = [$"API returned {(int)response.StatusCode}: {response.ReasonPhrase}"]
};
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<NotifySendResult>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new NotifySendResult { Success = false, Errors = ["Empty response"] };
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while sending notification");
return new NotifySendResult
{
Success = false,
Errors = [$"Connection error: {ex.Message}"]
};
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while sending notification");
return new NotifySendResult
{
Success = false,
Errors = ["Request timed out"]
};
}
}
public async Task<NotifySimulationResult> SimulateAsync(
NotifySimulationRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var json = JsonSerializer.Serialize(request, SerializerOptions);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/api/v2/simulate")
{
Content = content
};
if (!string.IsNullOrWhiteSpace(request.TenantId))
{
httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.TenantId);
}
await AuthorizeRequestAsync(httpRequest, "notify.simulate", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to simulate notify rules (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new NotifySimulationResult { SimulationId = null, TotalEvents = 0, TotalRules = 0 };
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<NotifySimulationResult>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new NotifySimulationResult { SimulationId = null, TotalEvents = 0, TotalRules = 0 };
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while simulating notify rules");
return new NotifySimulationResult { SimulationId = null, TotalEvents = 0, TotalRules = 0 };
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while simulating notify rules");
return new NotifySimulationResult { SimulationId = null, TotalEvents = 0, TotalRules = 0 };
}
}
public async Task<NotifyAckResult> AckAsync(
NotifyAckRequest request,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(request);
try
{
EnsureConfigured();
var hasToken = !string.IsNullOrWhiteSpace(request.Token);
using var httpRequest = hasToken
? new HttpRequestMessage(HttpMethod.Get, $"/api/v2/ack?token={Uri.EscapeDataString(request.Token!)}")
: new HttpRequestMessage(HttpMethod.Post, "/api/v2/ack")
{
Content = new StringContent(JsonSerializer.Serialize(new AckApiRequestBody
{
TenantId = request.TenantId,
IncidentId = request.IncidentId,
AcknowledgedBy = request.AcknowledgedBy,
Comment = request.Comment
}, SerializerOptions), Encoding.UTF8, "application/json")
};
if (!string.IsNullOrWhiteSpace(request.TenantId))
{
httpRequest.Headers.TryAddWithoutValidation("X-Tenant-Id", request.TenantId);
}
await AuthorizeRequestAsync(httpRequest, "notify.write", cancellationToken).ConfigureAwait(false);
using var response = await httpClient.SendAsync(httpRequest, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var payload = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
logger.LogError(
"Failed to acknowledge notification (status {StatusCode}). Response: {Payload}",
(int)response.StatusCode,
string.IsNullOrWhiteSpace(payload) ? "<empty>" : payload);
return new NotifyAckResult { Success = false, IncidentId = request.IncidentId, Error = payload };
}
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var result = await JsonSerializer
.DeserializeAsync<NotifyAckResult>(stream, SerializerOptions, cancellationToken)
.ConfigureAwait(false);
return result ?? new NotifyAckResult { Success = true, IncidentId = request.IncidentId };
}
catch (HttpRequestException ex)
{
logger.LogError(ex, "HTTP error while acknowledging notification");
return new NotifyAckResult { Success = false, IncidentId = request.IncidentId, Error = ex.Message };
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
logger.LogError(ex, "Request timed out while acknowledging notification");
return new NotifyAckResult { Success = false, IncidentId = request.IncidentId, Error = "Request timed out" };
}
}
private sealed record AckApiRequestBody
{
public string? TenantId { get; init; }
public string? IncidentId { get; init; }
public string? AcknowledgedBy { get; init; }
public string? Comment { get; init; }
}
private static string BuildChannelListUri(NotifyChannelListRequest request)
{
var queryParams = new List<string>();
if (!string.IsNullOrWhiteSpace(request.Tenant))
{
queryParams.Add($"tenant={Uri.EscapeDataString(request.Tenant)}");
}
if (!string.IsNullOrWhiteSpace(request.Type))
{
queryParams.Add($"type={Uri.EscapeDataString(request.Type)}");
}
if (request.Enabled.HasValue)
{
queryParams.Add($"enabled={request.Enabled.Value.ToString().ToLowerInvariant()}");
}
if (request.Limit.HasValue)
{
queryParams.Add($"limit={request.Limit.Value}");
}
if (request.Offset.HasValue)
{
queryParams.Add($"offset={request.Offset.Value}");
}
if (!string.IsNullOrWhiteSpace(request.Cursor))
{
queryParams.Add($"cursor={Uri.EscapeDataString(request.Cursor)}");
}
var query = queryParams.Count > 0 ? "?" + string.Join("&", queryParams) : string.Empty;
return $"/api/v1/notify/channels{query}";
}
private void EnsureConfigured()
{
if (string.IsNullOrWhiteSpace(options.BackendUrl) && httpClient.BaseAddress is null)
{
throw new InvalidOperationException(
"Backend URL not configured. Set STELLAOPS_BACKEND_URL or use --backend-url.");
}
}
private async Task AuthorizeRequestAsync(HttpRequestMessage request, string scope, CancellationToken cancellationToken)
{
var token = await GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
if (!string.IsNullOrWhiteSpace(token))
{
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
}
}
private async Task<string?> GetAccessTokenAsync(string scope, CancellationToken cancellationToken)
{
if (tokenClient is null)
{
return null;
}
lock (tokenSync)
{
if (cachedAccessToken is not null && DateTimeOffset.UtcNow < cachedAccessTokenExpiresAt - TokenRefreshSkew)
{
return cachedAccessToken;
}
}
try
{
var result = await tokenClient.GetAccessTokenAsync(scope, cancellationToken).ConfigureAwait(false);
lock (tokenSync)
{
cachedAccessToken = result.AccessToken;
cachedAccessTokenExpiresAt = result.ExpiresAt;
}
return result.AccessToken;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Token acquisition failed");
return null;
}
}
}