782 lines
31 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|