From 697b397b9b764b2b1aaa3488ecbed01183cdd5cd Mon Sep 17 00:00:00 2001 From: Hao Kung Date: Thu, 23 Jul 2020 15:00:01 -0700 Subject: [PATCH 1/5] Revert AuthenticationAddXyz overload changes (#24253) --- .../IdentityServiceCollectionExtensions.cs | 30 ------ .../CertificateAuthenticationExtensions.cs | 40 +------- .../samples/CookieSessionSample/Startup.cs | 7 +- .../Cookies/src/CookieExtensions.cs | 27 +----- .../Core/src/AuthenticationBuilder.cs | 95 ++----------------- .../Facebook/src/FacebookExtensions.cs | 23 +---- .../Google/src/GoogleExtensions.cs | 23 +---- .../JwtBearer/src/JwtBearerExtensions.cs | 23 +---- .../src/MicrosoftAccountExtensions.cs | 25 +---- .../Negotiate/src/NegotiateExtensions.cs | 47 +-------- .../OAuth/src/OAuthExtensions.cs | 32 +------ .../src/OpenIdConnectExtensions.cs | 23 +---- .../Twitter/src/TwitterExtensions.cs | 23 +---- .../src/WsFederationExtensions.cs | 47 +-------- 14 files changed, 28 insertions(+), 437 deletions(-) diff --git a/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs b/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs index ffbc9f563e3c..45e3d567eb09 100644 --- a/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs +++ b/src/Identity/Core/src/IdentityServiceCollectionExtensions.cs @@ -110,21 +110,6 @@ public static IdentityBuilder AddIdentity( public static IServiceCollection ConfigureApplicationCookie(this IServiceCollection services, Action configure) => services.Configure(IdentityConstants.ApplicationScheme, configure); - /// - /// Configures the application cookie. - /// - /// TService: A service resolved from the IServiceProvider for use when configuring this authentication provider. If you need multiple services then specify IServiceProvider and resolve them directly. - /// The services available in the application. - /// An action to configure the . - /// The services. - public static IServiceCollection ConfigureApplicationCookie(this IServiceCollection services, Action configure) where TService : class - { - services.AddOptions(IdentityConstants.ApplicationScheme) - .Configure(configure); - - return services; - } - /// /// Configure the external cookie. /// @@ -133,20 +118,5 @@ public static IServiceCollection ConfigureApplicationCookie(this IServ /// The services. public static IServiceCollection ConfigureExternalCookie(this IServiceCollection services, Action configure) => services.Configure(IdentityConstants.ExternalScheme, configure); - - /// - /// Configure the external cookie. - /// - /// TService: A service resolved from the IServiceProvider for use when configuring this authentication provider. If you need multiple services then specify IServiceProvider and resolve them directly. - /// The services available in the application. - /// An action to configure the . - /// The services. - public static IServiceCollection ConfigureExternalCookie(this IServiceCollection services, Action configure) where TService : class - { - services.AddOptions(IdentityConstants.ExternalScheme) - .Configure(configure); - - return services; - } } } diff --git a/src/Security/Authentication/Certificate/src/CertificateAuthenticationExtensions.cs b/src/Security/Authentication/Certificate/src/CertificateAuthenticationExtensions.cs index 8224eb16cea7..8da8e2e1c95b 100644 --- a/src/Security/Authentication/Certificate/src/CertificateAuthenticationExtensions.cs +++ b/src/Security/Authentication/Certificate/src/CertificateAuthenticationExtensions.cs @@ -28,7 +28,7 @@ public static AuthenticationBuilder AddCertificate(this AuthenticationBuilder bu /// /// The . public static AuthenticationBuilder AddCertificate(this AuthenticationBuilder builder, string authenticationScheme) - => builder.AddCertificate(authenticationScheme, configureOptions: (Action)null); + => builder.AddCertificate(authenticationScheme, configureOptions: null); /// /// Adds certificate authentication. @@ -39,16 +39,6 @@ public static AuthenticationBuilder AddCertificate(this AuthenticationBuilder bu public static AuthenticationBuilder AddCertificate(this AuthenticationBuilder builder, Action configureOptions) => builder.AddCertificate(CertificateAuthenticationDefaults.AuthenticationScheme, configureOptions); - /// - /// Adds certificate authentication. - /// - /// TService: A service resolved from the IServiceProvider for use when configuring this authentication provider. If you need multiple services then specify IServiceProvider and resolve them directly. - /// The . - /// - /// The . - public static AuthenticationBuilder AddCertificate(this AuthenticationBuilder builder, Action configureOptions) where TService : class - => builder.AddCertificate(CertificateAuthenticationDefaults.AuthenticationScheme, configureOptions); - /// /// Adds certificate authentication. /// @@ -60,33 +50,7 @@ public static AuthenticationBuilder AddCertificate( this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) - { - Action configureOptionsWithServices; - if (configureOptions == null) - { - configureOptionsWithServices = null; - } - else - { - configureOptionsWithServices = (options, _) => configureOptions(options); - } - - return builder.AddCertificate(authenticationScheme, configureOptionsWithServices); - } - - /// - /// Adds certificate authentication. - /// - /// TService: A service resolved from the IServiceProvider for use when configuring this authentication provider. If you need multiple services then specify IServiceProvider and resolve them directly. - /// The . - /// - /// - /// The . - public static AuthenticationBuilder AddCertificate( - this AuthenticationBuilder builder, - string authenticationScheme, - Action configureOptions) where TService : class - => builder.AddScheme(authenticationScheme, configureOptions); + => builder.AddScheme(authenticationScheme, configureOptions); /// /// Adds certificate authentication. diff --git a/src/Security/Authentication/Cookies/samples/CookieSessionSample/Startup.cs b/src/Security/Authentication/Cookies/samples/CookieSessionSample/Startup.cs index c538866d7e7f..726615970733 100644 --- a/src/Security/Authentication/Cookies/samples/CookieSessionSample/Startup.cs +++ b/src/Security/Authentication/Cookies/samples/CookieSessionSample/Startup.cs @@ -14,14 +14,15 @@ public class Startup { public void ConfigureServices(IServiceCollection services) { - services.AddSingleton(); - // This can be removed after https://github.com/aspnet/IISIntegration/issues/371 services.AddAuthentication(options => { options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = CookieAuthenticationDefaults.AuthenticationScheme; - }).AddCookie((o, ticketStore) => o.SessionStore = ticketStore); + }).AddCookie(); + + services.AddOptions(CookieAuthenticationDefaults.AuthenticationScheme) + .Configure((o, ticketStore) => o.SessionStore = ticketStore); } public void Configure(IApplicationBuilder app) diff --git a/src/Security/Authentication/Cookies/src/CookieExtensions.cs b/src/Security/Authentication/Cookies/src/CookieExtensions.cs index a67a70814991..7763e6a62496 100644 --- a/src/Security/Authentication/Cookies/src/CookieExtensions.cs +++ b/src/Security/Authentication/Cookies/src/CookieExtensions.cs @@ -15,40 +15,19 @@ public static AuthenticationBuilder AddCookie(this AuthenticationBuilder builder => builder.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme); public static AuthenticationBuilder AddCookie(this AuthenticationBuilder builder, string authenticationScheme) - => builder.AddCookie(authenticationScheme, configureOptions: (Action)null); + => builder.AddCookie(authenticationScheme, configureOptions: null); - public static AuthenticationBuilder AddCookie(this AuthenticationBuilder builder, Action configureOptions) - => builder.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, configureOptions); - - public static AuthenticationBuilder AddCookie(this AuthenticationBuilder builder, Action configureOptions) where TService : class + public static AuthenticationBuilder AddCookie(this AuthenticationBuilder builder, Action configureOptions) => builder.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, configureOptions); public static AuthenticationBuilder AddCookie(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) => builder.AddCookie(authenticationScheme, displayName: null, configureOptions: configureOptions); - public static AuthenticationBuilder AddCookie(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) where TService : class - => builder.AddCookie(authenticationScheme, displayName: null, configureOptions: configureOptions); - public static AuthenticationBuilder AddCookie(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions) - { - Action configureOptionsWithServices; - if (configureOptions == null) - { - configureOptionsWithServices = null; - } - else - { - configureOptionsWithServices = (options, _) => configureOptions(options); - } - - return builder.AddCookie(authenticationScheme, displayName, configureOptionsWithServices); - } - - public static AuthenticationBuilder AddCookie(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions) where TService : class { builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, PostConfigureCookieAuthenticationOptions>()); builder.Services.AddOptions(authenticationScheme).Validate(o => o.Cookie.Expiration == null, "Cookie.Expiration is ignored, use ExpireTimeSpan instead."); - return builder.AddScheme(authenticationScheme, displayName, configureOptions); + return builder.AddScheme(authenticationScheme, displayName, configureOptions); } } } diff --git a/src/Security/Authentication/Core/src/AuthenticationBuilder.cs b/src/Security/Authentication/Core/src/AuthenticationBuilder.cs index 829fe007d775..d4efd0c847f0 100644 --- a/src/Security/Authentication/Core/src/AuthenticationBuilder.cs +++ b/src/Security/Authentication/Core/src/AuthenticationBuilder.cs @@ -25,31 +25,25 @@ public AuthenticationBuilder(IServiceCollection services) /// public virtual IServiceCollection Services { get; } - private AuthenticationBuilder AddSchemeHelper(string authenticationScheme, string displayName, Action configureOptions) where TService : class + private AuthenticationBuilder AddSchemeHelper(string authenticationScheme, string displayName, Action configureOptions) where TOptions : AuthenticationSchemeOptions, new() where THandler : class, IAuthenticationHandler { Services.Configure(o => { - o.AddScheme(authenticationScheme, scheme => - { + o.AddScheme(authenticationScheme, scheme => { scheme.HandlerType = typeof(THandler); scheme.DisplayName = displayName; }); }); - - var optionsBuilder = Services.AddOptions(authenticationScheme) - .Validate(o => - { - o.Validate(authenticationScheme); - return true; - }); - if (configureOptions != null) { - optionsBuilder.Configure(configureOptions); + Services.Configure(authenticationScheme, configureOptions); } - + Services.AddOptions(authenticationScheme).Validate(o => { + o.Validate(authenticationScheme); + return true; + }); Services.AddTransient(); return this; } @@ -66,22 +60,7 @@ private AuthenticationBuilder AddSchemeHelper(stri public virtual AuthenticationBuilder AddScheme(string authenticationScheme, string displayName, Action configureOptions) where TOptions : AuthenticationSchemeOptions, new() where THandler : AuthenticationHandler - => AddSchemeHelper(authenticationScheme, displayName, MapConfiguration(configureOptions)); - - /// - /// Adds a which can be used by . - /// - /// The type to configure the handler."/>. - /// The used to handle this scheme. - /// TService: A service resolved from the IServiceProvider for use when configuring this authentication provider. If you need multiple services then specify IServiceProvider and resolve them directly. - /// The name of this scheme. - /// The display name of this scheme. - /// Used to configure the scheme options. - /// The builder. - public virtual AuthenticationBuilder AddScheme(string authenticationScheme, string displayName, Action configureOptions) where TService : class - where TOptions : AuthenticationSchemeOptions, new() - where THandler : AuthenticationHandler - => AddSchemeHelper(authenticationScheme, displayName, configureOptions); + => AddSchemeHelper(authenticationScheme, displayName, configureOptions); /// /// Adds a which can be used by . @@ -96,20 +75,6 @@ public virtual AuthenticationBuilder AddScheme(string authen where THandler : AuthenticationHandler => AddScheme(authenticationScheme, displayName: null, configureOptions: configureOptions); - /// - /// Adds a which can be used by . - /// - /// The type to configure the handler."/>. - /// The used to handle this scheme. - /// TService: A service resolved from the IServiceProvider for use when configuring this authentication provider. If you need multiple services then specify IServiceProvider and resolve them directly. - /// The name of this scheme. - /// Used to configure the scheme options. - /// The builder. - public virtual AuthenticationBuilder AddScheme(string authenticationScheme, Action configureOptions) where TService : class - where TOptions : AuthenticationSchemeOptions, new() - where THandler : AuthenticationHandler - => AddScheme(authenticationScheme, displayName: null, configureOptions: configureOptions); - /// /// Adds a based that supports remote authentication /// which can be used by . @@ -128,25 +93,6 @@ public virtual AuthenticationBuilder AddRemoteScheme(string return AddScheme(authenticationScheme, displayName, configureOptions: configureOptions); } - /// - /// Adds a based that supports remote authentication - /// which can be used by . - /// - /// The type to configure the handler."/>. - /// The used to handle this scheme. - /// TService: A service resolved from the IServiceProvider for use when configuring this authentication provider. If you need multiple services then specify IServiceProvider and resolve them directly. - /// The name of this scheme. - /// The display name of this scheme. - /// Used to configure the scheme options. - /// The builder. - public virtual AuthenticationBuilder AddRemoteScheme(string authenticationScheme, string displayName, Action configureOptions) where TService : class - where TOptions : RemoteAuthenticationOptions, new() - where THandler : RemoteAuthenticationHandler - { - Services.TryAddEnumerable(ServiceDescriptor.Singleton, EnsureSignInScheme>()); - return AddScheme(authenticationScheme, displayName, configureOptions: configureOptions); - } - /// /// Adds a based authentication handler which can be used to /// redirect to other authentication schemes. @@ -156,30 +102,7 @@ public virtual AuthenticationBuilder AddRemoteSchemeUsed to configure the scheme options. /// The builder. public virtual AuthenticationBuilder AddPolicyScheme(string authenticationScheme, string displayName, Action configureOptions) - => AddSchemeHelper(authenticationScheme, displayName, MapConfiguration(configureOptions)); - - /// - /// Adds a based authentication handler which can be used to - /// redirect to other authentication schemes. - /// - /// The name of this scheme. - /// The display name of this scheme. - /// Used to configure the scheme options. - /// The builder. - public virtual AuthenticationBuilder AddPolicyScheme(string authenticationScheme, string displayName, Action configureOptions) where TService : class - => AddSchemeHelper(authenticationScheme, displayName, configureOptions); - - private Action MapConfiguration(Action configureOptions) - { - if (configureOptions == null) - { - return null; - } - else - { - return (options, _) => configureOptions(options); - } - } + => AddSchemeHelper(authenticationScheme, displayName, configureOptions); // Used to ensure that there's always a default sign in scheme that's not itself private class EnsureSignInScheme : IPostConfigureOptions where TOptions : RemoteAuthenticationOptions diff --git a/src/Security/Authentication/Facebook/src/FacebookExtensions.cs b/src/Security/Authentication/Facebook/src/FacebookExtensions.cs index b32b35c6c1fd..2273724a42ff 100644 --- a/src/Security/Authentication/Facebook/src/FacebookExtensions.cs +++ b/src/Security/Authentication/Facebook/src/FacebookExtensions.cs @@ -15,31 +15,10 @@ public static AuthenticationBuilder AddFacebook(this AuthenticationBuilder build public static AuthenticationBuilder AddFacebook(this AuthenticationBuilder builder, Action configureOptions) => builder.AddFacebook(FacebookDefaults.AuthenticationScheme, configureOptions); - public static AuthenticationBuilder AddFacebook(this AuthenticationBuilder builder, Action configureOptions) where TService : class - => builder.AddFacebook(FacebookDefaults.AuthenticationScheme, configureOptions); - public static AuthenticationBuilder AddFacebook(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) => builder.AddFacebook(authenticationScheme, FacebookDefaults.DisplayName, configureOptions); - public static AuthenticationBuilder AddFacebook(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) where TService : class - => builder.AddFacebook(authenticationScheme, FacebookDefaults.DisplayName, configureOptions); - public static AuthenticationBuilder AddFacebook(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions) - { - Action configureOptionsWithServices; - if (configureOptions == null) - { - configureOptionsWithServices = null; - } - else - { - configureOptionsWithServices = (options, _) => configureOptions(options); - } - - return builder.AddFacebook(authenticationScheme, displayName, configureOptionsWithServices); - } - - public static AuthenticationBuilder AddFacebook(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions) where TService : class - => builder.AddOAuth(authenticationScheme, displayName, configureOptions); + => builder.AddOAuth(authenticationScheme, displayName, configureOptions); } } diff --git a/src/Security/Authentication/Google/src/GoogleExtensions.cs b/src/Security/Authentication/Google/src/GoogleExtensions.cs index 88add9610da6..95547014ca2e 100644 --- a/src/Security/Authentication/Google/src/GoogleExtensions.cs +++ b/src/Security/Authentication/Google/src/GoogleExtensions.cs @@ -15,31 +15,10 @@ public static AuthenticationBuilder AddGoogle(this AuthenticationBuilder builder public static AuthenticationBuilder AddGoogle(this AuthenticationBuilder builder, Action configureOptions) => builder.AddGoogle(GoogleDefaults.AuthenticationScheme, configureOptions); - public static AuthenticationBuilder AddGoogle(this AuthenticationBuilder builder, Action configureOptions) where TService : class - => builder.AddGoogle(GoogleDefaults.AuthenticationScheme, configureOptions); - public static AuthenticationBuilder AddGoogle(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) => builder.AddGoogle(authenticationScheme, GoogleDefaults.DisplayName, configureOptions); - public static AuthenticationBuilder AddGoogle(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) where TService : class - => builder.AddGoogle(authenticationScheme, GoogleDefaults.DisplayName, configureOptions); - public static AuthenticationBuilder AddGoogle(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions) - { - Action configureOptionsWithServices; - if (configureOptions == null) - { - configureOptionsWithServices = null; - } - else - { - configureOptionsWithServices = (options, _) => configureOptions(options); - } - - return builder.AddGoogle(authenticationScheme, displayName, configureOptionsWithServices); - } - - public static AuthenticationBuilder AddGoogle(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions) where TService : class - => builder.AddOAuth(authenticationScheme, displayName, configureOptions); + => builder.AddOAuth(authenticationScheme, displayName, configureOptions); } } diff --git a/src/Security/Authentication/JwtBearer/src/JwtBearerExtensions.cs b/src/Security/Authentication/JwtBearer/src/JwtBearerExtensions.cs index a5aa46a45197..334407c0dab9 100644 --- a/src/Security/Authentication/JwtBearer/src/JwtBearerExtensions.cs +++ b/src/Security/Authentication/JwtBearer/src/JwtBearerExtensions.cs @@ -17,34 +17,13 @@ public static AuthenticationBuilder AddJwtBearer(this AuthenticationBuilder buil public static AuthenticationBuilder AddJwtBearer(this AuthenticationBuilder builder, Action configureOptions) => builder.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, configureOptions); - public static AuthenticationBuilder AddJwtBearer(this AuthenticationBuilder builder, Action configureOptions) where TService : class - => builder.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, configureOptions); - public static AuthenticationBuilder AddJwtBearer(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) => builder.AddJwtBearer(authenticationScheme, displayName: null, configureOptions: configureOptions); - public static AuthenticationBuilder AddJwtBearer(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) where TService : class - => builder.AddJwtBearer(authenticationScheme, displayName: null, configureOptions: configureOptions); - public static AuthenticationBuilder AddJwtBearer(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions) - { - Action configureOptionsWithServices; - if (configureOptions == null) - { - configureOptionsWithServices = null; - } - else - { - configureOptionsWithServices = (options, _) => configureOptions(options); - } - - return builder.AddJwtBearer(authenticationScheme, displayName, configureOptionsWithServices); - } - - public static AuthenticationBuilder AddJwtBearer(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions) where TService : class { builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, JwtBearerPostConfigureOptions>()); - return builder.AddScheme(authenticationScheme, displayName, configureOptions); + return builder.AddScheme(authenticationScheme, displayName, configureOptions); } } } diff --git a/src/Security/Authentication/MicrosoftAccount/src/MicrosoftAccountExtensions.cs b/src/Security/Authentication/MicrosoftAccount/src/MicrosoftAccountExtensions.cs index 0c59ce3504f8..7f24e5af77a6 100644 --- a/src/Security/Authentication/MicrosoftAccount/src/MicrosoftAccountExtensions.cs +++ b/src/Security/Authentication/MicrosoftAccount/src/MicrosoftAccountExtensions.cs @@ -15,31 +15,10 @@ public static AuthenticationBuilder AddMicrosoftAccount(this AuthenticationBuild public static AuthenticationBuilder AddMicrosoftAccount(this AuthenticationBuilder builder, Action configureOptions) => builder.AddMicrosoftAccount(MicrosoftAccountDefaults.AuthenticationScheme, configureOptions); - public static AuthenticationBuilder AddMicrosoftAccount(this AuthenticationBuilder builder, Action configureOptions) where TService : class - => builder.AddMicrosoftAccount(MicrosoftAccountDefaults.AuthenticationScheme, configureOptions); - public static AuthenticationBuilder AddMicrosoftAccount(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) => builder.AddMicrosoftAccount(authenticationScheme, MicrosoftAccountDefaults.DisplayName, configureOptions); - public static AuthenticationBuilder AddMicrosoftAccount(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) where TService : class - => builder.AddMicrosoftAccount(authenticationScheme, MicrosoftAccountDefaults.DisplayName, configureOptions); - public static AuthenticationBuilder AddMicrosoftAccount(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions) - { - Action configureOptionsWithServices; - if (configureOptions == null) - { - configureOptionsWithServices = null; - } - else - { - configureOptionsWithServices = (options, _) => configureOptions(options); - } - - return builder.AddMicrosoftAccount(authenticationScheme, displayName, configureOptionsWithServices); - } - - public static AuthenticationBuilder AddMicrosoftAccount(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions) where TService : class - => builder.AddOAuth(authenticationScheme, displayName, configureOptions); + => builder.AddOAuth(authenticationScheme, displayName, configureOptions); } -} +} \ No newline at end of file diff --git a/src/Security/Authentication/Negotiate/src/NegotiateExtensions.cs b/src/Security/Authentication/Negotiate/src/NegotiateExtensions.cs index 401c3dc839ad..f5bbf8cbc860 100644 --- a/src/Security/Authentication/Negotiate/src/NegotiateExtensions.cs +++ b/src/Security/Authentication/Negotiate/src/NegotiateExtensions.cs @@ -31,16 +31,6 @@ public static AuthenticationBuilder AddNegotiate(this AuthenticationBuilder buil public static AuthenticationBuilder AddNegotiate(this AuthenticationBuilder builder, Action configureOptions) => builder.AddNegotiate(NegotiateDefaults.AuthenticationScheme, configureOptions); - /// - /// Adds and configures Negotiate authentication. - /// - /// TService: A service resolved from the IServiceProvider for use when configuring this authentication provider. If you need multiple services then specify IServiceProvider and resolve them directly. - /// The . - /// Allows for configuring the authentication handler. - /// The original builder. - public static AuthenticationBuilder AddNegotiate(this AuthenticationBuilder builder, Action configureOptions) where TService : class - => builder.AddNegotiate(NegotiateDefaults.AuthenticationScheme, configureOptions); - /// /// Adds and configures Negotiate authentication. /// @@ -51,17 +41,6 @@ public static AuthenticationBuilder AddNegotiate(this AuthenticationBu public static AuthenticationBuilder AddNegotiate(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) => builder.AddNegotiate(authenticationScheme, displayName: null, configureOptions: configureOptions); - /// - /// Adds and configures Negotiate authentication. - /// - /// TService: A service resolved from the IServiceProvider for use when configuring this authentication provider. If you need multiple services then specify IServiceProvider and resolve them directly. - /// The . - /// The scheme name used to identify the authentication handler internally. - /// Allows for configuring the authentication handler. - /// The original builder. - public static AuthenticationBuilder AddNegotiate(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) where TService : class - => builder.AddNegotiate(authenticationScheme, displayName: null, configureOptions: configureOptions); - /// /// Adds and configures Negotiate authentication. /// @@ -71,33 +50,9 @@ public static AuthenticationBuilder AddNegotiate(this AuthenticationBu /// Allows for configuring the authentication handler. /// The original builder. public static AuthenticationBuilder AddNegotiate(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions) - { - Action configureOptionsWithServices; - if (configureOptions == null) - { - configureOptionsWithServices = null; - } - else - { - configureOptionsWithServices = (options, _) => configureOptions(options); - } - - return builder.AddNegotiate(authenticationScheme, displayName, configureOptionsWithServices); - } - - /// - /// Adds and configures Negotiate authentication. - /// - /// TService: A service resolved from the IServiceProvider for use when configuring this authentication provider. If you need multiple services then specify IServiceProvider and resolve them directly. - /// The . - /// The scheme name used to identify the authentication handler internally. - /// The name displayed to users when selecting an authentication handler. The default is null to prevent this from displaying. - /// Allows for configuring the authentication handler. - /// The original builder. - public static AuthenticationBuilder AddNegotiate(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions) where TService : class { builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, PostConfigureNegotiateOptions>()); - return builder.AddScheme(authenticationScheme, displayName, configureOptions); + return builder.AddScheme(authenticationScheme, displayName, configureOptions); } } } diff --git a/src/Security/Authentication/OAuth/src/OAuthExtensions.cs b/src/Security/Authentication/OAuth/src/OAuthExtensions.cs index 69bb2d73d348..22c541a0acf9 100644 --- a/src/Security/Authentication/OAuth/src/OAuthExtensions.cs +++ b/src/Security/Authentication/OAuth/src/OAuthExtensions.cs @@ -14,50 +14,20 @@ public static class OAuthExtensions public static AuthenticationBuilder AddOAuth(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) => builder.AddOAuth>(authenticationScheme, configureOptions); - public static AuthenticationBuilder AddOAuth(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) where TService : class - => builder.AddOAuth, TService>(authenticationScheme, configureOptions); - public static AuthenticationBuilder AddOAuth(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions) => builder.AddOAuth>(authenticationScheme, displayName, configureOptions); - public static AuthenticationBuilder AddOAuth(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions) where TService : class - => builder.AddOAuth, TService>(authenticationScheme, displayName, configureOptions); - public static AuthenticationBuilder AddOAuth(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) where TOptions : OAuthOptions, new() where THandler : OAuthHandler => builder.AddOAuth(authenticationScheme, OAuthDefaults.DisplayName, configureOptions); - public static AuthenticationBuilder AddOAuth(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) - where TOptions : OAuthOptions, new() - where THandler : OAuthHandler - where TService : class - => builder.AddOAuth(authenticationScheme, OAuthDefaults.DisplayName, configureOptions); - public static AuthenticationBuilder AddOAuth(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions) where TOptions : OAuthOptions, new() where THandler : OAuthHandler - { - Action configureOptionsWithServices; - if (configureOptions == null) - { - configureOptionsWithServices = null; - } - else - { - configureOptionsWithServices = (options, _) => configureOptions(options); - } - - return builder.AddOAuth(authenticationScheme, displayName, configureOptionsWithServices); - } - - public static AuthenticationBuilder AddOAuth(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions) - where TOptions : OAuthOptions, new() - where THandler : OAuthHandler - where TService : class { builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, OAuthPostConfigureOptions>()); - return builder.AddRemoteScheme(authenticationScheme, displayName, configureOptions); + return builder.AddRemoteScheme(authenticationScheme, displayName, configureOptions); } } } diff --git a/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectExtensions.cs b/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectExtensions.cs index 482452bca246..f427bebaff1b 100644 --- a/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectExtensions.cs +++ b/src/Security/Authentication/OpenIdConnect/src/OpenIdConnectExtensions.cs @@ -17,34 +17,13 @@ public static AuthenticationBuilder AddOpenIdConnect(this AuthenticationBuilder public static AuthenticationBuilder AddOpenIdConnect(this AuthenticationBuilder builder, Action configureOptions) => builder.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, configureOptions); - public static AuthenticationBuilder AddOpenIdConnect(this AuthenticationBuilder builder, Action configureOptions) where TService : class - => builder.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, configureOptions); - public static AuthenticationBuilder AddOpenIdConnect(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) => builder.AddOpenIdConnect(authenticationScheme, OpenIdConnectDefaults.DisplayName, configureOptions); - public static AuthenticationBuilder AddOpenIdConnect(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) where TService : class - => builder.AddOpenIdConnect(authenticationScheme, OpenIdConnectDefaults.DisplayName, configureOptions); - public static AuthenticationBuilder AddOpenIdConnect(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions) - { - Action configureOptionsWithServices; - if (configureOptions == null) - { - configureOptionsWithServices = null; - } - else - { - configureOptionsWithServices = (options, _) => configureOptions(options); - } - - return builder.AddOpenIdConnect(authenticationScheme, displayName, configureOptionsWithServices); - } - - public static AuthenticationBuilder AddOpenIdConnect(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions) where TService : class { builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, OpenIdConnectPostConfigureOptions>()); - return builder.AddRemoteScheme(authenticationScheme, displayName, configureOptions); + return builder.AddRemoteScheme(authenticationScheme, displayName, configureOptions); } } } diff --git a/src/Security/Authentication/Twitter/src/TwitterExtensions.cs b/src/Security/Authentication/Twitter/src/TwitterExtensions.cs index f6f6b93a9e84..724380569232 100644 --- a/src/Security/Authentication/Twitter/src/TwitterExtensions.cs +++ b/src/Security/Authentication/Twitter/src/TwitterExtensions.cs @@ -17,34 +17,13 @@ public static AuthenticationBuilder AddTwitter(this AuthenticationBuilder builde public static AuthenticationBuilder AddTwitter(this AuthenticationBuilder builder, Action configureOptions) => builder.AddTwitter(TwitterDefaults.AuthenticationScheme, configureOptions); - public static AuthenticationBuilder AddTwitter(this AuthenticationBuilder builder, Action configureOptions) where TService : class - => builder.AddTwitter(TwitterDefaults.AuthenticationScheme, configureOptions); - public static AuthenticationBuilder AddTwitter(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) => builder.AddTwitter(authenticationScheme, TwitterDefaults.DisplayName, configureOptions); - public static AuthenticationBuilder AddTwitter(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) where TService : class - => builder.AddTwitter(authenticationScheme, TwitterDefaults.DisplayName, configureOptions); - public static AuthenticationBuilder AddTwitter(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions) - { - Action configureOptionsWithServices; - if (configureOptions == null) - { - configureOptionsWithServices = null; - } - else - { - configureOptionsWithServices = (options, _) => configureOptions(options); - } - - return builder.AddTwitter(authenticationScheme, displayName, configureOptionsWithServices); - } - - public static AuthenticationBuilder AddTwitter(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions) where TService : class { builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, TwitterPostConfigureOptions>()); - return builder.AddRemoteScheme(authenticationScheme, displayName, configureOptions); + return builder.AddRemoteScheme(authenticationScheme, displayName, configureOptions); } } } diff --git a/src/Security/Authentication/WsFederation/src/WsFederationExtensions.cs b/src/Security/Authentication/WsFederation/src/WsFederationExtensions.cs index 6a9ffad23990..47091d58d5d3 100644 --- a/src/Security/Authentication/WsFederation/src/WsFederationExtensions.cs +++ b/src/Security/Authentication/WsFederation/src/WsFederationExtensions.cs @@ -31,16 +31,6 @@ public static AuthenticationBuilder AddWsFederation(this AuthenticationBuilder b public static AuthenticationBuilder AddWsFederation(this AuthenticationBuilder builder, Action configureOptions) => builder.AddWsFederation(WsFederationDefaults.AuthenticationScheme, configureOptions); - /// - /// Registers the using the default authentication scheme, display name, and the given options configuration. - /// - /// TService: A service resolved from the IServiceProvider for use when configuring this authentication provider. If you need multiple services then specify IServiceProvider and resolve them directly. - /// - /// A delegate that configures the . - /// - public static AuthenticationBuilder AddWsFederation(this AuthenticationBuilder builder, Action configureOptions) where TService : class - => builder.AddWsFederation(WsFederationDefaults.AuthenticationScheme, configureOptions); - /// /// Registers the using the given authentication scheme, default display name, and the given options configuration. /// @@ -51,17 +41,6 @@ public static AuthenticationBuilder AddWsFederation(this Authenticatio public static AuthenticationBuilder AddWsFederation(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) => builder.AddWsFederation(authenticationScheme, WsFederationDefaults.DisplayName, configureOptions); - /// - /// Registers the using the given authentication scheme, default display name, and the given options configuration. - /// - /// TService: A service resolved from the IServiceProvider for use when configuring this authentication provider. If you need multiple services then specify IServiceProvider and resolve them directly. - /// - /// - /// A delegate that configures the . - /// - public static AuthenticationBuilder AddWsFederation(this AuthenticationBuilder builder, string authenticationScheme, Action configureOptions) where TService : class - => builder.AddWsFederation(authenticationScheme, WsFederationDefaults.DisplayName, configureOptions); - /// /// Registers the using the given authentication scheme, display name, and options configuration. /// @@ -71,33 +50,9 @@ public static AuthenticationBuilder AddWsFederation(this Authenticatio /// A delegate that configures the . /// public static AuthenticationBuilder AddWsFederation(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions) - { - Action configureOptionsWithServices; - if (configureOptions == null) - { - configureOptionsWithServices = null; - } - else - { - configureOptionsWithServices = (options, _) => configureOptions(options); - } - - return builder.AddWsFederation(authenticationScheme, displayName, configureOptionsWithServices); - } - - /// - /// Registers the using the given authentication scheme, display name, and options configuration. - /// - /// TService: A service resolved from the IServiceProvider for use when configuring this authentication provider. If you need multiple services then specify IServiceProvider and resolve them directly. - /// - /// - /// - /// A delegate that configures the . - /// - public static AuthenticationBuilder AddWsFederation(this AuthenticationBuilder builder, string authenticationScheme, string displayName, Action configureOptions) where TService : class { builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, WsFederationPostConfigureOptions>()); - return builder.AddRemoteScheme(authenticationScheme, displayName, configureOptions); + return builder.AddRemoteScheme(authenticationScheme, displayName, configureOptions); } } } From b33f8ddbe249db1b5c55f2377f9aaa663e1a7f28 Mon Sep 17 00:00:00 2001 From: Pranav K Date: Thu, 23 Jul 2020 16:37:18 -0700 Subject: [PATCH 2/5] Add dotnetRunMessages to ASP.NET Core templates (#24229) --- .../Properties/launchSettings.json | 1 + .../Client/Properties/launchSettings.json | 1 + .../Server/Properties/launchSettings.json | 1 + .../Properties/launchSettings.json | 13 +++++++------ .../Properties/launchSettings.json | 1 + .../Properties/launchSettings.json | 1 + .../Properties/launchSettings.json | 1 + .../Properties/launchSettings.json | 1 + .../Properties/launchSettings.json | 15 ++++++++------- .../WebApi-CSharp/Properties/launchSettings.json | 1 + .../WebApi-FSharp/Properties/launchSettings.json | 1 + .../Worker-CSharp/Properties/launchSettings.json | 1 + .../Worker-FSharp/Properties/launchSettings.json | 1 + 13 files changed, 26 insertions(+), 13 deletions(-) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorServerWeb-CSharp/Properties/launchSettings.json b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorServerWeb-CSharp/Properties/launchSettings.json index bc8b8f87465c..31dd791b2806 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorServerWeb-CSharp/Properties/launchSettings.json +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorServerWeb-CSharp/Properties/launchSettings.json @@ -26,6 +26,7 @@ }, "BlazorServerWeb-CSharp": { "commandName": "Project", + "dotnetRunMessages": "true", "launchBrowser": true, //#if(RequiresHttps) "applicationUrl": "https://localhost:5001;http://localhost:5000", diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/ComponentsWebAssembly-CSharp/Client/Properties/launchSettings.json b/src/ProjectTemplates/Web.ProjectTemplates/content/ComponentsWebAssembly-CSharp/Client/Properties/launchSettings.json index 54eb43e3063f..29e2cda6c5bb 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/ComponentsWebAssembly-CSharp/Client/Properties/launchSettings.json +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/ComponentsWebAssembly-CSharp/Client/Properties/launchSettings.json @@ -27,6 +27,7 @@ }, "ComponentsWebAssembly-CSharp": { "commandName": "Project", + "dotnetRunMessages": "true", "launchBrowser": true, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", //#if(RequiresHttps) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/ComponentsWebAssembly-CSharp/Server/Properties/launchSettings.json b/src/ProjectTemplates/Web.ProjectTemplates/content/ComponentsWebAssembly-CSharp/Server/Properties/launchSettings.json index 578c5e9c1cef..26097aecb86c 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/ComponentsWebAssembly-CSharp/Server/Properties/launchSettings.json +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/ComponentsWebAssembly-CSharp/Server/Properties/launchSettings.json @@ -22,6 +22,7 @@ }, "ComponentsWebAssembly-CSharp.Server": { "commandName": "Project", + "dotnetRunMessages": "true", "launchBrowser": true, "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", //#if(RequiresHttps) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/EmptyWeb-CSharp/Properties/launchSettings.json b/src/ProjectTemplates/Web.ProjectTemplates/content/EmptyWeb-CSharp/Properties/launchSettings.json index e6a6feff75d5..31a3d4c92cce 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/EmptyWeb-CSharp/Properties/launchSettings.json +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/EmptyWeb-CSharp/Properties/launchSettings.json @@ -1,11 +1,11 @@ { "iisSettings": { - //#if (WindowsAuth) - "windowsAuthentication": true, - "anonymousAuthentication": false, - //#else - "windowsAuthentication": false, - "anonymousAuthentication": true, + //#if (WindowsAuth) + "windowsAuthentication": true, + "anonymousAuthentication": false, + //#else + "windowsAuthentication": false, + "anonymousAuthentication": true, //#endif "iisExpress": { "applicationUrl": "http://localhost:8080", @@ -26,6 +26,7 @@ }, "Company.WebApplication1": { "commandName": "Project", + "dotnetRunMessages": "true", "launchBrowser": true, //#if(NoHttps) "applicationUrl": "http://localhost:5000", diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/EmptyWeb-FSharp/Properties/launchSettings.json b/src/ProjectTemplates/Web.ProjectTemplates/content/EmptyWeb-FSharp/Properties/launchSettings.json index 68c6cca232db..e423cdba42a1 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/EmptyWeb-FSharp/Properties/launchSettings.json +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/EmptyWeb-FSharp/Properties/launchSettings.json @@ -21,6 +21,7 @@ }, "Company.WebApplication1": { "commandName": "Project", + "dotnetRunMessages": "true", "launchBrowser": true, //#if(NoHttps) "applicationUrl": "http://localhost:5000", diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/GrpcService-CSharp/Properties/launchSettings.json b/src/ProjectTemplates/Web.ProjectTemplates/content/GrpcService-CSharp/Properties/launchSettings.json index c0f6adc2911b..404de9d593b0 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/GrpcService-CSharp/Properties/launchSettings.json +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/GrpcService-CSharp/Properties/launchSettings.json @@ -2,6 +2,7 @@ "profiles": { "GrpcService-CSharp": { "commandName": "Project", + "dotnetRunMessages": "true", "launchBrowser": false, "applicationUrl": "http://localhost:5000;https://localhost:5001", "environmentVariables": { diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/RazorPagesWeb-CSharp/Properties/launchSettings.json b/src/ProjectTemplates/Web.ProjectTemplates/content/RazorPagesWeb-CSharp/Properties/launchSettings.json index d40962c82da1..d7c9fd245e6b 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/RazorPagesWeb-CSharp/Properties/launchSettings.json +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/RazorPagesWeb-CSharp/Properties/launchSettings.json @@ -31,6 +31,7 @@ }, "Company.WebApplication1": { "commandName": "Project", + "dotnetRunMessages": "true", "launchBrowser": true, //#if(RequiresHttps) "applicationUrl": "https://localhost:5001;http://localhost:5000", diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-CSharp/Properties/launchSettings.json b/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-CSharp/Properties/launchSettings.json index d40962c82da1..d7c9fd245e6b 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-CSharp/Properties/launchSettings.json +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-CSharp/Properties/launchSettings.json @@ -31,6 +31,7 @@ }, "Company.WebApplication1": { "commandName": "Project", + "dotnetRunMessages": "true", "launchBrowser": true, //#if(RequiresHttps) "applicationUrl": "https://localhost:5001;http://localhost:5000", diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-FSharp/Properties/launchSettings.json b/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-FSharp/Properties/launchSettings.json index cdf9c612f0a5..31a3d4c92cce 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-FSharp/Properties/launchSettings.json +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/StarterWeb-FSharp/Properties/launchSettings.json @@ -1,12 +1,12 @@ { "iisSettings": { - //#if (WindowsAuth) - "windowsAuthentication": true, - "anonymousAuthentication": false, - //#else - "windowsAuthentication": false, - "anonymousAuthentication": true, - //#endif + //#if (WindowsAuth) + "windowsAuthentication": true, + "anonymousAuthentication": false, + //#else + "windowsAuthentication": false, + "anonymousAuthentication": true, + //#endif "iisExpress": { "applicationUrl": "http://localhost:8080", //#if(NoHttps) @@ -26,6 +26,7 @@ }, "Company.WebApplication1": { "commandName": "Project", + "dotnetRunMessages": "true", "launchBrowser": true, //#if(NoHttps) "applicationUrl": "http://localhost:5000", diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Properties/launchSettings.json b/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Properties/launchSettings.json index bff9348110eb..c01e1f6b983d 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Properties/launchSettings.json +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-CSharp/Properties/launchSettings.json @@ -28,6 +28,7 @@ }, "Company.WebApplication1": { "commandName": "Project", + "dotnetRunMessages": "true", "launchBrowser": true, "launchUrl": "weatherforecast", //#if(RequiresHttps) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-FSharp/Properties/launchSettings.json b/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-FSharp/Properties/launchSettings.json index 6b707d0246ea..db631ebf8a18 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-FSharp/Properties/launchSettings.json +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/WebApi-FSharp/Properties/launchSettings.json @@ -28,6 +28,7 @@ }, "Company.WebApplication1": { "commandName": "Project", + "dotnetRunMessages": "true", "launchBrowser": true, "launchUrl": "weatherforecast", //#if(NoHttps) diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/Worker-CSharp/Properties/launchSettings.json b/src/ProjectTemplates/Web.ProjectTemplates/content/Worker-CSharp/Properties/launchSettings.json index 2c42dbde5c16..8ff0bab9356e 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/Worker-CSharp/Properties/launchSettings.json +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/Worker-CSharp/Properties/launchSettings.json @@ -2,6 +2,7 @@ "profiles": { "Company.Application1": { "commandName": "Project", + "dotnetRunMessages": "true", "environmentVariables": { "DOTNET_ENVIRONMENT": "Development" } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/Worker-FSharp/Properties/launchSettings.json b/src/ProjectTemplates/Web.ProjectTemplates/content/Worker-FSharp/Properties/launchSettings.json index 2c42dbde5c16..8ff0bab9356e 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/Worker-FSharp/Properties/launchSettings.json +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/Worker-FSharp/Properties/launchSettings.json @@ -2,6 +2,7 @@ "profiles": { "Company.Application1": { "commandName": "Project", + "dotnetRunMessages": "true", "environmentVariables": { "DOTNET_ENVIRONMENT": "Development" } From 11835cf768de14fa9806241e498ff4d0cba362d6 Mon Sep 17 00:00:00 2001 From: Pranav K Date: Thu, 23 Jul 2020 17:04:31 -0700 Subject: [PATCH 3/5] Add support for launching a browser on file change (#24220) --- .../dotnet-watch/src/BrowserRefreshServer.cs | 108 +++++++++ src/Tools/dotnet-watch/src/DotNetWatcher.cs | 19 +- src/Tools/dotnet-watch/src/IFileSet.cs | 4 +- .../dotnet-watch/src/Internal/FileSet.cs | 7 +- .../src/Internal/MsBuildFileSetFactory.cs | 9 +- .../src/Internal/ProcessRunner.cs | 46 ++-- .../dotnet-watch/src/LaunchBrowserFilter.cs | 219 ++++++++++++++++++ .../dotnet-watch/src/LaunchSettingsJson.cs | 21 ++ src/Tools/dotnet-watch/src/ProcessSpec.cs | 8 +- src/Tools/dotnet-watch/src/Program.cs | 4 +- .../src/assets/DotNetWatch.targets | 15 +- .../dotnet-watch/src/dotnet-watch.csproj | 20 ++ .../src/runtimeconfig.template.json | 3 + .../dotnet-watch/test/BrowserLaunchTests.cs | 68 ++++++ .../test/MSBuildEvaluationFilterTest.cs | 4 +- .../test/Scenario/WatchableApp.cs | 8 + .../AppWithLaunchSettings.csproj | 9 + .../AppWithLaunchSettings/Program.cs | 32 +++ .../Properties/launchSettings.json | 29 +++ 19 files changed, 604 insertions(+), 29 deletions(-) create mode 100644 src/Tools/dotnet-watch/src/BrowserRefreshServer.cs create mode 100644 src/Tools/dotnet-watch/src/LaunchBrowserFilter.cs create mode 100644 src/Tools/dotnet-watch/src/LaunchSettingsJson.cs create mode 100644 src/Tools/dotnet-watch/src/runtimeconfig.template.json create mode 100644 src/Tools/dotnet-watch/test/BrowserLaunchTests.cs create mode 100644 src/Tools/dotnet-watch/test/TestProjects/AppWithLaunchSettings/AppWithLaunchSettings.csproj create mode 100644 src/Tools/dotnet-watch/test/TestProjects/AppWithLaunchSettings/Program.cs create mode 100644 src/Tools/dotnet-watch/test/TestProjects/AppWithLaunchSettings/Properties/launchSettings.json diff --git a/src/Tools/dotnet-watch/src/BrowserRefreshServer.cs b/src/Tools/dotnet-watch/src/BrowserRefreshServer.cs new file mode 100644 index 000000000000..f173980dba3d --- /dev/null +++ b/src/Tools/dotnet-watch/src/BrowserRefreshServer.cs @@ -0,0 +1,108 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Linq; +using System.Net.WebSockets; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.DotNet.Watcher.Tools +{ + public class BrowserRefreshServer : IAsyncDisposable + { + private readonly IReporter _reporter; + private readonly TaskCompletionSource _taskCompletionSource; + private IHost _refreshServer; + private WebSocket _webSocket; + + public BrowserRefreshServer(IReporter reporter) + { + _reporter = reporter; + _taskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + public async ValueTask StartAsync(CancellationToken cancellationToken) + { + _refreshServer = new HostBuilder() + .ConfigureWebHost(builder => + { + builder.UseKestrel(); + builder.UseUrls("http://127.0.0.1:0"); + + builder.Configure(app => + { + app.UseWebSockets(); + app.Run(WebSocketRequest); + }); + }) + .Build(); + + await _refreshServer.StartAsync(cancellationToken); + + var serverUrl = _refreshServer.Services + .GetRequiredService() + .Features + .Get() + .Addresses + .First(); + + return serverUrl.Replace("http://", "ws://"); + } + + private async Task WebSocketRequest(HttpContext context) + { + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = 400; + return; + } + + _webSocket = await context.WebSockets.AcceptWebSocketAsync(); + await _taskCompletionSource.Task; + } + + public async Task SendMessage(byte[] messageBytes, CancellationToken cancellationToken = default) + { + if (_webSocket == null || _webSocket.CloseStatus.HasValue) + { + return; + } + + try + { + await _webSocket.SendAsync(messageBytes, WebSocketMessageType.Text, endOfMessage: true, cancellationToken); + } + catch (Exception ex) + { + _reporter.Output($"Refresh server error: {ex}"); + } + } + + public async ValueTask DisposeAsync() + { + if (_webSocket != null) + { + await _webSocket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, null, default); + _webSocket.Dispose(); + } + + if (_refreshServer != null) + { + await _refreshServer.StopAsync(); + _refreshServer.Dispose(); + } + + _taskCompletionSource.TrySetResult(); + } + } +} diff --git a/src/Tools/dotnet-watch/src/DotNetWatcher.cs b/src/Tools/dotnet-watch/src/DotNetWatcher.cs index 76b5a3adc0f7..5e2584fc599e 100644 --- a/src/Tools/dotnet-watch/src/DotNetWatcher.cs +++ b/src/Tools/dotnet-watch/src/DotNetWatcher.cs @@ -14,7 +14,7 @@ namespace Microsoft.DotNet.Watcher { - public class DotNetWatcher + public class DotNetWatcher : IAsyncDisposable { private readonly IReporter _reporter; private readonly ProcessRunner _processRunner; @@ -31,6 +31,7 @@ public DotNetWatcher(IReporter reporter, IFileSetFactory fileSetFactory) { new MSBuildEvaluationFilter(fileSetFactory), new NoRestoreFilter(), + new LaunchBrowserFilter(), }; } @@ -140,5 +141,21 @@ public async Task WatchAsync(ProcessSpec processSpec, CancellationToken cancella } } } + + public async ValueTask DisposeAsync() + { + foreach (var filter in _filters) + { + if (filter is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync(); + } + else if (filter is IDisposable diposable) + { + diposable.Dispose(); + } + + } + } } } diff --git a/src/Tools/dotnet-watch/src/IFileSet.cs b/src/Tools/dotnet-watch/src/IFileSet.cs index 7554d3f542e8..177dc6d00ee5 100644 --- a/src/Tools/dotnet-watch/src/IFileSet.cs +++ b/src/Tools/dotnet-watch/src/IFileSet.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Collections.Generic; @@ -7,6 +7,8 @@ namespace Microsoft.DotNet.Watcher { public interface IFileSet : IEnumerable { + bool IsNetCoreApp31OrNewer { get; } + bool Contains(string filePath); } } diff --git a/src/Tools/dotnet-watch/src/Internal/FileSet.cs b/src/Tools/dotnet-watch/src/Internal/FileSet.cs index 7fdf8748e9f6..3ca17027db51 100644 --- a/src/Tools/dotnet-watch/src/Internal/FileSet.cs +++ b/src/Tools/dotnet-watch/src/Internal/FileSet.cs @@ -11,8 +11,9 @@ public class FileSet : IFileSet { private readonly HashSet _files; - public FileSet(IEnumerable files) + public FileSet(bool isNetCoreApp31OrNewer, IEnumerable files) { + IsNetCoreApp31OrNewer = isNetCoreApp31OrNewer; _files = new HashSet(files, StringComparer.OrdinalIgnoreCase); } @@ -20,7 +21,9 @@ public FileSet(IEnumerable files) public int Count => _files.Count; - public static IFileSet Empty = new FileSet(Array.Empty()); + public bool IsNetCoreApp31OrNewer { get; } + + public static IFileSet Empty = new FileSet(false, Array.Empty()); public IEnumerator GetEnumerator() => _files.GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => _files.GetEnumerator(); diff --git a/src/Tools/dotnet-watch/src/Internal/MsBuildFileSetFactory.cs b/src/Tools/dotnet-watch/src/Internal/MsBuildFileSetFactory.cs index 3cdf453067b7..dc6d7b082927 100644 --- a/src/Tools/dotnet-watch/src/Internal/MsBuildFileSetFactory.cs +++ b/src/Tools/dotnet-watch/src/Internal/MsBuildFileSetFactory.cs @@ -72,6 +72,7 @@ public async Task CreateAsync(CancellationToken cancellationToken) Arguments = new[] { "msbuild", + "/nologo", _projectFile, $"/p:_DotNetWatchListFile={watchList}" }.Concat(_buildFlags), @@ -84,8 +85,12 @@ public async Task CreateAsync(CancellationToken cancellationToken) if (exitCode == 0 && File.Exists(watchList)) { + var lines = File.ReadAllLines(watchList); + var isNetCoreApp31OrNewer = lines.FirstOrDefault() == "true"; + var fileset = new FileSet( - File.ReadAllLines(watchList) + isNetCoreApp31OrNewer, + lines.Skip(1) .Select(l => l?.Trim()) .Where(l => !string.IsNullOrEmpty(l))); @@ -123,7 +128,7 @@ public async Task CreateAsync(CancellationToken cancellationToken) { _reporter.Warn("Fix the error to continue or press Ctrl+C to exit."); - var fileSet = new FileSet(new[] { _projectFile }); + var fileSet = new FileSet(false, new[] { _projectFile }); using (var watcher = new FileSetWatcher(fileSet, _reporter)) { diff --git a/src/Tools/dotnet-watch/src/Internal/ProcessRunner.cs b/src/Tools/dotnet-watch/src/Internal/ProcessRunner.cs index 7874d592b6af..050828467ab0 100644 --- a/src/Tools/dotnet-watch/src/Internal/ProcessRunner.cs +++ b/src/Tools/dotnet-watch/src/Internal/ProcessRunner.cs @@ -37,37 +37,49 @@ public async Task RunAsync(ProcessSpec processSpec, CancellationToken cance { cancellationToken.Register(() => processState.TryKill()); - process.OutputDataReceived += (_, a) => + var readOutput = false; + var readError = false; + if (processSpec.IsOutputCaptured) { - if (!string.IsNullOrEmpty(a.Data)) + readOutput = true; + readError = true; + process.OutputDataReceived += (_, a) => { - processSpec.OutputCapture.AddLine(a.Data); - } - }; - process.ErrorDataReceived += (_, a) => - { - if (!string.IsNullOrEmpty(a.Data)) + if (!string.IsNullOrEmpty(a.Data)) + { + processSpec.OutputCapture.AddLine(a.Data); + } + }; + process.ErrorDataReceived += (_, a) => { - processSpec.OutputCapture.AddLine(a.Data); - } - }; + if (!string.IsNullOrEmpty(a.Data)) + { + processSpec.OutputCapture.AddLine(a.Data); + } + }; + } + else if (processSpec.OnOutput != null) + { + readOutput = true; + process.OutputDataReceived += processSpec.OnOutput; + } stopwatch.Start(); process.Start(); _reporter.Verbose($"Started '{processSpec.Executable}' with process id {process.Id}"); - if (processSpec.IsOutputCaptured) + if (readOutput) { - process.BeginErrorReadLine(); process.BeginOutputReadLine(); - await processState.Task; } - else + if (readError) { - await processState.Task; + process.BeginErrorReadLine(); } + await processState.Task; + exitCode = process.ExitCode; stopwatch.Stop(); _reporter.Verbose($"Process id {process.Id} ran for {stopwatch.ElapsedMilliseconds}ms"); @@ -87,7 +99,7 @@ private Process CreateProcess(ProcessSpec processSpec) Arguments = ArgumentEscaper.EscapeAndConcatenate(processSpec.Arguments), UseShellExecute = false, WorkingDirectory = processSpec.WorkingDirectory, - RedirectStandardOutput = processSpec.IsOutputCaptured, + RedirectStandardOutput = processSpec.IsOutputCaptured || (processSpec.OnOutput != null), RedirectStandardError = processSpec.IsOutputCaptured, } }; diff --git a/src/Tools/dotnet-watch/src/LaunchBrowserFilter.cs b/src/Tools/dotnet-watch/src/LaunchBrowserFilter.cs new file mode 100644 index 000000000000..de8b189f3f9c --- /dev/null +++ b/src/Tools/dotnet-watch/src/LaunchBrowserFilter.cs @@ -0,0 +1,219 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Tools.Internal; + +namespace Microsoft.DotNet.Watcher.Tools +{ + public sealed class LaunchBrowserFilter : IWatchFilter, IAsyncDisposable + { + private readonly byte[] ReloadMessage = Encoding.UTF8.GetBytes("Reload"); + private readonly byte[] WaitMessage = Encoding.UTF8.GetBytes("Wait"); + private static readonly Regex NowListeningRegex = new Regex(@"^\s*Now listening on: (?.*)$", RegexOptions.None | RegexOptions.Compiled, TimeSpan.FromSeconds(10)); + + private readonly bool _runningInTest; + private readonly bool _suppressLaunchBrowser; + private readonly string _browserPath; + private bool _canLaunchBrowser; + private Process _browserProcess; + private bool _browserLaunched; + private BrowserRefreshServer _refreshServer; + private IReporter _reporter; + private string _launchPath; + private CancellationToken _cancellationToken; + + public LaunchBrowserFilter() + { + var suppressLaunchBrowser = Environment.GetEnvironmentVariable("DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER"); + _suppressLaunchBrowser = (suppressLaunchBrowser == "1" || suppressLaunchBrowser == "true"); + _runningInTest = Environment.GetEnvironmentVariable("__DOTNET_WATCH_RUNNING_AS_TEST") == "true"; + _browserPath = Environment.GetEnvironmentVariable("DOTNET_WATCH_BROWSER_PATH"); + } + + public async ValueTask ProcessAsync(DotNetWatchContext context, CancellationToken cancellationToken) + { + if (_suppressLaunchBrowser) + { + return; + } + + if (context.Iteration == 0) + { + _reporter = context.Reporter; + + if (CanLaunchBrowser(context, out var launchPath)) + { + context.Reporter.Verbose("dotnet-watch is configured to launch a browser on ASP.NET Core application startup."); + _canLaunchBrowser = true; + _launchPath = launchPath; + _cancellationToken = cancellationToken; + + _refreshServer = new BrowserRefreshServer(context.Reporter); + var serverUrl = await _refreshServer.StartAsync(cancellationToken); + + context.Reporter.Verbose($"Refresh server running at {serverUrl}."); + context.ProcessSpec.EnvironmentVariables["DOTNET_WATCH_REFRESH_URL"] = serverUrl; + + context.ProcessSpec.OnOutput += OnOutput; + } + } + + if (_canLaunchBrowser) + { + if (context.Iteration > 0) + { + // We've detected a change. Notify the browser. + await _refreshServer.SendMessage(WaitMessage, cancellationToken); + } + } + } + + private void OnOutput(object sender, DataReceivedEventArgs eventArgs) + { + // We've redirected the output, but want to ensure that it continues to appear in the user's console. + Console.WriteLine(eventArgs.Data); + + if (string.IsNullOrEmpty(eventArgs.Data)) + { + return; + } + + var match = NowListeningRegex.Match(eventArgs.Data); + if (match.Success) + { + var launchUrl = match.Groups["url"].Value; + + var process = (Process)sender; + process.OutputDataReceived -= OnOutput; + process.CancelOutputRead(); + + if (!_browserLaunched) + { + _reporter.Verbose("Launching browser."); + try + { + LaunchBrowser(launchUrl); + _browserLaunched = true; + } + catch (Exception ex) + { + _reporter.Output($"Unable to launch browser: {ex}"); + _canLaunchBrowser = false; + } + } + else + { + _reporter.Verbose("Reloading browser."); + _ = _refreshServer.SendMessage(ReloadMessage, _cancellationToken); + } + } + } + + private void LaunchBrowser(string launchUrl) + { + var fileName = launchUrl + "/" + _launchPath; + var args = string.Empty; + if (!string.IsNullOrEmpty(_browserPath)) + { + args = fileName; + fileName = _browserPath; + } + + if (_runningInTest) + { + _reporter.Output($"Launching browser: {fileName} {args}"); + return; + } + + _browserProcess = Process.Start(new ProcessStartInfo + { + FileName = fileName, + Arguments = args, + UseShellExecute = true, + }); + } + + private static bool CanLaunchBrowser(DotNetWatchContext context, out string launchUrl) + { + launchUrl = null; + var reporter = context.Reporter; + + if (!context.FileSet.IsNetCoreApp31OrNewer) + { + // Browser refresh middleware supports 3.1 or newer + reporter.Verbose("Browser refresh is only supported in .NET Core 3.1 or newer projects."); + return false; + } + + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + // Launching a browser requires file associations that are not available in all operating systems. + reporter.Verbose("Browser refresh is only supported in Windows and MacOS."); + return false; + } + + var dotnetCommand = context.ProcessSpec.Arguments.FirstOrDefault(); + if (!string.Equals(dotnetCommand, "run", StringComparison.Ordinal)) + { + reporter.Verbose("Browser refresh is only supported for run commands."); + return false; + } + + // We're executing the run-command. Determine if the launchSettings allows it + var launchSettingsPath = Path.Combine(context.ProcessSpec.WorkingDirectory, "Properties", "launchSettings.json"); + if (!File.Exists(launchSettingsPath)) + { + reporter.Verbose($"No launchSettings.json file found at {launchSettingsPath}. Unable to determine if browser refresh is allowed."); + return false; + } + + LaunchSettingsJson launchSettings; + try + { + launchSettings = JsonSerializer.Deserialize( + File.ReadAllText(launchSettingsPath), + new JsonSerializerOptions(JsonSerializerDefaults.Web)); + } + catch (Exception ex) + { + reporter.Verbose($"Error reading launchSettings.json: {ex}."); + return false; + } + + var defaultProfile = launchSettings.Profiles.FirstOrDefault(f => f.Value.CommandName == "Project").Value; + if (defaultProfile is null) + { + reporter.Verbose("Unable to find default launchSettings profile."); + return false; + } + + if (!defaultProfile.LaunchBrowser) + { + reporter.Verbose("launchSettings does not allow launching browsers."); + return false; + } + + launchUrl = defaultProfile.LaunchUrl; + return true; + } + + public async ValueTask DisposeAsync() + { + _browserProcess?.Dispose(); + if (_refreshServer != null) + { + await _refreshServer.DisposeAsync(); + } + } + } +} diff --git a/src/Tools/dotnet-watch/src/LaunchSettingsJson.cs b/src/Tools/dotnet-watch/src/LaunchSettingsJson.cs new file mode 100644 index 000000000000..dc739b6c45b3 --- /dev/null +++ b/src/Tools/dotnet-watch/src/LaunchSettingsJson.cs @@ -0,0 +1,21 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.DotNet.Watcher.Tools +{ + public class LaunchSettingsJson + { + public Dictionary Profiles { get; set; } + } + + public class LaunchSettingsProfile + { + public string CommandName { get; set; } + + public bool LaunchBrowser { get; set; } + + public string LaunchUrl { get; set; } + } +} diff --git a/src/Tools/dotnet-watch/src/ProcessSpec.cs b/src/Tools/dotnet-watch/src/ProcessSpec.cs index ad5eb262b353..5e80a3f54bb3 100644 --- a/src/Tools/dotnet-watch/src/ProcessSpec.cs +++ b/src/Tools/dotnet-watch/src/ProcessSpec.cs @@ -1,8 +1,10 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Collections.Generic; +using System.Diagnostics; using System.IO; +using System.Threading; using Microsoft.DotNet.Watcher.Internal; namespace Microsoft.DotNet.Watcher @@ -19,5 +21,9 @@ public string ShortDisplayName() => Path.GetFileNameWithoutExtension(Executable); public bool IsOutputCaptured => OutputCapture != null; + + public DataReceivedEventHandler OnOutput { get; set; } + + public CancellationToken CancelOutputCapture { get; set; } } } diff --git a/src/Tools/dotnet-watch/src/Program.cs b/src/Tools/dotnet-watch/src/Program.cs index 8d0903bfc9c8..158f4adcf48f 100644 --- a/src/Tools/dotnet-watch/src/Program.cs +++ b/src/Tools/dotnet-watch/src/Program.cs @@ -162,8 +162,8 @@ private async Task MainInternalAsync( _reporter.Output("Polling file watcher is enabled"); } - await new DotNetWatcher(reporter, fileSetFactory) - .WatchAsync(processInfo, cancellationToken); + await using var watcher = new DotNetWatcher(reporter, fileSetFactory); + await watcher.WatchAsync(processInfo, cancellationToken); return 0; } diff --git a/src/Tools/dotnet-watch/src/assets/DotNetWatch.targets b/src/Tools/dotnet-watch/src/assets/DotNetWatch.targets index 5ce4f086726b..4126ed6ec432 100644 --- a/src/Tools/dotnet-watch/src/assets/DotNetWatch.targets +++ b/src/Tools/dotnet-watch/src/assets/DotNetWatch.targets @@ -9,9 +9,22 @@ them to a file. --> + + + <_IsMicrosoftNETCoreApp31OrNewer + Condition="'$(TargetFrameworkIdentifier)' == '.NETCoreApp' And $([MSBuild]::VersionGreaterThanOrEquals($(TargetFrameworkVersion), '3.1'))">true + + <_IsMicrosoftNETCoreApp31OrNewer Condition="'$(_IsMicrosoftNETCoreApp31OrNewer)' == ''">false + + + + <_WatchListLine Include="$(_IsMicrosoftNETCoreApp31OrNewer)" /> + <_WatchListLine Include="%(Watch.FullPath)" /> + + + Lines="@(_WatchListLine)" /> + + + + + + + + + + + + + + + <_ScopedCssExtension>.rz.scp.css + $(ResolveStaticWebAssetsInputsDependsOn);_CollectAllScopedCssAssets;AddScopedCssBundle + $(ResolveCurrentProjectStaticWebAssetsInputsDependsOn);_AddGeneratedScopedCssFiles + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_ScopedCssIntermediatePath>$([System.IO.Path]::GetFullPath($(IntermediateOutputPath)scopedcss\)) + + + + <_ScopedCss Condition="'%(_ScopedCss.Identity)' != ''"> + $(_ScopedCssIntermediatePath)%(RelativeDir)%(RecursiveDir)%(FileName)$(_ScopedCssExtension) + + <_ScopedCssOutputs Include="%(_ScopedCss.OutputFile)" /> + + + + + + + + + + + + + + + + + + + <_ScopedCssOutputPath>$(_ScopedCssIntermediatePath)_framework\scoped.styles.css + <_ScopedCssOutputFullPath>$([System.IO.Path]::Combine('$(MSBuildProjectFileDirectory)', '$(_ScopedCssIntermediatePath)_framework\scoped.styles.css')) + + + + + + + + $(PackageId) + $(_ScopedCssIntermediatePath) + $(StaticWebAssetBasePath) + _framework/scoped.styles.css + + <_ExternalStaticWebAsset Include="$(_ScopedCssOutputPath)" Condition="@(_AllScopedCss) != ''"> + generated + $(PackageId) + $(_ScopedCssIntermediatePath) + $(StaticWebAssetBasePath) + _framework/scoped.styles.css + + + + + + + + + + + + + + + + + _content/$(PackageId) + + + + + $(PackageId) + $(IntermediateOutputPath)scopedcss\ + $(StaticWebAssetBasePath) + $([MSBuild]::MakeRelative('$(_ScopedCssIntermediatePath)','%(_ScopedCss.OutputFile)')) + + + + + + + + + + + + + + + + + + + + <_ExternalPublishStaticWebAsset Include="$(_ScopedCssOutputFullPath)" ExcludeFromSingleFile="true"> + generated + $(PackageId) + $(_ScopedCssIntermediatePath) + $(StaticWebAssetBasePath) + PreserveNewest + $([MSBuild]::MakeRelative('$(MSBuildProjectDirectory)',$([MSBuild]::NormalizePath('wwwroot/$(StaticWebAssetBasePath)/_framework/scoped.styles.css')))) + + + + + + + <_CurrentProjectStaticWebAsset Remove="$(_ScopedCssOutputFullPath)" /> + + + + + + diff --git a/src/Razor/Microsoft.NET.Sdk.Razor/src/build/netstandard2.0/Sdk.Razor.CurrentVersion.props b/src/Razor/Microsoft.NET.Sdk.Razor/src/build/netstandard2.0/Sdk.Razor.CurrentVersion.props index 19e380c3a45f..3bc7a24d9eed 100644 --- a/src/Razor/Microsoft.NET.Sdk.Razor/src/build/netstandard2.0/Sdk.Razor.CurrentVersion.props +++ b/src/Razor/Microsoft.NET.Sdk.Razor/src/build/netstandard2.0/Sdk.Razor.CurrentVersion.props @@ -36,6 +36,11 @@ Copyright (c) .NET Foundation. All rights reserved. --> true + + true + $(_Targeting30OrNewerRazorLangVersion) + + + $(_Targeting30OrNewerRazorLangVersion) @@ -353,6 +356,8 @@ Copyright (c) .NET Foundation. All rights reserved. + + diff --git a/src/Razor/Microsoft.NET.Sdk.Razor/test/GenerateStaticWebAssetsPropsFileTest.cs b/src/Razor/Microsoft.NET.Sdk.Razor/test/GenerateStaticWebAssetsPropsFileTest.cs index 2525e156266f..48a70dca6144 100644 --- a/src/Razor/Microsoft.NET.Sdk.Razor/test/GenerateStaticWebAssetsPropsFileTest.cs +++ b/src/Razor/Microsoft.NET.Sdk.Razor/test/GenerateStaticWebAssetsPropsFileTest.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Collections.Generic; @@ -269,7 +269,7 @@ public void Fails_WhenStaticWebAsset_HaveDifferentSourceId() Assert.Equal(expectedError, message); } - [Fact] + [Fact(Skip = "https://github.com/dotnet/aspnetcore/issues/24257")] public void Fails_WhenStaticWebAsset_HaveDifferentContentRoot() { // Arrange diff --git a/src/Razor/test/testassets/ClassLibrary/Components/App.razor b/src/Razor/test/testassets/ClassLibrary/Components/App.razor new file mode 100644 index 000000000000..6ab209770e9e --- /dev/null +++ b/src/Razor/test/testassets/ClassLibrary/Components/App.razor @@ -0,0 +1 @@ +

Hello from razor

diff --git a/src/Razor/test/testassets/ClassLibrary/Components/App.razor.css b/src/Razor/test/testassets/ClassLibrary/Components/App.razor.css new file mode 100644 index 000000000000..8bf950df47ef --- /dev/null +++ b/src/Razor/test/testassets/ClassLibrary/Components/App.razor.css @@ -0,0 +1,3 @@ +p { + font-size: bold; +} diff --git a/src/Razor/test/testassets/ComponentApp/Components/Pages/Counter.razor.css b/src/Razor/test/testassets/ComponentApp/Components/Pages/Counter.razor.css new file mode 100644 index 000000000000..6fd5c7d6dfb3 --- /dev/null +++ b/src/Razor/test/testassets/ComponentApp/Components/Pages/Counter.razor.css @@ -0,0 +1,3 @@ +button { + font-size: 16px; +} diff --git a/src/Razor/test/testassets/ComponentApp/Components/Pages/Index.razor.css b/src/Razor/test/testassets/ComponentApp/Components/Pages/Index.razor.css new file mode 100644 index 000000000000..ae982b6049ac --- /dev/null +++ b/src/Razor/test/testassets/ComponentApp/Components/Pages/Index.razor.css @@ -0,0 +1,3 @@ +h1 { + font-weight: bold; +} diff --git a/src/Razor/test/testassets/PackageLibraryDirectDependency/Components/App.razor b/src/Razor/test/testassets/PackageLibraryDirectDependency/Components/App.razor new file mode 100644 index 000000000000..6ab209770e9e --- /dev/null +++ b/src/Razor/test/testassets/PackageLibraryDirectDependency/Components/App.razor @@ -0,0 +1 @@ +

Hello from razor

diff --git a/src/Razor/test/testassets/PackageLibraryDirectDependency/Components/App.razor.css b/src/Razor/test/testassets/PackageLibraryDirectDependency/Components/App.razor.css new file mode 100644 index 000000000000..8bf950df47ef --- /dev/null +++ b/src/Razor/test/testassets/PackageLibraryDirectDependency/Components/App.razor.css @@ -0,0 +1,3 @@ +p { + font-size: bold; +} From 088a6fa1ec2750640fafbc9352b3aca256e6eb9c Mon Sep 17 00:00:00 2001 From: Pranav K Date: Fri, 24 Jul 2020 08:45:06 -0700 Subject: [PATCH 5/5] Add support for binding record types (#23976) * Add support for binding record types * PR feedback * PR changes * Changes per PR comments * Changes per PR comments * Update src/Mvc/Mvc.Core/src/Resources.resx * Apply suggestions from code review * Add some more tests * Undo blazor.server.js changes * Fixup test --- .../ModelBinding/IPropertyFilterProvider.cs | 7 +- .../Metadata/ModelBindingMessageProvider.cs | 4 +- .../Metadata/ModelMetadataIdentity.cs | 40 +- .../Metadata/ModelMetadataKind.cs | 7 +- .../src/ModelBinding/ModelMetadata.cs | 97 +- .../src/ModelBinding/ModelMetadataProvider.cs | 13 +- .../src/ModelBinding/ModelStateDictionary.cs | 1 + .../test/ModelBinding/BindingInfoTest.cs | 14 +- .../test/ModelBinding/ModelMetadataTest.cs | 6 + ...lBindingAttributeHasSameNameAsParameter.cs | 2 +- src/Mvc/Mvc.Core/src/BindAttribute.cs | 17 +- .../Infrastructure/MvcCoreMvcOptionsSetup.cs | 2 +- .../Infrastructure/ParameterDefaultValues.cs | 2 +- .../Binders/ComplexObjectModelBinder.cs | 752 +++ .../ComplexObjectModelBinderProvider.cs | 64 + .../Binders/ComplexTypeModelBinder.cs | 1 + .../Binders/ComplexTypeModelBinderProvider.cs | 3 +- .../ModelBinding/Metadata/BindingMetadata.cs | 6 + .../DefaultBindingMetadataProvider.cs | 74 + .../Metadata/DefaultMetadataDetails.cs | 12 +- .../Metadata/DefaultModelMetadata.cs | 38 +- .../Metadata/DefaultModelMetadataProvider.cs | 106 +- .../DefaultValidationMetadataProvider.cs | 5 + .../ModelBinding/Metadata/ModelAttributes.cs | 10 + .../src/ModelBinding/ParameterBinder.cs | 2 +- .../DefaultComplexObjectValidationStrategy.cs | 89 +- .../Validation/ValidateNeverAttribute.cs | 11 +- src/Mvc/Mvc.Core/src/Resources.resx | 12 + .../DefaultApplicationModelProviderTest.cs | 4 +- ...InferParameterBindingInfoConventionTest.cs | 2 +- .../Microsoft.AspNetCore.Mvc.Core.Test.csproj | 3 +- .../ComplexObjectModelBinderProviderTest.cs | 92 + .../Binders/ComplexObjectModelBinderTest.cs | 1412 ++++++ .../ComplexTypeModelBinderProviderTest.cs | 4 +- .../Binders/ComplexTypeModelBinderTest.cs | 5 +- .../Binders/DictionaryModelBinderTest.cs | 5 +- .../DefaultBindingMetadataProviderTest.cs | 194 +- .../Metadata/DefaultModelMetadataTest.cs | 357 +- .../ModelBinding/ModelBindingHelperTest.cs | 14 +- .../test/ModelMetadataProviderTest.cs | 8 +- src/Mvc/Mvc/test/MvcOptionsSetupTest.cs | 2 +- .../Mvc.FunctionalTests/HtmlGenerationTest.cs | 34 + .../JsonInputFormatterTestBase.cs | 66 + .../SystemTextJsonInputFormatterTest.cs | 19 +- .../ActionParametersIntegrationTest.cs | 216 +- .../ComplexObjectIntegrationTest.cs | 13 + .../ComplexRecordIntegrationTest.cs | 4269 +++++++++++++++++ .../ComplexTypeIntegrationTestBase.cs | 3774 +++++++++++++++ .../ComplexTypeModelBinderIntegrationTest.cs | 3743 +-------------- ...oft.AspNetCore.Mvc.IntegrationTests.csproj | 1 + .../TryUpdateModelIntegrationTest.cs | 175 +- .../ValidationWithRecordIntegrationTests.cs | 2307 +++++++++ .../Controllers/JsonFormatterController.cs | 29 +- .../FormatterWebSite/FormatterWebSite.csproj | 3 +- .../Models/RecursiveIdentifier.cs | 6 +- .../HtmlGeneration_CustomerController.cs | 5 + .../HtmlGenerationWebSite.csproj | 1 + .../Models/CustomerRecord.cs | 29 + .../Views/Shared/CustomerWithRecords.cshtml | 46 + 59 files changed, 14347 insertions(+), 3888 deletions(-) create mode 100644 src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexObjectModelBinder.cs create mode 100644 src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexObjectModelBinderProvider.cs create mode 100644 src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexObjectModelBinderProviderTest.cs create mode 100644 src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexObjectModelBinderTest.cs create mode 100644 src/Mvc/test/Mvc.IntegrationTests/ComplexObjectIntegrationTest.cs create mode 100644 src/Mvc/test/Mvc.IntegrationTests/ComplexRecordIntegrationTest.cs create mode 100644 src/Mvc/test/Mvc.IntegrationTests/ComplexTypeIntegrationTestBase.cs create mode 100644 src/Mvc/test/Mvc.IntegrationTests/ValidationWithRecordIntegrationTests.cs create mode 100644 src/Mvc/test/WebSites/HtmlGenerationWebSite/Models/CustomerRecord.cs create mode 100644 src/Mvc/test/WebSites/HtmlGenerationWebSite/Views/Shared/CustomerWithRecords.cshtml diff --git a/src/Mvc/Mvc.Abstractions/src/ModelBinding/IPropertyFilterProvider.cs b/src/Mvc/Mvc.Abstractions/src/ModelBinding/IPropertyFilterProvider.cs index 76ddd463ccd6..0c364c5cc266 100644 --- a/src/Mvc/Mvc.Abstractions/src/ModelBinding/IPropertyFilterProvider.cs +++ b/src/Mvc/Mvc.Abstractions/src/ModelBinding/IPropertyFilterProvider.cs @@ -6,12 +6,17 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding { /// - /// Provides a predicate which can determines which model properties should be bound by model binding. + /// Provides a predicate which can determines which model properties or parameters should be bound by model binding. /// public interface IPropertyFilterProvider { /// + /// /// Gets a predicate which can determines which model properties should be bound by model binding. + /// + /// + /// This predicate is also used to determine which parameters are bound when a model's constructor is bound. + /// /// Func PropertyFilter { get; } } diff --git a/src/Mvc/Mvc.Abstractions/src/ModelBinding/Metadata/ModelBindingMessageProvider.cs b/src/Mvc/Mvc.Abstractions/src/ModelBinding/Metadata/ModelBindingMessageProvider.cs index 7992450f3723..fa9f3a4ff7ca 100644 --- a/src/Mvc/Mvc.Abstractions/src/ModelBinding/Metadata/ModelBindingMessageProvider.cs +++ b/src/Mvc/Mvc.Abstractions/src/ModelBinding/Metadata/ModelBindingMessageProvider.cs @@ -51,7 +51,7 @@ public abstract class ModelBindingMessageProvider /// /// Error message the model binding system adds when is of type /// or , value is known, and error is associated - /// with a collection element or action parameter. + /// with a collection element or parameter. /// /// Default is "The value '{0}' is not valid.". public virtual Func NonPropertyAttemptedValueIsInvalidAccessor { get; } = default!; @@ -67,7 +67,7 @@ public abstract class ModelBindingMessageProvider /// /// Error message the model binding system adds when is of type /// or , value is unknown, and error is associated - /// with a collection element or action parameter. + /// with a collection element or parameter. /// /// Default is "The supplied value is invalid.". public virtual Func NonPropertyUnknownValueIsInvalidAccessor { get; } = default!; diff --git a/src/Mvc/Mvc.Abstractions/src/ModelBinding/Metadata/ModelMetadataIdentity.cs b/src/Mvc/Mvc.Abstractions/src/ModelBinding/Metadata/ModelMetadataIdentity.cs index 5fff9bad3da6..b170496a5bcc 100644 --- a/src/Mvc/Mvc.Abstractions/src/ModelBinding/Metadata/ModelMetadataIdentity.cs +++ b/src/Mvc/Mvc.Abstractions/src/ModelBinding/Metadata/ModelMetadataIdentity.cs @@ -16,12 +16,14 @@ private ModelMetadataIdentity( Type modelType, string? name = null, Type? containerType = null, - object? fieldInfo = null) + object? fieldInfo = null, + ConstructorInfo? constructorInfo = null) { ModelType = modelType; Name = name; ContainerType = containerType; FieldInfo = fieldInfo; + ConstructorInfo = constructorInfo; } /// @@ -130,6 +132,28 @@ public static ModelMetadataIdentity ForParameter(ParameterInfo parameter, Type m return new ModelMetadataIdentity(modelType, parameter.Name, fieldInfo: parameter); } + /// + /// Creates a for the provided parameter with the specified + /// model type. + /// + /// The . + /// The model type. + /// A . + public static ModelMetadataIdentity ForConstructor(ConstructorInfo constructor, Type modelType) + { + if (constructor == null) + { + throw new ArgumentNullException(nameof(constructor)); + } + + if (modelType == null) + { + throw new ArgumentNullException(nameof(modelType)); + } + + return new ModelMetadataIdentity(modelType, constructor.Name, constructorInfo: constructor); + } + /// /// Gets the defining the model property represented by the current /// instance, or null if the current instance does not represent a property. @@ -152,6 +176,10 @@ public ModelMetadataKind MetadataKind { return ModelMetadataKind.Parameter; } + else if (ConstructorInfo != null) + { + return ModelMetadataKind.Constructor; + } else if (ContainerType != null && Name != null) { return ModelMetadataKind.Property; @@ -183,6 +211,12 @@ public ModelMetadataKind MetadataKind /// public PropertyInfo? PropertyInfo => FieldInfo as PropertyInfo; + /// + /// Gets a descriptor for the constructor, or null if this instance + /// does not represent a constructor. + /// + public ConstructorInfo? ConstructorInfo { get; } + /// public bool Equals(ModelMetadataIdentity other) { @@ -191,7 +225,8 @@ public bool Equals(ModelMetadataIdentity other) ModelType == other.ModelType && Name == other.Name && ParameterInfo == other.ParameterInfo && - PropertyInfo == other.PropertyInfo; + PropertyInfo == other.PropertyInfo && + ConstructorInfo == other.ConstructorInfo; } /// @@ -210,6 +245,7 @@ public override int GetHashCode() hash.Add(Name, StringComparer.Ordinal); hash.Add(ParameterInfo); hash.Add(PropertyInfo); + hash.Add(ConstructorInfo); return hash.ToHashCode(); } } diff --git a/src/Mvc/Mvc.Abstractions/src/ModelBinding/Metadata/ModelMetadataKind.cs b/src/Mvc/Mvc.Abstractions/src/ModelBinding/Metadata/ModelMetadataKind.cs index 4756f85226b9..b964992d1afe 100644 --- a/src/Mvc/Mvc.Abstractions/src/ModelBinding/Metadata/ModelMetadataKind.cs +++ b/src/Mvc/Mvc.Abstractions/src/ModelBinding/Metadata/ModelMetadataKind.cs @@ -22,5 +22,10 @@ public enum ModelMetadataKind /// Used for for a parameter. /// Parameter, + + /// + /// for a constructor. + /// + Constructor, } -} \ No newline at end of file +} diff --git a/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelMetadata.cs b/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelMetadata.cs index f04cac166ef9..4ce3e2afbbb4 100644 --- a/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelMetadata.cs +++ b/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelMetadata.cs @@ -4,8 +4,10 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Collections.ObjectModel; using System.ComponentModel; using System.Diagnostics; +using System.Linq; using System.Reflection; using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; @@ -24,7 +26,11 @@ public abstract class ModelMetadata : IEquatable, IModelMetadata ///
public static readonly int DefaultOrder = 10000; + private static readonly IReadOnlyDictionary EmptyParameterMapping = new Dictionary(0); + private int? _hashCode; + private IReadOnlyList? _boundProperties; + private IReadOnlyDictionary? _parameterMapping; /// /// Creates a new . @@ -83,7 +89,7 @@ public virtual ModelMetadata ContainerMetadata /// /// Gets the key for the current instance. /// - protected ModelMetadataIdentity Identity { get; } + protected internal ModelMetadataIdentity Identity { get; } /// /// Gets a collection of additional information about the model. @@ -95,6 +101,88 @@ public virtual ModelMetadata ContainerMetadata /// public abstract ModelPropertyCollection Properties { get; } + internal IReadOnlyList BoundProperties + { + get + { + // In record types, each constructor parameter in the primary constructor is also a settable property with the same name. + // Executing model binding on these parameters twice may have detrimental effects, such as duplicate ModelState entries, + // or failures if a model expects to be bound exactly ones. + // Consequently when binding to a constructor, we only bind and validate the subset of properties whose names + // haven't appeared as parameters. + if (BoundConstructor is null) + { + return Properties; + } + + if (_boundProperties is null) + { + var boundParameters = BoundConstructor.BoundConstructorParameters!; + var boundProperties = new List(); + + foreach (var metadata in Properties) + { + if (!boundParameters.Any(p => + string.Equals(p.ParameterName, metadata.PropertyName, StringComparison.Ordinal) + && p.ModelType == metadata.ModelType)) + { + boundProperties.Add(metadata); + } + } + + _boundProperties = boundProperties; + } + + return _boundProperties; + } + } + + internal IReadOnlyDictionary BoundConstructorParameterMapping + { + get + { + if (_parameterMapping != null) + { + return _parameterMapping; + } + + if (BoundConstructor is null) + { + _parameterMapping = EmptyParameterMapping; + return _parameterMapping; + } + + var boundParameters = BoundConstructor.BoundConstructorParameters!; + var parameterMapping = new Dictionary(); + + foreach (var parameter in boundParameters) + { + var property = Properties.FirstOrDefault(p => + string.Equals(p.Name, parameter.ParameterName, StringComparison.Ordinal) && + p.ModelType == parameter.ModelType); + + if (property != null) + { + parameterMapping[parameter] = property; + } + } + + _parameterMapping = parameterMapping; + return _parameterMapping; + } + } + + /// + /// Gets instance for a constructor of a record type that is used during binding and validation. + /// + public virtual ModelMetadata? BoundConstructor { get; } + + /// + /// Gets the collection of instances for parameters on a . + /// This is only available when is . + /// + public virtual IReadOnlyList? BoundConstructorParameters { get; } + /// /// Gets the name of a model if specified explicitly using . /// @@ -401,6 +489,11 @@ public virtual ModelMetadata ContainerMetadata /// public abstract Action PropertySetter { get; } + /// + /// Gets a delegate that invokes the bound constructor if non-. + /// + public virtual Func? BoundConstructorInvoker => null; + /// /// Gets a display name for the model. /// @@ -500,6 +593,8 @@ private string DebuggerToString() return $"ModelMetadata (Property: '{ContainerType!.Name}.{PropertyName}' Type: '{ModelType.Name}')"; case ModelMetadataKind.Type: return $"ModelMetadata (Type: '{ModelType.Name}')"; + case ModelMetadataKind.Constructor: + return $"ModelMetadata (Constructor: '{ModelType.Name}')"; default: return $"Unsupported MetadataKind '{MetadataKind}'."; } diff --git a/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelMetadataProvider.cs b/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelMetadataProvider.cs index 147ccc45f56e..7e22b3cbb69e 100644 --- a/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelMetadataProvider.cs +++ b/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelMetadataProvider.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; @@ -54,5 +54,16 @@ public virtual ModelMetadata GetMetadataForProperty(PropertyInfo propertyInfo, T { throw new NotSupportedException(); } + + /// + /// Supplies metadata describing a constructor. + /// + /// The . + /// The type declaring the constructor. + /// A instance describing the . + public virtual ModelMetadata GetMetadataForConstructor(ConstructorInfo constructor, Type modelType) + { + throw new NotSupportedException(); + } } } diff --git a/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelStateDictionary.cs b/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelStateDictionary.cs index 24581e05102b..61ef369e37ce 100644 --- a/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelStateDictionary.cs +++ b/src/Mvc/Mvc.Abstractions/src/ModelBinding/ModelStateDictionary.cs @@ -298,6 +298,7 @@ public bool TryAddModelError(string key, Exception exception, ModelMetadata meta // "The value '' is not valid." (when no value was provided, not even an empty string) and // "The supplied value is invalid for Int32." (when error is for an element or parameter). var messageProvider = metadata.ModelBindingMessageProvider; + var name = metadata.DisplayName ?? metadata.PropertyName; string errorMessage; if (entry == null && name == null) diff --git a/src/Mvc/Mvc.Abstractions/test/ModelBinding/BindingInfoTest.cs b/src/Mvc/Mvc.Abstractions/test/ModelBinding/BindingInfoTest.cs index 5c6cfca5fd2a..8d0d0c39dd75 100644 --- a/src/Mvc/Mvc.Abstractions/test/ModelBinding/BindingInfoTest.cs +++ b/src/Mvc/Mvc.Abstractions/test/ModelBinding/BindingInfoTest.cs @@ -83,7 +83,7 @@ public void GetBindingInfo_WithAttributesAndModelMetadata_UsesValuesFromBindingI // Arrange var attributes = new object[] { - new ModelBinderAttribute { BinderType = typeof(ComplexTypeModelBinder), Name = "Test" }, + new ModelBinderAttribute { BinderType = typeof(ComplexObjectModelBinder), Name = "Test" }, }; var modelType = typeof(Guid); var provider = new TestModelMetadataProvider(); @@ -100,7 +100,7 @@ public void GetBindingInfo_WithAttributesAndModelMetadata_UsesValuesFromBindingI // Assert Assert.NotNull(bindingInfo); - Assert.Same(typeof(ComplexTypeModelBinder), bindingInfo.BinderType); + Assert.Same(typeof(ComplexObjectModelBinder), bindingInfo.BinderType); Assert.Same("Test", bindingInfo.BinderModelName); } @@ -110,7 +110,7 @@ public void GetBindingInfo_WithAttributesAndModelMetadata_UsesBinderNameFromMode // Arrange var attributes = new object[] { - new ModelBinderAttribute(typeof(ComplexTypeModelBinder)), + new ModelBinderAttribute(typeof(ComplexObjectModelBinder)), new ControllerAttribute(), new BindNeverAttribute(), }; @@ -129,7 +129,7 @@ public void GetBindingInfo_WithAttributesAndModelMetadata_UsesBinderNameFromMode // Assert Assert.NotNull(bindingInfo); - Assert.Same(typeof(ComplexTypeModelBinder), bindingInfo.BinderType); + Assert.Same(typeof(ComplexObjectModelBinder), bindingInfo.BinderType); Assert.Same("Different", bindingInfo.BinderModelName); Assert.Same(BindingSource.Custom, bindingInfo.BindingSource); } @@ -143,7 +143,7 @@ public void GetBindingInfo_WithAttributesAndModelMetadata_UsesModelBinderFromMod var provider = new TestModelMetadataProvider(); provider.ForType(modelType).BindingDetails(metadata => { - metadata.BinderType = typeof(ComplexTypeModelBinder); + metadata.BinderType = typeof(ComplexObjectModelBinder); }); var modelMetadata = provider.GetMetadataForType(modelType); @@ -152,7 +152,7 @@ public void GetBindingInfo_WithAttributesAndModelMetadata_UsesModelBinderFromMod // Assert Assert.NotNull(bindingInfo); - Assert.Same(typeof(ComplexTypeModelBinder), bindingInfo.BinderType); + Assert.Same(typeof(ComplexObjectModelBinder), bindingInfo.BinderType); } [Fact] @@ -187,7 +187,7 @@ public void GetBindingInfo_WithAttributesAndModelMetadata_UsesPropertyPredicateP // Arrange var attributes = new object[] { - new ModelBinderAttribute(typeof(ComplexTypeModelBinder)), + new ModelBinderAttribute(typeof(ComplexObjectModelBinder)), new ControllerAttribute(), new BindNeverAttribute(), }; diff --git a/src/Mvc/Mvc.Abstractions/test/ModelBinding/ModelMetadataTest.cs b/src/Mvc/Mvc.Abstractions/test/ModelBinding/ModelMetadataTest.cs index 2f71dffe3263..f9e24ea41546 100644 --- a/src/Mvc/Mvc.Abstractions/test/ModelBinding/ModelMetadataTest.cs +++ b/src/Mvc/Mvc.Abstractions/test/ModelBinding/ModelMetadataTest.cs @@ -728,6 +728,12 @@ public override Action PropertySetter throw new NotImplementedException(); } } + + public override ModelMetadata BoundConstructor => throw new NotImplementedException(); + + public override Func BoundConstructorInvoker => throw new NotImplementedException(); + + public override IReadOnlyList BoundConstructorParameters => throw new NotImplementedException(); } private class CollectionImplementation : ICollection diff --git a/src/Mvc/Mvc.Analyzers/test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsTrue_IfPropertyWithModelBindingAttributeHasSameNameAsParameter.cs b/src/Mvc/Mvc.Analyzers/test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsTrue_IfPropertyWithModelBindingAttributeHasSameNameAsParameter.cs index 37d463012b25..5f2f1c5ecd96 100644 --- a/src/Mvc/Mvc.Analyzers/test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsTrue_IfPropertyWithModelBindingAttributeHasSameNameAsParameter.cs +++ b/src/Mvc/Mvc.Analyzers/test/TestFiles/TopLevelParameterNameAnalyzerTest/IsProblematicParameter_ReturnsTrue_IfPropertyWithModelBindingAttributeHasSameNameAsParameter.cs @@ -4,7 +4,7 @@ namespace Microsoft.AspNetCore.Mvc.Analyzers.TopLevelParameterNameAnalyzerTestFi { public class IsProblematicParameter_ReturnsTrue_IfPropertyWithModelBindingAttributeHasSameNameAsParameter { - [ModelBinder(typeof(ComplexTypeModelBinder), Name = "model")] + [ModelBinder(typeof(ComplexObjectModelBinder), Name = "model")] public string Different { get; set; } public void ActionMethod( diff --git a/src/Mvc/Mvc.Core/src/BindAttribute.cs b/src/Mvc/Mvc.Core/src/BindAttribute.cs index 409237e40efd..691c81ac7cac 100644 --- a/src/Mvc/Mvc.Core/src/BindAttribute.cs +++ b/src/Mvc/Mvc.Core/src/BindAttribute.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; namespace Microsoft.AspNetCore.Mvc { @@ -56,17 +57,23 @@ public Func PropertyFilter { if (Include != null && Include.Length > 0) { - if (_propertyFilter == null) - { - _propertyFilter = (m) => Include.Contains(m.PropertyName, StringComparer.Ordinal); - } - + _propertyFilter ??= PropertyFilter; return _propertyFilter; } else { return _default; } + + bool PropertyFilter(ModelMetadata modelMetadata) + { + if (modelMetadata.MetadataKind == ModelMetadataKind.Parameter) + { + return Include.Contains(modelMetadata.ParameterName, StringComparer.Ordinal); + } + + return Include.Contains(modelMetadata.PropertyName, StringComparer.Ordinal); + } } } diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/MvcCoreMvcOptionsSetup.cs b/src/Mvc/Mvc.Core/src/Infrastructure/MvcCoreMvcOptionsSetup.cs index a2c0dbdcaebb..a2b94da1188e 100644 --- a/src/Mvc/Mvc.Core/src/Infrastructure/MvcCoreMvcOptionsSetup.cs +++ b/src/Mvc/Mvc.Core/src/Infrastructure/MvcCoreMvcOptionsSetup.cs @@ -72,7 +72,7 @@ public void Configure(MvcOptions options) options.ModelBinderProviders.Add(new DictionaryModelBinderProvider()); options.ModelBinderProviders.Add(new ArrayModelBinderProvider()); options.ModelBinderProviders.Add(new CollectionModelBinderProvider()); - options.ModelBinderProviders.Add(new ComplexTypeModelBinderProvider()); + options.ModelBinderProviders.Add(new ComplexObjectModelBinderProvider()); // Set up filters options.Filters.Add(new UnsupportedContentTypeFilter()); diff --git a/src/Mvc/Mvc.Core/src/Infrastructure/ParameterDefaultValues.cs b/src/Mvc/Mvc.Core/src/Infrastructure/ParameterDefaultValues.cs index b7259fc9b793..f0bfc817e1cb 100644 --- a/src/Mvc/Mvc.Core/src/Infrastructure/ParameterDefaultValues.cs +++ b/src/Mvc/Mvc.Core/src/Infrastructure/ParameterDefaultValues.cs @@ -10,7 +10,7 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure { internal static class ParameterDefaultValues { - public static object[] GetParameterDefaultValues(MethodInfo methodInfo) + public static object[] GetParameterDefaultValues(MethodBase methodInfo) { if (methodInfo == null) { diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexObjectModelBinder.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexObjectModelBinder.cs new file mode 100644 index 000000000000..2bd38a4baeff --- /dev/null +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexObjectModelBinder.cs @@ -0,0 +1,752 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq.Expressions; +using System.Reflection; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Core; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders +{ + /// + /// implementation for binding complex types. + /// + public sealed class ComplexObjectModelBinder : IModelBinder + { + // Don't want a new public enum because communication between the private and internal methods of this class + // should not be exposed. Can't use an internal enum because types of [TheoryData] values must be public. + + // Model contains only properties that are expected to bind from value providers and no value provider has + // matching data. + internal const int NoDataAvailable = 0; + // If model contains properties that are expected to bind from value providers, no value provider has matching + // data. Remaining (greedy) properties might bind successfully. + internal const int GreedyPropertiesMayHaveData = 1; + // Model contains at least one property that is expected to bind from value providers and a value provider has + // matching data. + internal const int ValueProviderDataAvailable = 2; + + private readonly IDictionary _propertyBinders; + private readonly IReadOnlyList _parameterBinders; + private readonly ILogger _logger; + private Func _modelCreator; + + internal ComplexObjectModelBinder( + IDictionary propertyBinders, + IReadOnlyList parameterBinders, + ILogger logger) + { + _propertyBinders = propertyBinders; + _parameterBinders = parameterBinders; + _logger = logger; + } + + public Task BindModelAsync(ModelBindingContext bindingContext) + { + if (bindingContext == null) + { + throw new ArgumentNullException(nameof(bindingContext)); + } + + _logger.AttemptingToBindModel(bindingContext); + + var parameterData = CanCreateModel(bindingContext); + if (parameterData == NoDataAvailable) + { + return Task.CompletedTask; + } + + // Perf: separated to avoid allocating a state machine when we don't + // need to go async. + return BindModelCoreAsync(bindingContext, parameterData); + } + + private async Task BindModelCoreAsync(ModelBindingContext bindingContext, int propertyData) + { + Debug.Assert(propertyData == GreedyPropertiesMayHaveData || propertyData == ValueProviderDataAvailable); + + // Create model first (if necessary) to avoid reporting errors about properties when activation fails. + var attemptedBinding = false; + var bindingSucceeded = false; + + var modelMetadata = bindingContext.ModelMetadata; + + if (bindingContext.Model == null) + { + var boundConstructor = modelMetadata.BoundConstructor; + if (boundConstructor != null) + { + var values = new object[boundConstructor.BoundConstructorParameters.Count]; + var (attemptedParameterBinding, parameterBindingSucceeded) = await BindParametersAsync( + bindingContext, + propertyData, + boundConstructor.BoundConstructorParameters, + values); + + attemptedBinding |= attemptedParameterBinding; + bindingSucceeded |= parameterBindingSucceeded; + + if (!CreateModel(bindingContext, boundConstructor, values)) + { + return; + } + } + else + { + CreateModel(bindingContext); + } + } + + var (attemptedPropertyBinding, propertyBindingSucceeded) = await BindPropertiesAsync( + bindingContext, + propertyData, + modelMetadata.BoundProperties); + + attemptedBinding |= attemptedPropertyBinding; + bindingSucceeded |= propertyBindingSucceeded; + + // Have we created a top-level model despite an inability to bind anything in said model and a lack of + // other IsBindingRequired errors? Does that violate [BindRequired] on the model? This case occurs when + // 1. The top-level model has no public settable properties. + // 2. All properties in a [BindRequired] model have [BindNever] or are otherwise excluded from binding. + // 3. No data exists for any property. + if (!attemptedBinding && + bindingContext.IsTopLevelObject && + modelMetadata.IsBindingRequired) + { + var messageProvider = modelMetadata.ModelBindingMessageProvider; + var message = messageProvider.MissingBindRequiredValueAccessor(bindingContext.FieldName); + bindingContext.ModelState.TryAddModelError(bindingContext.ModelName, message); + } + + _logger.DoneAttemptingToBindModel(bindingContext); + + // Have all binders failed because no data was available? + // + // If CanCreateModel determined a property has data, failures are likely due to conversion errors. For + // example, user may submit ?[0].id=twenty&[1].id=twenty-one&[2].id=22 for a collection of a complex type + // with an int id property. In that case, the bound model should be [ {}, {}, { id = 22 }] and + // ModelState should contain errors about both [0].id and [1].id. Do not inform higher-level binders of the + // failure in this and similar cases. + // + // If CanCreateModel could not find data for non-greedy properties, failures indicate greedy binders were + // unsuccessful. For example, user may submit file attachments [0].File and [1].File but not [2].File for + // a collection of a complex type containing an IFormFile property. In that case, we have exhausted the + // attached files and checking for [3].File is likely be pointless. (And, if it had a point, would we stop + // after 10 failures, 100, or more -- all adding redundant errors to ModelState?) Inform higher-level + // binders of the failure. + // + // Required properties do not change the logic below. Missed required properties cause ModelState errors + // but do not necessarily prevent further attempts to bind. + // + // This logic is intended to maximize correctness but does not avoid infinite loops or recursion when a + // greedy model binder succeeds unconditionally. + if (!bindingContext.IsTopLevelObject && + !bindingSucceeded && + propertyData == GreedyPropertiesMayHaveData) + { + bindingContext.Result = ModelBindingResult.Failed(); + return; + } + + bindingContext.Result = ModelBindingResult.Success(bindingContext.Model); + } + + internal static bool CreateModel(ModelBindingContext bindingContext, ModelMetadata boundConstructor, object[] values) + { + try + { + bindingContext.Model = boundConstructor.BoundConstructorInvoker(values); + return true; + } + catch (Exception ex) + { + AddModelError(ex, bindingContext.ModelName, bindingContext); + bindingContext.Result = ModelBindingResult.Failed(); + return false; + } + } + + /// + /// Creates suitable for given . + /// + /// The . + /// An compatible with . + internal void CreateModel(ModelBindingContext bindingContext) + { + if (bindingContext == null) + { + throw new ArgumentNullException(nameof(bindingContext)); + } + + // If model creator throws an exception, we want to propagate it back up the call stack, since the + // application developer should know that this was an invalid type to try to bind to. + if (_modelCreator == null) + { + // The following check causes the ComplexTypeModelBinder to NOT participate in binding structs as + // reflection does not provide information about the implicit parameterless constructor for a struct. + // This binder would eventually fail to construct an instance of the struct as the Linq's NewExpression + // compile fails to construct it. + var modelTypeInfo = bindingContext.ModelType.GetTypeInfo(); + if (modelTypeInfo.IsAbstract || modelTypeInfo.GetConstructor(Type.EmptyTypes) == null) + { + var metadata = bindingContext.ModelMetadata; + switch (metadata.MetadataKind) + { + case ModelMetadataKind.Parameter: + throw new InvalidOperationException( + Resources.FormatComplexObjectModelBinder_NoSuitableConstructor_ForParameter( + modelTypeInfo.FullName, + metadata.ParameterName)); + case ModelMetadataKind.Property: + throw new InvalidOperationException( + Resources.FormatComplexObjectModelBinder_NoSuitableConstructor_ForProperty( + modelTypeInfo.FullName, + metadata.PropertyName, + bindingContext.ModelMetadata.ContainerType.FullName)); + case ModelMetadataKind.Type: + throw new InvalidOperationException( + Resources.FormatComplexObjectModelBinder_NoSuitableConstructor_ForType( + modelTypeInfo.FullName)); + } + } + + _modelCreator = Expression + .Lambda>(Expression.New(bindingContext.ModelType)) + .Compile(); + } + + bindingContext.Model = _modelCreator(); + } + + private async ValueTask<(bool attemptedBinding, bool bindingSucceeded)> BindParametersAsync( + ModelBindingContext bindingContext, + int propertyData, + IReadOnlyList parameters, + object[] parameterValues) + { + var attemptedBinding = false; + var bindingSucceeded = false; + + if (parameters.Count == 0) + { + return (attemptedBinding, bindingSucceeded); + } + + var postponePlaceholderBinding = false; + for (var i = 0; i < parameters.Count; i++) + { + var parameter = parameters[i]; + + var fieldName = parameter.BinderModelName ?? parameter.ParameterName; + var modelName = ModelNames.CreatePropertyModelName(bindingContext.ModelName, fieldName); + + if (!CanBindItem(bindingContext, parameter)) + { + continue; + } + + var parameterBinder = _parameterBinders[i]; + if (parameterBinder is PlaceholderBinder) + { + if (postponePlaceholderBinding) + { + // Decided to postpone binding properties that complete a loop in the model types when handling + // an earlier loop-completing property. Postpone binding this property too. + continue; + } + else if (!bindingContext.IsTopLevelObject && + !bindingSucceeded && + propertyData == GreedyPropertiesMayHaveData) + { + // Have no confirmation of data for the current instance. Postpone completing the loop until + // we _know_ the current instance is useful. Recursion would otherwise occur prior to the + // block with a similar condition after the loop. + // + // Example cases include an Employee class containing + // 1. a Manager property of type Employee + // 2. an Employees property of type IList + postponePlaceholderBinding = true; + continue; + } + } + + var result = await BindParameterAsync(bindingContext, parameter, parameterBinder, fieldName, modelName); + + if (result.IsModelSet) + { + attemptedBinding = true; + bindingSucceeded = true; + + parameterValues[i] = result.Model; + } + else if (parameter.IsBindingRequired) + { + attemptedBinding = true; + } + } + + if (postponePlaceholderBinding && bindingSucceeded) + { + // Have some data for this instance. Continue with the model type loop. + for (var i = 0; i < parameters.Count; i++) + { + var parameter = parameters[i]; + if (!CanBindItem(bindingContext, parameter)) + { + continue; + } + + var parameterBinder = _parameterBinders[i]; + if (parameterBinder is PlaceholderBinder) + { + var fieldName = parameter.BinderModelName ?? parameter.ParameterName; + var modelName = ModelNames.CreatePropertyModelName(bindingContext.ModelName, fieldName); + + var result = await BindParameterAsync(bindingContext, parameter, parameterBinder, fieldName, modelName); + + if (result.IsModelSet) + { + parameterValues[i] = result.Model; + } + } + } + } + + return (attemptedBinding, bindingSucceeded); + } + + private async ValueTask<(bool attemptedBinding, bool bindingSucceeded)> BindPropertiesAsync( + ModelBindingContext bindingContext, + int propertyData, + IReadOnlyList boundProperties) + { + var attemptedBinding = false; + var bindingSucceeded = false; + + if (boundProperties.Count == 0) + { + return (attemptedBinding, bindingSucceeded); + } + + var postponePlaceholderBinding = false; + for (var i = 0; i < boundProperties.Count; i++) + { + var property = boundProperties[i]; + if (!CanBindItem(bindingContext, property)) + { + continue; + } + + var propertyBinder = _propertyBinders[property]; + if (propertyBinder is PlaceholderBinder) + { + if (postponePlaceholderBinding) + { + // Decided to postpone binding properties that complete a loop in the model types when handling + // an earlier loop-completing property. Postpone binding this property too. + continue; + } + else if (!bindingContext.IsTopLevelObject && + !bindingSucceeded && + propertyData == GreedyPropertiesMayHaveData) + { + // Have no confirmation of data for the current instance. Postpone completing the loop until + // we _know_ the current instance is useful. Recursion would otherwise occur prior to the + // block with a similar condition after the loop. + // + // Example cases include an Employee class containing + // 1. a Manager property of type Employee + // 2. an Employees property of type IList + postponePlaceholderBinding = true; + continue; + } + } + + var fieldName = property.BinderModelName ?? property.PropertyName; + var modelName = ModelNames.CreatePropertyModelName(bindingContext.ModelName, fieldName); + var result = await BindPropertyAsync(bindingContext, property, propertyBinder, fieldName, modelName); + + if (result.IsModelSet) + { + attemptedBinding = true; + bindingSucceeded = true; + } + else if (property.IsBindingRequired) + { + attemptedBinding = true; + } + } + + if (postponePlaceholderBinding && bindingSucceeded) + { + // Have some data for this instance. Continue with the model type loop. + for (var i = 0; i < boundProperties.Count; i++) + { + var property = boundProperties[i]; + if (!CanBindItem(bindingContext, property)) + { + continue; + } + + var propertyBinder = _propertyBinders[property]; + if (propertyBinder is PlaceholderBinder) + { + var fieldName = property.BinderModelName ?? property.PropertyName; + var modelName = ModelNames.CreatePropertyModelName(bindingContext.ModelName, fieldName); + + await BindPropertyAsync(bindingContext, property, propertyBinder, fieldName, modelName); + } + } + } + + return (attemptedBinding, bindingSucceeded); + } + + internal bool CanBindItem(ModelBindingContext bindingContext, ModelMetadata propertyMetadata) + { + var metadataProviderFilter = bindingContext.ModelMetadata.PropertyFilterProvider?.PropertyFilter; + if (metadataProviderFilter?.Invoke(propertyMetadata) == false) + { + return false; + } + + if (bindingContext.PropertyFilter?.Invoke(propertyMetadata) == false) + { + return false; + } + + if (!propertyMetadata.IsBindingAllowed) + { + return false; + } + + if (propertyMetadata.MetadataKind == ModelMetadataKind.Property && propertyMetadata.IsReadOnly) + { + // Determine if we can update a readonly property (such as a collection). + return CanUpdateReadOnlyProperty(propertyMetadata.ModelType); + } + + return true; + } + + private async ValueTask BindPropertyAsync( + ModelBindingContext bindingContext, + ModelMetadata property, + IModelBinder propertyBinder, + string fieldName, + string modelName) + { + Debug.Assert(property.MetadataKind == ModelMetadataKind.Property); + + // Pass complex (including collection) values down so that binding system does not unnecessarily + // recreate instances or overwrite inner properties that are not bound. No need for this with simple + // values because they will be overwritten if binding succeeds. Arrays are never reused because they + // cannot be resized. + object propertyModel = null; + if (property.PropertyGetter != null && + property.IsComplexType && + !property.ModelType.IsArray) + { + propertyModel = property.PropertyGetter(bindingContext.Model); + } + + ModelBindingResult result; + using (bindingContext.EnterNestedScope( + modelMetadata: property, + fieldName: fieldName, + modelName: modelName, + model: propertyModel)) + { + await propertyBinder.BindModelAsync(bindingContext); + result = bindingContext.Result; + } + + if (result.IsModelSet) + { + SetProperty(bindingContext, modelName, property, result); + } + else if (property.IsBindingRequired) + { + var message = property.ModelBindingMessageProvider.MissingBindRequiredValueAccessor(fieldName); + bindingContext.ModelState.TryAddModelError(modelName, message); + } + + return result; + } + + private async ValueTask BindParameterAsync( + ModelBindingContext bindingContext, + ModelMetadata parameter, + IModelBinder parameterBinder, + string fieldName, + string modelName) + { + Debug.Assert(parameter.MetadataKind == ModelMetadataKind.Parameter); + + ModelBindingResult result; + using (bindingContext.EnterNestedScope( + modelMetadata: parameter, + fieldName: fieldName, + modelName: modelName, + model: null)) + { + await parameterBinder.BindModelAsync(bindingContext); + result = bindingContext.Result; + } + + if (!result.IsModelSet && parameter.IsBindingRequired) + { + var message = parameter.ModelBindingMessageProvider.MissingBindRequiredValueAccessor(fieldName); + bindingContext.ModelState.TryAddModelError(modelName, message); + } + + return result; + } + + internal int CanCreateModel(ModelBindingContext bindingContext) + { + var isTopLevelObject = bindingContext.IsTopLevelObject; + + // If we get here the model is a complex object which was not directly bound by any previous model binder, + // so we want to decide if we want to continue binding. This is important to get right to avoid infinite + // recursion. + // + // First, we want to make sure this object is allowed to come from a value provider source as this binder + // will only include value provider data. For instance if the model is marked with [FromBody], then we + // can just skip it. A greedy source cannot be a value provider. + // + // If the model isn't marked with ANY binding source, then we assume it's OK also. + // + // We skip this check if it is a top level object because we want to always evaluate + // the creation of top level object (this is also required for ModelBinderAttribute to work.) + var bindingSource = bindingContext.BindingSource; + if (!isTopLevelObject && bindingSource != null && bindingSource.IsGreedy) + { + return NoDataAvailable; + } + + // Create the object if: + // 1. It is a top level model. + if (isTopLevelObject) + { + return ValueProviderDataAvailable; + } + + // 2. Any of the model properties can be bound. + return CanBindAnyModelItem(bindingContext); + } + + private int CanBindAnyModelItem(ModelBindingContext bindingContext) + { + // If there are no properties on the model, and no constructor parameters, there is nothing to bind. We are here means this is not a top + // level object. So we return false. + var modelMetadata = bindingContext.ModelMetadata; + var performsConstructorBinding = bindingContext.Model == null && modelMetadata.BoundConstructor != null; + + if (modelMetadata.Properties.Count == 0 && + (!performsConstructorBinding || modelMetadata.BoundConstructor.BoundConstructorParameters.Count == 0)) + { + Log.NoPublicSettableItems(_logger, bindingContext); + return NoDataAvailable; + } + + // We want to check to see if any of the properties of the model can be bound using the value providers or + // a greedy binder. + // + // Because a property might specify a custom binding source ([FromForm]), it's not correct + // for us to just try bindingContext.ValueProvider.ContainsPrefixAsync(bindingContext.ModelName); + // that may include other value providers - that would lead us to mistakenly create the model + // when the data is coming from a source we should use (ex: value found in query string, but the + // model has [FromForm]). + // + // To do this we need to enumerate the properties, and see which of them provide a binding source + // through metadata, then we decide what to do. + // + // If a property has a binding source, and it's a greedy source, then it's always bound. + // + // If a property has a binding source, and it's a non-greedy source, then we'll filter the + // the value providers to just that source, and see if we can find a matching prefix + // (see CanBindValue). + // + // If a property does not have a binding source, then it's fair game for any value provider. + // + // Bottom line, if any property meets the above conditions and has a value from ValueProviders, then we'll + // create the model and try to bind it. Of, if ANY properties of the model have a greedy source, + // then we go ahead and create it. + var hasGreedyBinders = false; + for (var i = 0; i < bindingContext.ModelMetadata.Properties.Count; i++) + { + var propertyMetadata = bindingContext.ModelMetadata.Properties[i]; + if (!CanBindItem(bindingContext, propertyMetadata)) + { + continue; + } + + // If any property can be bound from a greedy binding source, then success. + var bindingSource = propertyMetadata.BindingSource; + if (bindingSource != null && bindingSource.IsGreedy) + { + hasGreedyBinders = true; + continue; + } + + // Otherwise, check whether the (perhaps filtered) value providers have a match. + var fieldName = propertyMetadata.BinderModelName ?? propertyMetadata.PropertyName; + var modelName = ModelNames.CreatePropertyModelName(bindingContext.ModelName, fieldName); + using (bindingContext.EnterNestedScope( + modelMetadata: propertyMetadata, + fieldName: fieldName, + modelName: modelName, + model: null)) + { + // If any property can be bound from a value provider, then success. + if (bindingContext.ValueProvider.ContainsPrefix(bindingContext.ModelName)) + { + return ValueProviderDataAvailable; + } + } + } + + if (performsConstructorBinding) + { + var parameters = bindingContext.ModelMetadata.BoundConstructor.BoundConstructorParameters; + for (var i = 0; i < parameters.Count; i++) + { + var parameterMetadata = parameters[i]; + if (!CanBindItem(bindingContext, parameterMetadata)) + { + continue; + } + + // If any parameter can be bound from a greedy binding source, then success. + var bindingSource = parameterMetadata.BindingSource; + if (bindingSource != null && bindingSource.IsGreedy) + { + hasGreedyBinders = true; + continue; + } + + // Otherwise, check whether the (perhaps filtered) value providers have a match. + var fieldName = parameterMetadata.BinderModelName ?? parameterMetadata.ParameterName; + var modelName = ModelNames.CreatePropertyModelName(bindingContext.ModelName, fieldName); + using (bindingContext.EnterNestedScope( + modelMetadata: parameterMetadata, + fieldName: fieldName, + modelName: modelName, + model: null)) + { + // If any parameter can be bound from a value provider, then success. + if (bindingContext.ValueProvider.ContainsPrefix(bindingContext.ModelName)) + { + return ValueProviderDataAvailable; + } + } + } + } + + if (hasGreedyBinders) + { + return GreedyPropertiesMayHaveData; + } + + _logger.CannotBindToComplexType(bindingContext); + + return NoDataAvailable; + } + + internal static bool CanUpdateReadOnlyProperty(Type propertyType) + { + // Value types have copy-by-value semantics, which prevents us from updating + // properties that are marked readonly. + if (propertyType.GetTypeInfo().IsValueType) + { + return false; + } + + // Arrays are strange beasts since their contents are mutable but their sizes aren't. + // Therefore we shouldn't even try to update these. Further reading: + // http://blogs.msdn.com/ericlippert/archive/2008/09/22/arrays-considered-somewhat-harmful.aspx + if (propertyType.IsArray) + { + return false; + } + + // Special-case known immutable reference types + if (propertyType == typeof(string)) + { + return false; + } + + return true; + } + + internal void SetProperty( + ModelBindingContext bindingContext, + string modelName, + ModelMetadata propertyMetadata, + ModelBindingResult result) + { + if (!result.IsModelSet) + { + // If we don't have a value, don't set it on the model and trounce a pre-initialized value. + return; + } + + if (propertyMetadata.IsReadOnly) + { + // The property should have already been set when we called BindPropertyAsync, so there's + // nothing to do here. + return; + } + + var value = result.Model; + try + { + propertyMetadata.PropertySetter(bindingContext.Model, value); + } + catch (Exception exception) + { + AddModelError(exception, modelName, bindingContext); + } + } + + private static void AddModelError( + Exception exception, + string modelName, + ModelBindingContext bindingContext) + { + var targetInvocationException = exception as TargetInvocationException; + if (targetInvocationException?.InnerException != null) + { + exception = targetInvocationException.InnerException; + } + + // Do not add an error message if a binding error has already occurred for this property. + var modelState = bindingContext.ModelState; + var validationState = modelState.GetFieldValidationState(modelName); + if (validationState == ModelValidationState.Unvalidated) + { + modelState.AddModelError(modelName, exception, bindingContext.ModelMetadata); + } + } + + private static class Log + { + private static readonly Action _noPublicSettableProperties = LoggerMessage.Define( + LogLevel.Debug, + new EventId(17, "NoPublicSettableItems"), + "Could not bind to model with name '{ModelName}' and type '{ModelType}' as the type has no public settable properties or constructor parameters."); + + public static void NoPublicSettableItems(ILogger logger, ModelBindingContext bindingContext) + { + _noPublicSettableProperties(logger, bindingContext.ModelName, bindingContext.ModelType, null); + } + } + } +} diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexObjectModelBinderProvider.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexObjectModelBinderProvider.cs new file mode 100644 index 000000000000..e5f63639f15b --- /dev/null +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexObjectModelBinderProvider.cs @@ -0,0 +1,64 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders +{ + /// + /// An for complex types. + /// + public class ComplexObjectModelBinderProvider : IModelBinderProvider + { + /// + public IModelBinder GetBinder(ModelBinderProviderContext context) + { + if (context == null) + { + throw new ArgumentNullException(nameof(context)); + } + + var metadata = context.Metadata; + if (metadata.IsComplexType && !metadata.IsCollectionType) + { + var loggerFactory = context.Services.GetRequiredService(); + var logger = loggerFactory.CreateLogger(); + var parameterBinders = GetParameterBinders(context); + + var propertyBinders = new Dictionary(); + for (var i = 0; i < context.Metadata.Properties.Count; i++) + { + var property = context.Metadata.Properties[i]; + propertyBinders.Add(property, context.CreateBinder(property)); + } + + return new ComplexObjectModelBinder(propertyBinders, parameterBinders, logger); + } + + return null; + } + + private static IReadOnlyList GetParameterBinders(ModelBinderProviderContext context) + { + var boundConstructor = context.Metadata.BoundConstructor; + if (boundConstructor is null) + { + return Array.Empty(); + } + + var parameterBinders = boundConstructor.BoundConstructorParameters.Count == 0 ? + Array.Empty() : + new IModelBinder[boundConstructor.BoundConstructorParameters.Count]; + + for (var i = 0; i < parameterBinders.Length; i++) + { + parameterBinders[i] = context.CreateBinder(boundConstructor.BoundConstructorParameters[i]); + } + + return parameterBinders; + } + } +} diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexTypeModelBinder.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexTypeModelBinder.cs index bd2caaa62542..d9c1f0616bba 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexTypeModelBinder.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexTypeModelBinder.cs @@ -16,6 +16,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders /// /// implementation for binding complex types. /// + [Obsolete("This type is obsolete and will be removed in a future version. Use ComplexObjectModelBinder instead.")] public class ComplexTypeModelBinder : IModelBinder { // Don't want a new public enum because communication between the private and internal methods of this class diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexTypeModelBinderProvider.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexTypeModelBinderProvider.cs index 21478b8677d0..69412231e826 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexTypeModelBinderProvider.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ComplexTypeModelBinderProvider.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; @@ -11,6 +11,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders /// /// An for complex types. /// + [Obsolete("This type is obsolete and will be removed in a future version. Use ComplexObjectModelBinderProvider instead.")] public class ComplexTypeModelBinderProvider : IModelBinderProvider { /// diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/BindingMetadata.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/BindingMetadata.cs index 656a6ea2c248..767f32694670 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/BindingMetadata.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/BindingMetadata.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Reflection; using Microsoft.AspNetCore.Mvc.Core; namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata @@ -97,5 +98,10 @@ public DefaultModelBindingMessageProvider ModelBindingMessageProvider /// See . /// public IPropertyFilterProvider PropertyFilterProvider { get; set; } + + /// + /// Gets or sets the used to model bind and validate the model type. + /// + public ConstructorInfo BoundConstructor { get; set; } } } diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultBindingMetadataProvider.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultBindingMetadataProvider.cs index 5cc570394baf..eea47efcd60f 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultBindingMetadataProvider.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultBindingMetadataProvider.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; +using Microsoft.Extensions.Internal; namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata { @@ -72,6 +73,79 @@ public void CreateBindingMetadata(BindingMetadataProviderContext context) context.BindingMetadata.IsBindingAllowed = bindingBehavior.Behavior != BindingBehavior.Never; context.BindingMetadata.IsBindingRequired = bindingBehavior.Behavior == BindingBehavior.Required; } + + if (GetBoundConstructor(context.Key.ModelType) is ConstructorInfo constructorInfo) + { + context.BindingMetadata.BoundConstructor = constructorInfo; + } + } + + internal static ConstructorInfo GetBoundConstructor(Type type) + { + if (type.IsAbstract || type.IsValueType || type.IsInterface) + { + return null; + } + + var constructors = type.GetConstructors(); + if (constructors.Length == 0) + { + return null; + } + + return GetRecordTypeConstructor(type, constructors); + } + + private static ConstructorInfo GetRecordTypeConstructor(Type type, ConstructorInfo[] constructors) + { + if (!IsRecordType(type)) + { + return null; + } + + // For record types, we will support binding and validating the primary constructor. + // There isn't metadata to identify a primary constructor. Our heuristic is: + // We require exactly one constructor to be defined on the type, and that every parameter on + // that constructor is mapped to a property with the same name and type. + + if (constructors.Length > 1) + { + return null; + } + + var constructor = constructors[0]; + + var parameters = constructor.GetParameters(); + if (parameters.Length == 0) + { + // We do not need to do special handling for parameterless constructors. + return null; + } + + var properties = PropertyHelper.GetVisibleProperties(type); + + for (var i = 0; i < parameters.Length; i++) + { + var parameter = parameters[i]; + var mappedProperty = properties.FirstOrDefault(property => + string.Equals(property.Name, parameter.Name, StringComparison.Ordinal) && + property.Property.PropertyType == parameter.ParameterType); + + if (mappedProperty is null) + { + // No property found, this is not a primary constructor. + return null; + } + } + + return constructor; + + static bool IsRecordType(Type type) + { + // Based on the state of the art as described in https://github.com/dotnet/roslyn/issues/45777 + var cloneMethod = type.GetMethod("<>Clone", BindingFlags.Public | BindingFlags.Instance); + return cloneMethod != null && cloneMethod.ReturnType == type; + } } private static BindingBehaviorAttribute FindBindingBehavior(BindingMetadataProviderContext context) diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultMetadataDetails.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultMetadataDetails.cs index cc9b540c5aed..5290766553ab 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultMetadataDetails.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultMetadataDetails.cs @@ -54,6 +54,11 @@ public DefaultMetadataDetails(ModelMetadataIdentity key, ModelAttributes attribu /// public ModelMetadata[] Properties { get; set; } + /// + /// Gets or sets the entries for constructor parameters. + /// + public ModelMetadata[] BoundConstructorParameters { get; set; } + /// /// Gets or sets a property getter delegate to get the property value from a model object. /// @@ -64,6 +69,11 @@ public DefaultMetadataDetails(ModelMetadataIdentity key, ModelAttributes attribu /// public Action PropertySetter { get; set; } + /// + /// Gets or sets a delegate used to invoke the bound constructor for record types. + /// + public Func BoundConstructorInvoker { get; set; } + /// /// Gets or sets the /// @@ -74,4 +84,4 @@ public DefaultMetadataDetails(ModelMetadataIdentity key, ModelAttributes attribu /// public ModelMetadata ContainerMetadata { get; set; } } -} \ No newline at end of file +} diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultModelMetadata.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultModelMetadata.cs index 2c2a128e4e49..7d57a10f3939 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultModelMetadata.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultModelMetadata.cs @@ -25,6 +25,7 @@ public class DefaultModelMetadata : ModelMetadata private ReadOnlyDictionary _additionalValues; private ModelMetadata _elementMetadata; + private ModelMetadata _constructorMetadata; private bool? _isBindingRequired; private bool? _isReadOnly; private bool? _isRequired; @@ -386,6 +387,28 @@ public override ModelPropertyCollection Properties } } + /// + public override ModelMetadata BoundConstructor + { + get + { + if (BindingMetadata.BoundConstructor == null) + { + return null; + } + + if (_constructorMetadata == null) + { + var modelMetadataProvider = (ModelMetadataProvider)_provider; + _constructorMetadata = modelMetadataProvider.GetMetadataForConstructor(BindingMetadata.BoundConstructor, ModelType); + } + + return _constructorMetadata; + } + } + + public override IReadOnlyList BoundConstructorParameters => _details.BoundConstructorParameters; + /// public override IPropertyFilterProvider PropertyFilterProvider => BindingMetadata.PropertyFilterProvider; @@ -494,7 +517,16 @@ internal static bool CalculateHasValidators(HashSet visite } else if (defaultModelMetadata.IsComplexType) { - foreach (var property in defaultModelMetadata.Properties) + var parameters = defaultModelMetadata.BoundConstructor?.BoundConstructorParameters ?? Array.Empty(); + foreach (var parameter in parameters) + { + if (CalculateHasValidators(visited, parameter)) + { + return true; + } + } + + foreach (var property in defaultModelMetadata.BoundProperties) { if (CalculateHasValidators(visited, property)) { @@ -527,6 +559,10 @@ public override IReadOnlyList ValidatorMetadata /// public override Action PropertySetter => _details.PropertySetter; + public override Func BoundConstructorInvoker => _details.BoundConstructorInvoker; + + internal DefaultMetadataDetails Details => _details; + /// public override ModelMetadata GetMetadataForType(Type modelType) { diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultModelMetadataProvider.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultModelMetadataProvider.cs index 710af66ebc38..1d7e3ed9bc1b 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultModelMetadataProvider.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultModelMetadataProvider.cs @@ -5,6 +5,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; +using System.Linq.Expressions; using System.Reflection; using Microsoft.Extensions.Internal; using Microsoft.Extensions.Options; @@ -16,7 +17,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Metadata /// public class DefaultModelMetadataProvider : ModelMetadataProvider { - private readonly TypeCache _typeCache = new TypeCache(); + private readonly ModelMetadataCache _modelMetadataCache = new ModelMetadataCache(); private readonly Func _cacheEntryFactory; private readonly ModelMetadataCacheEntry _metadataCacheEntryForObjectType; @@ -150,6 +151,18 @@ public override ModelMetadata GetMetadataForProperty(PropertyInfo propertyInfo, return cacheEntry.Metadata; } + + /// + public override ModelMetadata GetMetadataForConstructor(ConstructorInfo constructorInfo, Type modelType) + { + if (constructorInfo is null) + { + throw new ArgumentNullException(nameof(constructorInfo)); + } + + var cacheEntry = GetCacheEntry(constructorInfo, modelType); + return cacheEntry.Metadata; + } private static DefaultModelBindingMessageProvider GetMessageProvider(IOptions optionsAccessor) { @@ -174,7 +187,7 @@ private ModelMetadataCacheEntry GetCacheEntry(Type modelType) { var key = ModelMetadataIdentity.ForType(modelType); - cacheEntry = _typeCache.GetOrAdd(key, _cacheEntryFactory); + cacheEntry = _modelMetadataCache.GetOrAdd(key, _cacheEntryFactory); } return cacheEntry; @@ -182,22 +195,34 @@ private ModelMetadataCacheEntry GetCacheEntry(Type modelType) private ModelMetadataCacheEntry GetCacheEntry(ParameterInfo parameter, Type modelType) { - return _typeCache.GetOrAdd( + return _modelMetadataCache.GetOrAdd( ModelMetadataIdentity.ForParameter(parameter, modelType), _cacheEntryFactory); } private ModelMetadataCacheEntry GetCacheEntry(PropertyInfo property, Type modelType) { - return _typeCache.GetOrAdd( + return _modelMetadataCache.GetOrAdd( ModelMetadataIdentity.ForProperty(property, modelType, property.DeclaringType), _cacheEntryFactory); } + private ModelMetadataCacheEntry GetCacheEntry(ConstructorInfo constructor, Type modelType) + { + return _modelMetadataCache.GetOrAdd( + ModelMetadataIdentity.ForConstructor(constructor, modelType), + _cacheEntryFactory); + } + private ModelMetadataCacheEntry CreateCacheEntry(ModelMetadataIdentity key) { DefaultMetadataDetails details; - if (key.MetadataKind == ModelMetadataKind.Parameter) + + if (key.MetadataKind == ModelMetadataKind.Constructor) + { + details = CreateConstructorDetails(key); + } + else if (key.MetadataKind == ModelMetadataKind.Parameter) { details = CreateParameterDetails(key); } @@ -230,6 +255,73 @@ private DefaultMetadataDetails CreateSinglePropertyDetails(ModelMetadataIdentity return null; } + private DefaultMetadataDetails CreateConstructorDetails(ModelMetadataIdentity constructorKey) + { + var constructor = constructorKey.ConstructorInfo; + var parameters = constructor.GetParameters(); + var parameterMetadata = new ModelMetadata[parameters.Length]; + var parameterTypes = new Type[parameters.Length]; + + for (var i = 0; i < parameters.Length; i++) + { + var parameter = parameters[i]; + var parameterDetails = CreateParameterDetails(ModelMetadataIdentity.ForParameter(parameter)); + parameterMetadata[i] = CreateModelMetadata(parameterDetails); + + parameterTypes[i] = parameter.ParameterType; + } + + var constructorDetails = new DefaultMetadataDetails(constructorKey, ModelAttributes.Empty); + constructorDetails.BoundConstructorParameters = parameterMetadata; + constructorDetails.BoundConstructorInvoker = CreateObjectFactory(constructor); + + return constructorDetails; + + static Func CreateObjectFactory(ConstructorInfo constructor) + { + var args = Expression.Parameter(typeof(object[]), "args"); + var factoryExpressionBody = BuildFactoryExpression(constructor, args); + + var factoryLamda = Expression.Lambda>(factoryExpressionBody, args); + + return factoryLamda.Compile(); + } + } + + private static Expression BuildFactoryExpression( + ConstructorInfo constructor, + Expression factoryArgumentArray) + { + var constructorParameters = constructor.GetParameters(); + var constructorArguments = new Expression[constructorParameters.Length]; + + for (var i = 0; i < constructorParameters.Length; i++) + { + var constructorParameter = constructorParameters[i]; + var parameterType = constructorParameter.ParameterType; + + constructorArguments[i] = Expression.ArrayAccess(factoryArgumentArray, Expression.Constant(i)); + if (ParameterDefaultValue.TryGetDefaultValue(constructorParameter, out var defaultValue)) + { + // We have a default value; + } + else if (parameterType.IsValueType) + { + defaultValue = Activator.CreateInstance(parameterType); + } + + if (defaultValue != null) + { + var defaultValueExpression = Expression.Constant(defaultValue); + constructorArguments[i] = Expression.Coalesce(constructorArguments[i], defaultValueExpression); + } + + constructorArguments[i] = Expression.Convert(constructorArguments[i], parameterType); + } + + return Expression.New(constructor, constructorArguments); + } + private ModelMetadataCacheEntry GetMetadataCacheEntryForObjectType() { var key = ModelMetadataIdentity.ForType(typeof(object)); @@ -341,7 +433,7 @@ protected virtual DefaultMetadataDetails CreateParameterDetails(ModelMetadataIde ModelAttributes.GetAttributesForParameter(key.ParameterInfo, key.ModelType)); } - private class TypeCache : ConcurrentDictionary + private class ModelMetadataCache : ConcurrentDictionary { } @@ -358,4 +450,4 @@ public ModelMetadataCacheEntry(ModelMetadata metadata, DefaultMetadataDetails de public DefaultMetadataDetails Details { get; } } } -} \ No newline at end of file +} diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultValidationMetadataProvider.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultValidationMetadataProvider.cs index c8df349ccd1e..304bdd305439 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultValidationMetadataProvider.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/DefaultValidationMetadataProvider.cs @@ -53,6 +53,11 @@ public void CreateValidationMetadata(ValidationMetadataProviderContext context) context.ValidationMetadata.PropertyValidationFilter = validationFilter; } + else if (context.Key.MetadataKind == ModelMetadataKind.Parameter) + { + var validationFilter = context.ParameterAttributes.OfType().FirstOrDefault(); + context.ValidationMetadata.PropertyValidationFilter = validationFilter; + } } } } diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/ModelAttributes.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/ModelAttributes.cs index b5cc024d2229..31916e31cf60 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/ModelAttributes.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Metadata/ModelAttributes.cs @@ -13,6 +13,16 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding /// public class ModelAttributes { + internal static readonly ModelAttributes Empty = new ModelAttributes(Array.Empty()); + + /// + /// Creates a new . + /// + internal ModelAttributes(IReadOnlyList attributes) + { + Attributes = attributes; + } + /// /// Creates a new . /// diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/ParameterBinder.cs b/src/Mvc/Mvc.Core/src/ModelBinding/ParameterBinder.cs index c39ac3192a2a..1cd9853fed02 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/ParameterBinder.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/ParameterBinder.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Validation/DefaultComplexObjectValidationStrategy.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Validation/DefaultComplexObjectValidationStrategy.cs index f82ec567f1eb..e9a17b5a4beb 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/Validation/DefaultComplexObjectValidationStrategy.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Validation/DefaultComplexObjectValidationStrategy.cs @@ -4,7 +4,7 @@ using System; using System.Collections; using System.Collections.Generic; -using System.Reflection; +using Microsoft.AspNetCore.Mvc.Core; namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation { @@ -13,8 +13,6 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation /// internal class DefaultComplexObjectValidationStrategy : IValidationStrategy { - private static readonly bool IsMono = Type.GetType("Mono.Runtime") != null; - /// /// Gets an instance of . /// @@ -30,27 +28,42 @@ public IEnumerator GetChildren( string key, object model) { - return new Enumerator(metadata.Properties, key, model); + return new Enumerator(metadata, key, model); } private class Enumerator : IEnumerator { private readonly string _key; private readonly object _model; - private readonly ModelPropertyCollection _properties; + private readonly int _count; + private readonly ModelMetadata _modelMetadata; + private readonly IReadOnlyList _parameters; + private readonly IReadOnlyList _properties; private ValidationEntry _entry; private int _index; public Enumerator( - ModelPropertyCollection properties, + ModelMetadata modelMetadata, string key, object model) { - _properties = properties; + _modelMetadata = modelMetadata; _key = key; _model = model; + if (_modelMetadata.BoundConstructor == null) + { + _parameters = Array.Empty(); + } + else + { + _parameters = _modelMetadata.BoundConstructor.BoundConstructorParameters; + } + + _properties = _modelMetadata.BoundProperties; + _count = _properties.Count + _parameters.Count; + _index = -1; } @@ -61,27 +74,48 @@ public Enumerator( public bool MoveNext() { _index++; - if (_index >= _properties.Count) + + if (_index >= _count) { return false; } - var property = _properties[_index]; - var propertyName = property.BinderModelName ?? property.PropertyName; - var key = ModelNames.CreatePropertyModelName(_key, propertyName); - - if (_model == null) + if (_index < _parameters.Count) { - // Performance: Never create a delegate when container is null. - _entry = new ValidationEntry(property, key, model: null); - } - else if (IsMono) - { - _entry = new ValidationEntry(property, key, () => GetModelOnMono(_model, property.PropertyName)); + var parameter = _parameters[_index]; + var parameterName = parameter.BinderModelName ?? parameter.ParameterName; + var key = ModelNames.CreatePropertyModelName(_key, parameterName); + + if (_model is null) + { + _entry = new ValidationEntry(parameter, key, model: null); + } + else + { + if (!_modelMetadata.BoundConstructorParameterMapping.TryGetValue(parameter, out var property)) + { + throw new InvalidOperationException( + Resources.FormatValidationStrategy_MappedPropertyNotFound(parameter, _modelMetadata.ModelType)); + } + + _entry = new ValidationEntry(parameter, key, () => GetModel(_model, property)); + } } else { - _entry = new ValidationEntry(property, key, () => GetModel(_model, property)); + var property = _properties[_index - _parameters.Count]; + var propertyName = property.BinderModelName ?? property.PropertyName; + var key = ModelNames.CreatePropertyModelName(_key, propertyName); + + if (_model == null) + { + // Performance: Never create a delegate when container is null. + _entry = new ValidationEntry(property, key, model: null); + } + else + { + _entry = new ValidationEntry(property, key, () => GetModel(_model, property)); + } } return true; @@ -100,21 +134,6 @@ private static object GetModel(object container, ModelMetadata property) { return property.PropertyGetter(container); } - - // Our property accessors don't work on Mono 4.0.4 - see https://github.com/aspnet/External/issues/44 - // This is a workaround for what the PropertyGetter does in the background. - private static object GetModelOnMono(object container, string propertyName) - { - var propertyInfo = container.GetType().GetRuntimeProperty(propertyName); - try - { - return propertyInfo.GetValue(container); - } - catch (TargetInvocationException ex) - { - throw ex.InnerException; - } - } } } } diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Validation/ValidateNeverAttribute.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Validation/ValidateNeverAttribute.cs index a0c7181f3f15..600349a0c097 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/Validation/ValidateNeverAttribute.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Validation/ValidateNeverAttribute.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; @@ -6,11 +6,12 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Validation { /// - /// implementation that unconditionally indicates a property should be - /// excluded from validation. When applied to a property, the validation system excludes that property. When - /// applied to a type, the validation system excludes all properties within that type. + /// Indicates that a property or parameter should be excluded from validation. + /// When applied to a property, the validation system excludes that property. + /// When applied to a parameter, the validation system excludes that parameter. + /// When applied to a type, the validation system excludes all properties within that type. /// - [AttributeUsage(AttributeTargets.Class | AttributeTargets.Property, AllowMultiple = false, Inherited = true)] + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Property | AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)] public sealed class ValidateNeverAttribute : Attribute, IPropertyValidationFilter { /// diff --git a/src/Mvc/Mvc.Core/src/Resources.resx b/src/Mvc/Mvc.Core/src/Resources.resx index 93966b49f766..2ae174341060 100644 --- a/src/Mvc/Mvc.Core/src/Resources.resx +++ b/src/Mvc/Mvc.Core/src/Resources.resx @@ -522,4 +522,16 @@ Transformer '{0}' was retrieved from dependency injection with a state value. State can only be specified when the dynamic route is mapped using MapDynamicControllerRoute's state argument together with transient lifetime transformer. Ensure that '{0}' doesn't set its own state and that the transformer is registered with a transient lifetime in dependency injection. + + Could not create an instance of type '{0}'. Model bound complex types must not be abstract or value types and must have a parameterless constructor. Record types must have a single primary constructor. Alternatively, give the '{1}' parameter a non-null default value. + + + Could not create an instance of type '{0}'. Model bound complex types must not be abstract or value types and must have a parameterless constructor. Record types must have a single primary constructor. Alternatively, set the '{1}' property to a non-null value in the '{2}' constructor. + + + Could not create an instance of type '{0}'. Model bound complex types must not be abstract or value types and must have a parameterless constructor. Record types must have a single primary constructor. + + + No property found that maps to constructor parameter '{0}' for type '{1}'. Validation requires that each bound parameter of a record type's primary constructor must have a property to read the value. + diff --git a/src/Mvc/Mvc.Core/test/ApplicationModels/DefaultApplicationModelProviderTest.cs b/src/Mvc/Mvc.Core/test/ApplicationModels/DefaultApplicationModelProviderTest.cs index 78e9dd5daa98..efa5a1c06567 100644 --- a/src/Mvc/Mvc.Core/test/ApplicationModels/DefaultApplicationModelProviderTest.cs +++ b/src/Mvc/Mvc.Core/test/ApplicationModels/DefaultApplicationModelProviderTest.cs @@ -1270,7 +1270,7 @@ public class BindPropertyController { public string Property { get; set; } - [ModelBinder(typeof(ComplexTypeModelBinder))] + [ModelBinder(typeof(ComplexObjectModelBinder))] public string BinderType { get; set; } [FromRoute] @@ -1307,7 +1307,7 @@ public void CreatePropertyModel_DoesNotSetBindingInfo_IfPropertySpecifiesBinderT // Assert var bindingInfo = property.BindingInfo; - Assert.Same(typeof(ComplexTypeModelBinder), bindingInfo.BinderType); + Assert.Same(typeof(ComplexObjectModelBinder), bindingInfo.BinderType); } [Fact] diff --git a/src/Mvc/Mvc.Core/test/ApplicationModels/InferParameterBindingInfoConventionTest.cs b/src/Mvc/Mvc.Core/test/ApplicationModels/InferParameterBindingInfoConventionTest.cs index 4a38cbb4af5e..dccd843b4cdc 100644 --- a/src/Mvc/Mvc.Core/test/ApplicationModels/InferParameterBindingInfoConventionTest.cs +++ b/src/Mvc/Mvc.Core/test/ApplicationModels/InferParameterBindingInfoConventionTest.cs @@ -998,7 +998,7 @@ private class MultipleFromBodyController private class ParameterWithBindingInfo { [HttpGet("test")] - public IActionResult Action([ModelBinder(typeof(ComplexTypeModelBinder))] Car car) => null; + public IActionResult Action([ModelBinder(typeof(ComplexObjectModelBinder))] Car car) => null; } } } diff --git a/src/Mvc/Mvc.Core/test/Microsoft.AspNetCore.Mvc.Core.Test.csproj b/src/Mvc/Mvc.Core/test/Microsoft.AspNetCore.Mvc.Core.Test.csproj index 8ebae5504f72..4b77a3c2fbe8 100644 --- a/src/Mvc/Mvc.Core/test/Microsoft.AspNetCore.Mvc.Core.Test.csproj +++ b/src/Mvc/Mvc.Core/test/Microsoft.AspNetCore.Mvc.Core.Test.csproj @@ -1,8 +1,9 @@ - + $(DefaultNetCoreTargetFramework) Microsoft.AspNetCore.Mvc + 9.0 diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexObjectModelBinderProviderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexObjectModelBinderProviderTest.cs new file mode 100644 index 000000000000..082fe6b74e4e --- /dev/null +++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexObjectModelBinderProviderTest.cs @@ -0,0 +1,92 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders +{ + public class ComplexObjectModelBinderProviderTest + { + [Theory] + [InlineData(typeof(string))] + [InlineData(typeof(int))] + [InlineData(typeof(List))] + public void Create_ForNonComplexType_ReturnsNull(Type modelType) + { + // Arrange + var provider = new ComplexObjectModelBinderProvider(); + + var context = new TestModelBinderProviderContext(modelType); + + // Act + var result = provider.GetBinder(context); + + // Assert + Assert.Null(result); + } + + [Fact] + public void Create_ForSupportedTypes_ReturnsBinder() + { + // Arrange + var provider = new ComplexObjectModelBinderProvider(); + + var context = new TestModelBinderProviderContext(typeof(Person)); + context.OnCreatingBinder(m => + { + if (m.ModelType == typeof(int) || m.ModelType == typeof(string)) + { + return Mock.Of(); + } + else + { + Assert.False(true, "Not the right model type"); + return null; + } + }); + + // Act + var result = provider.GetBinder(context); + + // Assert + Assert.IsType(result); + } + + [Fact] + public void Create_ForSupportedType_ReturnsBinder() + { + // Arrange + var provider = new ComplexObjectModelBinderProvider(); + + var context = new TestModelBinderProviderContext(typeof(Person)); + context.OnCreatingBinder(m => + { + if (m.ModelType == typeof(int) || m.ModelType == typeof(string)) + { + return Mock.Of(); + } + else + { + Assert.False(true, "Not the right model type"); + return null; + } + }); + + // Act + var result = provider.GetBinder(context); + + // Assert + Assert.IsType(result); + } + + private class Person + { + public string Name { get; set; } + + public int Age { get; set; } + } + } +} diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexObjectModelBinderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexObjectModelBinderTest.cs new file mode 100644 index 000000000000..fcf5ea537ef3 --- /dev/null +++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexObjectModelBinderTest.cs @@ -0,0 +1,1412 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Reflection; +using System.Runtime.Serialization; +using System.Threading.Tasks; +using Castle.Core.Logging; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders +{ + public class ComplexObjectModelBinderTest + { + private static readonly IModelMetadataProvider _metadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + private readonly ILogger _logger = NullLogger.Instance; + + [Theory] + [InlineData(true, ComplexObjectModelBinder.ValueProviderDataAvailable)] + [InlineData(false, ComplexObjectModelBinder.NoDataAvailable)] + public void CanCreateModel_ReturnsTrue_IfIsTopLevelObject(bool isTopLevelObject, int expectedCanCreate) + { + var bindingContext = CreateContext(GetMetadataForType(typeof(Person))); + bindingContext.IsTopLevelObject = isTopLevelObject; + + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + var canCreate = binder.CanCreateModel(bindingContext); + + // Assert + Assert.Equal(expectedCanCreate, canCreate); + } + + [Fact] + public void CanCreateModel_ReturnsFalse_IfNotIsTopLevelObjectAndModelIsMarkedWithBinderMetadata() + { + var modelMetadata = GetMetadataForProperty(typeof(Document), nameof(Document.SubDocument)); + + var bindingContext = CreateContext(modelMetadata); + bindingContext.IsTopLevelObject = false; + + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + var canCreate = binder.CanCreateModel(bindingContext); + + // Assert + Assert.Equal(ComplexObjectModelBinder.NoDataAvailable, canCreate); + } + + [Fact] + public void CanCreateModel_ReturnsTrue_IfIsTopLevelObjectAndModelIsMarkedWithBinderMetadata() + { + var bindingContext = CreateContext(GetMetadataForType(typeof(Document))); + bindingContext.IsTopLevelObject = true; + + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + var canCreate = binder.CanCreateModel(bindingContext); + + // Assert + Assert.Equal(ComplexObjectModelBinder.ValueProviderDataAvailable, canCreate); + } + + [Theory] + [InlineData(ComplexObjectModelBinder.ValueProviderDataAvailable)] + [InlineData(ComplexObjectModelBinder.GreedyPropertiesMayHaveData)] + public void CanCreateModel_CreatesModel_WithAllGreedyProperties(int expectedCanCreate) + { + var bindingContext = CreateContext(GetMetadataForType(typeof(HasAllGreedyProperties))); + bindingContext.IsTopLevelObject = expectedCanCreate == ComplexObjectModelBinder.ValueProviderDataAvailable; + + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + var canCreate = binder.CanCreateModel(bindingContext); + + // Assert + Assert.Equal(expectedCanCreate, canCreate); + } + + [Theory] + [InlineData(ComplexObjectModelBinder.ValueProviderDataAvailable)] + [InlineData(ComplexObjectModelBinder.NoDataAvailable)] + public void CanCreateModel_ReturnsTrue_IfNotIsTopLevelObject_BasedOnValueAvailability(int valueAvailable) + { + // Arrange + var valueProvider = new Mock(MockBehavior.Strict); + valueProvider + .Setup(provider => provider.ContainsPrefix("SimpleContainer.Simple.Name")) + .Returns(valueAvailable == ComplexObjectModelBinder.ValueProviderDataAvailable); + + var modelMetadata = GetMetadataForProperty(typeof(SimpleContainer), nameof(SimpleContainer.Simple)); + var bindingContext = CreateContext(modelMetadata); + bindingContext.IsTopLevelObject = false; + bindingContext.ModelName = "SimpleContainer.Simple"; + bindingContext.ValueProvider = valueProvider.Object; + bindingContext.OriginalValueProvider = valueProvider.Object; + + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + var canCreate = binder.CanCreateModel(bindingContext); + + // Assert + // Result matches whether first Simple property can bind. + Assert.Equal(valueAvailable, canCreate); + } + + [Fact] + public void CanCreateModel_ReturnsFalse_IfNotIsTopLevelObjectAndModelHasNoProperties() + { + // Arrange + var bindingContext = CreateContext(GetMetadataForType(typeof(PersonWithNoProperties))); + bindingContext.IsTopLevelObject = false; + + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + var canCreate = binder.CanCreateModel(bindingContext); + + // Assert + Assert.Equal(ComplexObjectModelBinder.NoDataAvailable, canCreate); + } + + [Fact] + public void CanCreateModel_ReturnsTrue_IfIsTopLevelObjectAndModelHasNoProperties() + { + // Arrange + var bindingContext = CreateContext(GetMetadataForType(typeof(PersonWithNoProperties))); + bindingContext.IsTopLevelObject = true; + + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + var canCreate = binder.CanCreateModel(bindingContext); + + // Assert + Assert.Equal(ComplexObjectModelBinder.ValueProviderDataAvailable, canCreate); + } + + [Theory] + [InlineData(typeof(TypeWithNoBinderMetadata), ComplexObjectModelBinder.NoDataAvailable)] + [InlineData(typeof(TypeWithNoBinderMetadata), ComplexObjectModelBinder.ValueProviderDataAvailable)] + public void CanCreateModel_CreatesModelForValueProviderBasedBinderMetadatas_IfAValueProviderProvidesValue( + Type modelType, + int valueProviderProvidesValue) + { + var valueProvider = new Mock(); + valueProvider + .Setup(o => o.ContainsPrefix(It.IsAny())) + .Returns(valueProviderProvidesValue == ComplexObjectModelBinder.ValueProviderDataAvailable); + + var bindingContext = CreateContext(GetMetadataForType(modelType)); + bindingContext.IsTopLevelObject = false; + bindingContext.ValueProvider = valueProvider.Object; + bindingContext.OriginalValueProvider = valueProvider.Object; + + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + var canCreate = binder.CanCreateModel(bindingContext); + + // Assert + Assert.Equal(valueProviderProvidesValue, canCreate); + } + + [Theory] + [InlineData(typeof(TypeWithAtLeastOnePropertyMarkedUsingValueBinderMetadata), ComplexObjectModelBinder.GreedyPropertiesMayHaveData)] + [InlineData(typeof(TypeWithAtLeastOnePropertyMarkedUsingValueBinderMetadata), ComplexObjectModelBinder.ValueProviderDataAvailable)] + [InlineData(typeof(TypeWithUnmarkedAndBinderMetadataMarkedProperties), ComplexObjectModelBinder.GreedyPropertiesMayHaveData)] + [InlineData(typeof(TypeWithUnmarkedAndBinderMetadataMarkedProperties), ComplexObjectModelBinder.ValueProviderDataAvailable)] + public void CanCreateModel_CreatesModelForValueProviderBasedBinderMetadatas_IfPropertyHasGreedyBindingSource( + Type modelType, + int expectedCanCreate) + { + var valueProvider = new Mock(); + valueProvider + .Setup(o => o.ContainsPrefix(It.IsAny())) + .Returns(expectedCanCreate == ComplexObjectModelBinder.ValueProviderDataAvailable); + + var bindingContext = CreateContext(GetMetadataForType(modelType)); + bindingContext.IsTopLevelObject = false; + bindingContext.ValueProvider = valueProvider.Object; + bindingContext.OriginalValueProvider = valueProvider.Object; + + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + var canCreate = binder.CanCreateModel(bindingContext); + + // Assert + Assert.Equal(expectedCanCreate, canCreate); + } + + [Theory] + [InlineData(typeof(TypeWithAtLeastOnePropertyMarkedUsingValueBinderMetadata), ComplexObjectModelBinder.GreedyPropertiesMayHaveData)] + [InlineData(typeof(TypeWithAtLeastOnePropertyMarkedUsingValueBinderMetadata), ComplexObjectModelBinder.ValueProviderDataAvailable)] + public void CanCreateModel_ForExplicitValueProviderMetadata_UsesOriginalValueProvider( + Type modelType, + int expectedCanCreate) + { + var valueProvider = new Mock(); + valueProvider + .Setup(o => o.ContainsPrefix(It.IsAny())) + .Returns(false); + + var originalValueProvider = new Mock(); + originalValueProvider + .Setup(o => o.ContainsPrefix(It.IsAny())) + .Returns(expectedCanCreate == ComplexObjectModelBinder.ValueProviderDataAvailable); + + originalValueProvider + .Setup(o => o.Filter(It.IsAny())) + .Returns(source => source == BindingSource.Query ? originalValueProvider.Object : null); + + var bindingContext = CreateContext(GetMetadataForType(modelType)); + bindingContext.IsTopLevelObject = false; + bindingContext.ValueProvider = valueProvider.Object; + bindingContext.OriginalValueProvider = originalValueProvider.Object; + + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + var canCreate = binder.CanCreateModel(bindingContext); + + // Assert + Assert.Equal(expectedCanCreate, canCreate); + } + + [Theory] + [InlineData(typeof(TypeWithUnmarkedAndBinderMetadataMarkedProperties), false, ComplexObjectModelBinder.GreedyPropertiesMayHaveData)] + [InlineData(typeof(TypeWithUnmarkedAndBinderMetadataMarkedProperties), true, ComplexObjectModelBinder.ValueProviderDataAvailable)] + [InlineData(typeof(TypeWithNoBinderMetadata), false, ComplexObjectModelBinder.NoDataAvailable)] + [InlineData(typeof(TypeWithNoBinderMetadata), true, ComplexObjectModelBinder.ValueProviderDataAvailable)] + public void CanCreateModel_UnmarkedProperties_UsesCurrentValueProvider( + Type modelType, + bool valueProviderProvidesValue, + int expectedCanCreate) + { + var valueProvider = new Mock(); + valueProvider + .Setup(o => o.ContainsPrefix(It.IsAny())) + .Returns(valueProviderProvidesValue); + + var originalValueProvider = new Mock(); + originalValueProvider + .Setup(o => o.ContainsPrefix(It.IsAny())) + .Returns(false); + + var bindingContext = CreateContext(GetMetadataForType(modelType)); + bindingContext.IsTopLevelObject = false; + bindingContext.ValueProvider = valueProvider.Object; + bindingContext.OriginalValueProvider = originalValueProvider.Object; + + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + var canCreate = binder.CanCreateModel(bindingContext); + + // Assert + Assert.Equal(expectedCanCreate, canCreate); + } + + private IActionResult ActionWithComplexParameter(Person parameter) => null; + + [Fact] + public async Task BindModelAsync_CreatesModelAndAddsError_IfIsTopLevelObject_WithNoData() + { + // Arrange + var parameter = typeof(ComplexObjectModelBinderTest) + .GetMethod(nameof(ActionWithComplexParameter), BindingFlags.Instance | BindingFlags.NonPublic) + .GetParameters()[0]; + var metadataProvider = new TestModelMetadataProvider(); + metadataProvider + .ForParameter(parameter) + .BindingDetails(b => b.IsBindingRequired = true); + var metadata = metadataProvider.GetMetadataForParameter(parameter); + var bindingContext = new DefaultModelBindingContext + { + IsTopLevelObject = true, + FieldName = "fieldName", + ModelMetadata = metadata, + ModelName = string.Empty, + ValueProvider = new TestValueProvider(new Dictionary()), + ModelState = new ModelStateDictionary(), + }; + + // Mock binder fails to bind all properties. + var innerBinder = new StubModelBinder(); + var binders = new Dictionary(); + foreach (var property in metadataProvider.GetMetadataForProperties(typeof(Person))) + { + binders.Add(property, innerBinder); + } + + var binder = new ComplexObjectModelBinder( + binders, + Array.Empty(), + _logger); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.Result.IsModelSet); + Assert.IsType(bindingContext.Result.Model); + + var keyValuePair = Assert.Single(bindingContext.ModelState); + Assert.Equal(string.Empty, keyValuePair.Key); + var error = Assert.Single(keyValuePair.Value.Errors); + Assert.Equal("A value for the 'fieldName' parameter or property was not provided.", error.ErrorMessage); + } + + private IActionResult ActionWithNoSettablePropertiesParameter(PersonWithNoProperties parameter) => null; + + [Fact] + public async Task BindModelAsync_CreatesModelAndAddsError_IfIsTopLevelObject_WithNoSettableProperties() + { + // Arrange + var parameter = typeof(ComplexObjectModelBinderTest) + .GetMethod( + nameof(ActionWithNoSettablePropertiesParameter), + BindingFlags.Instance | BindingFlags.NonPublic) + .GetParameters()[0]; + var metadataProvider = new TestModelMetadataProvider(); + metadataProvider + .ForParameter(parameter) + .BindingDetails(b => b.IsBindingRequired = true); + var metadata = metadataProvider.GetMetadataForParameter(parameter); + var bindingContext = new DefaultModelBindingContext + { + IsTopLevelObject = true, + FieldName = "fieldName", + ModelMetadata = metadata, + ModelName = string.Empty, + ValueProvider = new TestValueProvider(new Dictionary()), + ModelState = new ModelStateDictionary(), + }; + + var binder = new ComplexObjectModelBinder( + new Dictionary(), + Array.Empty(), + _logger); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.Result.IsModelSet); + Assert.IsType(bindingContext.Result.Model); + + var keyValuePair = Assert.Single(bindingContext.ModelState); + Assert.Equal(string.Empty, keyValuePair.Key); + var error = Assert.Single(keyValuePair.Value.Errors); + Assert.Equal("A value for the 'fieldName' parameter or property was not provided.", error.ErrorMessage); + } + + private IActionResult ActionWithAllPropertiesExcludedParameter(PersonWithAllPropertiesExcluded parameter) => null; + + [Fact] + public async Task BindModelAsync_CreatesModelAndAddsError_IfIsTopLevelObject_WithAllPropertiesExcluded() + { + // Arrange + var parameter = typeof(ComplexObjectModelBinderTest) + .GetMethod( + nameof(ActionWithAllPropertiesExcludedParameter), + BindingFlags.Instance | BindingFlags.NonPublic) + .GetParameters()[0]; + var metadataProvider = new TestModelMetadataProvider(); + metadataProvider + .ForParameter(parameter) + .BindingDetails(b => b.IsBindingRequired = true); + var metadata = metadataProvider.GetMetadataForParameter(parameter); + var bindingContext = new DefaultModelBindingContext + { + IsTopLevelObject = true, + FieldName = "fieldName", + ModelMetadata = metadata, + ModelName = string.Empty, + ValueProvider = new TestValueProvider(new Dictionary()), + ModelState = new ModelStateDictionary(), + }; + + var binder = new ComplexObjectModelBinder( + new Dictionary(), + Array.Empty(), + _logger); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.Result.IsModelSet); + Assert.IsType(bindingContext.Result.Model); + + var keyValuePair = Assert.Single(bindingContext.ModelState); + Assert.Equal(string.Empty, keyValuePair.Key); + var error = Assert.Single(keyValuePair.Value.Errors); + Assert.Equal("A value for the 'fieldName' parameter or property was not provided.", error.ErrorMessage); + } + + [Theory] + [InlineData(nameof(CollectionContainer.ReadOnlyArray), false)] + [InlineData(nameof(CollectionContainer.ReadOnlyDictionary), true)] + [InlineData(nameof(CollectionContainer.ReadOnlyList), true)] + [InlineData(nameof(CollectionContainer.SettableDictionary), true)] + [InlineData(nameof(CollectionContainer.SettableList), true)] + public void CanUpdateProperty_CollectionProperty_FalseOnlyForArray(string propertyName, bool expected) + { + // Arrange + var metadataProvider = _metadataProvider; + var metadata = metadataProvider.GetMetadataForProperty(typeof(CollectionContainer), propertyName); + + // Act + var canUpdate = ComplexObjectModelBinder.CanUpdateReadOnlyProperty(metadata.ModelType); + + // Assert + Assert.Equal(expected, canUpdate); + } + + private class PersonWithName + { + public string Name { get; set; } + } + + [Fact] + public async Task BindModelAsync_ModelIsNotNull_DoesNotCallCreateModel() + { + // Arrange + var bindingContext = CreateContext(GetMetadataForType(typeof(PersonWithName)), new PersonWithName()); + var originalModel = bindingContext.Model; + + var binders = bindingContext.ModelMetadata.Properties.ToDictionary( + keySelector: item => item, + elementSelector: item => (IModelBinder)new TestModelBinderProvider(item, ModelBindingResult.Success("Test"))); + + var binder = new ComplexObjectModelBinder( + binders, + Array.Empty(), + _logger); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.Same(originalModel, bindingContext.Model); + } + + [Theory] + [InlineData(nameof(PersonWithBindExclusion.FirstName))] + [InlineData(nameof(PersonWithBindExclusion.LastName))] + public void CanBindProperty_GetSetProperty(string property) + { + // Arrange + var metadata = GetMetadataForProperty(typeof(PersonWithBindExclusion), property); + var bindingContext = new DefaultModelBindingContext() + { + ActionContext = new ActionContext() + { + HttpContext = new DefaultHttpContext() + { + RequestServices = new ServiceCollection().BuildServiceProvider(), + }, + }, + ModelMetadata = GetMetadataForType(typeof(PersonWithBindExclusion)), + }; + + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + var result = binder.CanBindItem(bindingContext, metadata); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData(nameof(PersonWithBindExclusion.NonUpdateableProperty))] + public void CanBindProperty_GetOnlyProperty_WithBindNever(string property) + { + // Arrange + var metadata = GetMetadataForProperty(typeof(PersonWithBindExclusion), property); + var bindingContext = new DefaultModelBindingContext() + { + ActionContext = new ActionContext() + { + HttpContext = new DefaultHttpContext() + { + RequestServices = new ServiceCollection().BuildServiceProvider(), + }, + }, + ModelMetadata = GetMetadataForType(typeof(PersonWithBindExclusion)), + }; + + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + var result = binder.CanBindItem(bindingContext, metadata); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData(nameof(PersonWithBindExclusion.DateOfBirth))] + [InlineData(nameof(PersonWithBindExclusion.DateOfDeath))] + public void CanBindProperty_GetSetProperty_WithBindNever(string property) + { + // Arrange + var metadata = GetMetadataForProperty(typeof(PersonWithBindExclusion), property); + var bindingContext = new DefaultModelBindingContext() + { + ActionContext = new ActionContext() + { + HttpContext = new DefaultHttpContext(), + }, + ModelMetadata = GetMetadataForType(typeof(PersonWithBindExclusion)), + }; + + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + var result = binder.CanBindItem(bindingContext, metadata); + + // Assert + Assert.False(result); + } + + [Theory] + [InlineData(nameof(TypeWithIncludedPropertiesUsingBindAttribute.IncludedExplicitly1), true)] + [InlineData(nameof(TypeWithIncludedPropertiesUsingBindAttribute.IncludedExplicitly2), true)] + [InlineData(nameof(TypeWithIncludedPropertiesUsingBindAttribute.ExcludedByDefault1), false)] + [InlineData(nameof(TypeWithIncludedPropertiesUsingBindAttribute.ExcludedByDefault2), false)] + public void CanBindProperty_WithBindInclude(string property, bool expected) + { + // Arrange + var metadata = GetMetadataForProperty(typeof(TypeWithIncludedPropertiesUsingBindAttribute), property); + var bindingContext = new DefaultModelBindingContext() + { + ActionContext = new ActionContext() + { + HttpContext = new DefaultHttpContext() + }, + ModelMetadata = GetMetadataForType(typeof(TypeWithIncludedPropertiesUsingBindAttribute)), + }; + + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + var result = binder.CanBindItem(bindingContext, metadata); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData(nameof(ModelWithMixedBindingBehaviors.Required), true)] + [InlineData(nameof(ModelWithMixedBindingBehaviors.Optional), true)] + [InlineData(nameof(ModelWithMixedBindingBehaviors.Never), false)] + public void CanBindProperty_BindingAttributes_OverridingBehavior(string property, bool expected) + { + // Arrange + var metadata = GetMetadataForProperty(typeof(ModelWithMixedBindingBehaviors), property); + var bindingContext = new DefaultModelBindingContext() + { + ActionContext = new ActionContext() + { + HttpContext = new DefaultHttpContext(), + }, + ModelMetadata = GetMetadataForType(typeof(ModelWithMixedBindingBehaviors)), + }; + + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + var result = binder.CanBindItem(bindingContext, metadata); + + // Assert + Assert.Equal(expected, result); + } + + [Fact] + [ReplaceCulture] + public async Task BindModelAsync_BindRequiredFieldMissing_RaisesModelError() + { + // Arrange + var model = new ModelWithBindRequired + { + Name = "original value", + Age = -20 + }; + + var property = GetMetadataForProperty(model.GetType(), nameof(ModelWithBindRequired.Age)); + + var bindingContext = CreateContext(GetMetadataForType(model.GetType()), model); + + var propertyBinder = new TestModelBinderProvider(property, ModelBindingResult.Failed()); + var binder = CreateBinder(bindingContext.ModelMetadata, options => + { + options.ModelBinderProviders.Insert(0, propertyBinder); + }); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + var modelStateDictionary = bindingContext.ModelState; + Assert.False(modelStateDictionary.IsValid); + Assert.Single(modelStateDictionary); + + // Check Age error. + Assert.True(modelStateDictionary.TryGetValue("theModel.Age", out var entry)); + var modelError = Assert.Single(entry.Errors); + Assert.Null(modelError.Exception); + Assert.NotNull(modelError.ErrorMessage); + Assert.Equal("A value for the 'Age' parameter or property was not provided.", modelError.ErrorMessage); + } + + private class TestModelBinderProvider : IModelBinderProvider, IModelBinder + { + private readonly ModelMetadata _modelMetadata; + private readonly ModelBindingResult _result; + + public TestModelBinderProvider(ModelMetadata modelMetadata, ModelBindingResult result) + { + _modelMetadata = modelMetadata; + _result = result; + } + + public Task BindModelAsync(ModelBindingContext bindingContext) + { + bindingContext.Result = _result; + return Task.CompletedTask; + } + + public IModelBinder GetBinder(ModelBinderProviderContext context) + { + if (context.Metadata == _modelMetadata) + { + return this; + } + + return null; + } + } + + [Fact] + [ReplaceCulture] + public async Task BindModelAsync_DataMemberIsRequiredFieldMissing_RaisesModelError() + { + // Arrange + var model = new ModelWithDataMemberIsRequired + { + Name = "original value", + Age = -20 + }; + + var bindingContext = CreateContext(GetMetadataForType(model.GetType()), model); + + var property = GetMetadataForProperty(model.GetType(), nameof(ModelWithDataMemberIsRequired.Age)); + var propertyBinder = new TestModelBinderProvider(property, ModelBindingResult.Failed()); + + var binder = CreateBinder(bindingContext.ModelMetadata, options => + { + options.ModelBinderProviders.Insert(0, propertyBinder); + }); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + var modelStateDictionary = bindingContext.ModelState; + Assert.False(modelStateDictionary.IsValid); + Assert.Single(modelStateDictionary); + + // Check Age error. + Assert.True(modelStateDictionary.TryGetValue("theModel.Age", out var entry)); + var modelError = Assert.Single(entry.Errors); + Assert.Null(modelError.Exception); + Assert.NotNull(modelError.ErrorMessage); + Assert.Equal("A value for the 'Age' parameter or property was not provided.", modelError.ErrorMessage); + } + + [Fact] + [ReplaceCulture] + public async Task BindModelAsync_ValueTypePropertyWithBindRequired_SetToNull_CapturesException() + { + // Arrange + var model = new ModelWithBindRequired + { + Name = "original value", + Age = -20 + }; + + var bindingContext = CreateContext(GetMetadataForType(model.GetType()), model); + + // Attempt to set non-Nullable property to null. BindRequiredAttribute should not be relevant in this + // case because the property did have a result. + var property = GetMetadataForProperty(model.GetType(), nameof(ModelWithBindRequired.Age)); + var propertyBinder = new TestModelBinderProvider(property, ModelBindingResult.Success(model: null)); + + var binder = CreateBinder(bindingContext.ModelMetadata, options => + { + options.ModelBinderProviders.Insert(0, propertyBinder); + }); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + var modelStateDictionary = bindingContext.ModelState; + Assert.False(modelStateDictionary.IsValid); + Assert.Single(modelStateDictionary); + + // Check Age error. + Assert.True(modelStateDictionary.TryGetValue("theModel.Age", out var entry)); + Assert.Equal(ModelValidationState.Invalid, entry.ValidationState); + + var modelError = Assert.Single(entry.Errors); + Assert.Equal(string.Empty, modelError.ErrorMessage); + Assert.IsType(modelError.Exception); + } + + [Fact] + public async Task BindModelAsync_ValueTypeProperty_WithBindingOptional_NoValueSet_NoError() + { + // Arrange + var model = new BindingOptionalProperty(); + var bindingContext = CreateContext(GetMetadataForType(model.GetType()), model); + + var property = GetMetadataForProperty(model.GetType(), nameof(BindingOptionalProperty.ValueTypeRequired)); + var propertyBinder = new TestModelBinderProvider(property, ModelBindingResult.Failed()); + + var binder = CreateBinder(bindingContext.ModelMetadata, options => + { + options.ModelBinderProviders.Insert(0, propertyBinder); + }); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + var modelStateDictionary = bindingContext.ModelState; + Assert.True(modelStateDictionary.IsValid); + } + + [Fact] + public async Task BindModelAsync_NullableValueTypeProperty_NoValueSet_NoError() + { + // Arrange + var model = new NullableValueTypeProperty(); + var bindingContext = CreateContext(GetMetadataForType(model.GetType()), model); + + var property = GetMetadataForProperty(model.GetType(), nameof(NullableValueTypeProperty.NullableValueType)); + var propertyBinder = new TestModelBinderProvider(property, ModelBindingResult.Failed()); + + var binder = CreateBinder(bindingContext.ModelMetadata, options => + { + options.ModelBinderProviders.Insert(0, propertyBinder); + }); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + var modelStateDictionary = bindingContext.ModelState; + Assert.True(modelStateDictionary.IsValid); + } + + [Fact] + public async Task BindModelAsync_ValueTypeProperty_NoValue_NoError() + { + // Arrange + var model = new Person(); + var containerMetadata = GetMetadataForType(model.GetType()); + + var bindingContext = CreateContext(containerMetadata, model); + + var property = GetMetadataForProperty(model.GetType(), nameof(Person.ValueTypeRequired)); + var propertyBinder = new TestModelBinderProvider(property, ModelBindingResult.Failed()); + + var binder = CreateBinder(bindingContext.ModelMetadata, options => + { + options.ModelBinderProviders.Insert(0, propertyBinder); + }); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.ModelState.IsValid); + Assert.Equal(0, model.ValueTypeRequired); + } + + [Fact] + public async Task BindModelAsync_ProvideRequiredField_Success() + { + // Arrange + var model = new Person(); + var containerMetadata = GetMetadataForType(model.GetType()); + + var bindingContext = CreateContext(containerMetadata, model); + + var property = GetMetadataForProperty(model.GetType(), nameof(Person.ValueTypeRequired)); + var propertyBinder = new TestModelBinderProvider(property, ModelBindingResult.Success(model: 57)); + + var binder = CreateBinder(bindingContext.ModelMetadata, options => + { + options.ModelBinderProviders.Insert(0, propertyBinder); + }); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.True(bindingContext.ModelState.IsValid); + Assert.Equal(57, model.ValueTypeRequired); + } + + [Fact] + public async Task BindModelAsync_Success() + { + // Arrange + var dob = new DateTime(2001, 1, 1); + var model = new PersonWithBindExclusion + { + DateOfBirth = dob + }; + + var containerMetadata = GetMetadataForType(model.GetType()); + + var bindingContext = CreateContext(containerMetadata, model); + + var binder = CreateBinder(bindingContext.ModelMetadata, options => + { + var firstNameProperty = containerMetadata.Properties[nameof(model.FirstName)]; + options.ModelBinderProviders.Insert(0, new TestModelBinderProvider(firstNameProperty, ModelBindingResult.Success("John"))); + + var lastNameProperty = containerMetadata.Properties[nameof(model.LastName)]; + options.ModelBinderProviders.Insert(0, new TestModelBinderProvider(lastNameProperty, ModelBindingResult.Success("Doe"))); + }); + + // Act + await binder.BindModelAsync(bindingContext); + + // Assert + Assert.Equal("John", model.FirstName); + Assert.Equal("Doe", model.LastName); + Assert.Equal(dob, model.DateOfBirth); + Assert.True(bindingContext.ModelState.IsValid); + } + + [Fact] + public void SetProperty_PropertyHasDefaultValue_DefaultValueAttributeDoesNothing() + { + // Arrange + var model = new Person(); + var bindingContext = CreateContext(GetMetadataForType(model.GetType()), model); + + var metadata = GetMetadataForType(typeof(Person)); + var propertyMetadata = metadata.Properties[nameof(model.PropertyWithDefaultValue)]; + + var result = ModelBindingResult.Failed(); + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + binder.SetProperty(bindingContext, "foo", propertyMetadata, result); + + // Assert + var person = Assert.IsType(bindingContext.Model); + Assert.Equal(0m, person.PropertyWithDefaultValue); + Assert.True(bindingContext.ModelState.IsValid); + } + + [Fact] + public void SetProperty_PropertyIsPreinitialized_NoValue_DoesNothing() + { + // Arrange + var model = new Person(); + var bindingContext = CreateContext(GetMetadataForType(model.GetType()), model); + + var metadata = GetMetadataForType(typeof(Person)); + var propertyMetadata = metadata.Properties[nameof(model.PropertyWithInitializedValue)]; + + // The null model value won't be used because IsModelBound = false. + var result = ModelBindingResult.Failed(); + + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + binder.SetProperty(bindingContext, "foo", propertyMetadata, result); + + // Assert + var person = Assert.IsType(bindingContext.Model); + Assert.Equal("preinitialized", person.PropertyWithInitializedValue); + Assert.True(bindingContext.ModelState.IsValid); + } + + [Fact] + public void SetProperty_PropertyIsPreinitialized_DefaultValueAttributeDoesNothing() + { + // Arrange + var model = new Person(); + var bindingContext = CreateContext(GetMetadataForType(model.GetType()), model); + + var metadata = GetMetadataForType(typeof(Person)); + var propertyMetadata = metadata.Properties[nameof(model.PropertyWithInitializedValueAndDefault)]; + + // The null model value won't be used because IsModelBound = false. + var result = ModelBindingResult.Failed(); + + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + binder.SetProperty(bindingContext, "foo", propertyMetadata, result); + + // Assert + var person = Assert.IsType(bindingContext.Model); + Assert.Equal("preinitialized", person.PropertyWithInitializedValueAndDefault); + Assert.True(bindingContext.ModelState.IsValid); + } + + [Fact] + public void SetProperty_PropertyIsReadOnly_DoesNothing() + { + // Arrange + var model = new Person(); + var bindingContext = CreateContext(GetMetadataForType(model.GetType()), model); + + var metadata = GetMetadataForType(typeof(Person)); + var propertyMetadata = metadata.Properties[nameof(model.NonUpdateableProperty)]; + + var result = ModelBindingResult.Failed(); + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + binder.SetProperty(bindingContext, "foo", propertyMetadata, result); + + // Assert + // If didn't throw, success! + } + + // Property name, property accessor + public static TheoryData> MyCanUpdateButCannotSetPropertyData + { + get + { + return new TheoryData> + { + { + nameof(MyModelTestingCanUpdateProperty.ReadOnlyObject), + model => ((Simple)((MyModelTestingCanUpdateProperty)model).ReadOnlyObject).Name + }, + { + nameof(MyModelTestingCanUpdateProperty.ReadOnlySimple), + model => ((MyModelTestingCanUpdateProperty)model).ReadOnlySimple.Name + }, + }; + } + } + + [Theory] + [MemberData(nameof(MyCanUpdateButCannotSetPropertyData))] + public void SetProperty_ValueProvidedAndCanUpdatePropertyTrue_DoesNothing( + string propertyName, + Func propertyAccessor) + { + // Arrange + var model = new MyModelTestingCanUpdateProperty(); + var type = model.GetType(); + var bindingContext = CreateContext(GetMetadataForType(type), model); + var modelState = bindingContext.ModelState; + var propertyMetadata = bindingContext.ModelMetadata.Properties[propertyName]; + var result = ModelBindingResult.Success(new Simple { Name = "Hanna" }); + + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + binder.SetProperty(bindingContext, propertyName, propertyMetadata, result); + + // Assert + Assert.Equal("Joe", propertyAccessor(model)); + Assert.True(modelState.IsValid); + Assert.Empty(modelState); + } + + [Fact] + public void SetProperty_ReadOnlyProperty_IsNoOp() + { + // Arrange + var model = new CollectionContainer(); + var originalCollection = model.ReadOnlyList; + + var modelMetadata = GetMetadataForType(model.GetType()); + var propertyMetadata = GetMetadataForProperty(model.GetType(), nameof(CollectionContainer.ReadOnlyList)); + + var bindingContext = CreateContext(modelMetadata, model); + var result = ModelBindingResult.Success(new List() { "hi" }); + + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + binder.SetProperty(bindingContext, propertyMetadata.PropertyName, propertyMetadata, result); + + // Assert + Assert.Same(originalCollection, model.ReadOnlyList); + Assert.Empty(model.ReadOnlyList); + } + + [Fact] + public void SetProperty_PropertyIsSettable_CallsSetter() + { + // Arrange + var model = new Person(); + var bindingContext = CreateContext(GetMetadataForType(model.GetType()), model); + var propertyMetadata = bindingContext.ModelMetadata.Properties[nameof(model.DateOfBirth)]; + + var result = ModelBindingResult.Success(new DateTime(2001, 1, 1)); + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + binder.SetProperty(bindingContext, "foo", propertyMetadata, result); + + // Assert + Assert.True(bindingContext.ModelState.IsValid); + Assert.Equal(new DateTime(2001, 1, 1), model.DateOfBirth); + } + + [Fact] + [ReplaceCulture] + public void SetProperty_PropertyIsSettable_SetterThrows_RecordsError() + { + // Arrange + var model = new Person + { + DateOfBirth = new DateTime(1900, 1, 1) + }; + + var bindingContext = CreateContext(GetMetadataForType(model.GetType()), model); + var propertyMetadata = bindingContext.ModelMetadata.Properties[nameof(model.DateOfDeath)]; + + var result = ModelBindingResult.Success(new DateTime(1800, 1, 1)); + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + binder.SetProperty(bindingContext, "foo", propertyMetadata, result); + + // Assert + Assert.Equal("Date of death can't be before date of birth. (Parameter 'value')", + bindingContext.ModelState["foo"].Errors[0].Exception.Message); + } + + [Fact] + [ReplaceCulture] + public void SetProperty_PropertySetterThrows_CapturesException() + { + // Arrange + var model = new ModelWhosePropertySetterThrows(); + var bindingContext = CreateContext(GetMetadataForType(model.GetType()), model); + bindingContext.ModelName = "foo"; + var propertyMetadata = bindingContext.ModelMetadata.Properties[nameof(model.NameNoAttribute)]; + + var result = ModelBindingResult.Success(model: null); + var binder = CreateBinder(bindingContext.ModelMetadata); + + // Act + binder.SetProperty(bindingContext, "foo.NameNoAttribute", propertyMetadata, result); + + // Assert + Assert.False(bindingContext.ModelState.IsValid); + Assert.Single(bindingContext.ModelState["foo.NameNoAttribute"].Errors); + Assert.Equal("This is a different exception. (Parameter 'value')", + bindingContext.ModelState["foo.NameNoAttribute"].Errors[0].Exception.Message); + } + + private static ComplexObjectModelBinder CreateBinder(ModelMetadata metadata, Action configureOptions = null) + { + var options = Options.Create(new MvcOptions()); + var setup = new MvcCoreMvcOptionsSetup(new TestHttpRequestStreamReaderFactory()); + setup.Configure(options.Value); + + configureOptions?.Invoke(options.Value); + + var factory = TestModelBinderFactory.Create(options.Value.ModelBinderProviders.ToArray()); + return (ComplexObjectModelBinder)factory.CreateBinder(new ModelBinderFactoryContext() + { + Metadata = metadata, + BindingInfo = new BindingInfo() + { + BinderModelName = metadata.BinderModelName, + BinderType = metadata.BinderType, + BindingSource = metadata.BindingSource, + PropertyFilterProvider = metadata.PropertyFilterProvider, + }, + }); + } + + private static DefaultModelBindingContext CreateContext(ModelMetadata metadata, object model = null) + { + var valueProvider = new TestValueProvider(new Dictionary()); + return new DefaultModelBindingContext() + { + BinderModelName = metadata.BinderModelName, + BindingSource = metadata.BindingSource, + IsTopLevelObject = true, + Model = model, + ModelMetadata = metadata, + ModelName = "theModel", + ModelState = new ModelStateDictionary(), + ValueProvider = valueProvider, + }; + } + + private static ModelMetadata GetMetadataForType(Type type) + { + return _metadataProvider.GetMetadataForType(type); + } + + private static ModelMetadata GetMetadataForProperty(Type type, string propertyName) + { + return _metadataProvider.GetMetadataForProperty(type, propertyName); + } + + private class Location + { + public PointStruct Point { get; set; } + } + + private readonly struct PointStruct + { + public PointStruct(double x, double y) + { + X = x; + Y = y; + } + + public double X { get; } + public double Y { get; } + } + + private class ClassWithNoParameterlessConstructor + { + public ClassWithNoParameterlessConstructor(string name) + { + Name = name; + } + + public string Name { get; set; } + } + + private class BindingOptionalProperty + { + [BindingBehavior(BindingBehavior.Optional)] + public int ValueTypeRequired { get; set; } + } + + private class NullableValueTypeProperty + { + [BindingBehavior(BindingBehavior.Optional)] + public int? NullableValueType { get; set; } + } + + private class Person + { + private DateTime? _dateOfDeath; + + [BindingBehavior(BindingBehavior.Optional)] + public DateTime DateOfBirth { get; set; } + + public DateTime? DateOfDeath + { + get { return _dateOfDeath; } + set + { + if (value < DateOfBirth) + { + throw new ArgumentOutOfRangeException(nameof(value), "Date of death can't be before date of birth."); + } + _dateOfDeath = value; + } + } + + [Required(ErrorMessage = "Sample message")] + public int ValueTypeRequired { get; set; } + + public string FirstName { get; set; } + public string LastName { get; set; } + public string NonUpdateableProperty { get; private set; } + + [BindingBehavior(BindingBehavior.Optional)] + [DefaultValue(typeof(decimal), "123.456")] + public decimal PropertyWithDefaultValue { get; set; } + + public string PropertyWithInitializedValue { get; set; } = "preinitialized"; + + [DefaultValue("default")] + public string PropertyWithInitializedValueAndDefault { get; set; } = "preinitialized"; + } + + private class PersonWithNoProperties + { + public string name = null; + } + + private class PersonWithAllPropertiesExcluded + { + [BindNever] + public DateTime DateOfBirth { get; set; } + + [BindNever] + public DateTime? DateOfDeath { get; set; } + + [BindNever] + public string FirstName { get; set; } + + [BindNever] + public string LastName { get; set; } + + public string NonUpdateableProperty { get; private set; } + } + + private class PersonWithBindExclusion + { + [BindNever] + public DateTime DateOfBirth { get; set; } + + [BindNever] + public DateTime? DateOfDeath { get; set; } + + public string FirstName { get; set; } + public string LastName { get; set; } + public string NonUpdateableProperty { get; private set; } + } + + private class ModelWithBindRequired + { + public string Name { get; set; } + + [BindRequired] + public int Age { get; set; } + } + + [DataContract] + private class ModelWithDataMemberIsRequired + { + public string Name { get; set; } + + [DataMember(IsRequired = true)] + public int Age { get; set; } + } + + [BindRequired] + private class ModelWithMixedBindingBehaviors + { + public string Required { get; set; } + + [BindNever] + public string Never { get; set; } + + [BindingBehavior(BindingBehavior.Optional)] + public string Optional { get; set; } + } + + private sealed class MyModelTestingCanUpdateProperty + { + public int ReadOnlyInt { get; private set; } + public string ReadOnlyString { get; private set; } + public object ReadOnlyObject { get; } = new Simple { Name = "Joe" }; + public string ReadWriteString { get; set; } + public Simple ReadOnlySimple { get; } = new Simple { Name = "Joe" }; + } + + private sealed class ModelWhosePropertySetterThrows + { + [Required(ErrorMessage = "This message comes from the [Required] attribute.")] + public string Name + { + get { return null; } + set { throw new ArgumentException("This is an exception.", "value"); } + } + + public string NameNoAttribute + { + get { return null; } + set { throw new ArgumentException("This is a different exception.", "value"); } + } + } + + private class TypeWithNoBinderMetadata + { + public int UnMarkedProperty { get; set; } + } + + private class HasAllGreedyProperties + { + [NonValueBinderMetadata] + public string MarkedWithABinderMetadata { get; set; } + } + + // Not a Metadata poco because there is a property with value binder Metadata. + private class TypeWithAtLeastOnePropertyMarkedUsingValueBinderMetadata + { + [NonValueBinderMetadata] + public string MarkedWithABinderMetadata { get; set; } + + [ValueBinderMetadata] + public string MarkedWithAValueBinderMetadata { get; set; } + } + + // not a Metadata poco because there is an unmarked property. + private class TypeWithUnmarkedAndBinderMetadataMarkedProperties + { + public int UnmarkedProperty { get; set; } + + [NonValueBinderMetadata] + public string MarkedWithABinderMetadata { get; set; } + } + + [Bind(new[] { nameof(IncludedExplicitly1), nameof(IncludedExplicitly2) })] + private class TypeWithIncludedPropertiesUsingBindAttribute + { + public int ExcludedByDefault1 { get; set; } + + public int ExcludedByDefault2 { get; set; } + + public int IncludedExplicitly1 { get; set; } + + public int IncludedExplicitly2 { get; set; } + } + + private class Document + { + [NonValueBinderMetadata] + public string Version { get; set; } + + [NonValueBinderMetadata] + public Document SubDocument { get; set; } + } + + private class NonValueBinderMetadataAttribute : Attribute, IBindingSourceMetadata + { + public BindingSource BindingSource + { + get { return new BindingSource("Special", string.Empty, isGreedy: true, isFromRequest: true); } + } + } + + private class ValueBinderMetadataAttribute : Attribute, IBindingSourceMetadata + { + public BindingSource BindingSource { get { return BindingSource.Query; } } + } + + private class ExcludedProvider : IPropertyFilterProvider + { + public Func PropertyFilter + { + get + { + return (m) => + !string.Equals("Excluded1", m.PropertyName, StringComparison.OrdinalIgnoreCase) && + !string.Equals("Excluded2", m.PropertyName, StringComparison.OrdinalIgnoreCase); + } + } + } + + private class SimpleContainer + { + public Simple Simple { get; set; } + } + + private class Simple + { + public string Name { get; set; } + } + + private class CollectionContainer + { + public int[] ReadOnlyArray { get; } = new int[4]; + + // Read-only collections get added values. + public IDictionary ReadOnlyDictionary { get; } = new Dictionary(); + + public IList ReadOnlyList { get; } = new List(); + + // Settable values are overwritten. + public int[] SettableArray { get; set; } = new int[] { 0, 1 }; + + public IDictionary SettableDictionary { get; set; } = new Dictionary + { + { 0, "zero" }, + { 25, "twenty-five" }, + }; + + public IList SettableList { get; set; } = new List { 3, 9, 0 }; + } + } +} diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexTypeModelBinderProviderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexTypeModelBinderProviderTest.cs index cf1641664165..c154bff7bbc5 100644 --- a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexTypeModelBinderProviderTest.cs +++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexTypeModelBinderProviderTest.cs @@ -1,4 +1,4 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; @@ -8,6 +8,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders { +#pragma warning disable CS0618 // Type or member is obsolete public class ComplexTypeModelBinderProviderTest { [Theory] @@ -89,4 +90,5 @@ private class Person public int Age { get; set; } } } +#pragma warning restore CS0618 // Type or member is obsolete } diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexTypeModelBinderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexTypeModelBinderTest.cs index 14db92193042..98bf6a1b6cf5 100644 --- a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexTypeModelBinderTest.cs +++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/ComplexTypeModelBinderTest.cs @@ -19,6 +19,7 @@ namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders { +#pragma warning disable CS0618 // Type or member is obsolete public class ComplexTypeModelBinderTest { private static readonly IModelMetadataProvider _metadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); @@ -1229,8 +1230,7 @@ private static TestableComplexTypeModelBinder CreateBinder(ModelMetadata metadat setup.Configure(options.Value); var lastIndex = options.Value.ModelBinderProviders.Count - 1; - Assert.IsType(options.Value.ModelBinderProviders[lastIndex]); - options.Value.ModelBinderProviders.RemoveAt(lastIndex); + options.Value.ModelBinderProviders.RemoveType(); options.Value.ModelBinderProviders.Add(new TestableComplexTypeModelBinderProvider()); var factory = TestModelBinderFactory.Create(options.Value.ModelBinderProviders.ToArray()); @@ -1662,4 +1662,5 @@ protected override void SetProperty( } } } +#pragma warning restore CS0618 // Type or member is obsolete } diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/DictionaryModelBinderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/DictionaryModelBinderTest.cs index 53075ff94bc8..936da88b62c3 100644 --- a/src/Mvc/Mvc.Core/test/ModelBinding/Binders/DictionaryModelBinderTest.cs +++ b/src/Mvc/Mvc.Core/test/ModelBinding/Binders/DictionaryModelBinderTest.cs @@ -278,12 +278,13 @@ public async Task BindModel_FallsBackToBindingValues_WithComplexValues() var binder = new DictionaryModelBinder( new SimpleTypeModelBinder(typeof(int), NullLoggerFactory.Instance), - new ComplexTypeModelBinder(new Dictionary() + new ComplexObjectModelBinder(new Dictionary() { { valueMetadata.Properties["Id"], new SimpleTypeModelBinder(typeof(int), NullLoggerFactory.Instance) }, { valueMetadata.Properties["Name"], new SimpleTypeModelBinder(typeof(string), NullLoggerFactory.Instance) }, }, - NullLoggerFactory.Instance), + Array.Empty(), + NullLogger.Instance), NullLoggerFactory.Instance); // Act diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Metadata/DefaultBindingMetadataProviderTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Metadata/DefaultBindingMetadataProviderTest.cs index 3a0756391dfb..8ab0bef56c41 100644 --- a/src/Mvc/Mvc.Core/test/ModelBinding/Metadata/DefaultBindingMetadataProviderTest.cs +++ b/src/Mvc/Mvc.Core/test/ModelBinding/Metadata/DefaultBindingMetadataProviderTest.cs @@ -658,6 +658,198 @@ public void CreateBindingDetails_BindingBehaviorLeftAlone_ForAttributeOnProperty Assert.Equal(initialValue, context.BindingMetadata.IsBindingRequired); } + private class DefaultConstructorType { } + + [Fact] + public void GetBoundConstructor_DefaultConstructor_ReturnsNull() + { + // Arrange + var type = typeof(DefaultConstructorType); + + // Act + var result = DefaultBindingMetadataProvider.GetBoundConstructor(type); + + // Assert + Assert.Null(result); + } + + private class ParameterlessConstructorType + { + public ParameterlessConstructorType() { } + } + + [Fact] + public void GetBoundConstructor_ParameterlessConstructor_ReturnsNull() + { + // Arrange + var type = typeof(ParameterlessConstructorType); + + // Act + var result = DefaultBindingMetadataProvider.GetBoundConstructor(type); + + // Assert + Assert.Null(result); + } + + private class NonPublicParameterlessConstructorType + { + protected NonPublicParameterlessConstructorType() { } + } + + [Fact] + public void GetBoundConstructor_DoesNotReturnsNonPublicParameterlessConstructor() + { + // Arrange + var type = typeof(NonPublicParameterlessConstructorType); + + // Act + var result = DefaultBindingMetadataProvider.GetBoundConstructor(type); + + // Assert + Assert.Null(result); + } + + private class MultipleConstructorType + { + public MultipleConstructorType() { } + public MultipleConstructorType(string prop) { } + } + + [Fact] + public void GetBoundConstructor_ReturnsParameterlessConstructor_ForTypeWithMultipleConstructors() + { + // Arrange + var type = typeof(NonPublicParameterlessConstructorType); + + // Act + var result = DefaultBindingMetadataProvider.GetBoundConstructor(type); + + // Assert + Assert.Null(result); + } + + private record RecordTypeWithPrimaryConstructor(string name) + { + } + + [Fact] + public void GetBoundConstructor_ReturnsPrimaryConstructor_ForRecordType() + { + // Arrange + var type = typeof(RecordTypeWithPrimaryConstructor); + + // Act + var result = DefaultBindingMetadataProvider.GetBoundConstructor(type); + + // Assert + Assert.NotNull(result); + Assert.Collection( + result.GetParameters(), + p => Assert.Equal("name", p.Name)); + } + + private record RecordTypeWithDefaultConstructor + { + public string Name { get; init; } + + public int Age { get; init; } + } + + private record RecordTypeWithParameterlessConstructor + { + public RecordTypeWithParameterlessConstructor() { } + + public string Name { get; init; } + + public int Age { get; init; } + } + + [Theory] + [InlineData(typeof(RecordTypeWithDefaultConstructor))] + [InlineData(typeof(RecordTypeWithParameterlessConstructor))] + public void GetBoundConstructor_ReturnsNull_ForRecordTypeWithParameterlessConstructor(Type type) + { + // Act + var result = DefaultBindingMetadataProvider.GetBoundConstructor(type); + + // Assert + Assert.Null(result); + } + + private record RecordTypeWithMultipleConstructors(string Name) + { + public RecordTypeWithMultipleConstructors(string Name, int age) : this(Name) => Age = age; + + public RecordTypeWithMultipleConstructors(int age) : this(string.Empty, age) { } + + public int Age { get; set; } + } + + [Fact] + public void GetBoundConstructor_ReturnsNull_ForRecordTypeWithMultipleConstructors() + { + // Arrange + var type = typeof(RecordTypeWithMultipleConstructors); + + // Act + var result = DefaultBindingMetadataProvider.GetBoundConstructor(type); + + // Assert + Assert.Null(result); + } + + private record RecordTypeWithConformingSynthesizedConstructor + { + public RecordTypeWithConformingSynthesizedConstructor(string Name, int Age) + { + } + + public string Name { get; set; } + + public int Age { get; set; } + } + + [Fact] + public void GetBoundConstructor_ReturnsConformingSynthesizedConstructor() + { + // Arrange + var type = typeof(RecordTypeWithConformingSynthesizedConstructor); + + // Act + var result = DefaultBindingMetadataProvider.GetBoundConstructor(type); + + // Assert + Assert.NotNull(result); + Assert.Collection( + result.GetParameters(), + p => Assert.Equal("Name", p.Name), + p => Assert.Equal("Age", p.Name)); + } + + private record RecordTypeWithNonConformingSynthesizedConstructor + { + public RecordTypeWithNonConformingSynthesizedConstructor(string name, string age) + { + } + + public string Name { get; set; } + + public int Age { get; set; } + } + + [Fact] + public void GetBoundConstructor_ReturnsNull_IfSynthesizedConstructorIsNonConforming() + { + // Arrange + var type = typeof(RecordTypeWithNonConformingSynthesizedConstructor); + + // Act + var result = DefaultBindingMetadataProvider.GetBoundConstructor(type); + + // Assert + Assert.Null(result); + } + [BindNever] private class BindNeverOnClass { @@ -704,4 +896,4 @@ private class CustomAttribute : Attribute public string Identifier { get; set; } } } -} \ No newline at end of file +} diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/Metadata/DefaultModelMetadataTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/Metadata/DefaultModelMetadataTest.cs index b3d87dbead8f..628471df74e9 100644 --- a/src/Mvc/Mvc.Core/test/ModelBinding/Metadata/DefaultModelMetadataTest.cs +++ b/src/Mvc/Mvc.Core/test/ModelBinding/Metadata/DefaultModelMetadataTest.cs @@ -197,7 +197,7 @@ public void NullDisplayText_DoesNotCacheInitialDelegateValue() var detailsProvider = new EmptyCompositeMetadataDetailsProvider(); var key = ModelMetadataIdentity.ForProperty( - typeof(TypeWithProperties).GetProperty(nameof(TypeWithProperties.PublicGetPublicSetProperty)), + typeof(TypeWithProperties).GetProperty(nameof(TypeWithProperties.PublicGetPublicSetProperty)), typeof(string), typeof(TypeWithProperties)); @@ -626,12 +626,12 @@ public void PropertiesSetOnce() var metadata = new DefaultModelMetadata(provider, detailsProvider, cache); // Act - var firstPropertiesEvaluation = metadata.Properties; + var SinglePropertiesEvaluation = metadata.Properties; var secondPropertiesEvaluation = metadata.Properties; // Assert // Same IEnumerable object. - Assert.Same(firstPropertiesEvaluation, secondPropertiesEvaluation); + Assert.Same(SinglePropertiesEvaluation, secondPropertiesEvaluation); } [Fact] @@ -647,12 +647,12 @@ public void PropertiesEnumerationEvaluatedOnce() var metadata = new DefaultModelMetadata(provider, detailsProvider, cache); // Act - var firstPropertiesEvaluation = metadata.Properties.ToList(); + var SinglePropertiesEvaluation = metadata.Properties.ToList(); var secondPropertiesEvaluation = metadata.Properties.ToList(); // Assert // Identical ModelMetadata objects every time we run through the Properties collection. - Assert.Equal(firstPropertiesEvaluation, secondPropertiesEvaluation); + Assert.Equal(SinglePropertiesEvaluation, secondPropertiesEvaluation); } [Fact] @@ -924,7 +924,7 @@ public void CalculateHasValidators_ParameterMetadata_TypeHasNoValidators() .GetMethod(nameof(CalculateHasValidators_ParameterMetadata_TypeHasNoValidatorsMethod), BindingFlags.Static | BindingFlags.NonPublic) .GetParameters()[0]; var modelIdentity = ModelMetadataIdentity.ForParameter(parameter); - var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), false); + var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), hasValidators: false); // Act var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); @@ -942,7 +942,7 @@ public void CalculateHasValidators_PropertyMetadata_TypeHasNoValidators() var property = GetType() .GetProperty(nameof(CalculateHasValidators_PropertyMetadata_TypeHasNoValidatorsProperty), BindingFlags.Static | BindingFlags.NonPublic); var modelIdentity = ModelMetadataIdentity.ForProperty(property, property.PropertyType, GetType()); - var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), false); + var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), hasValidators: false); // Act var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); @@ -958,7 +958,7 @@ public void CalculateHasValidators_TypeWithoutProperties_TypeHasNoValidators() { // Arrange var modelIdentity = ModelMetadataIdentity.ForType(typeof(string)); - var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), false); + var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), hasValidators: false); // Act var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); @@ -972,7 +972,7 @@ public void CalculateHasValidators_SimpleType_TypeHasValidators() { // Arrange var modelIdentity = ModelMetadataIdentity.ForType(typeof(string)); - var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), true); + var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), hasValidators: true); // Act var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); @@ -986,7 +986,7 @@ public void CalculateHasValidators_ReturnsTrue_SimpleType_TypeHasNonDeterministi { // Arrange var modelIdentity = ModelMetadataIdentity.ForType(typeof(string)); - var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), null); + var modelMetadata = CreateModelMetadata(modelIdentity, Mock.Of(), hasValidators: null); // Act var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); @@ -1002,7 +1002,7 @@ public void CalculateHasValidators_TypeWithProperties_PropertyIsNotDefaultModelM var modelType = typeof(TypeWithProperties); var modelIdentity = ModelMetadataIdentity.ForType(modelType); var metadataProvider = new Mock(); - var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false); + var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false); var property = typeof(TypeWithProperties).GetProperty(nameof(TypeWithProperties.PublicGetPublicSetProperty)); var propertyIdentity = ModelMetadataIdentity.ForProperty(property, typeof(int), typeof(TypeWithProperties)); @@ -1027,13 +1027,13 @@ public void CalculateHasValidators_TypeWithProperties_HasValidatorForAnyProperty var modelType = typeof(TypeWithProperties); var modelIdentity = ModelMetadataIdentity.ForType(modelType); var metadataProvider = new Mock(); - var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false); + var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false); var property1Identity = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(TypeWithProperties.PublicGetPublicSetProperty)), typeof(int), modelType); - var property1Metadata = CreateModelMetadata(property1Identity, metadataProvider.Object, false); + var property1Metadata = CreateModelMetadata(property1Identity, metadataProvider.Object, hasValidators: false); var property2Identity = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(TypeWithProperties.PublicGetProtectedSetProperty)), typeof(int), modelType); - var property2Metadata = CreateModelMetadata(property2Identity, metadataProvider.Object, true); + var property2Metadata = CreateModelMetadata(property2Identity, metadataProvider.Object, hasValidators: true); metadataProvider .Setup(mp => mp.GetMetadataForProperties(modelType)) @@ -1054,10 +1054,10 @@ public void CalculateHasValidators_TypeWithProperties_HasValidatorsForPropertyIs var modelType = typeof(TypeWithProperties); var modelIdentity = ModelMetadataIdentity.ForType(modelType); var metadataProvider = new Mock(); - var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false); + var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false); var propertyIdentity = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(TypeWithProperties.PublicGetPublicSetProperty)), typeof(int), modelType); - var propertyMetadata = CreateModelMetadata(propertyIdentity, metadataProvider.Object, null); + var propertyMetadata = CreateModelMetadata(propertyIdentity, metadataProvider.Object, hasValidators: null); metadataProvider .Setup(mp => mp.GetMetadataForProperties(modelType)) @@ -1078,13 +1078,13 @@ public void CalculateHasValidators_TypeWithProperties_HasValidatorForAllProperti var modelType = typeof(TypeWithProperties); var modelIdentity = ModelMetadataIdentity.ForType(modelType); var metadataProvider = new Mock(); - var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false); + var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false); var property1Identity = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(TypeWithProperties.PublicGetPublicSetProperty)), typeof(int), modelType); - var property1Metadata = CreateModelMetadata(property1Identity, metadataProvider.Object, false); + var property1Metadata = CreateModelMetadata(property1Identity, metadataProvider.Object, hasValidators: false); var property2Identity = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(TypeWithProperties.PublicGetProtectedSetProperty)), typeof(int), modelType); - var property2Metadata = CreateModelMetadata(property2Identity, metadataProvider.Object, false); + var property2Metadata = CreateModelMetadata(property2Identity, metadataProvider.Object, hasValidators: false); metadataProvider .Setup(mp => mp.GetMetadataForProperties(modelType)) @@ -1105,22 +1105,22 @@ public void CalculateHasValidators_SelfReferencingType_HasValidatorOnNestedPrope var modelType = typeof(Employee); var modelIdentity = ModelMetadataIdentity.ForType(modelType); var metadataProvider = new Mock(); - var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false); + var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false); var employeeId = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Id)), typeof(int), modelType); - var employeeIdMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false); + var employeeIdMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false); var employeeUnit = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Unit)), typeof(BusinessUnit), modelType); - var employeeUnitMetadata = CreateModelMetadata(employeeUnit, metadataProvider.Object, false); + var employeeUnitMetadata = CreateModelMetadata(employeeUnit, metadataProvider.Object, hasValidators: false); var employeeManager = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Manager)), typeof(Employee), modelType); - var employeeManagerMetadata = CreateModelMetadata(employeeManager, metadataProvider.Object, false); + var employeeManagerMetadata = CreateModelMetadata(employeeManager, metadataProvider.Object, hasValidators: false); var employeeEmployees = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Employees)), typeof(List), modelType); - var employeeEmployeesMetadata = CreateModelMetadata(employeeEmployees, metadataProvider.Object, false); + var employeeEmployeesMetadata = CreateModelMetadata(employeeEmployees, metadataProvider.Object, hasValidators: false); var unitModel = typeof(BusinessUnit); var unitHead = ModelMetadataIdentity.ForProperty(unitModel.GetProperty(nameof(BusinessUnit.Head)), typeof(Employee), unitModel); - var unitHeadMetadata = CreateModelMetadata(unitHead, metadataProvider.Object, false); + var unitHeadMetadata = CreateModelMetadata(unitHead, metadataProvider.Object, hasValidators: false); var unitId = ModelMetadataIdentity.ForProperty(unitModel.GetProperty(nameof(BusinessUnit.Id)), typeof(int), unitModel); - var unitIdMetadata = CreateModelMetadata(unitId, metadataProvider.Object, true); // BusinessUnit.Id has validators. + var unitIdMetadata = CreateModelMetadata(unitId, metadataProvider.Object, hasValidators: true); // BusinessUnit.Id has validators. metadataProvider .Setup(mp => mp.GetMetadataForProperties(modelType)) @@ -1146,22 +1146,22 @@ public void CalculateHasValidators_SelfReferencingType_HasValidatorOnSelfReferen var modelType = typeof(Employee); var modelIdentity = ModelMetadataIdentity.ForType(modelType); var metadataProvider = new Mock(); - var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false); + var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false); var employeeId = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Id)), typeof(int), modelType); - var employeeIdMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false); + var employeeIdMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false); var employeeUnit = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Unit)), typeof(BusinessUnit), modelType); - var employeeUnitMetadata = CreateModelMetadata(employeeUnit, metadataProvider.Object, false); + var employeeUnitMetadata = CreateModelMetadata(employeeUnit, metadataProvider.Object, hasValidators: false); var employeeManager = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Manager)), typeof(Employee), modelType); - var employeeManagerMetadata = CreateModelMetadata(employeeManager, metadataProvider.Object, false); + var employeeManagerMetadata = CreateModelMetadata(employeeManager, metadataProvider.Object, hasValidators: false); var employeeEmployees = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Employees)), typeof(List), modelType); - var employeeEmployeesMetadata = CreateModelMetadata(employeeEmployees, metadataProvider.Object, false); + var employeeEmployeesMetadata = CreateModelMetadata(employeeEmployees, metadataProvider.Object, hasValidators: false); var unitModel = typeof(BusinessUnit); var unitHead = ModelMetadataIdentity.ForProperty(unitModel.GetProperty(nameof(BusinessUnit.Head)), typeof(Employee), unitModel); - var unitHeadMetadata = CreateModelMetadata(unitHead, metadataProvider.Object, true); // BusinessUnit.Head has validators + var unitHeadMetadata = CreateModelMetadata(unitHead, metadataProvider.Object, hasValidators: true); // BusinessUnit.Head has validators var unitId = ModelMetadataIdentity.ForProperty(unitModel.GetProperty(nameof(BusinessUnit.Id)), typeof(int), unitModel); - var unitIdMetadata = CreateModelMetadata(unitId, metadataProvider.Object, false); + var unitIdMetadata = CreateModelMetadata(unitId, metadataProvider.Object, hasValidators: false); metadataProvider .Setup(mp => mp.GetMetadataForProperties(modelType)) @@ -1189,12 +1189,12 @@ public void CalculateHasValidators_CollectionElementHasValidators() var modelType = typeof(Employee); var modelIdentity = ModelMetadataIdentity.ForType(modelType); var metadataProvider = new Mock(); - var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false); + var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false); var employeeId = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Id)), typeof(int), modelType); var employeeIdMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false); var employeeEmployees = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Employees)), typeof(List), modelType); - var employeeEmployeesMetadata = CreateModelMetadata(employeeEmployees, metadataProvider.Object, false); + var employeeEmployeesMetadata = CreateModelMetadata(employeeEmployees, metadataProvider.Object, hasValidators: false); metadataProvider .Setup(mp => mp.GetMetadataForProperties(modelType)) @@ -1202,7 +1202,7 @@ public void CalculateHasValidators_CollectionElementHasValidators() metadataProvider .Setup(mp => mp.GetMetadataForType(modelType)) - .Returns(CreateModelMetadata(modelIdentity, metadataProvider.Object, true)); // Employees.Employee has validators + .Returns(CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: true)); // Employees.Employee has validators // Act var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); @@ -1218,22 +1218,22 @@ public void CalculateHasValidators_SelfReferencingType_NoValidatorsInGraph() var modelType = typeof(Employee); var modelIdentity = ModelMetadataIdentity.ForType(modelType); var metadataProvider = new Mock(); - var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false); + var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false); var employeeId = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Id)), typeof(int), modelType); - var employeeIdMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, false); + var employeeIdMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false); var employeeUnit = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Unit)), typeof(BusinessUnit), modelType); - var employeeUnitMetadata = CreateModelMetadata(employeeUnit, metadataProvider.Object, false); + var employeeUnitMetadata = CreateModelMetadata(employeeUnit, metadataProvider.Object, hasValidators: false); var employeeManager = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Manager)), typeof(Employee), modelType); - var employeeManagerMetadata = CreateModelMetadata(employeeManager, metadataProvider.Object, false); + var employeeManagerMetadata = CreateModelMetadata(employeeManager, metadataProvider.Object, hasValidators: false); var employeeEmployeesId = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(Employee.Employees)), typeof(List), modelType); - var employeeEmployeesIdMetadata = CreateModelMetadata(employeeEmployeesId, metadataProvider.Object, false); + var employeeEmployeesIdMetadata = CreateModelMetadata(employeeEmployeesId, metadataProvider.Object, hasValidators: false); var unitModel = typeof(BusinessUnit); var unitHead = ModelMetadataIdentity.ForProperty(unitModel.GetProperty(nameof(BusinessUnit.Head)), typeof(Employee), unitModel); - var unitHeadMetadata = CreateModelMetadata(unitHead, metadataProvider.Object, false); + var unitHeadMetadata = CreateModelMetadata(unitHead, metadataProvider.Object, hasValidators: false); var unitId = ModelMetadataIdentity.ForProperty(unitModel.GetProperty(nameof(BusinessUnit.Id)), typeof(int), unitModel); - var unitIdMetadata = CreateModelMetadata(unitId, metadataProvider.Object, false); + var unitIdMetadata = CreateModelMetadata(unitId, metadataProvider.Object, hasValidators: false); metadataProvider .Setup(mp => mp.GetMetadataForProperties(modelType)) @@ -1254,8 +1254,277 @@ public void CalculateHasValidators_SelfReferencingType_NoValidatorsInGraph() Assert.False(result); } + private record SimpleRecordType(int Property); + + [Fact] + public void CalculateHasValidators_RecordType_ParametersWithNoValidators() + { + // Arrange + var modelType = typeof(SimpleRecordType); + var constructor = modelType.GetConstructors().Single(); + var parameter = constructor.GetParameters().Single(); + var modelIdentity = ModelMetadataIdentity.ForType(modelType); + var metadataProvider = new Mock(); + var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false); + modelMetadata.BindingMetadata.BoundConstructor = constructor; + + var propertyId = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(SimpleRecordType.Property)), typeof(int), modelType); + var propertyMetadata = CreateModelMetadata(propertyId, metadataProvider.Object, hasValidators: false); + + var parameterId = ModelMetadataIdentity.ForParameter(parameter); + // Parameter has no validation metadata. + var parameterMetadata = CreateModelMetadata(parameterId, metadataProvider.Object, hasValidators: false); + + var constructorMetadata = CreateModelMetadata( + ModelMetadataIdentity.ForConstructor(constructor, modelType), metadataProvider.Object, hasValidators: null); + constructorMetadata.Details.BoundConstructorParameters = new[] + { + parameterMetadata, + }; + + metadataProvider + .Setup(mp => mp.GetMetadataForConstructor(constructor, modelType)) + .Returns(constructorMetadata) + .Verifiable(); + + metadataProvider + .Setup(mp => mp.GetMetadataForProperties(modelType)) + .Returns(new[] { propertyMetadata }) + .Verifiable(); + + // Act + var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); + + // Assert + Assert.False(result); + metadataProvider.Verify(); + } + + [Fact] + public void CalculateHasValidators_RecordType_ParametersWithValidators() + { + // Arrange + var modelType = typeof(SimpleRecordType); + var constructor = modelType.GetConstructors().Single(); + var parameter = constructor.GetParameters().Single(); + var modelIdentity = ModelMetadataIdentity.ForType(modelType); + var metadataProvider = new Mock(); + var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false); + modelMetadata.BindingMetadata.BoundConstructor = constructor; + + var propertyId = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(SimpleRecordType.Property)), typeof(int), modelType); + var propertyMetadata = CreateModelMetadata(propertyId, metadataProvider.Object, hasValidators: false); + + var parameterId = ModelMetadataIdentity.ForParameter(parameter); + // Parameter has some validation metadata. + var parameterMetadata = CreateModelMetadata(parameterId, metadataProvider.Object, hasValidators: true); + + var constructorMetadata = CreateModelMetadata(ModelMetadataIdentity.ForConstructor(constructor, modelType), metadataProvider.Object, hasValidators: null); + constructorMetadata.Details.BoundConstructorParameters = new[] + { + parameterMetadata, + }; + + metadataProvider + .Setup(mp => mp.GetMetadataForConstructor(constructor, modelType)) + .Returns(constructorMetadata); + + metadataProvider + .Setup(mp => mp.GetMetadataForProperties(modelType)) + .Returns(new[] { propertyMetadata }); + + // Act + var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); + + // Assert + Assert.True(result); + } + + private record SimpleRecordTypeWithProperty(int Property) + { + public int Property2 { get; set; } + } + + [Fact] + public void CalculateHasValidators_RecordTypeWithProperty_NoValidators() + { + // Arrange + var modelType = typeof(SimpleRecordTypeWithProperty); + var constructor = modelType.GetConstructors().Single(); + var parameter = constructor.GetParameters().Single(); + var modelIdentity = ModelMetadataIdentity.ForType(modelType); + var metadataProvider = new Mock(); + var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false); + modelMetadata.BindingMetadata.BoundConstructor = constructor; + + var propertyId = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(SimpleRecordTypeWithProperty.Property)), typeof(int), modelType); + var propertyMetadata = CreateModelMetadata(propertyId, metadataProvider.Object, hasValidators: false); + + // Property2 has no validators + var property2Id = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(SimpleRecordTypeWithProperty.Property2)), typeof(int), modelType); + var property2Metadata = CreateModelMetadata(property2Id, metadataProvider.Object, hasValidators: false); + + // Parameter named "Property" has no validators + var parameterId = ModelMetadataIdentity.ForParameter(parameter); + var parameterMetadata = CreateModelMetadata(parameterId, metadataProvider.Object, hasValidators: false); + + var constructorMetadata = CreateModelMetadata( + ModelMetadataIdentity.ForConstructor(constructor, modelType), metadataProvider.Object, hasValidators: null); + constructorMetadata.Details.BoundConstructorParameters = new[] + { + parameterMetadata, + }; + + metadataProvider + .Setup(mp => mp.GetMetadataForConstructor(constructor, modelType)) + .Returns(constructorMetadata); + + metadataProvider + .Setup(mp => mp.GetMetadataForProperties(modelType)) + .Returns(new[] { propertyMetadata, property2Metadata }); + + // Act + var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); + + // Assert + Assert.False(result); + } + + [Fact] + public void CalculateHasValidators_RecordTypeWithProperty_ParameteryHasValidators() + { + // Arrange + var modelType = typeof(SimpleRecordTypeWithProperty); + var constructor = modelType.GetConstructors().Single(); + var parameter = constructor.GetParameters().Single(); + var modelIdentity = ModelMetadataIdentity.ForType(modelType); + var metadataProvider = new Mock(); + var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false); + modelMetadata.BindingMetadata.BoundConstructor = constructor; + + var propertyId = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(SimpleRecordTypeWithProperty.Property)), typeof(int), modelType); + var propertyMetadata = CreateModelMetadata(propertyId, metadataProvider.Object, hasValidators: false); + + // Property2 has no validators + var property2Id = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(SimpleRecordTypeWithProperty.Property2)), typeof(int), modelType); + var property2Metadata = CreateModelMetadata(property2Id, metadataProvider.Object, hasValidators: false); + + // Parameter named "Property" has validators + var parameterId = ModelMetadataIdentity.ForParameter(parameter); + var parameterMetadata = CreateModelMetadata(parameterId, metadataProvider.Object, hasValidators: true); + + var constructorMetadata = CreateModelMetadata( + ModelMetadataIdentity.ForConstructor(constructor, modelType), metadataProvider.Object, hasValidators: null); + constructorMetadata.Details.BoundConstructorParameters = new[] + { + parameterMetadata, + }; + + metadataProvider + .Setup(mp => mp.GetMetadataForConstructor(constructor, modelType)) + .Returns(constructorMetadata); + + metadataProvider + .Setup(mp => mp.GetMetadataForProperties(modelType)) + .Returns(new[] { propertyMetadata, property2Metadata }); + + // Act + var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); + + // Assert + Assert.True(result); + } + + [Fact] + public void CalculateHasValidators_RecordTypeWithProperty_PropertyHasValidators() + { + // Arrange + var modelType = typeof(SimpleRecordTypeWithProperty); + var constructor = modelType.GetConstructors().Single(); + var parameter = constructor.GetParameters().Single(); + var modelIdentity = ModelMetadataIdentity.ForType(modelType); + var metadataProvider = new Mock(); + var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false); + modelMetadata.BindingMetadata.BoundConstructor = constructor; + + var propertyId = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(SimpleRecordTypeWithProperty.Property)), typeof(int), modelType); + var propertyMetadata = CreateModelMetadata(propertyId, metadataProvider.Object, hasValidators: false); + + // Property2 has some validators + var property2Id = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(SimpleRecordTypeWithProperty.Property2)), typeof(int), modelType); + var property2Metadata = CreateModelMetadata(property2Id, metadataProvider.Object, hasValidators: true); + + // Parameter named "Property" has no validators + var parameterId = ModelMetadataIdentity.ForParameter(parameter); + var parameterMetadata = CreateModelMetadata(parameterId, metadataProvider.Object, hasValidators: false); + + var constructorMetadata = CreateModelMetadata( + ModelMetadataIdentity.ForConstructor(constructor, modelType), metadataProvider.Object, hasValidators: null); + constructorMetadata.Details.BoundConstructorParameters = new[] + { + parameterMetadata, + }; + + metadataProvider + .Setup(mp => mp.GetMetadataForConstructor(constructor, modelType)) + .Returns(constructorMetadata); + + metadataProvider + .Setup(mp => mp.GetMetadataForProperties(modelType)) + .Returns(new[] { propertyMetadata, property2Metadata }); + + // Act + var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); + + // Assert + Assert.True(result); + } + + [Fact] + public void CalculateHasValidators_RecordTypeWithProperty_MappedPropertyHasValidators_ValidatorsAreIgnored() + { + // Arrange + var modelType = typeof(SimpleRecordTypeWithProperty); + var constructor = modelType.GetConstructors().Single(); + var parameter = constructor.GetParameters().Single(); + var modelIdentity = ModelMetadataIdentity.ForType(modelType); + var metadataProvider = new Mock(); + var modelMetadata = CreateModelMetadata(modelIdentity, metadataProvider.Object, hasValidators: false); + modelMetadata.BindingMetadata.BoundConstructor = constructor; + + var propertyId = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(SimpleRecordTypeWithProperty.Property)), typeof(int), modelType); + var propertyMetadata = CreateModelMetadata(propertyId, metadataProvider.Object, hasValidators: true); + + var property2Id = ModelMetadataIdentity.ForProperty(modelType.GetProperty(nameof(SimpleRecordTypeWithProperty.Property2)), typeof(int), modelType); + var property2Metadata = CreateModelMetadata(property2Id, metadataProvider.Object, hasValidators: false); + + var parameterId = ModelMetadataIdentity.ForParameter(parameter); + var parameterMetadata = CreateModelMetadata(parameterId, metadataProvider.Object, hasValidators: false); + + var constructorMetadata = CreateModelMetadata( + ModelMetadataIdentity.ForConstructor(constructor, modelType), metadataProvider.Object, hasValidators: null); + constructorMetadata.Details.BoundConstructorParameters = new[] + { + parameterMetadata, + }; + + metadataProvider + .Setup(mp => mp.GetMetadataForConstructor(constructor, modelType)) + .Returns(constructorMetadata); + + metadataProvider + .Setup(mp => mp.GetMetadataForProperties(modelType)) + .Returns(new[] { propertyMetadata, property2Metadata }); + + // Act + var result = DefaultModelMetadata.CalculateHasValidators(new HashSet(), modelMetadata); + + // Assert + Assert.False(result); + } + private static DefaultModelMetadata CreateModelMetadata( - ModelMetadataIdentity modelIdentity, + ModelMetadataIdentity modelIdentity, IModelMetadataProvider metadataProvider, bool? hasValidators) { diff --git a/src/Mvc/Mvc.Core/test/ModelBinding/ModelBindingHelperTest.cs b/src/Mvc/Mvc.Core/test/ModelBinding/ModelBindingHelperTest.cs index 6f858059ff53..59ed31da7208 100644 --- a/src/Mvc/Mvc.Core/test/ModelBinding/ModelBindingHelperTest.cs +++ b/src/Mvc/Mvc.Core/test/ModelBinding/ModelBindingHelperTest.cs @@ -54,7 +54,7 @@ public async Task TryUpdateModel_ReturnsFalse_IfModelValidationFails() var binderProviders = new IModelBinderProvider[] { new SimpleTypeModelBinderProvider(), - new ComplexTypeModelBinderProvider(), + new ComplexObjectModelBinderProvider(), }; var validator = new DataAnnotationsModelValidatorProvider( @@ -96,7 +96,7 @@ public async Task TryUpdateModel_ReturnsTrue_IfModelBindsAndValidatesSuccessfull var binderProviders = new IModelBinderProvider[] { new SimpleTypeModelBinderProvider(), - new ComplexTypeModelBinderProvider(), + new ComplexObjectModelBinderProvider(), }; var validator = new DataAnnotationsModelValidatorProvider( @@ -162,7 +162,7 @@ public async Task TryUpdateModel_UsingPropertyFilterOverload_ReturnsTrue_ModelBi var binderProviders = new IModelBinderProvider[] { new SimpleTypeModelBinderProvider(), - new ComplexTypeModelBinderProvider(), + new ComplexObjectModelBinderProvider(), }; var validator = new DataAnnotationsModelValidatorProvider( @@ -242,7 +242,7 @@ public async Task TryUpdateModel_UsingIncludeExpressionOverload_ReturnsTrue_Mode var binderProviders = new IModelBinderProvider[] { new SimpleTypeModelBinderProvider(), - new ComplexTypeModelBinderProvider(), + new ComplexObjectModelBinderProvider(), }; var validator = new DataAnnotationsModelValidatorProvider( @@ -293,7 +293,7 @@ public async Task TryUpdateModel_UsingDefaultIncludeOverload_IncludesAllProperti var binderProviders = new IModelBinderProvider[] { new SimpleTypeModelBinderProvider(), - new ComplexTypeModelBinderProvider(), + new ComplexObjectModelBinderProvider(), }; var validator = new DataAnnotationsModelValidatorProvider( @@ -490,7 +490,7 @@ public async Task TryUpdateModelNonGeneric_PropertyFilterOverload_ReturnsTrue_Mo var binderProviders = new IModelBinderProvider[] { new SimpleTypeModelBinderProvider(), - new ComplexTypeModelBinderProvider(), + new ComplexObjectModelBinderProvider(), }; var validator = new DataAnnotationsModelValidatorProvider( @@ -570,7 +570,7 @@ public async Task TryUpdateModelNonGeneric_ModelTypeOverload_ReturnsTrue_IfModel var binderProviders = new IModelBinderProvider[] { new SimpleTypeModelBinderProvider(), - new ComplexTypeModelBinderProvider(), + new ComplexObjectModelBinderProvider(), }; var validator = new DataAnnotationsModelValidatorProvider( diff --git a/src/Mvc/Mvc.DataAnnotations/test/ModelMetadataProviderTest.cs b/src/Mvc/Mvc.DataAnnotations/test/ModelMetadataProviderTest.cs index 2a40de238d5e..8b3fff9f320e 100644 --- a/src/Mvc/Mvc.DataAnnotations/test/ModelMetadataProviderTest.cs +++ b/src/Mvc/Mvc.DataAnnotations/test/ModelMetadataProviderTest.cs @@ -630,7 +630,7 @@ public void BinderTypeProviders_Fallback() var attributes = new[] { new TestBinderTypeProvider(), - new TestBinderTypeProvider() { BinderType = typeof(ComplexTypeModelBinder) } + new TestBinderTypeProvider() { BinderType = typeof(ComplexObjectModelBinder) } }; var provider = CreateProvider(attributes); @@ -639,7 +639,7 @@ public void BinderTypeProviders_Fallback() var metadata = provider.GetMetadataForType(typeof(string)); // Assert - Assert.Same(typeof(ComplexTypeModelBinder), metadata.BinderType); + Assert.Same(typeof(ComplexObjectModelBinder), metadata.BinderType); } [Fact] @@ -648,7 +648,7 @@ public void BinderTypeProviders_FirstAttributeHasPrecedence() // Arrange var attributes = new[] { - new TestBinderTypeProvider() { BinderType = typeof(ComplexTypeModelBinder) }, + new TestBinderTypeProvider() { BinderType = typeof(ComplexObjectModelBinder) }, new TestBinderTypeProvider() { BinderType = typeof(SimpleTypeModelBinder) } }; @@ -658,7 +658,7 @@ public void BinderTypeProviders_FirstAttributeHasPrecedence() var metadata = provider.GetMetadataForType(typeof(string)); // Assert - Assert.Same(typeof(ComplexTypeModelBinder), metadata.BinderType); + Assert.Same(typeof(ComplexObjectModelBinder), metadata.BinderType); } [Fact] diff --git a/src/Mvc/Mvc/test/MvcOptionsSetupTest.cs b/src/Mvc/Mvc/test/MvcOptionsSetupTest.cs index 6b686831183b..eb8d0933b48c 100644 --- a/src/Mvc/Mvc/test/MvcOptionsSetupTest.cs +++ b/src/Mvc/Mvc/test/MvcOptionsSetupTest.cs @@ -67,7 +67,7 @@ public void Setup_SetsUpModelBinderProviders() binder => Assert.IsType(binder), binder => Assert.IsType(binder), binder => Assert.IsType(binder), - binder => Assert.IsType(binder)); + binder => Assert.IsType(binder)); } [Fact] diff --git a/src/Mvc/test/Mvc.FunctionalTests/HtmlGenerationTest.cs b/src/Mvc/test/Mvc.FunctionalTests/HtmlGenerationTest.cs index 75ac9df6d976..98416f442ac9 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/HtmlGenerationTest.cs +++ b/src/Mvc/test/Mvc.FunctionalTests/HtmlGenerationTest.cs @@ -299,6 +299,40 @@ public async Task ValidationTagHelpers_GeneratesExpectedSpansAndDivs() #endif } + [Fact] + public async Task ValidationTagHelpers_UsingRecords() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Post, "http://localhost/Customer/HtmlGeneration_Customer/CustomerWithRecords"); + var nameValueCollection = new List> + { + new KeyValuePair("Number", string.Empty), + new KeyValuePair("Name", string.Empty), + new KeyValuePair("Email", string.Empty), + new KeyValuePair("PhoneNumber", string.Empty), + new KeyValuePair("Password", string.Empty) + }; + request.Content = new FormUrlEncodedContent(nameValueCollection); + + // Act + var response = await Client.SendAsync(request); + + // Assert + var document = await response.GetHtmlDocumentAsync(); + + var validation = document.RequiredQuerySelector("span[data-valmsg-for=Number]"); + Assert.Equal("The value '' is invalid.", validation.TextContent); + + validation = document.QuerySelector("span[data-valmsg-for=Name]"); + Assert.Null(validation); + + validation = document.QuerySelector("span[data-valmsg-for=Email]"); + Assert.Equal("field-validation-valid", validation.ClassName); + + validation = document.QuerySelector("span[data-valmsg-for=Password]"); + Assert.Equal("The Password field is required.", validation.TextContent); + } + [Fact] public async Task CacheTagHelper_CanCachePortionsOfViewsPartialViewsAndViewComponents() { diff --git a/src/Mvc/test/Mvc.FunctionalTests/JsonInputFormatterTestBase.cs b/src/Mvc/test/Mvc.FunctionalTests/JsonInputFormatterTestBase.cs index 347bca0e2548..966d0a51e964 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/JsonInputFormatterTestBase.cs +++ b/src/Mvc/test/Mvc.FunctionalTests/JsonInputFormatterTestBase.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Net; using System.Net.Http; +using System.Net.Http.Json; using System.Text; using System.Text.Json.Serialization; using System.Threading.Tasks; @@ -121,6 +122,71 @@ public async Task JsonInputFormatter_RoundtripsPocoModel() Assert.Equal(expected.StreetName, actual.StreetName); } + [Fact] + public virtual async Task JsonInputFormatter_RoundtripsRecordType() + { + // Arrange + var expected = new JsonFormatterController.SimpleRecordModel(18, "James", "JnK"); + + // Act + var response = await Client.PostAsJsonAsync("http://localhost/JsonFormatter/RoundtripRecordType/", expected); + var actual = await response.Content.ReadAsAsync(); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + Assert.Equal(expected.Id, actual.Id); + Assert.Equal(expected.Name, actual.Name); + Assert.Equal(expected.StreetName, actual.StreetName); + } + + [Fact] + public virtual async Task JsonInputFormatter_ValidationWithRecordTypes_ValidationErrors() + { + // Arrange + var expected = new JsonFormatterController.SimpleModelWithValidation(123, "This is a very long name", StreetName: null); + + // Act + var response = await Client.PostAsJsonAsync($"JsonFormatter/{nameof(JsonFormatterController.RoundtripModelWithValidation)}", expected); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.BadRequest); + var problem = await response.Content.ReadFromJsonAsync(); + Assert.Collection( + problem.Errors.OrderBy(e => e.Key), + kvp => + { + Assert.Equal("Id", kvp.Key); + Assert.Equal("The field Id must be between 1 and 100.", Assert.Single(kvp.Value)); + }, + kvp => + { + Assert.Equal("Name", kvp.Key); + Assert.Equal("The field Name must be a string with a minimum length of 2 and a maximum length of 8.", Assert.Single(kvp.Value)); + }, + kvp => + { + Assert.Equal("StreetName", kvp.Key); + Assert.Equal("The StreetName field is required.", Assert.Single(kvp.Value)); + }); + } + + [Fact] + public virtual async Task JsonInputFormatter_ValidationWithRecordTypes_NoValidationErrors() + { + // Arrange + var expected = new JsonFormatterController.SimpleModelWithValidation(99, "TestName", "Some address"); + + // Act + var response = await Client.PostAsJsonAsync($"JsonFormatter/{nameof(JsonFormatterController.RoundtripModelWithValidation)}", expected); + + // Assert + await response.AssertStatusCodeAsync(HttpStatusCode.OK); + var actual = await response.Content.ReadFromJsonAsync(); + Assert.Equal(expected.Id, actual.Id); + Assert.Equal(expected.Name, actual.Name); + Assert.Equal(expected.StreetName, actual.StreetName); + } + [Fact] public async Task JsonInputFormatter_Returns415UnsupportedMediaType_ForEmptyContentType() { diff --git a/src/Mvc/test/Mvc.FunctionalTests/SystemTextJsonInputFormatterTest.cs b/src/Mvc/test/Mvc.FunctionalTests/SystemTextJsonInputFormatterTest.cs index 9e09314712f9..a81adfeb804c 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/SystemTextJsonInputFormatterTest.cs +++ b/src/Mvc/test/Mvc.FunctionalTests/SystemTextJsonInputFormatterTest.cs @@ -13,13 +13,16 @@ public SystemTextJsonInputFormatterTest(MvcTestFixture base.JsonInputFormatter_RoundtripsRecordType(); + + [Fact(Skip = "https://github.com/dotnet/runtime/issues/38539")] + public override Task JsonInputFormatter_ValidationWithRecordTypes_NoValidationErrors() + => base.JsonInputFormatter_ValidationWithRecordTypes_NoValidationErrors(); + + [Fact(Skip = "https://github.com/dotnet/runtime/issues/38539")] + public override Task JsonInputFormatter_ValidationWithRecordTypes_ValidationErrors() + => base.JsonInputFormatter_ValidationWithRecordTypes_ValidationErrors(); } } diff --git a/src/Mvc/test/Mvc.IntegrationTests/ActionParametersIntegrationTest.cs b/src/Mvc/test/Mvc.IntegrationTests/ActionParametersIntegrationTest.cs index 6f84a74c88b5..99d82b9b719c 100644 --- a/src/Mvc/test/Mvc.IntegrationTests/ActionParametersIntegrationTest.cs +++ b/src/Mvc/test/Mvc.IntegrationTests/ActionParametersIntegrationTest.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; using Microsoft.Extensions.Logging.Abstractions; using Xunit; @@ -403,6 +404,72 @@ public async Task ActionParameter_ModelPropertyTypeWithNoParameterlessConstructo }); var modelState = testContext.ModelState; + // Act & Assert + var exception = await Assert.ThrowsAsync(() => parameterBinder.BindModelAsync(parameter, testContext)); + Assert.Equal( + string.Format( + "Could not create an instance of type '{0}'. Model bound complex types must not be abstract or " + + "value types and must have a parameterless constructor. Record types must have a single primary constructor. " + + "Alternatively, set the '{1}' property to a non-null value in the '{2}' constructor.", + typeof(ClassWithNoDefaultConstructor).FullName, + nameof(Class1.Property1), + typeof(Class1).FullName), + exception.Message); + } + + public record ActionParameter_DefaultValueConstructor(string Name = "test", int Age = 23); + + [Fact] + public async Task ActionParameter_UsesDefaultConstructorParameters() + { + // Arrange + var parameterType = typeof(ActionParameter_DefaultValueConstructor); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "p", + ParameterType = parameterType + }; + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = QueryString.Create("Name", "James"); + }); + var modelState = testContext.ModelState; + + // Act + var result = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelState.IsValid); + + var model = Assert.IsType(result.Model); + Assert.Equal("James", model.Name); + Assert.Equal(23, model.Age); + } + + [Fact] + public async Task ActionParameter_UsingComplexTypeModelBinder_ModelPropertyTypeWithNoParameterlessConstructor_ThrowsException() + { + // Arrange + var parameterType = typeof(Class1); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "p", + ParameterType = parameterType + }; + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = QueryString.Create("Name", "James").Add("Property1.City", "Seattle"); + }, updateOptions: options => + { + options.ModelBinderProviders.RemoveType(); +#pragma warning disable CS0618 // Type or member is obsolete + options.ModelBinderProviders.Add(new ComplexTypeModelBinderProvider()); +#pragma warning restore CS0618 // Type or member is obsolete + }); + var modelState = testContext.ModelState; + // Act & Assert var exception = await Assert.ThrowsAsync(() => parameterBinder.BindModelAsync(parameter, testContext)); Assert.Equal( @@ -434,21 +501,19 @@ public async Task ActionParameter_BindingToStructModel_ThrowsException() Assert.Equal( string.Format( "Could not create an instance of type '{0}'. Model bound complex types must not be abstract or " + - "value types and must have a parameterless constructor.", + "value types and must have a parameterless constructor. Record types must have a single primary constructor.", typeof(PointStruct).FullName), exception.Message); } - [Theory] - [InlineData(typeof(ClassWithNoDefaultConstructor))] - [InlineData(typeof(AbstractClassWithNoDefaultConstructor))] - public async Task ActionParameter_BindingToTypeWithNoParameterlessConstructor_ThrowsException(Type parameterType) + [Fact] + public async Task ActionParameter_BindingToAbstractionType_ThrowsException() { // Arrange var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); var parameter = new ParameterDescriptor() { - ParameterType = parameterType, + ParameterType = typeof(AbstractClassWithNoDefaultConstructor), Name = "p" }; var testContext = ModelBindingTestHelper.GetTestContext(); @@ -458,8 +523,78 @@ public async Task ActionParameter_BindingToTypeWithNoParameterlessConstructor_Th Assert.Equal( string.Format( "Could not create an instance of type '{0}'. Model bound complex types must not be abstract or " + - "value types and must have a parameterless constructor.", - parameterType.FullName), + "value types and must have a parameterless constructor. Record types must have a single primary constructor.", + typeof(AbstractClassWithNoDefaultConstructor).FullName), + exception.Message); + } + + public class ActionParameter_MultipleConstructorsWithDefaultValues_NoParameterlessConstructorModel + { + public ActionParameter_MultipleConstructorsWithDefaultValues_NoParameterlessConstructorModel(string name = "default-name") => (Name) = (name); + + public ActionParameter_MultipleConstructorsWithDefaultValues_NoParameterlessConstructorModel(string name, int age) => (Name, Age) = (name, age); + + public string Name { get; init; } + + public int Age { get; init; } + } + + [Fact] + public async Task ActionParameter_MultipleConstructorsWithDefaultValues_NoParameterlessConstructor_Throws() + { + // Arrange + var parameterType = typeof(ActionParameter_MultipleConstructorsWithDefaultValues_NoParameterlessConstructorModel); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "p", + ParameterType = parameterType + }; + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = QueryString.Create("Name", "James"); + }); + var modelState = testContext.ModelState; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => parameterBinder.BindModelAsync(parameter, testContext)); + Assert.Equal( + string.Format( + "Could not create an instance of type '{0}'. Model bound complex types must not be abstract or " + + "value types and must have a parameterless constructor. Record types must have a single primary constructor.", + typeof(ActionParameter_MultipleConstructorsWithDefaultValues_NoParameterlessConstructorModel).FullName), + exception.Message); + } + + public record ActionParameter_RecordTypeWithMultipleConstructors(string Name, int Age) + { + public ActionParameter_RecordTypeWithMultipleConstructors(string Name) : this(Name, 0) { } + } + + [Fact] + public async Task ActionParameter_RecordTypeWithMultipleConstructors_Throws() + { + // Arrange + var parameterType = typeof(ActionParameter_RecordTypeWithMultipleConstructors); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "p", + ParameterType = parameterType + }; + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = QueryString.Create("Name", "James").Add("Age", "29"); + }); + var modelState = testContext.ModelState; + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => parameterBinder.BindModelAsync(parameter, testContext)); + Assert.Equal( + string.Format( + "Could not create an instance of type '{0}'. Model bound complex types must not be abstract or " + + "value types and must have a parameterless constructor. Record types must have a single primary constructor.", + typeof(ActionParameter_RecordTypeWithMultipleConstructors).FullName), exception.Message); } @@ -527,6 +662,46 @@ public async Task ActionParameter_WithBindNever_DoesNotGetBound() Assert.True(modelState.IsValid); } + [Fact] + public async Task ActionParameter_WithValidateNever_DoesNotGetValidated() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = ParameterWithValidateNever.ValidateNeverParameterInfo.Name, + ParameterType = typeof(ModelWithIValidatableObject) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = QueryString.Create(nameof(ModelWithIValidatableObject.FirstName), "TestName"); + }); + + var modelState = testContext.ModelState; + var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var modelMetadata = modelMetadataProvider + .GetMetadataForParameter(ParameterWithValidateNever.ValidateNeverParameterInfo); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + parameter, + testContext, + modelMetadataProvider, + modelMetadata); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("TestName", model.FirstName); + + // No validation errors are expected. + // Assert.True(modelState.IsValid); + + // Tracking bug to enable this scenario: https://github.com/dotnet/aspnetcore/issues/24241 + Assert.False(modelState.IsValid); + } + [Theory] [InlineData(123, true)] [InlineData(null, false)] @@ -800,6 +975,29 @@ public static ParameterInfo GetParameterInfo(string parameterName) } } + private class ParameterWithValidateNever + { + public void MyAction([Required] string Name, [ValidateNever] ModelWithIValidatableObject validatableObject) + { + } + + private static MethodInfo MyActionMethodInfo + => typeof(ParameterWithValidateNever).GetMethod(nameof(MyAction)); + + public static ParameterInfo NameParamterInfo + => MyActionMethodInfo.GetParameters()[0]; + + public static ParameterInfo ValidateNeverParameterInfo + => MyActionMethodInfo.GetParameters()[1]; + + public static ParameterInfo GetParameterInfo(string parameterName) + { + return MyActionMethodInfo + .GetParameters() + .Single(p => p.Name.Equals(parameterName, StringComparison.Ordinal)); + } + } + private class CustomReadOnlyCollection : ICollection { private ICollection _original; @@ -865,7 +1063,9 @@ System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() // By default the ComplexTypeModelBinder fails to construct models for types with no parameterless constructor, // but a developer could change this behavior by overriding CreateModel +#pragma warning disable CS0618 // Type or member is obsolete private class CustomComplexTypeModelBinder : ComplexTypeModelBinder +#pragma warning restore CS0618 // Type or member is obsolete { public CustomComplexTypeModelBinder(IDictionary propertyBinders) : base(propertyBinders, NullLoggerFactory.Instance) diff --git a/src/Mvc/test/Mvc.IntegrationTests/ComplexObjectIntegrationTest.cs b/src/Mvc/test/Mvc.IntegrationTests/ComplexObjectIntegrationTest.cs new file mode 100644 index 000000000000..0f8fccce4fc2 --- /dev/null +++ b/src/Mvc/test/Mvc.IntegrationTests/ComplexObjectIntegrationTest.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; + +namespace Microsoft.AspNetCore.Mvc.IntegrationTests +{ + public class ComplexObjectIntegrationTest : ComplexTypeIntegrationTestBase + { + protected override Type ExpectedModelBinderType => typeof(ComplexObjectModelBinder); + } +} diff --git a/src/Mvc/test/Mvc.IntegrationTests/ComplexRecordIntegrationTest.cs b/src/Mvc/test/Mvc.IntegrationTests/ComplexRecordIntegrationTest.cs new file mode 100644 index 000000000000..f533be281214 --- /dev/null +++ b/src/Mvc/test/Mvc.IntegrationTests/ComplexRecordIntegrationTest.cs @@ -0,0 +1,4269 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.IntegrationTests +{ + // A clone of ComplexTypeIntegrationTestBase performed using record types + public class ComplexRecordIntegrationTest + { + private const string AddressBodyContent = "{ \"street\" : \"" + AddressStreetContent + "\" }"; + private const string AddressStreetContent = "1 Microsoft Way"; + + private static readonly byte[] ByteArrayContent = Encoding.BigEndianUnicode.GetBytes("abcd"); + private static readonly string ByteArrayEncoded = Convert.ToBase64String(ByteArrayContent); + + private record Order1(int ProductId, Person1 Customer); + + private record Person1(string Name, [FromBody] Address1 Address); + + private record Address1(string Street); + + [Fact] + public async Task BindsNestedPOCO_WithBodyModelBinder_WithPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order1) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Customer.Name=bill"); + SetJsonBodyContent(request, AddressBodyContent); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("bill", model.Customer.Name); + Assert.NotNull(model.Customer.Address); + Assert.Equal(AddressStreetContent, model.Customer.Address.Street); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + } + + [Fact] + public async Task BindsNestedPOCO_WithBodyModelBinder_WithEmptyPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order1) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Customer.Name=bill"); + SetJsonBodyContent(request, AddressBodyContent); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("bill", model.Customer.Name); + Assert.NotNull(model.Customer.Address); + Assert.Equal(AddressStreetContent, model.Customer.Address.Street); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Customer.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + } + + [Fact] + public async Task BindsNestedPOCO_WithBodyModelBinder_WithPrefix_NoBodyData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order1) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Customer.Name=bill"); + request.ContentType = "application/json"; + }); + + testContext.MvcOptions.AllowEmptyInputInBodyModelBinding = true; + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("bill", model.Customer.Name); + Assert.Null(model.Customer.Address); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + } + + [Fact] + public async Task BindsNestedPOCO_WithBodyModelBinder_WithPrefix_NoBodyData_ValueInQuery() + { + // With record types, constructor parameters also appear as settable properties. + // In this case, we will only attempt to bind the parameter and not the property. + + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order1) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Customer.Name=bill¶mater.Customer.Address=not-used"); + request.ContentType = "application/json"; + }); + + testContext.MvcOptions.AllowEmptyInputInBodyModelBinding = true; + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("bill", model.Customer.Name); + Assert.Null(model.Customer.Address); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + } + + [Fact] + public async Task BindsNestedPOCO_WithBodyModelBinder_WithPrefix_PartialData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order1) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.ProductId=10"); + SetJsonBodyContent(request, AddressBodyContent); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("1 Microsoft Way", model.Customer.Address.Street); + + Assert.Equal(10, model.ProductId); + + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState).Value; + Assert.Equal("10", entry.AttemptedValue); + Assert.Equal("10", entry.RawValue); + } + + [Fact] + public async Task BindsNestedPOCO_WithBodyModelBinder_WithPrefix_NoData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order1) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?"); + SetJsonBodyContent(request, AddressBodyContent); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("1 Microsoft Way", model.Customer.Address.Street); + + Assert.Empty(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + private record Order3(int ProductId, Person3 Customer); + + private record Person3(string Name, byte[] Token); + + [Fact] + public async Task BindsNestedPOCO_WithByteArrayModelBinder_WithPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order3) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = + new QueryString("?parameter.Customer.Name=bill¶meter.Customer.Token=" + ByteArrayEncoded); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("bill", model.Customer.Name); + Assert.Equal(ByteArrayContent, model.Customer.Token); + + Assert.Equal(2, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Token").Value; + Assert.Equal(ByteArrayEncoded, entry.AttemptedValue); + Assert.Equal(ByteArrayEncoded, entry.RawValue); + } + + [Fact] + public async Task BindsNestedPOCO_WithByteArrayModelBinder_WithEmptyPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order3) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Customer.Name=bill&Customer.Token=" + ByteArrayEncoded); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("bill", model.Customer.Name); + Assert.Equal(ByteArrayContent, model.Customer.Token); + + Assert.Equal(2, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Customer.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "Customer.Token").Value; + Assert.Equal(ByteArrayEncoded, entry.AttemptedValue); + Assert.Equal(ByteArrayEncoded, entry.RawValue); + } + + [Fact] + public async Task BindsNestedPOCO_WithByteArrayModelBinder_WithPrefix_NoData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order3) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Customer.Name=bill"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("bill", model.Customer.Name); + Assert.Null(model.Customer.Token); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + } + + private record Order4(int ProductId, Person4 Customer); + + private record Person4(string Name, IEnumerable Documents); + + [Fact] + public async Task BindsNestedPOCO_WithFormFileModelBinder_WithPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order4) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Customer.Name=bill"); + SetFormFileBodyContent(request, "Hello, World!", "parameter.Customer.Documents"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("bill", model.Customer.Name); + Assert.Single(model.Customer.Documents); + + Assert.Equal(2, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Documents").Value; + Assert.Null(entry.AttemptedValue); // FormFile entries for body don't include original text. + Assert.Null(entry.RawValue); + } + + [Fact] + public async Task BindsNestedPOCO_WithFormFileModelBinder_WithEmptyPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order4), + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Customer.Name=bill"); + SetFormFileBodyContent(request, "Hello, World!", "Customer.Documents"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("bill", model.Customer.Name); + Assert.Single(model.Customer.Documents); + + Assert.Equal(2, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Customer.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "Customer.Documents").Value; + Assert.Null(entry.AttemptedValue); // FormFile entries don't include the model. + Assert.Null(entry.RawValue); + } + + [Fact] + public async Task BindsNestedPOCO_WithFormFileModelBinder_WithPrefix_NoBodyData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order4) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Customer.Name=bill"); + + // Deliberately leaving out any form data. + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("bill", model.Customer.Name); + Assert.Null(model.Customer.Documents); + + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var kvp = Assert.Single(modelState); + Assert.Equal("parameter.Customer.Name", kvp.Key); + var entry = kvp.Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + } + + [Fact] + public async Task BindsNestedPOCO_WithFormFileModelBinder_WithPrefix_PartialData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order4) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.ProductId=10"); + SetFormFileBodyContent(request, "Hello, World!", "parameter.Customer.Documents"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + + var document = Assert.Single(model.Customer.Documents); + Assert.Equal("text.txt", document.FileName); + using (var reader = new StreamReader(document.OpenReadStream())) + { + Assert.Equal("Hello, World!", await reader.ReadToEndAsync()); + } + + Assert.Equal(10, model.ProductId); + + Assert.Equal(2, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + Assert.Single(modelState, e => e.Key == "parameter.Customer.Documents"); + var entry = Assert.Single(modelState, e => e.Key == "parameter.ProductId").Value; + Assert.Equal("10", entry.AttemptedValue); + Assert.Equal("10", entry.RawValue); + } + + [Fact] + public async Task BindsNestedPOCO_WithFormFileModelBinder_WithPrefix_NoData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order4) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?"); + SetFormFileBodyContent(request, "Hello, World!", "Customer.Documents"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + + var document = Assert.Single(model.Customer.Documents); + Assert.Equal("text.txt", document.FileName); + using (var reader = new StreamReader(document.OpenReadStream())) + { + Assert.Equal("Hello, World!", await reader.ReadToEndAsync()); + } + + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState); + Assert.Equal("Customer.Documents", entry.Key); + } + + private record Order5(string Name, int[] ProductIds); + + [Fact] + public async Task BindsArrayProperty_WithPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order5) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = + new QueryString("?parameter.Name=bill¶meter.ProductIds[0]=10¶meter.ProductIds[1]=11"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Name); + Assert.Equal(new int[] { 10, 11 }, model.ProductIds); + + Assert.Equal(3, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.ProductIds[0]").Value; + Assert.Equal("10", entry.AttemptedValue); + Assert.Equal("10", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.ProductIds[1]").Value; + Assert.Equal("11", entry.AttemptedValue); + Assert.Equal("11", entry.RawValue); + } + + [Fact] + public async Task BindsArrayProperty_EmptyPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order5) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Name=bill&ProductIds[0]=10&ProductIds[1]=11"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Name); + Assert.Equal(new int[] { 10, 11 }, model.ProductIds); + + Assert.Equal(3, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "ProductIds[0]").Value; + Assert.Equal("10", entry.AttemptedValue); + Assert.Equal("10", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "ProductIds[1]").Value; + Assert.Equal("11", entry.AttemptedValue); + Assert.Equal("11", entry.RawValue); + } + + [Fact] + public async Task BindsArrayProperty_NoCollectionData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order5) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Name=bill"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Name); + Assert.Null(model.ProductIds); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + } + + [Fact] + public async Task BindsArrayProperty_NoData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order5) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.Name); + Assert.Null(model.ProductIds); + + Assert.Empty(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + private record Order6(string Name, List ProductIds); + + [Fact] + public async Task BindsListProperty_WithPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order6) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = + new QueryString("?parameter.Name=bill¶meter.ProductIds[0]=10¶meter.ProductIds[1]=11"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Name); + Assert.Equal(new List() { 10, 11 }, model.ProductIds); + + Assert.Equal(3, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.ProductIds[0]").Value; + Assert.Equal("10", entry.AttemptedValue); + Assert.Equal("10", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.ProductIds[1]").Value; + Assert.Equal("11", entry.AttemptedValue); + Assert.Equal("11", entry.RawValue); + } + + [Fact] + public async Task BindsListProperty_EmptyPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order6) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Name=bill&ProductIds[0]=10&ProductIds[1]=11"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Name); + Assert.Equal(new List() { 10, 11 }, model.ProductIds); + + Assert.Equal(3, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "ProductIds[0]").Value; + Assert.Equal("10", entry.AttemptedValue); + Assert.Equal("10", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "ProductIds[1]").Value; + Assert.Equal("11", entry.AttemptedValue); + Assert.Equal("11", entry.RawValue); + } + + [Fact] + public async Task BindsListProperty_NoCollectionData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order6) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Name=bill"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Name); + Assert.Null(model.ProductIds); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + } + + [Fact] + public async Task BindsListProperty_NoData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order6) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.Name); + Assert.Null(model.ProductIds); + + Assert.Empty(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + private record Order7(string Name, Dictionary ProductIds); + + [Fact] + public async Task BindsDictionaryProperty_WithPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order7) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = + new QueryString("?parameter.Name=bill¶meter.ProductIds[0].Key=key0¶meter.ProductIds[0].Value=10"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Name); + Assert.Equal(new Dictionary() { { "key0", 10 } }, model.ProductIds); + + Assert.Equal(3, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.ProductIds[0].Key").Value; + Assert.Equal("key0", entry.AttemptedValue); + Assert.Equal("key0", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.ProductIds[0].Value").Value; + Assert.Equal("10", entry.AttemptedValue); + Assert.Equal("10", entry.RawValue); + } + + [Fact] + public async Task BindsDictionaryProperty_EmptyPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order7) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Name=bill&ProductIds[0].Key=key0&ProductIds[0].Value=10"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Name); + Assert.Equal(new Dictionary() { { "key0", 10 } }, model.ProductIds); + + Assert.Equal(3, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "ProductIds[0].Key").Value; + Assert.Equal("key0", entry.AttemptedValue); + Assert.Equal("key0", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "ProductIds[0].Value").Value; + Assert.Equal("10", entry.AttemptedValue); + Assert.Equal("10", entry.RawValue); + } + + [Fact] + public async Task BindsDictionaryProperty_NoCollectionData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order7) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Name=bill"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Name); + Assert.Null(model.ProductIds); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + } + + [Fact] + public async Task BindsDictionaryProperty_NoData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order7) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.Name); + Assert.Null(model.ProductIds); + + Assert.Empty(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + // Dictionary property with an IEnumerable<> value type + private record Car1(string Name, Dictionary> Specs); + + // Dictionary property with an Array value type + private record Car2(string Name, Dictionary Specs); + + private record Car3(string Name, IEnumerable>> Specs); + + private record SpecDoc(string Name); + + [Fact] + public async Task BindsDictionaryProperty_WithIEnumerableComplexTypeValue_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "p", + ParameterType = typeof(Car1) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + var queryString = "?p.Name=Accord" + + "&p.Specs[0].Key=camera_specs" + + "&p.Specs[0].Value[0].Name=camera_spec1.txt" + + "&p.Specs[0].Value[1].Name=camera_spec2.txt" + + "&p.Specs[1].Key=tyre_specs" + + "&p.Specs[1].Value[0].Name=tyre_spec1.txt" + + "&p.Specs[1].Value[1].Name=tyre_spec2.txt"; + request.QueryString = new QueryString(queryString); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("Accord", model.Name); + + Assert.Collection( + model.Specs, + (e) => + { + Assert.Equal("camera_specs", e.Key); + Assert.Collection( + e.Value, + (s) => + { + Assert.Equal("camera_spec1.txt", s.Name); + }, + (s) => + { + Assert.Equal("camera_spec2.txt", s.Name); + }); + }, + (e) => + { + Assert.Equal("tyre_specs", e.Key); + Assert.Collection( + e.Value, + (s) => + { + Assert.Equal("tyre_spec1.txt", s.Name); + }, + (s) => + { + Assert.Equal("tyre_spec2.txt", s.Name); + }); + }); + + Assert.Equal(7, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "p.Name").Value; + Assert.Equal("Accord", entry.AttemptedValue); + Assert.Equal("Accord", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Key").Value; + Assert.Equal("camera_specs", entry.AttemptedValue); + Assert.Equal("camera_specs", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Value[0].Name").Value; + Assert.Equal("camera_spec1.txt", entry.AttemptedValue); + Assert.Equal("camera_spec1.txt", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Value[1].Name").Value; + Assert.Equal("camera_spec2.txt", entry.AttemptedValue); + Assert.Equal("camera_spec2.txt", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Key").Value; + Assert.Equal("tyre_specs", entry.AttemptedValue); + Assert.Equal("tyre_specs", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Value[0].Name").Value; + Assert.Equal("tyre_spec1.txt", entry.AttemptedValue); + Assert.Equal("tyre_spec1.txt", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Value[1].Name").Value; + Assert.Equal("tyre_spec2.txt", entry.AttemptedValue); + Assert.Equal("tyre_spec2.txt", entry.RawValue); + } + + [Fact] + public async Task BindsDictionaryProperty_WithArrayOfComplexTypeValue_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "p", + ParameterType = typeof(Car2) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + var queryString = "?p.Name=Accord" + + "&p.Specs[0].Key=camera_specs" + + "&p.Specs[0].Value[0].Name=camera_spec1.txt" + + "&p.Specs[0].Value[1].Name=camera_spec2.txt" + + "&p.Specs[1].Key=tyre_specs" + + "&p.Specs[1].Value[0].Name=tyre_spec1.txt" + + "&p.Specs[1].Value[1].Name=tyre_spec2.txt"; + request.QueryString = new QueryString(queryString); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("Accord", model.Name); + + Assert.Collection( + model.Specs, + (e) => + { + Assert.Equal("camera_specs", e.Key); + Assert.Collection( + e.Value, + (s) => + { + Assert.Equal("camera_spec1.txt", s.Name); + }, + (s) => + { + Assert.Equal("camera_spec2.txt", s.Name); + }); + }, + (e) => + { + Assert.Equal("tyre_specs", e.Key); + Assert.Collection( + e.Value, + (s) => + { + Assert.Equal("tyre_spec1.txt", s.Name); + }, + (s) => + { + Assert.Equal("tyre_spec2.txt", s.Name); + }); + }); + + Assert.Equal(7, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "p.Name").Value; + Assert.Equal("Accord", entry.AttemptedValue); + Assert.Equal("Accord", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Key").Value; + Assert.Equal("camera_specs", entry.AttemptedValue); + Assert.Equal("camera_specs", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Value[0].Name").Value; + Assert.Equal("camera_spec1.txt", entry.AttemptedValue); + Assert.Equal("camera_spec1.txt", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Value[1].Name").Value; + Assert.Equal("camera_spec2.txt", entry.AttemptedValue); + Assert.Equal("camera_spec2.txt", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Key").Value; + Assert.Equal("tyre_specs", entry.AttemptedValue); + Assert.Equal("tyre_specs", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Value[0].Name").Value; + Assert.Equal("tyre_spec1.txt", entry.AttemptedValue); + Assert.Equal("tyre_spec1.txt", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Value[1].Name").Value; + Assert.Equal("tyre_spec2.txt", entry.AttemptedValue); + Assert.Equal("tyre_spec2.txt", entry.RawValue); + } + + [Fact] + public async Task BindsDictionaryProperty_WithIEnumerableOfKeyValuePair_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "p", + ParameterType = typeof(Car3) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + var queryString = "?p.Name=Accord" + + "&p.Specs[0].Key=camera_specs" + + "&p.Specs[0].Value[0].Name=camera_spec1.txt" + + "&p.Specs[0].Value[1].Name=camera_spec2.txt" + + "&p.Specs[1].Key=tyre_specs" + + "&p.Specs[1].Value[0].Name=tyre_spec1.txt" + + "&p.Specs[1].Value[1].Name=tyre_spec2.txt"; + request.QueryString = new QueryString(queryString); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("Accord", model.Name); + + Assert.Collection( + model.Specs, + (e) => + { + Assert.Equal("camera_specs", e.Key); + Assert.Collection( + e.Value, + (s) => + { + Assert.Equal("camera_spec1.txt", s.Name); + }, + (s) => + { + Assert.Equal("camera_spec2.txt", s.Name); + }); + }, + (e) => + { + Assert.Equal("tyre_specs", e.Key); + Assert.Collection( + e.Value, + (s) => + { + Assert.Equal("tyre_spec1.txt", s.Name); + }, + (s) => + { + Assert.Equal("tyre_spec2.txt", s.Name); + }); + }); + + Assert.Equal(7, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "p.Name").Value; + Assert.Equal("Accord", entry.AttemptedValue); + Assert.Equal("Accord", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Key").Value; + Assert.Equal("camera_specs", entry.AttemptedValue); + Assert.Equal("camera_specs", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Value[0].Name").Value; + Assert.Equal("camera_spec1.txt", entry.AttemptedValue); + Assert.Equal("camera_spec1.txt", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Value[1].Name").Value; + Assert.Equal("camera_spec2.txt", entry.AttemptedValue); + Assert.Equal("camera_spec2.txt", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Key").Value; + Assert.Equal("tyre_specs", entry.AttemptedValue); + Assert.Equal("tyre_specs", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Value[0].Name").Value; + Assert.Equal("tyre_spec1.txt", entry.AttemptedValue); + Assert.Equal("tyre_spec1.txt", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Value[1].Name").Value; + Assert.Equal("tyre_spec2.txt", entry.AttemptedValue); + Assert.Equal("tyre_spec2.txt", entry.RawValue); + } + + private record Order8(KeyValuePair ProductId, string Name = default!); + + [Fact] + public async Task BindsKeyValuePairProperty_WithPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order8) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = + new QueryString("?parameter.Name=bill¶meter.ProductId.Key=key0¶meter.ProductId.Value=10"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Name); + Assert.Equal(new KeyValuePair("key0", 10), model.ProductId); + + Assert.Equal(3, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.ProductId.Key").Value; + Assert.Equal("key0", entry.AttemptedValue); + Assert.Equal("key0", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.ProductId.Value").Value; + Assert.Equal("10", entry.AttemptedValue); + Assert.Equal("10", entry.RawValue); + } + + [Fact] + public async Task BindsKeyValuePairProperty_EmptyPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order8) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Name=bill&ProductId.Key=key0&ProductId.Value=10"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Name); + Assert.Equal(new KeyValuePair("key0", 10), model.ProductId); + + Assert.Equal(3, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "ProductId.Key").Value; + Assert.Equal("key0", entry.AttemptedValue); + Assert.Equal("key0", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "ProductId.Value").Value; + Assert.Equal("10", entry.AttemptedValue); + Assert.Equal("10", entry.RawValue); + } + + private record Car4(string Name, KeyValuePair> Specs); + + [Fact] + public async Task Foo_BindsKeyValuePairProperty_WithPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "p", + ParameterType = typeof(Car4) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + var queryString = "?p.Name=Accord" + + "&p.Specs.Key=camera_specs" + + "&p.Specs.Value[0].Key=spec1" + + "&p.Specs.Value[0].Value=spec1.txt" + + "&p.Specs.Value[1].Key=spec2" + + "&p.Specs.Value[1].Value=spec2.txt"; + + request.QueryString = new QueryString(queryString); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("Accord", model.Name); + + Assert.Collection( + model.Specs.Value, + (e) => + { + Assert.Equal("spec1", e.Key); + Assert.Equal("spec1.txt", e.Value); + }, + (e) => + { + Assert.Equal("spec2", e.Key); + Assert.Equal("spec2.txt", e.Value); + }); + + Assert.Equal(6, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "p.Name").Value; + Assert.Equal("Accord", entry.AttemptedValue); + Assert.Equal("Accord", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs.Key").Value; + Assert.Equal("camera_specs", entry.AttemptedValue); + Assert.Equal("camera_specs", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs.Value[0].Key").Value; + Assert.Equal("spec1", entry.AttemptedValue); + Assert.Equal("spec1", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs.Value[0].Value").Value; + Assert.Equal("spec1.txt", entry.AttemptedValue); + Assert.Equal("spec1.txt", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs.Value[1].Key").Value; + Assert.Equal("spec2", entry.AttemptedValue); + Assert.Equal("spec2", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs.Value[1].Value").Value; + Assert.Equal("spec2.txt", entry.AttemptedValue); + Assert.Equal("spec2.txt", entry.RawValue); + } + + private record Order9(Person9 Customer); + + private record Person9([FromBody] Address1 Address); + + // If a nested POCO object has all properties bound from a greedy source, then it should be populated + // if the top-level object is created. + [Fact] + public async Task BindsNestedPOCO_WithAllGreedyBoundProperties() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order9) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?"); + SetJsonBodyContent(request, AddressBodyContent); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + + Assert.NotNull(model.Customer.Address); + Assert.Equal(AddressStreetContent, model.Customer.Address.Street); + + Assert.Empty(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + private record Order10([BindRequired] Person10 Customer); + + private record Person10(string Name); + + [Fact] + public async Task WithRequiredComplexProperty_NoData_GetsErrors() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order10) + }; + + // No Data + var testContext = ModelBindingTestHelper.GetTestContext(); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.Customer); + + Assert.Single(modelState); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Customer").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + var error = Assert.Single(modelState["Customer"].Errors); + Assert.Equal("A value for the 'Customer' parameter or property was not provided.", error.ErrorMessage); + } + + [Fact] + public async Task WithBindRequired_NoData_AndCustomizedMessage_AddsGivenMessage() + { + // Arrange + var parameterInfo = typeof(Order10).GetConstructor(new[] { typeof(Person10) }).GetParameters()[0]; + var metadataProvider = new TestModelMetadataProvider(); + metadataProvider + .ForParameter(parameterInfo) + .BindingDetails((Action)(binding => + { + // A real details provider could customize message based on BindingMetadataProviderContext. + binding.ModelBindingMessageProvider.SetMissingBindRequiredValueAccessor( + name => $"Hurts when '{ name }' is not provided."); + })); + + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order10) + }; + + // No Data + var testContext = ModelBindingTestHelper.GetTestContext(metadataProvider: metadataProvider); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.Customer); + + Assert.Single(modelState); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Customer").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + var error = Assert.Single(modelState["Customer"].Errors); + Assert.Equal("Hurts when 'Customer' is not provided.", error.ErrorMessage); + } + + private record Order11(Person11 Customer); + + private record Person11(int Id, [BindRequired] string Name); + + [Fact] + public async Task WithNestedRequiredProperty_WithPartialData_GetsErrors() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order11) + }; + + // No Data + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Customer.Id=123"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal(123, model.Customer.Id); + Assert.Null(model.Customer.Name); + + Assert.Equal(2, modelState.Count); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Id").Value; + Assert.Equal("123", entry.RawValue); + Assert.Equal("123", entry.AttemptedValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + var error = Assert.Single(modelState["parameter.Customer.Name"].Errors); + Assert.Equal("A value for the 'Name' parameter or property was not provided.", error.ErrorMessage); + } + + [Fact] + public async Task WithNestedRequiredProperty_WithData_EmptyPrefix_GetsErrors() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order11) + }; + + // No Data + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Customer.Id=123"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal(123, model.Customer.Id); + Assert.Null(model.Customer.Name); + + Assert.Equal(2, modelState.Count); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Customer.Id").Value; + Assert.Equal("123", entry.RawValue); + Assert.Equal("123", entry.AttemptedValue); + + entry = Assert.Single(modelState, e => e.Key == "Customer.Name").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + var error = Assert.Single(modelState["Customer.Name"].Errors); + Assert.Equal("A value for the 'Name' parameter or property was not provided.", error.ErrorMessage); + } + + [Fact] + public async Task WithNestedRequiredProperty_WithData_CustomPrefix_GetsErrors() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order11), + BindingInfo = new BindingInfo() + { + BinderModelName = "customParameter" + } + }; + + // No Data + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?customParameter.Customer.Id=123"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal(123, model.Customer.Id); + Assert.Null(model.Customer.Name); + + Assert.Equal(2, modelState.Count); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "customParameter.Customer.Id").Value; + Assert.Equal("123", entry.RawValue); + Assert.Equal("123", entry.AttemptedValue); + + entry = Assert.Single(modelState, e => e.Key == "customParameter.Customer.Name").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + var error = Assert.Single(modelState["customParameter.Customer.Name"].Errors); + Assert.Equal("A value for the 'Name' parameter or property was not provided.", error.ErrorMessage); + } + + private record Order12([BindRequired] string ProductName); + + [Fact] + public async Task WithRequiredProperty_NoData_GetsErrors() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order12) + }; + + // No Data + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.ProductName); + + Assert.Single(modelState); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "ProductName").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + var error = Assert.Single(modelState["ProductName"].Errors); + Assert.Equal("A value for the 'ProductName' parameter or property was not provided.", error.ErrorMessage); + } + + [Fact] + public async Task WithRequiredProperty_NoData_CustomPrefix_GetsErrors() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order12), + BindingInfo = new BindingInfo() + { + BinderModelName = "customParameter" + } + }; + + // No Data + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.ProductName); + + Assert.Single(modelState); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "customParameter.ProductName").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + var error = Assert.Single(modelState["customParameter.ProductName"].Errors); + Assert.Equal("A value for the 'ProductName' parameter or property was not provided.", error.ErrorMessage); + } + + [Fact] + public async Task WithRequiredProperty_WithData_EmptyPrefix_GetsBound() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order12), + }; + + // No Data + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?ProductName=abc"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("abc", model.ProductName); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "ProductName").Value; + Assert.Equal("abc", entry.RawValue); + Assert.Equal("abc", entry.AttemptedValue); + } + + private record Order13([BindRequired] List OrderIds); + + [Fact] + public async Task WithRequiredCollectionProperty_NoData_GetsErrors() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order13) + }; + + // No Data + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.OrderIds); + + Assert.Single(modelState); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "OrderIds").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + var error = Assert.Single(modelState["OrderIds"].Errors); + Assert.Equal("A value for the 'OrderIds' parameter or property was not provided.", error.ErrorMessage); + } + + [Fact] + public async Task WithRequiredCollectionProperty_NoData_CustomPrefix_GetsErrors() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order13), + BindingInfo = new BindingInfo() + { + BinderModelName = "customParameter" + } + }; + + // No Data + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.OrderIds); + + Assert.Single(modelState); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "customParameter.OrderIds").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + var error = Assert.Single(modelState["customParameter.OrderIds"].Errors); + Assert.Equal("A value for the 'OrderIds' parameter or property was not provided.", error.ErrorMessage); + } + + [Fact] + public async Task WithRequiredCollectionProperty_WithData_EmptyPrefix_GetsBound() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order13), + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?OrderIds[0]=123"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal(new[] { 123 }, model.OrderIds.ToArray()); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "OrderIds[0]").Value; + Assert.Equal("123", entry.RawValue); + Assert.Equal("123", entry.AttemptedValue); + } + + private record Order14(int ProductId); + + // This covers the case where a key is present, but has an empty value. The type converter + // will report an error. + [Fact] + public async Task BindsPOCO_TypeConvertedPropertyNonConvertibleValue_GetsError() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order14) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.ProductId="); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model); + Assert.Equal(0, model.ProductId); + + Assert.Single(modelState); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.ProductId").Value; + Assert.Equal(string.Empty, entry.AttemptedValue); + Assert.Equal(string.Empty, entry.RawValue); + + var error = Assert.Single(entry.Errors); + Assert.Equal("The value '' is invalid.", error.ErrorMessage); + Assert.Null(error.Exception); + } + + // This covers the case where a key is present, but has no value. The model binder will + // report and error because it's a value type (non-nullable). + [Fact] + [ReplaceCulture] + public async Task BindsPOCO_TypeConvertedPropertyWithEmptyValue_Error() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order14) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.ProductId"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model); + Assert.Equal(0, model.ProductId); + + var entry = Assert.Single(modelState); + Assert.Equal("parameter.ProductId", entry.Key); + Assert.Equal(string.Empty, entry.Value.AttemptedValue); + + var error = Assert.Single(entry.Value.Errors); + Assert.Equal("The value '' is invalid.", error.ErrorMessage, StringComparer.Ordinal); + Assert.Null(error.Exception); + + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + } + + private record Person12(Address12 Address); + + [ModelBinder(Name = "HomeAddress")] + private record Address12(string Street); + + // Make sure the metadata is honored when a [ModelBinder] attribute is associated with a class somewhere in the + // type hierarchy of an action parameter. This should behave identically to such an attribute on a property in + // the type hierarchy. + [Theory] + [MemberData( + nameof(BinderTypeBasedModelBinderIntegrationTest.NullAndEmptyBindingInfo), + MemberType = typeof(BinderTypeBasedModelBinderIntegrationTest))] + public async Task ModelNameOnPropertyType_WithData_Succeeds(BindingInfo bindingInfo) + { + // Arrange + var parameter = new ParameterDescriptor + { + Name = "parameter-name", + BindingInfo = bindingInfo, + ParameterType = typeof(Person12), + }; + + var testContext = ModelBindingTestHelper.GetTestContext( + request => request.QueryString = new QueryString("?HomeAddress.Street=someStreet")); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + var person = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(person.Address); + Assert.Equal("someStreet", person.Address.Street, StringComparer.Ordinal); + + Assert.True(modelState.IsValid); + var kvp = Assert.Single(modelState); + Assert.Equal("HomeAddress.Street", kvp.Key); + var entry = kvp.Value; + Assert.NotNull(entry); + Assert.Empty(entry.Errors); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + } + + // Make sure the metadata is honored when a [ModelBinder] attribute is associated with an action parameter's + // type. This should behave identically to such an attribute on an action parameter. + [Theory] + [MemberData( + nameof(BinderTypeBasedModelBinderIntegrationTest.NullAndEmptyBindingInfo), + MemberType = typeof(BinderTypeBasedModelBinderIntegrationTest))] + public async Task ModelNameOnParameterType_WithData_Succeeds(BindingInfo bindingInfo) + { + // Arrange + var parameter = new ParameterDescriptor + { + Name = "parameter-name", + BindingInfo = bindingInfo, + ParameterType = typeof(Address12), + }; + + var testContext = ModelBindingTestHelper.GetTestContext( + request => request.QueryString = new QueryString("?HomeAddress.Street=someStreet")); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + var address = Assert.IsType(modelBindingResult.Model); + Assert.Equal("someStreet", address.Street, StringComparer.Ordinal); + + Assert.True(modelState.IsValid); + var kvp = Assert.Single(modelState); + Assert.Equal("HomeAddress.Street", kvp.Key); + var entry = kvp.Value; + Assert.NotNull(entry); + Assert.Empty(entry.Errors); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + } + + private record Person13(Address13 Address); + + [Bind("Street")] + private record Address13(int Number, string Street, string City, string State); + + // Make sure the metadata is honored when a [Bind] attribute is associated with a class somewhere in the type + // hierarchy of an action parameter. This should behave identically to such an attribute on a property in the + // type hierarchy. (Test is similar to ModelNameOnPropertyType_WithData_Succeeds() but covers implementing + // IPropertyFilterProvider, not IModelNameProvider.) + [Theory] + [MemberData( + nameof(BinderTypeBasedModelBinderIntegrationTest.NullAndEmptyBindingInfo), + MemberType = typeof(BinderTypeBasedModelBinderIntegrationTest))] + public async Task BindAttributeOnPropertyType_WithData_Succeeds(BindingInfo bindingInfo) + { + // Arrange + var parameter = new ParameterDescriptor + { + Name = "parameter-name", + BindingInfo = bindingInfo, + ParameterType = typeof(Person13), + }; + + var testContext = ModelBindingTestHelper.GetTestContext( + request => request.QueryString = new QueryString( + "?Address.Number=23&Address.Street=someStreet&Address.City=Redmond&Address.State=WA")); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + var person = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(person.Address); + Assert.Null(person.Address.City); + Assert.Equal(0, person.Address.Number); + Assert.Null(person.Address.State); + Assert.Equal("someStreet", person.Address.Street, StringComparer.Ordinal); + + Assert.True(modelState.IsValid); + var kvp = Assert.Single(modelState); + Assert.Equal("Address.Street", kvp.Key); + var entry = kvp.Value; + Assert.NotNull(entry); + Assert.Empty(entry.Errors); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + } + + // Make sure the metadata is honored when a [Bind] attribute is associated with an action parameter's type. + // This should behave identically to such an attribute on an action parameter. (Test is similar + // to ModelNameOnParameterType_WithData_Succeeds() but covers implementing IPropertyFilterProvider, not + // IModelNameProvider.) + [Theory] + [MemberData( + nameof(BinderTypeBasedModelBinderIntegrationTest.NullAndEmptyBindingInfo), + MemberType = typeof(BinderTypeBasedModelBinderIntegrationTest))] + public async Task BindAttributeOnParameterType_WithData_Succeeds(BindingInfo bindingInfo) + { + // Arrange + var parameter = new ParameterDescriptor + { + Name = "parameter-name", + BindingInfo = bindingInfo, + ParameterType = typeof(Address13), + }; + + var testContext = ModelBindingTestHelper.GetTestContext( + request => request.QueryString = new QueryString("?Number=23&Street=someStreet&City=Redmond&State=WA")); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + var address = Assert.IsType(modelBindingResult.Model); + Assert.Null(address.City); + Assert.Equal(0, address.Number); + Assert.Null(address.State); + Assert.Equal("someStreet", address.Street, StringComparer.Ordinal); + + Assert.True(modelState.IsValid); + var kvp = Assert.Single(modelState); + Assert.Equal("Street", kvp.Key); + var entry = kvp.Value; + Assert.NotNull(entry); + Assert.Empty(entry.Errors); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + } + + private record Product(int ProductId) + { + public string Name { get; } + + public IList Aliases { get; } + } + + [Theory] + [InlineData("?parameter.ProductId=10")] + [InlineData("?parameter.ProductId=10¶meter.Name=Camera")] + [InlineData("?parameter.ProductId=10¶meter.Name=Camera¶meter.Aliases[0]=Camera1")] + public async Task BindsSettableProperties(string queryString) + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Product) + }; + + // Need to have a key here so that the ComplexTypeModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString(queryString); + SetJsonBodyContent(request, AddressBodyContent); + }); + + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model); + Assert.Equal(10, model.ProductId); + Assert.Null(model.Name); + Assert.Null(model.Aliases); + } + + private record Photo(string Id, KeyValuePair Info); + + private record LocationInfo([FromHeader] string GpsCoordinates, int Zipcode); + + [Fact] + public async Task BindsKeyValuePairProperty_HavingFromHeaderProperty_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Photo) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.Headers.Add("GpsCoordinates", "10,20"); + request.QueryString = new QueryString("?Id=1&Info.Key=location1&Info.Value.Zipcode=98052"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + // Model + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("1", model.Id); + Assert.Equal("location1", model.Info.Key); + Assert.NotNull(model.Info.Value); + Assert.Equal("10,20", model.Info.Value.GpsCoordinates); + Assert.Equal(98052, model.Info.Value.Zipcode); + + // ModelState + Assert.Equal(4, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Id").Value; + Assert.Equal("1", entry.AttemptedValue); + Assert.Equal("1", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "Info.Key").Value; + Assert.Equal("location1", entry.AttemptedValue); + Assert.Equal("location1", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "Info.Value.Zipcode").Value; + Assert.Equal("98052", entry.AttemptedValue); + Assert.Equal("98052", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "Info.Value.GpsCoordinates").Value; + Assert.Equal("10,20", entry.AttemptedValue); + Assert.Equal("10,20", entry.RawValue); + } + + private record Person5(string Name, IFormFile Photo); + + // Regression test for #4802. + [Fact] + public async Task ReportsFailureToCollectionModelBinder() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(IList), + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + SetFormFileBodyContent(request, "Hello world!", "[0].Photo"); + + // CollectionModelBinder binds an empty collection when value providers are all empty. + request.QueryString = new QueryString("?a=b"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + var person = Assert.Single(model); + Assert.Null(person.Name); + Assert.NotNull(person.Photo); + using (var reader = new StreamReader(person.Photo.OpenReadStream())) + { + Assert.Equal("Hello world!", await reader.ReadToEndAsync()); + } + + Assert.True(modelState.IsValid); + var state = Assert.Single(modelState); + Assert.Equal("[0].Photo", state.Key); + Assert.Null(state.Value.AttemptedValue); + Assert.Empty(state.Value.Errors); + Assert.Null(state.Value.RawValue); + } + + private record TestModel(TestInnerModel[] InnerModels); + + private record TestInnerModel([ModelBinder(BinderType = typeof(NumberModelBinder))] decimal Rate); + + private class NumberModelBinder : IModelBinder + { + private readonly NumberStyles _supportedStyles = NumberStyles.Float | NumberStyles.AllowThousands; + private DecimalModelBinder _innerBinder; + + public NumberModelBinder(ILoggerFactory loggerFactory) + { + _innerBinder = new DecimalModelBinder(_supportedStyles, loggerFactory); + } + + public Task BindModelAsync(ModelBindingContext bindingContext) + { + return _innerBinder.BindModelAsync(bindingContext); + } + } + + // Regression test for #4939. + [Fact] + public async Task ReportsFailureToCollectionModelBinder_CustomBinder() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(TestModel), + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString( + "?parameter.InnerModels[0].Rate=1,000.00¶meter.InnerModels[1].Rate=2000"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.InnerModels); + Assert.Collection( + model.InnerModels, + item => Assert.Equal(1000, item.Rate), + item => Assert.Equal(2000, item.Rate)); + + Assert.True(modelState.IsValid); + Assert.Collection( + modelState, + kvp => + { + Assert.Equal("parameter.InnerModels[0].Rate", kvp.Key); + Assert.Equal("1,000.00", kvp.Value.AttemptedValue); + Assert.Empty(kvp.Value.Errors); + Assert.Equal("1,000.00", kvp.Value.RawValue); + Assert.Equal(ModelValidationState.Valid, kvp.Value.ValidationState); + }, + kvp => + { + Assert.Equal("parameter.InnerModels[1].Rate", kvp.Key); + Assert.Equal("2000", kvp.Value.AttemptedValue); + Assert.Empty(kvp.Value.Errors); + Assert.Equal("2000", kvp.Value.RawValue); + Assert.Equal(ModelValidationState.Valid, kvp.Value.ValidationState); + }); + } + + private record Person6(string Name, Person6 Mother, IFormFile Photo); + + // Regression test for #6616. + [Fact] + public async Task ReportsFailureToNearTopLevel() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Person6), + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + SetFormFileBodyContent(request, "Hello world!", "Photo"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.Mother); + Assert.Null(model.Name); + Assert.NotNull(model.Photo); + using (var reader = new StreamReader(model.Photo.OpenReadStream())) + { + Assert.Equal("Hello world!", await reader.ReadToEndAsync()); + } + + Assert.True(modelState.IsValid); + var state = Assert.Single(modelState); + Assert.Equal("Photo", state.Key); + Assert.Null(state.Value.AttemptedValue); + Assert.Empty(state.Value.Errors); + Assert.Null(state.Value.RawValue); + } + + // Regression test for #6616. + [Fact] + public async Task ReportsFailureToComplexTypeModelBinder() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Person6), + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + SetFormFileBodyContent(request, "Hello world!", "Photo"); + SetFormFileBodyContent(request, "Hello Mom!", "Mother.Photo"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Mother); + Assert.Null(model.Mother.Mother); + Assert.NotNull(model.Mother.Photo); + using (var reader = new StreamReader(model.Mother.Photo.OpenReadStream())) + { + Assert.Equal("Hello Mom!", await reader.ReadToEndAsync()); + } + + Assert.Null(model.Name); + Assert.NotNull(model.Photo); + using (var reader = new StreamReader(model.Photo.OpenReadStream())) + { + Assert.Equal("Hello world!", await reader.ReadToEndAsync()); + } + + Assert.True(modelState.IsValid); + Assert.Collection( + modelState, + kvp => + { + Assert.Equal("Photo", kvp.Key); + Assert.Null(kvp.Value.AttemptedValue); + Assert.Empty(kvp.Value.Errors); + Assert.Null(kvp.Value.RawValue); + }, + kvp => + { + Assert.Equal("Mother.Photo", kvp.Key); + Assert.Null(kvp.Value.AttemptedValue); + Assert.Empty(kvp.Value.Errors); + Assert.Null(kvp.Value.RawValue); + }); + } + + private record Person7(string Name, IList Children, IFormFile Photo); + + // Regression test for #6616. + [Fact] + public async Task ReportsFailureToViaCollection() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Person7), + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + SetFormFileBodyContent(request, "Hello world!", "Photo"); + SetFormFileBodyContent(request, "Hello Fred!", "Children[0].Photo"); + SetFormFileBodyContent(request, "Hello Ginger!", "Children[1].Photo"); + + request.QueryString = new QueryString("?Children[0].Name=Fred&Children[1].Name=Ginger"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Children); + Assert.Collection( + model.Children, + item => + { + Assert.Null(item.Children); + Assert.Equal("Fred", item.Name); + using (var reader = new StreamReader(item.Photo.OpenReadStream())) + { + Assert.Equal("Hello Fred!", reader.ReadToEnd()); + } + }, + item => + { + Assert.Null(item.Children); + Assert.Equal("Ginger", item.Name); + using (var reader = new StreamReader(item.Photo.OpenReadStream())) + { + Assert.Equal("Hello Ginger!", reader.ReadToEnd()); + } + }); + + Assert.Null(model.Name); + Assert.NotNull(model.Photo); + using (var reader = new StreamReader(model.Photo.OpenReadStream())) + { + Assert.Equal("Hello world!", await reader.ReadToEndAsync()); + } + + Assert.True(modelState.IsValid); + } + + private record LoopyModel([ModelBinder(typeof(SuccessfulModelBinder))] bool IsBound, LoopyModel SelfReference); + + // Regression test for #7052 + [Fact] + public async Task ModelBindingSystem_ThrowsOn33Binders() + { + // Arrange + var expectedMessage = $"Model binding system exceeded " + + $"{nameof(MvcOptions)}.{nameof(MvcOptions.MaxModelBindingRecursionDepth)} (32). Reduce the " + + $"potential nesting of '{typeof(LoopyModel)}'. For example, this type may have a property with a " + + $"model binder that always succeeds. See the " + + $"{nameof(MvcOptions)}.{nameof(MvcOptions.MaxModelBindingRecursionDepth)} documentation for more " + + $"information."; + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(LoopyModel), + }; + + var testContext = ModelBindingTestHelper.GetTestContext(); + var modelState = testContext.ModelState; + var metadata = testContext.MetadataProvider.GetMetadataForType(parameter.ParameterType); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => parameterBinder.BindModelAsync(parameter, testContext)); + Assert.Equal(expectedMessage, exception.Message); + } + + private record TwoDeepModel([ModelBinder(typeof(SuccessfulModelBinder))] bool IsBound); + + private record ThreeDeepModel([ModelBinder(typeof(SuccessfulModelBinder))] bool IsBound, TwoDeepModel Inner); + + // Ensure model binding system allows MaxModelBindingRecursionDepth binders on the stack. + [Fact] + public async Task ModelBindingSystem_BindsWith3Binders() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(ThreeDeepModel), + }; + + var testContext = ModelBindingTestHelper.GetTestContext( + updateOptions: options => options.MaxModelBindingRecursionDepth = 3); + + var modelState = testContext.ModelState; + var metadata = testContext.MetadataProvider.GetMetadataForType(parameter.ParameterType); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var result = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelState.IsValid); + Assert.Equal(0, modelState.ErrorCount); + + Assert.True(result.IsModelSet); + var model = Assert.IsType(result.Model); + Assert.True(model.IsBound); + Assert.NotNull(model.Inner); + Assert.True(model.Inner.IsBound); + } + + private record FourDeepModel([ModelBinder(typeof(SuccessfulModelBinder))] bool IsBound, ThreeDeepModel Inner); + + // Ensure model binding system disallows one more than MaxModelBindingRecursionDepth binders on the stack. + [Fact] + public async Task ModelBindingSystem_ThrowsOn4Binders() + { + // Arrange + var expectedMessage = $"Model binding system exceeded " + + $"{nameof(MvcOptions)}.{nameof(MvcOptions.MaxModelBindingRecursionDepth)} (3). Reduce the " + + $"potential nesting of '{typeof(FourDeepModel)}'. For example, this type may have a property with a " + + $"model binder that always succeeds. See the " + + $"{nameof(MvcOptions)}.{nameof(MvcOptions.MaxModelBindingRecursionDepth)} documentation for more " + + $"information."; + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(FourDeepModel), + }; + + var testContext = ModelBindingTestHelper.GetTestContext( + updateOptions: options => options.MaxModelBindingRecursionDepth = 3); + + var modelState = testContext.ModelState; + var metadata = testContext.MetadataProvider.GetMetadataForType(parameter.ParameterType); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => parameterBinder.BindModelAsync(parameter, testContext)); + Assert.Equal(expectedMessage, exception.Message); + } + + private record LoopyModel1([ModelBinder(typeof(SuccessfulModelBinder))] bool IsBound, LoopyModel2 Inner); + + private record LoopyModel2([ModelBinder(typeof(SuccessfulModelBinder))] bool IsBound, LoopyModel3 Inner); + + private record LoopyModel3([ModelBinder(typeof(SuccessfulModelBinder))] bool IsBound, LoopyModel1 Inner); + + [Fact] + public async Task ModelBindingSystem_ThrowsOn33Binders_WithIndirectModelTypeLoop() + { + // Arrange + var expectedMessage = $"Model binding system exceeded " + + $"{nameof(MvcOptions)}.{nameof(MvcOptions.MaxModelBindingRecursionDepth)} (32). Reduce the " + + $"potential nesting of '{typeof(LoopyModel1)}'. For example, this type may have a property with a " + + $"model binder that always succeeds. See the " + + $"{nameof(MvcOptions)}.{nameof(MvcOptions.MaxModelBindingRecursionDepth)} documentation for more " + + $"information."; + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(LoopyModel1), + }; + + var testContext = ModelBindingTestHelper.GetTestContext(); + var modelState = testContext.ModelState; + var metadata = testContext.MetadataProvider.GetMetadataForType(parameter.ParameterType); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => parameterBinder.BindModelAsync(parameter, testContext)); + Assert.Equal(expectedMessage, exception.Message); + } + + private record RecordTypeWithSettableProperty1(string Name) + { + public int Age { get; set; } + } + + [Fact] + public async Task RecordTypeWithBoundParametersAndProperties_NoData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(RecordTypeWithSettableProperty1) + }; + + // Need to have a key here so that the ComplexObjectModelBinder will recurse to bind elements. + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.Name); + Assert.Equal(0, model.Age); + + Assert.Empty(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + [Fact] + public async Task RecordTypeWithBoundParametersAndProperties_ValueForParameter() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(RecordTypeWithSettableProperty1) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?name=TestName"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("TestName", model.Name); + Assert.Equal(0, model.Age); + + var entry = Assert.Single(modelState); + Assert.Equal("Name", entry.Key); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + [Fact] + public async Task RecordTypeWithBoundParametersAndProperties_ValueForProperty() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(RecordTypeWithSettableProperty1) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?age=28"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.Name); + Assert.Equal(28, model.Age); + + var entry = Assert.Single(modelState); + Assert.Equal("Age", entry.Key); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + [Fact] + public async Task RecordTypeWithBoundParametersAndProperties_ValueForParameterAndProperty() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(RecordTypeWithSettableProperty1) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Name=test&age=28"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("test", model.Name); + Assert.Equal(28, model.Age); + + Assert.Equal(2, modelState.Count); + var entry = Assert.Single(modelState, m => m.Key == "Age"); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + entry = Assert.Single(modelState, m => m.Key == "Name"); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + public record RecordTypeWithFilteredProperty1([BindNever] string Id, string Name); + + [Fact] + public async Task RecordTypeWithBoundParameters_ParameterCannotBeBound() + { + // Annotatons on properties do not appear on properties. If an attribute is never bound, the property is also not bound. + + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(RecordTypeWithFilteredProperty1) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Id=not-bound&Name=test"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.Id); + Assert.Equal("test", model.Name); + + var entry = Assert.Single(modelState); + Assert.Equal("Name", entry.Key); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + [Bind(include: new[] { "Name" })] + public record RecordTypeWithFilteredProperty2(string Id, string Name); + + [Fact] + public async Task RecordTypeWithBoundParameters_ParameterAreFiltered() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(RecordTypeWithFilteredProperty2) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Id=not-bound&Name=test"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.Id); + Assert.Equal("test", model.Name); + + var entry = Assert.Single(modelState); + Assert.Equal("Name", entry.Key); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + public record RecordTypesWithDifferentMetadataOnParameterAndProperty([FromQuery] string Id, string Name) + { + [FromHeader] + public string Id { get; init; } = Id; + + public string Name { get; init; } = Name; + } + + [Fact] + public async Task RecordTypesWithDifferentMetadataOnParameterAndProperty_MetadataOnParameterIsUsed() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(RecordTypesWithDifferentMetadataOnParameterAndProperty) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.Headers.Add("Id", "not-bound"); + request.QueryString = new QueryString("?Id=testId&Name=test"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + Assert.True(modelState.IsValid); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("testId", model.Id); + Assert.Equal("test", model.Name); + + Assert.Single(modelState, e => e.Key == "Name"); + Assert.Single(modelState, e => e.Key == "Id"); + } + + [Fact] + public async Task RecordTypesWithDifferentMetadataOnParameterAndProperty_NoDataForParameter() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(RecordTypesWithDifferentMetadataOnParameterAndProperty) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.Headers.Add("Id", "not-bound"); + request.QueryString = new QueryString("?Name=test"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.Id); + Assert.Equal("test", model.Name); + + var entry = Assert.Single(modelState); + Assert.Equal("Name", entry.Key); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + private record RecordTypeWithCollectionParameter(string Id, IList Tags); + + [Fact] + public async Task RecordTypeWithCollectionParameter_WithData_Succeeds() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(RecordTypeWithCollectionParameter) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Id=test&Tags[0]=tag1&Tags[1]=tag2"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + Assert.True(modelState.IsValid); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("test", model.Id); + Assert.Equal(new[] { "tag1", "tag2" }, model.Tags); + + Assert.Single(modelState, e => e.Key == "Id"); + Assert.Single(modelState, e => e.Key == "Tags[0]"); + Assert.Single(modelState, e => e.Key == "Tags[1]"); + } + + [Fact] + public async Task RecordTypeCollectionParameter_NoData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(RecordTypeWithCollectionParameter) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Id=test"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("test", model.Id); + Assert.Null(model.Tags); + + var entry = Assert.Single(modelState, e => e.Key == "Id"); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + private record RecordTypesWithReadOnlyCollectionParameter(string Id, string[] Tags); + + [Fact] + public async Task RecordTypesWithReadOnlyCollectionParameter_Data_GetsBound() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(RecordTypesWithReadOnlyCollectionParameter) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Id=test&Tags[0]=tag1&Tags[1]=tag2"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + Assert.True(modelState.IsValid); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("test", model.Id); + Assert.Equal(new[] { "tag1", "tag2" }, model.Tags); + + Assert.Single(modelState, e => e.Key == "Id"); + Assert.Single(modelState, e => e.Key == "Tags[0]"); + Assert.Single(modelState, e => e.Key == "Tags[1]"); + } + + private record RecordTypesWithDefaultParameterValue(string Id = "default-id", string[] Tags = null); + + [Fact] + public async Task RecordTypesWithDefaultParameterValue_Data() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(RecordTypesWithDefaultParameterValue) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Id=test&Tags[0]=tag1&Tags[1]=tag2"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + Assert.True(modelState.IsValid); + Assert.Equal(0, modelState.ErrorCount); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("test", model.Id); + Assert.Equal(new[] { "tag1", "tag2" }, model.Tags); + + Assert.Single(modelState, e => e.Key == "Id"); + Assert.Single(modelState, e => e.Key == "Tags[0]"); + Assert.Single(modelState, e => e.Key == "Tags[1]"); + } + + [Fact] + public async Task RecordTypesWithDefaultParameterValue_NoData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(RecordTypesWithDefaultParameterValue) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + Assert.True(modelState.IsValid); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("default-id", model.Id); + Assert.Null(model.Tags); + + Assert.Empty(modelState); + } + + [Fact] + public async Task RecordTypesWithDefaultParameterValue_PartialData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(RecordTypesWithDefaultParameterValue) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Tags[0]=tag"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + Assert.True(modelState.IsValid); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("default-id", model.Id); + Assert.Equal(new[] { "tag" }, model.Tags); + + Assert.Equal(0, modelState.ErrorCount); + var entry = Assert.Single(modelState); + Assert.Equal("Tags[0]", entry.Key); + } + + [Fact] + public async Task RecordTypesWithDefaultParameterValue_PartialDataWithPrefix() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(RecordTypesWithDefaultParameterValue) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Tags[0]=tag"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + Assert.True(modelState.IsValid); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("default-id", model.Id); + Assert.Equal(new[] { "tag" }, model.Tags); + + Assert.Equal(0, modelState.ErrorCount); + var entry = Assert.Single(modelState); + Assert.Equal("parameter.Tags[0]", entry.Key); + } + + private record RecordTypeWithBindRequiredParameters([BindRequired] string Name, int Age); + + [Fact] + public async Task RecordTypeWithBindRequiredParameters_Data_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(RecordTypeWithBindRequiredParameters) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Name=test&Age=7"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + Assert.True(modelState.IsValid); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("test", model.Name); + Assert.Equal(7, model.Age); + + Assert.Equal(0, modelState.ErrorCount); + Assert.Equal(2, modelState.Count); + + Assert.Single(modelState, m => m.Key == "Age"); + Assert.Single(modelState, m => m.Key == "Name"); + } + + [Fact] + public async Task RecordTypeWithBindRequiredParameters_PartialData_BindRequiredError() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(RecordTypeWithBindRequiredParameters) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Age=7"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.Name); + Assert.Equal(7, model.Age); + + Assert.False(modelState.IsValid); + Assert.Equal(1, modelState.ErrorCount); + + Assert.Equal(2, modelState.Count); + var entry = Assert.Single(modelState, m => m.Key == "Age"); + Assert.Empty(entry.Value.Errors); + + entry = Assert.Single(modelState, m => m.Key == "Name"); + var error = Assert.Single(entry.Value.Errors); + Assert.Equal("A value for the 'Name' parameter or property was not provided.", error.ErrorMessage); + } + + private static void SetJsonBodyContent(HttpRequest request, string content) + { + var stream = new MemoryStream(new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(content)); + request.Body = stream; + request.ContentType = "application/json"; + } + + private static void SetFormFileBodyContent(HttpRequest request, string content, string name) + { + const string fileName = "text.txt"; + + FormFileCollection fileCollection; + if (request.HasFormContentType) + { + // Do less work and do not overwrite previous information if called a second time. + fileCollection = (FormFileCollection)request.Form.Files; + } + else + { + fileCollection = new FormFileCollection(); + var formCollection = new FormCollection(new Dictionary(), fileCollection); + + request.ContentType = "multipart/form-data; boundary=----WebKitFormBoundarymx2fSWqWSd0OxQqq"; + request.Form = formCollection; + } + + var memoryStream = new MemoryStream(Encoding.UTF8.GetBytes(content)); + var file = new FormFile(memoryStream, 0, memoryStream.Length, name, fileName) + { + Headers = new HeaderDictionary(), + + // Do not move this up. Headers must be non-null before the ContentDisposition property is accessed. + ContentDisposition = $"form-data; name={name}; filename={fileName}", + }; + + fileCollection.Add(file); + } + + private ModelMetadata GetMetadata(ModelBindingTestContext context, ParameterDescriptor parameter) + { + return context.MetadataProvider.GetMetadataForType(parameter.ParameterType); + } + + private IModelBinder GetModelBinder( + ModelBindingTestContext context, + ParameterDescriptor parameter, + ModelMetadata metadata) + { + var factory = ModelBindingTestHelper.GetModelBinderFactory( + context.MetadataProvider, + context.HttpContext.RequestServices); + var factoryContext = new ModelBinderFactoryContext + { + BindingInfo = parameter.BindingInfo, + CacheToken = parameter, + Metadata = metadata, + }; + + return factory.CreateBinder(factoryContext); + } + } +} diff --git a/src/Mvc/test/Mvc.IntegrationTests/ComplexTypeIntegrationTestBase.cs b/src/Mvc/test/Mvc.IntegrationTests/ComplexTypeIntegrationTestBase.cs new file mode 100644 index 000000000000..b2ffbcc4c4e2 --- /dev/null +++ b/src/Mvc/test/Mvc.IntegrationTests/ComplexTypeIntegrationTestBase.cs @@ -0,0 +1,3774 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; +using Microsoft.AspNetCore.Testing; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Primitives; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.IntegrationTests +{ + // Integration tests targeting the behavior of the ComplexTypeModelBinder and related classes + // with other model binders. + public abstract class ComplexTypeIntegrationTestBase + { + private const string AddressBodyContent = "{ \"street\" : \"" + AddressStreetContent + "\" }"; + private const string AddressStreetContent = "1 Microsoft Way"; + + private static readonly byte[] ByteArrayContent = Encoding.BigEndianUnicode.GetBytes("abcd"); + private static readonly string ByteArrayEncoded = Convert.ToBase64String(ByteArrayContent); + + protected abstract Type ExpectedModelBinderType { get; } + + [Fact] + public void ExpectedModelBinderIsConstructed() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order1) + }; + var testContext = GetTestContext(); + + var metadata = GetMetadata(testContext, parameter); + + // Act + var modelBinder = GetModelBinder(testContext, parameter, metadata); + + // Assert + Assert.Equal(ExpectedModelBinderType, modelBinder.GetType()); + } + + private class Order1 + { + public int ProductId { get; set; } + + public Person1 Customer { get; set; } + } + + private class Person1 + { + public string Name { get; set; } + + [FromBody] + public Address1 Address { get; set; } + } + + private class Address1 + { + public string Street { get; set; } + } + + [Fact] + public async Task ComplexBinder_BindsNestedPOCO_WithBodyModelBinder_WithPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order1) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Customer.Name=bill"); + SetJsonBodyContent(request, AddressBodyContent); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("bill", model.Customer.Name); + Assert.NotNull(model.Customer.Address); + Assert.Equal(AddressStreetContent, model.Customer.Address.Street); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + } + + [Fact] + public async Task ComplexBinder_BindsNestedPOCO_WithBodyModelBinder_WithEmptyPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order1) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?Customer.Name=bill"); + SetJsonBodyContent(request, AddressBodyContent); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("bill", model.Customer.Name); + Assert.NotNull(model.Customer.Address); + Assert.Equal(AddressStreetContent, model.Customer.Address.Street); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Customer.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + } + + [Fact] + public async Task ComplexBinder_BindsNestedPOCO_WithBodyModelBinder_WithPrefix_NoBodyData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order1) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Customer.Name=bill"); + request.ContentType = "application/json"; + }); + + testContext.MvcOptions.AllowEmptyInputInBodyModelBinding = true; + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("bill", model.Customer.Name); + Assert.Null(model.Customer.Address); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + } + + [Fact] + public async Task ComplexTypeModelBinder_BindsNestedPOCO_WithBodyModelBinder_WithPrefix_PartialData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order1) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.ProductId=10"); + SetJsonBodyContent(request, AddressBodyContent); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("1 Microsoft Way", model.Customer.Address.Street); + + Assert.Equal(10, model.ProductId); + + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState).Value; + Assert.Equal("10", entry.AttemptedValue); + Assert.Equal("10", entry.RawValue); + } + + [Fact] + public async Task ComplexTypeModelBinder_BindsNestedPOCO_WithBodyModelBinder_WithPrefix_NoData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order1) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?"); + SetJsonBodyContent(request, AddressBodyContent); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("1 Microsoft Way", model.Customer.Address.Street); + + Assert.Empty(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + private class Order3 + { + public int ProductId { get; set; } + + public Person3 Customer { get; set; } + } + + private class Person3 + { + public string Name { get; set; } + + public byte[] Token { get; set; } + } + + [Fact] + public async Task ComplexBinder_BindsNestedPOCO_WithByteArrayModelBinder_WithPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order3) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = + new QueryString("?parameter.Customer.Name=bill¶meter.Customer.Token=" + ByteArrayEncoded); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("bill", model.Customer.Name); + Assert.Equal(ByteArrayContent, model.Customer.Token); + + Assert.Equal(2, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Token").Value; + Assert.Equal(ByteArrayEncoded, entry.AttemptedValue); + Assert.Equal(ByteArrayEncoded, entry.RawValue); + } + + [Fact] + public async Task ComplexBinder_BindsNestedPOCO_WithByteArrayModelBinder_WithEmptyPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order3) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?Customer.Name=bill&Customer.Token=" + ByteArrayEncoded); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("bill", model.Customer.Name); + Assert.Equal(ByteArrayContent, model.Customer.Token); + + Assert.Equal(2, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Customer.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "Customer.Token").Value; + Assert.Equal(ByteArrayEncoded, entry.AttemptedValue); + Assert.Equal(ByteArrayEncoded, entry.RawValue); + } + + [Fact] + public async Task ComplexBinder_BindsNestedPOCO_WithByteArrayModelBinder_WithPrefix_NoData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order3) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Customer.Name=bill"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("bill", model.Customer.Name); + Assert.Null(model.Customer.Token); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + } + + private class Order4 + { + public int ProductId { get; set; } + + public Person4 Customer { get; set; } + } + + private class Person4 + { + public string Name { get; set; } + + public IEnumerable Documents { get; set; } + } + + [Fact] + public async Task ComplexBinder_BindsNestedPOCO_WithFormFileModelBinder_WithPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order4) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Customer.Name=bill"); + SetFormFileBodyContent(request, "Hello, World!", "parameter.Customer.Documents"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("bill", model.Customer.Name); + Assert.Single(model.Customer.Documents); + + Assert.Equal(2, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Documents").Value; + Assert.Null(entry.AttemptedValue); // FormFile entries for body don't include original text. + Assert.Null(entry.RawValue); + } + + [Fact] + public async Task ComplexBinder_BindsNestedPOCO_WithFormFileModelBinder_WithEmptyPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order4) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?Customer.Name=bill"); + SetFormFileBodyContent(request, "Hello, World!", "Customer.Documents"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("bill", model.Customer.Name); + Assert.Single(model.Customer.Documents); + + Assert.Equal(2, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Customer.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "Customer.Documents").Value; + Assert.Null(entry.AttemptedValue); // FormFile entries don't include the model. + Assert.Null(entry.RawValue); + } + + [Fact] + public async Task ComplexBinder_BindsNestedPOCO_WithFormFileModelBinder_WithPrefix_NoBodyData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order4) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Customer.Name=bill"); + + // Deliberately leaving out any form data. + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("bill", model.Customer.Name); + Assert.Null(model.Customer.Documents); + + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var kvp = Assert.Single(modelState); + Assert.Equal("parameter.Customer.Name", kvp.Key); + var entry = kvp.Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + } + + [Fact] + public async Task ComplexTypeModelBinder_BindsNestedPOCO_WithFormFileModelBinder_WithPrefix_PartialData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order4) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.ProductId=10"); + SetFormFileBodyContent(request, "Hello, World!", "parameter.Customer.Documents"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + + var document = Assert.Single(model.Customer.Documents); + Assert.Equal("text.txt", document.FileName); + using (var reader = new StreamReader(document.OpenReadStream())) + { + Assert.Equal("Hello, World!", await reader.ReadToEndAsync()); + } + + Assert.Equal(10, model.ProductId); + + Assert.Equal(2, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + Assert.Single(modelState, e => e.Key == "parameter.Customer.Documents"); + var entry = Assert.Single(modelState, e => e.Key == "parameter.ProductId").Value; + Assert.Equal("10", entry.AttemptedValue); + Assert.Equal("10", entry.RawValue); + } + + [Fact] + public async Task ComplexTypeModelBinder_BindsNestedPOCO_WithFormFileModelBinder_WithPrefix_NoData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order4) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?"); + SetFormFileBodyContent(request, "Hello, World!", "Customer.Documents"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + + var document = Assert.Single(model.Customer.Documents); + Assert.Equal("text.txt", document.FileName); + using (var reader = new StreamReader(document.OpenReadStream())) + { + Assert.Equal("Hello, World!", await reader.ReadToEndAsync()); + } + + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState); + Assert.Equal("Customer.Documents", entry.Key); + } + + private class Order5 + { + public string Name { get; set; } + + public int[] ProductIds { get; set; } + } + + [Fact] + public async Task ComplexBinder_BindsArrayProperty_WithPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order5) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = + new QueryString("?parameter.Name=bill¶meter.ProductIds[0]=10¶meter.ProductIds[1]=11"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Name); + Assert.Equal(new int[] { 10, 11 }, model.ProductIds); + + Assert.Equal(3, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.ProductIds[0]").Value; + Assert.Equal("10", entry.AttemptedValue); + Assert.Equal("10", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.ProductIds[1]").Value; + Assert.Equal("11", entry.AttemptedValue); + Assert.Equal("11", entry.RawValue); + } + + [Fact] + public async Task ComplexBinder_BindsArrayProperty_EmptyPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order5) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?Name=bill&ProductIds[0]=10&ProductIds[1]=11"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Name); + Assert.Equal(new int[] { 10, 11 }, model.ProductIds); + + Assert.Equal(3, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "ProductIds[0]").Value; + Assert.Equal("10", entry.AttemptedValue); + Assert.Equal("10", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "ProductIds[1]").Value; + Assert.Equal("11", entry.AttemptedValue); + Assert.Equal("11", entry.RawValue); + } + + [Fact] + public async Task ComplexBinder_BindsArrayProperty_NoCollectionData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order5) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Name=bill"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Name); + Assert.Null(model.ProductIds); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + } + + [Fact] + public async Task ComplexBinder_BindsArrayProperty_NoData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order5) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.Name); + Assert.Null(model.ProductIds); + + Assert.Empty(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + private class Order6 + { + public string Name { get; set; } + + public List ProductIds { get; set; } + } + + [Fact] + public async Task ComplexBinder_BindsListProperty_WithPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order6) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = + new QueryString("?parameter.Name=bill¶meter.ProductIds[0]=10¶meter.ProductIds[1]=11"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Name); + Assert.Equal(new List() { 10, 11 }, model.ProductIds); + + Assert.Equal(3, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.ProductIds[0]").Value; + Assert.Equal("10", entry.AttemptedValue); + Assert.Equal("10", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.ProductIds[1]").Value; + Assert.Equal("11", entry.AttemptedValue); + Assert.Equal("11", entry.RawValue); + } + + [Fact] + public async Task ComplexBinder_BindsListProperty_EmptyPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order6) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?Name=bill&ProductIds[0]=10&ProductIds[1]=11"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Name); + Assert.Equal(new List() { 10, 11 }, model.ProductIds); + + Assert.Equal(3, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "ProductIds[0]").Value; + Assert.Equal("10", entry.AttemptedValue); + Assert.Equal("10", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "ProductIds[1]").Value; + Assert.Equal("11", entry.AttemptedValue); + Assert.Equal("11", entry.RawValue); + } + + [Fact] + public async Task ComplexBinder_BindsListProperty_NoCollectionData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order6) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Name=bill"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Name); + Assert.Null(model.ProductIds); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + } + + [Fact] + public async Task ComplexBinder_BindsListProperty_NoData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order6) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.Name); + Assert.Null(model.ProductIds); + + Assert.Empty(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + private class Order7 + { + public string Name { get; set; } + + public Dictionary ProductIds { get; set; } + } + + [Fact] + public async Task ComplexBinder_BindsDictionaryProperty_WithPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order7) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = + new QueryString("?parameter.Name=bill¶meter.ProductIds[0].Key=key0¶meter.ProductIds[0].Value=10"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Name); + Assert.Equal(new Dictionary() { { "key0", 10 } }, model.ProductIds); + + Assert.Equal(3, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.ProductIds[0].Key").Value; + Assert.Equal("key0", entry.AttemptedValue); + Assert.Equal("key0", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.ProductIds[0].Value").Value; + Assert.Equal("10", entry.AttemptedValue); + Assert.Equal("10", entry.RawValue); + } + + [Fact] + public async Task ComplexBinder_BindsDictionaryProperty_EmptyPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order7) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?Name=bill&ProductIds[0].Key=key0&ProductIds[0].Value=10"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Name); + Assert.Equal(new Dictionary() { { "key0", 10 } }, model.ProductIds); + + Assert.Equal(3, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "ProductIds[0].Key").Value; + Assert.Equal("key0", entry.AttemptedValue); + Assert.Equal("key0", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "ProductIds[0].Value").Value; + Assert.Equal("10", entry.AttemptedValue); + Assert.Equal("10", entry.RawValue); + } + + [Fact] + public async Task ComplexBinder_BindsDictionaryProperty_NoCollectionData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order7) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Name=bill"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Name); + Assert.Null(model.ProductIds); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + } + + [Fact] + public async Task ComplexBinder_BindsDictionaryProperty_NoData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order7) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.Name); + Assert.Null(model.ProductIds); + + Assert.Empty(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + // Dictionary property with an IEnumerable<> value type + private class Car1 + { + public string Name { get; set; } + + public Dictionary> Specs { get; set; } + } + + // Dictionary property with an Array value type + private class Car2 + { + public string Name { get; set; } + + public Dictionary Specs { get; set; } + } + + private class Car3 + { + public string Name { get; set; } + + public IEnumerable>> Specs { get; set; } + } + + private class SpecDoc + { + public string Name { get; set; } + } + + [Fact] + public async Task ComplexBinder_BindsDictionaryProperty_WithIEnumerableComplexTypeValue_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "p", + ParameterType = typeof(Car1) + }; + + var testContext = GetTestContext(request => + { + var queryString = "?p.Name=Accord" + + "&p.Specs[0].Key=camera_specs" + + "&p.Specs[0].Value[0].Name=camera_spec1.txt" + + "&p.Specs[0].Value[1].Name=camera_spec2.txt" + + "&p.Specs[1].Key=tyre_specs" + + "&p.Specs[1].Value[0].Name=tyre_spec1.txt" + + "&p.Specs[1].Value[1].Name=tyre_spec2.txt"; + request.QueryString = new QueryString(queryString); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("Accord", model.Name); + + Assert.Collection( + model.Specs, + (e) => + { + Assert.Equal("camera_specs", e.Key); + Assert.Collection( + e.Value, + (s) => + { + Assert.Equal("camera_spec1.txt", s.Name); + }, + (s) => + { + Assert.Equal("camera_spec2.txt", s.Name); + }); + }, + (e) => + { + Assert.Equal("tyre_specs", e.Key); + Assert.Collection( + e.Value, + (s) => + { + Assert.Equal("tyre_spec1.txt", s.Name); + }, + (s) => + { + Assert.Equal("tyre_spec2.txt", s.Name); + }); + }); + + Assert.Equal(7, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "p.Name").Value; + Assert.Equal("Accord", entry.AttemptedValue); + Assert.Equal("Accord", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Key").Value; + Assert.Equal("camera_specs", entry.AttemptedValue); + Assert.Equal("camera_specs", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Value[0].Name").Value; + Assert.Equal("camera_spec1.txt", entry.AttemptedValue); + Assert.Equal("camera_spec1.txt", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Value[1].Name").Value; + Assert.Equal("camera_spec2.txt", entry.AttemptedValue); + Assert.Equal("camera_spec2.txt", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Key").Value; + Assert.Equal("tyre_specs", entry.AttemptedValue); + Assert.Equal("tyre_specs", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Value[0].Name").Value; + Assert.Equal("tyre_spec1.txt", entry.AttemptedValue); + Assert.Equal("tyre_spec1.txt", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Value[1].Name").Value; + Assert.Equal("tyre_spec2.txt", entry.AttemptedValue); + Assert.Equal("tyre_spec2.txt", entry.RawValue); + } + + [Fact] + public async Task ComplexBinder_BindsDictionaryProperty_WithArrayOfComplexTypeValue_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "p", + ParameterType = typeof(Car2) + }; + + var testContext = GetTestContext(request => + { + var queryString = "?p.Name=Accord" + + "&p.Specs[0].Key=camera_specs" + + "&p.Specs[0].Value[0].Name=camera_spec1.txt" + + "&p.Specs[0].Value[1].Name=camera_spec2.txt" + + "&p.Specs[1].Key=tyre_specs" + + "&p.Specs[1].Value[0].Name=tyre_spec1.txt" + + "&p.Specs[1].Value[1].Name=tyre_spec2.txt"; + request.QueryString = new QueryString(queryString); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("Accord", model.Name); + + Assert.Collection( + model.Specs, + (e) => + { + Assert.Equal("camera_specs", e.Key); + Assert.Collection( + e.Value, + (s) => + { + Assert.Equal("camera_spec1.txt", s.Name); + }, + (s) => + { + Assert.Equal("camera_spec2.txt", s.Name); + }); + }, + (e) => + { + Assert.Equal("tyre_specs", e.Key); + Assert.Collection( + e.Value, + (s) => + { + Assert.Equal("tyre_spec1.txt", s.Name); + }, + (s) => + { + Assert.Equal("tyre_spec2.txt", s.Name); + }); + }); + + Assert.Equal(7, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "p.Name").Value; + Assert.Equal("Accord", entry.AttemptedValue); + Assert.Equal("Accord", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Key").Value; + Assert.Equal("camera_specs", entry.AttemptedValue); + Assert.Equal("camera_specs", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Value[0].Name").Value; + Assert.Equal("camera_spec1.txt", entry.AttemptedValue); + Assert.Equal("camera_spec1.txt", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Value[1].Name").Value; + Assert.Equal("camera_spec2.txt", entry.AttemptedValue); + Assert.Equal("camera_spec2.txt", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Key").Value; + Assert.Equal("tyre_specs", entry.AttemptedValue); + Assert.Equal("tyre_specs", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Value[0].Name").Value; + Assert.Equal("tyre_spec1.txt", entry.AttemptedValue); + Assert.Equal("tyre_spec1.txt", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Value[1].Name").Value; + Assert.Equal("tyre_spec2.txt", entry.AttemptedValue); + Assert.Equal("tyre_spec2.txt", entry.RawValue); + } + + [Fact] + public async Task ComplexBinder_BindsDictionaryProperty_WithIEnumerableOfKeyValuePair_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "p", + ParameterType = typeof(Car3) + }; + + var testContext = GetTestContext(request => + { + var queryString = "?p.Name=Accord" + + "&p.Specs[0].Key=camera_specs" + + "&p.Specs[0].Value[0].Name=camera_spec1.txt" + + "&p.Specs[0].Value[1].Name=camera_spec2.txt" + + "&p.Specs[1].Key=tyre_specs" + + "&p.Specs[1].Value[0].Name=tyre_spec1.txt" + + "&p.Specs[1].Value[1].Name=tyre_spec2.txt"; + request.QueryString = new QueryString(queryString); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("Accord", model.Name); + + Assert.Collection( + model.Specs, + (e) => + { + Assert.Equal("camera_specs", e.Key); + Assert.Collection( + e.Value, + (s) => + { + Assert.Equal("camera_spec1.txt", s.Name); + }, + (s) => + { + Assert.Equal("camera_spec2.txt", s.Name); + }); + }, + (e) => + { + Assert.Equal("tyre_specs", e.Key); + Assert.Collection( + e.Value, + (s) => + { + Assert.Equal("tyre_spec1.txt", s.Name); + }, + (s) => + { + Assert.Equal("tyre_spec2.txt", s.Name); + }); + }); + + Assert.Equal(7, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "p.Name").Value; + Assert.Equal("Accord", entry.AttemptedValue); + Assert.Equal("Accord", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Key").Value; + Assert.Equal("camera_specs", entry.AttemptedValue); + Assert.Equal("camera_specs", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Value[0].Name").Value; + Assert.Equal("camera_spec1.txt", entry.AttemptedValue); + Assert.Equal("camera_spec1.txt", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Value[1].Name").Value; + Assert.Equal("camera_spec2.txt", entry.AttemptedValue); + Assert.Equal("camera_spec2.txt", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Key").Value; + Assert.Equal("tyre_specs", entry.AttemptedValue); + Assert.Equal("tyre_specs", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Value[0].Name").Value; + Assert.Equal("tyre_spec1.txt", entry.AttemptedValue); + Assert.Equal("tyre_spec1.txt", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Value[1].Name").Value; + Assert.Equal("tyre_spec2.txt", entry.AttemptedValue); + Assert.Equal("tyre_spec2.txt", entry.RawValue); + } + + private class Order8 + { + public string Name { get; set; } = default!; + + public KeyValuePair ProductId { get; set; } + } + + [Fact] + public async Task ComplexBinder_BindsKeyValuePairProperty_WithPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order8) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = + new QueryString("?parameter.Name=bill¶meter.ProductId.Key=key0¶meter.ProductId.Value=10"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Name); + Assert.Equal(new KeyValuePair("key0", 10), model.ProductId); + + Assert.Equal(3, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.ProductId.Key").Value; + Assert.Equal("key0", entry.AttemptedValue); + Assert.Equal("key0", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.ProductId.Value").Value; + Assert.Equal("10", entry.AttemptedValue); + Assert.Equal("10", entry.RawValue); + } + + [Fact] + public async Task ComplexBinder_BindsKeyValuePairProperty_EmptyPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order8) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?Name=bill&ProductId.Key=key0&ProductId.Value=10"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Name); + Assert.Equal(new KeyValuePair("key0", 10), model.ProductId); + + Assert.Equal(3, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "ProductId.Key").Value; + Assert.Equal("key0", entry.AttemptedValue); + Assert.Equal("key0", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "ProductId.Value").Value; + Assert.Equal("10", entry.AttemptedValue); + Assert.Equal("10", entry.RawValue); + } + + [Fact(Skip = "https://github.com/dotnet/aspnetcore/issues/11813")] + public async Task ComplexBinder_BindsKeyValuePairProperty_NoCollectionData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order8) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Name=bill"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Name); + Assert.Equal(default, model.ProductId); + + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.ProductId.Key").Value; + Assert.Single(entry.Errors); + } + + [Fact(Skip = "https://github.com/dotnet/aspnetcore/issues/11813")] + public async Task ComplexBinder_BindsKeyValuePairProperty_NoData() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order8) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.Name); + Assert.Equal(default, model.ProductId); + + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "ProductId.Key").Value; + Assert.Single(entry.Errors); + } + + private class Car4 + { + public string Name { get; set; } + + public KeyValuePair> Specs { get; set; } + } + + [Fact] + public async Task Foo_ComplexBinder_BindsKeyValuePairProperty_WithPrefix_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "p", + ParameterType = typeof(Car4) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + var queryString = "?p.Name=Accord" + + "&p.Specs.Key=camera_specs" + + "&p.Specs.Value[0].Key=spec1" + + "&p.Specs.Value[0].Value=spec1.txt" + + "&p.Specs.Value[1].Key=spec2" + + "&p.Specs.Value[1].Value=spec2.txt"; + + request.QueryString = new QueryString(queryString); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("Accord", model.Name); + + Assert.Collection( + model.Specs.Value, + (e) => + { + Assert.Equal("spec1", e.Key); + Assert.Equal("spec1.txt", e.Value); + }, + (e) => + { + Assert.Equal("spec2", e.Key); + Assert.Equal("spec2.txt", e.Value); + }); + + Assert.Equal(6, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "p.Name").Value; + Assert.Equal("Accord", entry.AttemptedValue); + Assert.Equal("Accord", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs.Key").Value; + Assert.Equal("camera_specs", entry.AttemptedValue); + Assert.Equal("camera_specs", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs.Value[0].Key").Value; + Assert.Equal("spec1", entry.AttemptedValue); + Assert.Equal("spec1", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs.Value[0].Value").Value; + Assert.Equal("spec1.txt", entry.AttemptedValue); + Assert.Equal("spec1.txt", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs.Value[1].Key").Value; + Assert.Equal("spec2", entry.AttemptedValue); + Assert.Equal("spec2", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "p.Specs.Value[1].Value").Value; + Assert.Equal("spec2.txt", entry.AttemptedValue); + Assert.Equal("spec2.txt", entry.RawValue); + } + + private class Order9 + { + public Person9 Customer { get; set; } + } + + private class Person9 + { + [FromBody] + public Address1 Address { get; set; } + } + + // If a nested POCO object has all properties bound from a greedy source, then it should be populated + // if the top-level object is created. + [Fact] + public async Task ComplexBinder_BindsNestedPOCO_WithAllGreedyBoundProperties() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order9) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?"); + SetJsonBodyContent(request, AddressBodyContent); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + + Assert.NotNull(model.Customer.Address); + Assert.Equal(AddressStreetContent, model.Customer.Address.Street); + + Assert.Empty(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + private class Order10 + { + [BindRequired] + public Person10 Customer { get; set; } + } + + private class Person10 + { + public string Name { get; set; } + } + + [Fact] + public async Task ComplexBinder_WithRequiredComplexProperty_NoData_GetsErrors() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order10) + }; + + // No Data + var testContext = GetTestContext(); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.Customer); + + Assert.Single(modelState); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Customer").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + var error = Assert.Single(modelState["Customer"].Errors); + Assert.Equal("A value for the 'Customer' parameter or property was not provided.", error.ErrorMessage); + } + + [Fact] + public async Task ComplexBinder_WithBindRequired_NoData_AndCustomizedMessage_AddsGivenMessage() + { + // Arrange + var metadataProvider = new TestModelMetadataProvider(); + metadataProvider + .ForProperty(typeof(Order10), nameof(Order10.Customer)) + .BindingDetails((Action)(binding => + { + // A real details provider could customize message based on BindingMetadataProviderContext. + binding.ModelBindingMessageProvider.SetMissingBindRequiredValueAccessor( + name => $"Hurts when '{ name }' is not provided."); + })); + + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order10) + }; + + // No Data + var testContext = GetTestContext(metadataProvider: metadataProvider); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.Customer); + + Assert.Single(modelState); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Customer").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + var error = Assert.Single(modelState["Customer"].Errors); + Assert.Equal("Hurts when 'Customer' is not provided.", error.ErrorMessage); + } + + private class Order11 + { + public Person11 Customer { get; set; } + } + + private class Person11 + { + public int Id { get; set; } + + [BindRequired] + public string Name { get; set; } + } + + [Fact] + public async Task ComplexBinder_WithNestedRequiredProperty_WithPartialData_GetsErrors() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order11) + }; + + // No Data + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Customer.Id=123"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal(123, model.Customer.Id); + Assert.Null(model.Customer.Name); + + Assert.Equal(2, modelState.Count); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Id").Value; + Assert.Equal("123", entry.RawValue); + Assert.Equal("123", entry.AttemptedValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + var error = Assert.Single(modelState["parameter.Customer.Name"].Errors); + Assert.Equal("A value for the 'Name' parameter or property was not provided.", error.ErrorMessage); + } + + [Fact] + public async Task ComplexBinder_WithNestedRequiredProperty_WithData_EmptyPrefix_GetsErrors() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order11) + }; + + // No Data + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?Customer.Id=123"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal(123, model.Customer.Id); + Assert.Null(model.Customer.Name); + + Assert.Equal(2, modelState.Count); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Customer.Id").Value; + Assert.Equal("123", entry.RawValue); + Assert.Equal("123", entry.AttemptedValue); + + entry = Assert.Single(modelState, e => e.Key == "Customer.Name").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + var error = Assert.Single(modelState["Customer.Name"].Errors); + Assert.Equal("A value for the 'Name' parameter or property was not provided.", error.ErrorMessage); + } + + [Fact] + public async Task ComplexBinder_WithNestedRequiredProperty_WithData_CustomPrefix_GetsErrors() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order11), + BindingInfo = new BindingInfo() + { + BinderModelName = "customParameter" + } + }; + + // No Data + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?customParameter.Customer.Id=123"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal(123, model.Customer.Id); + Assert.Null(model.Customer.Name); + + Assert.Equal(2, modelState.Count); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "customParameter.Customer.Id").Value; + Assert.Equal("123", entry.RawValue); + Assert.Equal("123", entry.AttemptedValue); + + entry = Assert.Single(modelState, e => e.Key == "customParameter.Customer.Name").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + var error = Assert.Single(modelState["customParameter.Customer.Name"].Errors); + Assert.Equal("A value for the 'Name' parameter or property was not provided.", error.ErrorMessage); + } + + private class Order12 + { + [BindRequired] + public string ProductName { get; set; } + } + + [Fact] + public async Task ComplexBinder_WithRequiredProperty_NoData_GetsErrors() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order12) + }; + + // No Data + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.ProductName); + + Assert.Single(modelState); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "ProductName").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + var error = Assert.Single(modelState["ProductName"].Errors); + Assert.Equal("A value for the 'ProductName' parameter or property was not provided.", error.ErrorMessage); + } + + [Fact] + public async Task ComplexBinder_WithRequiredProperty_NoData_CustomPrefix_GetsErrors() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order12), + BindingInfo = new BindingInfo() + { + BinderModelName = "customParameter" + } + }; + + // No Data + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.ProductName); + + Assert.Single(modelState); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "customParameter.ProductName").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + var error = Assert.Single(modelState["customParameter.ProductName"].Errors); + Assert.Equal("A value for the 'ProductName' parameter or property was not provided.", error.ErrorMessage); + } + + [Fact] + public async Task ComplexBinder_WithRequiredProperty_WithData_EmptyPrefix_GetsBound() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order12), + }; + + // No Data + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?ProductName=abc"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("abc", model.ProductName); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "ProductName").Value; + Assert.Equal("abc", entry.RawValue); + Assert.Equal("abc", entry.AttemptedValue); + } + + private class Order13 + { + [BindRequired] + public List OrderIds { get; set; } + } + + [Fact] + public async Task ComplexBinder_WithRequiredCollectionProperty_NoData_GetsErrors() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order13) + }; + + // No Data + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.OrderIds); + + Assert.Single(modelState); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "OrderIds").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + var error = Assert.Single(modelState["OrderIds"].Errors); + Assert.Equal("A value for the 'OrderIds' parameter or property was not provided.", error.ErrorMessage); + } + + [Fact] + public async Task ComplexBinder_WithRequiredCollectionProperty_NoData_CustomPrefix_GetsErrors() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order13), + BindingInfo = new BindingInfo() + { + BinderModelName = "customParameter" + } + }; + + // No Data + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.OrderIds); + + Assert.Single(modelState); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "customParameter.OrderIds").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + var error = Assert.Single(modelState["customParameter.OrderIds"].Errors); + Assert.Equal("A value for the 'OrderIds' parameter or property was not provided.", error.ErrorMessage); + } + + [Fact] + public async Task ComplexBinder_WithRequiredCollectionProperty_WithData_EmptyPrefix_GetsBound() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order13), + }; + + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?OrderIds[0]=123"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal(new[] { 123 }, model.OrderIds.ToArray()); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "OrderIds[0]").Value; + Assert.Equal("123", entry.RawValue); + Assert.Equal("123", entry.AttemptedValue); + } + + private class Order14 + { + public int ProductId { get; set; } + } + + // This covers the case where a key is present, but has an empty value. The type converter + // will report an error. + [Fact] + public async Task ComplexBinder_BindsPOCO_TypeConvertedPropertyNonConvertibleValue_GetsError() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order14) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.ProductId="); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model); + Assert.Equal(0, model.ProductId); + + Assert.Single(modelState); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.ProductId").Value; + Assert.Equal(string.Empty, entry.AttemptedValue); + Assert.Equal(string.Empty, entry.RawValue); + + var error = Assert.Single(entry.Errors); + Assert.Equal("The value '' is invalid.", error.ErrorMessage); + Assert.Null(error.Exception); + } + + // This covers the case where a key is present, but has no value. The model binder will + // report and error because it's a value type (non-nullable). + [Fact] + [ReplaceCulture] + public async Task ComplexBinder_BindsPOCO_TypeConvertedPropertyWithEmptyValue_Error() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order14) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.ProductId"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model); + Assert.Equal(0, model.ProductId); + + var entry = Assert.Single(modelState); + Assert.Equal("parameter.ProductId", entry.Key); + Assert.Equal(string.Empty, entry.Value.AttemptedValue); + + var error = Assert.Single(entry.Value.Errors); + Assert.Equal("The value '' is invalid.", error.ErrorMessage, StringComparer.Ordinal); + Assert.Null(error.Exception); + + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + } + + private class Person12 + { + public Address12 Address { get; set; } + } + + [ModelBinder(Name = "HomeAddress")] + private class Address12 + { + public string Street { get; set; } + } + + // Make sure the metadata is honored when a [ModelBinder] attribute is associated with a class somewhere in the + // type hierarchy of an action parameter. This should behave identically to such an attribute on a property in + // the type hierarchy. + [Theory] + [MemberData( + nameof(BinderTypeBasedModelBinderIntegrationTest.NullAndEmptyBindingInfo), + MemberType = typeof(BinderTypeBasedModelBinderIntegrationTest))] + public async Task ModelNameOnPropertyType_WithData_Succeeds(BindingInfo bindingInfo) + { + // Arrange + var parameter = new ParameterDescriptor + { + Name = "parameter-name", + BindingInfo = bindingInfo, + ParameterType = typeof(Person12), + }; + + var testContext = GetTestContext( + request => request.QueryString = new QueryString("?HomeAddress.Street=someStreet")); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + var person = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(person.Address); + Assert.Equal("someStreet", person.Address.Street, StringComparer.Ordinal); + + Assert.True(modelState.IsValid); + var kvp = Assert.Single(modelState); + Assert.Equal("HomeAddress.Street", kvp.Key); + var entry = kvp.Value; + Assert.NotNull(entry); + Assert.Empty(entry.Errors); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + } + + // Make sure the metadata is honored when a [ModelBinder] attribute is associated with an action parameter's + // type. This should behave identically to such an attribute on an action parameter. + [Theory] + [MemberData( + nameof(BinderTypeBasedModelBinderIntegrationTest.NullAndEmptyBindingInfo), + MemberType = typeof(BinderTypeBasedModelBinderIntegrationTest))] + public async Task ModelNameOnParameterType_WithData_Succeeds(BindingInfo bindingInfo) + { + // Arrange + var parameter = new ParameterDescriptor + { + Name = "parameter-name", + BindingInfo = bindingInfo, + ParameterType = typeof(Address12), + }; + + var testContext = GetTestContext( + request => request.QueryString = new QueryString("?HomeAddress.Street=someStreet")); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + var address = Assert.IsType(modelBindingResult.Model); + Assert.Equal("someStreet", address.Street, StringComparer.Ordinal); + + Assert.True(modelState.IsValid); + var kvp = Assert.Single(modelState); + Assert.Equal("HomeAddress.Street", kvp.Key); + var entry = kvp.Value; + Assert.NotNull(entry); + Assert.Empty(entry.Errors); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + } + + private class Person13 + { + public Address13 Address { get; set; } + } + + [Bind("Street")] + private class Address13 + { + public int Number { get; set; } + + public string Street { get; set; } + + public string City { get; set; } + + public string State { get; set; } + } + + // Make sure the metadata is honored when a [Bind] attribute is associated with a class somewhere in the type + // hierarchy of an action parameter. This should behave identically to such an attribute on a property in the + // type hierarchy. (Test is similar to ModelNameOnPropertyType_WithData_Succeeds() but covers implementing + // IPropertyFilterProvider, not IModelNameProvider.) + [Theory] + [MemberData( + nameof(BinderTypeBasedModelBinderIntegrationTest.NullAndEmptyBindingInfo), + MemberType = typeof(BinderTypeBasedModelBinderIntegrationTest))] + public async Task BindAttributeOnPropertyType_WithData_Succeeds(BindingInfo bindingInfo) + { + // Arrange + var parameter = new ParameterDescriptor + { + Name = "parameter-name", + BindingInfo = bindingInfo, + ParameterType = typeof(Person13), + }; + + var testContext = GetTestContext( + request => request.QueryString = new QueryString( + "?Address.Number=23&Address.Street=someStreet&Address.City=Redmond&Address.State=WA")); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + var person = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(person.Address); + Assert.Null(person.Address.City); + Assert.Equal(0, person.Address.Number); + Assert.Null(person.Address.State); + Assert.Equal("someStreet", person.Address.Street, StringComparer.Ordinal); + + Assert.True(modelState.IsValid); + var kvp = Assert.Single(modelState); + Assert.Equal("Address.Street", kvp.Key); + var entry = kvp.Value; + Assert.NotNull(entry); + Assert.Empty(entry.Errors); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + } + + // Make sure the metadata is honored when a [Bind] attribute is associated with an action parameter's type. + // This should behave identically to such an attribute on an action parameter. (Test is similar + // to ModelNameOnParameterType_WithData_Succeeds() but covers implementing IPropertyFilterProvider, not + // IModelNameProvider.) + [Theory] + [MemberData( + nameof(BinderTypeBasedModelBinderIntegrationTest.NullAndEmptyBindingInfo), + MemberType = typeof(BinderTypeBasedModelBinderIntegrationTest))] + public async Task BindAttributeOnParameterType_WithData_Succeeds(BindingInfo bindingInfo) + { + // Arrange + var parameter = new ParameterDescriptor + { + Name = "parameter-name", + BindingInfo = bindingInfo, + ParameterType = typeof(Address13), + }; + + var testContext = GetTestContext( + request => request.QueryString = new QueryString("?Number=23&Street=someStreet&City=Redmond&State=WA")); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + var address = Assert.IsType(modelBindingResult.Model); + Assert.Null(address.City); + Assert.Equal(0, address.Number); + Assert.Null(address.State); + Assert.Equal("someStreet", address.Street, StringComparer.Ordinal); + + Assert.True(modelState.IsValid); + var kvp = Assert.Single(modelState); + Assert.Equal("Street", kvp.Key); + var entry = kvp.Value; + Assert.NotNull(entry); + Assert.Empty(entry.Errors); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + } + + private class Product + { + public int ProductId { get; set; } + + public string Name { get; } + + public IList Aliases { get; } + } + + [Theory] + [InlineData("?parameter.ProductId=10")] + [InlineData("?parameter.ProductId=10¶meter.Name=Camera")] + [InlineData("?parameter.ProductId=10¶meter.Name=Camera¶meter.Aliases[0]=Camera1")] + public async Task ComplexTypeModelBinder_BindsSettableProperties(string queryString) + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Product) + }; + + // Need to have a key here so that the ComplexTypeModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString(queryString); + SetJsonBodyContent(request, AddressBodyContent); + }); + + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model); + Assert.Equal(10, model.ProductId); + Assert.Null(model.Name); + Assert.Null(model.Aliases); + } + + private class Photo + { + public string Id { get; set; } + + public KeyValuePair Info { get; set; } + } + + private class LocationInfo + { + [FromHeader] + public string GpsCoordinates { get; set; } + + public int Zipcode { get; set; } + } + + [Fact] + public async Task ComplexBinder_BindsKeyValuePairProperty_HavingFromHeaderProperty_Success() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Photo) + }; + + // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. + var testContext = GetTestContext(request => + { + request.Headers.Add("GpsCoordinates", "10,20"); + request.QueryString = new QueryString("?Id=1&Info.Key=location1&Info.Value.Zipcode=98052"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + // Model + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("1", model.Id); + Assert.Equal("location1", model.Info.Key); + Assert.NotNull(model.Info.Value); + Assert.Equal("10,20", model.Info.Value.GpsCoordinates); + Assert.Equal(98052, model.Info.Value.Zipcode); + + // ModelState + Assert.Equal(4, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Id").Value; + Assert.Equal("1", entry.AttemptedValue); + Assert.Equal("1", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "Info.Key").Value; + Assert.Equal("location1", entry.AttemptedValue); + Assert.Equal("location1", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "Info.Value.Zipcode").Value; + Assert.Equal("98052", entry.AttemptedValue); + Assert.Equal("98052", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "Info.Value.GpsCoordinates").Value; + Assert.Equal("10,20", entry.AttemptedValue); + Assert.Equal("10,20", entry.RawValue); + } + + private class Person5 + { + public string Name { get; set; } + public IFormFile Photo { get; set; } + } + + // Regression test for #4802. + [Fact] + public async Task ComplexTypeModelBinder_ReportsFailureToCollectionModelBinder() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(IList), + }; + + var testContext = GetTestContext(request => + { + SetFormFileBodyContent(request, "Hello world!", "[0].Photo"); + + // CollectionModelBinder binds an empty collection when value providers are all empty. + request.QueryString = new QueryString("?a=b"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + var person = Assert.Single(model); + Assert.Null(person.Name); + Assert.NotNull(person.Photo); + using (var reader = new StreamReader(person.Photo.OpenReadStream())) + { + Assert.Equal("Hello world!", await reader.ReadToEndAsync()); + } + + Assert.True(modelState.IsValid); + var state = Assert.Single(modelState); + Assert.Equal("[0].Photo", state.Key); + Assert.Null(state.Value.AttemptedValue); + Assert.Empty(state.Value.Errors); + Assert.Null(state.Value.RawValue); + } + + private class TestModel + { + public TestInnerModel[] InnerModels { get; set; } = Array.Empty(); + } + + private class TestInnerModel + { + [ModelBinder(BinderType = typeof(NumberModelBinder))] + public decimal Rate { get; set; } + } + + private class NumberModelBinder : IModelBinder + { + private readonly NumberStyles _supportedStyles = NumberStyles.Float | NumberStyles.AllowThousands; + private DecimalModelBinder _innerBinder; + + public NumberModelBinder(ILoggerFactory loggerFactory) + { + _innerBinder = new DecimalModelBinder(_supportedStyles, loggerFactory); + } + + public Task BindModelAsync(ModelBindingContext bindingContext) + { + return _innerBinder.BindModelAsync(bindingContext); + } + } + + // Regression test for #4939. + [Fact] + public async Task ComplexTypeModelBinder_ReportsFailureToCollectionModelBinder_CustomBinder() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(TestModel), + }; + + var testContext = GetTestContext(request => + { + request.QueryString = new QueryString( + "?parameter.InnerModels[0].Rate=1,000.00¶meter.InnerModels[1].Rate=2000"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.InnerModels); + Assert.Collection( + model.InnerModels, + item => Assert.Equal(1000, item.Rate), + item => Assert.Equal(2000, item.Rate)); + + Assert.True(modelState.IsValid); + Assert.Collection( + modelState, + kvp => + { + Assert.Equal("parameter.InnerModels[0].Rate", kvp.Key); + Assert.Equal("1,000.00", kvp.Value.AttemptedValue); + Assert.Empty(kvp.Value.Errors); + Assert.Equal("1,000.00", kvp.Value.RawValue); + Assert.Equal(ModelValidationState.Valid, kvp.Value.ValidationState); + }, + kvp => + { + Assert.Equal("parameter.InnerModels[1].Rate", kvp.Key); + Assert.Equal("2000", kvp.Value.AttemptedValue); + Assert.Empty(kvp.Value.Errors); + Assert.Equal("2000", kvp.Value.RawValue); + Assert.Equal(ModelValidationState.Valid, kvp.Value.ValidationState); + }); + } + + private class Person6 + { + public string Name { get; set; } + + public Person6 Mother { get; set; } + + public IFormFile Photo { get; set; } + } + + // Regression test for #6616. + [Fact] + public async Task ComplexTypeModelBinder_ReportsFailureToComplexTypeModelBinder_NearTopLevel() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Person6), + }; + + var testContext = GetTestContext(request => + { + SetFormFileBodyContent(request, "Hello world!", "Photo"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.Mother); + Assert.Null(model.Name); + Assert.NotNull(model.Photo); + using (var reader = new StreamReader(model.Photo.OpenReadStream())) + { + Assert.Equal("Hello world!", await reader.ReadToEndAsync()); + } + + Assert.True(modelState.IsValid); + var state = Assert.Single(modelState); + Assert.Equal("Photo", state.Key); + Assert.Null(state.Value.AttemptedValue); + Assert.Empty(state.Value.Errors); + Assert.Null(state.Value.RawValue); + } + + // Regression test for #6616. + [Fact] + public async Task ComplexTypeModelBinder_ReportsFailureToComplexTypeModelBinder() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Person6), + }; + + var testContext = GetTestContext(request => + { + SetFormFileBodyContent(request, "Hello world!", "Photo"); + SetFormFileBodyContent(request, "Hello Mom!", "Mother.Photo"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Mother); + Assert.Null(model.Mother.Mother); + Assert.NotNull(model.Mother.Photo); + using (var reader = new StreamReader(model.Mother.Photo.OpenReadStream())) + { + Assert.Equal("Hello Mom!", await reader.ReadToEndAsync()); + } + + Assert.Null(model.Name); + Assert.NotNull(model.Photo); + using (var reader = new StreamReader(model.Photo.OpenReadStream())) + { + Assert.Equal("Hello world!", await reader.ReadToEndAsync()); + } + + Assert.True(modelState.IsValid); + Assert.Collection( + modelState, + kvp => + { + Assert.Equal("Photo", kvp.Key); + Assert.Null(kvp.Value.AttemptedValue); + Assert.Empty(kvp.Value.Errors); + Assert.Null(kvp.Value.RawValue); + }, + kvp => + { + Assert.Equal("Mother.Photo", kvp.Key); + Assert.Null(kvp.Value.AttemptedValue); + Assert.Empty(kvp.Value.Errors); + Assert.Null(kvp.Value.RawValue); + }); + } + + private class Person7 + { + public string Name { get; set; } + + public IList Children { get; set; } + + public IFormFile Photo { get; set; } + } + + // Regression test for #6616. + [Fact] + public async Task ComplexTypeModelBinder_ReportsFailureToComplexTypeModelBinder_ViaCollection() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Person7), + }; + + var testContext = GetTestContext(request => + { + SetFormFileBodyContent(request, "Hello world!", "Photo"); + SetFormFileBodyContent(request, "Hello Fred!", "Children[0].Photo"); + SetFormFileBodyContent(request, "Hello Ginger!", "Children[1].Photo"); + + request.QueryString = new QueryString("?Children[0].Name=Fred&Children[1].Name=Ginger"); + }); + + var modelState = testContext.ModelState; + var metadata = GetMetadata(testContext, parameter); + var modelBinder = GetModelBinder(testContext, parameter, metadata); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync( + testContext, + modelBinder, + valueProvider, + parameter, + metadata, + value: null); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Children); + Assert.Collection( + model.Children, + item => + { + Assert.Null(item.Children); + Assert.Equal("Fred", item.Name); + using (var reader = new StreamReader(item.Photo.OpenReadStream())) + { + Assert.Equal("Hello Fred!", reader.ReadToEnd()); + } + }, + item => + { + Assert.Null(item.Children); + Assert.Equal("Ginger", item.Name); + using (var reader = new StreamReader(item.Photo.OpenReadStream())) + { + Assert.Equal("Hello Ginger!", reader.ReadToEnd()); + } + }); + + Assert.Null(model.Name); + Assert.NotNull(model.Photo); + using (var reader = new StreamReader(model.Photo.OpenReadStream())) + { + Assert.Equal("Hello world!", await reader.ReadToEndAsync()); + } + + Assert.True(modelState.IsValid); + } + + private class LoopyModel + { + [ModelBinder(typeof(SuccessfulModelBinder))] + public bool IsBound { get; set; } + + public LoopyModel SelfReference { get; set; } + } + + // Regression test for #7052 + [Fact] + public async Task ModelBindingSystem_ThrowsOn33Binders() + { + // Arrange + var expectedMessage = $"Model binding system exceeded " + + $"{nameof(MvcOptions)}.{nameof(MvcOptions.MaxModelBindingRecursionDepth)} (32). Reduce the " + + $"potential nesting of '{typeof(LoopyModel)}'. For example, this type may have a property with a " + + $"model binder that always succeeds. See the " + + $"{nameof(MvcOptions)}.{nameof(MvcOptions.MaxModelBindingRecursionDepth)} documentation for more " + + $"information."; + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(LoopyModel), + }; + + var testContext = GetTestContext(); + var modelState = testContext.ModelState; + var metadata = testContext.MetadataProvider.GetMetadataForType(parameter.ParameterType); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => parameterBinder.BindModelAsync(parameter, testContext)); + Assert.Equal(expectedMessage, exception.Message); + } + + private class TwoDeepModel + { + [ModelBinder(typeof(SuccessfulModelBinder))] + public bool IsBound { get; set; } + } + + private class ThreeDeepModel + { + [ModelBinder(typeof(SuccessfulModelBinder))] + public bool IsBound { get; set; } + + public TwoDeepModel Inner { get; set; } + } + + // Ensure model binding system allows MaxModelBindingRecursionDepth binders on the stack. + [Fact] + public async Task ModelBindingSystem_BindsWith3Binders() + { + // Arrange + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(ThreeDeepModel), + }; + + var testContext = GetTestContext( + updateOptions: options => options.MaxModelBindingRecursionDepth = 3); + + var modelState = testContext.ModelState; + var metadata = testContext.MetadataProvider.GetMetadataForType(parameter.ParameterType); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act + var result = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelState.IsValid); + Assert.Equal(0, modelState.ErrorCount); + + Assert.True(result.IsModelSet); + var model = Assert.IsType(result.Model); + Assert.True(model.IsBound); + Assert.NotNull(model.Inner); + Assert.True(model.Inner.IsBound); + } + + private class FourDeepModel + { + [ModelBinder(typeof(SuccessfulModelBinder))] + public bool IsBound { get; set; } + + public ThreeDeepModel Inner { get; set; } + } + + // Ensure model binding system disallows one more than MaxModelBindingRecursionDepth binders on the stack. + [Fact] + public async Task ModelBindingSystem_ThrowsOn4Binders() + { + // Arrange + var expectedMessage = $"Model binding system exceeded " + + $"{nameof(MvcOptions)}.{nameof(MvcOptions.MaxModelBindingRecursionDepth)} (3). Reduce the " + + $"potential nesting of '{typeof(FourDeepModel)}'. For example, this type may have a property with a " + + $"model binder that always succeeds. See the " + + $"{nameof(MvcOptions)}.{nameof(MvcOptions.MaxModelBindingRecursionDepth)} documentation for more " + + $"information."; + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(FourDeepModel), + }; + + var testContext = GetTestContext( + updateOptions: options => options.MaxModelBindingRecursionDepth = 3); + + var modelState = testContext.ModelState; + var metadata = testContext.MetadataProvider.GetMetadataForType(parameter.ParameterType); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => parameterBinder.BindModelAsync(parameter, testContext)); + Assert.Equal(expectedMessage, exception.Message); + } + + private class LoopyModel1 + { + [ModelBinder(typeof(SuccessfulModelBinder))] + public bool IsBound { get; set; } + + public LoopyModel2 Inner { get; set; } + } + + private class LoopyModel2 + { + [ModelBinder(typeof(SuccessfulModelBinder))] + public bool IsBound { get; set; } + + public LoopyModel3 Inner { get; set; } + } + + private class LoopyModel3 + { + [ModelBinder(typeof(SuccessfulModelBinder))] + public bool IsBound { get; set; } + + public LoopyModel1 Inner { get; set; } + } + + [Fact] + public async Task ModelBindingSystem_ThrowsOn33Binders_WithIndirectModelTypeLoop() + { + // Arrange + var expectedMessage = $"Model binding system exceeded " + + $"{nameof(MvcOptions)}.{nameof(MvcOptions.MaxModelBindingRecursionDepth)} (32). Reduce the " + + $"potential nesting of '{typeof(LoopyModel1)}'. For example, this type may have a property with a " + + $"model binder that always succeeds. See the " + + $"{nameof(MvcOptions)}.{nameof(MvcOptions.MaxModelBindingRecursionDepth)} documentation for more " + + $"information."; + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(LoopyModel1), + }; + + var testContext = GetTestContext(); + var modelState = testContext.ModelState; + var metadata = testContext.MetadataProvider.GetMetadataForType(parameter.ParameterType); + var valueProvider = await CompositeValueProvider.CreateAsync(testContext); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); + + // Act & Assert + var exception = await Assert.ThrowsAsync( + () => parameterBinder.BindModelAsync(parameter, testContext)); + Assert.Equal(expectedMessage, exception.Message); + } + + private static void SetJsonBodyContent(HttpRequest request, string content) + { + var stream = new MemoryStream(new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(content)); + request.Body = stream; + request.ContentType = "application/json"; + } + + private static void SetFormFileBodyContent(HttpRequest request, string content, string name) + { + const string fileName = "text.txt"; + + FormFileCollection fileCollection; + if (request.HasFormContentType) + { + // Do less work and do not overwrite previous information if called a second time. + fileCollection = (FormFileCollection)request.Form.Files; + } + else + { + fileCollection = new FormFileCollection(); + var formCollection = new FormCollection(new Dictionary(), fileCollection); + + request.ContentType = "multipart/form-data; boundary=----WebKitFormBoundarymx2fSWqWSd0OxQqq"; + request.Form = formCollection; + } + + var memoryStream = new MemoryStream(Encoding.UTF8.GetBytes(content)); + var file = new FormFile(memoryStream, 0, memoryStream.Length, name, fileName) + { + Headers = new HeaderDictionary(), + + // Do not move this up. Headers must be non-null before the ContentDisposition property is accessed. + ContentDisposition = $"form-data; name={name}; filename={fileName}", + }; + + fileCollection.Add(file); + } + + private ModelMetadata GetMetadata(ModelBindingTestContext context, ParameterDescriptor parameter) + { + return context.MetadataProvider.GetMetadataForType(parameter.ParameterType); + } + + private IModelBinder GetModelBinder( + ModelBindingTestContext context, + ParameterDescriptor parameter, + ModelMetadata metadata) + { + var factory = ModelBindingTestHelper.GetModelBinderFactory( + context.MetadataProvider, + context.HttpContext.RequestServices); + var factoryContext = new ModelBinderFactoryContext + { + BindingInfo = parameter.BindingInfo, + CacheToken = parameter, + Metadata = metadata, + }; + + return factory.CreateBinder(factoryContext); + } + + protected virtual ModelBindingTestContext GetTestContext( + Action updateRequest = null, + Action updateOptions = null, + IModelMetadataProvider metadataProvider = null) + => ModelBindingTestHelper.GetTestContext(updateRequest, updateOptions, actionDescriptor: null, metadataProvider); + } +} diff --git a/src/Mvc/test/Mvc.IntegrationTests/ComplexTypeModelBinderIntegrationTest.cs b/src/Mvc/test/Mvc.IntegrationTests/ComplexTypeModelBinderIntegrationTest.cs index 473926154d2f..0af0b2a0dfd7 100644 --- a/src/Mvc/test/Mvc.IntegrationTests/ComplexTypeModelBinderIntegrationTest.cs +++ b/src/Mvc/test/Mvc.IntegrationTests/ComplexTypeModelBinderIntegrationTest.cs @@ -2,3745 +2,34 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Text; -using System.Threading.Tasks; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; -using Microsoft.AspNetCore.Testing; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Primitives; -using Xunit; namespace Microsoft.AspNetCore.Mvc.IntegrationTests { - // Integration tests targeting the behavior of the ComplexTypeModelBinder and related classes - // with other model binders. - public class ComplexTypeModelBinderIntegrationTest - { - private const string AddressBodyContent = "{ \"street\" : \"" + AddressStreetContent + "\" }"; - private const string AddressStreetContent = "1 Microsoft Way"; - - private static readonly byte[] ByteArrayContent = Encoding.BigEndianUnicode.GetBytes("abcd"); - private static readonly string ByteArrayEncoded = Convert.ToBase64String(ByteArrayContent); - - private class Order1 - { - public int ProductId { get; set; } - - public Person1 Customer { get; set; } - } - - private class Person1 - { - public string Name { get; set; } - - [FromBody] - public Address1 Address { get; set; } - } - - private class Address1 - { - public string Street { get; set; } - } - - [Fact] - public async Task MutableObjectModelBinder_BindsNestedPOCO_WithBodyModelBinder_WithPrefix_Success() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order1) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?parameter.Customer.Name=bill"); - SetJsonBodyContent(request, AddressBodyContent); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(model.Customer); - Assert.Equal("bill", model.Customer.Name); - Assert.NotNull(model.Customer.Address); - Assert.Equal(AddressStreetContent, model.Customer.Address.Street); - - Assert.Single(modelState); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; - Assert.Equal("bill", entry.AttemptedValue); - Assert.Equal("bill", entry.RawValue); - } - - [Fact] - public async Task MutableObjectModelBinder_BindsNestedPOCO_WithBodyModelBinder_WithEmptyPrefix_Success() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order1) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?Customer.Name=bill"); - SetJsonBodyContent(request, AddressBodyContent); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(model.Customer); - Assert.Equal("bill", model.Customer.Name); - Assert.NotNull(model.Customer.Address); - Assert.Equal(AddressStreetContent, model.Customer.Address.Street); - - Assert.Single(modelState); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "Customer.Name").Value; - Assert.Equal("bill", entry.AttemptedValue); - Assert.Equal("bill", entry.RawValue); - } - - [Fact] - public async Task MutableObjectModelBinder_BindsNestedPOCO_WithBodyModelBinder_WithPrefix_NoBodyData() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order1) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?parameter.Customer.Name=bill"); - request.ContentType = "application/json"; - }); - - testContext.MvcOptions.AllowEmptyInputInBodyModelBinding = true; - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(model.Customer); - Assert.Equal("bill", model.Customer.Name); - Assert.Null(model.Customer.Address); - - Assert.Single(modelState); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; - Assert.Equal("bill", entry.AttemptedValue); - Assert.Equal("bill", entry.RawValue); - } - - [Fact] - public async Task ComplexTypeModelBinder_BindsNestedPOCO_WithBodyModelBinder_WithPrefix_PartialData() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order1) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?parameter.ProductId=10"); - SetJsonBodyContent(request, AddressBodyContent); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(model.Customer); - Assert.Equal("1 Microsoft Way", model.Customer.Address.Street); - - Assert.Equal(10, model.ProductId); - - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState).Value; - Assert.Equal("10", entry.AttemptedValue); - Assert.Equal("10", entry.RawValue); - } - - [Fact] - public async Task ComplexTypeModelBinder_BindsNestedPOCO_WithBodyModelBinder_WithPrefix_NoData() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order1) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?"); - SetJsonBodyContent(request, AddressBodyContent); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(model.Customer); - Assert.Equal("1 Microsoft Way", model.Customer.Address.Street); - - Assert.Empty(modelState); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - } - - private class Order3 - { - public int ProductId { get; set; } - - public Person3 Customer { get; set; } - } - - private class Person3 - { - public string Name { get; set; } - - public byte[] Token { get; set; } - } - - [Fact] - public async Task MutableObjectModelBinder_BindsNestedPOCO_WithByteArrayModelBinder_WithPrefix_Success() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order3) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = - new QueryString("?parameter.Customer.Name=bill¶meter.Customer.Token=" + ByteArrayEncoded); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(model.Customer); - Assert.Equal("bill", model.Customer.Name); - Assert.Equal(ByteArrayContent, model.Customer.Token); - - Assert.Equal(2, modelState.Count); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; - Assert.Equal("bill", entry.AttemptedValue); - Assert.Equal("bill", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Token").Value; - Assert.Equal(ByteArrayEncoded, entry.AttemptedValue); - Assert.Equal(ByteArrayEncoded, entry.RawValue); - } - - [Fact] - public async Task MutableObjectModelBinder_BindsNestedPOCO_WithByteArrayModelBinder_WithEmptyPrefix_Success() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order3) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?Customer.Name=bill&Customer.Token=" + ByteArrayEncoded); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(model.Customer); - Assert.Equal("bill", model.Customer.Name); - Assert.Equal(ByteArrayContent, model.Customer.Token); - - Assert.Equal(2, modelState.Count); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "Customer.Name").Value; - Assert.Equal("bill", entry.AttemptedValue); - Assert.Equal("bill", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "Customer.Token").Value; - Assert.Equal(ByteArrayEncoded, entry.AttemptedValue); - Assert.Equal(ByteArrayEncoded, entry.RawValue); - } - - [Fact] - public async Task MutableObjectModelBinder_BindsNestedPOCO_WithByteArrayModelBinder_WithPrefix_NoData() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order3) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?parameter.Customer.Name=bill"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(model.Customer); - Assert.Equal("bill", model.Customer.Name); - Assert.Null(model.Customer.Token); - - Assert.Single(modelState); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; - Assert.Equal("bill", entry.AttemptedValue); - Assert.Equal("bill", entry.RawValue); - } - - private class Order4 - { - public int ProductId { get; set; } - - public Person4 Customer { get; set; } - } - - private class Person4 - { - public string Name { get; set; } - - public IEnumerable Documents { get; set; } - } - - [Fact] - public async Task MutableObjectModelBinder_BindsNestedPOCO_WithFormFileModelBinder_WithPrefix_Success() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order4) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?parameter.Customer.Name=bill"); - SetFormFileBodyContent(request, "Hello, World!", "parameter.Customer.Documents"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(model.Customer); - Assert.Equal("bill", model.Customer.Name); - Assert.Single(model.Customer.Documents); - - Assert.Equal(2, modelState.Count); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; - Assert.Equal("bill", entry.AttemptedValue); - Assert.Equal("bill", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Documents").Value; - Assert.Null(entry.AttemptedValue); // FormFile entries for body don't include original text. - Assert.Null(entry.RawValue); - } - - [Fact] - public async Task MutableObjectModelBinder_BindsNestedPOCO_WithFormFileModelBinder_WithEmptyPrefix_Success() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order4) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?Customer.Name=bill"); - SetFormFileBodyContent(request, "Hello, World!", "Customer.Documents"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(model.Customer); - Assert.Equal("bill", model.Customer.Name); - Assert.Single(model.Customer.Documents); - - Assert.Equal(2, modelState.Count); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "Customer.Name").Value; - Assert.Equal("bill", entry.AttemptedValue); - Assert.Equal("bill", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "Customer.Documents").Value; - Assert.Null(entry.AttemptedValue); // FormFile entries don't include the model. - Assert.Null(entry.RawValue); - } - - [Fact] - public async Task MutableObjectModelBinder_BindsNestedPOCO_WithFormFileModelBinder_WithPrefix_NoBodyData() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order4) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?parameter.Customer.Name=bill"); - - // Deliberately leaving out any form data. - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(model.Customer); - Assert.Equal("bill", model.Customer.Name); - Assert.Null(model.Customer.Documents); - - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var kvp = Assert.Single(modelState); - Assert.Equal("parameter.Customer.Name", kvp.Key); - var entry = kvp.Value; - Assert.Equal("bill", entry.AttemptedValue); - Assert.Equal("bill", entry.RawValue); - } - - [Fact] - public async Task ComplexTypeModelBinder_BindsNestedPOCO_WithFormFileModelBinder_WithPrefix_PartialData() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order4) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?parameter.ProductId=10"); - SetFormFileBodyContent(request, "Hello, World!", "parameter.Customer.Documents"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(model.Customer); - - var document = Assert.Single(model.Customer.Documents); - Assert.Equal("text.txt", document.FileName); - using (var reader = new StreamReader(document.OpenReadStream())) - { - Assert.Equal("Hello, World!", await reader.ReadToEndAsync()); - } - - Assert.Equal(10, model.ProductId); - - Assert.Equal(2, modelState.Count); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - Assert.Single(modelState, e => e.Key == "parameter.Customer.Documents"); - var entry = Assert.Single(modelState, e => e.Key == "parameter.ProductId").Value; - Assert.Equal("10", entry.AttemptedValue); - Assert.Equal("10", entry.RawValue); - } - - [Fact] - public async Task ComplexTypeModelBinder_BindsNestedPOCO_WithFormFileModelBinder_WithPrefix_NoData() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order4) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?"); - SetFormFileBodyContent(request, "Hello, World!", "Customer.Documents"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(model.Customer); - - var document = Assert.Single(model.Customer.Documents); - Assert.Equal("text.txt", document.FileName); - using (var reader = new StreamReader(document.OpenReadStream())) - { - Assert.Equal("Hello, World!", await reader.ReadToEndAsync()); - } - - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState); - Assert.Equal("Customer.Documents", entry.Key); - } - - private class Order5 - { - public string Name { get; set; } - - public int[] ProductIds { get; set; } - } - - [Fact] - public async Task MutableObjectModelBinder_BindsArrayProperty_WithPrefix_Success() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order5) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = - new QueryString("?parameter.Name=bill¶meter.ProductIds[0]=10¶meter.ProductIds[1]=11"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Equal("bill", model.Name); - Assert.Equal(new int[] { 10, 11 }, model.ProductIds); - - Assert.Equal(3, modelState.Count); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; - Assert.Equal("bill", entry.AttemptedValue); - Assert.Equal("bill", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "parameter.ProductIds[0]").Value; - Assert.Equal("10", entry.AttemptedValue); - Assert.Equal("10", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "parameter.ProductIds[1]").Value; - Assert.Equal("11", entry.AttemptedValue); - Assert.Equal("11", entry.RawValue); - } - - [Fact] - public async Task MutableObjectModelBinder_BindsArrayProperty_EmptyPrefix_Success() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order5) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?Name=bill&ProductIds[0]=10&ProductIds[1]=11"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Equal("bill", model.Name); - Assert.Equal(new int[] { 10, 11 }, model.ProductIds); - - Assert.Equal(3, modelState.Count); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "Name").Value; - Assert.Equal("bill", entry.AttemptedValue); - Assert.Equal("bill", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "ProductIds[0]").Value; - Assert.Equal("10", entry.AttemptedValue); - Assert.Equal("10", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "ProductIds[1]").Value; - Assert.Equal("11", entry.AttemptedValue); - Assert.Equal("11", entry.RawValue); - } - - [Fact] - public async Task MutableObjectModelBinder_BindsArrayProperty_NoCollectionData() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order5) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?parameter.Name=bill"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Equal("bill", model.Name); - Assert.Null(model.ProductIds); - - Assert.Single(modelState); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; - Assert.Equal("bill", entry.AttemptedValue); - Assert.Equal("bill", entry.RawValue); - } - - [Fact] - public async Task MutableObjectModelBinder_BindsArrayProperty_NoData() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order5) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Null(model.Name); - Assert.Null(model.ProductIds); - - Assert.Empty(modelState); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - } - - private class Order6 - { - public string Name { get; set; } - - public List ProductIds { get; set; } - } - - [Fact] - public async Task MutableObjectModelBinder_BindsListProperty_WithPrefix_Success() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order6) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = - new QueryString("?parameter.Name=bill¶meter.ProductIds[0]=10¶meter.ProductIds[1]=11"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Equal("bill", model.Name); - Assert.Equal(new List() { 10, 11 }, model.ProductIds); - - Assert.Equal(3, modelState.Count); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; - Assert.Equal("bill", entry.AttemptedValue); - Assert.Equal("bill", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "parameter.ProductIds[0]").Value; - Assert.Equal("10", entry.AttemptedValue); - Assert.Equal("10", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "parameter.ProductIds[1]").Value; - Assert.Equal("11", entry.AttemptedValue); - Assert.Equal("11", entry.RawValue); - } - - [Fact] - public async Task MutableObjectModelBinder_BindsListProperty_EmptyPrefix_Success() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order6) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?Name=bill&ProductIds[0]=10&ProductIds[1]=11"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Equal("bill", model.Name); - Assert.Equal(new List() { 10, 11 }, model.ProductIds); - - Assert.Equal(3, modelState.Count); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "Name").Value; - Assert.Equal("bill", entry.AttemptedValue); - Assert.Equal("bill", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "ProductIds[0]").Value; - Assert.Equal("10", entry.AttemptedValue); - Assert.Equal("10", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "ProductIds[1]").Value; - Assert.Equal("11", entry.AttemptedValue); - Assert.Equal("11", entry.RawValue); - } - - [Fact] - public async Task MutableObjectModelBinder_BindsListProperty_NoCollectionData() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order6) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?parameter.Name=bill"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Equal("bill", model.Name); - Assert.Null(model.ProductIds); - - Assert.Single(modelState); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; - Assert.Equal("bill", entry.AttemptedValue); - Assert.Equal("bill", entry.RawValue); - } - - [Fact] - public async Task MutableObjectModelBinder_BindsListProperty_NoData() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order6) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Null(model.Name); - Assert.Null(model.ProductIds); - - Assert.Empty(modelState); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - } - - private class Order7 - { - public string Name { get; set; } - - public Dictionary ProductIds { get; set; } - } - - [Fact] - public async Task MutableObjectModelBinder_BindsDictionaryProperty_WithPrefix_Success() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order7) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = - new QueryString("?parameter.Name=bill¶meter.ProductIds[0].Key=key0¶meter.ProductIds[0].Value=10"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Equal("bill", model.Name); - Assert.Equal(new Dictionary() { { "key0", 10 } }, model.ProductIds); - - Assert.Equal(3, modelState.Count); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; - Assert.Equal("bill", entry.AttemptedValue); - Assert.Equal("bill", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "parameter.ProductIds[0].Key").Value; - Assert.Equal("key0", entry.AttemptedValue); - Assert.Equal("key0", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "parameter.ProductIds[0].Value").Value; - Assert.Equal("10", entry.AttemptedValue); - Assert.Equal("10", entry.RawValue); - } - - [Fact] - public async Task MutableObjectModelBinder_BindsDictionaryProperty_EmptyPrefix_Success() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order7) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?Name=bill&ProductIds[0].Key=key0&ProductIds[0].Value=10"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Equal("bill", model.Name); - Assert.Equal(new Dictionary() { { "key0", 10 } }, model.ProductIds); - - Assert.Equal(3, modelState.Count); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "Name").Value; - Assert.Equal("bill", entry.AttemptedValue); - Assert.Equal("bill", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "ProductIds[0].Key").Value; - Assert.Equal("key0", entry.AttemptedValue); - Assert.Equal("key0", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "ProductIds[0].Value").Value; - Assert.Equal("10", entry.AttemptedValue); - Assert.Equal("10", entry.RawValue); - } - - [Fact] - public async Task MutableObjectModelBinder_BindsDictionaryProperty_NoCollectionData() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order7) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?parameter.Name=bill"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Equal("bill", model.Name); - Assert.Null(model.ProductIds); - - Assert.Single(modelState); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; - Assert.Equal("bill", entry.AttemptedValue); - Assert.Equal("bill", entry.RawValue); - } - - [Fact] - public async Task MutableObjectModelBinder_BindsDictionaryProperty_NoData() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order7) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Null(model.Name); - Assert.Null(model.ProductIds); - - Assert.Empty(modelState); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - } - - // Dictionary property with an IEnumerable<> value type - private class Car1 - { - public string Name { get; set; } - - public Dictionary> Specs { get; set; } - } - - // Dictionary property with an Array value type - private class Car2 - { - public string Name { get; set; } - - public Dictionary Specs { get; set; } - } - - private class Car3 - { - public string Name { get; set; } - - public IEnumerable>> Specs { get; set; } - } - - private class SpecDoc - { - public string Name { get; set; } - } - - [Fact] - public async Task MutableObjectModelBinder_BindsDictionaryProperty_WithIEnumerableComplexTypeValue_Success() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "p", - ParameterType = typeof(Car1) - }; - - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - var queryString = "?p.Name=Accord" - + "&p.Specs[0].Key=camera_specs" - + "&p.Specs[0].Value[0].Name=camera_spec1.txt" - + "&p.Specs[0].Value[1].Name=camera_spec2.txt" - + "&p.Specs[1].Key=tyre_specs" - + "&p.Specs[1].Value[0].Name=tyre_spec1.txt" - + "&p.Specs[1].Value[1].Name=tyre_spec2.txt"; - request.QueryString = new QueryString(queryString); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Equal("Accord", model.Name); - - Assert.Collection( - model.Specs, - (e) => - { - Assert.Equal("camera_specs", e.Key); - Assert.Collection( - e.Value, - (s) => - { - Assert.Equal("camera_spec1.txt", s.Name); - }, - (s) => - { - Assert.Equal("camera_spec2.txt", s.Name); - }); - }, - (e) => - { - Assert.Equal("tyre_specs", e.Key); - Assert.Collection( - e.Value, - (s) => - { - Assert.Equal("tyre_spec1.txt", s.Name); - }, - (s) => - { - Assert.Equal("tyre_spec2.txt", s.Name); - }); - }); - - Assert.Equal(7, modelState.Count); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "p.Name").Value; - Assert.Equal("Accord", entry.AttemptedValue); - Assert.Equal("Accord", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Key").Value; - Assert.Equal("camera_specs", entry.AttemptedValue); - Assert.Equal("camera_specs", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Value[0].Name").Value; - Assert.Equal("camera_spec1.txt", entry.AttemptedValue); - Assert.Equal("camera_spec1.txt", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Value[1].Name").Value; - Assert.Equal("camera_spec2.txt", entry.AttemptedValue); - Assert.Equal("camera_spec2.txt", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Key").Value; - Assert.Equal("tyre_specs", entry.AttemptedValue); - Assert.Equal("tyre_specs", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Value[0].Name").Value; - Assert.Equal("tyre_spec1.txt", entry.AttemptedValue); - Assert.Equal("tyre_spec1.txt", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Value[1].Name").Value; - Assert.Equal("tyre_spec2.txt", entry.AttemptedValue); - Assert.Equal("tyre_spec2.txt", entry.RawValue); - } - - [Fact] - public async Task MutableObjectModelBinder_BindsDictionaryProperty_WithArrayOfComplexTypeValue_Success() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "p", - ParameterType = typeof(Car2) - }; - - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - var queryString = "?p.Name=Accord" - + "&p.Specs[0].Key=camera_specs" - + "&p.Specs[0].Value[0].Name=camera_spec1.txt" - + "&p.Specs[0].Value[1].Name=camera_spec2.txt" - + "&p.Specs[1].Key=tyre_specs" - + "&p.Specs[1].Value[0].Name=tyre_spec1.txt" - + "&p.Specs[1].Value[1].Name=tyre_spec2.txt"; - request.QueryString = new QueryString(queryString); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Equal("Accord", model.Name); - - Assert.Collection( - model.Specs, - (e) => - { - Assert.Equal("camera_specs", e.Key); - Assert.Collection( - e.Value, - (s) => - { - Assert.Equal("camera_spec1.txt", s.Name); - }, - (s) => - { - Assert.Equal("camera_spec2.txt", s.Name); - }); - }, - (e) => - { - Assert.Equal("tyre_specs", e.Key); - Assert.Collection( - e.Value, - (s) => - { - Assert.Equal("tyre_spec1.txt", s.Name); - }, - (s) => - { - Assert.Equal("tyre_spec2.txt", s.Name); - }); - }); - - Assert.Equal(7, modelState.Count); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "p.Name").Value; - Assert.Equal("Accord", entry.AttemptedValue); - Assert.Equal("Accord", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Key").Value; - Assert.Equal("camera_specs", entry.AttemptedValue); - Assert.Equal("camera_specs", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Value[0].Name").Value; - Assert.Equal("camera_spec1.txt", entry.AttemptedValue); - Assert.Equal("camera_spec1.txt", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Value[1].Name").Value; - Assert.Equal("camera_spec2.txt", entry.AttemptedValue); - Assert.Equal("camera_spec2.txt", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Key").Value; - Assert.Equal("tyre_specs", entry.AttemptedValue); - Assert.Equal("tyre_specs", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Value[0].Name").Value; - Assert.Equal("tyre_spec1.txt", entry.AttemptedValue); - Assert.Equal("tyre_spec1.txt", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Value[1].Name").Value; - Assert.Equal("tyre_spec2.txt", entry.AttemptedValue); - Assert.Equal("tyre_spec2.txt", entry.RawValue); - } - - [Fact] - public async Task MutableObjectModelBinder_BindsDictionaryProperty_WithIEnumerableOfKeyValuePair_Success() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "p", - ParameterType = typeof(Car3) - }; - - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - var queryString = "?p.Name=Accord" - + "&p.Specs[0].Key=camera_specs" - + "&p.Specs[0].Value[0].Name=camera_spec1.txt" - + "&p.Specs[0].Value[1].Name=camera_spec2.txt" - + "&p.Specs[1].Key=tyre_specs" - + "&p.Specs[1].Value[0].Name=tyre_spec1.txt" - + "&p.Specs[1].Value[1].Name=tyre_spec2.txt"; - request.QueryString = new QueryString(queryString); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Equal("Accord", model.Name); - - Assert.Collection( - model.Specs, - (e) => - { - Assert.Equal("camera_specs", e.Key); - Assert.Collection( - e.Value, - (s) => - { - Assert.Equal("camera_spec1.txt", s.Name); - }, - (s) => - { - Assert.Equal("camera_spec2.txt", s.Name); - }); - }, - (e) => - { - Assert.Equal("tyre_specs", e.Key); - Assert.Collection( - e.Value, - (s) => - { - Assert.Equal("tyre_spec1.txt", s.Name); - }, - (s) => - { - Assert.Equal("tyre_spec2.txt", s.Name); - }); - }); - - Assert.Equal(7, modelState.Count); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "p.Name").Value; - Assert.Equal("Accord", entry.AttemptedValue); - Assert.Equal("Accord", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Key").Value; - Assert.Equal("camera_specs", entry.AttemptedValue); - Assert.Equal("camera_specs", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Value[0].Name").Value; - Assert.Equal("camera_spec1.txt", entry.AttemptedValue); - Assert.Equal("camera_spec1.txt", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "p.Specs[0].Value[1].Name").Value; - Assert.Equal("camera_spec2.txt", entry.AttemptedValue); - Assert.Equal("camera_spec2.txt", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Key").Value; - Assert.Equal("tyre_specs", entry.AttemptedValue); - Assert.Equal("tyre_specs", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Value[0].Name").Value; - Assert.Equal("tyre_spec1.txt", entry.AttemptedValue); - Assert.Equal("tyre_spec1.txt", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "p.Specs[1].Value[1].Name").Value; - Assert.Equal("tyre_spec2.txt", entry.AttemptedValue); - Assert.Equal("tyre_spec2.txt", entry.RawValue); - } - - private class Order8 - { - public string Name { get; set; } = default!; - - public KeyValuePair ProductId { get; set; } - } - - [Fact] - public async Task MutableObjectModelBinder_BindsKeyValuePairProperty_WithPrefix_Success() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order8) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = - new QueryString("?parameter.Name=bill¶meter.ProductId.Key=key0¶meter.ProductId.Value=10"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Equal("bill", model.Name); - Assert.Equal(new KeyValuePair("key0", 10), model.ProductId); - - Assert.Equal(3, modelState.Count); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; - Assert.Equal("bill", entry.AttemptedValue); - Assert.Equal("bill", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "parameter.ProductId.Key").Value; - Assert.Equal("key0", entry.AttemptedValue); - Assert.Equal("key0", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "parameter.ProductId.Value").Value; - Assert.Equal("10", entry.AttemptedValue); - Assert.Equal("10", entry.RawValue); - } - - [Fact] - public async Task MutableObjectModelBinder_BindsKeyValuePairProperty_EmptyPrefix_Success() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order8) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?Name=bill&ProductId.Key=key0&ProductId.Value=10"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Equal("bill", model.Name); - Assert.Equal(new KeyValuePair("key0", 10), model.ProductId); - - Assert.Equal(3, modelState.Count); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "Name").Value; - Assert.Equal("bill", entry.AttemptedValue); - Assert.Equal("bill", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "ProductId.Key").Value; - Assert.Equal("key0", entry.AttemptedValue); - Assert.Equal("key0", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "ProductId.Value").Value; - Assert.Equal("10", entry.AttemptedValue); - Assert.Equal("10", entry.RawValue); - } - - [Fact(Skip = "https://github.com/dotnet/aspnetcore/issues/11813")] - public async Task MutableObjectModelBinder_BindsKeyValuePairProperty_NoCollectionData() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order8) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?parameter.Name=bill"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Equal("bill", model.Name); - Assert.Equal(default, model.ProductId); - - Assert.Equal(1, modelState.ErrorCount); - Assert.False(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; - Assert.Equal("bill", entry.AttemptedValue); - Assert.Equal("bill", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "parameter.ProductId.Key").Value; - Assert.Single(entry.Errors); - } - - [Fact(Skip = "https://github.com/dotnet/aspnetcore/issues/11813")] - public async Task MutableObjectModelBinder_BindsKeyValuePairProperty_NoData() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order8) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Null(model.Name); - Assert.Equal(default, model.ProductId); - - Assert.Equal(1, modelState.ErrorCount); - Assert.False(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "ProductId.Key").Value; - Assert.Single(entry.Errors); - } - - private class Car4 - { - public string Name { get; set; } - - public KeyValuePair> Specs { get; set; } - } - - [Fact] - public async Task Foo_MutableObjectModelBinder_BindsKeyValuePairProperty_WithPrefix_Success() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "p", - ParameterType = typeof(Car4) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - var queryString = "?p.Name=Accord" - + "&p.Specs.Key=camera_specs" - + "&p.Specs.Value[0].Key=spec1" - + "&p.Specs.Value[0].Value=spec1.txt" - + "&p.Specs.Value[1].Key=spec2" - + "&p.Specs.Value[1].Value=spec2.txt"; - - request.QueryString = new QueryString(queryString); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Equal("Accord", model.Name); - - Assert.Collection( - model.Specs.Value, - (e) => - { - Assert.Equal("spec1", e.Key); - Assert.Equal("spec1.txt", e.Value); - }, - (e) => - { - Assert.Equal("spec2", e.Key); - Assert.Equal("spec2.txt", e.Value); - }); - - Assert.Equal(6, modelState.Count); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "p.Name").Value; - Assert.Equal("Accord", entry.AttemptedValue); - Assert.Equal("Accord", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "p.Specs.Key").Value; - Assert.Equal("camera_specs", entry.AttemptedValue); - Assert.Equal("camera_specs", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "p.Specs.Value[0].Key").Value; - Assert.Equal("spec1", entry.AttemptedValue); - Assert.Equal("spec1", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "p.Specs.Value[0].Value").Value; - Assert.Equal("spec1.txt", entry.AttemptedValue); - Assert.Equal("spec1.txt", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "p.Specs.Value[1].Key").Value; - Assert.Equal("spec2", entry.AttemptedValue); - Assert.Equal("spec2", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "p.Specs.Value[1].Value").Value; - Assert.Equal("spec2.txt", entry.AttemptedValue); - Assert.Equal("spec2.txt", entry.RawValue); - } - - private class Order9 - { - public Person9 Customer { get; set; } - } - - private class Person9 - { - [FromBody] - public Address1 Address { get; set; } - } - - // If a nested POCO object has all properties bound from a greedy source, then it should be populated - // if the top-level object is created. - [Fact] - public async Task MutableObjectModelBinder_BindsNestedPOCO_WithAllGreedyBoundProperties() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order9) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?"); - SetJsonBodyContent(request, AddressBodyContent); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(model.Customer); - - Assert.NotNull(model.Customer.Address); - Assert.Equal(AddressStreetContent, model.Customer.Address.Street); - - Assert.Empty(modelState); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - } - - private class Order10 - { - [BindRequired] - public Person10 Customer { get; set; } - } - - private class Person10 - { - public string Name { get; set; } - } - - [Fact] - public async Task MutableObjectModelBinder_WithRequiredComplexProperty_NoData_GetsErrors() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order10) - }; - - // No Data - var testContext = ModelBindingTestHelper.GetTestContext(); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Null(model.Customer); - - Assert.Single(modelState); - Assert.Equal(1, modelState.ErrorCount); - Assert.False(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "Customer").Value; - Assert.Null(entry.RawValue); - Assert.Null(entry.AttemptedValue); - var error = Assert.Single(modelState["Customer"].Errors); - Assert.Equal("A value for the 'Customer' parameter or property was not provided.", error.ErrorMessage); - } - - [Fact] - public async Task MutableObjectModelBinder_WithBindRequired_NoData_AndCustomizedMessage_AddsGivenMessage() - { - // Arrange - var metadataProvider = new TestModelMetadataProvider(); - metadataProvider - .ForProperty(typeof(Order10), nameof(Order10.Customer)) - .BindingDetails((Action)(binding => - { - // A real details provider could customize message based on BindingMetadataProviderContext. - binding.ModelBindingMessageProvider.SetMissingBindRequiredValueAccessor( - name => $"Hurts when '{ name }' is not provided."); - })); - - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order10) - }; - - // No Data - var testContext = ModelBindingTestHelper.GetTestContext(metadataProvider: metadataProvider); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Null(model.Customer); - - Assert.Single(modelState); - Assert.Equal(1, modelState.ErrorCount); - Assert.False(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "Customer").Value; - Assert.Null(entry.RawValue); - Assert.Null(entry.AttemptedValue); - var error = Assert.Single(modelState["Customer"].Errors); - Assert.Equal("Hurts when 'Customer' is not provided.", error.ErrorMessage); - } - - private class Order11 - { - public Person11 Customer { get; set; } - } - - private class Person11 - { - public int Id { get; set; } - - [BindRequired] - public string Name { get; set; } - } - - [Fact] - public async Task MutableObjectModelBinder_WithNestedRequiredProperty_WithPartialData_GetsErrors() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order11) - }; - - // No Data - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?parameter.Customer.Id=123"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(model.Customer); - Assert.Equal(123, model.Customer.Id); - Assert.Null(model.Customer.Name); - - Assert.Equal(2, modelState.Count); - Assert.Equal(1, modelState.ErrorCount); - Assert.False(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Id").Value; - Assert.Equal("123", entry.RawValue); - Assert.Equal("123", entry.AttemptedValue); - - entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; - Assert.Null(entry.RawValue); - Assert.Null(entry.AttemptedValue); - var error = Assert.Single(modelState["parameter.Customer.Name"].Errors); - Assert.Equal("A value for the 'Name' parameter or property was not provided.", error.ErrorMessage); - } - - [Fact] - public async Task MutableObjectModelBinder_WithNestedRequiredProperty_WithData_EmptyPrefix_GetsErrors() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order11) - }; - - // No Data - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?Customer.Id=123"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(model.Customer); - Assert.Equal(123, model.Customer.Id); - Assert.Null(model.Customer.Name); - - Assert.Equal(2, modelState.Count); - Assert.Equal(1, modelState.ErrorCount); - Assert.False(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "Customer.Id").Value; - Assert.Equal("123", entry.RawValue); - Assert.Equal("123", entry.AttemptedValue); - - entry = Assert.Single(modelState, e => e.Key == "Customer.Name").Value; - Assert.Null(entry.RawValue); - Assert.Null(entry.AttemptedValue); - var error = Assert.Single(modelState["Customer.Name"].Errors); - Assert.Equal("A value for the 'Name' parameter or property was not provided.", error.ErrorMessage); - } - - [Fact] - public async Task MutableObjectModelBinder_WithNestedRequiredProperty_WithData_CustomPrefix_GetsErrors() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order11), - BindingInfo = new BindingInfo() - { - BinderModelName = "customParameter" - } - }; - - // No Data - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?customParameter.Customer.Id=123"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(model.Customer); - Assert.Equal(123, model.Customer.Id); - Assert.Null(model.Customer.Name); - - Assert.Equal(2, modelState.Count); - Assert.Equal(1, modelState.ErrorCount); - Assert.False(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "customParameter.Customer.Id").Value; - Assert.Equal("123", entry.RawValue); - Assert.Equal("123", entry.AttemptedValue); - - entry = Assert.Single(modelState, e => e.Key == "customParameter.Customer.Name").Value; - Assert.Null(entry.RawValue); - Assert.Null(entry.AttemptedValue); - var error = Assert.Single(modelState["customParameter.Customer.Name"].Errors); - Assert.Equal("A value for the 'Name' parameter or property was not provided.", error.ErrorMessage); - } - - private class Order12 - { - [BindRequired] - public string ProductName { get; set; } - } - - [Fact] - public async Task MutableObjectModelBinder_WithRequiredProperty_NoData_GetsErrors() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order12) - }; - - // No Data - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Null(model.ProductName); - - Assert.Single(modelState); - Assert.Equal(1, modelState.ErrorCount); - Assert.False(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "ProductName").Value; - Assert.Null(entry.RawValue); - Assert.Null(entry.AttemptedValue); - var error = Assert.Single(modelState["ProductName"].Errors); - Assert.Equal("A value for the 'ProductName' parameter or property was not provided.", error.ErrorMessage); - } - - [Fact] - public async Task MutableObjectModelBinder_WithRequiredProperty_NoData_CustomPrefix_GetsErrors() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order12), - BindingInfo = new BindingInfo() - { - BinderModelName = "customParameter" - } - }; - - // No Data - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Null(model.ProductName); - - Assert.Single(modelState); - Assert.Equal(1, modelState.ErrorCount); - Assert.False(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "customParameter.ProductName").Value; - Assert.Null(entry.RawValue); - Assert.Null(entry.AttemptedValue); - var error = Assert.Single(modelState["customParameter.ProductName"].Errors); - Assert.Equal("A value for the 'ProductName' parameter or property was not provided.", error.ErrorMessage); - } - - [Fact] - public async Task MutableObjectModelBinder_WithRequiredProperty_WithData_EmptyPrefix_GetsBound() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order12), - }; - - // No Data - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?ProductName=abc"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Equal("abc", model.ProductName); - - Assert.Single(modelState); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "ProductName").Value; - Assert.Equal("abc", entry.RawValue); - Assert.Equal("abc", entry.AttemptedValue); - } - - private class Order13 - { - [BindRequired] - public List OrderIds { get; set; } - } - - [Fact] - public async Task MutableObjectModelBinder_WithRequiredCollectionProperty_NoData_GetsErrors() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order13) - }; - - // No Data - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Null(model.OrderIds); - - Assert.Single(modelState); - Assert.Equal(1, modelState.ErrorCount); - Assert.False(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "OrderIds").Value; - Assert.Null(entry.RawValue); - Assert.Null(entry.AttemptedValue); - var error = Assert.Single(modelState["OrderIds"].Errors); - Assert.Equal("A value for the 'OrderIds' parameter or property was not provided.", error.ErrorMessage); - } - - [Fact] - public async Task MutableObjectModelBinder_WithRequiredCollectionProperty_NoData_CustomPrefix_GetsErrors() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order13), - BindingInfo = new BindingInfo() - { - BinderModelName = "customParameter" - } - }; - - // No Data - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Null(model.OrderIds); - - Assert.Single(modelState); - Assert.Equal(1, modelState.ErrorCount); - Assert.False(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "customParameter.OrderIds").Value; - Assert.Null(entry.RawValue); - Assert.Null(entry.AttemptedValue); - var error = Assert.Single(modelState["customParameter.OrderIds"].Errors); - Assert.Equal("A value for the 'OrderIds' parameter or property was not provided.", error.ErrorMessage); - } - - [Fact] - public async Task MutableObjectModelBinder_WithRequiredCollectionProperty_WithData_EmptyPrefix_GetsBound() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order13), - }; - - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?OrderIds[0]=123"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Equal(new[] { 123 }, model.OrderIds.ToArray()); - - Assert.Single(modelState); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "OrderIds[0]").Value; - Assert.Equal("123", entry.RawValue); - Assert.Equal("123", entry.AttemptedValue); - } - - private class Order14 - { - public int ProductId { get; set; } - } - - // This covers the case where a key is present, but has an empty value. The type converter - // will report an error. - [Fact] - public async Task MutableObjectModelBinder_BindsPOCO_TypeConvertedPropertyNonConvertibleValue_GetsError() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order14) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?parameter.ProductId="); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(model); - Assert.Equal(0, model.ProductId); - - Assert.Single(modelState); - Assert.Equal(1, modelState.ErrorCount); - Assert.False(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "parameter.ProductId").Value; - Assert.Equal(string.Empty, entry.AttemptedValue); - Assert.Equal(string.Empty, entry.RawValue); - - var error = Assert.Single(entry.Errors); - Assert.Equal("The value '' is invalid.", error.ErrorMessage); - Assert.Null(error.Exception); - } - - // This covers the case where a key is present, but has no value. The model binder will - // report and error because it's a value type (non-nullable). - [Fact] - [ReplaceCulture] - public async Task MutableObjectModelBinder_BindsPOCO_TypeConvertedPropertyWithEmptyValue_Error() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Order14) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString("?parameter.ProductId"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(model); - Assert.Equal(0, model.ProductId); - - var entry = Assert.Single(modelState); - Assert.Equal("parameter.ProductId", entry.Key); - Assert.Equal(string.Empty, entry.Value.AttemptedValue); - - var error = Assert.Single(entry.Value.Errors); - Assert.Equal("The value '' is invalid.", error.ErrorMessage, StringComparer.Ordinal); - Assert.Null(error.Exception); - - Assert.Equal(1, modelState.ErrorCount); - Assert.False(modelState.IsValid); - } - - private class Person12 - { - public Address12 Address { get; set; } - } - - [ModelBinder(Name = "HomeAddress")] - private class Address12 - { - public string Street { get; set; } - } - - // Make sure the metadata is honored when a [ModelBinder] attribute is associated with a class somewhere in the - // type hierarchy of an action parameter. This should behave identically to such an attribute on a property in - // the type hierarchy. - [Theory] - [MemberData( - nameof(BinderTypeBasedModelBinderIntegrationTest.NullAndEmptyBindingInfo), - MemberType = typeof(BinderTypeBasedModelBinderIntegrationTest))] - public async Task ModelNameOnPropertyType_WithData_Succeeds(BindingInfo bindingInfo) - { - // Arrange - var parameter = new ParameterDescriptor - { - Name = "parameter-name", - BindingInfo = bindingInfo, - ParameterType = typeof(Person12), - }; - - var testContext = ModelBindingTestHelper.GetTestContext( - request => request.QueryString = new QueryString("?HomeAddress.Street=someStreet")); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - var person = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(person.Address); - Assert.Equal("someStreet", person.Address.Street, StringComparer.Ordinal); - - Assert.True(modelState.IsValid); - var kvp = Assert.Single(modelState); - Assert.Equal("HomeAddress.Street", kvp.Key); - var entry = kvp.Value; - Assert.NotNull(entry); - Assert.Empty(entry.Errors); - Assert.Equal(ModelValidationState.Valid, entry.ValidationState); - } - - // Make sure the metadata is honored when a [ModelBinder] attribute is associated with an action parameter's - // type. This should behave identically to such an attribute on an action parameter. - [Theory] - [MemberData( - nameof(BinderTypeBasedModelBinderIntegrationTest.NullAndEmptyBindingInfo), - MemberType = typeof(BinderTypeBasedModelBinderIntegrationTest))] - public async Task ModelNameOnParameterType_WithData_Succeeds(BindingInfo bindingInfo) - { - // Arrange - var parameter = new ParameterDescriptor - { - Name = "parameter-name", - BindingInfo = bindingInfo, - ParameterType = typeof(Address12), - }; - - var testContext = ModelBindingTestHelper.GetTestContext( - request => request.QueryString = new QueryString("?HomeAddress.Street=someStreet")); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - var address = Assert.IsType(modelBindingResult.Model); - Assert.Equal("someStreet", address.Street, StringComparer.Ordinal); - - Assert.True(modelState.IsValid); - var kvp = Assert.Single(modelState); - Assert.Equal("HomeAddress.Street", kvp.Key); - var entry = kvp.Value; - Assert.NotNull(entry); - Assert.Empty(entry.Errors); - Assert.Equal(ModelValidationState.Valid, entry.ValidationState); - } - - private class Person13 - { - public Address13 Address { get; set; } - } - - [Bind("Street")] - private class Address13 - { - public int Number { get; set; } - - public string Street { get; set; } - - public string City { get; set; } - - public string State { get; set; } - } - - // Make sure the metadata is honored when a [Bind] attribute is associated with a class somewhere in the type - // hierarchy of an action parameter. This should behave identically to such an attribute on a property in the - // type hierarchy. (Test is similar to ModelNameOnPropertyType_WithData_Succeeds() but covers implementing - // IPropertyFilterProvider, not IModelNameProvider.) - [Theory] - [MemberData( - nameof(BinderTypeBasedModelBinderIntegrationTest.NullAndEmptyBindingInfo), - MemberType = typeof(BinderTypeBasedModelBinderIntegrationTest))] - public async Task BindAttributeOnPropertyType_WithData_Succeeds(BindingInfo bindingInfo) - { - // Arrange - var parameter = new ParameterDescriptor - { - Name = "parameter-name", - BindingInfo = bindingInfo, - ParameterType = typeof(Person13), - }; - - var testContext = ModelBindingTestHelper.GetTestContext( - request => request.QueryString = new QueryString( - "?Address.Number=23&Address.Street=someStreet&Address.City=Redmond&Address.State=WA")); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - var person = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(person.Address); - Assert.Null(person.Address.City); - Assert.Equal(0, person.Address.Number); - Assert.Null(person.Address.State); - Assert.Equal("someStreet", person.Address.Street, StringComparer.Ordinal); - - Assert.True(modelState.IsValid); - var kvp = Assert.Single(modelState); - Assert.Equal("Address.Street", kvp.Key); - var entry = kvp.Value; - Assert.NotNull(entry); - Assert.Empty(entry.Errors); - Assert.Equal(ModelValidationState.Valid, entry.ValidationState); - } - - // Make sure the metadata is honored when a [Bind] attribute is associated with an action parameter's type. - // This should behave identically to such an attribute on an action parameter. (Test is similar - // to ModelNameOnParameterType_WithData_Succeeds() but covers implementing IPropertyFilterProvider, not - // IModelNameProvider.) - [Theory] - [MemberData( - nameof(BinderTypeBasedModelBinderIntegrationTest.NullAndEmptyBindingInfo), - MemberType = typeof(BinderTypeBasedModelBinderIntegrationTest))] - public async Task BindAttributeOnParameterType_WithData_Succeeds(BindingInfo bindingInfo) - { - // Arrange - var parameter = new ParameterDescriptor - { - Name = "parameter-name", - BindingInfo = bindingInfo, - ParameterType = typeof(Address13), - }; - - var testContext = ModelBindingTestHelper.GetTestContext( - request => request.QueryString = new QueryString("?Number=23&Street=someStreet&City=Redmond&State=WA")); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - var address = Assert.IsType(modelBindingResult.Model); - Assert.Null(address.City); - Assert.Equal(0, address.Number); - Assert.Null(address.State); - Assert.Equal("someStreet", address.Street, StringComparer.Ordinal); - - Assert.True(modelState.IsValid); - var kvp = Assert.Single(modelState); - Assert.Equal("Street", kvp.Key); - var entry = kvp.Value; - Assert.NotNull(entry); - Assert.Empty(entry.Errors); - Assert.Equal(ModelValidationState.Valid, entry.ValidationState); - } - - private class Product - { - public int ProductId { get; set; } - - public string Name { get; } - - public IList Aliases { get; } - } - - [Theory] - [InlineData("?parameter.ProductId=10")] - [InlineData("?parameter.ProductId=10¶meter.Name=Camera")] - [InlineData("?parameter.ProductId=10¶meter.Name=Camera¶meter.Aliases[0]=Camera1")] - public async Task ComplexTypeModelBinder_BindsSettableProperties(string queryString) - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Product) - }; - - // Need to have a key here so that the ComplexTypeModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString(queryString); - SetJsonBodyContent(request, AddressBodyContent); - }); - - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(model); - Assert.Equal(10, model.ProductId); - Assert.Null(model.Name); - Assert.Null(model.Aliases); - } - - private class Photo - { - public string Id { get; set; } - - public KeyValuePair Info { get; set; } - } - - private class LocationInfo - { - [FromHeader] - public string GpsCoordinates { get; set; } - - public int Zipcode { get; set; } - } - - [Fact] - public async Task MutableObjectModelBinder_BindsKeyValuePairProperty_HavingFromHeaderProperty_Success() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Photo) - }; - - // Need to have a key here so that the MutableObjectModelBinder will recurse to bind elements. - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.Headers.Add("GpsCoordinates", "10,20"); - request.QueryString = new QueryString("?Id=1&Info.Key=location1&Info.Value.Zipcode=98052"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - // Model - var model = Assert.IsType(modelBindingResult.Model); - Assert.Equal("1", model.Id); - Assert.Equal("location1", model.Info.Key); - Assert.NotNull(model.Info.Value); - Assert.Equal("10,20", model.Info.Value.GpsCoordinates); - Assert.Equal(98052, model.Info.Value.Zipcode); - - // ModelState - Assert.Equal(4, modelState.Count); - Assert.Equal(0, modelState.ErrorCount); - Assert.True(modelState.IsValid); - - var entry = Assert.Single(modelState, e => e.Key == "Id").Value; - Assert.Equal("1", entry.AttemptedValue); - Assert.Equal("1", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "Info.Key").Value; - Assert.Equal("location1", entry.AttemptedValue); - Assert.Equal("location1", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "Info.Value.Zipcode").Value; - Assert.Equal("98052", entry.AttemptedValue); - Assert.Equal("98052", entry.RawValue); - - entry = Assert.Single(modelState, e => e.Key == "Info.Value.GpsCoordinates").Value; - Assert.Equal("10,20", entry.AttemptedValue); - Assert.Equal("10,20", entry.RawValue); - } - - private class Person5 - { - public string Name { get; set; } - public IFormFile Photo { get; set; } - } - - // Regression test for #4802. - [Fact] - public async Task ComplexTypeModelBinder_ReportsFailureToCollectionModelBinder() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(IList), - }; - - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - SetFormFileBodyContent(request, "Hello world!", "[0].Photo"); - - // CollectionModelBinder binds an empty collection when value providers are all empty. - request.QueryString = new QueryString("?a=b"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType>(modelBindingResult.Model); - var person = Assert.Single(model); - Assert.Null(person.Name); - Assert.NotNull(person.Photo); - using (var reader = new StreamReader(person.Photo.OpenReadStream())) - { - Assert.Equal("Hello world!", await reader.ReadToEndAsync()); - } - - Assert.True(modelState.IsValid); - var state = Assert.Single(modelState); - Assert.Equal("[0].Photo", state.Key); - Assert.Null(state.Value.AttemptedValue); - Assert.Empty(state.Value.Errors); - Assert.Null(state.Value.RawValue); - } - - private class TestModel - { - public TestInnerModel[] InnerModels { get; set; } = Array.Empty(); - } - - private class TestInnerModel - { - [ModelBinder(BinderType = typeof(NumberModelBinder))] - public decimal Rate { get; set; } - } - - private class NumberModelBinder : IModelBinder - { - private readonly NumberStyles _supportedStyles = NumberStyles.Float | NumberStyles.AllowThousands; - private DecimalModelBinder _innerBinder; - - public NumberModelBinder(ILoggerFactory loggerFactory) - { - _innerBinder = new DecimalModelBinder(_supportedStyles, loggerFactory); - } - - public Task BindModelAsync(ModelBindingContext bindingContext) - { - return _innerBinder.BindModelAsync(bindingContext); - } - } - - // Regression test for #4939. - [Fact] - public async Task ComplexTypeModelBinder_ReportsFailureToCollectionModelBinder_CustomBinder() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(TestModel), - }; - - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - request.QueryString = new QueryString( - "?parameter.InnerModels[0].Rate=1,000.00¶meter.InnerModels[1].Rate=2000"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(model.InnerModels); - Assert.Collection( - model.InnerModels, - item => Assert.Equal(1000, item.Rate), - item => Assert.Equal(2000, item.Rate)); - - Assert.True(modelState.IsValid); - Assert.Collection( - modelState, - kvp => - { - Assert.Equal("parameter.InnerModels[0].Rate", kvp.Key); - Assert.Equal("1,000.00", kvp.Value.AttemptedValue); - Assert.Empty(kvp.Value.Errors); - Assert.Equal("1,000.00", kvp.Value.RawValue); - Assert.Equal(ModelValidationState.Valid, kvp.Value.ValidationState); - }, - kvp => - { - Assert.Equal("parameter.InnerModels[1].Rate", kvp.Key); - Assert.Equal("2000", kvp.Value.AttemptedValue); - Assert.Empty(kvp.Value.Errors); - Assert.Equal("2000", kvp.Value.RawValue); - Assert.Equal(ModelValidationState.Valid, kvp.Value.ValidationState); - }); - } - - private class Person6 - { - public string Name { get; set; } - - public Person6 Mother { get; set; } - - public IFormFile Photo { get; set; } - } - - // Regression test for #6616. - [Fact] - public async Task ComplexTypeModelBinder_ReportsFailureToComplexTypeModelBinder_NearTopLevel() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Person6), - }; - - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - SetFormFileBodyContent(request, "Hello world!", "Photo"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.Null(model.Mother); - Assert.Null(model.Name); - Assert.NotNull(model.Photo); - using (var reader = new StreamReader(model.Photo.OpenReadStream())) - { - Assert.Equal("Hello world!", await reader.ReadToEndAsync()); - } - - Assert.True(modelState.IsValid); - var state = Assert.Single(modelState); - Assert.Equal("Photo", state.Key); - Assert.Null(state.Value.AttemptedValue); - Assert.Empty(state.Value.Errors); - Assert.Null(state.Value.RawValue); - } + public class ComplexTypeModelBinderIntegrationTest : ComplexTypeIntegrationTestBase + { +#pragma warning disable CS0618 // Type or member is obsolete + protected override Type ExpectedModelBinderType => typeof(ComplexTypeModelBinder); - // Regression test for #6616. - [Fact] - public async Task ComplexTypeModelBinder_ReportsFailureToComplexTypeModelBinder() + protected override ModelBindingTestContext GetTestContext( + Action updateRequest = null, + Action updateOptions = null, + IModelMetadataProvider metadataProvider = null) { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Person6), - }; - - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - SetFormFileBodyContent(request, "Hello world!", "Photo"); - SetFormFileBodyContent(request, "Hello Mom!", "Mother.Photo"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(model.Mother); - Assert.Null(model.Mother.Mother); - Assert.NotNull(model.Mother.Photo); - using (var reader = new StreamReader(model.Mother.Photo.OpenReadStream())) - { - Assert.Equal("Hello Mom!", await reader.ReadToEndAsync()); - } - - Assert.Null(model.Name); - Assert.NotNull(model.Photo); - using (var reader = new StreamReader(model.Photo.OpenReadStream())) - { - Assert.Equal("Hello world!", await reader.ReadToEndAsync()); - } - - Assert.True(modelState.IsValid); - Assert.Collection( - modelState, - kvp => + return ModelBindingTestHelper.GetTestContext( + updateRequest, + updateOptions: options => { - Assert.Equal("Photo", kvp.Key); - Assert.Null(kvp.Value.AttemptedValue); - Assert.Empty(kvp.Value.Errors); - Assert.Null(kvp.Value.RawValue); - }, - kvp => - { - Assert.Equal("Mother.Photo", kvp.Key); - Assert.Null(kvp.Value.AttemptedValue); - Assert.Empty(kvp.Value.Errors); - Assert.Null(kvp.Value.RawValue); - }); - } - - private class Person7 - { - public string Name { get; set; } - - public IList Children { get; set; } - - public IFormFile Photo { get; set; } - } + options.ModelBinderProviders.RemoveType(); + options.ModelBinderProviders.Add(new ComplexTypeModelBinderProvider()); - // Regression test for #6616. - [Fact] - public async Task ComplexTypeModelBinder_ReportsFailureToComplexTypeModelBinder_ViaCollection() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(Person7), - }; - - var testContext = ModelBindingTestHelper.GetTestContext(request => - { - SetFormFileBodyContent(request, "Hello world!", "Photo"); - SetFormFileBodyContent(request, "Hello Fred!", "Children[0].Photo"); - SetFormFileBodyContent(request, "Hello Ginger!", "Children[1].Photo"); - - request.QueryString = new QueryString("?Children[0].Name=Fred&Children[1].Name=Ginger"); - }); - - var modelState = testContext.ModelState; - var metadata = GetMetadata(testContext, parameter); - var modelBinder = GetModelBinder(testContext, parameter, metadata); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var modelBindingResult = await parameterBinder.BindModelAsync( - testContext, - modelBinder, - valueProvider, - parameter, - metadata, - value: null); - - // Assert - Assert.True(modelBindingResult.IsModelSet); - - var model = Assert.IsType(modelBindingResult.Model); - Assert.NotNull(model.Children); - Assert.Collection( - model.Children, - item => - { - Assert.Null(item.Children); - Assert.Equal("Fred", item.Name); - using (var reader = new StreamReader(item.Photo.OpenReadStream())) - { - Assert.Equal("Hello Fred!", reader.ReadToEnd()); - } + updateOptions?.Invoke(options); }, - item => - { - Assert.Null(item.Children); - Assert.Equal("Ginger", item.Name); - using (var reader = new StreamReader(item.Photo.OpenReadStream())) - { - Assert.Equal("Hello Ginger!", reader.ReadToEnd()); - } - }); - - Assert.Null(model.Name); - Assert.NotNull(model.Photo); - using (var reader = new StreamReader(model.Photo.OpenReadStream())) - { - Assert.Equal("Hello world!", await reader.ReadToEndAsync()); - } - - Assert.True(modelState.IsValid); - } - - private class LoopyModel - { - [ModelBinder(typeof(SuccessfulModelBinder))] - public bool IsBound { get; set; } - - public LoopyModel SelfReference { get; set; } - } - - // Regression test for #7052 - [Fact] - public async Task ModelBindingSystem_ThrowsOn33Binders() - { - // Arrange - var expectedMessage = $"Model binding system exceeded " + - $"{nameof(MvcOptions)}.{nameof(MvcOptions.MaxModelBindingRecursionDepth)} (32). Reduce the " + - $"potential nesting of '{typeof(LoopyModel)}'. For example, this type may have a property with a " + - $"model binder that always succeeds. See the " + - $"{nameof(MvcOptions)}.{nameof(MvcOptions.MaxModelBindingRecursionDepth)} documentation for more " + - $"information."; - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(LoopyModel), - }; - - var testContext = ModelBindingTestHelper.GetTestContext(); - var modelState = testContext.ModelState; - var metadata = testContext.MetadataProvider.GetMetadataForType(parameter.ParameterType); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act & Assert - var exception = await Assert.ThrowsAsync( - () => parameterBinder.BindModelAsync(parameter, testContext)); - Assert.Equal(expectedMessage, exception.Message); - } - - private class TwoDeepModel - { - [ModelBinder(typeof(SuccessfulModelBinder))] - public bool IsBound { get; set; } - } - - private class ThreeDeepModel - { - [ModelBinder(typeof(SuccessfulModelBinder))] - public bool IsBound { get; set; } - - public TwoDeepModel Inner { get; set; } - } - - // Ensure model binding system allows MaxModelBindingRecursionDepth binders on the stack. - [Fact] - public async Task ModelBindingSystem_BindsWith3Binders() - { - // Arrange - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(ThreeDeepModel), - }; - - var testContext = ModelBindingTestHelper.GetTestContext( - updateOptions: options => options.MaxModelBindingRecursionDepth = 3); - - var modelState = testContext.ModelState; - var metadata = testContext.MetadataProvider.GetMetadataForType(parameter.ParameterType); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act - var result = await parameterBinder.BindModelAsync(parameter, testContext); - - // Assert - Assert.True(modelState.IsValid); - Assert.Equal(0, modelState.ErrorCount); - - Assert.True(result.IsModelSet); - var model = Assert.IsType(result.Model); - Assert.True(model.IsBound); - Assert.NotNull(model.Inner); - Assert.True(model.Inner.IsBound); - } - - private class FourDeepModel - { - [ModelBinder(typeof(SuccessfulModelBinder))] - public bool IsBound { get; set; } - - public ThreeDeepModel Inner { get; set; } - } - - // Ensure model binding system disallows one more than MaxModelBindingRecursionDepth binders on the stack. - [Fact] - public async Task ModelBindingSystem_ThrowsOn4Binders() - { - // Arrange - var expectedMessage = $"Model binding system exceeded " + - $"{nameof(MvcOptions)}.{nameof(MvcOptions.MaxModelBindingRecursionDepth)} (3). Reduce the " + - $"potential nesting of '{typeof(FourDeepModel)}'. For example, this type may have a property with a " + - $"model binder that always succeeds. See the " + - $"{nameof(MvcOptions)}.{nameof(MvcOptions.MaxModelBindingRecursionDepth)} documentation for more " + - $"information."; - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(FourDeepModel), - }; - - var testContext = ModelBindingTestHelper.GetTestContext( - updateOptions: options => options.MaxModelBindingRecursionDepth = 3); - - var modelState = testContext.ModelState; - var metadata = testContext.MetadataProvider.GetMetadataForType(parameter.ParameterType); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act & Assert - var exception = await Assert.ThrowsAsync( - () => parameterBinder.BindModelAsync(parameter, testContext)); - Assert.Equal(expectedMessage, exception.Message); - } - - private class LoopyModel1 - { - [ModelBinder(typeof(SuccessfulModelBinder))] - public bool IsBound { get; set; } - - public LoopyModel2 Inner { get; set; } - } - - private class LoopyModel2 - { - [ModelBinder(typeof(SuccessfulModelBinder))] - public bool IsBound { get; set; } - - public LoopyModel3 Inner { get; set; } - } - - private class LoopyModel3 - { - [ModelBinder(typeof(SuccessfulModelBinder))] - public bool IsBound { get; set; } - - public LoopyModel1 Inner { get; set; } - } - - [Fact] - public async Task ModelBindingSystem_ThrowsOn33Binders_WithIndirectModelTypeLoop() - { - // Arrange - var expectedMessage = $"Model binding system exceeded " + - $"{nameof(MvcOptions)}.{nameof(MvcOptions.MaxModelBindingRecursionDepth)} (32). Reduce the " + - $"potential nesting of '{typeof(LoopyModel1)}'. For example, this type may have a property with a " + - $"model binder that always succeeds. See the " + - $"{nameof(MvcOptions)}.{nameof(MvcOptions.MaxModelBindingRecursionDepth)} documentation for more " + - $"information."; - var parameter = new ParameterDescriptor() - { - Name = "parameter", - ParameterType = typeof(LoopyModel1), - }; - - var testContext = ModelBindingTestHelper.GetTestContext(); - var modelState = testContext.ModelState; - var metadata = testContext.MetadataProvider.GetMetadataForType(parameter.ParameterType); - var valueProvider = await CompositeValueProvider.CreateAsync(testContext); - var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext); - - // Act & Assert - var exception = await Assert.ThrowsAsync( - () => parameterBinder.BindModelAsync(parameter, testContext)); - Assert.Equal(expectedMessage, exception.Message); - } - - private static void SetJsonBodyContent(HttpRequest request, string content) - { - var stream = new MemoryStream(new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(content)); - request.Body = stream; - request.ContentType = "application/json"; - } - - private static void SetFormFileBodyContent(HttpRequest request, string content, string name) - { - const string fileName = "text.txt"; - - FormFileCollection fileCollection; - if (request.HasFormContentType) - { - // Do less work and do not overwrite previous information if called a second time. - fileCollection = (FormFileCollection)request.Form.Files; - } - else - { - fileCollection = new FormFileCollection(); - var formCollection = new FormCollection(new Dictionary(), fileCollection); - - request.ContentType = "multipart/form-data; boundary=----WebKitFormBoundarymx2fSWqWSd0OxQqq"; - request.Form = formCollection; - } - - var memoryStream = new MemoryStream(Encoding.UTF8.GetBytes(content)); - var file = new FormFile(memoryStream, 0, memoryStream.Length, name, fileName) - { - Headers = new HeaderDictionary(), - - // Do not move this up. Headers must be non-null before the ContentDisposition property is accessed. - ContentDisposition = $"form-data; name={name}; filename={fileName}", - }; - - fileCollection.Add(file); - } - - private ModelMetadata GetMetadata(ModelBindingTestContext context, ParameterDescriptor parameter) - { - return context.MetadataProvider.GetMetadataForType(parameter.ParameterType); - } - - private IModelBinder GetModelBinder( - ModelBindingTestContext context, - ParameterDescriptor parameter, - ModelMetadata metadata) - { - var factory = ModelBindingTestHelper.GetModelBinderFactory( - context.MetadataProvider, - context.HttpContext.RequestServices); - var factoryContext = new ModelBinderFactoryContext - { - BindingInfo = parameter.BindingInfo, - CacheToken = parameter, - Metadata = metadata, - }; - - return factory.CreateBinder(factoryContext); + metadataProvider: metadataProvider); } +#pragma warning restore CS0618 // Type or member is obsolete } } diff --git a/src/Mvc/test/Mvc.IntegrationTests/Microsoft.AspNetCore.Mvc.IntegrationTests.csproj b/src/Mvc/test/Mvc.IntegrationTests/Microsoft.AspNetCore.Mvc.IntegrationTests.csproj index 219c48691150..e08e64bec423 100644 --- a/src/Mvc/test/Mvc.IntegrationTests/Microsoft.AspNetCore.Mvc.IntegrationTests.csproj +++ b/src/Mvc/test/Mvc.IntegrationTests/Microsoft.AspNetCore.Mvc.IntegrationTests.csproj @@ -3,6 +3,7 @@ $(DefaultNetCoreTargetFramework) false + 9.0 diff --git a/src/Mvc/test/Mvc.IntegrationTests/TryUpdateModelIntegrationTest.cs b/src/Mvc/test/Mvc.IntegrationTests/TryUpdateModelIntegrationTest.cs index eb0a6d7cd959..1487aa7cffbe 100644 --- a/src/Mvc/test/Mvc.IntegrationTests/TryUpdateModelIntegrationTest.cs +++ b/src/Mvc/test/Mvc.IntegrationTests/TryUpdateModelIntegrationTest.cs @@ -1139,6 +1139,179 @@ public async Task TryUpdateModel_ExistingModelWithNoParameterlessConstructor_Ove Assert.Equal(ModelValidationState.Valid, state.ValidationState); } + private record AddressRecord(string Street, string City) + { + public string ZipCode { get; set; } + } + + [Fact] + public async Task TryUpdateModel_RecordTypeModel_DoesNotOverwriteConstructorParameters() + { + // Arrange + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = QueryString.Create("Street", "SomeStreet"); + }); + + var modelState = testContext.ModelState; + var model = new AddressRecord("DefaultStreet", "Toronto") + { + ZipCode = "98001", + }; + var oldModel = model; + + // Act + var result = await TryUpdateModelAsync(model, string.Empty, testContext); + + // Assert + Assert.True(result); + + // Model + Assert.Same(oldModel, model); + Assert.Equal("DefaultStreet", model.Street); + Assert.Equal("Toronto", model.City); + Assert.Equal("98001", model.ZipCode); + + // ModelState + Assert.True(modelState.IsValid); + Assert.Empty(modelState); + } + + [Fact] + public async Task TryUpdateModel_RecordTypeModel_UpdatesProperties() + { + // Arrange + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = QueryString.Create("ZipCode", "98007").Add("Street", "SomeStreet"); + }); + + var modelState = testContext.ModelState; + var model = new AddressRecord("DefaultStreet", "Toronto") + { + ZipCode = "98001", + }; + var oldModel = model; + + // Act + var result = await TryUpdateModelAsync(model, string.Empty, testContext); + + // Assert + Assert.True(result); + + // Model + Assert.Same(oldModel, model); + Assert.Equal("DefaultStreet", model.Street); + Assert.Equal("Toronto", model.City); + Assert.Equal("98007", model.ZipCode); + + // ModelState + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState); + Assert.Equal("ZipCode", entry.Key); + var state = entry.Value; + Assert.Equal("98007", state.AttemptedValue); + Assert.Equal("98007", state.RawValue); + Assert.Empty(state.Errors); + Assert.Equal(ModelValidationState.Valid, state.ValidationState); + } + + private class ModelWithRecordTypeProperty + { + public AddressRecord Address { get; set; } + } + + [Fact] + public async Task TryUpdateModel_RecordTypeProperty() + { + // Arrange + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = QueryString.Create("Address.ZipCode", "98007").Add("Address.Street", "SomeStreet"); + }); + + var modelState = testContext.ModelState; + var model = new ModelWithRecordTypeProperty(); + var oldModel = model; + + // Act + var result = await TryUpdateModelAsync(model, string.Empty, testContext); + + // Assert + Assert.True(result); + + // Model + Assert.Same(oldModel, model); + Assert.NotNull(model.Address); + var address = model.Address; + Assert.Equal("SomeStreet", address.Street); + Assert.Null(address.City); + Assert.Equal("98007", address.ZipCode); + + // ModelState + Assert.True(modelState.IsValid); + + Assert.Equal(2, modelState.Count); + var entry = Assert.Single(modelState, k => k.Key == "Address.ZipCode"); + var state = entry.Value; + Assert.Equal("98007", state.AttemptedValue); + Assert.Equal("98007", state.RawValue); + Assert.Empty(state.Errors); + Assert.Equal(ModelValidationState.Valid, state.ValidationState); + + entry = Assert.Single(modelState, k => k.Key == "Address.Street"); + state = entry.Value; + Assert.Equal("SomeStreet", state.AttemptedValue); + Assert.Equal("SomeStreet", state.RawValue); + Assert.Empty(state.Errors); + Assert.Equal(ModelValidationState.Valid, state.ValidationState); + } + + [Fact] + public async Task TryUpdateModel_RecordTypeProperty_InitializedDoesNotOverwriteConstructorParameters() + { + // Arrange + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = QueryString.Create("Address.ZipCode", "98007").Add("Address.Street", "SomeStreet"); + }); + + var modelState = testContext.ModelState; + var model = new ModelWithRecordTypeProperty + { + Address = new AddressRecord("DefaultStreet", "DefaultCity") + { + ZipCode = "98056", + }, + }; + var oldModel = model; + + // Act + var result = await TryUpdateModelAsync(model, string.Empty, testContext); + + // Assert + Assert.True(result); + + // Model + Assert.Same(oldModel, model); + Assert.NotNull(model.Address); + var address = model.Address; + Assert.Equal("DefaultStreet", address.Street); + Assert.Equal("DefaultCity", address.City); + Assert.Equal("98007", address.ZipCode); + + // ModelState + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState); + var state = entry.Value; + Assert.Equal("98007", state.AttemptedValue); + Assert.Equal("98007", state.RawValue); + Assert.Empty(state.Errors); + Assert.Equal(ModelValidationState.Valid, state.ValidationState); + } + private void UpdateRequest(HttpRequest request, string data, string name) { const string fileName = "text.txt"; @@ -1237,4 +1410,4 @@ private async Task TryUpdateModelAsync( ModelBindingTestHelper.GetObjectValidator(testContext.MetadataProvider)); } } -} \ No newline at end of file +} diff --git a/src/Mvc/test/Mvc.IntegrationTests/ValidationWithRecordIntegrationTests.cs b/src/Mvc/test/Mvc.IntegrationTests/ValidationWithRecordIntegrationTests.cs new file mode 100644 index 000000000000..92865e46bffa --- /dev/null +++ b/src/Mvc/test/Mvc.IntegrationTests/ValidationWithRecordIntegrationTests.cs @@ -0,0 +1,2307 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel.DataAnnotations; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ModelBinding.Metadata; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.IntegrationTests +{ + public class ValidationWithRecordIntegrationTests + { + private record TransferInfo([Range(25, 50)] int AccountId, double Amount); + + private class TestController { } + + public static TheoryData> MultipleActionParametersAndValidationData + { + get + { + return new TheoryData> + { + // Irrespective of the order in which the parameters are defined on the action, + // the validation on the TransferInfo's AccountId should occur. + // Here 'accountId' parameter is bound by the prefix 'accountId' while the 'transferInfo' + // property is bound using the empty prefix and the 'TransferInfo' property names. + new List() + { + new ParameterDescriptor() + { + Name = "accountId", + ParameterType = typeof(int) + }, + new ParameterDescriptor() + { + Name = "transferInfo", + ParameterType = typeof(TransferInfo), + BindingInfo = new BindingInfo() + { + BindingSource = BindingSource.Body + } + } + }, + new List() + { + new ParameterDescriptor() + { + Name = "transferInfo", + ParameterType = typeof(TransferInfo), + BindingInfo = new BindingInfo() + { + BindingSource = BindingSource.Body + } + }, + new ParameterDescriptor() + { + Name = "accountId", + ParameterType = typeof(int) + } + } + }; + } + } + + [Theory] + [MemberData(nameof(MultipleActionParametersAndValidationData))] + public async Task ValidationIsTriggered_OnFromBodyModels(List parameters) + { + // Arrange + var actionDescriptor = new ControllerActionDescriptor() + { + BoundProperties = new List(), + Parameters = parameters + }; + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + + var testContext = ModelBindingTestHelper.GetTestContext( + request => + { + request.QueryString = new QueryString("?accountId=30"); + request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{\"accountId\": 15,\"amount\": 250.0}")); + request.ContentType = "application/json"; + }, + actionDescriptor: actionDescriptor); + + var modelState = testContext.ModelState; + + // Act + foreach (var parameter in parameters) + { + await parameterBinder.BindModelAsync(parameter, testContext); + } + + // Assert + Assert.False(modelState.IsValid); + + var entry = Assert.Single( + modelState, + e => string.Equals(e.Key, "AccountId", StringComparison.OrdinalIgnoreCase)).Value; + var error = Assert.Single(entry.Errors); + Assert.Equal(ValidationAttributeUtil.GetRangeErrorMessage(25, 50, "AccountId"), error.ErrorMessage); + } + + [Theory] + [MemberData(nameof(MultipleActionParametersAndValidationData))] + public async Task MultipleActionParameter_ValidModelState(List parameters) + { + // Since validation attribute is only present on the FromBody model's property(TransferInfo's AccountId), + // validation should not trigger for the parameter which is bound from Uri. + + // Arrange + var actionDescriptor = new ControllerActionDescriptor() + { + BoundProperties = new List(), + Parameters = parameters + }; + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + + var testContext = ModelBindingTestHelper.GetTestContext( + request => + { + request.QueryString = new QueryString("?accountId=10"); + request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{\"accountId\": 40,\"amount\": 250.0}")); + request.ContentType = "application/json"; + }, + actionDescriptor: actionDescriptor); + + var modelState = testContext.ModelState; + + // Act + foreach (var parameter in parameters) + { + await parameterBinder.BindModelAsync(parameter, testContext); + } + + // Assert + Assert.True(modelState.IsValid); + } + + private record Order1([Required] string CustomerName); + + [Fact] + public async Task Validation_RequiredAttribute_OnSimpleTypeProperty_WithData() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order1) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.CustomerName=bill"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.CustomerName); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.CustomerName").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + Assert.Empty(entry.Errors); + } + + [Fact] + public async Task Validation_RequiredAttribute_OnSimpleTypeProperty_NoData() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order1) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.CustomerName); + + Assert.Single(modelState); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "CustomerName").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + Assert.Equal(ModelValidationState.Invalid, entry.ValidationState); + + var error = Assert.Single(entry.Errors); + AssertRequiredError("CustomerName", error); + } + + private record Order2([Required] Person2 Customer); + + + private record Person2(string Name); + + [Fact] + public async Task Validation_RequiredAttribute_OnPOCOProperty_WithData() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order2) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Customer.Name=bill"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("bill", model.Customer.Name); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + Assert.Empty(entry.Errors); + } + + [Fact] + public async Task Validation_RequiredAttribute_OnPOCOProperty_NoData() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order2) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.Customer); + + Assert.Single(modelState); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Customer").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + Assert.Equal(ModelValidationState.Invalid, entry.ValidationState); + + var error = Assert.Single(entry.Errors); + AssertRequiredError("Customer", error); + } + + private record Order3(Person3 Customer); + + private record Person3(int Age, [Required] string Name); + + [Fact] + public async Task Validation_RequiredAttribute_OnNestedSimpleTypeProperty_WithData() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order3) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Customer.Name=bill"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal("bill", model.Customer.Name); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + Assert.Empty(entry.Errors); + } + + [Fact] + public async Task Validation_RequiredAttribute_OnNestedSimpleTypeProperty_NoDataForRequiredProperty() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order3) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + // Force creation of the Customer model. + request.QueryString = new QueryString("?parameter.Customer.Age=17"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Customer); + Assert.Equal(17, model.Customer.Age); + Assert.Null(model.Customer.Name); + + Assert.Equal(2, modelState.Count); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + Assert.Equal(ModelValidationState.Invalid, entry.ValidationState); + + var error = Assert.Single(entry.Errors); + AssertRequiredError("Name", error); + } + + private record Order4([Required] List Items); + + private record Item4(int ItemId); + + [Fact] + public async Task Validation_RequiredAttribute_OnCollectionProperty_WithData() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order4) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Items[0].ItemId=17"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.NotNull(model.Items); + Assert.Equal(17, Assert.Single(model.Items).ItemId); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Items[0].ItemId").Value; + Assert.Equal("17", entry.AttemptedValue); + Assert.Equal("17", entry.RawValue); + Assert.Empty(entry.Errors); + } + + [Fact] + public async Task Validation_RequiredAttribute_OnCollectionProperty_NoData() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order4) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + // Force creation of the Customer model. + request.QueryString = new QueryString("?"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.Items); + + Assert.Single(modelState); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "Items").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + Assert.Equal(ModelValidationState.Invalid, entry.ValidationState); + + var error = Assert.Single(entry.Errors); + AssertRequiredError("Items", error); + } + + private record Order5([Required] int? ProductId, string Name); + + [Fact] + public async Task Validation_RequiredAttribute_OnPOCOPropertyOfBoundElement_WithData() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(List) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter[0].ProductId=17"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + Assert.Equal(17, Assert.Single(model).ProductId); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter[0].ProductId").Value; + Assert.Equal("17", entry.AttemptedValue); + Assert.Equal("17", entry.RawValue); + Assert.Empty(entry.Errors); + } + + [Fact] + public async Task Validation_RequiredAttribute_OnPOCOPropertyOfBoundElement_NoDataForRequiredProperty() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(List) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + // Force creation of the Customer model. + request.QueryString = new QueryString("?parameter[0].Name=bill"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + var item = Assert.Single(model); + Assert.Null(item.ProductId); + Assert.Equal("bill", item.Name); + + Assert.Equal(2, modelState.Count); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter[0].ProductId").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + Assert.Equal(ModelValidationState.Invalid, entry.ValidationState); + + var error = Assert.Single(entry.Errors); + AssertRequiredError("ProductId", error); + } + + private record Order6([StringLength(5, ErrorMessage = "Too Long.")] string Name); + + [Fact] + public async Task Validation_StringLengthAttribute_OnPropertyOfPOCO_Valid() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order6) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Name=bill"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Name); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + Assert.Empty(entry.Errors); + } + + [Fact] + public async Task Validation_StringLengthAttribute_OnPropertyOfPOCO_Invalid() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order6) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Name=billybob"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("billybob", model.Name); + + Assert.Single(modelState); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Name").Value; + Assert.Equal("billybob", entry.AttemptedValue); + Assert.Equal("billybob", entry.RawValue); + + var error = Assert.Single(entry.Errors); + Assert.Equal("Too Long.", error.ErrorMessage); + Assert.Null(error.Exception); + } + + private record Order7(Person7 Customer); + + private record Person7([StringLength(5, ErrorMessage = "Too Long.")] string Name); + + [Fact] + public async Task Validation_StringLengthAttribute_OnPropertyOfNestedPOCO_Valid() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order7) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Customer.Name=bill"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Customer.Name); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + Assert.Empty(entry.Errors); + } + + [Fact] + public async Task Validation_StringLengthAttribute_OnPropertyOfNestedPOCO_Invalid() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order7) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Customer.Name=billybob"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("billybob", model.Customer.Name); + + Assert.Single(modelState); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; + Assert.Equal("billybob", entry.AttemptedValue); + Assert.Equal("billybob", entry.RawValue); + + var error = Assert.Single(entry.Errors); + Assert.Equal("Too Long.", error.ErrorMessage); + Assert.Null(error.Exception); + } + + [Fact] + public async Task Validation_StringLengthAttribute_OnPropertyOfNestedPOCO_NoData() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order7) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Null(model.Customer); + + Assert.Empty(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + private record Order8([ValidatePerson8] Person8 Customer); + + private record Person8(string Name); + + private class ValidatePerson8Attribute : ValidationAttribute + { + protected override ValidationResult IsValid(object value, ValidationContext validationContext) + { + if (((Person8)value).Name == "bill") + { + return null; + } + else + { + return new ValidationResult("Invalid Person."); + } + } + } + + [Fact] + public async Task Validation_CustomAttribute_OnPOCOProperty_Valid() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order8) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Customer.Name=bill"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", model.Customer.Name); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + Assert.Empty(entry.Errors); + } + + [Fact] + public async Task Validation_CustomAttribute_OnPOCOProperty_Invalid() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order8) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Customer.Name=billybob"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("billybob", model.Customer.Name); + + Assert.Equal(2, modelState.Count); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Customer.Name").Value; + Assert.Equal("billybob", entry.AttemptedValue); + Assert.Equal("billybob", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.Customer").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + Assert.Equal(ModelValidationState.Invalid, entry.ValidationState); + var error = Assert.Single(entry.Errors); + Assert.Equal("Invalid Person.", error.ErrorMessage); + Assert.Null(error.Exception); + } + + private record Order9([ValidateProducts9] List Products); + + private record Product9(string Name); + + private class ValidateProducts9Attribute : ValidationAttribute + { + protected override ValidationResult IsValid(object value, ValidationContext validationContext) + { + if (((List)value)[0].Name == "bill") + { + return null; + } + else + { + return new ValidationResult("Invalid Product."); + } + } + } + + [Fact] + public async Task Validation_CustomAttribute_OnCollectionElement_Valid() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order9) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Products[0].Name=bill"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("bill", Assert.Single(model.Products).Name); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Products[0].Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + Assert.Empty(entry.Errors); + } + + [Fact] + public async Task Validation_CustomAttribute_OnCollectionElement_Invalid() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order9) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter.Products[0].Name=billybob"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal("billybob", Assert.Single(model.Products).Name); + + Assert.Equal(2, modelState.Count); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter.Products[0].Name").Value; + Assert.Equal("billybob", entry.AttemptedValue); + Assert.Equal("billybob", entry.RawValue); + + entry = Assert.Single(modelState, e => e.Key == "parameter.Products").Value; + Assert.Null(entry.RawValue); + Assert.Null(entry.AttemptedValue); + Assert.Equal(ModelValidationState.Invalid, entry.ValidationState); + + var error = Assert.Single(entry.Errors); + Assert.Equal("Invalid Product.", error.ErrorMessage); + Assert.Null(error.Exception); + } + + private record Order10([StringLength(5, ErrorMessage = "Too Long.")] string Name); + + [Fact] + public async Task Validation_StringLengthAttribute_OnPropertyOfCollectionElement_Valid() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(List) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter[0].Name=bill"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + Assert.Equal("bill", Assert.Single(model).Name); + + Assert.Single(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter[0].Name").Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + Assert.Empty(entry.Errors); + } + + [Fact] + public async Task Validation_StringLengthAttribute_OnPropertyOfCollectionElement_Invalid() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(List) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter[0].Name=billybob"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + Assert.Equal("billybob", Assert.Single(model).Name); + + Assert.Single(modelState); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "parameter[0].Name").Value; + Assert.Equal("billybob", entry.AttemptedValue); + Assert.Equal("billybob", entry.RawValue); + + var error = Assert.Single(entry.Errors); + Assert.Equal("Too Long.", error.ErrorMessage); + Assert.Null(error.Exception); + } + + [Fact] + public async Task Validation_StringLengthAttribute_OnPropertyOfCollectionElement_NoData() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(List) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + Assert.Empty(model); + + Assert.Empty(modelState); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + } + + private record User(int Id, uint Zip); + + [Fact] + public async Task Validation_FormatException_ShowsInvalidValueMessage_OnSimpleTypeProperty() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(User) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Id=bill"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal(0, model.Id); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var state = Assert.Single(modelState); + Assert.Equal("Id", state.Key); + var entry = state.Value; + Assert.Equal("bill", entry.AttemptedValue); + Assert.Equal("bill", entry.RawValue); + Assert.Single(entry.Errors); + + var error = entry.Errors[0]; + Assert.Equal("The value 'bill' is not valid.", error.ErrorMessage); + } + + [Fact] + public async Task Validation_OverflowException_ShowsInvalidValueMessage_OnSimpleTypeProperty() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(User) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Zip=-123"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal(0, model.Zip); + Assert.Equal(1, modelState.ErrorCount); + Assert.False(modelState.IsValid); + + var state = Assert.Single(modelState); + Assert.Equal("Zip", state.Key); + var entry = state.Value; + Assert.Equal("-123", entry.AttemptedValue); + Assert.Equal("-123", entry.RawValue); + Assert.Single(entry.Errors); + + var error = entry.Errors[0]; + Assert.Equal("The value '-123' is not valid.", error.ErrorMessage); + } + + private record NeverValid(string NeverValidProperty) : IValidatableObject + { + public IEnumerable Validate(ValidationContext validationContext) + { + var result = new ValidationResult( + $"'{validationContext.MemberName}' (display: '{validationContext.DisplayName}') is not valid due " + + $"to its {nameof(NeverValid)} type."); + return new[] { result }; + } + } + + private class NeverValidAttribute : ValidationAttribute + { + protected override ValidationResult IsValid(object value, ValidationContext validationContext) + { + // By default, ValidationVisitor visits _all_ properties within a non-null complex object. + // But, like most reasonable ValidationAttributes, NeverValidAttribute ignores null property values. + if (value == null) + { + return ValidationResult.Success; + } + + return new ValidationResult( + $"'{validationContext.MemberName}' (display: '{validationContext.DisplayName}') is not valid due " + + $"to its associated {nameof(NeverValidAttribute)}."); + } + } + + private record ValidateSomeProperties( + [Display(Name = "Not ever valid")] NeverValid NeverValidBecauseType, + + [NeverValid] + [Display(Name = "Never valid")] + string NeverValidBecauseAttribute, + + [ValidateNever] + [NeverValid] + string ValidateNever) + { + + [ValidateNever] + public int ValidateNeverLength => ValidateNever.Length; + } + + [Fact] + public async Task IValidatableObject_IsValidated() + { + // Arrange + var parameter = new ParameterDescriptor + { + Name = "parameter", + ParameterType = typeof(ValidateSomeProperties), + }; + + var testContext = ModelBindingTestHelper.GetTestContext( + request => request.QueryString + = new QueryString($"?{nameof(ValidateSomeProperties.NeverValidBecauseType)}.{nameof(NeverValid.NeverValidProperty)}=1")); + + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var modelState = testContext.ModelState; + + // Act + var result = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(result.IsModelSet); + var model = Assert.IsType(result.Model); + Assert.Equal("1", model.NeverValidBecauseType.NeverValidProperty); + + Assert.False(modelState.IsValid); + Assert.Equal(1, modelState.ErrorCount); + Assert.Collection( + modelState, + state => + { + Assert.Equal(nameof(ValidateSomeProperties.NeverValidBecauseType), state.Key); + Assert.Equal(ModelValidationState.Invalid, state.Value.ValidationState); + + var error = Assert.Single(state.Value.Errors); + Assert.Equal( + "'NeverValidBecauseType' (display: 'Not ever valid') is not valid due to its NeverValid type.", + error.ErrorMessage); + Assert.Null(error.Exception); + }, + state => + { + Assert.Equal( + $"{nameof(ValidateSomeProperties.NeverValidBecauseType)}.{nameof(NeverValid.NeverValidProperty)}", + state.Key); + Assert.Equal(ModelValidationState.Valid, state.Value.ValidationState); + }); + } + + [Fact] + public async Task CustomValidationAttribute_IsValidated() + { + // Arrange + var parameter = new ParameterDescriptor + { + Name = "parameter", + ParameterType = typeof(ValidateSomeProperties), + }; + + var testContext = ModelBindingTestHelper.GetTestContext( + request => request.QueryString + = new QueryString($"?{nameof(ValidateSomeProperties.NeverValidBecauseAttribute)}=1")); + + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var modelState = testContext.ModelState; + + // Act + var result = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(result.IsModelSet); + var model = Assert.IsType(result.Model); + Assert.Equal("1", model.NeverValidBecauseAttribute); + + Assert.False(modelState.IsValid); + Assert.Equal(1, modelState.ErrorCount); + var kvp = Assert.Single(modelState); + Assert.Equal(nameof(ValidateSomeProperties.NeverValidBecauseAttribute), kvp.Key); + var state = kvp.Value; + Assert.NotNull(state); + Assert.Equal(ModelValidationState.Invalid, state.ValidationState); + var error = Assert.Single(state.Errors); + Assert.Equal( + "'NeverValidBecauseAttribute' (display: 'Never valid') is not valid due to its associated NeverValidAttribute.", + error.ErrorMessage); + Assert.Null(error.Exception); + } + + [Fact] + public async Task ValidateNeverProperty_IsSkipped() + { + // Arrange + var parameter = new ParameterDescriptor + { + Name = "parameter", + ParameterType = typeof(ValidateSomeProperties), + }; + + var testContext = ModelBindingTestHelper.GetTestContext( + request => request.QueryString + = new QueryString($"?{nameof(ValidateSomeProperties.ValidateNever)}=1")); + + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var modelState = testContext.ModelState; + + // Act + var result = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(result.IsModelSet); + var model = Assert.IsType(result.Model); + Assert.Equal("1", model.ValidateNever); + + Assert.True(modelState.IsValid); + var kvp = Assert.Single(modelState); + Assert.Equal(nameof(ValidateSomeProperties.ValidateNever), kvp.Key); + var state = kvp.Value; + Assert.NotNull(state); + Assert.Equal(ModelValidationState.Skipped, state.ValidationState); + } + + [Fact] + public async Task ValidateNeverProperty_IsSkippedWithoutAccessingModel() + { + // Arrange + var parameter = new ParameterDescriptor + { + Name = "parameter", + ParameterType = typeof(ValidateSomeProperties), + }; + + var testContext = ModelBindingTestHelper.GetTestContext(); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var modelState = testContext.ModelState; + + // Act + var result = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(result.IsModelSet); + var model = Assert.IsType(result.Model); + + // Note this Exception is not thrown earlier. + Assert.Throws(() => model.ValidateNeverLength); + + Assert.True(modelState.IsValid); + Assert.Empty(modelState); + } + + private class ValidateSometimesAttribute : Attribute, IPropertyValidationFilter + { + private readonly string _otherProperty; + + public ValidateSometimesAttribute(string otherProperty) + { + // Would null-check otherProperty in real life. + _otherProperty = otherProperty; + } + + public bool ShouldValidateEntry(ValidationEntry entry, ValidationEntry parentEntry) + { + if (entry.Metadata.MetadataKind == ModelMetadataKind.Property && + parentEntry.Metadata != null) + { + // In real life, would throw an InvalidOperationException if otherProperty were null i.e. the + // property was not known. Could also assert container is non-null (see ValidationVisitor). + var container = parentEntry.Model; + var otherProperty = parentEntry.Metadata.Properties[_otherProperty]; + if (otherProperty.PropertyGetter(container) == null) + { + return false; + } + } + + return true; + } + } + + private record ValidateSomePropertiesSometimes(string Control) + { + [ValidateSometimes(nameof(Control))] + [Range(0, 10)] + public int ControlLength => Control.Length; + } + + [Fact] + public async Task PropertyToSometimesSkip_IsSkipped_IfControlIsNull() + { + // Arrange + var parameter = new ParameterDescriptor + { + Name = "parameter", + ParameterType = typeof(ValidateSomePropertiesSometimes), + }; + + var testContext = ModelBindingTestHelper.GetTestContext(); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var modelState = testContext.ModelState; + + // Add an entry for the ControlLength property so that we can observe Skipped versus Valid states. + modelState.SetModelValue( + nameof(ValidateSomePropertiesSometimes.ControlLength), + rawValue: null, + attemptedValue: null); + + // Act + var result = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(result.IsModelSet); + var model = Assert.IsType(result.Model); + Assert.Null(model.Control); + + // Note this Exception is not thrown earlier. + Assert.Throws(() => model.ControlLength); + + Assert.True(modelState.IsValid); + var kvp = Assert.Single(modelState); + Assert.Equal(nameof(ValidateSomePropertiesSometimes.ControlLength), kvp.Key); + Assert.Equal(ModelValidationState.Skipped, kvp.Value.ValidationState); + } + + [Fact] + public async Task PropertyToSometimesSkip_IsValidated_IfControlIsNotNull() + { + // Arrange + var parameter = new ParameterDescriptor + { + Name = "parameter", + ParameterType = typeof(ValidateSomePropertiesSometimes), + }; + + var testContext = ModelBindingTestHelper.GetTestContext( + request => request.QueryString = new QueryString( + $"?{nameof(ValidateSomePropertiesSometimes.Control)}=1")); + + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var modelState = testContext.ModelState; + + // Add an entry for the ControlLength property so that we can observe Skipped versus Valid states. + modelState.SetModelValue( + nameof(ValidateSomePropertiesSometimes.ControlLength), + rawValue: null, + attemptedValue: null); + + // Act + var result = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(result.IsModelSet); + var model = Assert.IsType(result.Model); + Assert.Equal("1", model.Control); + Assert.Equal(1, model.ControlLength); + + Assert.True(modelState.IsValid); + Assert.Collection( + modelState, + state => Assert.Equal(nameof(ValidateSomePropertiesSometimes.Control), state.Key), + state => + { + Assert.Equal(nameof(ValidateSomePropertiesSometimes.ControlLength), state.Key); + Assert.Equal(ModelValidationState.Valid, state.Value.ValidationState); + }); + } + + // This type has a IPropertyValidationFilter declared on a property, but no validators. + // We should expect validation to short-circuit + private record ValidateSomePropertiesSometimesWithoutValidation(string Control) + { + [ValidateSometimes(nameof(Control))] + public int ControlLength => Control.Length; + } + + [Fact] + public async Task PropertyToSometimesSkip_IsNotValidated_IfNoValidationAttributesExistButPropertyValidationFilterExists() + { + // Arrange + var parameter = new ParameterDescriptor + { + Name = "parameter", + ParameterType = typeof(ValidateSomePropertiesSometimesWithoutValidation), + }; + + var testContext = ModelBindingTestHelper.GetTestContext(); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var modelState = testContext.ModelState; + + // Add an entry for the ControlLength property so that we can observe Skipped versus Valid states. + modelState.SetModelValue( + nameof(ValidateSomePropertiesSometimes.ControlLength), + rawValue: null, + attemptedValue: null); + + // Act + var result = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(result.IsModelSet); + var model = Assert.IsType(result.Model); + Assert.Null(model.Control); + + // Note this Exception is not thrown earlier. + Assert.Throws(() => model.ControlLength); + + Assert.True(modelState.IsValid); + var kvp = Assert.Single(modelState); + Assert.Equal(nameof(ValidateSomePropertiesSometimesWithoutValidation.ControlLength), kvp.Key); + Assert.Equal(ModelValidationState.Valid, kvp.Value.ValidationState); + } + + private record Order11 + ( + IEnumerable
ShippingAddresses, + + Address HomeAddress, + + [FromBody] + Address OfficeAddress + ); + + private record Address + ( + int Street, + + string State, + + [Range(10000, 99999)] + int Zip, + + Country Country + ); + + private record Country(string Name); + + [Fact] + public async Task TypeBasedExclusion_ForBodyAndNonBodyBoundModels() + { + // Arrange + var parameter = new ParameterDescriptor + { + Name = "parameter", + ParameterType = typeof(Order11) + }; + + var input = "{\"Zip\":\"47\"}"; + var testContext = ModelBindingTestHelper.GetTestContext( + request => + { + request.QueryString = + new QueryString("?HomeAddress.Country.Name=US&ShippingAddresses[0].Zip=45&HomeAddress.Zip=46"); + request.Body = new MemoryStream(Encoding.UTF8.GetBytes(input)); + request.ContentType = "application/json"; + }, + options => + { + options.ModelMetadataDetailsProviders.Add(new SuppressChildValidationMetadataProvider(typeof(Address))); + }); + + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(testContext.HttpContext.RequestServices); + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + Assert.Equal(3, modelState.Count); + Assert.Equal(0, modelState.ErrorCount); + Assert.True(modelState.IsValid); + + var entry = Assert.Single(modelState, e => e.Key == "HomeAddress.Country.Name").Value; + Assert.Equal("US", entry.AttemptedValue); + Assert.Equal("US", entry.RawValue); + Assert.Equal(ModelValidationState.Skipped, entry.ValidationState); + + entry = Assert.Single(modelState, e => e.Key == "ShippingAddresses[0].Zip").Value; + Assert.Equal("45", entry.AttemptedValue); + Assert.Equal("45", entry.RawValue); + Assert.Equal(ModelValidationState.Skipped, entry.ValidationState); + + entry = Assert.Single(modelState, e => e.Key == "HomeAddress.Zip").Value; + Assert.Equal("46", entry.AttemptedValue); + Assert.Equal("46", entry.RawValue); + Assert.Equal(ModelValidationState.Skipped, entry.ValidationState); + } + + [Fact] + public async Task FromBody_JToken_ExcludedFromValidation() + { + // Arrange + var options = new TestMvcOptions().Value; + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(options); + var parameter = new ParameterDescriptor + { + Name = "Parameter1", + BindingInfo = new BindingInfo + { + BinderModelName = "CustomParameter", + BindingSource = BindingSource.Body + }, + ParameterType = typeof(JToken) + }; + + var testContext = ModelBindingTestHelper.GetTestContext( + updateRequest: request => + { + request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{ message: \"Hello\" }")); + request.ContentType = "application/json"; + }, + mvcOptions: options); + + var httpContext = testContext.HttpContext; + var modelState = testContext.ModelState; + + // We need to add another model state entry which should get marked as skipped so + // we can prove that the JObject was skipped. + modelState.SetModelValue("CustomParameter.message", "Hello", "Hello"); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + Assert.NotNull(modelBindingResult.Model); + var message = Assert.IsType(modelBindingResult.Model).GetValue("message").Value(); + Assert.Equal("Hello", message); + + Assert.True(modelState.IsValid); + Assert.Single(modelState); + + var entry = Assert.Single(modelState, kvp => kvp.Key == "CustomParameter.message"); + Assert.Equal(ModelValidationState.Skipped, entry.Value.ValidationState); + } + + // Regression test for https://github.com/aspnet/Mvc/issues/3743 + // + // A cancellation token that's bound with the empty prefix will end up suppressing + // the empty prefix. Since the empty prefix is a prefix of everything, this will + // basically result in clearing out all model errors, which is BAD. + // + // The fix is to treat non-user-input as have a key of null, which means that the MSD + // isn't even examined when it comes to suppressing validation. + [Fact] + public async Task CancellationToken_WithEmptyPrefix_DoesNotSuppressUnrelatedErrors() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(new TestMvcOptions().Value); + var parameter = new ParameterDescriptor + { + Name = "cancellationToken", + ParameterType = typeof(CancellationToken) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(); + + var httpContext = testContext.HttpContext; + var modelState = testContext.ModelState; + + // We need to add another model state entry - we want this to be ignored. + modelState.SetModelValue("message", "Hello", "Hello"); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + Assert.NotNull(modelBindingResult.Model); + Assert.IsType(modelBindingResult.Model); + + Assert.False(modelState.IsValid); + Assert.Single(modelState); + + var entry = Assert.Single(modelState, kvp => kvp.Key == "message"); + Assert.Equal(ModelValidationState.Unvalidated, entry.Value.ValidationState); + } + + // Similar to CancellationToken_WithEmptyPrefix_DoesNotSuppressUnrelatedErrors - binding the body + // with the empty prefix should not cause unrelated modelstate entries to get suppressed. + [Fact] + public async Task FromBody_WithEmptyPrefix_DoesNotSuppressUnrelatedErrors_Valid() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(new TestMvcOptions().Value); + var parameter = new ParameterDescriptor + { + Name = "Parameter1", + BindingInfo = new BindingInfo + { + BindingSource = BindingSource.Body + }, + ParameterType = typeof(Greeting) + }; + + var testContext = ModelBindingTestHelper.GetTestContext( + request => + { + request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{ message: \"Hello\" }")); + request.ContentType = "application/json"; + }); + + var httpContext = testContext.HttpContext; + var modelState = testContext.ModelState; + + // We need to add another model state entry which should not get changed. + modelState.SetModelValue("other.key", "1", "1"); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + Assert.NotNull(modelBindingResult.Model); + var message = Assert.IsType(modelBindingResult.Model).Message; + Assert.Equal("Hello", message); + + Assert.False(modelState.IsValid); + Assert.Single(modelState); + + var entry = Assert.Single(modelState, kvp => kvp.Key == "other.key"); + Assert.Equal(ModelValidationState.Unvalidated, entry.Value.ValidationState); + } + + // Similar to CancellationToken_WithEmptyPrefix_DoesNotSuppressUnrelatedErrors - binding the body + // with the empty prefix should not cause unrelated modelstate entries to get suppressed. + [Fact] + public async Task FromBody_WithEmptyPrefix_DoesNotSuppressUnrelatedErrors_Invalid() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(new TestMvcOptions().Value); + var parameter = new ParameterDescriptor + { + Name = "Parameter1", + BindingInfo = new BindingInfo + { + BindingSource = BindingSource.Body + }, + ParameterType = typeof(Greeting) + }; + + var testContext = ModelBindingTestHelper.GetTestContext( + request => + { + // This string is too long and will have a validation error. + request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{ message: \"Hello There\" }")); + request.ContentType = "application/json"; + }); + + var httpContext = testContext.HttpContext; + var modelState = testContext.ModelState; + + // We need to add another model state entry which should not get changed. + modelState.SetModelValue("other.key", "1", "1"); + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + Assert.NotNull(modelBindingResult.Model); + var message = Assert.IsType(modelBindingResult.Model).Message; + Assert.Equal("Hello There", message); + + Assert.False(modelState.IsValid); + Assert.Equal(2, modelState.Count); + + var entry = Assert.Single(modelState, kvp => kvp.Key == "Message"); + Assert.Equal(ModelValidationState.Invalid, entry.Value.ValidationState); + + entry = Assert.Single(modelState, kvp => kvp.Key == "other.key"); + Assert.Equal(ModelValidationState.Unvalidated, entry.Value.ValidationState); + } + + private record Greeting([StringLength(5)] string Message); + + [Fact] + public async Task Validation_NoAttributeInGraphOfObjects_WithDefaultValidatorProviders() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Order12), + BindingInfo = new BindingInfo + { + BindingSource = BindingSource.Body + }, + }; + + var input = new Order12(10, new byte[40]); + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.Body = new MemoryStream(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(input))); + request.ContentType = "application/json"; + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal(input.Id, model.Id); + Assert.Equal(input.OrderFile, model.OrderFile); + Assert.Null(model.RelatedOrders); + + Assert.Empty(modelState); + Assert.Equal(ModelValidationState.Valid, modelState.ValidationState); + } + + private record Order12(int Id, byte[] OrderFile) + { + public IList RelatedOrders { get; set; } + } + + [Fact] + public async Task Validation_ListOfType_NoValidatorOnParameter() + { + // Arrange + var parameterInfo = GetType().GetMethod(nameof(Validation_ListOfType_NoValidatorOnParameterTestMethod), BindingFlags.NonPublic | BindingFlags.Static) + .GetParameters() + .First(); + + var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var modelMetadata = modelMetadataProvider.GetMetadataForParameter(parameterInfo); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(modelMetadataProvider); + + var parameter = new ParameterDescriptor() + { + Name = parameterInfo.Name, + ParameterType = parameterInfo.ParameterType, + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?[0]=1&[1]=2"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext, modelMetadataProvider, modelMetadata); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + Assert.Equal(new[] { 1, 2 }, model); + + Assert.False(modelMetadata.HasValidators); + + Assert.True(modelState.IsValid); + Assert.Equal(ModelValidationState.Valid, modelState.ValidationState); + + var entry = Assert.Single(modelState, e => e.Key == "[0]").Value; + Assert.Equal("1", entry.AttemptedValue); + Assert.Equal("1", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + + entry = Assert.Single(modelState, e => e.Key == "[1]").Value; + Assert.Equal("2", entry.AttemptedValue); + Assert.Equal("2", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + } + + private static void Validation_ListOfType_NoValidatorOnParameterTestMethod(List parameter) { } + + [Fact] + public async Task Validation_ListOfType_ValidatorOnParameter() + { + // Arrange + var parameterInfo = GetType().GetMethod(nameof(Validation_ListOfType_ValidatorOnParameterTestMethod), BindingFlags.NonPublic | BindingFlags.Static) + .GetParameters() + .First(); + + var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var modelMetadata = modelMetadataProvider.GetMetadataForParameter(parameterInfo); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(modelMetadataProvider); + + var parameter = new ParameterDescriptor() + { + Name = parameterInfo.Name, + ParameterType = parameterInfo.ParameterType, + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?[0]=1&[1]=2"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext, modelMetadataProvider, modelMetadata); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + Assert.Equal(new[] { 1, 2 }, model); + + Assert.True(modelMetadata.HasValidators); + + Assert.False(modelState.IsValid); + Assert.Equal(ModelValidationState.Invalid, modelState.ValidationState); + + var entry = Assert.Single(modelState, e => e.Key == "").Value; + Assert.Equal(ModelValidationState.Invalid, entry.ValidationState); + + entry = Assert.Single(modelState, e => e.Key == "[0]").Value; + Assert.Equal("1", entry.AttemptedValue); + Assert.Equal("1", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + + entry = Assert.Single(modelState, e => e.Key == "[1]").Value; + Assert.Equal("2", entry.AttemptedValue); + Assert.Equal("2", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + } + + private static void Validation_ListOfType_ValidatorOnParameterTestMethod([ConsistentMinLength(3)] List parameter) { } + + private class ConsistentMinLength : ValidationAttribute + { + private readonly int _length; + + public ConsistentMinLength(int length) + { + _length = length; + } + + public override bool IsValid(object value) + { + return value is ICollection collection && collection.Count >= _length; + } + } + + [Fact] + public async Task Validation_CollectionOfType_ValidatorOnElement() + { + // Arrange + var parameterInfo = GetType().GetMethod(nameof(Validation_CollectionOfType_ValidatorOnElementTestMethod), BindingFlags.NonPublic | BindingFlags.Static) + .GetParameters() + .First(); + + var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var modelMetadata = modelMetadataProvider.GetMetadataForParameter(parameterInfo); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(modelMetadataProvider); + + var parameter = new ParameterDescriptor() + { + Name = parameterInfo.Name, + ParameterType = parameterInfo.ParameterType, + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?p[0].Id=1&p[1].Id=2"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext, modelMetadataProvider, modelMetadata); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + Assert.Equal(1, model[0].Id); + Assert.Equal(2, model[1].Id); + + Assert.True(modelMetadata.HasValidators); + + Assert.False(modelState.IsValid); + Assert.Equal(ModelValidationState.Invalid, modelState.ValidationState); + + var entry = Assert.Single(modelState, e => e.Key == "p[0].Id").Value; + Assert.Equal("1", entry.AttemptedValue); + Assert.Equal("1", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + + entry = Assert.Single(modelState, e => e.Key == "p[1]").Value; + Assert.Equal(ModelValidationState.Invalid, entry.ValidationState); + + entry = Assert.Single(modelState, e => e.Key == "p[1].Id").Value; + Assert.Equal("2", entry.AttemptedValue); + Assert.Equal("2", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + } + + private static void Validation_CollectionOfType_ValidatorOnElementTestMethod(Collection p) { } + + public class InvalidEvenIds : IValidatableObject + { + public int Id { get; set; } + + public IEnumerable Validate(ValidationContext validationContext) + { + if (Id % 2 == 0) + { + yield return new ValidationResult("Failed validation"); + } + } + } + + [Fact] + public async Task Validation_DictionaryType_NoValidators() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(IDictionary) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter[0].Key=key0¶meter[0].Value=10"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + Assert.Collection( + model.OrderBy(k => k.Key), + kvp => + { + Assert.Equal("key0", kvp.Key); + Assert.Equal(10, kvp.Value); + }); + + Assert.True(modelState.IsValid); + Assert.Equal(ModelValidationState.Valid, modelState.ValidationState); + + var entry = Assert.Single(modelState, e => e.Key == "parameter[0].Key").Value; + Assert.Equal("key0", entry.AttemptedValue); + Assert.Equal("key0", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + + entry = Assert.Single(modelState, e => e.Key == "parameter[0].Value").Value; + Assert.Equal("10", entry.AttemptedValue); + Assert.Equal("10", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + } + + [Fact] + public async Task Validation_DictionaryType_ValueHasValidators() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(Dictionary) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?parameter[0].Key=key0¶meter[0].Value.NeverValidProperty=value0"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType>(modelBindingResult.Model); + Assert.Collection( + model.OrderBy(k => k.Key), + kvp => + { + Assert.Equal("key0", kvp.Key); + Assert.Equal("value0", kvp.Value.NeverValidProperty); + }); + + Assert.False(modelState.IsValid); + Assert.Equal(ModelValidationState.Invalid, modelState.ValidationState); + + var entry = Assert.Single(modelState, e => e.Key == "parameter[0].Key").Value; + Assert.Equal("key0", entry.AttemptedValue); + Assert.Equal("key0", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + + entry = Assert.Single(modelState, e => e.Key == "parameter[0].Value.NeverValidProperty").Value; + Assert.Equal("value0", entry.AttemptedValue); + Assert.Equal("value0", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + + entry = Assert.Single(modelState, e => e.Key == "parameter[0].Value").Value; + Assert.Equal(ModelValidationState.Invalid, entry.ValidationState); + Assert.Single(entry.Errors); + } + + [Fact] + public async Task Validation_TopLevelProperty_NoValidation() + { + // Arrange + var modelType = typeof(Validation_TopLevelPropertyController); + var propertyInfo = modelType.GetProperty(nameof(Validation_TopLevelPropertyController.Model)); + + var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var modelMetadata = modelMetadataProvider.GetMetadataForProperty(propertyInfo, propertyInfo.PropertyType); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(modelMetadataProvider); + + var parameter = new ParameterDescriptor() + { + Name = propertyInfo.Name, + ParameterType = propertyInfo.PropertyType, + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Model.Id=12"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext, modelMetadataProvider, modelMetadata); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal(12, model.Id); + + Assert.False(modelMetadata.HasValidators); + + Assert.True(modelState.IsValid); + Assert.Equal(ModelValidationState.Valid, modelState.ValidationState); + + var entry = Assert.Single(modelState, e => e.Key == "Model.Id").Value; + Assert.Equal("12", entry.AttemptedValue); + Assert.Equal("12", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + } + + public record Validation_TopLevelPropertyModel(int Id); + + private class Validation_TopLevelPropertyController + { + public Validation_TopLevelPropertyModel Model { get; set; } + } + + [Fact] + public async Task Validation_TopLevelProperty_ValidationOnProperty() + { + // Arrange + var modelType = typeof(Validation_TopLevelProperty_ValidationOnPropertyController); + var propertyInfo = modelType.GetProperty(nameof(Validation_TopLevelProperty_ValidationOnPropertyController.Model)); + + var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var modelMetadata = modelMetadataProvider.GetMetadataForProperty(propertyInfo, propertyInfo.PropertyType); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(modelMetadataProvider); + + var parameter = new ParameterDescriptor() + { + Name = propertyInfo.Name, + ParameterType = propertyInfo.PropertyType, + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Model.Id=12"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext, modelMetadataProvider, modelMetadata); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal(12, model.Id); + + Assert.True(modelMetadata.HasValidators); + + Assert.False(modelState.IsValid); + Assert.Equal(ModelValidationState.Invalid, modelState.ValidationState); + + var entry = Assert.Single(modelState, e => e.Key == "Model.Id").Value; + Assert.Equal("12", entry.AttemptedValue); + Assert.Equal("12", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + + entry = Assert.Single(modelState, e => e.Key == "Model").Value; + Assert.Equal(ModelValidationState.Invalid, entry.ValidationState); + } + + public class Validation_TopLevelProperty_ValidationOnPropertyController + { + [CustomValidation(typeof(Validation_TopLevelProperty_ValidationOnPropertyController), nameof(Validate))] + public Validation_TopLevelPropertyModel Model { get; set; } + + public static ValidationResult Validate(ValidationContext context) + { + return new ValidationResult("Invalid result"); + } + } + + [Fact] + public async Task Validation_InfinitelyRecursiveType_NoValidators() + { + // Arrange + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(); + var parameter = new ParameterDescriptor() + { + Name = "parameter", + ParameterType = typeof(RecursiveModel) + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Property1=8"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal(8, model.Property1); + + Assert.True(modelState.IsValid); + Assert.Equal(ModelValidationState.Valid, modelState.ValidationState); + + var entry = Assert.Single(modelState, e => e.Key == "Property1").Value; + Assert.Equal("8", entry.AttemptedValue); + Assert.Equal("8", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + } + + private record RecursiveModel(int Property1) + { + public RecursiveModel Property2 { get; set; } + + public RecursiveModel Property3 => new RecursiveModel(Property1); + } + + [Fact] + public async Task Validation_InifnitelyRecursiveModel_ValidationOnTopLevelParameter() + { + // Arrange + var parameterInfo = GetType().GetMethod(nameof(Validation_InifnitelyRecursiveModel_ValidationOnTopLevelParameterMethod), BindingFlags.NonPublic | BindingFlags.Static) + .GetParameters() + .First(); + + var modelMetadataProvider = TestModelMetadataProvider.CreateDefaultProvider(); + var modelMetadata = modelMetadataProvider.GetMetadataForParameter(parameterInfo); + var parameterBinder = ModelBindingTestHelper.GetParameterBinder(modelMetadataProvider); + + var parameter = new ParameterDescriptor() + { + Name = parameterInfo.Name, + ParameterType = parameterInfo.ParameterType, + }; + + var testContext = ModelBindingTestHelper.GetTestContext(request => + { + request.QueryString = new QueryString("?Property1=8"); + }); + + var modelState = testContext.ModelState; + + // Act + var modelBindingResult = await parameterBinder.BindModelAsync(parameter, testContext, modelMetadataProvider, modelMetadata); + + // Assert + Assert.True(modelBindingResult.IsModelSet); + + var model = Assert.IsType(modelBindingResult.Model); + Assert.Equal(8, model.Property1); + + Assert.True(modelState.IsValid); + Assert.Equal(ModelValidationState.Valid, modelState.ValidationState); + + var entry = Assert.Single(modelState, e => e.Key == "Property1").Value; + Assert.Equal("8", entry.AttemptedValue); + Assert.Equal("8", entry.RawValue); + Assert.Equal(ModelValidationState.Valid, entry.ValidationState); + } + + private static void Validation_InifnitelyRecursiveModel_ValidationOnTopLevelParameterMethod([Required] RecursiveModel model) { } + + private static void AssertRequiredError(string key, ModelError error) + { + Assert.Equal(ValidationAttributeUtil.GetRequiredErrorMessage(key), error.ErrorMessage); + Assert.Null(error.Exception); + } + } +} diff --git a/src/Mvc/test/WebSites/FormatterWebSite/Controllers/JsonFormatterController.cs b/src/Mvc/test/WebSites/FormatterWebSite/Controllers/JsonFormatterController.cs index 1e848590ae95..7732292b37fb 100644 --- a/src/Mvc/test/WebSites/FormatterWebSite/Controllers/JsonFormatterController.cs +++ b/src/Mvc/test/WebSites/FormatterWebSite/Controllers/JsonFormatterController.cs @@ -1,7 +1,10 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.Buffers; +using System.ComponentModel.DataAnnotations; +using System.Security.Cryptography.X509Certificates; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Formatters; @@ -44,7 +47,7 @@ public IActionResult ReturnsIndentedJson() } [HttpPost] - public IActionResult ReturnInput([FromBody]DummyClass dummyObject) + public IActionResult ReturnInput([FromBody] DummyClass dummyObject) { if (!ModelState.IsValid) { @@ -71,6 +74,9 @@ public ActionResult RoundtripSimpleModel([FromBody] SimpleModel mod return model; } + [HttpPost] + public ActionResult RoundtripRecordType([FromBody] SimpleRecordModel model) => model; + public class SimpleModel { public int Id { get; set; } @@ -79,5 +85,26 @@ public class SimpleModel public string StreetName { get; set; } } + + public record SimpleRecordModel(int Id, string Name, string StreetName); + + public record SimpleModelWithValidation( + [Range(1, 100)] + int Id, + + [Required] + [StringLength(8, MinimumLength = 2)] + string Name, + + [Required] + string StreetName); + + [HttpPost] + public ActionResult RoundtripModelWithValidation([FromBody] SimpleModelWithValidation model) + { + if (!ModelState.IsValid) + return ValidationProblem(); + return model; + } } } \ No newline at end of file diff --git a/src/Mvc/test/WebSites/FormatterWebSite/FormatterWebSite.csproj b/src/Mvc/test/WebSites/FormatterWebSite/FormatterWebSite.csproj index 99e5ec73c777..681a4b66b920 100644 --- a/src/Mvc/test/WebSites/FormatterWebSite/FormatterWebSite.csproj +++ b/src/Mvc/test/WebSites/FormatterWebSite/FormatterWebSite.csproj @@ -1,7 +1,8 @@ - + $(DefaultNetCoreTargetFramework) + 9.0 diff --git a/src/Mvc/test/WebSites/FormatterWebSite/Models/RecursiveIdentifier.cs b/src/Mvc/test/WebSites/FormatterWebSite/Models/RecursiveIdentifier.cs index 49e8ab2e9103..49351cb7c50d 100644 --- a/src/Mvc/test/WebSites/FormatterWebSite/Models/RecursiveIdentifier.cs +++ b/src/Mvc/test/WebSites/FormatterWebSite/Models/RecursiveIdentifier.cs @@ -1,9 +1,10 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Linq; +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; namespace FormatterWebSite { @@ -15,6 +16,7 @@ public RecursiveIdentifier(string identifier) Value = identifier; } + [Required] public string Value { get; } public RecursiveIdentifier AccountIdentifier => new RecursiveIdentifier(Value); @@ -24,4 +26,4 @@ public IEnumerable Validate(ValidationContext validationContex return Enumerable.Empty(); } } -} \ No newline at end of file +} diff --git a/src/Mvc/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Controllers/HtmlGeneration_CustomerController.cs b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Controllers/HtmlGeneration_CustomerController.cs index 1d72588f2f76..e79741b381a1 100644 --- a/src/Mvc/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Controllers/HtmlGeneration_CustomerController.cs +++ b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Areas/Customer/Controllers/HtmlGeneration_CustomerController.cs @@ -12,5 +12,10 @@ public IActionResult Index(Models.Customer customer) { return View("Customer"); } + + public IActionResult CustomerWithRecords(Models.CustomerRecord customer) + { + return View("CustomerWithRecords"); + } } } \ No newline at end of file diff --git a/src/Mvc/test/WebSites/HtmlGenerationWebSite/HtmlGenerationWebSite.csproj b/src/Mvc/test/WebSites/HtmlGenerationWebSite/HtmlGenerationWebSite.csproj index f62cdd2b0e3e..928ecd370f01 100644 --- a/src/Mvc/test/WebSites/HtmlGenerationWebSite/HtmlGenerationWebSite.csproj +++ b/src/Mvc/test/WebSites/HtmlGenerationWebSite/HtmlGenerationWebSite.csproj @@ -2,6 +2,7 @@ $(DefaultNetCoreTargetFramework) + 9.0 diff --git a/src/Mvc/test/WebSites/HtmlGenerationWebSite/Models/CustomerRecord.cs b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Models/CustomerRecord.cs new file mode 100644 index 000000000000..3e2c6df9e94e --- /dev/null +++ b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Models/CustomerRecord.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Mvc; + +namespace HtmlGenerationWebSite.Models +{ + public record CustomerRecord + ( + [Range(1, 100)] + int Number, + + string Name, + + [Required] + string Password, + + [EnumDataType(typeof(Gender))] + Gender Gender, + + string PhoneNumber, + + [DataType(DataType.EmailAddress)] + string Email, + + string Key + ); +} \ No newline at end of file diff --git a/src/Mvc/test/WebSites/HtmlGenerationWebSite/Views/Shared/CustomerWithRecords.cshtml b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Views/Shared/CustomerWithRecords.cshtml new file mode 100644 index 000000000000..13dddfc0819e --- /dev/null +++ b/src/Mvc/test/WebSites/HtmlGenerationWebSite/Views/Shared/CustomerWithRecords.cshtml @@ -0,0 +1,46 @@ +@model HtmlGenerationWebSite.Models.CustomerRecord + +@{ + ViewBag.Title = "Customer Page"; +} + +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers + + + +
+
+ + + +
+
+ + +
+
+ + + +
+
+ + +
+
+ + + +
+
+ + Male + Female + +
+
+
+ +
+ + \ No newline at end of file