In this post, let's have a look at asynchronous validation support for
Minimal APIs, which is going to be available in ASP.NET Core 11.0.
Some time back, I blogged about
Validation Support for Minimal APIs
and
Custom Validation
in ASP.NET Core 10.0. This is a continuation of that, but this time the
validators run asynchronously.
With the latest .NET 11 Preview 6, Minimal APIs now supports
asynchronous validators end-to-end.
Let's look at the two ways to write asynchronous validators.
AsyncValidationAttribute
This is the asynchronous version of a custom ValidationAttribute. We inherit from AsyncValidationAttribute and override
IsValidAsync. Here we can resolve the services from the
ValidationContext and use those services for our validation logic.
public record Product( [Required, UniqueSku] string Sku, [Required] string Name); internal sealed class UniqueSkuAttribute : AsyncValidationAttribute { protected override ValidationResult? IsValid(object? value, ValidationContext validationContext) => throw new InvalidOperationException($"Validate this attribute with '{nameof(IsValidAsync)}'."); protected override async Task<ValidationResult?> IsValidAsync(object? value, ValidationContext validationContext, CancellationToken cancellationToken) { ICatalogService catalogService = validationContext.GetRequiredService<ICatalogService>(); if (value is string sku && await catalogService.SkuExistsAsync(sku, cancellationToken)) { return new ValidationResult("A product with that SKU already exists."); } return ValidationResult.Success; } }
Note the UniqueSku attribute in Sku. That's the custom attribute we created that performs an
asynchronous validation. Here since IsValid in ValidationAttribute is abstract, we have to implement it even
though we validate asynchronously. Here it just throws.
Something to remember: for minimal API
validation using Microsoft.Extensions.Validation, the framework
always calls the async path and never the sync path. So it's safe to throw.
Now say we have the following API.
using System.ComponentModel.DataAnnotations; using System.Runtime.CompilerServices; WebApplicationBuilder builder = WebApplication.CreateBuilder(args); // Add validation services to the container builder.Services.AddValidation(); builder.Services.AddSingleton<ICatalogService, DummyCatalogService>(); WebApplication app = builder.Build(); app.MapPost("/products", (Product product) => Results.Ok(product)); app.MapPost("/orders", (OrderRequest orderRequest) => Results.Ok(orderRequest)); app.Run();
Just like before, we enable validation by calling
AddValidation().
Now let's create a Product with an existing SKU.
@WebApplication1_HostAddress = http://localhost:5016
POST {{WebApplication1_HostAddress}}/products
Content-Type: application/json
{
"sku": "EXISTING_SKU",
"name": "Some Product"
}
Throws 400 as expected.
IAsyncValidatableObject
This is the asynchronous version of
IValidatableObject. This is useful when the validation needs more than one property. We
implement ValidateAsync, which returns an
IAsyncEnumerable<ValidationResult>.
public record OrderRequest( [Range(1, int.MaxValue)] int ProductId, [Range(1, int.MaxValue)] int Quantity, string PromoCode) : IAsyncValidatableObject { public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) => throw new InvalidOperationException($"Validate this type with '{nameof(ValidateAsync)}'."); public async IAsyncEnumerable<ValidationResult> ValidateAsync( ValidationContext validationContext, [EnumeratorCancellation] CancellationToken cancellationToken = default) { ICatalogService catalogService = validationContext.GetRequiredService<ICatalogService>(); if (!await catalogService.HasStockAsync(ProductId, Quantity, cancellationToken)) { yield return new ValidationResult( $"Product '{ProductId}' does not have '{Quantity}' unit(s) in stock.", [nameof(Quantity)]); } if (!await catalogService.IsPromoCodeValidAsync(PromoCode, cancellationToken)) { yield return new ValidationResult( $"Promo code '{PromoCode}' is not valid.", [nameof(PromoCode)]); } } }
IAsyncValidatableObject extends IValidatableObject, so the synchronous Validate still has to be there. Same as before,
it just throws.
Ordering more than what we have in stock and also with an invalid Promo
Code,
@WebApplication1_HostAddress = http://localhost:5016
POST {{WebApplication1_HostAddress}}/orders
Content-Type: application/json
{
"productId": 1,
"quantity": 100,
"promoCode": "INVALID"
}
Happy Coding.
Regards,
Jaliya
No comments:
Post a Comment