Photo by Constantin Shimonenko
Scenario
We are using a third party system for managing our users. Whenever something happens with a user (added/updated/removed...), the system will notify us by using a webhook. The system that we are using can only post to one endpoint. To be able to identify what kind of event that happend, they are also sending a header, X-Operation.
Let's see how we're currently handling this:
One action method - switch case
[ApiController]
[Route("[controller]")]
public IActionResult HandleWebhook()
{
Request.Headers.TryGetValue("X-Operation", out var operationHeader);
switch (operationHeader)
{
case "UserAdded":
// Handle UserAdded here
return new OkObjectResult("UserAdded operation was handled");
case "UserRemoved":
// Handle UserRemoved here
return new OkObjectResult("UserRemoved operation was handled");
case "UserUpdated":
// Handle UserUpdated here
return new OkObjectResult("UserUpdated operation was handled");
default:
return new BadRequestObjectResult($"{operationHeader} is not supported");
}
}
As you can see, we're having one action method that parses out the X-Operation header and we're then using a switch case to handle the different events. This works OK, but I feel like it's a bit clumsy. It would feel much better if we could have one action method per operation.
IActionConstraint
It's quite easy to achieve this in ASP.NET Core by using IActionConstraint.
Supports conditional logic to determine whether or not an associated action is valid to be selected for the given request.
I will not go into any depth regarding IActionConstraint, if you want to learn more about it I recommend this post by Filip W.
So, we need to be able to say something like THIS action method handles THIS header with THIS value.
To be able to do that we'll start by creating a new attribute that we'll use to decorate our action methods.
public class WebhookOperationAttribute : Attribute, IActionConstraint
{
private readonly string _header;
private readonly string _value;
public WebhookOperationAttribute(string header, string value, int order = 0)
{
_header = header;
_value = value;
Order = order;
}
public bool Accept(ActionConstraintContext context)
{
if (!context.RouteContext.HttpContext.Request.Headers.ContainsKey(_header))
{
return false;
}
var headerValue = context.RouteContext.HttpContext.Request.Headers[_header];
return headerValue.Equals(_value);
}
public int Order { get; }
}
- If the request doesn't contain the specified header -> return false.
- Extract the header value.
- Check if the header value matches the specified value.
One thing that's good to keep in mind here is that the above code will be executed for every decorated action method with the same Order.
Once the stage order is identified, each action has all of its constraints in that stage executed. If any constraint does not match, then that action is not a candidate for selection. If any actions with constraints in the current state are still candidates, then those are the 'best' actions and this process will repeat with the next stage on the set of 'best' actions. If after processing the subsequent stages of the 'best' actions no candidates remain, this process will repeat on the set of 'other' candidate actions from this stage (those without a constraint).
The only thing left for us to do now is to create our action methods and decorate them accordingly.
[ApiController]
[Route("[controller]")]
public class WebhookController : ControllerBase
{
[HttpPost]
[WebhookOperation(WebhookHeader.Operation, WebhookOperation.UserAdded)]
public IActionResult UserAdded()
{
return new OkObjectResult($"Hello from {nameof(UserAdded)} action");
}
[HttpPost]
[WebhookOperation(WebhookHeader.Operation, WebhookOperation.UserRemoved)]
public IActionResult UserRemoved()
{
return new OkObjectResult($"Hello from {nameof(UserRemoved)} action");
}
[HttpPost]
[WebhookOperation(WebhookHeader.Operation, WebhookOperation.UserUpdated)]
public IActionResult UserUpdated()
{
return new OkObjectResult($"Hello from {nameof(UserUpdated)} action");
}
}
We can then verify that it works as intended with the following integration test.
public class WebhookIntegrationTests : IClassFixture<WebApplicationFactory<Startup>>
{
private readonly WebApplicationFactory<Startup> _webApplicationFactory;
public WebhookIntegrationTests(WebApplicationFactory<Startup> webApplicationFactory)
{
_webApplicationFactory = webApplicationFactory ?? throw new ArgumentNullException(nameof(webApplicationFactory));
}
[Theory]
[InlineData(WebhookOperation.UserAdded)]
[InlineData(WebhookOperation.UserUpdated)]
[InlineData(WebhookOperation.UserRemoved)]
public async Task ShouldUseCorrectActionForWebhookBasedOnOperationHeader(string operation)
{
var client = _webApplicationFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Post, "webhook")
{
Headers = {{WebhookHeader.Operation, operation}}
};
var response = await client.SendAsync(request);
var responseBody = await response.Content.ReadAsStringAsync();
responseBody.ShouldBe($"Hello from {operation} action");
}
}
This was just a quick write up of how to route to a specific action method based on a header value. If you want to learn more about IActionConstraint I recommend the following posts:
If you are using Endpoint routing, you might want to have a look at this issue on GitHub.