Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Creating a ModelBinder for MongoDB ObjectId on Asp.Net Core

I'm trying to create a very simple model binder for ObjectId types in my models but can't seem to make it work so far.

Here's the model binder:

public class ObjectIdModelBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        var result = bindingContext.ValueProvider.GetValue(bindingContext.FieldName);
        return Task.FromResult(new ObjectId(result.FirstValue));
    }
}

This is the ModelBinderProvider I've coded:

public class ObjectIdModelBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context == null) throw new ArgumentNullException(nameof(context));

        if (context.Metadata.ModelType == typeof(ObjectId))
        {
            return new BinderTypeModelBinder(typeof(ObjectIdModelBinder));
        }

        return null;
    }
}

Here's the class I'm trying to bind the body parameter to:

public class Player
{
    [BsonId]
    [ModelBinder(BinderType = typeof(ObjectIdModelBinder))]
    public ObjectId Id { get; set; }
    public Guid PlatformId { get; set; }
    public string Name { get; set; }
    public int Score { get; set; }
    public int Level { get; set; }
}

This is the action method:

[HttpPost("join")]
public async Task<SomeThing> Join(Player player)
{
    return await _someService.DoSomethingOnthePlayer(player);
}

For this code to work, I mean for the model binder to run, I inherited the controller from Controller and removed the [FromBody] attribute from the Player parameter.

When I run this, I can step into BindModelAsync method of the model binder, however I can't seem to get the Id parameter value from the post data. I can see the bindingContext.FieldName is correct; it is set to Id but result.FirstValue is null.

I've been away from Asp.Net MVC for a while, and it seems lots of things have been changed and became more confusing :-)

EDIT Based on comments I think I should provide more context.

If I put [FromBody] before the Player action parameter, player is set to null. If I remove [FromBody], player is set to a default value, not to the values I post. The post body is shown below, it's just a simple JSON:

{
    "Id": "507f1f77bcf86cd799439011"
    "PlatformId": "9c8aae0f-6aad-45df-a5cf-4ca8f729b70f"
}
like image 307
Élodie Petit Avatar asked Nov 11 '18 07:11

Élodie Petit


1 Answers

If I remove [FromBody], player is set to a default value, not to the values I post.

Reading data from the body is opt-in (unless you're using [ApiController]). When you remove [FromBody] from your Player parameter, the model-binding process will look to populate properties of Player using the route, query-string and form-values, by default. In your example, there are no such properties in these locations and so none of Player's properties get set.

If I put [FromBody] before the Player action parameter, player is set to null.

With the presence of the [FromBody] attribute, the model-binding process attempts to read from the body according to the Content-Type provided with the request. If this is application/json, the body will be parsed as JSON and mapped to your Player's properties. In your example, the JSON-parsing process fails as it doesn't know how to convert from a string to an ObjectId. When this happens, ModelState.IsValid within your controller will return false and your Player parameter will be null.

For this code to work, I mean for the model binder to run, I inherited the controller from Controller and removed the [FromBody] attribute from the Player parameter.

When you remove [FromBody], the [ModelBinder(...)] attribute you've set on your Id property is respected and so your code runs. However, with the presence of [FromBody], this attribute effectively is ignored. There's a lot going on behind-the-scenes here, but essentially it boils down to the fact that you've already opted-in to model-binding from the body as JSON and that's where model-binding stops in this scenario.


I mentioned above that it's the JSON-parsing process that's failing here due to not understanding how to process ObjectId. As this JSON-parsing is handled by Newtonsoft.Json (aka JSON.NET), a possible solution is to create a custom JsonConverter. This is covered well here on Stack Overflow, so I won't go into the details of how it works. Here's a complete example (error-handling omitted for brevity and laziness):

public class ObjectIdJsonConverter : JsonConverter
{
    public override bool CanConvert(Type objectType) =>
        objectType == typeof(ObjectId);

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) =>
        ObjectId.Parse(reader.Value as string);

    public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) =>
        writer.WriteValue(((ObjectId)value).ToString());
}

To make use of this, just replace your existing [ModelBinder(...)] attribute with a [JsonConverter(...)] attribute, like this:

[BsonId]
[JsonConverter(typeof(ObjectIdJsonConverter))]    
public ObjectId Id { get; set; }

Alternatively, you can register ObjectIdJsonConverter globally so that it applies to all ObjectId properties, using something like this in Startup.ConfigureServices:

services.AddMvc()
        .AddJsonOptions(options =>
            options.SerializerSettings.Converters.Add(new ObjectIdJsonConverter());
        );
like image 128
Kirk Larkin Avatar answered Sep 19 '22 15:09

Kirk Larkin