user10691876
user10691876

Reputation:

Cleanest way to implement multiple parameters filters in a REST API

I am currently implementing a RESTFUL API that provides endpoints to interface with a database .

I want to implement filtering in my API , but I need to provide an endpoint that can provide a way to apply filtering on a table using all the table's columns.

I've found some patterns such as :

GET /api/ressource?param1=value1,param2=value2...paramN=valueN

param1,param2...param N being my table columns and the values.

I've also found another pattern that consists of send a JSON object that represents the query .

To filter on a field, simply add that field and its value to the query :

GET /app/items
{
  "items": [
    {
      "param1": "value1",
      "param2": "value",
      "param N": "value N"
    }
  ]
}

I'm looking for the best practice to achieve this .

I'm using EF Core with ASP.NET Core for implementing this.

Upvotes: 2

Views: 7079

Answers (2)

Artem Vertiy
Artem Vertiy

Reputation: 1118

I have invented and found it useful to combine a few filters into one type for example CommonFilters and make this type parseable from string:

[TypeConverter(typeof(CommonFiltersTypeConverter))]
public class CommonFilters
{
    public PageOptions PageOptions { get; set; }

    public Range<decimal> Amount { get; set; }
      
    //... other filters 

    [JsonIgnore]
    public bool HasAny => Amount.HasValue || PageOptions!=null;

    public static bool TryParse(string str, out CommonFilters result)
    {
        result = new CommonFilters();
        if (string.IsNullOrEmpty(str))
            return false;

        var parts = str.Split(new[] { ' ', ';' }, StringSplitOptions.RemoveEmptyEntries);
        foreach (var part in parts)
        {
            if (part.StartsWith("amount:") && Range<decimal>.TryParse(part.Substring(7), out Range<decimal> amount))
            {
                result.Amount = amount;
                continue;
            }
            if (part.StartsWith("page-options:") && PageOptions.TryParse(part.Substring(13), out PageOptions pageOptions))
            {
                result.PageOptions = pageOptions;
                continue;
            }
            //etc.
        }
        return result.HasAny;
    }

    public static implicit operator CommonFilters(string str)
    {
        if (TryParse(str, out CommonFilters res))
            return res;
        return null;
    }

}


public class CommonFiltersTypeConverter : 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 str)
        {
            if (CommonFilters.TryParse(str, out CommonFilters obj))
            {
                return obj;
            }
        }

        return base.ConvertFrom(context, culture, value);
    }

}

the request looks like this:

public class GetOrdersRequest
    {
        [DefaultValue("page-options:50;amount:0.001-1000;min-qty:10")]
        public CommonFilters Filters { get; set; }
        
        //...other stuff
    }

In this way you reduce the number of input request parameters, especially when some queries don't care about all filters

If you use swagger map this type as string:

c.MapTypeAsString<CommonFilters>();

public static void MapTypeAsString<T>(this SwaggerGenOptions swaggerGenOptions)
        {
            swaggerGenOptions.MapType(typeof(T), () => new OpenApiSchema(){Type = "string"});
        }

Upvotes: 0

Steve Py
Steve Py

Reputation: 34783

Firstly be cautious about filtering on everything/anything. Base the available filters on what users will need and expand from that depending on demand. Less code to write, less complexity, fewer indexes needed on the DB side, better performance.

That said, the approach I use for pages that have a significant number of filters is to use an enumeration server side where my criteria fields are passed back their enumeration value (number) to provide on the request. So a filter field would comprise of a name, default or applicable values, and an enumeration value to use when passing an entered or selected value back to the search. The requesting code creates a JSON object with the applied filters and Base64's it to send in the request:

I.e.

{
  p1: "Jake",
  p2: "8"
}

The query string looks like: .../api/customer/search?filters=XHgde0023GRw....

On the server side I extract the Base64 then parse it as a Dictionary<string,string> to feed to the filter parsing. For example given that the criteria was for searching for a child using name and age:

// this is the search filter keys, these (int) values are passed to the search client for each filter field.
public enum FilterKeys
{
    None = 0,
    Name,
    Age,
    ParentName
}

public JsonResult Search(string filters)
{
    string filterJson = Encoding.UTF8.GetString(Convert.FromBase64String(filters));
    var filterData = JsonConvert.DeserializeObject<Dictionary<string, string>>(filterJson);

    using (var context = new TestDbContext())
    {
        var query = context.Children.AsQueryable();

        foreach (var filter in filterData)
            query = filterChildren(query, filter.Key, filter.Value);

        var results = query.ToList(); //example fetch.
        // TODO: Get the results, package up view models, and return...
    }
}

private IQueryable<Child> filterChildren(IQueryable<Child> query, string key, string value)
{
    var filterKey = parseFilterKey(key);
    if (filterKey == FilterKeys.None)
        return query;

    switch (filterKey)
    {
        case FilterKeys.Name:
            query = query.Where(x => x.Name == value);
            break;
        case FilterKeys.Age:
            DateTime birthDateStart = DateTime.Today.AddYears((int.Parse(value) + 1) * -1);
            DateTime birthDateEnd = birthDateStart.AddYears(1);
            query = query.Where(x => x.BirthDate <= birthDateEnd && x.BirthDate >= birthDateStart);
            break;
    }
    return query;
}

private FilterKeys parseFilterKey(string key)
{
    FilterKeys filterKey = FilterKeys.None;

    Enum.TryParse(key.Substring(1), out filterKey);
    return filterKey;
}

You can use strings and constants to avoid the enum parsing, however I find enums are readable and keep the sent payload a little more compact. The above is a simplified example and obviously needs error checking. The implementation code for complex filter conditions such as the age to birth date above would better be suited as a separate method, but it should give you some ideas. You can search for children by name, and/or age, and/or parent's name for example.

Upvotes: 2

Related Questions