Métodos de ação ambígua do ASP.NET MVC

135

Eu tenho dois métodos de ação que são conflitantes. Basicamente, quero conseguir a mesma exibição usando duas rotas diferentes, pelo ID de um item ou pelo nome do item e do pai (os itens podem ter o mesmo nome entre pais diferentes). Um termo de pesquisa pode ser usado para filtrar a lista.

Por exemplo...

Items/{action}/ParentName/ItemName
Items/{action}/1234-4321-1234-4321

Aqui estão meus métodos de ação (também existem Removemétodos de ação) ...

// Method #1
public ActionResult Assign(string parentName, string itemName) { 
    // Logic to retrieve item's ID here...
    string itemId = ...;
    return RedirectToAction("Assign", "Items", new { itemId });
}

// Method #2
public ActionResult Assign(string itemId, string searchTerm, int? page) { ... }

E aqui estão as rotas ...

routes.MapRoute("AssignRemove",
                "Items/{action}/{itemId}",
                new { controller = "Items" }
                );

routes.MapRoute("AssignRemovePretty",
                "Items/{action}/{parentName}/{itemName}",
                new { controller = "Items" }
                );

Entendo por que o erro está ocorrendo, pois o pageparâmetro pode ser nulo, mas não consigo descobrir a melhor maneira de resolvê-lo. Meu projeto é ruim para começar? Pensei em estender Method #1a assinatura para incluir os parâmetros de pesquisa e mover a lógica Method #2para um método privado que eles chamariam, mas não acredito que isso realmente resolva a ambiguidade.

Qualquer ajuda seria muito apreciada.


Solução real (com base na resposta de Levi)

Eu adicionei a seguinte classe ...

public class RequireRouteValuesAttribute : ActionMethodSelectorAttribute {
    public RequireRouteValuesAttribute(string[] valueNames) {
        ValueNames = valueNames;
    }

    public override bool IsValidForRequest(ControllerContext controllerContext, MethodInfo methodInfo) {
        bool contains = false;
        foreach (var value in ValueNames) {
            contains = controllerContext.RequestContext.RouteData.Values.ContainsKey(value);
            if (!contains) break;
        }
        return contains;
    }

    public string[] ValueNames { get; private set; }
}

E então decorou os métodos de ação ...

[RequireRouteValues(new[] { "parentName", "itemName" })]
public ActionResult Assign(string parentName, string itemName) { ... }

[RequireRouteValues(new[] { "itemId" })]
public ActionResult Assign(string itemId) { ... }
Jonathan Freeland
fonte
3
Obrigado por publicar a implementação real. Com certeza ajuda as pessoas com problemas semelhantes. Como eu tinha hoje. :-P
Paulo Santos
4
Surpreendente! Menor mudança sugestão: (imo realmente útil) 1) params string [] valuenames para fazer a declaração de atributo mais conciso e (preferência) 2) substituir o IsValidForRequest corpo do método comreturn ValueNames.All(v => controllerContext.RequestContext.RouteData.Values.ContainsKey(v));
Benjamin Podszun
2
Eu tive o mesmo problema no parâmetro querystring. Se você precisar dos parâmetros considerados para o requisito, troque a contains = ...seção por algo assim:contains = controllerContext.RequestContext.RouteData.Values.ContainsKey(value) || controllerContext.RequestContext.HttpContext.Request.Params.AllKeys.Contains(value);
patridge
3
Nota de aviso: os parâmetros necessários devem ser enviados exatamente como nomeados. Se o parâmetro do método de ação for um tipo complexo preenchido passando suas propriedades por nome (e permitindo que o MVC as massageie no tipo complexo), esse sistema falhará porque o nome não está nas chaves da cadeia de caracteres de consulta. Por exemplo, isso não funcionará:, ActionResult DoSomething(Person p)onde Personpossui várias propriedades simples como Name, e solicitações são feitas diretamente com os nomes das propriedades (por exemplo, /dosomething/?name=joe+someone&other=properties).
Patridge
4
Se você estiver usando o MVC4 em diante, use em controllerContext.HttpContext.Request[value] != nullvez de controllerContext.RequestContext.RouteData.Values.ContainsKey(value); mas um bom trabalho, no entanto.
Kevin Farrugia

