.Net 6 How detect action filter is on Controller or Action method - asp.net-core

I have an action filter like this:
public class TestAttribute : IAsyncActionFilter, IOrderedFilter
{
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var onController= ?//here I want to detect whether attribute is on controller or action
}
public int Order { get; }
}
And I put attribute on controller like this (I know for sake of this purpose you need to use IFilterFactory or ServiceFilter but I removed them for simplicity):
[Test]
public class FileController : BaseApiController
Or for Action methods:
[Test]
public async Task<ActionResult<FileResponse>> UploadAsync()
So my question is how to detect this attribute is execute for controller scope or action scope in .net 6?

This code will help you;
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var currentFilter = context.ActionDescriptor
.FilterDescriptors
.FirstOrDefault(filterDescriptor => ReferenceEquals(filterDescriptor.Filter, this));
if (currentFilter == null)
{
return;
}
if (currentFilter.Scope == FilterScope.Action)
{
//..
}
if (currentFilter.Scope == FilterScope.Controller)
{
//...
}
await next();
}

Related

Differ IOutputFormatter per endpoint in ASP.NET Core 6

I have a legacy ASP.NET Web API 2 app which must be ported to ASP.NET Core 6 and it has the following behaviour:
Some controllers return responses in Pascal-case Json
Some controllers return responses in camel-case Json
All controllers have the same authentication/authorization, but they return different objects using different serializers for 401/403 cases.
In ASP.NET Web API 2 it was easily solved with IControllerConfiguration (to set the formatter for a controller), AuthorizeAttribute (to throw exceptions for 401/403), ExceptionFilterAttribute to set 401/403 status code and response which will be serialized using correct formatter.
In ASP.NET Core, it seems that IOutputFormatter collection is global for all controllers and it is not available during UseAuthentication + UseAuthorization pipeline where it terminates in case of failure.
Best I could come up with is to always "succeed" in authentication / authorization with some failing flag in claims and add IActionFilter as first filter checking those flags, but it looks very hacky.
Is there some better approach?
Update1:
Implementing different output formatters for IActionResult from controller or IFilter (including IExceptionFilter) is not very difficult.
What I want is to be able to either set IActionResult or use IOutputFormatter related to Action identified by UseRouting for Authentication/Authorization error or IAuthorizationHandler, but looks like all those auth steps are invoked before either ActionContext or IOutputFormatter is invoked.
So 2 approaches I see now:
hack auth code to "always pass" and handle HttpContext.Items["MyRealAuthResult"] object in IActionFilter
expose V1OutputFormatter/V2OutputFormatter in a static field and duplicate selection logic in HandleChallengeAsync/HandleForbiddenAsync based on to what controller/action it was routed from UseRouting step.
Here is sample app that uses auth and has 2 endpoints:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<IConfigureOptions<MvcOptions>, MvcOptionsSetup>();
builder.Services.AddAuthentication(options =>
{
options.AddScheme<DefAuthHandler>("defscheme", "defscheme");
});
builder.Services.AddAuthorization(options =>
options.DefaultPolicy = new AuthorizationPolicyBuilder("defscheme")
.RequireAssertion(context =>
// false here should result in Pascal case POCO for WeatherForecastV1Controller
// and camel case POCO for WeatherForecastV2Controller
context.User.Identities.Any(c => c.AuthenticationType == "secretheader"))
.Build())
.AddSingleton<IAuthorizationMiddlewareResultHandler, AuthorizationResultHandler>();
builder.Services.AddControllers();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
public class AuthorizationResultHandler : IAuthorizationMiddlewareResultHandler
{
private readonly AuthorizationMiddlewareResultHandler _handler;
public AuthorizationResultHandler()
{
_handler = new AuthorizationMiddlewareResultHandler();
}
public async Task HandleAsync(RequestDelegate next, HttpContext context, AuthorizationPolicy policy, PolicyAuthorizationResult authorizeResult)
{
// Can't set ActionContext.Response here or use IOutputFormatter
await _handler.HandleAsync(next, context, policy, authorizeResult);
}
}
public class DefAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public DefAuthHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, ISystemClock clock)
: base(options, logger, encoder, clock) { }
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var claims = new List<ClaimsIdentity>();
if (Request.Headers.ContainsKey("secretheader")) claims.Add(new ClaimsIdentity("secretheader"));
return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(claims), "defscheme"));
}
}
public class MvcOptionsSetup : IConfigureOptions<MvcOptions>
{
private readonly ArrayPool<char> arrayPool;
private readonly MvcNewtonsoftJsonOptions mvcNewtonsoftJsonOptions;
public MvcOptionsSetup(ArrayPool<char> arrayPool, IOptions<MvcNewtonsoftJsonOptions> mvcNewtonsoftJsonOptions)
{
this.arrayPool = arrayPool;
this.mvcNewtonsoftJsonOptions = mvcNewtonsoftJsonOptions.Value;
}
public void Configure(MvcOptions options)
{
options.OutputFormatters.Insert(0, new V1OutputFormatter(arrayPool, options, mvcNewtonsoftJsonOptions));
options.OutputFormatters.Insert(0, new V2OutputFormatter(arrayPool, options, mvcNewtonsoftJsonOptions));
}
}
public class V1OutputFormatter : NewtonsoftJsonOutputFormatter
{
public V1OutputFormatter(ArrayPool<char> charPool, MvcOptions mvcOptions, MvcNewtonsoftJsonOptions? jsonOptions)
: base(new JsonSerializerSettings { ContractResolver = new DefaultContractResolver() }, charPool, mvcOptions, jsonOptions) { }
public override bool CanWriteResult(OutputFormatterCanWriteContext context)
{
var controllerDescriptor = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata<Microsoft.AspNetCore.Mvc.Controllers.ControllerActionDescriptor>();
return controllerDescriptor?.ControllerName == "WeatherForecastV1";
}
}
public class V2OutputFormatter : NewtonsoftJsonOutputFormatter
{
public V2OutputFormatter(ArrayPool<char> charPool, MvcOptions mvcOptions, MvcNewtonsoftJsonOptions? jsonOptions)
: base(new JsonSerializerSettings { ContractResolver = new CamelCasePropertyNamesContractResolver() }, charPool, mvcOptions, jsonOptions) { }
public override bool CanWriteResult(OutputFormatterCanWriteContext context)
{
var controllerDescriptor = context.HttpContext.GetEndpoint()?.Metadata.GetMetadata<Microsoft.AspNetCore.Mvc.Controllers.ControllerActionDescriptor>();
return controllerDescriptor?.ControllerName == "WeatherForecastV2";
}
}
[ApiController]
[Authorize]
[Route("v1/weatherforecast")]
public class WeatherForecastV1Controller : ControllerBase
{
[HttpGet]
public IActionResult Get()
{
// This must be Pascal case
return Ok(new WeatherForecast() { Summary = "summary" });
}
}
[ApiController]
[Authorize]
[Route("v2/weatherforecast")]
public class WeatherForecastV2Controller : ControllerBase
{
[HttpGet]
public IActionResult Get()
{
// This must be camel case
return Ok(new WeatherForecast() { Summary = "summary" });
}
}
If there is no way to configure controllers independently, then you could use some middleware to convert output from selected controllers that meet a path-based predicate.
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapWhen(ctx => ctx.Request.Path.Containes("v2/"), cfg =>
{
app.UseMiddleware<JsonCapitalizer>();
});
app.Run();
And then create a JsonCapitalizer class to convert output from any path that contains "v2/". Note, this middleware will not run if the predicate in MapWhen is not satisfied.
public class JsonCapitalizer
{
readonly RequestDelegate _nextRequestDelegate;
public RequestLoggingMiddleware(
RequestDelegate nextRequestDelegate)
{
_nextRequestDelegate = nextRequestDelegate;
}
public async Task Invoke(HttpContext httpContext)
{
await _nextRequestDelegate(httpContext);
// Get the httpContext.Response
// Capitalize it
// Rewrite the response
}
}
There may be better ways, but that's the first that comes to mind.
The following link will help with manipulation of the response body:
https://itecnote.com/tecnote/c-how-to-read-asp-net-core-response-body/
I also faced such a problem in ASP Core 7 and ended up with writing an attribute.
So the attribute will be applied on each Action where the response type has to be converted. You can write many an attribute for camelcase response and another attribute for pascalcase. The attribute will look like below for CamelCase
public class CamelCaseAttribute : ActionFilterAttribute
{
private static readonly SystemTextJsonOutputFormatter formatter = new SystemTextJsonOutputFormatter(new()
{
ReferenceHandler = ReferenceHandler.IgnoreCycles,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
public override void OnActionExecuted(ActionExecutedContext context)
{
if (context.Result is ObjectResult objectResult)
{
objectResult.Formatters
.RemoveType<NewtonsoftJsonOutputFormatter>();
objectResult.Formatters.Add(formatter);
}
else
{
base.OnActionExecuted(context);
}
}
}
And on the Contoller Action you can use it like below
[CamelCase]
public async IAsyncEnumerable<ResponseResult<IReadOnlyList<VendorBalanceReportDto>>> VendorBalanceReport([FromQuery] Paginator paginator, [FromQuery] VendorBalanceReportFilter filter, [EnumeratorCancellation] CancellationToken token)
{
var response = _reportService.VendorBalanceReport(paginator, filter, token);
await foreach (var emailMessage in response)
{
yield return emailMessage;
}
}

Action filter does not override controller action?

I have implemented an IAsyncAuthorizationFilter/IActionFilter filter and implemented TypeFilterAttribute for the filter. When I add the attribute to both the controller and action, the action filter does not appear to override the controller level filter.
public class MyAuthorizeAttribute : TypeFilterAttribute
{
public MyAuthorizeAttribute (bool redirectOnFailure = true)
: base(typeof(MyFilter))
{
Arguments = new object[]
{
redirectOnFailure
};
}
}
public class MyFilter: IAsyncAuthorizationFilter, IActionFilter
{
public bool RedirectOnFailure { get; set; }
public MyFilter(bool redirectOnFailure)
{
RedirectOnFailure = redirectOnFailure;
}
public void OnActionExecuting(ActionExecutingContext context)
{
if (context.Controller is Controller controller)
{
// Do some work
if (true)
{
if (!RedirectOnFailure)
{
context.Result = new JsonResult("Your session has expired.");
}
else
{
context.Result = new RedirectResult("LoginUrl");
}
return;
}
}
}
public void OnActionExecuted(ActionExecutedContext context)
{
// Do nothing
}
public virtual async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
// Do work
}
}
The redirectOnFailure will be true for the Index action even though the filter specified false. In ASP.NET MVC, the action filter would override the controller filter. You could have a default for all actions but override specific actions with different properties/parameters. Can you not do this in Core?
[MyAuthorize]
public class HomeController : Controller
{
[MyAuthorize(redirectOnFailure: false)]
public IActionResult Index()
{
// Do work
}
}
As per the Microsoft website, filters do not override each other. They simply run one after the other in the order described in the cited document.
Just because the same attribute is put in both the controller and the action doesn't mean that ASP.net will say "ah, you probably want to override the class-level attribute". That's just not how it works.
If you want override logic, you need to write override logic.
Here's a sample made for .Net 6. The magic is done by the FindEffectivePolicy() method. This sample shows how to compare the current object against the effective one and only run the logic if the comparison matches.
public class MyFilter : IAsyncAuthorizationFilter
{
#region Properties
public string Name { get; }
#endregion
#region Constructors
public MyFilter(string name)
{
Name = name;
}
#endregion
#region IAsyncAuthorizationFilter
public Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
var effectiveAtt = context.FindEffectivePolicy<MyFilter>();
System.Diagnostics.Debug.Print($"Effective filter's name: {effectiveAtt?.Name}");
System.Diagnostics.Debug.Print($"Am I the effective attribute? {this == effectiveAtt}");
if (this == effectiveAtt)
{
// Do stuff since this is the effective attribute (policy).
}
else
{
// ELSE part probably not needed. We just want the IF to make sure the code runs only once.
}
return Task.CompletedTask;
}
#endregion
}

