Skip to content

Commit 7e2cda0

Browse files
authored
Webhook.Sender Endpoints (#141)
1 parent 796b886 commit 7e2cda0

14 files changed

+294
-5
lines changed

Nist.sln

+7
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "dotnet", "dotnet", "{23CD49
6767
EndProject
6868
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nist.Proxy.Tests", "proxy\dotnet\tests\Nist.Proxy.Tests.csproj", "{AD3144EF-31F1-4795-940C-249257BAFEF7}"
6969
EndProject
70+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Nist.Webhook.Sender.Playground", "webhooks\send\playground\Nist.Webhook.Sender.Playground.csproj", "{9BA28776-A8DD-44FF-AF05-9208FAC4AA27}"
71+
EndProject
7072
Global
7173
GlobalSection(SolutionConfigurationPlatforms) = preSolution
7274
Debug|Any CPU = Debug|Any CPU
@@ -148,6 +150,10 @@ Global
148150
{AD3144EF-31F1-4795-940C-249257BAFEF7}.Debug|Any CPU.Build.0 = Debug|Any CPU
149151
{AD3144EF-31F1-4795-940C-249257BAFEF7}.Release|Any CPU.ActiveCfg = Release|Any CPU
150152
{AD3144EF-31F1-4795-940C-249257BAFEF7}.Release|Any CPU.Build.0 = Release|Any CPU
153+
{9BA28776-A8DD-44FF-AF05-9208FAC4AA27}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
154+
{9BA28776-A8DD-44FF-AF05-9208FAC4AA27}.Debug|Any CPU.Build.0 = Debug|Any CPU
155+
{9BA28776-A8DD-44FF-AF05-9208FAC4AA27}.Release|Any CPU.ActiveCfg = Release|Any CPU
156+
{9BA28776-A8DD-44FF-AF05-9208FAC4AA27}.Release|Any CPU.Build.0 = Release|Any CPU
151157
EndGlobalSection
152158
GlobalSection(NestedProjects) = preSolution
153159
{3448B6A0-78B9-4F52-A971-9407F9B331CF} = {AA026DC3-ACA9-418C-9D5F-C0FA54F764CA}
@@ -173,5 +179,6 @@ Global
173179
{3D2342D2-81CD-4B92-89A0-62F504205B07} = {230574B4-9AC7-44CC-9DFD-FFA367356379}
174180
{23CD4903-6234-4603-ABA4-ECE8DAD2C38B} = {DA0F6345-2D0B-427A-95B1-FD36E76E0212}
175181
{AD3144EF-31F1-4795-940C-249257BAFEF7} = {23CD4903-6234-4603-ABA4-ECE8DAD2C38B}
182+
{9BA28776-A8DD-44FF-AF05-9208FAC4AA27} = {9F87D931-2A57-4C0E-9E28-22EE758171D0}
176183
EndGlobalSection
177184
EndGlobal

versions/0.7/index.md

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
- [ ] Nist.Queries
2+
- [ ] `Search` and `SearchInt` extension methods
13
- [ ] Nist.Webhooks.Dump <VERSION>
24
- [ ] Sort by date desc
35
- [ ] Allow limit specification with 100 by default
46
- [ ] Nist.Webhooks.Sender <VERSION>
5-
- [ ] WebhooksManager.Enqueue
7+
- [x] WebhookSender Endpoints
8+
- [ ] WebhooksManager.Enqueue

webhooks/send/lib/Endpoints.cs

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
global using QueryEnumerable = System.Collections.Generic.IEnumerable<System.Collections.Generic.KeyValuePair<string, Microsoft.Extensions.Primitives.StringValues>>;
2+
using Microsoft.EntityFrameworkCore;
3+
4+
namespace Nist;
5+
6+
public static class WebhookEndpoints {
7+
public static IEndpointRouteBuilder MapGetWebhooks<TDb>(this IEndpointRouteBuilder app) where TDb : IDbWithWebhookRecord {
8+
app.MapGet($"/{WebhookUris.Webhooks}", GetWebhooks<TDb>);
9+
10+
return app;
11+
}
12+
13+
public static async Task<WebhookCollection> GetWebhooks<TDb>(HttpRequest request, TDb db) where TDb : IDbWithWebhookRecord {
14+
var query = WebhookQuery.Parse(request.Query);
15+
16+
var counters = await db.WebhookRecords
17+
.GroupBy(r => r.Status)
18+
.Select(g => new {
19+
Status = g.Key,
20+
Count = g.Count()
21+
})
22+
.ToArrayAsync();
23+
24+
var selected = await db.WebhookRecords
25+
.OrderByDescending(r => r.Id)
26+
.Take(query.Limit ?? WebhookQuery.DefaultLimit)
27+
.ToArrayAsync();
28+
29+
return new WebhookCollection(
30+
TotalCounts: counters.ToDictionary(c => c.Status.ToLower(), c => c.Count),
31+
Count: selected.Length,
32+
Items: selected
33+
);
34+
}
35+
}
36+
37+
public class WebhookUris {
38+
public const string Webhooks = "webhooks";
39+
}
40+
41+
public record WebhookQuery(
42+
int? Limit = null
43+
)
44+
{
45+
public const int DefaultLimit = 100;
46+
47+
public static WebhookQuery Parse(IQueryCollection source) => new(
48+
Limit: source.SearchInt(nameof(Limit))
49+
);
50+
}
51+
52+
public record WebhookCollection(
53+
Dictionary<string, int> TotalCounts,
54+
int Count,
55+
WebhookRecord[] Items
56+
);
57+
58+
public static class QueryCollectionExtensions
59+
{
60+
public static int? SearchInt(this QueryEnumerable query, string key)
61+
{
62+
var value = query.Search(key);
63+
return value == null ? null : int.Parse(value!);
64+
}
65+
66+
public static string? Search(this QueryEnumerable query, string key)
67+
{
68+
var pair = query.FirstOrDefault(q => string.Equals(q.Key, key, StringComparison.InvariantCultureIgnoreCase));
69+
return pair.Value.Where(x => x != null).FirstOrDefault();
70+
}
71+
}
72+

webhooks/send/lib/Nist.Webhooks.Sender.csproj

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
<Project Sdk="Microsoft.NET.Sdk">
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
22

33
<PropertyGroup>
44
<TargetFramework>net9.0</TargetFramework>
55
<ImplicitUsings>enable</ImplicitUsings>
66
<Nullable>enable</Nullable>
7+
<RootNamespace>Nist</RootNamespace>
8+
<OutputType>Library</OutputType>
79
</PropertyGroup>
810

911
<ItemGroup>

webhooks/send/lib/WebhookSending.cs

+19-3
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44
using Microsoft.EntityFrameworkCore;
55
using Microsoft.EntityFrameworkCore.ChangeTracking;
66
using Microsoft.EntityFrameworkCore.Infrastructure;
7-
using Microsoft.Extensions.DependencyInjection;
8-
using Microsoft.Extensions.Logging;
97

108
namespace Nist;
119

@@ -111,7 +109,7 @@ private async Task SendPendingWebhook(WebhookRecord record)
111109

112110
record.Status = response.IsSuccessStatusCode ? WebhookStatus.Success : WebhookStatus.Error;
113111
record.ResponseStatusCode = (int)response.StatusCode;
114-
record.Response = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync());
112+
record.Response = Json.ParseSafely(await response.Content.ReadAsStreamAsync());
115113
}
116114
}
117115

