Skip to content

Fix IServiceProvider resolution for DataAnnotations validation on Options#125651

Closed
Copilot wants to merge 3 commits intomainfrom
copilot/fix-validate-dataannotations-options
Closed

Fix IServiceProvider resolution for DataAnnotations validation on Options#125651
Copilot wants to merge 3 commits intomainfrom
copilot/fix-validate-dataannotations-options

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 17, 2026

Description

When using ValidateDataAnnotations() on options, the ValidationContext was created without an IServiceProvider, preventing custom ValidationAttributes from resolving services (e.g. IWebHostEnvironment) via validationContext.GetRequiredService<T>().

Changes Made

  • DataAnnotationValidateOptions.cs: Added a new public constructor DataAnnotationValidateOptions(string? name, IServiceProvider? serviceProvider). The existing single-arg constructor now delegates to it with null, preserving full backward compatibility. The service provider is stored in a private field and passed into every new ValidationContext(...) call — including recursive calls for [ValidateObjectMembers] and [ValidateEnumeratedItems] properties.

  • OptionsBuilderDataAnnotationsExtensions.cs: Changed ValidateDataAnnotations() to register IValidateOptions<TOptions> using a factory (sp => new DataAnnotationValidateOptions<TOptions>(name, sp)) so the root IServiceProvider from the DI container is captured and wired up automatically.

  • ref/Microsoft.Extensions.Options.DataAnnotations.cs: Updated the ref assembly to include the new constructor signature.

  • OptionsBuilderTest.cs: Added two regression tests — one confirming a service can be resolved inside a custom ValidationAttribute's IsValid method, and one confirming validation correctly fails when the expected service is not registered.

Note: The new constructor is new public API surface. Per dotnet/runtime policy, an API proposal issue must be filed and approved before this PR can be merged.

Testing

  • ✅ All 111 existing and new tests in Microsoft.Extensions.Options.Tests pass
  • ValidateDataAnnotations_ServiceProviderIsAvailableInValidationContext — verifies service resolution works inside a custom validation attribute
  • ValidateDataAnnotations_ServiceProviderFailsWhenServiceNotAvailableInValidationContext — verifies validation fails when the required service is not registered
  • ✅ No regressions in existing DataAnnotations validation tests
Original prompt

This section details on the original issue you should resolve

<issue_title>Wire up IServiceProvider when validating DataAnnotations attributes on Options</issue_title>
<issue_description>### Describe the bug
App was unable to resolve IWebHostEnvironment from ValidationContext when validating options with custom Required attribute inside Configure method.

To Reproduce

IWebHostEnvironment not resolving, when trying to create a custom validation attribute to make something required only in Production environment.

Relevant code snippets

    public class DemoOptions
    {
        [RequiredInProd]
        public string FooBar { get; set; }
    }

    public class RequiredInProd : RequiredAttribute
    {
        protected override ValidationResult IsValid(object value, ValidationContext validationContext)
        {
            //this errors
            var env = validationContext.GetRequiredService<IWebHostEnvironment>();
            return ValidationResult.Success;
        }
    }
//Startup.cs methods
public void ConfigureServices(IServiceCollection services)
        {
            services.AddOptions<DemoOptions>()
                .Bind(Configuration.GetSection(nameof(DemoOptions)))
                .ValidateDataAnnotations();
        }

        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            //this line works fine and IWebHostEnvironment is resolved.
            var envFromServices = app.ApplicationServices.GetRequiredService<IWebHostEnvironment>();
            //this errors when trying to resolve IWebHostEnvironment inside Attribute methods
            _ = app.ApplicationServices.GetRequiredService<IOptions<DemoOptions>>().Value;
        }

-->

</issue_description>

<agent_instructions>propose a PR for this issue, make it a draft, focus on correctness, we don't want to break anything</agent_instructions>

Comments on the Issue (you are @copilot in this section)


📱 Kick off Copilot coding agent tasks wherever you are with GitHub Mobile, available on iOS and Android.

@rosebyte
Copy link
Copy Markdown
Member

