Pathoschild / FluentHttpClient

A modern async HTTP client for REST APIs. Its fluent interface lets you send an HTTP request and parse the response in one go.
MIT License
345 stars 52 forks source link

Enforce maximum request size #110

Closed Jericho closed 2 years ago

Jericho commented 2 years ago

I interface with a 3rd party API and I know that my requests will be rejected if they exceed a given maximum size. For example, I can attach multiple files when I POST to an endpoint but my request must not exceed 10MB. If I am not careful, I could add multiple files to my request which would grow in size to be much more than the allowed 10MB. I would needlessly upload all this content to the API and receive a "Your request exceeds the maximum allowable size" exception.

It would be very convenient if FluentHttpClient was able to check the size of a request and refuse to dispatch it if exceeds a given max size. The default, of course, would be not to enforce any max size in order to match the current behavior. What I have in mind is something like this:

    // If desired, set a global default which will be enforced with every single request
    var fluentClient = new FluentClient(new Uri("https://example.org"))
        .AddDefault(req => req.WithMaxSize(1234));

    // alternatively, set the max size only for a given request
    var response = await fluentClient
        .PostAsync("endpoint")
        .WithBody(new StringContent("Hello World!"))
        .WithMaxSize(4567)
        .AsResponse()
        .ConfigureAwait(false);

The size would be calculated by adding up the number of bytes in the request headers with the number of bytes in the body.

The goal is to avoid uploading potentially large content to an API endpoint when we know that it will be rejected.

Will submit PR with my proposed implementation.

Pathoschild commented 2 years ago

Thanks for the PR! It seems a bit specialized to be its own feature though. I'd suggest using a request filter instead, which is designed to support custom validation like that. For example:

public class MaxRequestSizeFilter : IHttpFilter
{
    private readonly ulong MaxSize;

    public MaxRequestSizeFilter(ulong maxSize)
    {
        this.MaxSize = maxSize;
    }

    public void OnRequest(IRequest request)
    {
        // ensure the size of the request does not exceed the max size
        string headers = request.Message.Headers.ToString();
        byte[] content = request.Message.Content.ReadAsByteArrayAsync().Result;
        ulong requestSizeInBytes = (ulong)((headers.Length * sizeof(char)) + content.Length);

        if (requestSizeInBytes > this.MaxSize)
        {
            throw new InvalidOperationException($"The request size ({requestSizeInBytes} bytes) exceeds the maximum size of {this.MaxSize} bytes.");
        }
    }

    public void OnResponse(IResponse response, bool httpErrorAsException) { }
}
IClient client = new FluentClient("https://example.org");
client.Filters.Add(new MaxRequestSizeFilter(1234));

var response = await fluentClient
    .PostAsync("endpoint")
    .WithBody(new StringContent("Hello World!"))
    .AsResponse();

If you need to configure it per-request, we could add support for setting filters per-request too.

Jericho commented 2 years ago

You're right: a filter is a much better idea. I should have thought of that!

As far as configuring it per-request, here's what I came up with:

// Prepare the request
var request = fluentClient
    .PostAsync("endpoint")
    .WithBody(new StringContent("Hello World!"))
    .WithCancellationToken(cancellationToken);

// Replace the current filter (if any) with a new filter with the desired max size
request.Filters.Remove<MaxRequestSizeFilter>();
request.Filters.Add(new MaxRequestSizeFilter(1234));

// Send the request
var response = await request
    .AsResponse()
    .ConfigureAwait(false);

Thanks for helping me think through this scenario and for suggesting a solution that is simpler and better than what I had come up with. Feel free to close this issue and the related PR.

Jericho commented 2 years ago

By the way, Filters.Remove followed by Filters.Add works fine but it's not "fluent". Maybe adding a "WithFilter" method to IRequest and Request would be more elegant. Something as simple as this:

public IRequest WithFilter<T>(T filter) where T : IHttpFilter
{
    this.Filters.Remove<T>();
    this.Filters.Add(filter);
    return this;
}

This would allow me to change my code to:

var response = await fluentClient
    .PostAsync("endpoint")
    .WithBody(new StringContent("Hello World!"))
    .WithFilter(new MaxRequestSizeFilter(1234))
    .AsResponse();
Pathoschild commented 2 years ago

Yep, that's what I was thinking. I wouldn't remove previous filters automatically though, since that wouldn't always be what you want. For example:

await client
    .WithFilter(new HandleErrorCode("ItemNotFound", HttpStatusCode.NotFound))
    .WithFilter(new HandleErrorCode("WhoAreYou", HttpStatusCode.Unauthorized))

We could add something like .WithoutFilter<T>(), but you might not need it since the WithFilter would only apply for the request you're configuring now.

Jericho commented 2 years ago

In my scenario, I always want the global filter to be replaced but it did occur to me that it might not be the case for every scenario. I propose improving my suggestion like so:

public IRequest WithFilter<TFilter>(TFilter filter, bool removeExisting = true) where TFilter : IHttpFilter
{
    if (removeExisting) this.Filters.Remove<TFilter>();
    this.Filters.Add(filter);
    return this;
}
Pathoschild commented 2 years ago

Done in #112 (thanks for the PR!). The new fluent equivalent is:


var response = await client
    .PostAsync("endpoint")
    .WithBody(new StringContent("Hello World!"))
    .WithCancellationToken(cancellationToken)
    .WithoutFilter<MaxRequestSizeFilter>() // can be skipped if you didn't add one on the client level
    .WithFilter(new MaxRequestSizeFilter(1234))
    .AsResponse()
    .ConfigureAwait(false);