web-dev-qa-db-fra.com

ASP.NET Core MVC Liaison et validation de modèle mixte Route / FromBody

J'utilise ASP.NET Core 1.1 MVC pour créer une API JSON. Étant donné le modèle et la méthode d'action suivants:

public class TestModel
{
    public int Id { get; set; }

    [Range(100, 999)]
    public int RootId { get; set; }

    [Required, MaxLength(200)]
    public string Name { get; set; }

    public string Description { get; set; }
}

[HttpPost("/test/{rootId}/echo/{id}")]
public IActionResult TestEcho([FromBody] TestModel data)
{
    return Json(new
    {
        data.Id,
        data.RootId,
        data.Name,
        data.Description,
        Errors = ModelState.IsValid ? null : ModelState.SelectMany(x => x.Value.Errors)
    });
}

Le [FromBody] sur mon paramètre de méthode d'action entraîne la liaison du modèle à partir de la charge utile JSON qui est publiée sur le noeud final, mais il empêche également les propriétés Id et RootId d'être liées via la route paramètres.

Je pourrais diviser cela en plusieurs modèles, un lié à la route et l'autre au corps ou je pourrais également forcer n'importe quel client à envoyer le id & rootId dans le cadre de la charge utile, mais ces deux solutions semblent compliquer les choses plus que je ne le souhaiterais et ne me permettent pas de conserver la logique de validation en un seul endroit. Existe-t-il un moyen de faire fonctionner cette situation où le modèle peut être lié correctement et où je peux garder ma logique de modèle et de validation ensemble?

15
heavyd

Vous pouvez supprimer le [FromBody] décorateur sur votre entrée et laissez la liaison MVC mapper les propriétés:

[HttpPost("/test/{rootId}/echo/{id}")]
public IActionResult TestEcho(TestModel data)
{
    return Json(new
    {
        data.Id,
        data.RootId,
        data.Name,
        data.Description,
        Errors = ModelState.IsValid ? null : ModelState.SelectMany(x => x.Value.Errors)
    });
}

Plus d'informations: liaison de modèle dans ASP.NET Core MVC

[~ # ~] mise à jour [~ # ~]

Essai

enter image description here

enter image description here

MISE À JOUR 2

@heavyd, vous avez raison, les données JSON nécessitent [FromBody] attribut pour lier votre modèle. Donc, ce que j'ai dit ci-dessus fonctionnera sur les données du formulaire, mais pas sur les données JSON.

Comme alternative, vous pouvez créer un classeur de modèle personnalisé qui lie les propriétés Id et RootId à partir de l'URL, tandis qu'il lie le reste des propriétés à partir du corps de la demande.

public class TestModelBinder : IModelBinder
{
    private BodyModelBinder defaultBinder;

    public TestModelBinder(IList<IInputFormatter> formatters, IHttpRequestStreamReaderFactory readerFactory) // : base(formatters, readerFactory)
    {
        defaultBinder = new BodyModelBinder(formatters, readerFactory);
    }

    public async Task BindModelAsync(ModelBindingContext bindingContext)
    {
        // callinng the default body binder
        await defaultBinder.BindModelAsync(bindingContext);

        if (bindingContext.Result.IsModelSet)
        {
            var data = bindingContext.Result.Model as TestModel;
            if (data != null)
            {
                var value = bindingContext.ValueProvider.GetValue("Id").FirstValue;
                int intValue = 0;
                if (int.TryParse(value, out intValue))
                {
                    // Override the Id property
                    data.Id = intValue;
                }
                value = bindingContext.ValueProvider.GetValue("RootId").FirstValue;
                if (int.TryParse(value, out intValue))
                {
                    // Override the RootId property
                    data.RootId = intValue;
                }
                bindingContext.Result = ModelBindingResult.Success(data);
            }

        }

    }
}

Créez un fournisseur de classeur:

public class TestModelBinderProvider : IModelBinderProvider
{
    private readonly IList<IInputFormatter> formatters;
    private readonly IHttpRequestStreamReaderFactory readerFactory;

    public TestModelBinderProvider(IList<IInputFormatter> formatters, IHttpRequestStreamReaderFactory readerFactory)
    {
        this.formatters = formatters;
        this.readerFactory = readerFactory;
    }

    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context.Metadata.ModelType == typeof(TestModel))
            return new TestModelBinder(formatters, readerFactory);

        return null;
    }
}

