Files
git.stella-ops.org/src/Notifier/StellaOps.Notifier/StellaOps.Notifier.WebService/Endpoints/NotifyApiEndpoints.cs
StellaOps Bot 564df71bfb
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
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
up
2025-12-13 00:20:26 +02:00

720 lines
26 KiB
C#

using System.Collections.Immutable;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using StellaOps.Notifier.WebService.Contracts;
using StellaOps.Notifier.Worker.Dispatch;
using StellaOps.Notifier.Worker.Templates;
using StellaOps.Notify.Models;
using StellaOps.Notifier.Worker.Storage;
using StellaOps.Notifier.WebService.Extensions;
namespace StellaOps.Notifier.WebService.Endpoints;
/// <summary>
/// API endpoints for rules, templates, and incidents management.
/// </summary>
public static class NotifyApiEndpoints
{
/// <summary>
/// Maps all Notify API v2 endpoints.
/// </summary>
public static IEndpointRouteBuilder MapNotifyApiV2(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v2/notify")
.WithTags("Notify")
.WithOpenApi();
// Rules CRUD
MapRulesEndpoints(group);
// Templates CRUD + Preview
MapTemplatesEndpoints(group);
// Incidents
MapIncidentsEndpoints(group);
return app;
}
private static void MapRulesEndpoints(RouteGroupBuilder group)
{
group.MapGet("/rules", async (
HttpContext context,
INotifyRuleRepository ruleRepository,
CancellationToken cancellationToken) =>
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var rules = await ruleRepository.ListAsync(tenantId, cancellationToken);
var response = rules.Select(MapRuleToResponse).ToList();
return Results.Ok(response);
});
group.MapGet("/rules/{ruleId}", async (
HttpContext context,
string ruleId,
INotifyRuleRepository ruleRepository,
CancellationToken cancellationToken) =>
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var rule = await ruleRepository.GetAsync(tenantId, ruleId, cancellationToken);
if (rule is null)
{
return Results.NotFound(Error("rule_not_found", $"Rule {ruleId} not found.", context));
}
return Results.Ok(MapRuleToResponse(rule));
});
group.MapPost("/rules", async (
HttpContext context,
RuleCreateRequest request,
INotifyRuleRepository ruleRepository,
INotifyAuditRepository auditRepository,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
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 now = timeProvider.GetUtcNow();
var rule = MapRequestToRule(request, tenantId, actor, now);
await ruleRepository.UpsertAsync(rule, cancellationToken);
await AuditAsync(auditRepository, tenantId, "rule.created", actor, new Dictionary<string, string>
{
["ruleId"] = rule.RuleId,
["name"] = rule.Name
}, cancellationToken);
return Results.Created($"/api/v2/notify/rules/{rule.RuleId}", MapRuleToResponse(rule));
});
group.MapPut("/rules/{ruleId}", async (
HttpContext context,
string ruleId,
RuleUpdateRequest request,
INotifyRuleRepository ruleRepository,
INotifyAuditRepository auditRepository,
TimeProvider timeProvider,
CancellationToken cancellationToken) =>
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var existing = await ruleRepository.GetAsync(tenantId, ruleId, cancellationToken);
if (existing is null)
{
return Results.NotFound(Error("rule_not_found", $"Rule {ruleId} not found.", context));
}
var actor = GetActor(context);
var now = timeProvider.GetUtcNow();
var updated = ApplyRuleUpdate(existing, request, actor, now);
await ruleRepository.UpsertAsync(updated, cancellationToken);
await AuditAsync(auditRepository, tenantId, "rule.updated", actor, new Dictionary<string, string>
{
["ruleId"] = updated.RuleId,
["name"] = updated.Name
}, cancellationToken);
return Results.Ok(MapRuleToResponse(updated));
});
group.MapDelete("/rules/{ruleId}", async (
HttpContext context,
string ruleId,
INotifyRuleRepository ruleRepository,
INotifyAuditRepository auditRepository,
CancellationToken cancellationToken) =>
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var existing = await ruleRepository.GetAsync(tenantId, ruleId, cancellationToken);
if (existing is null)
{
return Results.NotFound(Error("rule_not_found", $"Rule {ruleId} not found.", context));
}
var actor = GetActor(context);
await ruleRepository.DeleteAsync(tenantId, ruleId, cancellationToken);
await AuditAsync(auditRepository, tenantId, "rule.deleted", actor, new Dictionary<string, string>
{
["ruleId"] = ruleId
}, cancellationToken);
return Results.NoContent();
});
}
private static void MapTemplatesEndpoints(RouteGroupBuilder group)
{
group.MapGet("/templates", async (
HttpContext context,
string? keyPrefix,
string? channelType,
string? locale,
int? limit,
INotifyTemplateService templateService,
CancellationToken cancellationToken) =>
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
NotifyChannelType? channelTypeEnum = null;
if (!string.IsNullOrWhiteSpace(channelType) &&
Enum.TryParse<NotifyChannelType>(channelType, true, out var parsed))
{
channelTypeEnum = parsed;
}
var templates = await templateService.ListAsync(tenantId, new TemplateListOptions
{
KeyPrefix = keyPrefix,
ChannelType = channelTypeEnum,
Locale = locale,
Limit = limit
}, cancellationToken);
var response = templates.Select(MapTemplateToResponse).ToList();
return Results.Ok(response);
});
group.MapGet("/templates/{templateId}", async (
HttpContext context,
string templateId,
INotifyTemplateService templateService,
CancellationToken cancellationToken) =>
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var template = await templateService.GetByIdAsync(tenantId, templateId, cancellationToken);
if (template is null)
{
return Results.NotFound(Error("template_not_found", $"Template {templateId} not found.", context));
}
return Results.Ok(MapTemplateToResponse(template));
});
group.MapPost("/templates", async (
HttpContext context,
TemplateCreateRequest request,
INotifyTemplateService templateService,
CancellationToken cancellationToken) =>
{
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);
if (!Enum.TryParse<NotifyChannelType>(request.ChannelType, true, out var channelType))
{
return Results.BadRequest(Error("invalid_channel_type", $"Invalid channel type: {request.ChannelType}", context));
}
var renderMode = NotifyTemplateRenderMode.Markdown;
if (!string.IsNullOrWhiteSpace(request.RenderMode) &&
Enum.TryParse<NotifyTemplateRenderMode>(request.RenderMode, true, out var parsedMode))
{
renderMode = parsedMode;
}
var format = NotifyDeliveryFormat.Json;
if (!string.IsNullOrWhiteSpace(request.Format) &&
Enum.TryParse<NotifyDeliveryFormat>(request.Format, true, out var parsedFormat))
{
format = parsedFormat;
}
var template = 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);
var result = await templateService.UpsertAsync(template, actor, cancellationToken);
if (!result.Success)
{
return Results.BadRequest(Error("template_validation_failed", result.Error ?? "Validation failed.", context));
}
var created = await templateService.GetByIdAsync(tenantId, request.TemplateId, cancellationToken);
return result.IsNew
? Results.Created($"/api/v2/notify/templates/{request.TemplateId}", MapTemplateToResponse(created!))
: Results.Ok(MapTemplateToResponse(created!));
});
group.MapDelete("/templates/{templateId}", async (
HttpContext context,
string templateId,
INotifyTemplateService templateService,
CancellationToken cancellationToken) =>
{
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 deleted = await templateService.DeleteAsync(tenantId, templateId, actor, cancellationToken);
if (!deleted)
{
return Results.NotFound(Error("template_not_found", $"Template {templateId} not found.", context));
}
return Results.NoContent();
});
group.MapPost("/templates/preview", async (
HttpContext context,
TemplatePreviewRequest request,
INotifyTemplateService templateService,
INotifyTemplateRenderer templateRenderer,
CancellationToken cancellationToken) =>
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
NotifyTemplate? template = null;
List<string>? warnings = null;
if (!string.IsNullOrWhiteSpace(request.TemplateId))
{
template = await templateService.GetByIdAsync(tenantId, request.TemplateId, cancellationToken);
if (template is null)
{
return Results.NotFound(Error("template_not_found", $"Template {request.TemplateId} not found.", context));
}
}
else if (!string.IsNullOrWhiteSpace(request.TemplateBody))
{
var validation = templateService.Validate(request.TemplateBody);
if (!validation.IsValid)
{
return Results.BadRequest(Error("template_invalid", string.Join("; ", validation.Errors), context));
}
warnings = validation.Warnings.ToList();
var format = NotifyDeliveryFormat.PlainText;
if (!string.IsNullOrWhiteSpace(request.OutputFormat) &&
Enum.TryParse<NotifyDeliveryFormat>(request.OutputFormat, true, out var parsedFormat))
{
format = parsedFormat;
}
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("template_required", "Either templateId or templateBody must be provided.", context));
}
var sampleEvent = NotifyEvent.Create(
eventId: Guid.NewGuid(),
kind: request.EventKind ?? "preview.event",
tenant: tenantId,
ts: DateTimeOffset.UtcNow,
payload: request.SamplePayload ?? new JsonObject(),
attributes: request.SampleAttributes ?? new Dictionary<string, string>(),
actor: "preview",
version: "1");
var rendered = await templateRenderer.RenderAsync(template, sampleEvent, cancellationToken);
return Results.Ok(new TemplatePreviewResponse
{
RenderedBody = rendered.Body,
RenderedSubject = rendered.Subject,
BodyHash = rendered.BodyHash,
Format = rendered.Format.ToString(),
Warnings = warnings
});
});
group.MapPost("/templates/validate", (
HttpContext context,
TemplatePreviewRequest request,
INotifyTemplateService templateService) =>
{
if (string.IsNullOrWhiteSpace(request.TemplateBody))
{
return Results.BadRequest(Error("template_body_required", "templateBody is required.", context));
}
var result = templateService.Validate(request.TemplateBody);
return Results.Ok(new
{
isValid = result.IsValid,
errors = result.Errors,
warnings = result.Warnings
});
});
}
private static void MapIncidentsEndpoints(RouteGroupBuilder group)
{
group.MapGet("/incidents", async (
HttpContext context,
string? status,
string? eventKindPrefix,
DateTimeOffset? since,
DateTimeOffset? until,
int? limit,
string? cursor,
INotifyDeliveryRepository deliveryRepository,
CancellationToken cancellationToken) =>
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
// For now, return recent deliveries grouped by event kind as "incidents"
// Full incident correlation will be implemented in NOTIFY-SVC-39-001
var queryResult = await deliveryRepository.QueryAsync(tenantId, since, status, limit ?? 100, cursor, cancellationToken);
var deliveries = queryResult.Items;
var incidents = deliveries
.GroupBy(d => d.EventId)
.Select(g => new IncidentResponse
{
IncidentId = g.Key.ToString(),
TenantId = tenantId,
EventKind = g.First().Kind,
Status = g.All(d => d.Status == NotifyDeliveryStatus.Delivered) ? "resolved" : "open",
Severity = "medium",
Title = $"Notification: {g.First().Kind}",
Description = null,
EventCount = g.Count(),
FirstOccurrence = g.Min(d => d.CreatedAt),
LastOccurrence = g.Max(d => d.CreatedAt),
Labels = null,
Metadata = null
})
.ToList();
return Results.Ok(new IncidentListResponse
{
Incidents = incidents,
TotalCount = incidents.Count,
NextCursor = queryResult.ContinuationToken
});
});
group.MapPost("/incidents/{incidentId}/ack", async (
HttpContext context,
string incidentId,
IncidentAckRequest request,
INotifyAuditRepository auditRepository,
CancellationToken cancellationToken) =>
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var actor = request.Actor ?? GetActor(context);
await AuditAsync(auditRepository, tenantId, "incident.acknowledged", actor, new Dictionary<string, string>
{
["incidentId"] = incidentId,
["comment"] = request.Comment ?? ""
}, cancellationToken);
return Results.NoContent();
});
group.MapPost("/incidents/{incidentId}/resolve", async (
HttpContext context,
string incidentId,
IncidentResolveRequest request,
INotifyAuditRepository auditRepository,
CancellationToken cancellationToken) =>
{
var tenantId = GetTenantId(context);
if (tenantId is null)
{
return Results.BadRequest(Error("tenant_missing", "X-StellaOps-Tenant header is required.", context));
}
var actor = request.Actor ?? GetActor(context);
await AuditAsync(auditRepository, tenantId, "incident.resolved", actor, new Dictionary<string, string>
{
["incidentId"] = incidentId,
["reason"] = request.Reason ?? "",
["comment"] = request.Comment ?? ""
}, cancellationToken);
return Results.NoContent();
});
}
#region Helpers
private static string? GetTenantId(HttpContext context)
{
var value = context.Request.Headers["X-StellaOps-Tenant"].ToString();
return string.IsNullOrWhiteSpace(value) ? null : value;
}
private static string GetActor(HttpContext context)
{
return context.Request.Headers["X-StellaOps-Actor"].ToString() is { Length: > 0 } actor
? actor
: "api";
}
private static object Error(string code, string message, HttpContext context) => new
{
error = new
{
code,
message,
traceId = context.TraceIdentifier
}
};
private static async Task AuditAsync(
INotifyAuditRepository repository,
string tenantId,
string action,
string actor,
Dictionary<string, string> metadata,
CancellationToken cancellationToken)
{
try
{
await repository.AppendAsync(tenantId, action, actor, metadata, cancellationToken);
}
catch
{
// Ignore audit failures
}
}
#endregion
#region Mappers
private static RuleResponse MapRuleToResponse(NotifyRule rule)
{
return new RuleResponse
{
RuleId = rule.RuleId,
TenantId = rule.TenantId,
Name = rule.Name,
Description = rule.Description,
Enabled = rule.Enabled,
Match = new RuleMatchResponse
{
EventKinds = rule.Match.EventKinds.ToList(),
Namespaces = rule.Match.Namespaces.ToList(),
Repositories = rule.Match.Repositories.ToList(),
Digests = rule.Match.Digests.ToList(),
Labels = rule.Match.Labels.ToList(),
ComponentPurls = rule.Match.ComponentPurls.ToList(),
MinSeverity = rule.Match.MinSeverity,
Verdicts = rule.Match.Verdicts.ToList(),
KevOnly = rule.Match.KevOnly ?? false
},
Actions = rule.Actions.Select(a => new RuleActionResponse
{
ActionId = a.ActionId,
Channel = a.Channel,
Template = a.Template,
Digest = a.Digest,
Throttle = a.Throttle?.ToString(),
Locale = a.Locale,
Enabled = a.Enabled,
Metadata = a.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value)
}).ToList(),
Labels = rule.Labels.ToDictionary(kv => kv.Key, kv => kv.Value),
Metadata = rule.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value),
CreatedBy = rule.CreatedBy,
CreatedAt = rule.CreatedAt,
UpdatedBy = rule.UpdatedBy,
UpdatedAt = rule.UpdatedAt
};
}
private static NotifyRule MapRequestToRule(
RuleCreateRequest request,
string tenantId,
string actor,
DateTimeOffset now)
{
var match = NotifyRuleMatch.Create(
eventKinds: request.Match.EventKinds,
namespaces: request.Match.Namespaces,
repositories: request.Match.Repositories,
digests: request.Match.Digests,
labels: request.Match.Labels,
componentPurls: request.Match.ComponentPurls,
minSeverity: request.Match.MinSeverity,
verdicts: request.Match.Verdicts,
kevOnly: request.Match.KevOnly);
var actions = request.Actions.Select(a => NotifyRuleAction.Create(
actionId: a.ActionId,
channel: a.Channel,
template: a.Template,
digest: a.Digest,
throttle: string.IsNullOrWhiteSpace(a.Throttle) ? null : System.Xml.XmlConvert.ToTimeSpan(a.Throttle),
locale: a.Locale,
enabled: a.Enabled,
metadata: a.Metadata));
return NotifyRule.Create(
ruleId: request.RuleId,
tenantId: tenantId,
name: request.Name,
match: match,
actions: actions,
enabled: request.Enabled,
description: request.Description,
labels: request.Labels,
metadata: request.Metadata,
createdBy: actor,
createdAt: now,
updatedBy: actor,
updatedAt: now);
}
private static NotifyRule ApplyRuleUpdate(
NotifyRule existing,
RuleUpdateRequest request,
string actor,
DateTimeOffset now)
{
var match = request.Match is not null
? NotifyRuleMatch.Create(
eventKinds: request.Match.EventKinds ?? existing.Match.EventKinds.ToList(),
namespaces: request.Match.Namespaces ?? existing.Match.Namespaces.ToList(),
repositories: request.Match.Repositories ?? existing.Match.Repositories.ToList(),
digests: request.Match.Digests ?? existing.Match.Digests.ToList(),
labels: request.Match.Labels ?? existing.Match.Labels.ToList(),
componentPurls: request.Match.ComponentPurls ?? existing.Match.ComponentPurls.ToList(),
minSeverity: request.Match.MinSeverity ?? existing.Match.MinSeverity,
verdicts: request.Match.Verdicts ?? existing.Match.Verdicts.ToList(),
kevOnly: request.Match.KevOnly ?? existing.Match.KevOnly)
: existing.Match;
var actions = request.Actions is not null
? request.Actions.Select(a => NotifyRuleAction.Create(
actionId: a.ActionId,
channel: a.Channel,
template: a.Template,
digest: a.Digest,
throttle: string.IsNullOrWhiteSpace(a.Throttle) ? null : System.Xml.XmlConvert.ToTimeSpan(a.Throttle),
locale: a.Locale,
enabled: a.Enabled,
metadata: a.Metadata))
: existing.Actions;
return NotifyRule.Create(
ruleId: existing.RuleId,
tenantId: existing.TenantId,
name: request.Name ?? existing.Name,
match: match,
actions: actions,
enabled: request.Enabled ?? existing.Enabled,
description: request.Description ?? existing.Description,
labels: request.Labels ?? existing.Labels.ToDictionary(kv => kv.Key, kv => kv.Value),
metadata: request.Metadata ?? existing.Metadata.ToDictionary(kv => kv.Key, kv => kv.Value),
createdBy: existing.CreatedBy,
createdAt: existing.CreatedAt,
updatedBy: actor,
updatedAt: now);
}
private static TemplateResponse MapTemplateToResponse(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(kv => kv.Key, kv => kv.Value),
CreatedBy = template.CreatedBy,
CreatedAt = template.CreatedAt,
UpdatedBy = template.UpdatedBy,
UpdatedAt = template.UpdatedAt
};
}
#endregion
}