Saibamen
Saibamen

Reputation: 648

Handle multiple endpoints in .NET Core 3.1 Web API by Query Params

I am migrating controllers from .NET Framework to .NET Core and I want to be compatibility with API calls from previous version. I have problem with handling multiple routes from Query Params.

My example controller:

[Route("/api/[controller]")]
[Route("/api/[controller]/[action]")]
public class StaticFileController : ControllerBase
{
    [HttpGet("{name}")]
    public HttpResponseMessage GetByName(string name)
    {
    }

    [HttpGet]
    public IActionResult Get()
    {
    }
}

Calling api/StaticFile?name=someFunnyName will lead me to Get() action instead of expected GetByName(string name).

What I want to achieve:

My app.UseEndpoints() from Startup.cs have only these lines:

endpoints.MapControllers();
endpoints.MapDefaultControllerRoute();

If I use [HttpGet] everywhere and add ([FromQuery] string name) it gets me AmbiguousMatchException: The request matched multiple endpoints

Thank you for your time to helping me (and maybe others)

Upvotes: 4

Views: 5154

Answers (4)

TeaDrinkingProgrammer
TeaDrinkingProgrammer

Reputation: 155

I have taken the answer by Fei Han and simplified it even more:

/// <summary>
/// This is an attribute that functions as a guard for query parameters. Input the query parameters that may pass where "" means no parameters.
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public class QueryParameterGuardAttribute(params string?[] whitelistedQueryParams) : ActionMethodSelectorAttribute
{

    public override bool IsValidForRequest(RouteContext routeContext, ActionDescriptor action)
    {
        var requestQueryParams = routeContext.HttpContext.Request.Query;
        return (whitelistedQueryParams is [""] && requestQueryParams.Count == 0) || requestQueryParams.Keys.SequenceEqual(whitelistedQueryParams);
    }
}

You don't need the canpass attribute anymore, and you can define multiple query parameters.

[Route("api/[controller]")]
[ApiController]
public class StaticFileController : ControllerBase
{
    [HttpGet]
    [QueryStringConstraint("name", "lastname")]
    public IActionResult GetByName(string name, string lastname)
    {
        return Ok("From `GetByName` Action");
    }
    [HttpGet]
    [QueryStringConstraint("name")]
    public IActionResult GetByName(string name)
    {
        return Ok("From `GetByName` Action");
    }

    [HttpGet]
    [QueryStringConstraint("")]
    public IActionResult Get()
    {
        return Ok("From `Get` Action");
    }
}

Upvotes: 0

Saibamen
Saibamen

Reputation: 648

I got my solution from https://www.strathweb.com/2016/09/required-query-string-parameters-in-asp-net-core-mvc/

public class RequiredFromQueryAttribute : FromQueryAttribute, IParameterModelConvention
{
    public void Apply(ParameterModel parameter)
    {
        if (parameter.Action.Selectors != null && parameter.Action.Selectors.Any())
        {
            parameter.Action.Selectors.Last().ActionConstraints.Add(new RequiredFromQueryActionConstraint(parameter.BindingInfo?.BinderModelName ?? parameter.ParameterName));
        }
    }
}
public class RequiredFromQueryActionConstraint : IActionConstraint
{
    private readonly string _parameter;

    public RequiredFromQueryActionConstraint(string parameter)
    {
        _parameter = parameter;
    }

    public int Order => 999;

    public bool Accept(ActionConstraintContext context)
    {
        if (!context.RouteContext.HttpContext.Request.Query.ContainsKey(_parameter))
        {
            return false;
        }

        return true;
    }
}

For example, if using [RequiredFromQuery] in StaticFileController we are able to call /api/StaticFile?name=withoutAction and /api/StaticFile/GetByName?name=wAction but not /api/StaticFile/someFunnyName (?name= and /)

Workaround solution for that is to create separate controller action to handle such requests

Upvotes: 1

Fei Han
Fei Han

Reputation: 27793

What I want to achieve:

  • Calling GET api/StaticFile -> goes to Get() action
  • Calling GET api/StaticFile?name=someFunnyName -> goes to GetByName() action

To achieve above requirement of matching request(s) to expected action(s) based on the query string, you can try to implement a custom ActionMethodSelectorAttribute and apply it to your actions, like below.

[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public class QueryStringConstraintAttribute : ActionMethodSelectorAttribute
{
    public string QueryStingName { get; set; }
    public bool CanPass { get; set; }
    public QueryStringConstraintAttribute(string qname, bool canpass)
    {
        QueryStingName = qname;
        CanPass = canpass;
    }
    public override bool IsValidForRequest(RouteContext routeContext, ActionDescriptor action)
    {
        StringValues value;

        routeContext.HttpContext.Request.Query.TryGetValue(QueryStingName, out value);

        if (QueryStingName == "" && CanPass)
        {
            return true;
        }
        else
        {
            if (CanPass)
            {
                return !StringValues.IsNullOrEmpty(value);
            }

            return StringValues.IsNullOrEmpty(value);
        }
    }
}

Apply to Actions

[Route("api/[controller]")]
[ApiController]
public class StaticFileController : ControllerBase
{
    [HttpGet]
    [QueryStringConstraint("name", true)]
    [QueryStringConstraint("", false)]
    public IActionResult GetByName(string name)
    {
        return Ok("From `GetByName` Action");
    }

    [HttpGet]
    [QueryStringConstraint("name", false)]
    [QueryStringConstraint("", true)]
    public IActionResult Get()
    {
        return Ok("From `Get` Action");
    }
}

Test Result

enter image description here

Upvotes: 5

Roman
Roman

Reputation: 12171

The parameter for HttpGet sets the route, not query string parameter name.

You should add FromQuery attribute for action parameter and use HttpGet without "{name}":

[HttpGet]
public HttpResponseMessage GetByName([FromQuery] string name)
{
    // ...
}

You can also set different name for query parameter:

[HttpGet]
public HttpResponseMessage GetByName([FromQuery(Name = "your_query_parameter_name")] string name)
{
    // ...
}

But now you have two actions matching same route so you will get exception. The only way to execute different logic based on query string part only (the route is the same) is to check query string inside action:

[HttpGet]
public IActionResult Get([FromQuery] string name)
{
    if (name == null)
    {
        // execute code when there is not name in query string
    }
    else
    {
        // execute code when name is in query string
    }
}

So you have only one action which handles both cases using same route.

Upvotes: 4

Related Questions