ASP.NET Webhook Custom Publisher (Sender, Server) ve Subscriber(Receiver, Client)

Webhook Nedir?

Webhook, sisteme abone olanların belirlediği HTTP Callback URL'lere belirli olayları gönderen basit ve kullanışlı bir altyapıdır. Örneğin: Github sitesinde bir yorum, commit gibi bir olay gerçekleştiğinde, github üzerinden belirlediğimiz bu url'e github event verileri gönderilir. HMAC-SHA256 gibi özet tabanlı mesaj doğrulama kodu ile kimlik doğrulaması yapılır. Genellikle entegrasyon, hata izleme, işlem güvenliği gibi senaryolarda kullanılır.

Örnek projeyi github üzerinden indirmek veya görüntülemek için https://github.com/ayzdru/AspNetWebhookPublisherAndSubscriber adresine gidebilirsiniz.

Kodlamaya Başlayalım

Bu projede örnek bir custom publisher(sender, server) ve subscriber(receiver, client) altyapısı yazılmıştır. Örnek projede Publisher projesini çalıştırdığımızda, bir personel oluşturulup, veritabanına eklenmektedir. Ardından "Personel Oluşturuldu" eventi bütün abonelere gönderiliyor. Subscriber projesi gelen eventleri yakalamaktadır.

Webhook Publisher(sender, server) Projesi

Entity sınıflarını oluşturalım.

public abstract class BaseEntity
{
        public Guid Id { get; set; }
        public DateTime Created { get; set; }
}
//Gönderilecek olay verisi
public class Person : BaseEntity
{
        public string FirstName { get; set; }
        public string LastName { get; set; }
}
//Webhook Olaylarının tanımlandığı entity
public class WebhookEvent : BaseEntity
 {
        public string Name { get; set; }
        public string DisplayName { get; set; }
        public string Description { get; set; }
        public WebhookEvent()
        {

        }       
}
//Webhook yükleri
public class WebhookPayload : BaseEntity
{
        public Guid WebhookEventId { get; set; }
        public WebhookEvent WebhookEvent { get; set; }
        public int Attempt { get; set; }
        public string Data { get; set; }
}
//Response Entity
public class WebhookResponse : BaseEntity
{       
        public Guid WebhookPayloadId { get; set; }
        public WebhookPayload WebhookPayload { get; set; }
        public string Data { get; set; }
        public int? HttpStatusCode { get; set; }       
}
//Abonelerin olduğu entity
public class WebhookSubscription : BaseEntity
{
        public Guid WebhookSubscriptionContentTypeId { get; set; }
        public WebhookSubscriptionContentType WebhookSubscriptionContentType { get; set; }
        public Guid WebhookSubscriptionTypeId { get; set; }
        public WebhookSubscriptionType WebhookSubscriptionType { get; set; }       
        public string PayloadUrl { get; set; }
        public string Secret { get; set; }
        public bool IsActive { get; set; }
        public ICollection<WebhookSubscriptionAllowedEvent> WebhookSubscriptionAllowedEvents { get; set; }
}
//Kullanıcının gönderilmesine izin verdiği olaylar
public class WebhookSubscriptionAllowedEvent : BaseEntity
{
        public Guid WebhookSubscriptionId { get; set; }
        public WebhookSubscription WebhookSubscription { get; set; }
        public Guid WebhookEventId { get; set; }
        public WebhookEvent WebhookEvent { get; set; }
}
//Gönderilecek içeriğin tipi (json, form)
public class WebhookSubscriptionContentType : BaseEntity
{
        public string Name { get; set; }
}
//Abonelik Tipi
public class WebhookSubscriptionType : BaseEntity
{
        public string Name { get; set; }
}

Enumları oluşturalım.

public enum WebHookEvents
{
        [Display(Name = "person.created")]
        PersonCreated,
        [Display(Name = "person.updated")]
        PersonUpdated,
        [Display(Name = "person.deleted")]
        PersonDeleted
}
public enum WebhookSubscriptionTypes
{
        All,
        Specific
}

Arayüzlerimizi oluşturalım.

 public interface IWebhookPublisher
{
        Task Publish<T>(WebHookEvents webhookEvent, T data);
}

Publisher servisimizi oluşturuyoruz.