@copilot, you can proceed now.

…ons validation

Pass the root IServiceProvider to ValidationContext when validating
DataAnnotations attributes on Options. This enables custom validation
attributes to resolve services via validationContext.GetService() or
validationContext.GetRequiredService().

Changes:
- Add new public constructor DataAnnotationValidateOptions(name, serviceProvider)
- Change ValidateDataAnnotations() to register using a factory that
  captures and passes IServiceProvider to the new constructor
- Pass IServiceProvider through to all ValidationContext instances,
  including nested ValidateObjectMembers and ValidateEnumeratedItems calls
- Update ref assembly with the new constructor
- Add tests verifying service resolution works and fails correctly

Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/b340196e-dcb9-42de-ad3c-eb10b9d9199c

Co-authored-by: rosebyte <14963300+rosebyte@users.noreply.github.com>
Copy link
Copy Markdown
Contributor Author

Copilot AI commented Mar 30, 2026

@copilot, you can proceed now.

Done! Commit e11f8bc implements the fix:

  • Added a new public constructor DataAnnotationValidateOptions(string? name, IServiceProvider? serviceProvider) — backward-compatible, the existing single-arg constructor delegates to it with null.
  • ValidateDataAnnotations() now registers via a factory (sp => new DataAnnotationValidateOptions<TOptions>(name, sp)) so the root IServiceProvider from the DI container is captured and passed into every ValidationContext created during validation (including recursive nested-object and enumerable-item calls).
  • Updated the ref assembly with the new constructor.
  • Added two tests: one verifying a service can be resolved inside a custom attribute's IsValid, and one verifying validation fails when the expected service is not registered.

Note: the new constructor is a new public API surface — dotnet/runtime requires an api-approved issue before merging. I can file that proposal if you'd like to proceed.

Copilot AI changed the title [WIP] Fix IServiceProvider resolution for DataAnnotations validation Fix IServiceProvider resolution for DataAnnotations validation on Options Mar 30, 2026
@github-actions github-actions Bot added area-Extensions-Options and removed needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners labels Mar 30, 2026
Copilot AI review requested due to automatic review settings April 22, 2026 14:17
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR updates Microsoft.Extensions.Options.DataAnnotations so DataAnnotations validation for options constructs ValidationContext with an IServiceProvider, enabling custom ValidationAttributes to resolve services during validation.

Changes:

  • Add a new public DataAnnotationValidateOptions(string? name, IServiceProvider? serviceProvider) constructor and flow the service provider into all ValidationContext creation paths (including recursive validation).
  • Change ValidateDataAnnotations() registration to use a DI factory so the container service provider is captured and passed into DataAnnotationValidateOptions.
  • Add regression tests verifying service resolution succeeds when registered and fails when missing.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.

File Description
src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs Adds regression tests and a custom ValidationAttribute to verify service resolution via ValidationContext.
src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/OptionsBuilderDataAnnotationsExtensions.cs Updates ValidateDataAnnotations() to register the validator via factory to capture DI IServiceProvider.
src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/DataAnnotationValidateOptions.cs Introduces a new ctor storing IServiceProvider and passes it into ValidationContext for all validation calls.
src/libraries/Microsoft.Extensions.Options.DataAnnotations/ref/Microsoft.Extensions.Options.DataAnnotations.cs Updates the reference assembly to include the new public constructor.

var sp = services.BuildServiceProvider();

var error = Assert.Throws<OptionsValidationException>(() => sp.GetRequiredService<IOptions<AnnotatedOptionsWithServiceDependency>>().Value);
Assert.Contains("SomeService not resolved correctly", error.Message);
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

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

This test asserts on OptionsValidationException.Message, which is derived formatting over Failures and can be brittle if message formatting changes. Prefer asserting on error.Failures (or using the existing ValidateFailure<TOptions> helper in this file) to validate the exact failure(s) without depending on Message formatting.

