up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Policy Simulation / policy-simulate (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,420 @@
|
||||
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
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user