public class WebhookPublisher : IWebhookPublisher
    {
        protected const string SignatureHeaderKey = "sha256";
        protected const string SignatureHeaderValueTemplate = SignatureHeaderKey + "={0}";
        protected const string SignatureHeaderName = "webhook-signature";

        private readonly ApplicationDbContext _applicationDbContext;
        private readonly HttpClient _httpClient;
        public WebhookPublisher(HttpClient httpClient, ApplicationDbContext applicationDbContext)
        {
            _httpClient = httpClient;
            _httpClient.Timeout = TimeSpan.FromSeconds(30);
            _applicationDbContext = applicationDbContext;
        }
        private string GetDisplayName(WebHookEvents webHookEvent)
        {
            var displayAttribute = typeof(WebHookEvents).GetMember(webHookEvent.ToString())[0].GetCustomAttribute<DisplayAttribute>();
            if (displayAttribute != null && !string.IsNullOrEmpty(displayAttribute.Name))
            {
                return displayAttribute.Name;
            }
            return null;
        }
        public async Task Publish<T>(WebHookEvents webhookEvent, T data)
        {
            var webhookEventName = GetDisplayName(webhookEvent);
            var dataJson = JsonSerializer.Serialize(data);
            var maxRetryAttempts = 3;
            var pauseBetweenFailures = TimeSpan.FromSeconds(2);
            var circuitBreakerPolicy = Policy.Handle<Exception>().CircuitBreakerAsync(5, TimeSpan.FromSeconds(30));
            System.Diagnostics.Debug.WriteLine("STARTING...");
            var retryPolicy = Policy
                .Handle<Exception>()
                .WaitAndRetryAsync(maxRetryAttempts, i => pauseBetweenFailures,
onRetry: (response, delay, retryCount, context) =>
{
    System.Diagnostics.Debug.WriteLine("RETRYING... - " + retryCount);
});
            var webhookSubscriptions = _applicationDbContext.WebhookSubscriptions.Include(q => q.WebhookSubscriptionContentType).Where(q => q.IsActive == true && q.WebhookSubscriptionType.Name == WebhookSubscriptionTypes.All.ToString() || (q.WebhookSubscriptionAllowedEvents.Where(q => q.WebhookEvent.Name == webhookEventName).Any() == true)).ToList();
            foreach (var webhookSubscription in webhookSubscriptions)
            {
                try
                {
                    var options = new JsonSerializerOptions
                    {
                        WriteIndented = true,
                        Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
                    };
                    var hashJson = JsonSerializer.Serialize(new { Data = dataJson, Event = webhookEventName }, options);
                    var webhookEventEntity = _applicationDbContext.WebhookEvents.Where(q => q.Name == webhookEventName).SingleOrDefault();
                    if (webhookEventEntity != null)
                    {
                        var webhookPayloadEntity = new WebhookPayload() { WebhookEventId = webhookEventEntity.Id, Data = dataJson, Attempt = 0, Created = DateTime.Now };
                        _applicationDbContext.WebhookPayloads.Add(webhookPayloadEntity);
                        _applicationDbContext.SaveChanges();

                        HttpContent httpContent = null;
                        if (webhookSubscription.WebhookSubscriptionContentType.Name == "application/json")
                        {
                            httpContent = new StringContent(hashJson, Encoding.UTF8, "application/json");
                        }
                        else
                        {
                            var formData = new Dictionary<string, string> { { "Data", dataJson }, { "Event", webhookEventName } };
                            httpContent = new FormUrlEncodedContent(formData);
                        }

                        var secretBytes = Encoding.UTF8.GetBytes(webhookSubscription.Secret);

                        using (var hasher = new HMACSHA256(secretBytes))
                        {
                            var hashData = Encoding.UTF8.GetBytes(hashJson);
                            var sha256 = hasher.ComputeHash(hashData);
                            var headerValue = string.Format(CultureInfo.InvariantCulture, SignatureHeaderValueTemplate, BitConverter.ToString(sha256));
                            httpContent.Headers.Add(SignatureHeaderName, headerValue);
                        }


                        await retryPolicy.WrapAsync(circuitBreakerPolicy).ExecuteAsync(async () =>
                        {
                            var response = await _httpClient.PostAsync(webhookSubscription.PayloadUrl, httpContent);
                            _applicationDbContext.WebhookResponses.Add(new WebhookResponse() { Data = await response.Content.ReadAsStringAsync(), HttpStatusCode = (int)response.StatusCode, WebhookPayloadId = webhookPayloadEntity.Id });
                            _applicationDbContext.SaveChanges();
                            if (!response.IsSuccessStatusCode)
                            {
                                var webhookPayload = _applicationDbContext.WebhookPayloads.Where(q => q.Id == webhookPayloadEntity.Id).SingleOrDefault();
                                if (webhookPayloadEntity != null)
                                {
                                    webhookPayload.Attempt += 1;
                                    _applicationDbContext.WebhookPayloads.Update(webhookPayload);
                                    _applicationDbContext.SaveChanges();
                                }
                            }
                            response.EnsureSuccessStatusCode();
                        });


                    }
                }
                catch (Exception ex)
                {

                }
            }
        }
    }