Respostas:

180

O MVC não suporta sobrecarga de método baseada apenas em assinatura, portanto, isso irá falhar:

public ActionResult MyMethod(int someInt) { /* ... */ }
public ActionResult MyMethod(string someString) { /* ... */ }

No entanto, ele faz método de apoio sobrecarga com base no atributo:

[RequireRequestValue("someInt")]
public ActionResult MyMethod(int someInt) { /* ... */ }

[RequireRequestValue("someString")]
public ActionResult MyMethod(string someString) { /* ... */ }

public class RequireRequestValueAttribute : ActionMethodSelectorAttribute {
    public RequireRequestValueAttribute(string valueName) {
        ValueName = valueName;
    }
    public override bool IsValidForRequest(ControllerContext controllerContext, MethodInfo methodInfo) {
        return (controllerContext.HttpContext.Request[ValueName] != null);
    }
    public string ValueName { get; private set; }
}

No exemplo acima, o atributo simplesmente diz "este método corresponde se a chave xxx estava presente na solicitação". Você também pode filtrar pelas informações contidas na rota (controllerContext.RequestContext), se isso for mais adequado aos seus objetivos.

Levi
fonte
Isso acabou sendo exatamente o que eu precisava. Como você sugeriu, eu precisava usar controllerContext.RequestContext.
Jonathan Freeland
4
Agradável! Eu ainda não tinha visto o atributo RequireRequestValue. É bom saber disso.
CoderDennis
1
podemos usar o valueprovider para obter valores de várias fontes como: controllerContext.Controller.ValueProvider.GetValue (value);
precisa
Eu fui atrás do ...RouteData.Valueslugar, mas isso "funciona". Se é ou não um bom padrão, está aberto ao debate. :)
bambams
1
Eu tenho a minha edição anterior rejeitado então eu só vou comentário: [AttributeUsage (AttributeTargets.All, AllowMultiple = true)]
MZN
7

Os parâmetros em suas rotas {roleId}, {applicationName}e {roleName}não correspondem aos nomes de parâmetro em seus métodos de ação. Não sei se isso importa, mas torna mais difícil descobrir qual é a sua intenção.

O itemId está em conformidade com um padrão que pode ser correspondido via regex? Nesse caso, você pode adicionar uma restrição ao seu percurso para que apenas os URLs que correspondam ao padrão sejam identificados como contendo um itemId.

Se seu itemId contivesse apenas dígitos, isso funcionaria:

routes.MapRoute("AssignRemove",
                "Items/{action}/{itemId}",
                new { controller = "Items" },
                new { itemId = "\d+" }
                );

Editar: Você também pode adicionar uma restrição à AssignRemovePrettyrota para que ambos {parentName}e {itemName}sejam necessários.

Editar 2: além disso, como sua primeira ação está apenas redirecionando para a segunda, você pode remover alguma ambiguidade renomeando a primeira.

// Method #1
public ActionResult AssignRemovePretty(string parentName, string itemName) { 
    // Logic to retrieve item's ID here...
    string itemId = ...;
    return RedirectToAction("Assign", itemId);
}

// Method #2
public ActionResult Assign(string itemId, string searchTerm, int? page) { ... }

Em seguida, especifique os nomes de Ação em suas rotas para forçar o método apropriado a ser chamado:

routes.MapRoute("AssignRemove",
                "Items/Assign/{itemId}",
                new { controller = "Items", action = "Assign" },
                new { itemId = "\d+" }
                );

routes.MapRoute("AssignRemovePretty",
                "Items/Assign/{parentName}/{itemName}",
                new { controller = "Items", action = "AssignRemovePretty" },
                new { parentName = "\w+", itemName = "\w+" }
                );
CoderDennis
fonte
1
Desculpe Dennis, os parâmetros realmente correspondem. Eu corrigi a pergunta. Vou experimentar a restrição regex e voltar para você. Obrigado!
Jonathan Freeland
Sua segunda edição me ajudou, mas finalmente foi a sugestão de Levi que selou o acordo. Obrigado novamente!
Jonathan Freeland
3

