using System.ComponentModel.DataAnnotations; using System.IO; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using StellaOps.Scheduler.WebService.Options; namespace StellaOps.Scheduler.WebService.EventWebhooks; public static class EventWebhookEndpointExtensions { public static void MapSchedulerEventWebhookEndpoints(this IEndpointRouteBuilder builder) { var group = builder.MapGroup("/events"); group.MapPost("/conselier-export", HandleConselierExportAsync); group.MapPost("/excitor-export", HandleExcitorExportAsync); } private static async Task HandleConselierExportAsync( HttpContext httpContext, [FromServices] IOptionsMonitor options, [FromServices] IWebhookRequestAuthenticator authenticator, [FromServices] IWebhookRateLimiter rateLimiter, [FromServices] IInboundExportEventSink sink, CancellationToken cancellationToken) { var webhookOptions = options.CurrentValue.Webhooks.Conselier; if (!webhookOptions.Enabled) { return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); } var readResult = await ReadPayloadAsync(httpContext, cancellationToken).ConfigureAwait(false); if (!readResult.Succeeded) { return readResult.ErrorResult!; } if (!rateLimiter.TryAcquire("conselier", webhookOptions.RateLimitRequests, webhookOptions.GetRateLimitWindow(), out var retryAfter)) { var response = Results.StatusCode(StatusCodes.Status429TooManyRequests); if (retryAfter > TimeSpan.Zero) { httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(); } return response; } var authResult = await authenticator.AuthenticateAsync(httpContext, readResult.RawBody, webhookOptions, cancellationToken).ConfigureAwait(false); if (!authResult.Succeeded) { return authResult.ToResult(); } try { await sink.HandleConselierAsync(readResult.Payload!, cancellationToken).ConfigureAwait(false); return Results.Accepted(value: new { status = "accepted" }); } catch (ValidationException ex) { return Results.BadRequest(new { error = ex.Message }); } } private static async Task HandleExcitorExportAsync( HttpContext httpContext, [FromServices] IOptionsMonitor options, [FromServices] IWebhookRequestAuthenticator authenticator, [FromServices] IWebhookRateLimiter rateLimiter, [FromServices] IInboundExportEventSink sink, CancellationToken cancellationToken) { var webhookOptions = options.CurrentValue.Webhooks.Excitor; if (!webhookOptions.Enabled) { return Results.StatusCode(StatusCodes.Status503ServiceUnavailable); } var readResult = await ReadPayloadAsync(httpContext, cancellationToken).ConfigureAwait(false); if (!readResult.Succeeded) { return readResult.ErrorResult!; } if (!rateLimiter.TryAcquire("excitor", webhookOptions.RateLimitRequests, webhookOptions.GetRateLimitWindow(), out var retryAfter)) { var response = Results.StatusCode(StatusCodes.Status429TooManyRequests); if (retryAfter > TimeSpan.Zero) { httpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(); } return response; } var authResult = await authenticator.AuthenticateAsync(httpContext, readResult.RawBody, webhookOptions, cancellationToken).ConfigureAwait(false); if (!authResult.Succeeded) { return authResult.ToResult(); } try { await sink.HandleExcitorAsync(readResult.Payload!, cancellationToken).ConfigureAwait(false); return Results.Accepted(value: new { status = "accepted" }); } catch (ValidationException ex) { return Results.BadRequest(new { error = ex.Message }); } } private static async Task> ReadPayloadAsync(HttpContext context, CancellationToken cancellationToken) { context.Request.EnableBuffering(); await using var buffer = new MemoryStream(); await context.Request.Body.CopyToAsync(buffer, cancellationToken).ConfigureAwait(false); var bodyBytes = buffer.ToArray(); context.Request.Body.Position = 0; try { var payload = JsonSerializer.Deserialize(bodyBytes, new JsonSerializerOptions(JsonSerializerDefaults.Web)); if (payload is null) { return RequestPayload.Failed(Results.BadRequest(new { error = "Request payload cannot be empty." })); } return RequestPayload.Success(payload, bodyBytes); } catch (JsonException ex) { return RequestPayload.Failed(Results.BadRequest(new { error = ex.Message })); } catch (ValidationException ex) { return RequestPayload.Failed(Results.BadRequest(new { error = ex.Message })); } } private readonly struct RequestPayload { private RequestPayload(T? payload, byte[] rawBody, IResult? error, bool succeeded) { Payload = payload; RawBody = rawBody; ErrorResult = error; Succeeded = succeeded; } public T? Payload { get; } public byte[] RawBody { get; } public IResult? ErrorResult { get; } public bool Succeeded { get; } public static RequestPayload Success(T payload, byte[] rawBody) => new(payload, rawBody, null, true); public static RequestPayload Failed(IResult error) => new(default, Array.Empty(), error, false); } }