@@ -123,4 +121,22 @@ public static void AddContinuousWebhooksSending(this IServiceCollection services
123121
services.AddScoped(provider => dbFactory(provider));
124122
services.AddContinuousBackgroundService<WebhooksSendingIteration>();
125123
}
124+
}
125+
126+
public static class Json
127+
{
128+
/// <summary>
129+
/// Because .NET team decided not to implement proper TryParse: https://github.com/dotnet/runtime/issues/82605
130+
/// </summary>
131+
/// <param name="stream"></param>
132+
/// <returns></returns>
133+
public static JsonDocument? ParseSafely(this Stream stream)
134+
{
135+
try {
136+
return JsonDocument.Parse(stream);
137+
}
138+
catch {
139+
return null;
140+
}
141+
}
126142
}

webhooks/send/playground/.http

+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
POST /webhooks
2+
3+
{
4+
"url" : "{{ host }}/destination",
5+
"body" : {
6+
"example" : "one"
7+
}
8+
}
9+
10+
###
11+
POST /webhooks
12+
13+
{
14+
"url" : "{{ host }}/webhooks/dump",
15+
"body" : {
16+
"example" : "one"
17+
}
18+
}
19+
20+
###
21+
GET /webhooks
22+
23+
###
24+
GET /webhooks/dump
+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
{
2+
"defaultHeaders": {
3+
"Content-Type": "application/json"
4+
},
5+
"environments": {
6+
"$shared" : {
7+
"host" : "http://localhost:5195"
8+
}
9+
}
10+
}

webhooks/send/playground/Makefile

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
run:
2+
dotnet run
3+
4+
up:
5+
docker compose up -d
6+
7+
yac:
8+
httpyac send .http --all
9+
10+
down:
11+
docker compose down
12+
docker volume prune --force
13+
14+
reset:
15+
make down
16+
make up
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
2+
3+
<ItemGroup>
4+
<ProjectReference Include="..\lib\Nist.Webhooks.Sender.csproj" />
5+
</ItemGroup>
6+
7+
<ItemGroup>
8+
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.4" />
9+
<PackageReference Include="Nist.Webhooks.Dump" Version="2025.104.115.11" />
10+
<PackageReference Include="Persic.EF" Version="2025.104.121.14" />
11+
<PackageReference Include="Persic.EF.Postgres" Version="2025.104.108.5" />
12+
</ItemGroup>
13+
14+
<PropertyGroup>
15+
<TargetFramework>net9.0</TargetFramework>
16+
<Nullable>enable</Nullable>
17+
<ImplicitUsings>enable</ImplicitUsings>
18+
</PropertyGroup>
19+
20+
</Project>

