billbogaiv/hybrid-model-binding

Map entire body to model property (rest of the properties bind from route)

Misiu opened this issue · 1 comments

Misiu commented

I have a REST API that needs to support a dynamic model,
I have this endpoint:

[HttpPost("{entity}")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[AllowAnonymous]
public async Task<object> CreateSingleRecord2([ModelBinder(typeof(CreateSingleRecordBinder))] CreateSingleRecord model)
{
    //process model;
}

and this model:

public class CreateSingleRecord : ICommand<object>
{
    [FromRoute(Name ="entity")]
    public string Entity { get; init; }

    [FromBody]
    public IDictionary<string, object> Record { get; init; }
}

I'm doing this request:

curl --location --request POST 'https://localhost:7299/api/data/cars' \
--header 'Accept: application/json' \
--header 'Content-Type: application/json' \
--data-raw '{
    "model": 1,
    "name": "Micra",
    "id":"a47d52de-fcd1-48e7-8656-7edb84dc78bd",
    "is_created": true,
    "date":"2022-09-23",
    "datetime":"2022-09-23 13:10"
}'

Right now I'm using this binder:

public class CreateSingleRecordBinder : IModelBinder
{
    public async Task BindModelAsync(ModelBindingContext bindingContext)
    {
        if (bindingContext == null)
        {
            throw new ArgumentNullException(nameof(bindingContext));
        }

        if (!bindingContext.ActionContext.RouteData.Values.ContainsKey("entity"))
        {
            bindingContext.Result = ModelBindingResult.Failed();
            return;
        }

        var entityName = bindingContext.ActionContext.RouteData.Values["entity"]?.ToString();
        if (string.IsNullOrWhiteSpace(entityName))
        {
            bindingContext.Result = ModelBindingResult.Failed();
            return;
        }

        using var reader = new StreamReader(bindingContext.HttpContext.Request.Body);
        var body = await reader.ReadToEndAsync();
        var data = System.Text.Json.JsonSerializer.Deserialize<IDictionary<string, object>>(body);
        if (data == null || !data.Any())
        {
            //return failed result
            bindingContext.Result = ModelBindingResult.Failed();
            return;
        }

        var model = new CreateSingleRecord
        {
            Entity = entityName,
            Record = data
        };

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

I'd like to avoid writing a custom binder because the same request binds to:

[HttpPost("{entity}")]
[ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[AllowAnonymous]
public async Task<object> CreateSingleRecord([FromRoute] string entity, [FromBody] IDictionary<string, object> model)
{
    //process model
}

Sadly when I try to use FromHybrid I'm getting this error:

{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "traceId": "00-5975e1c9239eebbea90add81d3698958-d4ad813d42423c4a-00",
    "errors": {
        "Record": [
            "The Record field is required."
        ]
    }
}

How can I use Your binder to bind entire body of request to Property of a model.
Can I use FromBody or do I have to create another binder, for example FromEntireBody?

Misiu commented

I've created a question on Stack Overflow: https://stackoverflow.com/questions/73827565/custom-model-binder-with-idictionarystring-object/73854657?noredirect=1#73854657 and this currently works:

[HttpPost("{entity}")]
public async Task<object> CreateSingleRecord([FromRoute] CreateSingleRecord model)
{
    //process
}

Notice [FromRoute] attribute.
Ideally, I'd like to add more properties to my models, two from headers and one from claims, so my question and my problem are still actual - how to make this work with your package.

As a reference please take a look at this model:

    public class CreateSingleRecord
    {
        [HybridBindProperty(Source.Claim, "UserId")]
        public int UserId { get; set; }

        [HybridBindProperty(Source.Header, "ServerName")]
        public string Name { get; set; }
        
        [HybridBindProperty(Source.Route)]
        public string Entity { get; set; }

        [HybridBindProperty(Source.Body)]
        public IDictionary<string, object> Record { get; set; }
    }