Recentemente, aproveitei a oportunidade para melhorar a resposta do @ Levi para dar suporte a uma ampla variedade de cenários com os quais tive que lidar, como: suporte a vários parâmetros, igualar qualquer um deles (em vez de todos) e até mesmo nenhum deles.

Aqui está o atributo que estou usando agora:

/// <summary>
/// Flags an Action Method valid for any incoming request only if all, any or none of the given HTTP parameter(s) are set,
/// enabling the use of multiple Action Methods with the same name (and different signatures) within the same MVC Controller.
/// </summary>
public class RequireParameterAttribute : ActionMethodSelectorAttribute
{
    public RequireParameterAttribute(string parameterName) : this(new[] { parameterName })
    {
    }

    public RequireParameterAttribute(params string[] parameterNames)
    {
        IncludeGET = true;
        IncludePOST = true;
        IncludeCookies = false;
        Mode = MatchMode.All;
    }

    public override bool IsValidForRequest(ControllerContext controllerContext, MethodInfo methodInfo)
    {
        switch (Mode)
        {
            case MatchMode.All:
            default:
                return (
                    (IncludeGET && ParameterNames.All(p => controllerContext.HttpContext.Request.QueryString.AllKeys.Contains(p)))
                    || (IncludePOST && ParameterNames.All(p => controllerContext.HttpContext.Request.Form.AllKeys.Contains(p)))
                    || (IncludeCookies && ParameterNames.All(p => controllerContext.HttpContext.Request.Cookies.AllKeys.Contains(p)))
                    );
            case MatchMode.Any:
                return (
                    (IncludeGET && ParameterNames.Any(p => controllerContext.HttpContext.Request.QueryString.AllKeys.Contains(p)))
                    || (IncludePOST && ParameterNames.Any(p => controllerContext.HttpContext.Request.Form.AllKeys.Contains(p)))
                    || (IncludeCookies && ParameterNames.Any(p => controllerContext.HttpContext.Request.Cookies.AllKeys.Contains(p)))
                    );
            case MatchMode.None:
                return (
                    (!IncludeGET || !ParameterNames.Any(p => controllerContext.HttpContext.Request.QueryString.AllKeys.Contains(p)))
                    && (!IncludePOST || !ParameterNames.Any(p => controllerContext.HttpContext.Request.Form.AllKeys.Contains(p)))
                    && (!IncludeCookies || !ParameterNames.Any(p => controllerContext.HttpContext.Request.Cookies.AllKeys.Contains(p)))
                    );
        }
    }

    public string[] ParameterNames { get; private set; }

    /// <summary>
    /// Set it to TRUE to include GET (QueryStirng) parameters, FALSE to exclude them:
    /// default is TRUE.
    /// </summary>
    public bool IncludeGET { get; set; }

    /// <summary>
    /// Set it to TRUE to include POST (Form) parameters, FALSE to exclude them:
    /// default is TRUE.
    /// </summary>
    public bool IncludePOST { get; set; }

    /// <summary>
    /// Set it to TRUE to include parameters from Cookies, FALSE to exclude them:
    /// default is FALSE.
    /// </summary>
    public bool IncludeCookies { get; set; }

    /// <summary>
    /// Use MatchMode.All to invalidate the method unless all the given parameters are set (default).
    /// Use MatchMode.Any to invalidate the method unless any of the given parameters is set.
    /// Use MatchMode.None to invalidate the method unless none of the given parameters is set.
    /// </summary>
    public MatchMode Mode { get; set; }

    public enum MatchMode : int
    {
        All,
        Any,
        None
    }
}

Para obter mais informações e exemplos de implementação de instruções, consulte esta postagem de blog que escrevi sobre este tópico.

Darkseal
fonte
Obrigado, grande melhoria! Mas ParameterNames não está definido no ctor
nvirth
0
routes.MapRoute("AssignRemove",
                "Items/{parentName}/{itemName}",
                new { controller = "Items", action = "Assign" }
                );

considere usar a biblioteca de rotas de teste do MVC Contribs para testar suas rotas

"Items/parentName/itemName".Route().ShouldMapTo<Items>(x => x.Assign("parentName", itemName));
Rony
fonte