Is there a way to define binding attributes (FromBody, FromQuery, etc.) in a fluent way, out of the target model? Similar to FluentValidation vs [Required], [MaxLength], etc. attributes.
Background story:
I would like to use command models as controller action parameters:
[HttpPost]
public async Task<ActionResult<int>> Create(UpdateTodoListCommand command)
{
return await Mediator.Send(command);
}
Even more, I would like the model to be bound from multiple sources (route, body, atc.):
[HttpPut("{id}")]
public async Task<ActionResult> Update(UpdateTodoListCommand command)
{
// command.Id is bound from the route, the rest is from the request body
}
This should be possible (https://josef.codes/model-bind-multiple-sources-to-a-single-class-in-asp-net-core/, https://github.com/ardalis/RouteAndBodyModelBinding), but requires binding attributes right on the command's properties, which should be avoided.
AspNetCore modelBinding process could be customized without attribute with a custom IModelBinderProvider.
I will explain a way to achieve the following result for a request like this one:
Header: PUT
URL: /TodoList/testid?Title=mytitle&Index=2
BODY: { "Description": "mydesc" }
Expected response body :
{"Id":"testid","Title":"mytitle","Description":"mydesc","Index":2}
So the controller should mix all data from route, query and body as a single model, then return the serialized model (we just want to check our custom binding result in the example).
The C# POCO could be:
public class UpdateTodoListCommand
{
public string Id { get; set; }
public string Title { get; set; }
public string Description { get; set; }
// string properties are too easy to bind, so we add an extra property of another type for the demo
public int Index { get; set; }
}
The controller:
[Route("[controller]")]
public class TodoListController : Controller
{
[HttpPut("{id}")]
public IActionResult Update(UpdateTodoListCommand command
{
// return the serialized model so we can check all query and route data are merged as expected in the command instance
return Ok(JsonSerializer.Serialize(command));
}
}
We need some boilerplate code to declare metadata about our commands, and define which property should be binded to query or route data. I make it very simple, because it's not the purpose of the subject:
public class CommandBindingModel
{
public HashSet<string> FromQuery { get; } = new HashSet<string>();
public HashSet<string> FromPath { get; } = new HashSet<string>();
}
public class CommandBindingModelStore
{
private readonly Dictionary<Type, CommandBindingModel> _inner = new ();
public CommandBindingModel? Get(Type type, bool createIfNotExists)
{
if (_inner.TryGetValue(type, out var model))
return model;
if (createIfNotExists)
{
model = new CommandBindingModel();
_inner.Add(type, model);
}
return model;
}
}
The store will contains a metadata snapshot for all command you want bind with your custom process.
The fluent builder for the store could be like this one (again I try to be simple):
public class CommandBindingModelBuilder
{
public CommandBindingModelStore Store { get; } = new CommandBindingModelStore();
public CommandBindingModelBuilder Configure<TModel>(Action<Step<TModel>> configure)
{
var model = Store.Get(typeof(TModel), true);
configure(new Step<TModel>(model ?? throw new Exception()));
return this;
}
public class Step<TModel>
{
private readonly CommandBindingModel _model;
public Step(CommandBindingModel model)
{
_model = model;
}
public Step<TModel> FromQuery<TProperty>(Expression<Func<TModel, TProperty>> property
{
if (property.Body is not MemberExpression me)
throw new NotImplementedException();
_model.FromQuery.Add(me.Member.Name);
return this;
}
public Step<TModel> FromPath<TProperty>(Expression<Func<TModel, TProperty>> property)
{
if (property.Body is not MemberExpression me)
throw new NotImplementedException();
_model.FromPath.Add(me.Member.Name);
return this;
}
}
}
Now we can create a custom implementation of IModelBinderProvider. This one has the responsability to give a custom IModelBinder to MVC for every command of our store. Our comamnd are complex type, so we have to get some metadata (from MVC apis) to make easier the binding of properties:
public class CommandModelBinderProvider : IModelBinderProvider
{
private readonly CommandBindingModelStore _store;
public CommandModelBinderProvider(CommandBindingModelStore store)
{
_store = store;
}
public IModelBinder? GetBinder(ModelBinderProviderContext context)
{
var model = _store.Get(context.Metadata.ModelType, false);
if (model != null)
{
var binders = new Dictionary<ModelMetadata, IModelBinder>();
foreach(var property in model.FromQuery.Concat(model.FromPath))
{
var metadata = context.Metadata.GetMetadataForProperty(context.Metadata.ModelType, property);
var binder = context.CreateBinder(metadata);
binders.Add(metadata, binder);
}
return new CommandModelBinder(model, binders);
}
return null;
}
}
The custom binder will read the request body (as JSON in the example, but you can read and parse in any format you need):
public class CommandModelBinder : IModelBinder
{
private readonly CommandBindingModel _commandBindingModel;
private readonly Dictionary<ModelMetadata, IModelBinder> _binders;
public CommandModelBinder(CommandBindingModel commandBindingModel, Dictionary<ModelMetadata, IModelBinder> binders)
{
_commandBindingModel = commandBindingModel;
_binders = binders;
}
public async Task BindModelAsync(ModelBindingContext bindingContext)
{
var value = await bindingContext.HttpContext.Request.ReadFromJsonAsync(bindingContext.ModelType);
if (value == null)
{
bindingContext.Result = ModelBindingResult.Failed();
return;
}
bindingContext.Model = value;
/* CUSTOM BINDING HERE */
bindingContext.Result = ModelBindingResult.Success(value);
}
}
If we execute the code now (assuming the custom providers is known by MVC, this is not true at this stage of my example), only the property Description is binded as expected. So we have to bind properties from QueryString. In MVC Binding philosophy, ValueProviders are responsible to get raw values from the request: QueryStringValueProvider is the one for QueryString. So we can use it:
var queryStringValueProvider = new QueryStringValueProvider(BindingSource.Query, bindingContext.HttpContext.Request.Query, CultureInfo.CurrentCulture);
foreach (var fq in _commandBindingModel.FromQuery)
{
var r = queryStringValueProvider.GetValue(fq);
bindingContext.ModelState.SetModelValue(fq, r);
if (r == ValueProviderResult.None) continue;
/* we have to bind the value to our command */
}
Here it's easy to use reflection to set the property on our command, but MVC give us some tools, so I think it's better to use them. Moreover, we just get a raw value typed as StringValues, so it could be painfull to convert it to the expected type of the property (think to the property Index of our UpdateTodoListCommand). This is the time to use the binders created in the custom IModelProvider:
var m = bindingContext.ModelMetadata.GetMetadataForProperty(bindingContext.ModelType, fq);
using (bindingContext.EnterNestedScope(m, fq, fq, m.PropertyGetter(value)))
{
bindingContext.Result = ModelBindingResult.Success(r);
var binder = _binders[m];
await binder.BindModelAsync(bindingContext);
var result = bindingContext.Result;
m.PropertySetter(value, result.Model);
}
Now, in our example, Title and Index will be binded as expected. Id property could be binded with the RouteValueProvider:
var routeValueProvider = new RouteValueProvider(BindingSource.Path, bindingContext.ActionContext.RouteData.Values);
foreach (var fp in _commandBindingModel.FromPath)
{
var r = routeValueProvider.GetValue(fp);
bindingContext.ModelState.SetModelValue(fp, r);
if (r == ValueProviderResult.None) continue;
var m = bindingContext.ModelMetadata.GetMetadataForProperty(bindingContext.ModelType, fp);
using (bindingContext.EnterNestedScope(m, fp, fp, m.PropertyGetter(value)))
{
bindingContext.Result = ModelBindingResult.Success(r);
var binder = _binders[m];
await binder.BindModelAsync(bindingContext);
var result = bindingContext.Result;
m.PropertySetter(value, result.Model);
}
}
The last thing to do is to tell MVC about our custom IModelBinderProvider, should be done in the MvcOptions:
var store = new CommandBindingModelBuilder()
.Configure<UpdateTodoListCommand>(e => e
.FromPath(c => c.Id)
.FromQuery(c => c.Title)
.FromQuery(c => c.Index)
)
.Store;
builder.Services.AddControllers().AddMvcOptions(options =>
{
options.ModelBinderProviders.Insert(0, new CommandModelBinderProvider(store));
});
A complete gist here: https://gist.github.com/thomasouvre/e5438816af1a0ad81bddf106432cfa7d
EDIT: Sure you can customize NSwag operation generation with a custom IOperationProcessor like this one :
public class NSwagCommandOperationProcessor : IOperationProcessor
{
private readonly CommandBindingModelStore _store;
public NSwagCommandOperationProcessor(CommandBindingModelStore store)
{
_store = store;
}
public bool Process(OperationProcessorContext context)
{
ParameterInfo? pinfo = null;
CommandBindingModel? model = null;
// check if there is a command parameter in the action
foreach (var p in context.MethodInfo.GetParameters())
{
pinfo = p;
model = _store.Get(pinfo.ParameterType, false);
if (model != null) break;
}
if (model == null || pinfo == null) return true; // false will exclude the action
var jsonSchema = JsonSchema.FromType(pinfo.ParameterType); // create a full schema from the command type
if (jsonSchema.Type != JsonObjectType.Object) return false;
var bodyParameter = new OpenApiParameter() { IsRequired = true, Kind = OpenApiParameterKind.Body, Schema = jsonSchema, Name = pinfo.Name };
foreach (var prop in jsonSchema.Properties.Keys.ToList())
{
if (model.FromQuery.Contains(prop) || model.FromPath.Contains(prop))
{
// then excludes some properties from the schema
jsonSchema.Properties.Remove(prop);
continue;
}
bodyParameter.Properties.Add(prop, jsonSchema.Properties[prop]);
// if the property is not excluded, the property should be binded from the body
// so we have to delete existing parameters generated by NSwag (probably binded as from query)
var operationParameter = context.OperationDescription.Operation.Parameters.FirstOrDefault(p => p.Name == prop);
if (operationParameter != null)
context.OperationDescription.Operation.Parameters.Remove(operationParameter);
}
if (bodyParameter.Properties.Count > 0)
context.OperationDescription.Operation.Parameters.Add(bodyParameter);
return true;
}
}
The actual results from Swagger UI:

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With