Jonathan Anctil
Jonathan Anctil

Reputation: 1037

Web API with complex array parameters

Need help on this one. I have a WebAPI who can receive multiple ids as parameters. The user can call the API using 2 route:

First route:

api/{controller}/{action}/{ids}

ex: http://localhost/api/{controller}/{action}/id1,id2,[...],idN

Method signature

public HttpResponseMessage MyFunction(
    string action, 
    IList<string> values)

Second route:

"api/{controller}/{values}"

ex: http://localhost/api/{controller}/id1;type1,id2;type2,[...],idN;typeN

public HttpResponseMessage MyFunction(
    IList<KeyValuePair<string, string>> ids)

Now I need to pass a new parameter to the 2 existing route. The problem is this parameter is optional and tightly associated with the id value. I made some attempt like a method with KeyValuePair into KeyValuePair parameter but its results in some conflict between routes.

What I need is something like that :

ex: http://localhost/api/{controller}/{action}/id1;param1,id2;param2,[...],idN;paramN

http://localhost/api/{controller}/id1;type1;param1,id2;type2;param2,[...],idN;typeN;paramN

Upvotes: 4

Views: 2081

Answers (4)

Jonathan Anctil
Jonathan Anctil

Reputation: 1037

I found a solution.

First, I created a class to override the

KeyValuePair<string, string>

type to add a third element (I know it's not really a pair!). I could have use Tuple type also:

 public sealed class KeyValuePair<TKey, TValue1, TValue2>
    : IEquatable<KeyValuePair<TKey, TValue1, TValue2>>

To use this type with parameter, I create an

ActionFilterAttribute

to split (";") the value from the url and create a KeyValuePair (third element is optional)

 public override void OnActionExecuting(HttpActionContext actionContext)
    {
        if (actionContext.ActionArguments.ContainsKey(ParameterName))
        {
            var keyValuePairs = /* function to split parameters */;

            actionContext.ActionArguments[ParameterName] = 
                keyValuePairs.Select(
                    x => x.Split(new[] { "," }, StringSplitOptions.None))
                        .Select(x => new KeyValuePair<string, string, string>(x[0], x[1], x.Length == 3 ? x[2] : string.Empty))
                        .ToList();                
        }
    }

And finally, I add the action attribute filter to the controller route and change the parameter type:

"api/{controller}/{values}"

ex: http://localhost/api/{controller}/id1;type1;param1,id2;type2,[...],idN;typeN;param3

[MyCustomFilter("ids")]
public HttpResponseMessage MyFunction(
    IList<KeyValuePair<string, string, string>> ids)

I could use some url parsing technique, but the ActionFilterAttribute is great and the code is not a mess finally!

Upvotes: 0

Eric Kelly
Eric Kelly

Reputation: 452

Looks like a good scenario for a custom model binder. You can handle your incoming data and detect it your self and pass it to your own type to use in your controller. No need to fight with the built in types.

See here.

From the page (to keep the answer on SO):

Model Binders

A more flexible option than a type converter is to create a custom model binder. With a model binder, you have access to things like the HTTP request, the action description, and the raw values from the route data.

To create a model binder, implement the IModelBinder interface. This interface defines a single method, BindModel:

bool BindModel(HttpActionContext actionContext, ModelBindingContext
bindingContext); 

Here is a model binder for GeoPoint objects.

public class GeoPointModelBinder : IModelBinder {
// List of known locations.
private static ConcurrentDictionary<string, GeoPoint> _locations
    = new ConcurrentDictionary<string, GeoPoint>(StringComparer.OrdinalIgnoreCase);

static GeoPointModelBinder()
{
    _locations["redmond"] = new GeoPoint() { Latitude = 47.67856, Longitude = -122.131 };
    _locations["paris"] = new GeoPoint() { Latitude = 48.856930, Longitude = 2.3412 };
    _locations["tokyo"] = new GeoPoint() { Latitude = 35.683208, Longitude = 139.80894 };
}

public bool BindModel(HttpActionContext actionContext, ModelBindingContext bindingContext)
{
    if (bindingContext.ModelType != typeof(GeoPoint))
    {
        return false;
    }

    ValueProviderResult val = bindingContext.ValueProvider.GetValue(
        bindingContext.ModelName);
    if (val == null)
    {
        return false;
    }

    string key = val.RawValue as string;
    if (key == null)
    {
        bindingContext.ModelState.AddModelError(
            bindingContext.ModelName, "Wrong value type");
        return false;
    }

    GeoPoint result;
    if (_locations.TryGetValue(key, out result) || GeoPoint.TryParse(key, out result))
    {
        bindingContext.Model = result;
        return true;
    }

    bindingContext.ModelState.AddModelError(
        bindingContext.ModelName, "Cannot convert value to Location");
    return false;
} } A model binder gets raw input values from a value provider. This design separates two distinct functions:

The value provider takes the HTTP request and populates a dictionary of key-value pairs. The model binder uses this dictionary to populate the model. The default value provider in Web API gets values from the route data and the query string. For example, if the URI is http://localhost/api/values/1?location=48,-122, the value provider creates the following key-value pairs:

id = "1" location = "48,122" (I'm assuming the default route template, which is "api/{controller}/{id}".)

The name of the parameter to bind is stored in the ModelBindingContext.ModelName property. The model binder looks for a key with this value in the dictionary. If the value exists and can be converted into a GeoPoint, the model binder assigns the bound value to the ModelBindingContext.Model property.

Notice that the model binder is not limited to a simple type conversion. In this example, the model binder first looks in a table of known locations, and if that fails, it uses type conversion.

Setting the Model Binder

There are several ways to set a model binder. First, you can add a [ModelBinder] attribute to the parameter.

public HttpResponseMessage
Get([ModelBinder(typeof(GeoPointModelBinder))] GeoPoint location) 

You can also add a [ModelBinder] attribute to the type. Web API will use the specified model binder for all parameters of that type.

[ModelBinder(typeof(GeoPointModelBinder))] public class GeoPoint {
// .... }

Upvotes: 0

Andy_Vulhop
Andy_Vulhop

Reputation: 4789

Assumption: You are actually doing some command with the data.

If your payload to the server is getting more complex than a simple route can handle, consider using a POST http verb and send it to the server as JSON instead of mangling the uri to shoehorn it in as a GET.


Different assumption: You are doing a complex fetch and GET is idiomatically correct for a RESTFUL service.

Use a querystring, per the answer posted by @TrevorPilley

Upvotes: 0

Trevor Pilley
Trevor Pilley

Reputation: 16393

You might be able to deal with it by accepting an array:

public HttpResponseMessage MyFunction(
    string action, 
    string[] values)

Mapping the route as:

api/{controller}/{action}

And using the query string to supply values:

GET http://server/api/Controller?values=1&values=2&values=3

Upvotes: 1

Related Questions