dotnet / aspnetcore

ASP.NET Core is a cross-platform .NET framework for building modern cloud-based web applications on Windows, Mac, or Linux.
https://asp.net
MIT License
35.06k stars 9.9k forks source link

Pass an object as a parameter to a minimal API endpoint #55184

Open htmlsplash opened 4 months ago

htmlsplash commented 4 months ago

Is there an existing issue for this?

Is your feature request related to a problem? Please describe the problem.

I would like to pass an object (instead of primitive type) as a parameter to my minimal API as part of a get request. Basically, I have a Bazlor component that runs on the client (using InteractiveWebassembly render mode) which invokes a minimal api end point on the server in the following way:

GetFromJsonAsync

Example:

I have the following minimal API code:

builder.MapGet("/api/counties", GetCounties2);

Task<IList<CodeType>> GetCounties2(string keywords, [FromQuery] AutoCompleteParameters parameters, ICountiesService countiesService, HttpContext context)

Invocation from the WebAssembly component:

` public async Task<IList> GetCountiesAsync(string keywords, AutoCompleteParameters parameters) {

var qs = $"?keywords={keywords}";
qs = parameters.AppendToQuery(qs);
Console.WriteLine(qs);

var coll = await _http.GetFromJsonAsync<CodeType[]>($"/api/counties/{qs}") ?? [];
return coll;

} `

The AppendToQuery() method is (which just appends key/value pair to query string for each public field that is set by the user):

var buff = new StringBuilder(queryString); var fields = this.GetType().GetFields(); foreach (var field in fields) { var value = field.GetValue(this); if (value != null) { buff.Append($"&{field.Name}={value}"); } } return buff.ToString();