Et dites à MVC de l'utiliser:

services.AddMvc()
  .AddMvcOptions(options =>
  {
     IHttpRequestStreamReaderFactory readerFactory = services.BuildServiceProvider().GetRequiredService<IHttpRequestStreamReaderFactory>();
     options.ModelBinderProviders.Insert(0, new TestModelBinderProvider(options.InputFormatters, readerFactory));
  });

Ensuite, votre contrôleur a:

[HttpPost("/test/{rootId}/echo/{id}")]
public IActionResult TestEcho(TestModel data)
{...}

Essai

enter image description hereenter image description here

Vous pouvez ajouter un Id et RootId à votre JSON mais ils seront ignorés car nous les remplaçons dans notre classeur de modèle.

MISE À JOUR 3

Ce qui précède vous permet d'utiliser vos annotations de modèle de données pour valider Id et RootId. Mais je pense que cela peut confondre d'autres développeurs qui regarderaient votre code API. Je suggérerais simplement de simplifier la signature de l'API pour accepter un modèle différent à utiliser avec [FromBody] et séparez les deux autres propriétés qui proviennent de l'uri.

[HttpPost("/test/{rootId}/echo/{id}")]
public IActionResult TestEcho(int id, int rootId, [FromBody]TestModelNameAndAddress testModelNameAndAddress)

Et vous pourriez tout simplement corriger un validateur pour toutes vos entrées, comme:

// This would return a list of tuples of property and error message.
var errors = validator.Validate(id, rootId, testModelNameAndAddress); 
if (errors.Count() > 0)
{
    foreach (var error in errors)
    {
        ModelState.AddModelError(error.Property, error.Message);
    }
}
10
Frank Fajardo

Après des recherches, j'ai trouvé une solution de création d'un nouveau classeur de modèle + source de liaison + attribut qui combine les fonctionnalités de BodyModelBinder et ComplexTypeModelBinder. Il utilise d'abord BodyModelBinder pour lire à partir du corps, puis ComplexModelBinder remplit d'autres champs. Code ici:

public class BodyAndRouteBindingSource : BindingSource
{
    public static readonly BindingSource BodyAndRoute = new BodyAndRouteBindingSource(
        "BodyAndRoute",
        "BodyAndRoute",
        true,
        true
        );

    public BodyAndRouteBindingSource(string id, string displayName, bool isGreedy, bool isFromRequest) : base(id, displayName, isGreedy, isFromRequest)
    {
    }

    public override bool CanAcceptDataFrom(BindingSource bindingSource)
    {
        return bindingSource == Body || bindingSource == this;
    }
}

