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:
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:
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).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?
Well, this is the best I've come up with to try and minimize the 'ceremony' needed in different classes.
ApiPayload.BindAsync
(just let it throw)ApiPayload?
in my endpoint handlerMapPostWithGuard
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
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.
The BindAsync
was throwing an exception before any Api filters run so I couldn't catch error in filter or in endpoint handler itself.
@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).
@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.
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.