diff --git a/samples/WebhookSink/HookCapture.cs b/samples/WebhookSink/HookCapture.cs new file mode 100644 index 0000000..0cf678f --- /dev/null +++ b/samples/WebhookSink/HookCapture.cs @@ -0,0 +1,9 @@ +namespace WebhookSink; + +internal sealed record HookCapture( + Guid Id, + string Bucket, + string Method, + IReadOnlyDictionary Headers, + string Body, + DateTimeOffset ReceivedAt); diff --git a/samples/WebhookSink/HookStore.cs b/samples/WebhookSink/HookStore.cs new file mode 100644 index 0000000..8719854 --- /dev/null +++ b/samples/WebhookSink/HookStore.cs @@ -0,0 +1,54 @@ +namespace WebhookSink; + +internal interface IHookStore +{ + void Add(HookCapture hook); + IReadOnlyList GetAll(); + HookCapture? GetById(Guid id); +} + +internal sealed class HookStore : IHookStore +{ + private readonly int _capacity; + private readonly Queue _ring; + private readonly Dictionary _index; + private readonly Lock _lock = new(); + + public HookStore(int capacity) + { + ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1); + _capacity = capacity; + _ring = new Queue(capacity); + _index = new Dictionary(capacity); + } + + public void Add(HookCapture hook) + { + lock (_lock) + { + if (_ring.Count >= _capacity) + { + var evicted = _ring.Dequeue(); + _index.Remove(evicted.Id); + } + _ring.Enqueue(hook); + _index[hook.Id] = hook; + } + } + + public IReadOnlyList GetAll() + { + lock (_lock) + { + return _ring.Reverse().ToArray(); + } + } + + public HookCapture? GetById(Guid id) + { + lock (_lock) + { + return _index.GetValueOrDefault(id); + } + } +} diff --git a/samples/WebhookSink/Program.cs b/samples/WebhookSink/Program.cs index 0d9491d..4d2e4e8 100644 --- a/samples/WebhookSink/Program.cs +++ b/samples/WebhookSink/Program.cs @@ -1,4 +1,10 @@ +using WebhookSink; + var builder = WebApplication.CreateBuilder(args); + +var capacity = builder.Configuration.GetValue("WebhookSink:StoreCapacity", 200); +builder.Services.AddSingleton(_ => new HookStore(capacity)); + var app = builder.Build(); app.MapGet("/healthz", () => Results.Ok()); @@ -16,6 +22,41 @@ """, "text/html")); +string[] allVerbs = ["DELETE", "GET", "HEAD", "OPTIONS", "PATCH", "POST", "PUT", "TRACE"]; + +app.MapMethods("/in/{bucket}", allVerbs, async ( + string bucket, + HttpRequest request, + IHookStore store, + CancellationToken ct) => +{ + using var reader = new StreamReader(request.Body); + var body = await reader.ReadToEndAsync(ct).ConfigureAwait(false); + + var headers = request.Headers.ToDictionary( + h => h.Key, + h => h.Value.ToString() ?? string.Empty); + + var capture = new HookCapture( + Id: Guid.NewGuid(), + Bucket: bucket, + Method: request.Method, + Headers: headers, + Body: body, + ReceivedAt: DateTimeOffset.UtcNow); + + store.Add(capture); + return Results.Accepted(); +}); + +app.MapGet("/api/hooks", (IHookStore store) => Results.Ok(store.GetAll())); + +app.MapGet("/api/hooks/{id:guid}", (Guid id, IHookStore store) => +{ + var hook = store.GetById(id); + return hook is not null ? Results.Ok(hook) : Results.NotFound(); +}); + app.Run(); public partial class Program { } diff --git a/tests/WebhookSink.Tests/CaptureEndpointTests.cs b/tests/WebhookSink.Tests/CaptureEndpointTests.cs new file mode 100644 index 0000000..949f750 --- /dev/null +++ b/tests/WebhookSink.Tests/CaptureEndpointTests.cs @@ -0,0 +1,87 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using AwesomeAssertions; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace WebhookSink.Tests; + +public sealed class CaptureEndpointTests : IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + private readonly WebApplicationFactory _factory; + private readonly HttpClient _client; + + public CaptureEndpointTests() + { + _factory = new WebApplicationFactory() + .WithWebHostBuilder(b => b.UseSetting("WebhookSink:StoreCapacity", "3")); + _client = _factory.CreateClient(); + } + + public void Dispose() + { + _client.Dispose(); + _factory.Dispose(); + } + + [Fact] + public async Task POST_in_bucket_captures_hook_and_GET_api_hooks_returns_it() + { + var body = """{"event":"order.placed"}"""; + + var postResponse = await _client.PostAsync( + "/in/orders", + new StringContent(body, Encoding.UTF8, "application/json")); + + postResponse.StatusCode.Should().Be(HttpStatusCode.Accepted); + + var getResponse = await _client.GetAsync("/api/hooks"); + getResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var hooks = JsonSerializer.Deserialize>( + await getResponse.Content.ReadAsStringAsync(), JsonOptions)!; + + hooks.Should().ContainSingle(h => h.Bucket == "orders" && h.Body == body && h.Method == "POST"); + } + + [Fact] + public async Task GET_api_hooks_by_id_returns_hook_and_404_for_missing_id() + { + await _client.PostAsync("/in/payments", + new StringContent("ping", Encoding.UTF8, "text/plain")); + + var hooksResponse = await _client.GetAsync("/api/hooks"); + var hooks = JsonSerializer.Deserialize>( + await hooksResponse.Content.ReadAsStringAsync(), JsonOptions)!; + + var id = hooks[0].Id; + + var found = await _client.GetAsync($"/api/hooks/{id}"); + found.StatusCode.Should().Be(HttpStatusCode.OK); + + var missing = await _client.GetAsync($"/api/hooks/{Guid.NewGuid()}"); + missing.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task Ring_evicts_oldest_when_capacity_is_exceeded() + { + for (var i = 1; i <= 4; i++) + { + await _client.PostAsync("/in/bucket", + new StringContent($"hook-{i}", Encoding.UTF8, "text/plain")); + } + + var response = await _client.GetAsync("/api/hooks"); + var hooks = JsonSerializer.Deserialize>( + await response.Content.ReadAsStringAsync(), JsonOptions)!; + + // capacity=3: hook-1 evicted; newest first → hook-4, hook-3, hook-2 + hooks.Should().HaveCount(3); + hooks.Select(h => h.Body).Should().Equal("hook-4", "hook-3", "hook-2"); + } + + private sealed record HookDto(Guid Id, string Bucket, string Method, string Body, DateTimeOffset ReceivedAt); +}