Restructure solution layout by module
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Services;
|
||||
|
||||
internal interface INotifyChannelHealthService
|
||||
{
|
||||
Task<ChannelHealthResponse> CheckAsync(
|
||||
string tenantId,
|
||||
NotifyChannel channel,
|
||||
string traceId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class NotifyChannelHealthService : INotifyChannelHealthService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<NotifyChannelHealthService> _logger;
|
||||
private readonly IReadOnlyDictionary<NotifyChannelType, INotifyChannelHealthProvider> _providers;
|
||||
|
||||
public NotifyChannelHealthService(
|
||||
TimeProvider timeProvider,
|
||||
ILogger<NotifyChannelHealthService> logger,
|
||||
IEnumerable<INotifyChannelHealthProvider> providers)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_providers = BuildProviderMap(providers ?? Array.Empty<INotifyChannelHealthProvider>(), _logger);
|
||||
}
|
||||
|
||||
public async Task<ChannelHealthResponse> CheckAsync(
|
||||
string tenantId,
|
||||
NotifyChannel channel,
|
||||
string traceId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var target = ResolveTarget(channel);
|
||||
var timestamp = _timeProvider.GetUtcNow();
|
||||
var context = new ChannelHealthContext(
|
||||
tenantId,
|
||||
channel,
|
||||
target,
|
||||
timestamp,
|
||||
traceId);
|
||||
|
||||
ChannelHealthResult? providerResult = null;
|
||||
var providerName = "fallback";
|
||||
|
||||
if (_providers.TryGetValue(channel.Type, out var provider))
|
||||
{
|
||||
try
|
||||
{
|
||||
providerResult = await provider.CheckAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
providerName = provider.GetType().FullName ?? provider.GetType().Name;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Notify channel health provider {Provider} failed for tenant {TenantId}, channel {ChannelId} ({ChannelType}).",
|
||||
provider.GetType().FullName,
|
||||
tenantId,
|
||||
channel.ChannelId,
|
||||
channel.Type);
|
||||
providerResult = new ChannelHealthResult(
|
||||
ChannelHealthStatus.Degraded,
|
||||
"Channel health provider threw an exception. See logs for details.",
|
||||
new Dictionary<string, string>(StringComparer.Ordinal));
|
||||
}
|
||||
}
|
||||
|
||||
var metadata = MergeMetadata(context, providerName, providerResult?.Metadata);
|
||||
var status = providerResult?.Status ?? ChannelHealthStatus.Healthy;
|
||||
var message = providerResult?.Message ?? "Channel metadata returned without provider-specific diagnostics.";
|
||||
|
||||
var response = new ChannelHealthResponse(
|
||||
tenantId,
|
||||
channel.ChannelId,
|
||||
status,
|
||||
message,
|
||||
timestamp,
|
||||
traceId,
|
||||
metadata);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Notify channel health generated for tenant {TenantId}, channel {ChannelId} ({ChannelType}) using provider {Provider}.",
|
||||
tenantId,
|
||||
channel.ChannelId,
|
||||
channel.Type,
|
||||
providerName);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<NotifyChannelType, INotifyChannelHealthProvider> BuildProviderMap(
|
||||
IEnumerable<INotifyChannelHealthProvider> providers,
|
||||
ILogger logger)
|
||||
{
|
||||
var map = new Dictionary<NotifyChannelType, INotifyChannelHealthProvider>();
|
||||
foreach (var provider in providers)
|
||||
{
|
||||
if (provider is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (map.TryGetValue(provider.ChannelType, out var existing))
|
||||
{
|
||||
logger?.LogWarning(
|
||||
"Multiple Notify channel health providers registered for {ChannelType}. Keeping {ExistingProvider} and ignoring {NewProvider}.",
|
||||
provider.ChannelType,
|
||||
existing.GetType().FullName,
|
||||
provider.GetType().FullName);
|
||||
continue;
|
||||
}
|
||||
|
||||
map[provider.ChannelType] = provider;
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static string ResolveTarget(NotifyChannel channel)
|
||||
{
|
||||
var target = channel.Config.Target ?? channel.Config.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
return channel.Name;
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> MergeMetadata(
|
||||
ChannelHealthContext context,
|
||||
string providerName,
|
||||
IReadOnlyDictionary<string, string>? providerMetadata)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["channelType"] = context.Channel.Type.ToString().ToLowerInvariant(),
|
||||
["target"] = context.Target,
|
||||
["previewProvider"] = providerName,
|
||||
["traceId"] = context.TraceId,
|
||||
["channelEnabled"] = context.Channel.Enabled.ToString()
|
||||
};
|
||||
|
||||
foreach (var label in context.Channel.Labels)
|
||||
{
|
||||
metadata[$"label.{label.Key}"] = label.Value;
|
||||
}
|
||||
|
||||
if (providerMetadata is not null)
|
||||
{
|
||||
foreach (var pair in providerMetadata)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
metadata[pair.Key.Trim()] = pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Engine;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Services;
|
||||
|
||||
internal interface INotifyChannelTestService
|
||||
{
|
||||
Task<ChannelTestSendResponse> SendAsync(
|
||||
string tenantId,
|
||||
NotifyChannel channel,
|
||||
ChannelTestSendRequest request,
|
||||
string traceId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class NotifyChannelTestService : INotifyChannelTestService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<NotifyChannelTestService> _logger;
|
||||
private readonly IReadOnlyDictionary<NotifyChannelType, INotifyChannelTestProvider> _providers;
|
||||
|
||||
public NotifyChannelTestService(
|
||||
TimeProvider timeProvider,
|
||||
ILogger<NotifyChannelTestService> logger,
|
||||
IEnumerable<INotifyChannelTestProvider> providers)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_providers = BuildProviderMap(providers ?? Array.Empty<INotifyChannelTestProvider>(), _logger);
|
||||
}
|
||||
|
||||
public async Task<ChannelTestSendResponse> SendAsync(
|
||||
string tenantId,
|
||||
NotifyChannel channel,
|
||||
ChannelTestSendRequest request,
|
||||
string traceId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
throw new ChannelTestSendValidationException("Channel is disabled. Enable it before issuing test sends.");
|
||||
}
|
||||
|
||||
var target = ResolveTarget(channel, request.Target);
|
||||
var timestamp = _timeProvider.GetUtcNow();
|
||||
var previewRequest = BuildPreviewRequest(request);
|
||||
var context = new ChannelTestPreviewContext(
|
||||
tenantId,
|
||||
channel,
|
||||
target,
|
||||
previewRequest,
|
||||
timestamp,
|
||||
traceId);
|
||||
|
||||
ChannelTestPreviewResult? providerResult = null;
|
||||
var providerName = "fallback";
|
||||
|
||||
if (_providers.TryGetValue(channel.Type, out var provider))
|
||||
{
|
||||
try
|
||||
{
|
||||
providerResult = await provider.BuildPreviewAsync(context, cancellationToken).ConfigureAwait(false);
|
||||
providerName = provider.GetType().FullName ?? provider.GetType().Name;
|
||||
}
|
||||
catch (ChannelTestPreviewException ex)
|
||||
{
|
||||
throw new ChannelTestSendValidationException(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
var rendered = providerResult is not null
|
||||
? EnsureBodyHash(providerResult.Preview)
|
||||
: CreateFallbackPreview(context);
|
||||
|
||||
var metadata = MergeMetadata(
|
||||
context,
|
||||
providerName,
|
||||
providerResult?.Metadata);
|
||||
|
||||
var response = new ChannelTestSendResponse(
|
||||
tenantId,
|
||||
channel.ChannelId,
|
||||
rendered,
|
||||
timestamp,
|
||||
traceId,
|
||||
metadata);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Notify test send preview generated for tenant {TenantId}, channel {ChannelId} ({ChannelType}) using provider {Provider}.",
|
||||
tenantId,
|
||||
channel.ChannelId,
|
||||
channel.Type,
|
||||
providerName);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<NotifyChannelType, INotifyChannelTestProvider> BuildProviderMap(
|
||||
IEnumerable<INotifyChannelTestProvider> providers,
|
||||
ILogger logger)
|
||||
{
|
||||
var map = new Dictionary<NotifyChannelType, INotifyChannelTestProvider>();
|
||||
foreach (var provider in providers)
|
||||
{
|
||||
if (provider is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (map.TryGetValue(provider.ChannelType, out var existing))
|
||||
{
|
||||
logger?.LogWarning(
|
||||
"Multiple Notify channel test providers registered for {ChannelType}. Keeping {ExistingProvider} and ignoring {NewProvider}.",
|
||||
provider.ChannelType,
|
||||
existing.GetType().FullName,
|
||||
provider.GetType().FullName);
|
||||
continue;
|
||||
}
|
||||
|
||||
map[provider.ChannelType] = provider;
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static ChannelTestPreviewRequest BuildPreviewRequest(ChannelTestSendRequest request)
|
||||
{
|
||||
return new ChannelTestPreviewRequest(
|
||||
TrimToNull(request.Target),
|
||||
TrimToNull(request.TemplateId),
|
||||
TrimToNull(request.Title),
|
||||
TrimToNull(request.Summary),
|
||||
request.Body,
|
||||
TrimToNull(request.TextBody),
|
||||
TrimToLowerInvariant(request.Locale),
|
||||
NormalizeInputMetadata(request.Metadata),
|
||||
NormalizeAttachments(request.AttachmentRefs));
|
||||
}
|
||||
|
||||
private static string ResolveTarget(NotifyChannel channel, string? overrideTarget)
|
||||
{
|
||||
var target = string.IsNullOrWhiteSpace(overrideTarget)
|
||||
? channel.Config.Target ?? channel.Config.Endpoint
|
||||
: overrideTarget.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(target))
|
||||
{
|
||||
throw new ChannelTestSendValidationException("Channel target is required. Provide 'target' or configure channel.config.target/endpoint.");
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
private static NotifyDeliveryRendered CreateFallbackPreview(ChannelTestPreviewContext context)
|
||||
{
|
||||
var format = MapFormat(context.Channel.Type);
|
||||
var title = context.Request.Title ?? $"Stella Ops Notify Test ({context.Channel.Name})";
|
||||
var body = context.Request.Body ?? BuildDefaultBody(context.Channel, context.Timestamp);
|
||||
var summary = context.Request.Summary ?? $"Preview generated for {context.Channel.Type} destination.";
|
||||
|
||||
return NotifyDeliveryRendered.Create(
|
||||
context.Channel.Type,
|
||||
format,
|
||||
context.Target,
|
||||
title,
|
||||
body,
|
||||
summary,
|
||||
context.Request.TextBody,
|
||||
context.Request.Locale,
|
||||
ChannelTestPreviewUtilities.ComputeBodyHash(body),
|
||||
context.Request.Attachments);
|
||||
}
|
||||
|
||||
private static NotifyDeliveryRendered EnsureBodyHash(NotifyDeliveryRendered preview)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(preview.BodyHash))
|
||||
{
|
||||
return preview;
|
||||
}
|
||||
|
||||
var hash = ChannelTestPreviewUtilities.ComputeBodyHash(preview.Body);
|
||||
return NotifyDeliveryRendered.Create(
|
||||
preview.ChannelType,
|
||||
preview.Format,
|
||||
preview.Target,
|
||||
preview.Title,
|
||||
preview.Body,
|
||||
preview.Summary,
|
||||
preview.TextBody,
|
||||
preview.Locale,
|
||||
hash,
|
||||
preview.Attachments);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> MergeMetadata(
|
||||
ChannelTestPreviewContext context,
|
||||
string providerName,
|
||||
IReadOnlyDictionary<string, string>? providerMetadata)
|
||||
{
|
||||
var metadata = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["channelType"] = context.Channel.Type.ToString().ToLowerInvariant(),
|
||||
["target"] = context.Target,
|
||||
["previewProvider"] = providerName,
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
foreach (var pair in context.Request.Metadata)
|
||||
{
|
||||
metadata[pair.Key] = pair.Value;
|
||||
}
|
||||
|
||||
if (providerMetadata is not null)
|
||||
{
|
||||
foreach (var pair in providerMetadata)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key) || pair.Value is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
metadata[pair.Key.Trim()] = pair.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private static NotifyDeliveryFormat MapFormat(NotifyChannelType type)
|
||||
=> type switch
|
||||
{
|
||||
NotifyChannelType.Slack => NotifyDeliveryFormat.Slack,
|
||||
NotifyChannelType.Teams => NotifyDeliveryFormat.Teams,
|
||||
NotifyChannelType.Email => NotifyDeliveryFormat.Email,
|
||||
NotifyChannelType.Webhook => NotifyDeliveryFormat.Webhook,
|
||||
_ => NotifyDeliveryFormat.Json
|
||||
};
|
||||
|
||||
private static string BuildDefaultBody(NotifyChannel channel, DateTimeOffset timestamp)
|
||||
{
|
||||
return $"This is a Stella Ops Notify test message for channel '{channel.Name}' " +
|
||||
$"({channel.ChannelId}, type {channel.Type}). Generated at {timestamp:O}.";
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string> NormalizeInputMetadata(IDictionary<string, string>? source)
|
||||
{
|
||||
if (source is null || source.Count == 0)
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, string>(source.Count, StringComparer.Ordinal);
|
||||
foreach (var pair in source)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(pair.Key) || string.IsNullOrWhiteSpace(pair.Value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
result[pair.Key.Trim()] = pair.Value.Trim();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> NormalizeAttachments(IList<string>? attachments)
|
||||
{
|
||||
if (attachments is null || attachments.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var list = new List<string>(attachments.Count);
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attachment))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
list.Add(attachment.Trim());
|
||||
}
|
||||
|
||||
return list.Count == 0 ? Array.Empty<string>() : list;
|
||||
}
|
||||
|
||||
private static string? TrimToNull(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private static string? TrimToLowerInvariant(string? value)
|
||||
{
|
||||
var trimmed = TrimToNull(value);
|
||||
return trimmed?.ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class ChannelTestSendValidationException : Exception
|
||||
{
|
||||
public ChannelTestSendValidationException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
using System.Text.Json.Nodes;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Services;
|
||||
|
||||
internal sealed class NotifySchemaMigrationService
|
||||
{
|
||||
public NotifyRule UpgradeRule(JsonNode json)
|
||||
=> NotifySchemaMigration.UpgradeRule(json ?? throw new ArgumentNullException(nameof(json)));
|
||||
|
||||
public NotifyChannel UpgradeChannel(JsonNode json)
|
||||
=> NotifySchemaMigration.UpgradeChannel(json ?? throw new ArgumentNullException(nameof(json)));
|
||||
|
||||
public NotifyTemplate UpgradeTemplate(JsonNode json)
|
||||
=> NotifySchemaMigration.UpgradeTemplate(json ?? throw new ArgumentNullException(nameof(json)));
|
||||
}
|
||||
Reference in New Issue
Block a user