up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
This commit is contained in:
654
src/Cli/StellaOps.Cli/Services/NotifyClient.cs
Normal file
654
src/Cli/StellaOps.Cli/Services/NotifyClient.cs
Normal file
@@ -0,0 +1,654 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
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.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"))}");
|
||||
}
|
||||
if (request.Until.HasValue)
|
||||
{
|
||||
queryParams.Add($"until={Uri.EscapeDataString(request.Until.Value.ToString("O"))}");
|
||||
}
|
||||
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"]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
var result = await tokenClient.GetTokenAsync(
|
||||
new StellaOpsTokenRequest { Scopes = [scope] },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
lock (tokenSync)
|
||||
{
|
||||
cachedAccessToken = result.AccessToken;
|
||||
cachedAccessTokenExpiresAt = result.ExpiresAt;
|
||||
}
|
||||
return result.AccessToken;
|
||||
}
|
||||
|
||||
logger.LogWarning("Token acquisition failed: {Error}", result.Error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user