0

I have REST API in .net5.0.
My controller inherits ControllerBaseand has the ApiController attribute.
My simple model and I need to bind value from Claim to a single property. My model looks like this:

public class CreatePlugin
{
    [Required]
    [JsonProperty("name"), JsonPropertyName("name")]
    public string Name { get; set; }

    [Required]
    [JsonProperty("description"), JsonPropertyName("description")]
    public string Description { get; set; }

    [Required]
    [JsonProperty("version"), JsonPropertyName("version")]
    public string Version { get; set; }

    [Required]
    [JsonProperty("public"), JsonPropertyName("public")]
    public bool Public { get; set; }

    [BindProperty(BinderType = typeof(CreatedByModelBinder))]
    [ModelBinder(BinderType = typeof(CreatedByModelBinder))]
    [Newtonsoft.Json.JsonConverter(typeof(CreatedByConverter))]
    [SwaggerIgnore]
    [JsonProperty("created_by"), JsonPropertyName("created_by")]
    public int CreatedBy { get; set; }
}

and a simple controller method:

[HttpPost]
[Produces("application/json")]
[ProducesResponseType(typeof(Plugin), (int)HttpStatusCode.OK)]
public async Task<IActionResult> Add(CreatePlugin plugin)
{
  //some logic
  return Ok();
}

My request looks like this:

{
    "name": "test",
    "description": "sample description",
    "version": "1.0",
    "public": true
}

Because of ApiController attribute, my model binder isn't working. The same happens when I remove ApiController attribute from my controller and add FromBody to Add method parameter (ref: Custom model binder not firing for a single property in ASP.NET Core 2). When I remove both attributes, the binder works fine, but the rest of the dto is empty.
I've tried using a custom JsonConverter, but it only works if I pass something in the created_by field.

How can I bind a single property of the model to something that isn't coming from the request data (in my case to claims)?

My workaround for now is to manually assign property value in each method like so:

plugin.CreatedBy = _identityContext.UserId;

Most of my models have CreatedBy and EditedBy properties, and I'd like to automatically fill them with a specific value (in this case with a value from a Claim)

Misiu
  • 4,738
  • 21
  • 94
  • 198

1 Answers1

2

You can custom the model binding like below:

public class CreatedByModelBinder : IModelBinder
{        
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        if (bindingContext == null)
            throw new ArgumentNullException(nameof(bindingContext));
        var claim = int.Parse(bindingContext.HttpContext.User.FindFirst("KeyName").Value);
        bindingContext.Result = ModelBindingResult.Success(claim);
        return Task.CompletedTask;
    }
}

Model:

public class CreatePlugin
{
    [Required]
    [JsonProperty("name"), JsonPropertyName("name")]
    public string Name { get; set; }
    //...
    [NSwag.Annotations.SwaggerIgnore]

    [ModelBinder(BinderType = typeof(CreatedByModelBinder))]
    [JsonProperty("created_by"), JsonPropertyName("created_by")]
    public int CreatedBy { get; set; }
}

Controller:

Not sure how do you add the claims, I just add the claim in cookie authentication.

[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    [HttpGet]
    public async Task<IActionResult> GetAsync()
    {
        var claims = new List<Claim>
        {
            new Claim("KeyName","4")
        };
        var authProperties = new AuthenticationProperties
        {
            IssuedUtc = DateTimeOffset.UtcNow,
            ExpiresUtc = DateTimeOffset.UtcNow.AddHours(1),
            IsPersistent = false
        };
        const string authenticationType = "Cookies";
        var claimsIdentity = new ClaimsIdentity(claims, authenticationType);
        await HttpContext.SignInAsync(authenticationType, new ClaimsPrincipal(claimsIdentity), authProperties);
        return Ok();
    }

    [HttpPost]
    [Produces("application/json")]
    public async Task<IActionResult> Add([FromForm]CreatePlugin plugin)   //add FromForm....
    {
        //some logic
        return Ok();
    }
}

Result:

enter image description here


UPDATE:

If you must using FromBody, you can custom JsonConverter for the model:

public class CreatedByConverter : Newtonsoft.Json.JsonConverter
{
    private readonly IHttpContextAccessor httpContextAccessor;
    public CreatedByConverter(IHttpContextAccessor httpContextAccessor)
    {
        this.httpContextAccessor = httpContextAccessor;
    }
    public override bool CanConvert(Type objectType)
    {           
        return (objectType == typeof(CreatePlugin));
    }

