The HTTP specification is a living document constantly being evaluated and modified. While many developers may consider the specification “done”, many suggested HTTP methods are on the horizon that will allow you to do new and exciting things. One such HTTP method is the QUERY method, which will have similar semantics to a GET method call, but allow users to include a request body. As you can imagine, this opens up a world of possibilities for more complex queries than your typical query string would allow.
This post will show how to add support for experimental HTTP methods in ASP.NET Core. We’ll also discuss why you may not want to do so. Let’s get started.
What is the HTTP Query Method?
Before we jump into the C# implementation, we should cover HTTP Query and why folks may want to use it in their API implementations.
When developers build HTTP APIs, queries are typically transmitted using the GET or POST method. GET requests include the query parameters in the query string and the URL path. While it is possible to communicate complex queries using the GET method, it has limitations within the HTTP spec. For example, values in the query string must be URL encoded, and there is a limit to how much data can be in a URL. Therefore, when developers reach the limits of using the GET method, they typically use the POST method, which allows for more complex query syntax in the request’s body. However, using POST also has drawbacks, as these calls will not opt into the same caching policies as a GET request and essentially put more stress on the host server.
The QUERY method combines the benefits of the GET method, as it pertains to caching and idempotency semantics, while also giving developers the ability to post a body similar to the POST request. The addition of the body allows developers to send any message to the server, including complex query languages like GraphQL, SQL, LINQ, and anything.
I suggest reading the post Your Guide to the New HTTP Query Method to learn more about the new HTTP method.
Add A New HTTP Method To Minimal API Endpoints
Luckily, ASP.NET Core is flexible in its HTTP method detection. As a result, you can add any HTTP method without much plumbing code. Let’s first add the QUERY method to a Minimal API endpoint.
When working with Minimal APIs, you can use the MapMethods
method found on IEndpointConventionBuilder
. The method allows you to pass in any string value that ASP.NET Core could interpret as an HTTP method. For ease of use, you can also make a MapQuery
extension method to match the other methods of MapGet
, MapPost
, etc.
public static class HttpQueryExtensions
{
public static IEndpointConventionBuilder MapQuery(
this IEndpointRouteBuilder endpoints,
string pattern,
Func<Query, IResult> requestDelegate)
{
return endpoints.MapMethods(pattern, new[] { "QUERY" }, requestDelegate);
}
public static IEndpointConventionBuilder MapQuery(
this IEndpointRouteBuilder endpoints,
string pattern,
RequestDelegate requestDelegate)
{
return endpoints.MapMethods(pattern, new[] { "QUERY" }, requestDelegate);
}
}
You may also notice that our extension method has a Func<Query,IResult>
. The Query
type is there for model binding the body of our request to a string. Let’s look at this class; although it’s entirely optional, you may choose to implement the reading of the body any way you’d like, possibly even serializing it into a strongly-typed entity.
public class Query
{
public string? Text { get; set; }
public static async ValueTask<Query> BindAsync(
HttpContext context,
ParameterInfo parameter)
{
string? text = null;
var request = context.Request;
if (!request.Body.CanSeek)
{
// We only do this if the stream isn't *already* seekable,
// as EnableBuffering will create a new stream instance
// each time it's called
request.EnableBuffering();
}
if (request.Body.CanRead)
{
request.Body.Position = 0;
var reader = new StreamReader(request.Body, Encoding.UTF8);
text = await reader.ReadToEndAsync().ConfigureAwait(false);
request.Body.Position = 0;
}
return new Query { Text = text };
}
public static implicit operator string(Query query) // implicit digit to byte conversion operator
{
return query.Text ?? string.Empty; // implicit conversion
}
}
Finally, let’s implement our endpoint. In this example, I’ll convert the request’s body into a LINQ expression. Warning: Do not do this in production, as an expression is C# code and could lead to security vulnerabilities. This sample is for demo purposes.
app.MapQuery("/people", query =>
{
try
{
// database
var people = Enumerable.Range(1, 100)
.Select(i => new Person { Index = i, Name = $"Minion #{i}" });
// let's use the Query
var parameter = Expression.Parameter(typeof(IEnumerable<Person>), nameof(people));
var expression = DynamicExpressionParser.ParseLambda(new[] { parameter }, null, query.Text);
var compiled = expression.Compile();
// execute query
var result = compiled.DynamicInvoke(people);
return Results.Ok(new
{
query,
source = "endpoint",
results = result
});
}
catch (Exception e)
{
return Results.BadRequest(e);
}
})
.WithName("endpoint");
We’ll get to calling this endpoint later, but first, let’s also add QUERY support to ASP.NET Core MVC.
Add A New HTTP Method To ASP.NET Core MVC
Adding experimental HTTP methods to ASP.NET Core MVC is much more straightforward. You only need to implement a class derived from HttpMethodAttribute
.
public class HttpQueryAttribute : HttpMethodAttribute
{
private static readonly IEnumerable<string> SupportedMethods = new[] { "QUERY" };
/// <summary>
/// Creates a new <see cref="Microsoft.AspNetCore.Mvc.HttpGetAttribute"/>.
/// </summary>
public HttpQueryAttribute()
: base(SupportedMethods)
{
}
/// <summary>
/// Creates a new <see cref="Microsoft.AspNetCore.Mvc.HttpGetAttribute"/> with the given route template.
/// </summary>
/// <param name="template">The route template. May not be null.</param>
public HttpQueryAttribute([StringSyntax("Route")] string template)
: base(SupportedMethods, template)
{
if (template == null)
{
throw new ArgumentNullException(nameof(template));
}
}
}
Once implemented, you only need to decorate your ASP.NET Core MVC action with the new attribute. As a final implementation, let’s look at an action that can now handle QUERY requests.
[Route("api/people")]
public class PeopleController : Controller
{
[HttpQuery, Route("", Name = "controller")]
public async Task<IActionResult> Index()
{
try
{
var body = await new StreamReader(Request.Body).ReadToEndAsync();
var query = new Query { Text = body };
// database
var people = Enumerable.Range(1, 100)
.Select(i => new Person { Index = i, Name = $"Minion #{i}" });
// let's use the Query
var parameter = Expression.Parameter(typeof(IEnumerable<Person>), nameof(people));
var expression = DynamicExpressionParser.ParseLambda(new[] { parameter }, null, query.Text);
var compiled = expression.Compile();
// execute query
var result = compiled.DynamicInvoke(people);
return Ok(new
{
query,
source = "controller",
results = result
});
}
catch (Exception e)
{
return BadRequest(e);
}
}
}
In this case, I’ve avoided the model binding because ASP.NET Core MVC’s model binding can be overly complicated. However, if you’d like to try to implement it yourself, please be kind and send me a link to your implementation. As mentioned, you may want to consider your query methodology before completely implementing the QUERY method.
Great! Now we have a Minimal API endpoint and an ASP.NET Core MVC action that can respond to a Query call from a client. Let’s see how we call these endpoints.
Calling our QUERY Endpoints and Actions
Here lies the first limitation you’ll likely run into implementing experimental HTTP methods: Most of the tooling you rely on has no idea about the existence of the method. Unfortunately, the experimental nature means you can’t use tools like Postman, Swagger, and similar tools. Code is your friend. Luckily, it’s pretty easy to call the endpoints with the HttpClient
class.
I’ll use another GET endpoint to call our Query endpoint, although it could be any C# code hosted in any code base that makes the request.
app.MapGet("/", async (HttpContext ctx, LinkGenerator generator, string? q, string? e) =>
{
var client = new HttpClient();
var endpoint = e switch {
"endpoint" => "endpoint",
"controller" => "controller",
_ => "endpoint"
};
var request = new HttpRequestMessage(
new HttpMethod("QUERY"),
generator.GetUriByName(ctx, endpoint)
);
//language=C#
q ??= "people.OrderByDescending(p => p.Index).Take(10)";
request.Content = new StringContent(q, Encoding.UTF8, "text/plain");
var response = client.Send(request);
var result = await response.Content.ReadAsStringAsync();
return Results.Content(result, "application/json");
});
The most important takeaway from the preceding code is using the HttpMethod
class. It allows us to pass in any string value; in our case, we want to set the method to QUERY. We also will be sending a LINQ expression to filter our dataset. The rest of the code is what you’d typically see when using an HttpClient
.
Calling our GET endpoint triggers a call to our Query endpoints and retrieves our results.
Try out the complete sample at my GitHub repository.
Drawbacks of Being Experimental
While trying and thinking about using experimental HTTP methods in your APIs is fun, you should consider the drawbacks before jumping into the process.
- It is experimental so the specification may change after your implementation.
- Your favorite tools will likely not have support for your experimental methods, forcing you to code all interactions.
- Common ASP.NET Core libraries will not work as expected. For example, in ASP.NET Core, Swashbuckle (The OpenAPI library) will fail to materialize your endpoint schema.
- I know no infrastructural benefits from caching, load balancers, etc. Worse, You may introduce issues into your infrastructure.
Conclusion
ASP.NET Core supports adding any method you would like as long as you know what to implement. This was a fun exercise and, if nothing else, teaches you some of the mechanisms around endpoint resolution.
If you’d like to try the complete sample, I’ve included all the code on my GitHub repository..
Thank you for reading and sharing my blog posts with other #dotnet developers.