DEV Community

Eelco Los
Eelco Los

Posted on

Migrating Azure Event Grid to Minimal API with FastEndpoints

In my previous article, I described how to migrate HTTP calls to Minimal API using FastEndpoints. During the migration of one of our business-critical applications, one of the Function Apps contained Azure Event Grid calls. This article focuses on the experience of migrating Azure Event Grid to Minimal API using FastEndpoints.


Why Migrate Event Grid Items?

You might wonder, why migrate these Azure Function Event Grid items at all? In our specific case, the function consisted of a dozen function methods, including two business-critical functions. These two calls were difficult to maintain and highlighted the limitations of using Azure Functions for business-critical services. Specifically, maintaining functions that need to handle over 100,000 calls a day while ensuring they can be updated during business hours with zero downtime.

For these reasons, we chose to move the entire function set, as-is (to the extent possible), to a Kubernetes container-based Minimal API with FastEndpoints.

Additionally, we aimed to gain better control over the developer experience. While Azure Functions offer many convenient abstractions for handling Event Grid events, we were willing to give up some of this β€œmagic” in exchange for more granular control.


Migration Steps: Key Takeaways

Migrating Azure Functions Event Grid items to Minimal API using FastEndpoints requires you to:

  1. Handle Webhook HTTP OPTIONS Validation
  2. Securely Handle Webhook Delivery
  3. Model Bind the Event Grid EventGridEvent or CloudEvent
  4. Process the Request

Handling Webhook Endpoint Validation

Azure Functions handle validation automatically, but this step must be explicitly implemented for other webhook-based calls. Event Grid sends an HTTP OPTIONS request to validate new webhook endpoints. This process is defined in Endpoint validation with CloudEvents schema and Endpoint validation with Event Grid event schema. Essentially, Event Grid requires an OK() response from the endpoint to confirm its validity. Our Minimal API must handle this request explicitly:

public class EventGridSubscriptionValidation : Endpoint<EventGridSubscriptionValidationRequest, EventGridSubscriptionValidationResponse>
{
    public override void Configure()
    {
        Verbs(Http.OPTIONS);
        Routes("contractadded", "contractchanged", "contractremoved");
        Description(x => x.Accepts<EventGridSubscriptionValidationRequest>());
    }

    public override async Task HandleAsync(EventGridSubscriptionValidationRequest req, CancellationToken ct)
    {
        await SendOkAsync(new EventGridSubscriptionValidationResponse()
        {
            WebHookAllowedRate = "*",
            WebHookAllowedOrigin = req.WebHookRequestOrigin
        }, ct);
    }
}

public record EventGridSubscriptionValidationRequest()
{
    [FromHeader(HeaderName = HeaderConstants.WebHookRequestOriginHeaderName), HideFromDocs]
    public string WebHookRequestOrigin { get; set; } = string.Empty;
};

public record EventGridSubscriptionValidationResponse()
{
    [ToHeader(HeaderName = HeaderConstants.WebHookAllowedRateHeaderName), HideFromDocs]
    public string WebHookAllowedRate { get; set; } = string.Empty;

    [ToHeader(HeaderName = HeaderConstants.WebHookAllowedOriginHeaderName), HideFromDocs]
    public string WebHookAllowedOrigin { get; set; } = string.Empty;
}

internal class EventGridSubscriptionValidationValidator : Validator<EventGridSubscriptionValidationRequest>
{
    public EventGridSubscriptionValidationValidator()
    {
        RuleFor(x => x.WebHookRequestOrigin).NotEmpty();
    }
}
Enter fullscreen mode Exit fullscreen mode

This ensures that Event Grid can properly register our Minimal API as an event handler.


Delivering Events to a Webhook in a Different Microsoft Entra Tenant

When using Event Grid across different Microsoft Entra tenants, additional security measures must be implemented. Microsoft provides guidance on securing webhook delivery at Multi-Tenant Secure Webhook Delivery.

Multi-Tenant Secure Webhook Delivery

Experience with Secure Delivery