    public override object ReadJson(JsonReader reader, Type objectType, object existingValue, Newtonsoft.Json.JsonSerializer serializer)
    {
        var claim = int.Parse(httpContextAccessor.HttpContext.User.FindFirst("KeyName").Value);
        JObject obj = JObject.Load(reader);
        CreatePlugin root = new CreatePlugin();
        root.Name = (string)obj["name"];
        root.Description = (string)obj["description"];
        root.Version = (string)obj["version"];
        root.Public = (bool)obj["public"];

        root.CreatedBy = claim;
        return root;
    }      
    public override void WriteJson(JsonWriter writer, object value, Newtonsoft.Json.JsonSerializer serializer)
    {
        JToken t = JToken.FromObject(value);
        if (t.Type != JTokenType.Object)
        {
            t.WriteTo(writer);
        }
        else
        {
            JObject o = (JObject)t;
            o.WriteTo(writer);
        }
    }
}

Model:

Note: If you get the claim from HttpContext, you need inject the service and cannot use JsonConverter attribute any more. You need register it in Startup.cs

public class CreatePlugin
{
    [Required]
    [JsonProperty("name"), JsonPropertyName("name")]
    public string Name { get; set; }
    //more property...

    [NSwag.Annotations.SwaggerIgnore]
    //[Newtonsoft.Json.JsonConverter(typeof(CreatedByConverter))]
    [JsonProperty("created_by"), JsonPropertyName("created_by")]
    public int CreatedBy { get; set; }
}

Controller:

[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{
    [HttpGet]
    public async Task<IActionResult> GetAsync()
    {
        //add claims like the first answer..
        return Ok();
    }
    [HttpPost]
    [Produces("application/json")]
    public async Task<IActionResult> Add(CreatePlugin plugin)
    {
        //some logic
        return Ok(plugin);
    }
}

Startup.cs:

public class Startup
{
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }
    public IConfiguration Configuration { get; }
    public void ConfigureServices(IServiceCollection services)
    {           
        var httpContextAccessor = new HttpContextAccessor();
        services.AddControllers()
                .AddNewtonsoftJson(options =>
                {
                    options.SerializerSettings.Converters.Add(new CreatedByConverter(httpContextAccessor));
                });
        services.AddSingleton<IHttpContextAccessor>(httpContextAccessor);
        services.AddSwaggerDocument();
        services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
                .AddCookie(o => o.LoginPath = new PathString("/account/login"));
   
    }
    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        app.UseHttpsRedirection();
        app.UseOpenApi();
        app.UseSwaggerUi3();
                 
        app.UseRouting();
        app.UseAuthentication();
        app.UseAuthorization();

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllers();
        });
    }
}
Rena
  • 30,832
  • 6
  • 37
  • 72
  • Thank you for the detailed answer, but I need to get that working with `FromBody` attribute (as described in my question). Sadly I can't change that part as my API must work with other systems and they only can do json requests – Misiu Mar 01 '22 at 07:26
  • 1
    Got it. I will update my code. I just think custom model binder is more easy so i give you this solution. – Rena Mar 01 '22 at 07:34
  • 1
    Hi @Misiu, pls check my updated answer. – Rena Mar 01 '22 at 09:19
  • thank you for the update, this will work for a single dto, but I have like 20 dto's in my project, like CreatePlugin, CreateUser etc (that have CreatedBy property), but there are also dto's for edit and delete, they have other fields (EditedBy, DeletedBy) I'd like to refill. I've tried the attribute approach in the first place because it seems more flexible. Any ideas on how to make this more generic (instead of creating a converter for each dto? – Misiu Mar 01 '22 at 09:47
  • 1
    That is why I suggest using IModelBinder. For your scenraio, You can add the condition in the converter for each dto. 1. add or (`||`) condition for the dto in the CanConvert method( `return (objectType == typeof(CreatePlugin)||objectType==typeof(OtherDto));`). 2. Judge the `objectType` in the ReadJson method and repeat set value for dto. – Rena Mar 02 '22 at 01:55
  • I've found a great library - https://github.com/billbogaiv/hybrid-model-binding and contributed ClaimValueProvider that can bind claim value to a model property. I'm now creating tests for this project and creating a POC to test my scenarios – Misiu Mar 02 '22 at 15:12