micro-elements/MicroElements.Swashbuckle.FluentValidation

Passing an object to an inheritance validator does not emit FluentValidation rules on property

icnocop opened this issue · 15 comments

Hi.

When I create a rule for a property which sets and passes an object to an inheritance validator, the FluentValidation rules for that property's type are not emitted in swagger.json.

For example, when not passing an object to an inheritance validator, the NotEmpty and MaxLength rules are emitted in swagger.json:

using FluentValidation;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;

namespace SampleWebApi.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class IssueA : Controller
    {
        public class TenantA
        {
            public PersonA Owner { get; set; }

            [UsedImplicitly]
            public class TenantAValidator : AbstractValidator<TenantA>
            {
                public TenantAValidator()
                {
                    RuleFor(x => x.Owner)
                        .SetValidator(x => new PersonA.PersonAValidator(/*x*/));
                }
            }
        }

        public class PersonA
        {
            public AccountA Account { get; set; }

            public class PersonAValidator : AbstractValidator<PersonA>
            {
                public PersonAValidator(/*TenantA tenant*/)
                {
                    RuleFor(x => x.Account)
                        .SetInheritanceValidator(x =>
                        {
                            x.Add(new StrictAccountA.StrictAccountAValidator(/*tenant*/));
                        });
                }
            }
        }

        public class AccountA
        {
            public string UserName { get; set; }

            [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
            public string Password { get; set; }
        }

        public class StrictAccountA : AccountA
        {
            public class StrictAccountAValidator : AbstractValidator<AccountA>
            {
                public StrictAccountAValidator(/*TenantA tenant*/)
                {
                    this.RuleFor(x => x.UserName)
                        .NotEmpty()
                        .MaximumLength(50);

                    this.RuleFor(x => x.Password)
                        .MaximumLength(256);
                }
            }
        }

        [HttpPost("[action]")]
        public ActionResult<TenantA> AddTenantA(TenantA tenant)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            return tenant;
        }
    }
}

However, when passing an object to an inheritance validator, the NotEmpty and MaxLength rules are not emitted in swagger.json:

using FluentValidation;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;

namespace SampleWebApi.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class IssueB : Controller
    {
        public class TenantB
        {
            public PersonB Owner { get; set; }

            [UsedImplicitly]
            public class TenantBValidator : AbstractValidator<TenantB>
            {
                public TenantBValidator()
                {
                    RuleFor(x => x.Owner)
                        .SetValidator(x => new PersonB.PersonBValidator(x));
                }
            }
        }

        public class PersonB
        {
            public AccountB Account { get; set; }

            public class PersonBValidator : AbstractValidator<PersonB>
            {
                public PersonBValidator(TenantB tenant)
                {
                    RuleFor(x => x.Account)
                        .SetInheritanceValidator(x =>
                        {
                            x.Add(new StrictAccountB.StrictAccountBValidator(tenant));
                        });
                }
            }
        }

        public class AccountB
        {
            public string UserName { get; set; }

            [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
            public string Password { get; set; }
        }

        public class StrictAccountB : AccountB
        {
            public class StrictAccountBValidator : AbstractValidator<AccountB>
            {
                public StrictAccountBValidator(TenantB tenant)
                {
                    this.RuleFor(x => x.UserName)
                        .NotEmpty()
                        .MaximumLength(50);

                    this.RuleFor(x => x.Password)
                        .MaximumLength(256);
                }
            }
        }

        [HttpPost("[action]")]
        public ActionResult<TenantB> AddTenantB(TenantB tenant)
        {
            if (!ModelState.IsValid)
            {
                return BadRequest(ModelState);
            }

            return tenant;
        }
    }
}

image

Any ideas?

Thank you.

When debugging, the following exception is thrown and I see it in the Debug Output window:

MicroElements.Swashbuckle.FluentValidation.FluentValidationRules: Warning: GetValidator for type 'SampleWebApi.Controllers.IssueB+AccountB' fails.