I have followed instructions at (https://learn.microsoft.com/en-us/aspnet/web-api/overview/formats-and-model-binding/parameter-binding-in-aspnet-web-api) and tried the first 2 examples, using FromQuery and Type Converters, and both examples did not work. I haven't tried the 3rd example using a "Model Binder" example since I don't know if that will work either.

When using the FromQuery attribute I got an exception that my type did not have TryParse() method. After adding TryParse() method, it was never called by the run time.

As for the type converter, I added it to my type and it was completely ignored. I keep getting null for the parameter on the server for my object, the type converter is not used and my TryParse is not being invoked.

[TypeConverter(typeof(AutoCompleteParametersConverter))] public class AutoCompleteParameters

Implementation of my type converter is:

`internal class AutoCompleteParametersConverter : TypeConverter { public override bool CanConvertFrom(ITypeDescriptorContext context, Type sourceType) { if (sourceType == typeof(string)) { return true; } return base.CanConvertFrom(context, sourceType); }

public override object ConvertFrom(ITypeDescriptorContext context,
    CultureInfo culture, object value)
{
    if (value is string)
    {
        AutoCompleteParameters p;
        if (AutoCompleteParameters.TryParse((string)value, out p))
        {
            return p;
        }
    }
    return base.ConvertFrom(context, culture, value);
}

}`

Describe the solution you'd like

See original problem description.

Additional context

No response

htmlsplash commented 4 months ago

As a work around, I have hacked it this way, but I wish this would work automatically out of the box:

static Task<IList<CodeType>> GetCounties2(string keywords, ICountiesService countiesService, HttpContext context) { AutoCompleteParameters.TryParse(context.Request.QueryString.ToString(), out var parameters); return countiesService.GetCountiesAsync(keywords, parameters); }

captainsafia commented 3 months ago

@htmlsplash It looks like you're using minimal APIs. If so, TypeConverters won't work in this case because they are only support in MVC.

For minimal APIs, you might consider implementing a custom BindAsync method for your type. The docs are a good start for this.

htmlsplash commented 3 months ago

Thanks for the link and your answer. Will give it a try!

htmlsplash commented 3 months ago

@captainsafia I cannot use the BindAsync solution (but I trust it works) because the AutoCompleteParameters type/class that is serialized to Query String lives in BlazorComponents.Client assembly and there's NO http context which is required by BindAsync implementation.

However, I was looking at the other option, using a static TryParse method, which I tried, but did not work for me. In other words, the TryParse() method was never invoked on my type (AutoCompleteParameters) when the call was made on the server. My method signature of the api endpoint (GetCounties) is a bit different from the example (I have more parameters in it), and I am NOT passing parameters as comma delimited values (in query string) but instead as key value pairs. Which assumption am I violating with my example below?

My code:

AutoCompleteParameters type here's the method signature for try parse:

public static bool TryParse(string s, IFormatProvider provider, out AutoCompleteParameters result)

Method signature on the server for the api endpoint:

builder.MapGet("/api/counties", GetCounties);

static async Task<IResult> GetCounties(string keywords, AutoCompleteParameters parameters, ICountiesService countiesService, HttpContext context)

And here's the request made, specifically, the QueryString looks something like this:

?keywords=a&EntityName=County&SearchField=Description&DisplayFields=Code%2C+Description

captainsafia commented 3 months ago

@htmlsplash Can you try annotation your AutoCompleteParameters argument with the [FromQuery] attribute like so?

static async Task<IResult> GetCounties(
    string keywords,
    [FromQuery] AutoCompleteParameters parameters,
    ICountiesService countiesService,
    HttpContext context)
htmlsplash commented 3 months ago

@captainsafia I tried it both ways, same result. After reading the documentation more carefully, it appears to me that the Binder has no way to hookup my AutoCompleteParameters type (named "parameters" in the route handler) to the Query string (QS) values, because such identifier doesn't exist anywhere in the QS. In the point Example, the QS contained "point" identifier that matched the route handler's parameter name.

I bet if I rewrite my QS as such:

?keywords=a&parameters=County,Description,Code%2C+Description

Then the Binder will see and match parameters identifier in QS to the parameters name in the route handler. I will try this tomorrow. On a side note than, instead of using positional parsing (as demonstrated in the "Point" example) would there be a possibility to have support for key/value pair binding. So the QS could looks like this instead:

?keywords=a&parameters=EntityName:County,SearchField:Description,DisplayFields:Code%2C+Description

During binding, the binder would hydrate my AutoCompleteParameters type identified by parameters name in the route handler using key/value pairs in QS.

UPDATE: I corrected my QS as indicated above (by adding "parameters" identifier) and it worked, my TryParse method is now being invoked.

UPDATE 2 In addition, got it to work using "AsParameters" attribute. The "keywords" parameter is extracted from QS and the "parameters" parameter is extracted from the route's segments.

The request URI looks like this (where "qs" is the query string):

$"/api/counties/{parameters.EntityName}/{parameters.SearchField}/{parameters.DisplayFields}/{qs}"

And the Route handler is mapped as follows:

builder.MapGet("/api/counties/{EntityName}/{SearchField}/{DisplayFields}", GetCounties3);

GetCounties3([FromQuery] string keywords, [AsParameters] AutoCompleteParameters parameters, ICountiesService countiesService, HttpContext context)

The only very minor criticism is that for larger objects (with many properties), you have to be very careful that the fields in the api call must match the order you map them in the route handler. Since this code is in 2 different places, you might make a mistake and not realize it.

To my pleasant surprise the "AsParameters" attribute will even work if you have 2, or 3 separate/distinct objects in the route handler that need to be hydrated from a single route segment. As long as there's no field name collisions (ie. the different objects use unique field names) it just works. This example should be in the docs, because it is super useful feature and might be a common scenario.

Anyway, I have many options to chose from, I'll probably stick with using "AsParameters" binding.

infofromca commented 3 months ago

@htmlsplash do not use minimal api which is not mature. and following this post just make me headaches. instead, use mvc https://github.com/dotnet/aspnetcore/issues/55719

htmlsplash commented 3 months ago

@infofromca Actually, our application requirements for generating some data from the server are very low end. Minimal api is perfect for this. Ex: AutoComplete list control, I don't need full blown MVC for this. As a matter of fact, MVC is great, but it is overkill for what we want to do; The current features of minimal apis are sufficient albeit it sometimes requires some extra work arounds.