We encountered a few challenges when setting up multi-tenant data.

First, it is important to note that the account used for verification (e.g., your GitHub account calling the validation endpoint) must be a multi-tenant account. If the account is single-tenant, you will encounter errors when using the sample script provided by Microsoft.

Additionally, the Sample Script provided by Microsoft has an invalid parameter for one of the methods triggered:

    if ($appRoles.DisplayName -match $eventGridRoleName)
    {
        Write-Host "The Azure Event Grid role is already defined.`n"
    } else {      
        Write-Host "Creating the Azure Event Grid role in Microsoft Entra Application: " $webhookAppObjectId
        $newRole = CreateAppRole -Name $eventGridRoleName -Description "Azure Event Grid Role"
        $appRoles += $newRole
        Update-MgApplication -ApplicationId $webhookAppObjectId -AppRoles $appRoles
    }
Enter fullscreen mode Exit fullscreen mode

In this section, notice that the -ApplicationId parameter from Update-MgApplication is the one you need to focus on. You require both the ApplicationId and the ObjectID for this script. After calling Update-MgApplication, ensure you retrieve the $app object ($app = Get-MgApplication -ApplicationId $webhookAppObjectId) for future use. Omitting this step will prevent the $app from having the necessary underlying data to finalize the cross-tenant actions.


Model Binding for Event Grid

Event Grid delivers messages in either EventGridEvent or CloudEvent format, which can be parsed using the EventGridEvent or CloudEvent class from the Azure.Messaging.EventGrid NuGet package. Instead of manually handling deserialization, we use a custom model binder in FastEndpoints.

Custom Model Binder for FastEndpoints

internal class ContractAddedRequestBinder : RequestBinder<PostContractAddedRequest>
{
    public async override ValueTask<PostContractAddedRequest> BindAsync(BinderContext ctx, CancellationToken ct)
    {
        var httpContext = ctx.HttpContext;
        var httpContent = httpContext.Request.Body;

        using var memoryStream = new MemoryStream();
        await httpContent.CopyToAsync(memoryStream, ct);
        var bytes = memoryStream.ToArray();

        var cloudEvents = CloudEvent.ParseMany(new BinaryData(bytes));
        var cloudEvent = cloudEvents.FirstOrDefault();

        return new PostContractAddedRequest(cloudEvent!)
        {
            ContractAdded = cloudEvent?.As<YourCustomObject>()!,
            EventId = cloudEvent?.Id!
        };
    }
}
Enter fullscreen mode Exit fullscreen mode

With this binder, incoming Event Grid events are automatically parsed into strongly-typed objects.

We also added an extension method to convert the object more easily and ensure type safety:

public static class CloudEventExtensions
{
    public static T As<T>(this CloudEvent cloudEvent)
    {
        ArgumentNullException.ThrowIfNull(cloudEvent);
        ArgumentNullException.ThrowIfNull(cloudEvent.Data);

        return cloudEvent.Data.ToObjectFromJson<T>() ?? throw new InvalidOperationException();
    }
}
Enter fullscreen mode Exit fullscreen mode

Note:
When using validation on EventGridEvent or CloudEvent, it is important to minimize validation. Since these are external objects, they should be assumed to pass through. Validation should instead focus on internal object conversion.


Comparing Azure Functions to Minimal API

Azure Function Example

[FunctionName(nameof(OnContractAdded))]
public async Task OnContractAdded([EventGridTrigger] EventGridEvent eventGridEvent)
{
    var eventData = eventGridEvent.As<object>(); // Anonymized data

    Activity.Current?.AddTag("EventId", eventGridEvent.Id);

    // Perform internal actions (e.g., updating records, processing data)

    _telemetryClient.TrackEvent(
        "EventProcessed",
        new Dictionary<string, string>
        {
            { "CustomerId", "Redacted" },  // Anonymized customer data
            { "Role", "Redacted" },         // Anonymized role data
        });

    var elapsedTime = DateTime.UtcNow - eventGridEvent.EventTime;

    _telemetryClient.GetMetric(
        "EventLatency", 
        "CustomerId", 
        "Role", 
        "EventId")
        .TrackValue(
            elapsedTime.TotalMinutes, 
            "Redacted",  // Anonymized customer data
            "Redacted",  // Anonymized role data
            eventGridEvent.Id
        );
}

Enter fullscreen mode Exit fullscreen mode

Minimal API with FastEndpoints

public class PostContractAddedEndpoint(
    IRepository repository,  // Anonymized repository service
    TelemetryClient telemetry)   // Anonymized telemetry service
    : Endpoint<PostContractAddedRequest>
{
    public override void Configure()
    {
        Post("contractadded");
        RequestBinder(new ContractAddedRequestBinder());
    }

    public override async Task HandleAsync(PostContractAddedRequest req, CancellationToken ct)
    {
        Activity.Current?.AddTag("EventId", req.EventId);

        // Perform internal actions (e.g., updating records, processing data)
        await repository.PerformActionAsync(
            req.ContractDetails.CompanyId,    // Anonymized property name
            req.ContractDetails.Role,         // Anonymized property name
            action => action.CreateContract(
                new ContractMutation(
                    req.CloudEvent.Time!.Value.DateTime)),
            ct);

        telemetry.TrackEvent("EventProcessed",   // Anonymized event name
            new Dictionary<string, string>
            {
                { "CustomerId", "Redacted" },  // Anonymized customer data
                { "Role", "Redacted" },        // Anonymized role data
            });

        var elapsedTime = DateTime.UtcNow - (DateTimeOffset)req.CloudEvent.Time!;
        telemetry.GetMetric("EventLatency", "CustomerId", "Role", "EventId")
            .TrackValue(elapsedTime.TotalMinutes, "Redacted", "Redacted", req.EventId);
    }
}

Enter fullscreen mode Exit fullscreen mode

Request

public record PostContractAddedRequest(CloudEvent CloudEvent) : ICloudEvent
{
    [HideFromDocs]
    public object ContractDetails { get; set; } = default!;  // Anonymized object

    [HideFromDocs]
    public string EventId { get; set; } = default!;  // Anonymized event ID
};
Enter fullscreen mode Exit fullscreen mode

Validator:

internal class PostContractAddedValidator : Validator<PostContractAddedRequest>
{
    public PostContractAddedValidator(ILogger<PostContractAddedValidator> logger)
    {
        RuleFor(x => x.CloudEvent).Must((_, cloudEvent) =>
        {
            logger.LogInformation("Validating CloudEvent for Event: {CloudEvent}", JsonSerializer.Serialize(cloudEvent));
            if (cloudEvent is null ||
                cloudEvent.Data is null ||
                cloudEvent.Time is null)
            {
                return false;
            }

            return true;
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

RequestBinder:

internal class ContractAddedRequestBinder : RequestBinder<PostContractAddedRequest>
{
    public async override ValueTask<PostContractAddedRequest> BindAsync(BinderContext ctx, CancellationToken ct)
    {
        var httpContext = ctx.HttpContext;
        var httpContent = httpContext.Request.Body;

        // Read the request body as byte array
        using var memoryStream = new MemoryStream();
        await httpContent.CopyToAsync(memoryStream, ct);
        var bytes = memoryStream.ToArray();

        // Parse the JSON payload into a list of events
        var cloudEvents = CloudEvent.ParseMany(new BinaryData(bytes));

        var cloudEvent = cloudEvents.FirstOrDefault();

        var result = new PostContractAddedRequest(cloudEvent!)
        {
            ContractDetails = cloudEvent?.As<object>()!,  // Anonymized data
            EventId = cloudEvent?.Id!  // Anonymized event ID
        };

        return result;
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Migrating Azure Event Grid handlers from Azure Functions to Minimal API with FastEndpoints requires some effort to get set up. However, with the steps outlined here, you should be able to migrate your solution successfully.

Have you migrated Azure Event Grid calls to webhooks? Feel free to share your experience 😊

Top comments (0)