Files
git.stella-ops.org/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Endpoints/TemplateEndpoints.cs
StellaOps Bot ef6e4b2067
Some checks failed
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Docs CI / lint-and-preview (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Merge branch 'main' of https://git.stella-ops.org/stella-ops.org/git.stella-ops.org
2025-11-27 21:45:32 +02:00

421 lines
15 KiB
C#

using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Notify.Models;
using StellaOps.Notify.Storage.Mongo.Documents;
using StellaOps.Notify.Storage.Mongo.Repositories;
using StellaOps.Notifier.WebService.Contracts;
using StellaOps.Notifier.Worker.Dispatch;
using StellaOps.Notifier.Worker.Templates;
namespace StellaOps.Notifier.WebService.Endpoints;
/// <summary>
/// Maps template management endpoints.
/// </summary>
public static class TemplateEndpoints
{
public static IEndpointRouteBuilder MapTemplateEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v2/templates")
.WithTags("Templates");
group.MapGet("/", ListTemplatesAsync)
.WithName("ListTemplates")
.WithSummary("Lists all templates for a tenant");
group.MapGet("/{templateId}", GetTemplateAsync)
.WithName("GetTemplate")
.WithSummary("Gets a template by ID");
group.MapPost("/", CreateTemplateAsync)
.WithName("CreateTemplate")
.WithSummary("Creates a new template");
group.MapPut("/{templateId}", UpdateTemplateAsync)
.WithName("UpdateTemplate")
.WithSummary("Updates an existing template");
group.MapDelete("/{templateId}", DeleteTemplateAsync)
.WithName("DeleteTemplate")
.WithSummary("Deletes a template");
group.MapPost("/preview", PreviewTemplateAsync)
.WithName("PreviewTemplate")
.WithSummary("Previews a template rendering");
return app;
}
private static async Task<IResult> ListTemplatesAsync(
HttpContext context,
INotifyTemplateRepository templates,
string? keyPrefix = null,
string? channelType = null,
string? locale = null,
int? limit = null)
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var allTemplates = await templates.ListAsync(tenantId, context.RequestAborted);
IEnumerable<NotifyTemplate> filtered = allTemplates;
if (!string.IsNullOrWhiteSpace(keyPrefix))
{
filtered = filtered.Where(t => t.Key.StartsWith(keyPrefix, StringComparison.OrdinalIgnoreCase));
}
if (!string.IsNullOrWhiteSpace(channelType) && Enum.TryParse<NotifyChannelType>(channelType, true, out var ct))
{
filtered = filtered.Where(t => t.ChannelType == ct);
}
if (!string.IsNullOrWhiteSpace(locale))
{
filtered = filtered.Where(t => t.Locale.Equals(locale, StringComparison.OrdinalIgnoreCase));
}
if (limit.HasValue && limit.Value > 0)
{
filtered = filtered.Take(limit.Value);
}
var response = filtered.Select(MapToResponse).ToList();
return Results.Ok(response);
}
private static async Task<IResult> GetTemplateAsync(
HttpContext context,
string templateId,
INotifyTemplateRepository templates)
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var template = await templates.GetAsync(tenantId, templateId, context.RequestAborted);
if (template is null)
{
return Results.NotFound(Error("template_not_found", $"Template '{templateId}' not found.", context));
}
return Results.Ok(MapToResponse(template));
}
private static async Task<IResult> CreateTemplateAsync(
HttpContext context,
TemplateCreateRequest request,
INotifyTemplateRepository templates,
INotifyTemplateService? templateService,
INotifyAuditRepository audit,
TimeProvider timeProvider)
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var actor = GetActor(context);
// Validate template body
if (templateService is not null)
{
var validation = templateService.Validate(request.Body);
if (!validation.IsValid)
{
return Results.BadRequest(Error("invalid_template", string.Join("; ", validation.Errors), context));
}
}
// Check if template already exists
var existing = await templates.GetAsync(tenantId, request.TemplateId, context.RequestAborted);
if (existing is not null)
{
return Results.Conflict(Error("template_exists", $"Template '{request.TemplateId}' already exists.", context));
}
var template = MapFromRequest(request, tenantId, actor, timeProvider);
await templates.UpsertAsync(template, context.RequestAborted);
await AppendAuditAsync(audit, tenantId, actor, "template.created", request.TemplateId, "template", request, timeProvider, context.RequestAborted);
return Results.Created($"/api/v2/templates/{template.TemplateId}", MapToResponse(template));
}
private static async Task<IResult> UpdateTemplateAsync(
HttpContext context,
string templateId,
TemplateCreateRequest request,
INotifyTemplateRepository templates,
INotifyTemplateService? templateService,
INotifyAuditRepository audit,
TimeProvider timeProvider)
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var actor = GetActor(context);
// Validate template body
if (templateService is not null)
{
var validation = templateService.Validate(request.Body);
if (!validation.IsValid)
{
return Results.BadRequest(Error("invalid_template", string.Join("; ", validation.Errors), context));
}
}
var existing = await templates.GetAsync(tenantId, templateId, context.RequestAborted);
if (existing is null)
{
return Results.NotFound(Error("template_not_found", $"Template '{templateId}' not found.", context));
}
var updated = MapFromRequest(request with { TemplateId = templateId }, tenantId, actor, timeProvider, existing);
await templates.UpsertAsync(updated, context.RequestAborted);
await AppendAuditAsync(audit, tenantId, actor, "template.updated", templateId, "template", request, timeProvider, context.RequestAborted);
return Results.Ok(MapToResponse(updated));
}
private static async Task<IResult> DeleteTemplateAsync(
HttpContext context,
string templateId,
INotifyTemplateRepository templates,
INotifyAuditRepository audit,
TimeProvider timeProvider)
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var actor = GetActor(context);
var existing = await templates.GetAsync(tenantId, templateId, context.RequestAborted);
if (existing is null)
{
return Results.NotFound(Error("template_not_found", $"Template '{templateId}' not found.", context));
}
await templates.DeleteAsync(tenantId, templateId, context.RequestAborted);
await AppendAuditAsync(audit, tenantId, actor, "template.deleted", templateId, "template", new { templateId }, timeProvider, context.RequestAborted);
return Results.NoContent();
}
private static async Task<IResult> PreviewTemplateAsync(
HttpContext context,
TemplatePreviewRequest request,
INotifyTemplateRepository templates,
INotifyTemplateRenderer renderer,
INotifyTemplateService? templateService,
TimeProvider timeProvider)
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
NotifyTemplate? template = null;
if (!string.IsNullOrWhiteSpace(request.TemplateId))
{
template = await templates.GetAsync(tenantId, request.TemplateId, context.RequestAborted);
if (template is null)
{
return Results.NotFound(Error("template_not_found", $"Template '{request.TemplateId}' not found.", context));
}
}
else if (!string.IsNullOrWhiteSpace(request.TemplateBody))
{
// Create a temporary template for preview
var format = Enum.TryParse<NotifyDeliveryFormat>(request.OutputFormat, true, out var f)
? f
: NotifyDeliveryFormat.PlainText;
template = NotifyTemplate.Create(
templateId: "preview",
tenantId: tenantId,
channelType: NotifyChannelType.Custom,
key: "preview",
locale: "en-us",
body: request.TemplateBody,
format: format);
}
else
{
return Results.BadRequest(Error("invalid_request", "Either templateId or templateBody is required.", context));
}
// Validate template body
List<string>? warnings = null;
if (templateService is not null)
{
var validation = templateService.Validate(template.Body);
if (!validation.IsValid)
{
return Results.BadRequest(Error("invalid_template", string.Join("; ", validation.Errors), context));
}
warnings = validation.Warnings.ToList();
}
// Create sample event
var sampleEvent = NotifyEvent.Create(
eventId: Guid.NewGuid(),
kind: request.EventKind ?? "sample.event",
tenant: tenantId,
ts: timeProvider.GetUtcNow(),
payload: request.SamplePayload ?? new JsonObject(),
attributes: request.SampleAttributes ?? new Dictionary<string, string>(),
actor: "preview",
version: "1");
var rendered = await renderer.RenderAsync(template, sampleEvent, context.RequestAborted);
return Results.Ok(new TemplatePreviewResponse
{
RenderedBody = rendered.Body,
RenderedSubject = rendered.Subject,
BodyHash = rendered.BodyHash,
Format = rendered.Format.ToString(),
Warnings = warnings?.Count > 0 ? warnings : null
});
}
private static NotifyTemplate MapFromRequest(
TemplateCreateRequest request,
string tenantId,
string actor,
TimeProvider timeProvider,
NotifyTemplate? existing = null)
{
var now = timeProvider.GetUtcNow();
var channelType = Enum.TryParse<NotifyChannelType>(request.ChannelType, true, out var ct)
? ct
: NotifyChannelType.Custom;
var renderMode = Enum.TryParse<NotifyTemplateRenderMode>(request.RenderMode, true, out var rm)
? rm
: NotifyTemplateRenderMode.Markdown;
var format = Enum.TryParse<NotifyDeliveryFormat>(request.Format, true, out var f)
? f
: NotifyDeliveryFormat.PlainText;
return NotifyTemplate.Create(
templateId: request.TemplateId,
tenantId: tenantId,
channelType: channelType,
key: request.Key,
locale: request.Locale,
body: request.Body,
renderMode: renderMode,
format: format,
description: request.Description,
metadata: request.Metadata,
createdBy: existing?.CreatedBy ?? actor,
createdAt: existing?.CreatedAt ?? now,
updatedBy: actor,
updatedAt: now);
}
private static TemplateResponse MapToResponse(NotifyTemplate template)
{
return new TemplateResponse
{
TemplateId = template.TemplateId,
TenantId = template.TenantId,
Key = template.Key,
ChannelType = template.ChannelType.ToString(),
Locale = template.Locale,
Body = template.Body,
RenderMode = template.RenderMode.ToString(),
Format = template.Format.ToString(),
Description = template.Description,
Metadata = template.Metadata.ToDictionary(kvp => kvp.Key, kvp => kvp.Value),
CreatedBy = template.CreatedBy,
CreatedAt = template.CreatedAt,
UpdatedBy = template.UpdatedBy,
UpdatedAt = template.UpdatedAt
};
}
private static string? GetTenantId(HttpContext context)
{
var tenantId = context.Request.Headers["X-StellaOps-Tenant"].ToString();
return string.IsNullOrWhiteSpace(tenantId) ? null : tenantId;
}
private static string GetActor(HttpContext context)
{
var actor = context.Request.Headers["X-StellaOps-Actor"].ToString();
return string.IsNullOrWhiteSpace(actor) ? "api" : actor;
}
private static async Task AppendAuditAsync(
INotifyAuditRepository audit,
string tenantId,
string actor,
string action,
string entityId,
string entityType,
object payload,
TimeProvider timeProvider,
CancellationToken cancellationToken)
{
try
{
var entry = new NotifyAuditEntryDocument
{
TenantId = tenantId,
Actor = actor,
Action = action,
EntityId = entityId,
EntityType = entityType,
Timestamp = timeProvider.GetUtcNow(),
Payload = MongoDB.Bson.Serialization.BsonSerializer.Deserialize<MongoDB.Bson.BsonDocument>(
JsonSerializer.Serialize(payload))
};
await audit.AppendAsync(entry, cancellationToken);
}
catch
{
// Ignore audit failures
}
}
private static object Error(string code, string message, HttpContext context) => new
{
error = new
{
code,
message,
traceId = context.TraceIdentifier
}
};
}