webhooks/send/playground/Program.cs

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
using System.Text.Json;
2+
using Microsoft.EntityFrameworkCore;
3+
using Microsoft.EntityFrameworkCore.Diagnostics;
4+
using Microsoft.EntityFrameworkCore.Metadata.Builders;
5+
using Nist;
6+
using Persic;
7+
8+
var builder = WebApplication.CreateBuilder(args);
9+
10+
builder.Logging.AddSimpleConsole(c => c.SingleLine = true);
11+
12+
builder.Services.AddPostgres<Db>();
13+
builder.Services.AddContinuousWebhooksSending(sp => sp.GetRequiredService<Db>());
14+
15+
var app = builder.Build();
16+
17+
await app.Services.EnsureRecreated<Db>();
18+
19+
app.UseRequestBodyStringReader();
20+
21+
app.MapPost(WebhookUris.Webhooks, async (WebhookCandidate candidate, Db db) => {
22+
var record = new WebhookRecord() {
23+
Url = candidate.Url,
24+
Body = candidate.Body,
25+
CreatedAt = DateTime.UtcNow,
26+
Status = WebhookStatus.Pending
27+
};
28+
29+
db.Add(record);
30+
await db.SaveChangesAsync();
31+
return record;
32+
});
33+
34+
app.MapGetWebhooks<Db>();
35+
app.MapWebhookDump<Db>();
36+
37+
app.Run();
38+
39+
public class Db(DbContextOptions<Db> options) : DbContext(options), IDbWithWebhookRecord, IDbWithWebhookDump {
40+
public DbSet<WebhookRecord> WebhookRecords { get; set; }
41+
public DbSet<WebhookDump> WebhookDumps { get; set; }
42+
43+
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
44+
{
45+
base.OnConfiguring(optionsBuilder);
46+
optionsBuilder.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning));
47+
}
48+
49+
protected override void OnModelCreating(ModelBuilder modelBuilder)
50+
{
51+
modelBuilder.Entity<WebhookRecord>().Property(p => p.Body).HasStringConversion();
52+
modelBuilder.Entity<WebhookRecord>().Property(p => p.Response!).HasStringConversion();
53+
modelBuilder.Entity<WebhookDump>().Property(p => p.Body).HasStringConversion();
54+
}
55+
}
56+
57+
public static class JsonDocumentPropertyBuilderExtensions
58+
{
59+
public static PropertyBuilder<JsonDocument> HasStringConversion(this PropertyBuilder<JsonDocument> builder) =>
60+
builder.HasConversion(
61+
v => v.RootElement.GetRawText(),
62+
v => JsonDocument.Parse(v, new())
63+
);
64+
}
65+
66+
public record WebhookCandidate(
67+
JsonDocument Body,
68+
string Url
69+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"$schema": "https://json.schemastore.org/launchsettings.json",
3+
"profiles": {
4+
"http": {
5+
"commandName": "Project",
6+
"dotnetRunMessages": true,
7+
"launchBrowser": true,
8+
"applicationUrl": "http://localhost:5195",
9+
"environmentVariables": {
10+
"ASPNETCORE_ENVIRONMENT": "Development",
11+
"ConnectionStrings__Postgres" : "Host=localhost;Port=5432;Username=postgres;Password=postgres;Database=playground",
12+
}
13+
},
14+
"https": {
15+
"commandName": "Project",
16+
"dotnetRunMessages": true,
17+
"launchBrowser": true,
18+
"applicationUrl": "https://localhost:7146;http://localhost:5195",
19+
"environmentVariables": {
20+
"ASPNETCORE_ENVIRONMENT": "Development"
21+
}
22+
}
23+
}
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"Logging": {
3+
"LogLevel": {
4+
"Default": "Information",
5+
"Microsoft.AspNetCore": "Warning"
6+
}
7+
}
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"Logging": {
3+
"LogLevel": {
4+
"Default": "Information",
5+
"Microsoft.AspNetCore": "Warning"
6+
}
7+
},
8+
"AllowedHosts": "*"
9+
}

webhooks/send/playground/compose.yml

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
services:
2+
postgres:
3+
image: postgres
4+
environment:
5+
POSTGRES_USER: postgres
6+
POSTGRES_PASSWORD: postgres
7+
POSTGRES_DB: postgres
8+
ports:
9+
- "5432:5432"

0 commit comments

Comments
 (0)