Search code examples
c#minimal-apis

How to handle MultipartBodyLengthLimit in Minimal APIs with custom model binding from Form


My code is working, but I'm looking to see if I'm doing this in the 'proper' fashion. I have a Minimal API that supports file uploads but also I needed to pass required meta data along with the files, and since [FromForm] isn't supported yet, I did custom model binding via BindAsync method.

Original code:

// Program.cs
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>( options => {
    // MultipartBodyLengthLimit is in kilobytes (KB) 10 * 1024
    options.MultipartBodyLengthLimit = 1024 * 1; // Testing limit, will be bigger in production
});
var app = builder.Build();
app.MapPost( "/api/document-center/upload", DocumentCenterUploadAsync );

// Endpoint implementation...
async Task<IResult> DocumentCenterUploadAsync( ApiPayload apiPayload )
{
    var postedFile = appApiPayload.PostedFiles.FirstOrDefault();
    var category = apiPayload.Category;
    // Upload document
}

// ApiPayload Class
public class ApiPayload
{
    public required string Category { get; init; }
    public required IFormFileCollection PostedFiles { get; init; } = new FormFileCollection();

    public static async ValueTask<ApiPayload?> BindAsync(HttpContext context)
    {
        var form = await context.Request.ReadFormAsync();

        return new ApiPayload
        {
            Category = form[ "Category" ],
            PostedFiles = form.Files
        };
    }
}

This worked well when the limit isn't exceeded. But when file is larger than 1MB, var form = await context.Request.ReadFormAsync(); throws an InvalidDataException. But I need put a custom message and 'input id' based on which endpoint is using the ApiPayload parameter. Below was my first attempt using AddEndpointFilter with the following issues:

  1. The filter never executes before custom model binding occurs, so I can't catch exception.
  2. Unless I'm missing something, the 'message' check for length exceeded is only way I could figure out to tell if this type of exception occurred (since I think InvalidDataException is used for multiple scenarios).
app.MapPost( "/api/document-center/upload", DocumentCenterUploadAsync )
    .AddEndpointFilter(async (efiContext, next) =>
    {
        try
        {
            return await next(efiContext);
        }
        catch ( InvalidDataException ex ) when ( ex.Message.IndexOf( "Multipart body length limit" ) > -1 )
        {
            return Results.Extensions.BadResponse( 
                new Dictionary<string, string>{ 
                    { "iUpload", "You must select a document with a size less than 5MB to upload." } 
                } 
            );
        }
    });

So unfortunately, I had to put the try catch inside my ApiPayload class and communicate back to the endpoint. Below is what I came up with, with the following concerns:

  1. Try/catch is in a model class and have to 'communicate' to whatever endpoint might be using it.
  2. 'Message' check issue still.
  3. Having a generic IEndpointFilter that looked for input id and message info via WithMetadata and efiContext.HttpContext.GetEndpoint().Metadata seemed like clean solution compared to what I have (lot of ceremony to pull it off).
  4. Had to make ApiPayload? nullable since my BindAsync could return null now. Also having to dereference via ! on first call to apiPayload.
// Changed ApiPayload.BindAsync to have try/catch
public static async ValueTask<ApiPayload?> BindAsync(HttpContext context)
{
    try
    {
        var form = await context.Request.ReadFormAsync();

        return new ApiPayload
        {
            Category = form[ "Category" ],
            PostedFiles = form.Files
        };
    }
    catch ( InvalidDataException ex ) when ( ex.Message.IndexOf( "Multipart body length limit" ) > -1 )
    {
        context.Items[ "MaxRequestLengthException" ] = ex;
        return null;
    }
}

// Updated filter to look for context.Items
app.MapPost( "/api/document-center/upload", DocumentCenterUploadAsync )
    .AddEndpointFilter(async (efiContext, next) =>
    {
        var ex = efiContext.HttpContext.Items[ "MaxRequestLengthException" ];

        if ( ex != null )
        {
            return Results.Extensions.BadResponse( new Dictionary<string, string>{ { "iUpload", "You must select a document with a size less than 5MB to upload." } } );
        }

        return await next(efiContext);
    });

// Had to update the signature of DocumentCenterUploadAsync to allow nullable and dereference on first usage
async Task<IResult> DocumentCenterUploadAsync( ApiPayload? apiPayload )
{
    var postedFile = appApiPayload!.PostedFiles.FirstOrDefault();
    var category = apiPayload.Category;
    // Upload document
}

Is this my best option to achieve my goal?


Solution

  • Well, this is the best I've come up with to try and minimize the 'ceremony' needed in different classes.

    1. Removed error handling from my ApiPayload.BindAsync (just let it throw)
    2. Removed the nullablility for ApiPayload? in my endpoint handler
    3. Added MapPostWithGuard extensions.
    // Program.cs
    app.MapPostWithGuard( 
        "/api/document-center/upload", 
        DocumentCenterUploadAsync,
        ex => ex.Message.IndexOf( "Multipart body length limit" ) > -1,
        "iUpload", "You must select a document with a size less than 5MB to upload."
    );
    
    // Extensions
    public static RouteHandlerBuilder MapPostWithGuard( 
        this WebApplication app, 
        string pattern, Delegate handler,
        Func<Exception, bool> guardPredicate,
        Func<HttpContext, Exception, Task> guardHandler
    )
    {
        app.UseWhen( 
            context => context.Request.Path.StartsWithSegments( pattern ),
            appBuilder => {
                appBuilder
                    .Use( async ( context, next ) =>
                    {
                        try
                        {
                            await next();
                        }
                        catch ( Exception ex ) when ( guardPredicate( ex ) )
                        {                            
                            await guardHandler( context, ex );
                        }
                    });
            }
        );
        
        return app.MapPost( pattern, handler );
    }
    
    public static RouteHandlerBuilder MapPostWithGuard( 
        this WebApplication app, 
        string pattern,  Delegate handler,
        Func<Exception, bool> guardPredicate,
        string validationId, string validationMessage
    ) => app.MapPostWithGuard( 
        pattern,
        handler,
        guardPredicate,
        async ( context, ex ) => 
        {
            var badResponseResult = new BadResponseResult(
                new Dictionary<string, string>{ { validationId, validationMessage } },
                ex 
            );
            await badResponseResult.ExecuteAsync( context );
        }
    );
    

    Suggestions welcome.

    Notes

    1. In case it wasn't obvious, I'm just trying to get an html input ID and error messaged passed down to the client. The application could multiple upload screens with different input IDs. So I cannot just have a generic Middleware that does 'all processing' because based on a page, I need to pass custom messages/IDs.

    2. The BindAsync was throwing an exception before any Api filters run so I couldn't catch error in filter or in endpoint handler itself.

    3. @davidfowl - As for checking message text, it just felt wrong to be doing that. In asp.net 4.* web api, I was doing something like: catch ( Exception ex ) when ( ( ex.InnerException?.InnerException as HttpException )?.WebEventCode == 3004 ) (maybe that was wrong, but was just hoping for a 'code' or something to check).

    4. @davidfowl - I had to make ApiPayload? nullable in my endpoint handler originally because if not, when my BindAsync returned null, .NET Core would throw an exception before even getting into my DocumentCenterUploadAsync handler.

    5. I guess I like this over the original attempt because now I don't have to have ApiPayload nullable in any handler that might be receiving an upload and deal with ? and !. Any model I might have with custom BindAsync that might receive a request that is too large, I don't have to duplicate code in each class looking for specific exception and then passing back a 'failure' via a magic context.Items[""] value.