Suggested change
Assert.Contains("SomeService not resolved correctly", error.Message);
Assert.Contains("SomeService not resolved correctly", error.Failures);

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +25
optionsBuilder.Services.AddSingleton<IValidateOptions<TOptions>>(
sp => new DataAnnotationValidateOptions<TOptions>(optionsBuilder.Name, sp));
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

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

The singleton factory lambda closes over the entire optionsBuilder instance. Capture optionsBuilder.Name into a local variable first and close over the string instead, to avoid retaining the builder object longer than necessary and to make the registration behavior clearer.

Suggested change
optionsBuilder.Services.AddSingleton<IValidateOptions<TOptions>>(
sp => new DataAnnotationValidateOptions<TOptions>(optionsBuilder.Name, sp));
string? name = optionsBuilder.Name;
optionsBuilder.Services.AddSingleton<IValidateOptions<TOptions>>(
sp => new DataAnnotationValidateOptions<TOptions>(name, sp));

Copilot uses AI. Check for mistakes.
Comment on lines +33 to +35
/// <summary>
/// Initializes a new instance of <see cref="DataAnnotationValidateOptions{TOptions}"/> .
/// </summary>
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

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

XML doc comment has an extra space before the period after the <see cref="DataAnnotationValidateOptions{TOptions}"/> reference; this shows up in generated docs and should be removed.

Copilot uses AI. Check for mistakes.
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
public class RequiresServiceAttribute : ValidationAttribute
{
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

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

ValidationAttribute.IsValid is nullability-annotated in the BCL; to avoid signature mismatches/warnings and to correctly express the contract, this override should use object? for value and return ValidationResult?.

Suggested change
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)

Copilot uses AI. Check for mistakes.
@rosebyte
Copy link
Copy Markdown
Member

I see why the IServiceProvider isn't supported in the DataAnnotationValidateOptions now. It's registered with the singleton lifetime so the IServiceProvider it gets is strictly the root provider, and anyone requesting a scoped service from it wouldn't get the correct instance for their scope but a "scoped" instance from root, i.e. effectively a singleton, and every disposable transient service requested from it would live until the whole container is disposed.

@rosebyte rosebyte closed this Apr 22, 2026
@github-actions
Copy link
Copy Markdown
Contributor

🤖 Copilot Code Review — PR #125651

Note

This review was generated by Copilot and reflects automated analysis. Models contributing: Claude Opus 4.6 (primary), Claude Haiku 4.5 (extensions-reviewer sub-agent). GPT-5.3-Codex timed out and did not contribute.

Holistic Assessment

