Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions samples/WebhookSink/HookCapture.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace WebhookSink;

internal sealed record HookCapture(
Guid Id,
string Bucket,
string Method,
IReadOnlyDictionary<string, string> Headers,
string Body,
DateTimeOffset ReceivedAt);
54 changes: 54 additions & 0 deletions samples/WebhookSink/HookStore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
namespace WebhookSink;

internal interface IHookStore

Check failure on line 3 in samples/WebhookSink/HookStore.cs

View workflow job for this annotation

GitHub Actions / build · test · pack

File name must match type name (interface IHookStore), expected file name: 'IHookStore' (https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0048.md)

Check failure on line 3 in samples/WebhookSink/HookStore.cs

View workflow job for this annotation

GitHub Actions / build · test · pack

File name must match type name (interface IHookStore), expected file name: 'IHookStore' (https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0048.md)
{
void Add(HookCapture hook);
IReadOnlyList<HookCapture> GetAll();
HookCapture? GetById(Guid id);
}

internal sealed class HookStore : IHookStore
{
private readonly int _capacity;
private readonly Queue<HookCapture> _ring;
private readonly Dictionary<Guid, HookCapture> _index;
private readonly Lock _lock = new();

public HookStore(int capacity)
{
ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1);
_capacity = capacity;
_ring = new Queue<HookCapture>(capacity);
_index = new Dictionary<Guid, HookCapture>(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<HookCapture> GetAll()
{
lock (_lock)
{
return _ring.Reverse().ToArray();
}
}

public HookCapture? GetById(Guid id)
{
lock (_lock)
{
return _index.GetValueOrDefault(id);
}
}
}
41 changes: 41 additions & 0 deletions samples/WebhookSink/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
using WebhookSink;

var builder = WebApplication.CreateBuilder(args);

var capacity = builder.Configuration.GetValue("WebhookSink:StoreCapacity", 200);
builder.Services.AddSingleton<IHookStore>(_ => new HookStore(capacity));

var app = builder.Build();

app.MapGet("/healthz", () => Results.Ok());
Expand All @@ -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(

Check failure on line 36 in samples/WebhookSink/Program.cs

View workflow job for this annotation

GitHub Actions / build · test · pack

Use an overload that has a IEqualityComparer<string> or IComparer<string> parameter (https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0002.md)

Check failure on line 36 in samples/WebhookSink/Program.cs

View workflow job for this annotation

GitHub Actions / build · test · pack

Use an overload that has a IEqualityComparer<string> or IComparer<string> parameter (https://github.com/meziantou/Meziantou.Analyzer/blob/main/docs/Rules/MA0002.md)
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 { }
87 changes: 87 additions & 0 deletions tests/WebhookSink.Tests/CaptureEndpointTests.cs
Original file line number Diff line number Diff line change
@@ -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<Program> _factory;
private readonly HttpClient _client;

public CaptureEndpointTests()
{
_factory = new WebApplicationFactory<Program>()
.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<List<HookDto>>(
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<List<HookDto>>(
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<List<HookDto>>(
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);
}
Loading