Webhooks in .NET

webhook-in-dotnet

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.

web hooks-example

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.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir