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:
- Handle Webhook HTTP OPTIONS Validation
- Securely Handle Webhook Delivery
- Model Bind the Event Grid
EventGridEvent
orCloudEvent
- 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();
}
}
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.
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
}
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!
};
}
}
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();
}
}
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
);
}
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);
}
}
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
};
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;
});
}
}
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;
}
}
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)