Skip to content

Deprecation of IsRequiredAttribute #847

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 28 commits into from
Oct 5, 2020
Merged

Deprecation of IsRequiredAttribute #847

merged 28 commits into from
Oct 5, 2020

Conversation

maurei
Copy link
Member

@maurei maurei commented Sep 30, 2020

Closes #834.

Deprecates IsRequired attribute and allows for the same functionality by using the aspnetcore built-in Required attribute instead.

First approach

Property validators attributes like Required, MinLength etc are handled by DataAnnotationsModelValidatorProvider. My initial approach was to replace this provider with an adjusted provider to cover for what is needed in #671. I expected it would be possible to just add new a provider to MvcOptions.ModelValidatorProviders and remove the built-in one.

This turns out not to be possible because DataAnnotationsModelValidatorProvider inaccessible. If even if it would have been possible to replace this validator with our own, it would have been problematic because the class is both internal and sealed, which means we would end up copying the code which is a pain to maintain.

DataAnnotationsModelValidatorProvider is inaccessible because it is not exposed to the developer through MvcOptions.ModelValidatorProviders . Instead, it is added to the list of validator after the developer and JADNC have configured MvcOptions (right here). It happens in the app.UseEndpoints(endpoints => endpoints.MapControllers()); call in phase 2 of Startup.cs, and pretty much directly after it is consumed and disposed without a way for us to intercept that.

### Final approach
I ended up adding a custom JsonApiModelValidatorProvider to the validator pipeline. Unlike the other built-in IModelValidatorProviders, this one does not create any validator objects. Instead it is only used to access the ModelValidatorProviderContext which is shared among all validation providers. This object contains metadata about which validation attributes are to be executed. By doing a minor adjustment on that object, I trick DataAnnotationsModelValidatorProvider into executing our own JsonApiRequiredAttribute when the validation for the built-in RequiredAttribute is triggered. This way, a developer doesn't have to use our custom [IsRequired] any longer.

A point of attention here is the use of reflection to update a property that does not have a setter. I'm aware its a smell to mess with an internal backing field, but I think the risk of bad maintainability is relatively low because the targeted property is part of the public API and therefore the backing field is not likely to change.

That being said, since intercepting DataAnnotationsModelValidatorProvider is not possible, we can't get around tampering with some internal code anyway, and I believe this one comes with the lowest risk. I also think its worth it if it means people will no longer have to use our own IsRequired attribute.

Update: See comments in thread for final approach.

@maurei maurei requested a review from bart-degreed September 30, 2020 14:11
@maurei maurei marked this pull request as ready for review September 30, 2020 14:11
@maurei maurei changed the title Feat/834 Deprecation of IsRequiredAttribute Sep 30, 2020
@bart-degreed
Copy link
Contributor

The goal should be to intercept whether IsRequired validation executes or not from outside the attribute itself. Because from within the attribute, we cannot access the position in the object graph it is executing for. And we need to know that to implement correct behavior. This PR does not solve that.

Some quick unpolished prototyped code that demonstrates what needs to be done:

        public void ConfigureMvc()
        {
            _mvcBuilder.AddMvcOptions(options =>
            {
                options.EnableEndpointRouting = true;
                options.Filters.AddService<IAsyncJsonApiExceptionFilter>();
                options.Filters.AddService<IAsyncQueryStringActionFilter>();
                options.Filters.AddService<IAsyncConvertEmptyActionResultFilter>();

                var innerProvider = options.ModelValidatorProviders.First();
                options.ModelValidatorProviders.Clear();
                options.ModelValidatorProviders.Add(new CustomModelValidatorProvider(innerProvider));

                ConfigureMvcOptions?.Invoke(options);
            });

            if (_options.ValidateModelState)
            {
                _mvcBuilder.AddDataAnnotations();
            }
        }

        public class CustomModelValidatorProvider : IModelValidatorProvider
        {
            private readonly IModelValidatorProvider _innerProvider;

            public CustomModelValidatorProvider(IModelValidatorProvider innerProvider)
            {
                _innerProvider = innerProvider;
            }

            // https://github.com/dotnet/aspnetcore/blob/v3.1.4/src/Mvc/Mvc.DataAnnotations/src/DataAnnotationsModelValidatorProvider.cs
            public void CreateValidators(ModelValidatorProviderContext context)
            {
                var meta = context.ModelMetadata;
                var kind = context.ModelMetadata.MetadataKind;

                foreach (ValidatorItem result in context.Results)
                {
                    if (result.ValidatorMetadata is RequiredAttribute requiredAttribute)
                    {
                        result.Validator = new CustomRequiredValidator(requiredAttribute);
                    }
                }

                _innerProvider.CreateValidators(context);
            }
        }

        public class CustomRequiredValidator : IModelValidator
        {
            private readonly RequiredAttribute _requiredAttribute;

            public CustomRequiredValidator(RequiredAttribute requiredAttribute)
            {
                _requiredAttribute = requiredAttribute;
            }

            // https://github.com/dotnet/aspnetcore/blob/c565386a3ed135560bc2e9017aa54a950b4e35dd/src/Mvc/Mvc.DataAnnotations/src/DataAnnotationsModelValidator.cs
            public IEnumerable<ModelValidationResult> Validate(ModelValidationContext validationContext)
            {
                var metadata = validationContext.ModelMetadata;
                var memberName = metadata.Name;
                var container = validationContext.Container;

                var context = new ValidationContext(
                    instance: container ?? validationContext.Model ?? new object(),
                    serviceProvider: validationContext.ActionContext?.HttpContext?.RequestServices,
                    items: null)
                {
                    DisplayName = metadata.GetDisplayName(),
                    MemberName = memberName
                };

                // TODO: Choose if we want to run or skip required-field-validation, based on request.

                var result = _requiredAttribute.GetValidationResult(validationContext.Model, context);
                if (result != null)
                {
                    return new[]
                    {
                        new ModelValidationResult(memberName, result.ErrorMessage)
                    };
                }

                return Array.Empty<ModelValidationResult>();
            }
        }

Let me know if you need me to dive in more or that you'll take it from here.

@maurei
Copy link
Member Author

maurei commented Oct 3, 2020

After extensive discussions and a proper deep dive with @bart-degreed, I came up with an alternative solution.

In the ValidationVisitor while recursing through the object model graph there is a property that determines if a certain property should be validated or not. This property is a IPropertyValidationFilter that lives on ModelMetadata. By default this property is not set. It is possible to access the property setter by registering a custom implementation of IModelMetadataProvider. Through there one can assign a custom PartialPatchValidationFilter.

Unfortunately, this does not completely do the trick... Apart from the position in the object model graph, we also need access to JsonApiRequest and HttpContextAccessor, like in the old solution. The callee of this filter (ValidationVisitor) does not pass along any context, so currently this is unavailable.

We can work around this by having a custom ObjectModelValidator that used a slightly adjusted ValidationVisitor that passes along the IoC. This is such a minor adjustment that I think it could make sense for aspnetcore to include this in their internal implementation. I have opened up an issue for that, see dotnet/aspnetcore#26580. Meanwhile, we can stick with this solution that has the work around.

Copy link
Contributor

@bart-degreed bart-degreed left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will discuss the rest offline.

Bart Koelman added 5 commits October 5, 2020 15:03
@bart-degreed
Copy link
Contributor

Pushed changes that simplify injection.

@maurei maurei merged commit f72615f into master Oct 5, 2020
@maurei maurei deleted the feat/834 branch October 5, 2020 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

Improved ModelState validation
2 participants