Restructure solution layout by module
This commit is contained in:
4
src/Notify/StellaOps.Notify.WebService/AGENTS.md
Normal file
4
src/Notify/StellaOps.Notify.WebService/AGENTS.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# StellaOps.Notify.WebService — Agent Charter
|
||||
|
||||
## Mission
|
||||
Implement Notify control plane per `docs/ARCHITECTURE_NOTIFY.md`.
|
||||
@@ -0,0 +1,17 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Notify.Engine;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Response payload describing channel health diagnostics.
|
||||
/// </summary>
|
||||
public sealed record ChannelHealthResponse(
|
||||
string TenantId,
|
||||
string ChannelId,
|
||||
ChannelHealthStatus Status,
|
||||
string? Message,
|
||||
DateTimeOffset CheckedAt,
|
||||
string TraceId,
|
||||
IReadOnlyDictionary<string, string> Metadata);
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Payload for Notify channel test send requests.
|
||||
/// </summary>
|
||||
public sealed record ChannelTestSendRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional override for the default channel destination (email address, webhook URL, Slack channel, etc.).
|
||||
/// </summary>
|
||||
public string? Target { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional template identifier to drive future rendering hooks.
|
||||
/// </summary>
|
||||
public string? TemplateId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Preview title (fallback supplied when omitted).
|
||||
/// </summary>
|
||||
public string? Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional short summary to show in UI cards.
|
||||
/// </summary>
|
||||
public string? Summary { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Primary body payload rendered for the connector.
|
||||
/// </summary>
|
||||
public string? Body { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional text-only representation (used by email/plaintext destinations).
|
||||
/// </summary>
|
||||
public string? TextBody { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional locale hint (RFC 5646).
|
||||
/// </summary>
|
||||
public string? Locale { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional metadata for future expansion (headers, context labels, etc.).
|
||||
/// </summary>
|
||||
public IDictionary<string, string>? Metadata { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional attachment references emitted with the preview.
|
||||
/// </summary>
|
||||
[JsonPropertyName("attachments")]
|
||||
public IList<string>? AttachmentRefs { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Response payload summarising a Notify channel test send preview.
|
||||
/// </summary>
|
||||
public sealed record ChannelTestSendResponse(
|
||||
string TenantId,
|
||||
string ChannelId,
|
||||
NotifyDeliveryRendered Preview,
|
||||
DateTimeOffset QueuedAt,
|
||||
string TraceId,
|
||||
IReadOnlyDictionary<string, string> Metadata);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
internal sealed record AcquireLockRequest(string Resource, string Owner, int TtlSeconds);
|
||||
|
||||
internal sealed record ReleaseLockRequest(string Resource, string Owner);
|
||||
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks Notify WebService readiness information for `/readyz`.
|
||||
/// </summary>
|
||||
internal sealed class ServiceStatus
|
||||
{
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly DateTimeOffset _startedAt;
|
||||
private ReadySnapshot _readySnapshot;
|
||||
|
||||
public ServiceStatus(TimeProvider timeProvider)
|
||||
{
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
_startedAt = _timeProvider.GetUtcNow();
|
||||
_readySnapshot = ReadySnapshot.CreateInitial(_startedAt);
|
||||
}
|
||||
|
||||
public ServiceSnapshot CreateSnapshot()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return new ServiceSnapshot(_startedAt, now, _readySnapshot);
|
||||
}
|
||||
|
||||
public void RecordReadyCheck(bool success, TimeSpan latency, string? errorMessage = null)
|
||||
{
|
||||
var timestamp = _timeProvider.GetUtcNow();
|
||||
_readySnapshot = new ReadySnapshot(timestamp, latency, success, success ? null : errorMessage);
|
||||
}
|
||||
|
||||
public readonly record struct ServiceSnapshot(
|
||||
DateTimeOffset StartedAt,
|
||||
DateTimeOffset CapturedAt,
|
||||
ReadySnapshot Ready);
|
||||
|
||||
public readonly record struct ReadySnapshot(
|
||||
DateTimeOffset CheckedAt,
|
||||
TimeSpan? Latency,
|
||||
bool IsReady,
|
||||
string? Error)
|
||||
{
|
||||
public static ReadySnapshot CreateInitial(DateTimeOffset timestamp)
|
||||
=> new(timestamp, null, false, "initialising");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using YamlDotNet.Serialization;
|
||||
using YamlDotNet.Serialization.NamingConventions;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Extensions;
|
||||
|
||||
internal static class ConfigurationExtensions
|
||||
{
|
||||
public static IConfigurationBuilder AddNotifyYaml(this IConfigurationBuilder builder, string path)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
|
||||
var deserializer = new DeserializerBuilder()
|
||||
.WithNamingConvention(CamelCaseNamingConvention.Instance)
|
||||
.Build();
|
||||
|
||||
using var reader = File.OpenText(path);
|
||||
var yamlObject = deserializer.Deserialize(reader);
|
||||
|
||||
if (yamlObject is null)
|
||||
{
|
||||
return builder;
|
||||
}
|
||||
|
||||
var payload = JsonSerializer.Serialize(yamlObject);
|
||||
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(payload));
|
||||
return builder.AddJsonStream(stream);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using StellaOps.Notify.WebService.Options;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Hosting;
|
||||
|
||||
internal static class NotifyPluginHostFactory
|
||||
{
|
||||
public static PluginHostOptions Build(NotifyWebServiceOptions options, string contentRootPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(contentRootPath);
|
||||
|
||||
var hostOptions = new PluginHostOptions
|
||||
{
|
||||
BaseDirectory = options.Plugins.BaseDirectory ?? Path.Combine(contentRootPath, ".."),
|
||||
PluginsDirectory = options.Plugins.Directory ?? Path.Combine("plugins", "notify"),
|
||||
PrimaryPrefix = "StellaOps.Notify"
|
||||
};
|
||||
|
||||
if (!Path.IsPathRooted(hostOptions.BaseDirectory))
|
||||
{
|
||||
hostOptions.BaseDirectory = Path.GetFullPath(Path.Combine(contentRootPath, hostOptions.BaseDirectory));
|
||||
}
|
||||
|
||||
if (!Path.IsPathRooted(hostOptions.PluginsDirectory))
|
||||
{
|
||||
hostOptions.PluginsDirectory = Path.Combine(hostOptions.BaseDirectory, hostOptions.PluginsDirectory);
|
||||
}
|
||||
|
||||
foreach (var pattern in options.Plugins.SearchPatterns)
|
||||
{
|
||||
hostOptions.SearchPatterns.Add(pattern);
|
||||
}
|
||||
|
||||
foreach (var prefix in options.Plugins.OrderedPlugins)
|
||||
{
|
||||
hostOptions.PluginOrder.Add(prefix);
|
||||
}
|
||||
|
||||
return hostOptions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Internal;
|
||||
|
||||
internal sealed class JsonHttpResult : IResult
|
||||
{
|
||||
private readonly string _payload;
|
||||
private readonly int _statusCode;
|
||||
private readonly string? _location;
|
||||
|
||||
public JsonHttpResult(string payload, int statusCode, string? location)
|
||||
{
|
||||
_payload = payload;
|
||||
_statusCode = statusCode;
|
||||
_location = location;
|
||||
}
|
||||
|
||||
public async Task ExecuteAsync(HttpContext httpContext)
|
||||
{
|
||||
httpContext.Response.StatusCode = _statusCode;
|
||||
httpContext.Response.ContentType = "application/json";
|
||||
if (!string.IsNullOrWhiteSpace(_location))
|
||||
{
|
||||
httpContext.Response.Headers.Location = _location;
|
||||
}
|
||||
|
||||
await httpContext.Response.WriteAsync(_payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Options;
|
||||
|
||||
/// <summary>
|
||||
/// Strongly typed configuration for the Notify WebService host.
|
||||
/// </summary>
|
||||
public sealed class NotifyWebServiceOptions
|
||||
{
|
||||
public const string SectionName = "notify";
|
||||
|
||||
/// <summary>
|
||||
/// Schema version that downstream consumers can use to detect breaking changes.
|
||||
/// </summary>
|
||||
public int SchemaVersion { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Authority / authentication configuration.
|
||||
/// </summary>
|
||||
public AuthorityOptions Authority { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Mongo storage configuration for configuration state and audit logs.
|
||||
/// </summary>
|
||||
public StorageOptions Storage { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Plug-in loader configuration.
|
||||
/// </summary>
|
||||
public PluginOptions Plugins { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// HTTP API behaviour.
|
||||
/// </summary>
|
||||
public ApiOptions Api { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Telemetry configuration toggles.
|
||||
/// </summary>
|
||||
public TelemetryOptions Telemetry { get; set; } = new();
|
||||
|
||||
public sealed class AuthorityOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public bool AllowAnonymousFallback { get; set; }
|
||||
|
||||
public string Issuer { get; set; } = "https://authority.local";
|
||||
|
||||
public string? MetadataAddress { get; set; }
|
||||
|
||||
public bool RequireHttpsMetadata { get; set; } = true;
|
||||
|
||||
public int BackchannelTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
public int TokenClockSkewSeconds { get; set; } = 60;
|
||||
|
||||
public IList<string> Audiences { get; set; } = new List<string> { "notify" };
|
||||
|
||||
public string ReadScope { get; set; } = "notify.read";
|
||||
|
||||
public string AdminScope { get; set; } = "notify.admin";
|
||||
|
||||
/// <summary>
|
||||
/// Optional development signing key for symmetric JWT validation when Authority is disabled.
|
||||
/// </summary>
|
||||
public string? DevelopmentSigningKey { get; set; }
|
||||
}
|
||||
|
||||
public sealed class StorageOptions
|
||||
{
|
||||
public string Driver { get; set; } = "mongo";
|
||||
|
||||
public string ConnectionString { get; set; } = string.Empty;
|
||||
|
||||
public string Database { get; set; } = "notify";
|
||||
|
||||
public int CommandTimeoutSeconds { get; set; } = 30;
|
||||
}
|
||||
|
||||
public sealed class PluginOptions
|
||||
{
|
||||
public string? BaseDirectory { get; set; }
|
||||
|
||||
public string? Directory { get; set; }
|
||||
|
||||
public IList<string> SearchPatterns { get; set; } = new List<string>();
|
||||
|
||||
public IList<string> OrderedPlugins { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
public sealed class ApiOptions
|
||||
{
|
||||
public string BasePath { get; set; } = "/api/v1/notify";
|
||||
|
||||
public string InternalBasePath { get; set; } = "/internal/notify";
|
||||
|
||||
public string TenantHeader { get; set; } = "X-StellaOps-Tenant";
|
||||
|
||||
public RateLimitOptions RateLimits { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class RateLimitOptions
|
||||
{
|
||||
public RateLimitPolicyOptions DeliveryHistory { get; set; } = RateLimitPolicyOptions.CreateDefault(
|
||||
tokenLimit: 60,
|
||||
tokensPerPeriod: 30,
|
||||
replenishmentPeriodSeconds: 60,
|
||||
queueLimit: 20);
|
||||
|
||||
public RateLimitPolicyOptions TestSend { get; set; } = RateLimitPolicyOptions.CreateDefault(
|
||||
tokenLimit: 5,
|
||||
tokensPerPeriod: 5,
|
||||
replenishmentPeriodSeconds: 60,
|
||||
queueLimit: 2);
|
||||
}
|
||||
|
||||
public sealed class RateLimitPolicyOptions
|
||||
{
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public int TokenLimit { get; set; } = 10;
|
||||
|
||||
public int TokensPerPeriod { get; set; } = 10;
|
||||
|
||||
public int ReplenishmentPeriodSeconds { get; set; } = 60;
|
||||
|
||||
public int QueueLimit { get; set; } = 0;
|
||||
|
||||
public static RateLimitPolicyOptions CreateDefault(int tokenLimit, int tokensPerPeriod, int replenishmentPeriodSeconds, int queueLimit)
|
||||
{
|
||||
return new RateLimitPolicyOptions
|
||||
{
|
||||
TokenLimit = tokenLimit,
|
||||
TokensPerPeriod = tokensPerPeriod,
|
||||
ReplenishmentPeriodSeconds = replenishmentPeriodSeconds,
|
||||
QueueLimit = queueLimit
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class TelemetryOptions
|
||||
{
|
||||
public bool EnableRequestLogging { get; set; } = true;
|
||||
|
||||
public string MinimumLogLevel { get; set; } = "Information";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Options;
|
||||
|
||||
internal static class NotifyWebServiceOptionsPostConfigure
|
||||
{
|
||||
public static void Apply(NotifyWebServiceOptions options, string contentRootPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(contentRootPath);
|
||||
|
||||
NormalizePluginOptions(options.Plugins, contentRootPath);
|
||||
}
|
||||
|
||||
private static void NormalizePluginOptions(NotifyWebServiceOptions.PluginOptions plugins, string contentRootPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plugins);
|
||||
|
||||
var baseDirectory = plugins.BaseDirectory;
|
||||
if (string.IsNullOrWhiteSpace(baseDirectory))
|
||||
{
|
||||
baseDirectory = Path.Combine(contentRootPath, "..");
|
||||
}
|
||||
else if (!Path.IsPathRooted(baseDirectory))
|
||||
{
|
||||
baseDirectory = Path.GetFullPath(Path.Combine(contentRootPath, baseDirectory));
|
||||
}
|
||||
|
||||
plugins.BaseDirectory = baseDirectory;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(plugins.Directory))
|
||||
{
|
||||
plugins.Directory = Path.Combine("plugins", "notify");
|
||||
}
|
||||
|
||||
if (!Path.IsPathRooted(plugins.Directory))
|
||||
{
|
||||
plugins.Directory = Path.Combine(baseDirectory, plugins.Directory);
|
||||
}
|
||||
|
||||
if (plugins.SearchPatterns.Count == 0)
|
||||
{
|
||||
plugins.SearchPatterns.Add("StellaOps.Notify.Connectors.*.dll");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Options;
|
||||
|
||||
internal static class NotifyWebServiceOptionsValidator
|
||||
{
|
||||
public static void Validate(NotifyWebServiceOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
ValidateStorage(options.Storage);
|
||||
ValidateAuthority(options.Authority);
|
||||
ValidateApi(options.Api);
|
||||
}
|
||||
|
||||
private static void ValidateStorage(NotifyWebServiceOptions.StorageOptions storage)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(storage);
|
||||
|
||||
var driver = storage.Driver ?? string.Empty;
|
||||
if (!string.Equals(driver, "mongo", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(driver, "memory", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new InvalidOperationException($"Unsupported storage driver '{storage.Driver}'.");
|
||||
}
|
||||
|
||||
if (string.Equals(driver, "mongo", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(storage.ConnectionString))
|
||||
{
|
||||
throw new InvalidOperationException("notify:storage:connectionString must be provided.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(storage.Database))
|
||||
{
|
||||
throw new InvalidOperationException("notify:storage:database must be provided.");
|
||||
}
|
||||
|
||||
if (storage.CommandTimeoutSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("notify:storage:commandTimeoutSeconds must be positive.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateAuthority(NotifyWebServiceOptions.AuthorityOptions authority)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(authority);
|
||||
|
||||
if (authority.Enabled)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authority.Issuer))
|
||||
{
|
||||
throw new InvalidOperationException("notify:authority:issuer must be provided when authority is enabled.");
|
||||
}
|
||||
|
||||
if (authority.Audiences is null || authority.Audiences.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("notify:authority:audiences must include at least one value.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(authority.AdminScope) || string.IsNullOrWhiteSpace(authority.ReadScope))
|
||||
{
|
||||
throw new InvalidOperationException("notify:authority admin and read scopes must be configured.");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authority.DevelopmentSigningKey) || authority.DevelopmentSigningKey.Length < 32)
|
||||
{
|
||||
throw new InvalidOperationException("notify:authority:developmentSigningKey must be at least 32 characters when authority is disabled.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateApi(NotifyWebServiceOptions.ApiOptions api)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(api);
|
||||
|
||||
if (!api.BasePath.StartsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("notify:api:basePath must start with '/'.");
|
||||
}
|
||||
|
||||
if (!api.InternalBasePath.StartsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
throw new InvalidOperationException("notify:api:internalBasePath must start with '/'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(api.TenantHeader))
|
||||
{
|
||||
throw new InvalidOperationException("notify:api:tenantHeader must be provided.");
|
||||
}
|
||||
|
||||
ValidateRateLimits(api.RateLimits);
|
||||
}
|
||||
|
||||
private static void ValidateRateLimits(NotifyWebServiceOptions.RateLimitOptions rateLimits)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rateLimits);
|
||||
|
||||
ValidatePolicy(rateLimits.DeliveryHistory, "notify:api:rateLimits:deliveryHistory");
|
||||
ValidatePolicy(rateLimits.TestSend, "notify:api:rateLimits:testSend");
|
||||
|
||||
static void ValidatePolicy(NotifyWebServiceOptions.RateLimitPolicyOptions options, string prefix)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.TokenLimit <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"{prefix}:tokenLimit must be positive when enabled.");
|
||||
}
|
||||
|
||||
if (options.TokensPerPeriod <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"{prefix}:tokensPerPeriod must be positive when enabled.");
|
||||
}
|
||||
|
||||
if (options.ReplenishmentPeriodSeconds <= 0)
|
||||
{
|
||||
throw new InvalidOperationException($"{prefix}:replenishmentPeriodSeconds must be positive when enabled.");
|
||||
}
|
||||
|
||||
if (options.QueueLimit < 0)
|
||||
{
|
||||
throw new InvalidOperationException($"{prefix}:queueLimit cannot be negative.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Plugin.Hosting;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Plugins;
|
||||
|
||||
internal interface INotifyPluginRegistry
|
||||
{
|
||||
Task<int> WarmupAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
internal sealed class NotifyPluginRegistry : INotifyPluginRegistry
|
||||
{
|
||||
private readonly PluginHostOptions _hostOptions;
|
||||
private readonly ILogger<NotifyPluginRegistry> _logger;
|
||||
|
||||
public NotifyPluginRegistry(
|
||||
PluginHostOptions hostOptions,
|
||||
ILogger<NotifyPluginRegistry> logger)
|
||||
{
|
||||
_hostOptions = hostOptions ?? throw new ArgumentNullException(nameof(hostOptions));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public Task<int> WarmupAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var result = PluginHost.LoadPlugins(_hostOptions, _logger);
|
||||
|
||||
if (result.Plugins.Count == 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"No Notify plug-ins discovered under '{PluginDirectory}'.",
|
||||
result.PluginDirectory);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Loaded {PluginCount} Notify plug-in(s) from '{PluginDirectory}'.",
|
||||
result.Plugins.Count,
|
||||
result.PluginDirectory);
|
||||
}
|
||||
|
||||
if (result.MissingOrderedPlugins.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Configured plug-ins missing from disk: {Missing}.",
|
||||
string.Join(", ", result.MissingOrderedPlugins));
|
||||
}
|
||||
|
||||
return Task.FromResult(result.Plugins.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace StellaOps.Notify.WebService;
|
||||
|
||||
public partial class Program;
|
||||
850
src/Notify/StellaOps.Notify.WebService/Program.cs
Normal file
850
src/Notify/StellaOps.Notify.WebService/Program.cs
Normal file
@@ -0,0 +1,850 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using System.Threading.RateLimiting;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using StellaOps.Auth.ServerIntegration;
|
||||
using StellaOps.Configuration;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
using StellaOps.Notify.WebService.Diagnostics;
|
||||
using StellaOps.Notify.WebService.Extensions;
|
||||
using StellaOps.Notify.WebService.Hosting;
|
||||
using StellaOps.Notify.WebService.Options;
|
||||
using StellaOps.Notify.WebService.Plugins;
|
||||
using StellaOps.Notify.WebService.Security;
|
||||
using StellaOps.Notify.WebService.Services;
|
||||
using StellaOps.Notify.WebService.Internal;
|
||||
using StellaOps.Notify.WebService.Storage.InMemory;
|
||||
using StellaOps.Plugin.DependencyInjection;
|
||||
using MongoDB.Bson;
|
||||
using StellaOps.Notify.WebService.Contracts;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Configuration.AddStellaOpsDefaults(options =>
|
||||
{
|
||||
options.BasePath = builder.Environment.ContentRootPath;
|
||||
options.EnvironmentPrefix = "NOTIFY_";
|
||||
options.ConfigureBuilder = configurationBuilder =>
|
||||
{
|
||||
configurationBuilder.AddNotifyYaml(Path.Combine(builder.Environment.ContentRootPath, "../etc/notify.yaml"));
|
||||
};
|
||||
});
|
||||
|
||||
var contentRootPath = builder.Environment.ContentRootPath;
|
||||
|
||||
var bootstrapOptions = builder.Configuration.BindOptions<NotifyWebServiceOptions>(
|
||||
NotifyWebServiceOptions.SectionName,
|
||||
(opts, _) =>
|
||||
{
|
||||
NotifyWebServiceOptionsPostConfigure.Apply(opts, contentRootPath);
|
||||
NotifyWebServiceOptionsValidator.Validate(opts);
|
||||
});
|
||||
|
||||
builder.Services.AddOptions<NotifyWebServiceOptions>()
|
||||
.Bind(builder.Configuration.GetSection(NotifyWebServiceOptions.SectionName))
|
||||
.PostConfigure(options =>
|
||||
{
|
||||
NotifyWebServiceOptionsPostConfigure.Apply(options, contentRootPath);
|
||||
NotifyWebServiceOptionsValidator.Validate(options);
|
||||
})
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Host.UseSerilog((context, services, loggerConfiguration) =>
|
||||
{
|
||||
var minimumLevel = MapLogLevel(bootstrapOptions.Telemetry.MinimumLogLevel);
|
||||
|
||||
loggerConfiguration
|
||||
.MinimumLevel.Is(minimumLevel)
|
||||
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Warning)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console();
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton(TimeProvider.System);
|
||||
builder.Services.AddSingleton<ServiceStatus>();
|
||||
builder.Services.AddSingleton<NotifySchemaMigrationService>();
|
||||
|
||||
if (string.Equals(bootstrapOptions.Storage.Driver, "mongo", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
builder.Services.AddNotifyMongoStorage(builder.Configuration.GetSection("notify:storage"));
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddInMemoryNotifyStorage();
|
||||
}
|
||||
|
||||
var pluginHostOptions = NotifyPluginHostFactory.Build(bootstrapOptions, contentRootPath);
|
||||
builder.Services.AddSingleton(pluginHostOptions);
|
||||
builder.Services.RegisterPluginRoutines(builder.Configuration, pluginHostOptions);
|
||||
builder.Services.AddSingleton<INotifyPluginRegistry, NotifyPluginRegistry>();
|
||||
builder.Services.AddSingleton<INotifyChannelTestService, NotifyChannelTestService>();
|
||||
builder.Services.AddSingleton<INotifyChannelHealthService, NotifyChannelHealthService>();
|
||||
|
||||
ConfigureAuthentication(builder, bootstrapOptions);
|
||||
ConfigureRateLimiting(builder, bootstrapOptions);
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
var readyStatus = app.Services.GetRequiredService<ServiceStatus>();
|
||||
|
||||
var resolvedOptions = app.Services.GetRequiredService<IOptions<NotifyWebServiceOptions>>().Value;
|
||||
await InitialiseAsync(app.Services, readyStatus, app.Logger, resolvedOptions);
|
||||
|
||||
ConfigureRequestPipeline(app, bootstrapOptions);
|
||||
ConfigureEndpoints(app);
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
static void ConfigureAuthentication(WebApplicationBuilder builder, NotifyWebServiceOptions options)
|
||||
{
|
||||
if (options.Authority.Enabled)
|
||||
{
|
||||
builder.Services.AddStellaOpsResourceServerAuthentication(
|
||||
builder.Configuration,
|
||||
configurationSection: null,
|
||||
configure: resourceOptions =>
|
||||
{
|
||||
resourceOptions.Authority = options.Authority.Issuer;
|
||||
resourceOptions.RequireHttpsMetadata = options.Authority.RequireHttpsMetadata;
|
||||
resourceOptions.MetadataAddress = options.Authority.MetadataAddress;
|
||||
resourceOptions.BackchannelTimeout = TimeSpan.FromSeconds(options.Authority.BackchannelTimeoutSeconds);
|
||||
resourceOptions.TokenClockSkew = TimeSpan.FromSeconds(options.Authority.TokenClockSkewSeconds);
|
||||
|
||||
resourceOptions.Audiences.Clear();
|
||||
foreach (var audience in options.Authority.Audiences)
|
||||
{
|
||||
resourceOptions.Audiences.Add(audience);
|
||||
}
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization(auth =>
|
||||
{
|
||||
auth.AddStellaOpsScopePolicy(NotifyPolicies.Read, options.Authority.ReadScope);
|
||||
auth.AddStellaOpsScopePolicy(NotifyPolicies.Admin, options.Authority.AdminScope);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(jwt =>
|
||||
{
|
||||
jwt.RequireHttpsMetadata = false;
|
||||
jwt.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = options.Authority.Issuer,
|
||||
ValidateAudience = options.Authority.Audiences.Count > 0,
|
||||
ValidAudiences = options.Authority.Audiences,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(options.Authority.DevelopmentSigningKey!)),
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromSeconds(options.Authority.TokenClockSkewSeconds),
|
||||
NameClaimType = ClaimTypes.Name
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization(auth =>
|
||||
{
|
||||
auth.AddPolicy(
|
||||
NotifyPolicies.Read,
|
||||
policy => policy
|
||||
.RequireAuthenticatedUser()
|
||||
.RequireAssertion(ctx =>
|
||||
HasScope(ctx.User, options.Authority.ReadScope) ||
|
||||
HasScope(ctx.User, options.Authority.AdminScope)));
|
||||
|
||||
auth.AddPolicy(
|
||||
NotifyPolicies.Admin,
|
||||
policy => policy
|
||||
.RequireAuthenticatedUser()
|
||||
.RequireAssertion(ctx => HasScope(ctx.User, options.Authority.AdminScope)));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static void ConfigureRateLimiting(WebApplicationBuilder builder, NotifyWebServiceOptions options)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
var tenantHeader = options.Api.TenantHeader;
|
||||
var limits = options.Api.RateLimits;
|
||||
|
||||
builder.Services.AddRateLimiter(rateLimiterOptions =>
|
||||
{
|
||||
rateLimiterOptions.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
rateLimiterOptions.OnRejected = static (context, _) =>
|
||||
{
|
||||
context.HttpContext.Response.Headers.TryAdd("Retry-After", "1");
|
||||
return ValueTask.CompletedTask;
|
||||
};
|
||||
|
||||
ConfigurePolicy(rateLimiterOptions, NotifyRateLimitPolicies.DeliveryHistory, limits.DeliveryHistory, tenantHeader, "deliveries");
|
||||
ConfigurePolicy(rateLimiterOptions, NotifyRateLimitPolicies.TestSend, limits.TestSend, tenantHeader, "channel-test");
|
||||
});
|
||||
|
||||
static void ConfigurePolicy(
|
||||
RateLimiterOptions rateLimiterOptions,
|
||||
string policyName,
|
||||
NotifyWebServiceOptions.RateLimitPolicyOptions policy,
|
||||
string tenantHeader,
|
||||
string prefix)
|
||||
{
|
||||
rateLimiterOptions.AddPolicy(policyName, httpContext =>
|
||||
{
|
||||
if (policy is null || !policy.Enabled)
|
||||
{
|
||||
return RateLimitPartition.GetNoLimiter("notify-disabled");
|
||||
}
|
||||
|
||||
var identity = ResolveIdentity(httpContext, tenantHeader, prefix);
|
||||
|
||||
return RateLimitPartition.GetTokenBucketLimiter(identity, _ => new TokenBucketRateLimiterOptions
|
||||
{
|
||||
TokenLimit = policy.TokenLimit,
|
||||
TokensPerPeriod = policy.TokensPerPeriod,
|
||||
ReplenishmentPeriod = TimeSpan.FromSeconds(policy.ReplenishmentPeriodSeconds),
|
||||
QueueLimit = policy.QueueLimit,
|
||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||
AutoReplenishment = true
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static string ResolveIdentity(HttpContext httpContext, string tenantHeader, string prefix)
|
||||
{
|
||||
var tenant = httpContext.Request.Headers.TryGetValue(tenantHeader, out var header) && !StringValues.IsNullOrEmpty(header)
|
||||
? header.ToString().Trim()
|
||||
: "anonymous";
|
||||
|
||||
var subject = httpContext.User.FindFirst("sub")?.Value
|
||||
?? httpContext.User.Identity?.Name
|
||||
?? httpContext.Connection.RemoteIpAddress?.ToString()
|
||||
?? "anonymous";
|
||||
|
||||
return string.Concat(prefix, ':', tenant, ':', subject);
|
||||
}
|
||||
}
|
||||
|
||||
static async Task InitialiseAsync(IServiceProvider services, ServiceStatus status, Microsoft.Extensions.Logging.ILogger logger, NotifyWebServiceOptions options)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
if (string.Equals(options.Storage.Driver, "mongo", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await RunMongoMigrationsAsync(scope.ServiceProvider);
|
||||
}
|
||||
|
||||
var registry = scope.ServiceProvider.GetRequiredService<INotifyPluginRegistry>();
|
||||
var count = await registry.WarmupAsync();
|
||||
|
||||
stopwatch.Stop();
|
||||
status.RecordReadyCheck(success: true, stopwatch.Elapsed);
|
||||
logger.LogInformation("Notify WebService initialised in {ElapsedMs} ms; loaded {PluginCount} plug-in(s).", stopwatch.Elapsed.TotalMilliseconds, count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
status.RecordReadyCheck(success: false, stopwatch.Elapsed, ex.Message);
|
||||
logger.LogError(ex, "Failed to initialise Notify WebService.");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task RunMongoMigrationsAsync(IServiceProvider services)
|
||||
{
|
||||
var initializerType = Type.GetType("StellaOps.Notify.Storage.Mongo.Internal.NotifyMongoInitializer, StellaOps.Notify.Storage.Mongo");
|
||||
if (initializerType is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var initializer = services.GetService(initializerType);
|
||||
if (initializer is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var method = initializerType.GetMethod("EnsureIndexesAsync", new[] { typeof(CancellationToken) });
|
||||
if (method is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (method.Invoke(initializer, new object[] { CancellationToken.None }) is Task task)
|
||||
{
|
||||
await task.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
static void ConfigureRequestPipeline(WebApplication app, NotifyWebServiceOptions options)
|
||||
{
|
||||
if (options.Telemetry.EnableRequestLogging)
|
||||
{
|
||||
app.UseSerilogRequestLogging(c =>
|
||||
{
|
||||
c.IncludeQueryInRequestPath = true;
|
||||
c.GetLevel = (_, _, exception) => exception is null ? LogEventLevel.Information : LogEventLevel.Error;
|
||||
});
|
||||
}
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseRateLimiter();
|
||||
app.UseAuthorization();
|
||||
}
|
||||
|
||||
static void ConfigureEndpoints(WebApplication app)
|
||||
{
|
||||
app.MapGet("/healthz", () => Results.Ok(new { status = "ok" }));
|
||||
|
||||
app.MapGet("/readyz", (ServiceStatus status) =>
|
||||
{
|
||||
var snapshot = status.CreateSnapshot();
|
||||
if (snapshot.Ready.IsReady)
|
||||
{
|
||||
return Results.Ok(new
|
||||
{
|
||||
status = "ready",
|
||||
checkedAt = snapshot.Ready.CheckedAt,
|
||||
latencyMs = snapshot.Ready.Latency?.TotalMilliseconds,
|
||||
snapshot.StartedAt
|
||||
});
|
||||
}
|
||||
|
||||
return JsonResponse(
|
||||
new
|
||||
{
|
||||
status = "unready",
|
||||
snapshot.Ready.Error,
|
||||
checkedAt = snapshot.Ready.CheckedAt,
|
||||
latencyMs = snapshot.Ready.Latency?.TotalMilliseconds
|
||||
},
|
||||
StatusCodes.Status503ServiceUnavailable);
|
||||
});
|
||||
|
||||
var options = app.Services.GetRequiredService<IOptions<NotifyWebServiceOptions>>().Value;
|
||||
var tenantHeader = options.Api.TenantHeader;
|
||||
var apiBasePath = options.Api.BasePath.TrimEnd('/');
|
||||
var apiGroup = app.MapGroup(options.Api.BasePath);
|
||||
var internalGroup = app.MapGroup(options.Api.InternalBasePath);
|
||||
|
||||
internalGroup.MapPost("/rules/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeRule))
|
||||
.WithName("notify.rules.normalize")
|
||||
.Produces(StatusCodes.Status200OK)
|
||||
.Produces(StatusCodes.Status400BadRequest);
|
||||
|
||||
internalGroup.MapPost("/channels/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeChannel))
|
||||
.WithName("notify.channels.normalize");
|
||||
|
||||
internalGroup.MapPost("/templates/normalize", (JsonNode? body, NotifySchemaMigrationService service) => Normalize(body, service.UpgradeTemplate))
|
||||
.WithName("notify.templates.normalize");
|
||||
|
||||
apiGroup.MapGet("/rules", async ([FromServices] INotifyRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var rules = await repository.ListAsync(tenant, cancellationToken);
|
||||
return JsonResponse(rules);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Read);
|
||||
|
||||
apiGroup.MapGet("/rules/{ruleId}", async (string ruleId, [FromServices] INotifyRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var rule = await repository.GetAsync(tenant, ruleId, cancellationToken);
|
||||
return rule is null ? Results.NotFound() : JsonResponse(rule);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Read);
|
||||
|
||||
apiGroup.MapPost("/rules", async (JsonNode? body, NotifySchemaMigrationService service, INotifyRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Request body is required." });
|
||||
}
|
||||
|
||||
var rule = service.UpgradeRule(body);
|
||||
if (!string.Equals(rule.TenantId, tenant, StringComparison.Ordinal))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
|
||||
}
|
||||
|
||||
await repository.UpsertAsync(rule, cancellationToken);
|
||||
|
||||
return CreatedJson(BuildResourceLocation(apiBasePath, "rules", rule.RuleId), rule);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin);
|
||||
|
||||
apiGroup.MapDelete("/rules/{ruleId}", async (string ruleId, INotifyRuleRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
await repository.DeleteAsync(tenant, ruleId, cancellationToken);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin);
|
||||
|
||||
apiGroup.MapGet("/channels", async (INotifyChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var channels = await repository.ListAsync(tenant, cancellationToken);
|
||||
return JsonResponse(channels);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Read);
|
||||
|
||||
apiGroup.MapPost("/channels", async (JsonNode? body, NotifySchemaMigrationService service, INotifyChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Request body is required." });
|
||||
}
|
||||
|
||||
var channel = service.UpgradeChannel(body);
|
||||
if (!string.Equals(channel.TenantId, tenant, StringComparison.Ordinal))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
|
||||
}
|
||||
|
||||
await repository.UpsertAsync(channel, cancellationToken);
|
||||
return CreatedJson(BuildResourceLocation(apiBasePath, "channels", channel.ChannelId), channel);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin);
|
||||
|
||||
apiGroup.MapPost("/channels/{channelId}/test", async (string channelId, [FromBody] ChannelTestSendRequest? request, INotifyChannelRepository repository, INotifyChannelTestService testService, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
if (request is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Request body is required." });
|
||||
}
|
||||
|
||||
var channel = await repository.GetAsync(tenant, channelId, cancellationToken);
|
||||
if (channel is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await testService.SendAsync(tenant, channel, request, context.TraceIdentifier, cancellationToken).ConfigureAwait(false);
|
||||
return JsonResponse(response, StatusCodes.Status202Accepted);
|
||||
}
|
||||
catch (ChannelTestSendValidationException ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin)
|
||||
.RequireRateLimiting(NotifyRateLimitPolicies.TestSend);
|
||||
|
||||
apiGroup.MapGet("/channels/{channelId}/health", async (string channelId, INotifyChannelRepository repository, INotifyChannelHealthService healthService, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var channel = await repository.GetAsync(tenant, channelId, cancellationToken);
|
||||
if (channel is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var response = await healthService.CheckAsync(tenant, channel, context.TraceIdentifier, cancellationToken).ConfigureAwait(false);
|
||||
return JsonResponse(response);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Read);
|
||||
|
||||
apiGroup.MapDelete("/channels/{channelId}", async (string channelId, INotifyChannelRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
await repository.DeleteAsync(tenant, channelId, cancellationToken);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin);
|
||||
|
||||
apiGroup.MapGet("/templates", async (INotifyTemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var templates = await repository.ListAsync(tenant, cancellationToken);
|
||||
return JsonResponse(templates);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Read);
|
||||
|
||||
apiGroup.MapPost("/templates", async (JsonNode? body, NotifySchemaMigrationService service, INotifyTemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Request body is required." });
|
||||
}
|
||||
|
||||
var template = service.UpgradeTemplate(body);
|
||||
if (!string.Equals(template.TenantId, tenant, StringComparison.Ordinal))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
|
||||
}
|
||||
|
||||
await repository.UpsertAsync(template, cancellationToken);
|
||||
return CreatedJson(BuildResourceLocation(apiBasePath, "templates", template.TemplateId), template);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin);
|
||||
|
||||
apiGroup.MapDelete("/templates/{templateId}", async (string templateId, INotifyTemplateRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
await repository.DeleteAsync(tenant, templateId, cancellationToken);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin);
|
||||
|
||||
apiGroup.MapPost("/deliveries", async (JsonNode? body, INotifyDeliveryRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Request body is required." });
|
||||
}
|
||||
|
||||
var delivery = NotifyCanonicalJsonSerializer.Deserialize<NotifyDelivery>(body.ToJsonString());
|
||||
if (!string.Equals(delivery.TenantId, tenant, StringComparison.Ordinal))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
|
||||
}
|
||||
|
||||
await repository.UpdateAsync(delivery, cancellationToken);
|
||||
return CreatedJson(BuildResourceLocation(apiBasePath, "deliveries", delivery.DeliveryId), delivery);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin);
|
||||
|
||||
apiGroup.MapGet("/deliveries", async ([FromServices] INotifyDeliveryRepository repository, HttpContext context, [FromQuery] DateTimeOffset? since, [FromQuery] string? status, [FromQuery] int? limit, [FromQuery] string? continuationToken, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var effectiveLimit = NormalizeLimit(limit);
|
||||
var result = await repository.QueryAsync(tenant, since, status, effectiveLimit, continuationToken, cancellationToken).ConfigureAwait(false);
|
||||
var payload = new
|
||||
{
|
||||
items = result.Items,
|
||||
continuationToken = result.ContinuationToken,
|
||||
count = result.Items.Count
|
||||
};
|
||||
|
||||
return JsonResponse(payload);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Read)
|
||||
.RequireRateLimiting(NotifyRateLimitPolicies.DeliveryHistory);
|
||||
|
||||
apiGroup.MapGet("/deliveries/{deliveryId}", async (string deliveryId, INotifyDeliveryRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var delivery = await repository.GetAsync(tenant, deliveryId, cancellationToken);
|
||||
return delivery is null ? Results.NotFound() : JsonResponse(delivery);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Read)
|
||||
.RequireRateLimiting(NotifyRateLimitPolicies.DeliveryHistory);
|
||||
|
||||
apiGroup.MapPost("/digests", async ([FromBody] NotifyDigestDocument payload, INotifyDigestRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
if (!string.Equals(payload.TenantId, tenant, StringComparison.Ordinal))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Tenant mismatch between header and payload." });
|
||||
}
|
||||
|
||||
await repository.UpsertAsync(payload, cancellationToken);
|
||||
return Results.Ok();
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin);
|
||||
|
||||
apiGroup.MapGet("/digests/{actionKey}", async (string actionKey, INotifyDigestRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var digest = await repository.GetAsync(tenant, actionKey, cancellationToken);
|
||||
return digest is null ? Results.NotFound() : JsonResponse(digest);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Read);
|
||||
|
||||
apiGroup.MapDelete("/digests/{actionKey}", async (string actionKey, INotifyDigestRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
await repository.RemoveAsync(tenant, actionKey, cancellationToken);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin);
|
||||
|
||||
apiGroup.MapPost("/locks/acquire", async ([FromBody] AcquireLockRequest request, INotifyLockRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var acquired = await repository.TryAcquireAsync(tenant, request.Resource, request.Owner, TimeSpan.FromSeconds(request.TtlSeconds), cancellationToken);
|
||||
return JsonResponse(new { acquired });
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin);
|
||||
|
||||
apiGroup.MapPost("/locks/release", async ([FromBody] ReleaseLockRequest request, INotifyLockRepository repository, HttpContext context, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
await repository.ReleaseAsync(tenant, request.Resource, request.Owner, cancellationToken);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin);
|
||||
|
||||
apiGroup.MapPost("/audit", async ([FromBody] JsonNode? body, INotifyAuditRepository repository, HttpContext context, ClaimsPrincipal user, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
if (body is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Request body is required." });
|
||||
}
|
||||
|
||||
var action = body["action"]?.GetValue<string>();
|
||||
if (string.IsNullOrWhiteSpace(action))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Action is required." });
|
||||
}
|
||||
|
||||
var entry = new NotifyAuditEntryDocument
|
||||
{
|
||||
Id = ObjectId.GenerateNewId(),
|
||||
TenantId = tenant,
|
||||
Action = action,
|
||||
Actor = user.Identity?.Name ?? "unknown",
|
||||
EntityId = body["entityId"]?.GetValue<string>() ?? string.Empty,
|
||||
EntityType = body["entityType"]?.GetValue<string>() ?? string.Empty,
|
||||
Timestamp = DateTimeOffset.UtcNow,
|
||||
Payload = body["payload"] is JsonObject payloadObj
|
||||
? BsonDocument.Parse(payloadObj.ToJsonString())
|
||||
: new BsonDocument()
|
||||
};
|
||||
|
||||
await repository.AppendAsync(entry, cancellationToken);
|
||||
return CreatedJson(BuildResourceLocation(apiBasePath, "audit", entry.Id.ToString()), new { entry.Id });
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Admin);
|
||||
|
||||
apiGroup.MapGet("/audit", async (INotifyAuditRepository repository, HttpContext context, [FromQuery] DateTimeOffset? since, [FromQuery] int? limit, CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!TryResolveTenant(context, tenantHeader, out var tenant, out var error))
|
||||
{
|
||||
return error!;
|
||||
}
|
||||
|
||||
var entries = await repository.QueryAsync(tenant, since, limit, cancellationToken);
|
||||
var response = entries.Select(e => new
|
||||
{
|
||||
e.Id,
|
||||
e.TenantId,
|
||||
e.Actor,
|
||||
e.Action,
|
||||
e.EntityId,
|
||||
e.EntityType,
|
||||
e.Timestamp,
|
||||
Payload = JsonNode.Parse(e.Payload.ToJson())
|
||||
});
|
||||
|
||||
return JsonResponse(response);
|
||||
})
|
||||
.RequireAuthorization(NotifyPolicies.Read);
|
||||
}
|
||||
|
||||
static int NormalizeLimit(int? value)
|
||||
{
|
||||
if (value is null || value <= 0)
|
||||
{
|
||||
return 50;
|
||||
}
|
||||
|
||||
return Math.Min(value.Value, 200);
|
||||
}
|
||||
|
||||
static bool TryResolveTenant(HttpContext context, string tenantHeader, out string tenant, out IResult? error)
|
||||
{
|
||||
if (!context.Request.Headers.TryGetValue(tenantHeader, out var header) || string.IsNullOrWhiteSpace(header))
|
||||
{
|
||||
tenant = string.Empty;
|
||||
error = Results.BadRequest(new { error = $"{tenantHeader} header is required." });
|
||||
return false;
|
||||
}
|
||||
|
||||
tenant = header.ToString().Trim();
|
||||
error = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
static string BuildResourceLocation(string basePath, params string[] segments)
|
||||
{
|
||||
if (segments.Length == 0)
|
||||
{
|
||||
return basePath;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(basePath);
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
builder.Append('/');
|
||||
builder.Append(Uri.EscapeDataString(segment));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
static IResult JsonResponse<T>(T value, int statusCode = StatusCodes.Status200OK, string? location = null)
|
||||
{
|
||||
var payload = JsonSerializer.Serialize(value, new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
return new JsonHttpResult(payload, statusCode, location);
|
||||
}
|
||||
|
||||
static IResult CreatedJson<T>(string location, T value)
|
||||
=> JsonResponse(value, StatusCodes.Status201Created, location);
|
||||
|
||||
static IResult Normalize<TModel>(JsonNode? body, Func<JsonNode, TModel> upgrade)
|
||||
{
|
||||
if (body is null)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Request body is required." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var model = upgrade(body);
|
||||
var json = NotifyCanonicalJsonSerializer.Serialize(model);
|
||||
return Results.Content(json, "application/json");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
static bool HasScope(ClaimsPrincipal principal, string scope)
|
||||
{
|
||||
if (principal is null || string.IsNullOrWhiteSpace(scope))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var claim in principal.FindAll("scope"))
|
||||
{
|
||||
if (string.Equals(claim.Value, scope, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static LogEventLevel MapLogLevel(string configuredLevel)
|
||||
{
|
||||
return configuredLevel?.ToLowerInvariant() switch
|
||||
{
|
||||
"verbose" => LogEventLevel.Verbose,
|
||||
"debug" => LogEventLevel.Debug,
|
||||
"warning" => LogEventLevel.Warning,
|
||||
"error" => LogEventLevel.Error,
|
||||
"fatal" => LogEventLevel.Fatal,
|
||||
_ => LogEventLevel.Information
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace StellaOps.Notify.WebService.Security;
|
||||
|
||||
internal static class NotifyPolicies
|
||||
{
|
||||
public const string Read = "notify.read";
|
||||
public const string Admin = "notify.admin";
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace StellaOps.Notify.WebService.Security;
|
||||
|
||||
internal static class NotifyRateLimitPolicies
|
||||
{
|
||||
public const string DeliveryHistory = "notify-deliveries";
|
||||
|
||||
public const string TestSend = "notify-test-send";
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
|
||||
<PackageReference Include="YamlDotNet" Version="13.7.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Configuration/StellaOps.Configuration.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.DependencyInjection/StellaOps.DependencyInjection.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Notify.Models/StellaOps.Notify.Models.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Notify.Storage.Mongo/StellaOps.Notify.Storage.Mongo.csproj" />
|
||||
<ProjectReference Include="../__Libraries/StellaOps.Notify.Engine/StellaOps.Notify.Engine.csproj" />
|
||||
<ProjectReference Include="../../__Libraries/StellaOps.Plugin/StellaOps.Plugin.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Abstractions/StellaOps.Auth.Abstractions.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.Client/StellaOps.Auth.Client.csproj" />
|
||||
<ProjectReference Include="../../Authority/StellaOps.Authority/StellaOps.Auth.ServerIntegration/StellaOps.Auth.ServerIntegration.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,360 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notify.Storage.Mongo.Documents;
|
||||
using StellaOps.Notify.Storage.Mongo.Repositories;
|
||||
|
||||
namespace StellaOps.Notify.WebService.Storage.InMemory;
|
||||
|
||||
internal static class InMemoryStorageModule
|
||||
{
|
||||
public static IServiceCollection AddInMemoryNotifyStorage(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<InMemoryStore>();
|
||||
services.AddSingleton<INotifyRuleRepository, InMemoryRuleRepository>();
|
||||
services.AddSingleton<INotifyChannelRepository, InMemoryChannelRepository>();
|
||||
services.AddSingleton<INotifyTemplateRepository, InMemoryTemplateRepository>();
|
||||
services.AddSingleton<INotifyDeliveryRepository, InMemoryDeliveryRepository>();
|
||||
services.AddSingleton<INotifyDigestRepository, InMemoryDigestRepository>();
|
||||
services.AddSingleton<INotifyLockRepository, InMemoryLockRepository>();
|
||||
services.AddSingleton<INotifyAuditRepository, InMemoryAuditRepository>();
|
||||
return services;
|
||||
}
|
||||
|
||||
private sealed class InMemoryStore
|
||||
{
|
||||
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyRule>> Rules { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyChannel>> Channels { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyTemplate>> Templates { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyDelivery>> Deliveries { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public ConcurrentDictionary<string, ConcurrentDictionary<string, NotifyDigestDocument>> Digests { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public ConcurrentDictionary<string, ConcurrentDictionary<string, LockEntry>> Locks { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public ConcurrentDictionary<string, ConcurrentQueue<NotifyAuditEntryDocument>> AuditEntries { get; } = new(StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
private sealed class InMemoryRuleRepository : INotifyRuleRepository
|
||||
{
|
||||
private readonly InMemoryStore _store;
|
||||
|
||||
public InMemoryRuleRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
|
||||
public Task UpsertAsync(NotifyRule rule, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var map = _store.Rules.GetOrAdd(rule.TenantId, _ => new ConcurrentDictionary<string, NotifyRule>(StringComparer.Ordinal));
|
||||
map[rule.RuleId] = rule;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyRule?> GetAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Rules.TryGetValue(tenantId, out var map) && map.TryGetValue(ruleId, out var rule))
|
||||
{
|
||||
return Task.FromResult<NotifyRule?>(rule);
|
||||
}
|
||||
|
||||
return Task.FromResult<NotifyRule?>(null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NotifyRule>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Rules.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<NotifyRule>>(map.Values.OrderBy(static r => r.RuleId, StringComparer.Ordinal).ToList());
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<NotifyRule>>(Array.Empty<NotifyRule>());
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string ruleId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Rules.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
map.TryRemove(ruleId, out _);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryChannelRepository : INotifyChannelRepository
|
||||
{
|
||||
private readonly InMemoryStore _store;
|
||||
|
||||
public InMemoryChannelRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
|
||||
public Task UpsertAsync(NotifyChannel channel, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var map = _store.Channels.GetOrAdd(channel.TenantId, _ => new ConcurrentDictionary<string, NotifyChannel>(StringComparer.Ordinal));
|
||||
map[channel.ChannelId] = channel;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyChannel?> GetAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Channels.TryGetValue(tenantId, out var map) && map.TryGetValue(channelId, out var channel))
|
||||
{
|
||||
return Task.FromResult<NotifyChannel?>(channel);
|
||||
}
|
||||
|
||||
return Task.FromResult<NotifyChannel?>(null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NotifyChannel>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Channels.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<NotifyChannel>>(map.Values.OrderBy(static c => c.ChannelId, StringComparer.Ordinal).ToList());
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<NotifyChannel>>(Array.Empty<NotifyChannel>());
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string channelId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Channels.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
map.TryRemove(channelId, out _);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryTemplateRepository : INotifyTemplateRepository
|
||||
{
|
||||
private readonly InMemoryStore _store;
|
||||
|
||||
public InMemoryTemplateRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
|
||||
public Task UpsertAsync(NotifyTemplate template, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var map = _store.Templates.GetOrAdd(template.TenantId, _ => new ConcurrentDictionary<string, NotifyTemplate>(StringComparer.Ordinal));
|
||||
map[template.TemplateId] = template;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyTemplate?> GetAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Templates.TryGetValue(tenantId, out var map) && map.TryGetValue(templateId, out var template))
|
||||
{
|
||||
return Task.FromResult<NotifyTemplate?>(template);
|
||||
}
|
||||
|
||||
return Task.FromResult<NotifyTemplate?>(null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NotifyTemplate>> ListAsync(string tenantId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Templates.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<NotifyTemplate>>(map.Values.OrderBy(static t => t.TemplateId, StringComparer.Ordinal).ToList());
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<NotifyTemplate>>(Array.Empty<NotifyTemplate>());
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string tenantId, string templateId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Templates.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
map.TryRemove(templateId, out _);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryDeliveryRepository : INotifyDeliveryRepository
|
||||
{
|
||||
private readonly InMemoryStore _store;
|
||||
|
||||
public InMemoryDeliveryRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
|
||||
public Task AppendAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
|
||||
=> UpdateAsync(delivery, cancellationToken);
|
||||
|
||||
public Task UpdateAsync(NotifyDelivery delivery, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var map = _store.Deliveries.GetOrAdd(delivery.TenantId, _ => new ConcurrentDictionary<string, NotifyDelivery>(StringComparer.Ordinal));
|
||||
map[delivery.DeliveryId] = delivery;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyDelivery?> GetAsync(string tenantId, string deliveryId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Deliveries.TryGetValue(tenantId, out var map) && map.TryGetValue(deliveryId, out var delivery))
|
||||
{
|
||||
return Task.FromResult<NotifyDelivery?>(delivery);
|
||||
}
|
||||
|
||||
return Task.FromResult<NotifyDelivery?>(null);
|
||||
}
|
||||
|
||||
public Task<NotifyDeliveryQueryResult> QueryAsync(string tenantId, DateTimeOffset? since, string? status, int? limit, string? continuationToken = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.Deliveries.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
return Task.FromResult(new NotifyDeliveryQueryResult(Array.Empty<NotifyDelivery>(), null));
|
||||
}
|
||||
|
||||
var query = map.Values.AsEnumerable();
|
||||
if (since.HasValue)
|
||||
{
|
||||
query = query.Where(d => d.CreatedAt >= since.Value);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<NotifyDeliveryStatus>(status, true, out var parsed))
|
||||
{
|
||||
query = query.Where(d => d.Status == parsed);
|
||||
}
|
||||
|
||||
query = query.OrderByDescending(d => d.CreatedAt).ThenBy(d => d.DeliveryId, StringComparer.Ordinal);
|
||||
|
||||
if (limit.HasValue && limit.Value > 0)
|
||||
{
|
||||
query = query.Take(limit.Value);
|
||||
}
|
||||
|
||||
var items = query.ToList();
|
||||
return Task.FromResult(new NotifyDeliveryQueryResult(items, ContinuationToken: null));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryDigestRepository : INotifyDigestRepository
|
||||
{
|
||||
private readonly InMemoryStore _store;
|
||||
|
||||
public InMemoryDigestRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
|
||||
public Task UpsertAsync(NotifyDigestDocument document, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var map = _store.Digests.GetOrAdd(document.TenantId, _ => new ConcurrentDictionary<string, NotifyDigestDocument>(StringComparer.Ordinal));
|
||||
map[document.ActionKey] = document;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<NotifyDigestDocument?> GetAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Digests.TryGetValue(tenantId, out var map) && map.TryGetValue(actionKey, out var document))
|
||||
{
|
||||
return Task.FromResult<NotifyDigestDocument?>(document);
|
||||
}
|
||||
|
||||
return Task.FromResult<NotifyDigestDocument?>(null);
|
||||
}
|
||||
|
||||
public Task RemoveAsync(string tenantId, string actionKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Digests.TryGetValue(tenantId, out var map))
|
||||
{
|
||||
map.TryRemove(actionKey, out _);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryLockRepository : INotifyLockRepository
|
||||
{
|
||||
private readonly InMemoryStore _store;
|
||||
|
||||
public InMemoryLockRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
|
||||
public Task<bool> TryAcquireAsync(string tenantId, string resource, string owner, TimeSpan ttl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var map = _store.Locks.GetOrAdd(tenantId, _ => new ConcurrentDictionary<string, LockEntry>(StringComparer.Ordinal));
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var entry = map.GetOrAdd(resource, _ => new LockEntry(owner, now, now.Add(ttl)));
|
||||
|
||||
lock (entry)
|
||||
{
|
||||
if (entry.Owner == owner || entry.ExpiresAt <= now)
|
||||
{
|
||||
entry.Owner = owner;
|
||||
entry.AcquiredAt = now;
|
||||
entry.ExpiresAt = now.Add(ttl);
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
|
||||
public Task ReleaseAsync(string tenantId, string resource, string owner, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_store.Locks.TryGetValue(tenantId, out var map) && map.TryGetValue(resource, out var entry))
|
||||
{
|
||||
lock (entry)
|
||||
{
|
||||
if (entry.Owner == owner)
|
||||
{
|
||||
map.TryRemove(resource, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class InMemoryAuditRepository : INotifyAuditRepository
|
||||
{
|
||||
private readonly InMemoryStore _store;
|
||||
|
||||
public InMemoryAuditRepository(InMemoryStore store) => _store = store ?? throw new ArgumentNullException(nameof(store));
|
||||
|
||||
public Task AppendAsync(NotifyAuditEntryDocument entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var queue = _store.AuditEntries.GetOrAdd(entry.TenantId, _ => new ConcurrentQueue<NotifyAuditEntryDocument>());
|
||||
queue.Enqueue(entry);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<NotifyAuditEntryDocument>> QueryAsync(string tenantId, DateTimeOffset? since, int? limit, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!_store.AuditEntries.TryGetValue(tenantId, out var queue))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<NotifyAuditEntryDocument>>(Array.Empty<NotifyAuditEntryDocument>());
|
||||
}
|
||||
|
||||
var items = queue
|
||||
.Where(entry => !since.HasValue || entry.Timestamp >= since.Value)
|
||||
.OrderByDescending(entry => entry.Timestamp)
|
||||
.ThenBy(entry => entry.Id.ToString(), StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (limit.HasValue && limit.Value > 0 && items.Count > limit.Value)
|
||||
{
|
||||
items = items.Take(limit.Value).ToList();
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<NotifyAuditEntryDocument>>(items);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class LockEntry
|
||||
{
|
||||
public LockEntry(string owner, DateTimeOffset acquiredAt, DateTimeOffset expiresAt)
|
||||
{
|
||||
Owner = owner;
|
||||
AcquiredAt = acquiredAt;
|
||||
ExpiresAt = expiresAt;
|
||||
}
|
||||
|
||||
public string Owner { get; set; }
|
||||
|
||||
public DateTimeOffset AcquiredAt { get; set; }
|
||||
|
||||
public DateTimeOffset ExpiresAt { get; set; }
|
||||
}
|
||||
}
|
||||
2
src/Notify/StellaOps.Notify.WebService/TASKS.md
Normal file
2
src/Notify/StellaOps.Notify.WebService/TASKS.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# Notify WebService Task Board (Sprint 15)
|
||||
> Archived 2025-10-26 — control plane now lives in `src/Notifier/StellaOps.Notifier` (Sprints 38–40).
|
||||
Reference in New Issue
Block a user