override default asp.net core cancelationtoken or change default timeout for requests

In my asp.net core 5.0 app, I call an async method that might take some time to be processed.
await someObject.LongRunningProcess(cancelationToken);
However, I want that method to be timeout after 5 seconds. I know that instead of "cancelationToken" passed by asp.net core action, I can use "CancellationTokenSource" :
var s_cts = new CancellationTokenSource();
s_cts.CancelAfter(TimeSpan.FromSeconds(5);
await someObject.LongRunningProcess(s_cts );
Is it possible to use "CancellationTokenSource" as a default "Cancelation Token" policy for all asp.net core requests ? I mean override the one which is passed as a parameter of the action ?
Or is it possible to change the default timeout for all request in asp.net core 5.0 ?
[Update]
Customizing the CancellationToken passed to actions
You need to replace the default CancellationTokenModelBinderProvider that binds HttpContext.RequestAborted token to CancellationToken parameters of actions.
This involves creating a custom IModelBinderProvider. Then we can replace the default binding result with our own.
public class TimeoutCancellationTokenModelBinderProvider : IModelBinderProvider
{
public IModelBinder? GetBinder(ModelBinderProviderContext context)
{
if (context?.Metadata.ModelType != typeof(CancellationToken))
{
return null;
}
var config = context.Services.GetRequiredService<IOptions<TimeoutOptions>>().Value;
return new TimeoutCancellationTokenModelBinder(config);
}
private class TimeoutCancellationTokenModelBinder : CancellationTokenModelBinder, IModelBinder
{
private readonly TimeoutOptions _options;
public TimeoutCancellationTokenModelBinder(TimeoutOptions options)
{
_options = options;
}
public new async Task BindModelAsync(ModelBindingContext bindingContext)
{
await base.BindModelAsync(bindingContext);
if (bindingContext.Result.Model is CancellationToken cancellationToken)
{
// combine the default token with a timeout
var timeoutCts = new CancellationTokenSource();
timeoutCts.CancelAfter(_options.Timeout);
var combinedCts =
CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken);
// We need to force boxing now, so we can insert the same reference to the boxed CancellationToken
// in both the ValidationState and ModelBindingResult.
//
// DO NOT simplify this code by removing the cast.
var model = (object)combinedCts.Token;
bindingContext.ValidationState.Clear();
bindingContext.ValidationState.Add(model, new ValidationStateEntry() { SuppressValidation = true });
bindingContext.Result = ModelBindingResult.Success(model);
}
}
}
}
class TimeoutOptions
{
public int TimeoutSeconds { get; set; } = 30; // seconds
public TimeSpan Timeout => TimeSpan.FromSeconds(TimeoutSeconds);
}
Then add this provider to Mvc's default binder provider list. It needs to run before all others, so we insert it at the beginning.
public void ConfigureServices(IServiceCollection services)
{
services.AddControllers();
services.Configure<MvcOptions>(options =>
{
options.ModelBinderProviders.RemoveType<CancellationTokenModelBinderProvider>();
options.ModelBinderProviders.Insert(0, new TimeoutCancellationTokenModelBinderProvider());
});
// remember to set the default timeout
services.Configure<TimeoutOptions>(configuration => { configuration.TimeoutSeconds = 2; });
}
Now ASP.NET Core will run your binder whenever it sees a parameter of CancellationToken type, which combines HttpContext.RequestAborted token with our timeout token. The combined token is triggered whenever one of its component is cancelled (due to timeout or request abortion, whichever is cancelled first)
[HttpGet("")]
public async Task<IActionResult> Index(CancellationToken cancellationToken)
{
await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); // throws TaskCanceledException after 2 seconds
return Ok("hey");
}
References:
https://github.com/dotnet/aspnetcore/blob/348b810d286fd2258aa763d6eda667a83ff972dc/src/Mvc/Mvc.Core/src/ModelBinding/Binders/CancellationTokenModelBinder.cs
https://abdus.dev/posts/aspnetcore-model-binding-json-query-params/
https://learn.microsoft.com/en-us/aspnet/core/mvc/advanced/custom-model-binding?view=aspnetcore-5.0#custom-model-binder-sample
One approach to solve this problem is wrapping that logic inside a class. Write a class that runs a task with a configurable timeout.
Then register it in DI, then use it anywhere you want to reuse the configuration.
public class TimeoutRunner
{
private TimeoutRunnerOptions _options;
public TimeoutRunner(IOptions<TimeoutRunnerOptions> options)
{
_options = options.Value;
}
public async Task<T> RunAsync<T>(Func<CancellationToken, Task<T>> runnable,
CancellationToken cancellationToken = default)
{
// cancel the task as soon as one of the tokens is set
var timeoutCts = new CancellationTokenSource();
var token = timeoutCts.Token;
if (cancellationToken != default)
{
timeoutCts.CancelAfter(_options.Timeout);
var combinedCts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken);
token = combinedCts.Token;
}
return await runnable(token);
}
}
internal static class ServiceCollectionExtensions
{
public static IServiceCollection AddTimeoutRunner(this IServiceCollection services,
Action<TimeoutRunnerOptions> configure = null)
{
if (configure != null)
{
services.Configure<TimeoutRunnerOptions>(configure);
}
return services.AddTransient<TimeoutRunner>();
}
}
public class TimeoutRunnerOptions
{
public int TimeoutSeconds { get; set; } = 10;
public TimeSpan Timeout => TimeSpan.FromSeconds(TimeoutSeconds);
}
you'd then register this in Startup class,
public void ConfigureServices(IServiceCollection services)
{
services.AddTimeoutRunner(options =>
{
options.TimeoutSeconds = 10;
});
}
then consume it wherever you need that global option:
public class MyController : ControllerBase
{
private TimeoutRunner _timeoutRunner;
public MyController(TimeoutRunner timeoutRunner)
{
_timeoutRunner = timeoutRunner;
}
public async Task<IActionResult> DoSomething(CancellationToken cancellationToken)
{
await _timeoutRunner.RunAsync(
async (CancellationToken token) => {
await Task.Delay(TimeSpan.FromSeconds(20), token);
},
cancellationToken
);
return Ok();
}
}
Running a task before every action dispatch
Method 1: Action filters
We can use action filters to run a task before/after every request.
public class ApiCallWithTimeeotActionFilter : IAsyncActionFilter
{
private TimeoutRunner _runner;
public ApiCallWithTimeeotActionFilter(TimeoutRunner runner)
{
_runner = runner;
}
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
var result = await _runner.RunAsync(
async (CancellationToken token) =>
{
await Task.Delay(TimeSpan.FromSeconds(20), token);
return 42;
},
default
);
await next();
}
}
then to use it annotate a class with [TypeFilter(typeof(MyAction))]:
[TypeFilter(typeof(ApiCallWithTimeeotActionFilter))]
public class MyController : ControllerBase { /* ... */ }
Method 2: Middlewares
Another option is to use a middleware
class ApiCallTimeoutMiddleware
{
private TimeoutRunner _runner;
public ApiCallTimeoutMiddleware(TimeoutRunner runner)
{
_runner = runner;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
// run a task before every request
var result = await _runner.RunAsync(
async (CancellationToken token) =>
{
await Task.Delay(TimeSpan.FromSeconds(20), token);
return 42;
},
default
);
await next(context);
}
}
then attach the middleware in Startup.Configure method:
public void Configure(IApplicationBuilder app)
{
app.UseMiddleware<ApiCallTimeoutMiddleware>();
app.UseRouting();
app.UseEndpoints(e => e.MapControllers());
}