Motivation: The problem is real and well-documented — ValidationContext was created without an IServiceProvider, preventing custom ValidationAttributes from resolving services. This has been an open request since 2020 (issue #36009). ASP.NET Core MVC already passes the service provider into ValidationContext, so the Options pipeline lagging behind is a genuine gap.

Approach: The approach is correct — store IServiceProvider in the validator, thread it through recursive TryValidateOptions calls, and capture it from the DI container via a factory registration. The implementation is minimal and backward-compatible.

Summary: ⚠️ Needs Changes. The code itself is mechanically correct, but there are three blocking concerns: (1) new public API surface without api-approved status, (2) a source generator parity gap that must be tracked, and (3) missing test coverage for nested/recursive validation paths.


Detailed Findings

❌ Missing API Approval — New public constructor requires api-approved issue

File: ref/Microsoft.Extensions.Options.DataAnnotations.cs:21-22

public DataAnnotationValidateOptions(string? name, System.IServiceProvider? serviceProvider) { }

This adds a new public constructor to the API surface. The linked issue #36009 has labels feature-request, area-Extensions-Options, and in-pr — but does not have the api-approved label. Per [dotnet/runtime policy]((aka.ms/redacted), all new public API surface must go through API review and receive approval before a PR can merge. The PR description acknowledges this requirement but the approval has not been obtained.

Action required: File an API proposal issue (or add the proposal to #36009) with the proposed shape, get it through API review, and link the approved issue. Alternatively, make the new constructor internal pending API review.


⚠️ Source generator parity gap — ValidationContext gets null service provider in generated code

File: src/libraries/Microsoft.Extensions.Options/gen/Emitter.cs:696

The Options validation source generator creates ValidationContext without an IServiceProvider:

// Emitter.cs line 696
var context = new ValidationContext(options, "SimpleName", null, null);
//                                                        ^^^^
//                              serviceProvider is hardcoded to null

After this PR, any custom ValidationAttribute using validationContext.GetService() will work via ValidateDataAnnotations() (runtime path) but will silently get null via the source-generated validator ([OptionsValidator]). This is a behavioral divergence that will confuse users who switch between the two validation mechanisms.

Action required: At minimum, document this gap in the PR description and file a tracking issue for source generator parity. Ideally, the generated validator should also thread IServiceProvider through its validation path.


⚠️ Missing tests for recursive/nested validation with IServiceProvider

File: src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs

The _serviceProvider is threaded through three recursive call sites in TryValidateOptions:

  • Top-level validation (line 100)
  • [ValidateObjectMembers] nested objects (line 132)
  • [ValidateEnumeratedItems] collections (line 144)

The two new tests only validate flat options objects. There are no tests verifying that IServiceProvider propagates correctly when validating nested objects or enumerated items. A custom ValidationAttribute on a nested object's property should still be able to resolve services.

Action required: Add a test with a nested options class using [ValidateObjectMembers] where the nested type has a [RequiresService]-attributed property, confirming the service provider flows through.


⚠️ Field placement violates dotnet/runtime style conventions

File: src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/DataAnnotationValidateOptions.cs:51

The field _serviceProvider is placed after the Name property:

public string? Name { get; }          // line 49
private readonly IServiceProvider? _serviceProvider;  // line 51

Per dotnet/runtime coding style, fields should be declared at the top of the type, before constructors and properties. Move the field to just inside the class declaration:

public class DataAnnotationValidateOptions<...>
{
    private readonly IServiceProvider? _serviceProvider;

    public DataAnnotationValidateOptions(string? name) ...
    public DataAnnotationValidateOptions(string? name, IServiceProvider? serviceProvider) ...
    public string? Name { get; }
    ...

💡 Consider non-nullable IServiceProvider for the new constructor

File: src/libraries/Microsoft.Extensions.Options.DataAnnotations/src/DataAnnotationValidateOptions.cs:40

The new constructor accepts IServiceProvider? as nullable. When called from the factory registration, sp from the DI container is never null. The nullable parameter exists solely for the delegation from the old constructor. Consider whether a non-nullable parameter would make the API intent clearer — the old constructor serves the "no DI" case, the new one serves the "with DI" case. This is a judgment call best made during API review.


💡 Use ValidateFailure test helper for consistency

File: src/libraries/Microsoft.Extensions.Options/tests/Microsoft.Extensions.Options.Tests/OptionsBuilderTest.cs:862

The failure test uses Assert.Contains on the error message. Other tests in this file use a ValidateFailure<TOptions> helper that validates the exception type, options name, failure count, and full error message format. Using it would be more thorough and consistent with the existing test patterns.


✅ DI registration change is correct

The switch from AddSingleton(instance) to AddSingleton(sp => factory) is correct. Both produce ServiceLifetime.Singleton. The factory runs once at first resolution and the result is cached. The old code constructed eagerly at registration time; the new code constructs lazily at first resolution — a harmless and idiomatic improvement for accessing IServiceProvider.


✅ Backward compatibility preserved

The original constructor delegates to the new one with serviceProvider: null. ValidationContext(object, null, null) is equivalent to ValidationContext(object) — the latter internally calls this(instance, null, null). Binary and behavioral backward compatibility is fully maintained.


✅ Thread safety is correct

The root IServiceProvider is thread-safe. The _serviceProvider field is readonly. A new ValidationContext is created per Validate call with no shared mutable state. No concerns.


✅ Trim attributes are correct

The new constructor carries the same [RequiresUnreferencedCode] attribute as the existing constructor with identical message text. The ref assembly matches.

Generated by Code Review for issue #125651 ·

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wire up IServiceProvider when validating DataAnnotations attributes on Options

3 participants