Restructure solution layout by module
This commit is contained in:
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user