31

I have an action on API controller like this:

[HttpPost]
public async Task<IActionResult> StartDeployment(
    [FromQuery]Guid deploymentId,
    [FromRoute]RequestInfo requestInfo,
    [FromBody]DeploymenRequest deploymentRequest)
{
}

which is available by complex url (requestInfo) and receives HTTP POST request payload (deploymentRequest).

Is it possible to combine [FromRoute] and [FromBody] so I would have single request model:

public class UberDeploymentRequestInfo
{
    [FromQuery]public Guid DeploymentId { get; set; }

    [FromRoute]public RequestInfo RequestInfo { get; set; }

    [FromBody]public DeploymenRequest DeploymentRequest { get; set; }
}

so I could have single validator using Fluent Validation:

internal class UberDeploymentRequestInfoValidator : AbstractValidator<UberDeploymentRequestInfo>
{
    public UberDeploymentRequestInfoValidator()
    {
        // validation rules can access both url and payload
    }
}
abatishchev
  • 98,240
  • 88
  • 296
  • 433
  • I'm interested in this question :-) The route I've gone down so far is to apply the route parameters to the model in the controller method, and then validate the model - not an ideal solution. – ProgrammingLlama Jul 13 '18 at 00:47
  • 1
    @john: Same here: the only place where I have access to all parameters is the controller itself so have to perform the validation there. I want separate validator and clean actions. – abatishchev Jul 13 '18 at 00:49
  • 4
    You could probably do this in a custom model binder? – DavidG Jul 13 '18 at 00:58
  • @DavidG: Cool! Would love to see some examples/pointers. Thanks! – abatishchev Jul 13 '18 at 04:26
  • @abatishchev Since you apparently got an answer at https://github.com/aspnet/Mvc/issues/8111 would you mind posting it here for completeness? – Ian Kemp Nov 12 '19 at 16:30
  • This approach works for me: https://stackoverflow.com/a/46411870/9888512 – MatterOfFact Mar 09 '20 at 08:17

3 Answers3

3

It's doable by a custom model binder as mentioned in the comment. Here is a few code snippets to wire everything up, with the example you can send a http request with the following JSON body to an API /api/cats?From=james&Days=20

{
    "Name":"",
    "EyeColor":"Red"
}

A few classes, you can find them here as well: https://github.com/atwayne/so-51316269

// We read Cat from request body
public class Cat
{
    public string Name { get; set; }
    public string EyeColor { get; set; }
}

// AdoptionRequest from Query String or Route
public class AdoptionRequest
{
    public string From { get; set; }
    public string Days { get; set; }
}

// One class to merge them together
[ModelBinder(BinderType = typeof(CatAdoptionEntityBinder))]
public class CatAdoptionRequest
{
    public Cat Cat { get; set; }
    public AdoptionRequest AdoptionRequest { get; set; }
}


public class CatAdoptionEntityBinder : IModelBinder
{
    public async Task BindModelAsync(ModelBindingContext bindingContext)
    {
        // Read Cat from Body
        var memoryStream = new MemoryStream();
        var body = bindingContext.HttpContext.Request.Body;
        var reader = new StreamReader(body, Encoding.UTF8);
        var text = reader.ReadToEnd();
        var cat = JsonConvert.DeserializeObject<Cat>(text);

        // Read Adoption Request from query or route
        var adoptionRequest = new AdoptionRequest();
        var properties = typeof(AdoptionRequest).GetProperties();
        foreach (var property in properties)
        {
            var valueProvider = bindingContext.ValueProvider.GetValue(property.Name);
            if (valueProvider != null)
            {
                property.SetValue(adoptionRequest, valueProvider.FirstValue);
            }
        }

        // Merge
        var model = new CatAdoptionRequest()
        {
            Cat = cat,
            AdoptionRequest = adoptionRequest
        };

        bindingContext.Result = ModelBindingResult.Success(model);
        return;
    }
}


// Controller
[HttpPost()]
public bool Post([CustomizeValidator]CatAdoptionRequest adoptionRequest)
{
    return ModelState.IsValid;
}

public class CatAdoptionRequestValidator : AbstractValidator<CatAdoptionRequest>
{
    public CatAdoptionRequestValidator()
    {
        RuleFor(profile => profile.Cat).NotNull();
        RuleFor(profile => profile.AdoptionRequest).NotNull();
        RuleFor(profile => profile.Cat.Name).NotEmpty();
    }
}

// and in our Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc().AddFluentValidation();
    services.AddTransient<IValidator<CatAdoptionRequest>, CatAdoptionRequestValidator>();
}
lastr2d2
  • 3,604
  • 2
  • 22
  • 37
  • In this case I explicitly specify what to read from where, correct? How can achieve the same dynamically/read the corresponding attribute? Because I have more then one model. – abatishchev Jul 18 '18 at 19:03
  • I think it's possible if you have everything in one model, (say serialize the http body with null property values and fill with data from elsewhere). Otherwise you will have to specific it somewhere. It would be great if you can share your case – lastr2d2 Jul 19 '18 at 03:14
  • 1
    Also if you use attribute to do that it's always possible to read the attribute of the Type inside the bindModel method and do serialization accordingly. – lastr2d2 Jul 19 '18 at 04:47
2

I've customized the above ModelBinder further so that it's more generic and it works on many different contracts now. Figured I may as well share it here, where I found the majority of the code below.

public class BodyAndQueryAndRouteModelBinder<T> : IModelBinder where T : new()
{
    public async Task BindModelAsync(ModelBindingContext bindingContext)
    {
        // Read Cat from Body
        var memoryStream = new MemoryStream();
        var body = bindingContext.HttpContext.Request.Body;
        var reader = new StreamReader(body);
        var text = await reader.ReadToEndAsync();
        var contract = JsonConvert.DeserializeObject<T>(text);

        var properties = typeof(T).GetProperties();
        foreach (var property in properties)
        {
            var valueProvider = bindingContext.ValueProvider.GetValue(property.Name);
            if (valueProvider.FirstValue.IsNotNullOrEmpty())
            {
                property.SetValue(contract, valueProvider.FirstValue);
            }
        }

        bindingContext.Result = ModelBindingResult.Success(contract);
    }
}

I then use the binder on the parent contract:

[ModelBinder(BinderType = typeof(BodyAndQueryAndRouteModelBinder<ConfirmStatusRequest>))]
    public class ConfirmStatusRequest
    {
        public string ShortCode { get; set; }
        public IEnumerable<DependantRequest> Dependants { get; set; }
        public IEnumerable<CheckinQuestionAnswer> Answers { get; set; }
    }
1

I have found an other solution with inject the IActionContextAccessor into the Validator. With this I can access the ROUTE paramerter without the need of a special model binding.

Startup.cs

services.AddHttpContextAccessor();
services.AddSingleton<IActionContextAccessor, ActionContextAccessor>();

CatValidator.cs

public class CatValidator : AbstractValidator<CatDto>
{
    public CatValidator(IActionContextAccessor actionContextAccessor)
    {
        RuleFor(item => item.Age)
            .MustAsync(async (context, age, propertyValidatorContext, cancellationToken) =>
            {
                var catId = (string)actionContextAccessor.ActionContext.RouteData.Values
                .Where(o => o.Key == "catId")
                .Select(o => o.Value)
                .FirstOrDefault();

                return true;
            });
    }
}
live2
  • 3,771
  • 2
  • 37
  • 46