Webhooks in .NET
Webhook günümüz modern web uygulamaları tarafından sıkça kullanılan bir teknolojidir. Webhook bir sistem içerisinde gerçekleşen bir olayın başka bir sistemdeki tanımlı URL’ye POST
isteği atılarak ilgili olayın bildirilmesi amacıyla kullanılmaktadır. Böylece iki sistem arasında sağlanan iletişimle verilerin güncelliği ve senkronizasyonu sağlanmaktadır. Webhook kullanımı birçok senaryoda tercih edilebilmektedir. Örneğin, bir e-ticaret sisteminde bir ürünün fiyatı değiştiğinde bu bilgiyi üçüncü taraf sistemlere aktarmak için kullanılabilir. Bu örnekte fiyat değişimlerinden haberdar olmak isteyen üçüncü taraf sistemler kendilerini istemci olarak kaydetmelidirler.
Senaryoya göre bir havayolu firmasına ait uçuşlarda gerçekleşen fiyat değişikliği olaylarından seyahat acentelerinin haberdar olmak istediğini düşünelim.
Buna göre uçuş fiyatları değiştiğinde ilgili bilgiler bir message broker’a iletilecektir. Sonrasındaysa kuyruktaki mesajlar işlenerek yeni fiyat bilgileri istemci acentelere POST
isteğiyle gönderilerek bilgilendirilmeleri sağlanmaktadır.
API
Uygulamaya yeni bir webapi
projesi oluşturarak başlayalım ve gerekli NuGet paketlerini kuralım. Message Broker olarak RabbitMQ kullanılacaktır.
dotnet new webapi -n AirlineApi
dotnet add package RabbitMQ.Client
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Mapster
Uçuş ve subscription bilgilerinin tutulacağı varlıkları oluşturalım.
public class Flight
{
public int Id { get; set; }
public string Code { get; set; } = default!;
public decimal Price { get; set; }
}
public class WebhookSubscription
{
public int Id { get; set; }
public string WebhookUri { get; set; } = default!;
public Guid Secret { get; set; } = default!;
public string WebhookType { get; set; } = default!;
public string WebhookPublisher { get; set; } = default!;
}
Senaryoda bu bilgiler SQL Server üzerinde depolanacağından DbContext
sınıfını oluşturalım.
public class AirlineDbContext : DbContext
{
public AirlineDbContext(DbContextOptions options) : base(options) { }
public DbSet<Flight> Flights => Set<Flight>();
public DbSet<WebhookSubscription> WebhookSubscriptions => Set<WebhookSubscription>();
}
Subscription işlemlerini yürütecek Controller
sınıfını yazalım. Böylece fiyat değişikliği bilgisinden haberdar olmak isteyen istemciler kendilerini sisteme kaydedebilecektir. WebhookSubscription
sınıfına dikkat edilirse kayıt işlemi sırasında istemciden hangi olaya abone olacağı ve olay tetiklendiğinde hangi adresten bilgilendirileceği bilgileri alınmaktadır.
[ApiController, Route("api/[controller]")]
public class WebhookSubscriptionsController : ControllerBase
{
private readonly AirlineDbContext _context;
public WebhookSubscriptionsController(AirlineDbContext context)
{
_context = context;
}
[HttpPost]
public async Task<IActionResult> PostAsync([FromBody] WebhookSubscriptionForUpsert subscriptionForInsert, CancellationToken cancellationToken)
{
var subscription = subscriptionForInsert.Adapt<WebhookSubscription>();
subscription.Secret = Guid.NewGuid();
subscription.WebhookPublisher = "Airline Company";
await _context.AddAsync(subscription, cancellationToken);
var result = await _context.SaveChangesAsync(cancellationToken) > 0;
return result ? Ok(subscription.Adapt<WebhookSubscriptionForView>()) : BadRequest();
}
}
Yazının uzamaması adına yalnızca PostAsync
action methodu ele alınmıştır. Bu method içerisinde dikkat edilirse istemciye özel bir secret bilgisi de oluşturulmaktadır. Böylece ihtiyaçlar doğrultusunda abone doğrulama işlemleri de gerçekleştirilebilir.
Bir olay gerçekleştiğinde –örneğimizde fiyat değişimi– Message Bus’a ilgili bilgileri geçecek sınıfı yazalım.
public interface IMessageBus
{
void Publish(NotificationMessage notificationMessage);
}
public class RabbitMQMessageBus : IMessageBus
{
public void Publish(NotificationMessage notificationMessage)
{
var factory = new ConnectionFactory { HostName = "localhost", Port = 5672 };
using var connection = factory.CreateConnection();
using var channel = connection.CreateModel();
channel.ExchangeDeclare(exchange: "trigger", type: ExchangeType.Fanout);
var message = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(notificationMessage));
channel.BasicPublish(exchange: "trigger", routingKey: string.Empty, basicProperties: null, body: message);
}
}
Görüldüğü üzere bir Fanout tipinde bir exchange oluşturularak mesaj gönderimi sağlanmaktadır. RabbitMQ ile ilgili detaylı bilgiye önceki yazımdan ulaşabilirsiniz.
Yalnızca ihtiyaç duyulan alanlardan oluşan NotificationMessage
sınıfı aşağıdaki gibidir.
public class NotificationMessage
{
public string WebhookType { get; set; } = default!;
public string FlightCode { get; set; } = default!;
public decimal NewPrice { get; set; } = default!;
}
Uçuş ile ilgili işlemleri gerçekleştirecek Controller
sınıfını yazalım. Yine aynı şekilde burada yalnızca PostAsync
ve PutAsync
action methodları ele alınacaktır.
[ApiController, Route("api/[controller]")]
public class FlightsController : ControllerBase
{
private readonly AirlineDbContext _context;
private readonly IMessageBus _messageBus;
public FlightsController(AirlineDbContext context, IMessageBus messageBus)
{
_context = context;
_messageBus = messageBus;
}
[HttpPost]
public async Task<IActionResult> PostAsync([FromBody] FlightForUpsert flightForInsert, CancellationToken cancellationToken)
{
var flight = flightForInsert.Adapt<Flight>();
await _context.AddAsync(flight, cancellationToken);
var result = await _context.SaveChangesAsync(cancellationToken) > 0;
return result ? Ok(flight.Adapt<FlightForView>()) : BadRequest();
}
[HttpPut("{id}")]
public async Task<IActionResult> PutAsync(int id, [FromBody] FlightForUpsert flightForUpdate, CancellationToken cancellationToken)
{
var flight = await _context.Flights.FirstOrDefaultAsync(a => a.Id == id, cancellationToken);
if (flight.Price != flightForUpdate.Price)
{
_messageBus.Publish(new()
{
WebhookType = "PriceChange",
FlightCode = flight.Code,
NewPrice = flightForUpdate.Price,
});
}
flight.Code = flightForUpdate.Code;
flight.Price = flightForUpdate.Price;
_context.Update(flight);
return await _context.SaveChangesAsync() > 0 ? NoContent() : BadRequest();
}
}
Görüldüğü üzere PutAsync
action methodu içerisinde fiyat kontrolü yapılmaktadır. Bir değişim söz konusuysa Message Bus üzerinden ilgili değişiklik kuyruğa gönderilmektedir.
builder.Services.AddDbContext<AirlineDbContext>(options =>
{
options.UseSqlServer(builder.Configuration.GetConnectionString("SqlServer"));
});
builder.Services.AddSingleton<IMessageBus, RabbitMQMessageBus>();
Artık DbContext
ve Message Bus sınıflarını yukarıdaki gibi birer servis olarak ekleyebiliriz.
Webhook Worker
Webhook uygulamasının ikinci ayağı olan ve Message Bus ile gönderilen mesajları işleyip ilgili istemcileri bilgilendirecek console
projesini oluşturalım ve gerekli NuGet paketlerini kuralım.
dotnet new console -n Worker
dotnet add package RabbitMQ.Client
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.Extensions.Http
Gelen mesaj içerisindeki WebhookType
olay tipine göre abone olmuş istemcileri çekebilmek için DbContext
sınıfını oluşturalım.
public class WorkerDbContext : DbContext
{
public WorkerDbContext(DbContextOptions options) : base(options) { }
public DbSet<WebhookSubscription> WebhookSubscriptions => Set<WebhookSubscription>();
}
Böylece istemcilerin URL
adresleri ve ihtiyaç duyulan diğer bilgiler de elde edilebilecektir.
İstemci uygulamalara POST
isteği atılmasını sağlayacak HttpClient sınıfını yazalım.
public interface IWebhookClient
{
Task SendAsync(string uri, ChangePayload payload, IDictionary<string, string> headers);
}
public class HttpWebhookClient : IWebhookClient
{
private readonly IHttpClientFactory _httpClientFactory;
public HttpWebhookClient(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
}
public async Task SendAsync(string uri, ChangePayload payload, IDictionary<string, string> headers)
{
var httpClient = _httpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, uri);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
foreach (var header in headers) request.Headers.Add(header.Key, header.Value);
var content = JsonSerializer.Serialize(payload);
request.Content = new StringContent(content);
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json");
using var response = await httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
}
}
İstemci uygulamalara gönderilecek mesaj aşağıdaki gibidir. Yalnızca uçuş kodu ve fiyatını içermektedir.
public class ChangePayload
{
public string FlightCode { get; set; } = default!;
public decimal NewPrice { get; set; } = default!;
}
Console uygulaması içerisinde ihtiyaç duyulan servisleri DI Container üzerinden alabilmek adına uygulama mantığını soyutlayalım.
public interface IAppHost
{
void Run();
}
public class AppHost : IAppHost
{
private readonly IWebhookClient _client;
private readonly WorkerDbContext _context;
public AppHost(WorkerDbContext context, IWebhookClient client)
{
_context = context;
_client = client;
}
public void Run()
{
var factory = new ConnectionFactory { HostName = "localhost", Port = 5672 };
using var connection = factory.CreateConnection();
using var channel = connection.CreateModel();
channel.ExchangeDeclare(exchange: "trigger", type: ExchangeType.Fanout);
var queueName = channel.QueueDeclare().QueueName;
channel.QueueBind(queue: queueName, exchange: "trigger", routingKey: string.Empty);
var consumer = new EventingBasicConsumer(channel);
consumer.Received += async (sender, args) =>
{
var message = JsonSerializer.Deserialize<NotificationMessage>(Encoding.UTF8.GetString(args.Body.ToArray()));
if (message is null) return;
foreach (var whs in _context.WebhookSubscriptions.Where(w => w.WebhookType == message.WebhookType))
{
var webhookToSend = new ChangePayload
{
NewPrice = message.NewPrice,
FlightCode = message.FlightCode,
};
await _client.SendAsync(whs.WebhookUri, webhookToSend, new Dictionary<string, string>
{
{ "Secret", whs.Secret.ToString() },
{ "Publisher", whs.WebhookPublisher },
{ "Event-Type", message.WebhookType.ToString() },
});
}
};
channel.BasicConsume(queue: queueName, autoAck: true, consumer: consumer);
Console.ReadKey();
}
}
Yukarıdaki kod incelendiğinde oluşturulan bağlantı doğrultusunda queue üzerinden mesaj dinlenildiği görülecektir. Yeni bir mesaj alındığında ilgili olaya subscribe olmuş istemciler elde edilmektedir. Sonrasındaysa istemcinin kayıt olurken belirtmiş olduğu URL
adresine ilgili bilgiler POST
isteğiyle gönderilmektedir.
Program.cs
içerisinde uygulamamızı ayağa kaldıracak konfigürasyonları sağlayalım.
var host = Host.CreateDefaultBuilder(args)
.ConfigureServices((context, services) =>
{
services.AddDbContext<AirlineDbContext>(o => o.UseSqlServer(context.Configuration.GetConnectionString("SqlServer")));
services.AddScoped<IWebhookClient, WebhookHttpClient>();
services.AddHttpClient();
services.AddScoped<IAppHost, AppHost>();
}).Build();
using var scope = host.Services.CreateScope();
scope.ServiceProvider.GetService<IAppHost>()?.Run();
Görüldüğü üzere DbContext
ve oluşturulan sınıflar birer servis olarak eklenmektedir. Sonrasında IAppHost
elde edilerek uygulama ayağa kaldırılmaktadır.
Subscriber
Sistem üzerinde meydana gelen değişikliklerle ilgilenen istemci projesini oluşturalım.
dotnet new webapi -n TravelAgent
İstemci uygulamanın belirtmiş olduğu URL
adresine gönderilecek istekleri karşılayacağı ilgili Controller
sınıfını ve action methodu aşağıdaki şekilde oluşturalım.
[ApiController, Route("api/[controller]")]
public class NotificationsController : ControllerBase
{
private readonly ILogger<NotificationsController> _logger;
public NotificationsController(ILogger<NotificationsController> logger)
{
_logger = logger;
}
[HttpPost]
public async Task<IActionResult> PostAsync([FromBody] ChangePayload payload, CancellationToken cancellationToken = default)
{
var secret = GetHeader("Secret");
var publisher = GetHeader("Publisher");
var webhookType = GetHeader("Event-Type");
_logger.LogInformation($"Webhook received from: {publisher}");
return Ok();
}
private string? GetHeader(string key) => Request.Headers.ContainsKey(key) ? Request.Headers[key] : default!;
}
Uygulama ayağa kaldırıldığında istemci uygulamanın değişikliklerden haberdar olacağı görülecektir. Yazı içerisinde kullanılan projeye GitHub üzerinden erişebilirsiniz.