FluentValidator and JsonPatchDocument

I have WebAPI (.NET Core) and use FluentValidator to validate model, including updating.
I use PATCH verb and have the following method:
public IActionResult Update(int id, [FromBody] JsonPatchDocument<TollUpdateAPI> jsonPatchDocument)
{
also, I have a validator class:
public class TollUpdateFluentValidator : AbstractValidator<TollUpdateAPI>
{
public TollUpdateFluentValidator ()
{
RuleFor(d => d.Date)
.NotNull().WithMessage("Date is required");
RuleFor(d => d.DriverId)
.GreaterThan(0).WithMessage("Invalid DriverId");
RuleFor(d => d.Amount)
.NotNull().WithMessage("Amount is required");
RuleFor(d => d.Amount)
.GreaterThanOrEqualTo(0).WithMessage("Invalid Amount");
}
}
and map this validator in Startup class:
services.AddTransient<IValidator<TollUpdateAPI>, TollUpdateFluentValidator>();
but it does not work. How to write valid FluentValidator for my task?
You will need to trigger the validation manually.
Your action method will be somthing like this:
public IActionResult Update(int id, [FromBody] JsonPatchDocument<TollUpdateAPI> jsonPatchDocument)
{
// Load your db entity
var myDbEntity = myService.LoadEntityFromDb(id);
// Copy/Map data to the entity to patch using AutoMapper for example
var entityToPatch = myMapper.Map<TollUpdateAPI>(myDbEntity);
// Apply the patch to the entity to patch
jsonPatchDocument.ApplyTo(entityToPatch);
// Trigger validation manually
var validationResult = new TollUpdateFluentValidator().Validate(entityToPatch);
if (!validationResult.IsValid)
{
// Add validation errors to ModelState
foreach (var error in validationResult.Errors)
{
ModelState.AddModelError(error.PropertyName, error.ErrorMessage);
}
// Patch failed, return 422 result
return UnprocessableEntity(ModelState);
}
// Map the patch to the dbEntity
myMapper.Map(entityToPatch, myDbEntity);
myService.SaveChangesToDb();
// So far so good, patch done
return NoContent();
}
You can utilize a custom rule builder for this. It might not be the most elegant way of handling it but at least the validation logic is where you expect it to be.
Say you have the following request model:
public class CarRequestModel
{
public string Make { get; set; }
public string Model { get; set; }
public decimal EngineDisplacement { get; set; }
}
Your Validator class can inherit from the AbstractValidator of JsonPatchDocument instead of the concrete request model type.
The fluent validator, on the other hand, provides us with decent extension points such as the Custom rule.
Combining these two ideas you can create something like this:
public class Validator : AbstractValidator<JsonPatchDocument<CarRequestModel>>
{
public Validator()
{
RuleForEach(x => x.Operations)
.Custom(HandleInternalPropertyValidation);
}
private void HandleInternalPropertyValidation(JsonPatchOperation property, CustomContext context)
{
void AddFailureForPropertyIf<T>(
Expression<Func<T, object>> propertySelector,
JsonPatchOperationType operation,
Func<JsonPatchOperation, bool> predicate, string errorMessage)
{
var propertyName = (propertySelector.Body as MemberExpression)?.Member.Name;
if (propertyName is null)
throw new ArgumentException("Property selector must be of type MemberExpression");
if (!property.Path.ToLowerInvariant().Contains(propertyName.ToLowerInvariant()) ||
property.Operation != operation) return;
if (predicate(property)) context.AddFailure(propertyName, errorMessage);
}
AddFailureForPropertyIf<CarRequestModel>(x => x.Make, JsonPatchOperationType.remove,
x => true, "Car Make cannot be removed.");
AddFailureForPropertyIf<CarRequestModel>(x => x.EngineDisplacement, JsonPatchOperationType.replace,
x => (decimal) x.Value < 12m, "Engine displacement must be less than 12l.");
}
}
In some cases, it might be tedious to write down all the actions that are not allowed from the domain perspective but are defined in the JsonPatch RFC.
This problem could be eased by defining none but rules which would define the set of operations that are valid from the perspective of your domain.
Realization bellow allow use IValidator<Model> inside IValidator<JsonPatchDocument<Model>>, but you need create model with valid properties values.
public class ModelValidator : AbstractValidator<JsonPatchDocument<Model>>
{
public override ValidationResult Validate(ValidationContext<JsonPatchDocument<Model>> context)
{
return _validator.Validate(GetRequestToValidate(context));
}
public override Task<ValidationResult> ValidateAsync(ValidationContext<JsonPatchDocument<Model>> context, CancellationToken cancellation = default)
{
return _validator.ValidateAsync(GetRequestToValidate(context), cancellation);
}
private static Model GetRequestToValidate(ValidationContext<JsonPatchDocument<Model>> context)
{
var validModel = new Model()
{
Name = nameof(Model.Name),
Url = nameof(Model.Url)
};
context.InstanceToValidate.ApplyTo(validModel);
return validModel;
}
private class Validator : AbstractValidator<Model>
{
/// <inheritdoc />
public Validator()
{
RuleFor(r => r.Name).NotEmpty();
RuleFor(r => r.Url).NotEmpty();
}
}
private static readonly Validator _validator = new();
}
You may try the below generic validator - it validates only updated properties:
public class JsonPatchDocumentValidator<T> : AbstractValidator<JsonPatchDocument<T>> where T: class, new()
{
private readonly IValidator<T> _validator;
public JsonPatchDocumentValidator(IValidator<T> validator)
{
_validator = validator;
}
private static string NormalizePropertyName(string propertyName)
{
if (propertyName[0] == '/')
{
propertyName = propertyName.Substring(1);
}
return char.ToUpper(propertyName[0]) + propertyName.Substring(1);
}
// apply path to the model
private static T ApplyPath(JsonPatchDocument<T> patchDocument)
{
var model = new T();
patchDocument.ApplyTo(model);
return model;
}
// returns only updated properties
private static string[] CollectUpdatedProperties(JsonPatchDocument<T> patchDocument)
=> patchDocument.Operations.Select(t => NormalizePropertyName(t.path)).Distinct().ToArray();
public override ValidationResult Validate(ValidationContext<JsonPatchDocument<T>> context)
{
return _validator.Validate(ApplyPath(context.InstanceToValidate),
o => o.IncludeProperties(CollectUpdatedProperties(context.InstanceToValidate)));
}
public override async Task<ValidationResult> ValidateAsync(ValidationContext<JsonPatchDocument<T>> context, CancellationToken cancellation = new CancellationToken())
{
return await _validator.ValidateAsync(ApplyPath(context.InstanceToValidate),
o => o.IncludeProperties(CollectUpdatedProperties(context.InstanceToValidate)), cancellation);
}
}
it has to be registered manually:
builder.Services.AddScoped<IValidator<JsonPatchDocument<TollUpdateAPI>>, JsonPatchDocumentValidator<TollUpdateAPI>>();

Modelbinding an optional array of a custom model bound type

I'm stuck with binding an optional array in an ASP.NET Core Controller. The array contains elements of a custom type. Single elements of this type are bound with a custom model binder and validated in it.
Sample repo here: https://github.com/MarcusKohnert/OptionalArrayModelBinding
I get only two tests out of three working in the sample test project:
https://github.com/MarcusKohnert/OptionalArrayModelBinding/blob/master/OptionalArrayModelBindingTest/TestOptionalArrayCustomModelBinder.cs
public class TestOptionalArrayCustomModelBinder
{
private readonly TestServer server;
private readonly HttpClient client;
public TestOptionalArrayCustomModelBinder()
{
server = new TestServer(new WebHostBuilder().UseStartup<Startup>());
client = server.CreateClient();
}
[Fact]
public async Task SuccessWithoutProvidingIds()
{
var response = await client.GetAsync("/api/values");
Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task SuccessWithValidIds()
{
var response = await client.GetAsync("/api/values?ids=aaa001&ids=bbb002");
Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task FailureWithOneInvalidId()
{
var response = await client.GetAsync("/api/values?ids=xaaa001&ids=bbb002");
Assert.Equal(System.Net.HttpStatusCode.BadRequest, response.StatusCode);
}
}
Controller:
[Route("api/[controller]")]
public class ValuesController : Controller
{
[HttpGet]
public IActionResult Get(CustomIdentifier[] ids)
{
if (this.ModelState.IsValid == false) return this.BadRequest();
return this.Ok(ids);
}
}
Startup:
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc(options =>
{
options.ModelBinderProviders.Insert(0, new CutomIdentifierModelBinderProvider());
//options.ModelBinderProviders.Add(new CutomIdentifierModelBinderProvider());
});
}
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
{
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
app.UseMvc();
}
}
ModelBinder:
public class CutomIdentifierModelBinderProvider : IModelBinderProvider
{
public IModelBinder GetBinder(ModelBinderProviderContext context)
{
//if (context.Metadata.ModelType.IsArray && context.Metadata.ModelType == typeof(CustomIdentifier[]))
//{
// return new ArrayModelBinder<CustomIdentifier>(new CustomIdentifierModelBinder());
//}
if (context.Metadata.ModelType == typeof(CustomIdentifier))
{
return new BinderTypeModelBinder(typeof(CustomIdentifierModelBinder));
}
return null;
}
}
public class CustomIdentifierModelBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
var attemptedValue = bindingContext.ValueProvider.GetValue(bindingContext.ModelName).ToString();
var parseResult = CustomIdentifier.TryParse(attemptedValue);
if (parseResult.Failed)
{
bindingContext.Result = ModelBindingResult.Failed();
bindingContext.ModelState.AddModelError(bindingContext.ModelName, parseResult.Message.Message);
}
else
{
bindingContext.Model = parseResult.Value;
bindingContext.Result = ModelBindingResult.Success(parseResult.Value);
}
return Task.CompletedTask;
}
}
The MVC default ArrayModelBinder of T binds optional arrays correctly and sets ModelState.IsValid to true. If I use my own CustomIdentifierModelBinder however ModelState.IsValid will be false. Empty arrays are not recognized as valid.
How can I solve this problem? Thanks in advance.
You are very close. Just customize behavior of built-in ArrayModelBinder for the case of missing parameter. If extracted value is an empty string just fill the model with an empty array. In all other cases you could call usual ArrayModelBinder.
Here is a working sample that passes all your 3 tests:
public class CutomIdentifierModelBinderProvider : IModelBinderProvider
{
public IModelBinder GetBinder(ModelBinderProviderContext context)
{
if (context.Metadata.ModelType.IsArray && context.Metadata.ModelType == typeof(CustomIdentifier[]))
{
return new CustomArrayModelBinder<CustomIdentifier>(new CustomIdentifierModelBinder());
}
return null;
}
}
public class CustomArrayModelBinder<T> : IModelBinder
{
private readonly ArrayModelBinder<T> innerModelBinder;
public CustomArrayModelBinder(IModelBinder elemeBinder)
{
innerModelBinder = new ArrayModelBinder<T>(elemeBinder);
}
public Task BindModelAsync(ModelBindingContext bindingContext)
{
var attemptedValue = bindingContext.ValueProvider.GetValue(bindingContext.ModelName).ToString();
if (String.IsNullOrEmpty(attemptedValue))
{
bindingContext.Model = new T[0];
bindingContext.Result = ModelBindingResult.Success(bindingContext.Model);
return Task.CompletedTask;
}
return innerModelBinder.BindModelAsync(bindingContext);
}
}
The solution is the following code change, reflected in this commit:
https://github.com/MarcusKohnert/OptionalArrayModelBinding/commit/552f4d35d8c33c002e1aa0c05acb407f1f962102
I've found the solution by inspecting MVC's source code again.
https://github.com/aspnet/Mvc/blob/35601f95b345d0ef938fb21ce1c51f5a67a1fb62/src/Microsoft.AspNetCore.Mvc.Core/ModelBinding/Binders/SimpleTypeModelBinder.cs#L37
You'll need to check the valueProviderResult for None. If it's none then there is no parameter given and the ModelBinder binds correctly.
var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (valueProviderResult == ValueProviderResult.None)
And also you register the provided ArrayModelBinder of T with your custom ModelBinder:
if (context.Metadata.ModelType.IsArray && context.Metadata.ModelType == typeof(CustomIdentifier[]))
{
return new ArrayModelBinder<CustomIdentifier>(new CustomIdentifierModelBinder());
}