Index.cshtml.cs dosyasını düzenliyoruz.

 public class IndexModel : PageModel
    {
        private readonly ILogger<IndexModel> _logger;
        private readonly IWebhookPublisher _webhookPublisher;
        private readonly ApplicationDbContext _applicationDbContext;
        public IndexModel(ILogger<IndexModel> logger, IWebhookPublisher webhookPublisher, ApplicationDbContext applicationDbContext)
        {
            _webhookPublisher = webhookPublisher;
            _applicationDbContext = applicationDbContext;
            _logger = logger;
        }

        public async Task<IActionResult>  OnGetAsync()
        {
            var personEntity = new Faker<Entities.Person>()
                .RuleFor(u => u.FirstName, (f, u) => f.Name.FirstName())
                .RuleFor(u => u.LastName, (f, u) => f.Name.LastName())
                .RuleFor(u => u.Id, (f, u) => Guid.NewGuid())
                 .RuleFor(u => u.Created, (f, u) => DateTime.Now)
                .Generate();
            _applicationDbContext.Persons.Add(personEntity);
            _applicationDbContext.SaveChanges();
//Veritabanına bir personel ekledikten sonra 'Personel Oluşturuldu' olayını bütün abonelere gönderiyoruz.
            await _webhookPublisher.Publish(Enums.WebHookEvents.PersonCreated, personEntity);
            return Page();
        }
    }

 

Webhook Subscriber(receiver, client) Projesi

 TestController.cs dosyamızı düzenliyoruz.

public class TestController : ControllerBase
    {
        protected const string SignatureHeaderName = "webhook-signature";
        protected const string Secret = "secret"; 
//Form post için   
        [HttpPost("webhook-form-data-test")]
        public IActionResult WebhookFormDataTest([FromForm] WebhookBindingModel webhookBindingModel)
        {           
            if (!CheckMessageAuthenticationCode("secret", GetHashJson(webhookBindingModel)))
            {
                throw new Exception("Unexpected Signature");
            }
            return Ok();
        }
//Json post için
        [HttpPost("webhook-json-data-test")]
        public IActionResult WebhookJsonDataTest([FromBody] WebhookBindingModel webhookBindingModel)
        {
            if (!CheckMessageAuthenticationCode("secret", GetHashJson(webhookBindingModel)))
            {
                throw new Exception("Unexpected Signature");
            }
            return Ok();
        }
        private string GetHashJson(WebhookBindingModel webhookBindingModel)
        {
            var options = new JsonSerializerOptions
            {
                WriteIndented = true,
                Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping
            };
            return JsonSerializer.Serialize(webhookBindingModel, options);
        }
//Özet tabanlı mesaj doğrulama
        private bool CheckMessageAuthenticationCode(string secret, string hashJson)
        {
            if (!HttpContext.Request.Headers.ContainsKey(SignatureHeaderName))
            {
                return false;
            }

            var receivedSignature = HttpContext.Request.Headers[SignatureHeaderName].ToString().Split("=");

            string computedSignature;
            switch (receivedSignature[0])
            {
                case "sha256":
                    var secretBytes = Encoding.UTF8.GetBytes(secret);
                    using (var hasher = new HMACSHA256(secretBytes))
                    {
                        var data = Encoding.UTF8.GetBytes(hashJson);
                        computedSignature = BitConverter.ToString(hasher.ComputeHash(data));
                    }
                    break;
                default:
                    throw new NotImplementedException();
            }
            return computedSignature == receivedSignature[1];
        }
    }

Örnek projeyi github üzerinden indirmek veya görüntülemek için https://github.com/ayzdru/AspNetWebhookPublisherAndSubscriber adresine gidebilirsiniz.

Sağlıcakla kalın..

Yorumlar kapalı