Restructure solution layout by module

This commit is contained in:
master
2025-10-28 15:10:40 +02:00
parent 95daa159c4
commit d870da18ce
4103 changed files with 192899 additions and 187024 deletions

View File

@@ -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;
}
}

View File

@@ -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)
{
}
}

View File

@@ -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)));
}