[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public class FromBodyAndRouteAttribute : Attribute, IBindingSourceMetadata
{
    public BindingSource BindingSource => BodyAndRouteBindingSource.BodyAndRoute;
}

public class BodyAndRouteModelBinder : IModelBinder
{
    private readonly IModelBinder _bodyBinder;
    private readonly IModelBinder _complexBinder;

    public BodyAndRouteModelBinder(IModelBinder bodyBinder, IModelBinder complexBinder)
    {
        _bodyBinder = bodyBinder;
        _complexBinder = complexBinder;
    }

    public async Task BindModelAsync(ModelBindingContext bindingContext)
    {
        await _bodyBinder.BindModelAsync(bindingContext);

        if (bindingContext.Result.IsModelSet)
        {
            bindingContext.Model = bindingContext.Result.Model;
        }

        await _complexBinder.BindModelAsync(bindingContext);
    }
}

public class BodyAndRouteModelBinderProvider : IModelBinderProvider
{
    private BodyModelBinderProvider _bodyModelBinderProvider;
    private ComplexTypeModelBinderProvider _complexTypeModelBinderProvider;

    public BodyAndRouteModelBinderProvider(BodyModelBinderProvider bodyModelBinderProvider, ComplexTypeModelBinderProvider complexTypeModelBinderProvider)
    {
        _bodyModelBinderProvider = bodyModelBinderProvider;
        _complexTypeModelBinderProvider = complexTypeModelBinderProvider;
    }

    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        var bodyBinder = _bodyModelBinderProvider.GetBinder(context);
        var complexBinder = _complexTypeModelBinderProvider.GetBinder(context);

        if (context.BindingInfo.BindingSource != null
            && context.BindingInfo.BindingSource.CanAcceptDataFrom(BodyAndRouteBindingSource.BodyAndRoute))
        {
            return new BodyAndRouteModelBinder(bodyBinder, complexBinder);
        }
        else
        {
            return null;
        }
    }
}

public static class BodyAndRouteModelBinderProviderSetup
{
    public static void InsertBodyAndRouteBinding(this IList<IModelBinderProvider> providers)
    {
        var bodyProvider = providers.Single(provider => provider.GetType() == typeof(BodyModelBinderProvider)) as BodyModelBinderProvider;
        var complexProvider = providers.Single(provider => provider.GetType() == typeof(ComplexTypeModelBinderProvider)) as ComplexTypeModelBinderProvider;

        var bodyAndRouteProvider = new BodyAndRouteModelBinderProvider(bodyProvider, complexProvider);

        providers.Insert(0, bodyAndRouteProvider);
    }
}
14
Matiszak
  1. Install-Package HybridModelBinding

  2. Ajouter à Statrup:

    services.AddMvc()
        .AddHybridModelBinder();
    
  3. Modèle:

    public class Person
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public string FavoriteColor { get; set; }
    }
    
  4. Manette:

    [HttpPost]
    [Route("people/{id}")]
    public IActionResult Post([FromHybrid]Person model)
    { }
    
  5. Demande:

    curl -X POST -H "Accept: application/json" -H "Content-Type:application/json" -d '{
        "id": 999,
        "name": "Bill Boga",
        "favoriteColor": "Blue"
    }' "https://localhost/people/123?name=William%20Boga"
    
  6. Résultat:

    {
        "Id": 123,
        "Name": "William Boga",
        "FavoriteColor": "Blue"
    }
    
  7. Il existe d'autres fonctionnalités avancées.

12
Mentor

Je n'ai pas essayé cela pour votre exemple, mais cela devrait fonctionner comme une liaison de modèle de support principal asp.net comme celle-ci.

Vous pouvez créer un modèle comme celui-ci.

public class TestModel
{
    [FromRoute]
    public int Id { get; set; }

    [FromRoute]
    [Range(100, 999)]
    public int RootId { get; set; }

    [FromBody]
    [Required, MaxLength(200)]
    public string Name { get; set; }

    [FromBody]
    public string Description { get; set; }
}

Mise à jour 1: ci-dessus ne fonctionnera pas dans le cas où le flux n'est pas rembobinable. Principalement dans votre cas lorsque vous publiez des données json.

Le classeur de modèles personnalisés est une solution, mais si vous ne voulez toujours pas créer celui-ci et que vous souhaitez simplement gérer avec Model, vous pouvez créer deux modèles.

public class TestModel
    {
        [FromRoute]
        public int Id { get; set; }

        [FromRoute]
        [Range(100, 999)]
        public int RootId { get; set; }        

        [FromBody]
        public ChildModel OtherData { get; set; }        
    }


    public class ChildModel
    {            
        [Required, MaxLength(200)]
        public string Name { get; set; }

        public string Description { get; set; }
    }

Remarque: Cela fonctionne parfaitement avec la liaison application/json car elle fonctionne différemment des autres types de contenu.

2
dotnetstep