System.InvalidOperationException: Unable to resolve service for type 'SampleWebApi.Controllers.IssueB+TenantB' while attempting to activate 'SampleWebApi.Controllers.IssueB+StrictAccountB+StrictAccountBValidator'.
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.CreateArgumentCallSites(Type serviceType, Type implementationType, CallSiteChain callSiteChain, ParameterInfo[] parameters, Boolean throwIfCallSiteNotFound)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.CreateConstructorCallSite(ResultCache lifetime, Type serviceType, Type implementationType, CallSiteChain callSiteChain)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.TryCreateExact(ServiceDescriptor descriptor, Type serviceType, CallSiteChain callSiteChain, Int32 slot)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.TryCreateExact(Type serviceType, CallSiteChain callSiteChain)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.CreateCallSite(Type serviceType, CallSiteChain callSiteChain)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.<>c__DisplayClass7_0.<GetCallSite>b__0(Type type)
   at System.Collections.Concurrent.ConcurrentDictionary`2.GetOrAdd(TKey key, Func`2 valueFactory)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteFactory.GetCallSite(Type serviceType, CallSiteChain callSiteChain)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngine.CreateServiceAccessor(Type serviceType)
   at System.Collections.Concurrent.ConcurrentDictionary`2.GetOrAdd(TKey key, Func`2 valueFactory)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngine.GetService(Type serviceType, ServiceProviderEngineScope serviceProviderEngineScope)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope.GetService(Type serviceType)
   at MicroElements.Swashbuckle.FluentValidation.HttpContextServiceProviderValidatorFactory.CreateInstance(Type validatorType) in C:\github\icnocop\MicroElements.Swashbuckle.FluentValidation\src\MicroElements.Swashbuckle.FluentValidation\AspNetCore\HttpContextServiceProviderValidatorFactory.cs:line 30
   at FluentValidation.ValidatorFactoryBase.GetValidator(Type type) in C:\Projects\FluentValidation\src\FluentValidation\ValidatorFactoryBase.cs:line 41
   at MicroElements.Swashbuckle.FluentValidation.FluentValidationRules.Apply(OpenApiSchema schema, SchemaFilterContext context) in C:\github\icnocop\MicroElements.Swashbuckle.FluentValidation\src\MicroElements.Swashbuckle.FluentValidation\Swashbuckle\FluentValidationRules.cs:line 92
Exception thrown: 'System.InvalidOperationException' in Microsoft.Extensions.DependencyInjection.dll

As a work-around, I have to add this to ConfigureServices in Startup.cs:

services.AddScoped(c => new Controllers.IssueB.TenantB());

services.AddScoped(c => new Controllers.IssueB.TenantB()); is needed registration.
All validators resolves in runtime so tenant should be registered.

The validators themselves work during runtime without issues even without tenant being registered because tenant is a dynamic object passed to the validators at the time of validation.

tenant shouldn't be registered as part of the same service provider as the ASP.NET Core pipeline because it's created during runtime with property values provided from the HTTP request and/or retrieved from the database, so an instance of the object cannot be registered in Startup.cs for example.

The problem occurs when generating swagger.json because HttpContextServiceProviderValidatorFactory doesn't know how to create an instance of tenant, needed to create an instance of the validator, because tenant hasn't been registered in the service collection.

I will provide some samples that resolves this issue.

Can you provide more datailed sample of using tenant in validator?

One example is validating an account password based on a password policy.

An account password must adhere to the password policy defined on the tenant.

The application must support multiple tenants with different password policies, so the account password validation is dynamic.

So the validator uses the password policy defined in the tenant to determine if the password is valid or not.

Thank you for the sample work-around.

In the work-around example you provided, if I try to use tenant to validate the password, then the maximum length rule on the password is not emitted in swagger.json.

For example:

this.RuleFor(x => x.Password)
    .MaximumLength(256)
    .Must(x => x.Contains("!"))
    .When(x => tenant.Owner.Account.UserName == "admin");

Produces:
image

Hello!
MicroElements.Swashbuckle.FluentValidation is a plugin for Swashbuckle.AspNetCore that generates swagger specification on application startup. Conditional validation logic is skipped because swagger did not support it (before OpenAPI 3.0).
So MicroElements.Swashbuckle.FluentValidation cant generate swagger depending user input (swagger is static document).

I think that some conditional logic can be done with oneOf
see also: https://swagger.io/specification/
and this: https://swagger.io/docs/specification/data-models/inheritance-and-polymorphism/

Thank you! 🙏

When I added ApplyConditionTo.CurrentValidator to When it works as expected.

This works in an MVC Controller, but the OnActionExecutionAsync method isn't available to override in a web API controller deriving from ControllerBase.

You can do the same with IAsyncActionFilter

Thank you.

I've tried using an action filter, but FluentValidation runs during the model binding process so the action filter doesn't have a chance to run before validation.

From FluentValidation/FluentValidation#1607 (comment):

FluentValidation runs during ASP.NET's model binding process. You'd need to write a custom model binder if you want to customize this process (not a filter).

From https://docs.microsoft.com/en-us/aspnet/core/mvc/controllers/filters?view=aspnetcore-3.1:

filter-pipeline-2

yes it's true
I will try to create a sample.

Other opportunity is to disable auto validation and validate in own filter after context is built

Thank you.

I was able to resolve the issue as follows:

  1. Set fluentValidationMvcConfiguration.ImplicitlyValidateChildProperties = false; in Startup.cs.
  2. Implement IValidatorInterceptor in the top-level validator:
public ValidationResult AfterAspNetValidation(ActionContext actionContext, IValidationContext validationContext, ValidationResult result)
{
    return result;
}

public IValidationContext BeforeAspNetValidation(ActionContext actionContext, IValidationContext commonContext)
{
    // get model
    MyModel model = commonContext.InstanceToValidate as MyModel;

    // set model in accessor
    IMyAccessor myAccessor = actionContext.HttpContext.RequestServices.GetService<IMyAccessor>();
    myAccessor.MyModel = model;

    return commonContext;
}
  1. Manually call SetValidator for child properties.