diff --git a/src/Mvc/Mvc.Abstractions/src/ModelBinding/BindingInfo.cs b/src/Mvc/Mvc.Abstractions/src/ModelBinding/BindingInfo.cs index ad294bf6a52d..1e4f8f3ca836 100644 --- a/src/Mvc/Mvc.Abstractions/src/ModelBinding/BindingInfo.cs +++ b/src/Mvc/Mvc.Abstractions/src/ModelBinding/BindingInfo.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Reflection; using Microsoft.AspNetCore.Mvc.Abstractions; +using Microsoft.Extensions.DependencyInjection; namespace Microsoft.AspNetCore.Mvc.ModelBinding; @@ -35,6 +36,7 @@ public BindingInfo(BindingInfo other) PropertyFilterProvider = other.PropertyFilterProvider; RequestPredicate = other.RequestPredicate; EmptyBodyBehavior = other.EmptyBodyBehavior; + ServiceKey = other.ServiceKey; } /// @@ -89,6 +91,11 @@ public Type? BinderType /// public EmptyBodyBehavior EmptyBodyBehavior { get; set; } + /// + /// Get or sets the value used as the key when looking for a keyed service + /// + public object? ServiceKey { get; set; } + /// /// Constructs a new instance of from the given . /// @@ -169,6 +176,19 @@ public Type? BinderType break; } + // Keyed services + if (attributes.OfType().FirstOrDefault() is { } fromKeyedServicesAttribute) + { + if (bindingInfo.BindingSource != null) + { + throw new NotSupportedException( + $"The {nameof(FromKeyedServicesAttribute)} is not supported on parameters that are also annotated with {nameof(IBindingSourceMetadata)}."); + } + isBindingInfoPresent = true; + bindingInfo.BindingSource = BindingSource.Services; + bindingInfo.ServiceKey = fromKeyedServicesAttribute.Key; + } + return isBindingInfoPresent ? bindingInfo : null; } diff --git a/src/Mvc/Mvc.Abstractions/src/PublicAPI.Unshipped.txt b/src/Mvc/Mvc.Abstractions/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..b6cfbc5fdfa4 100644 --- a/src/Mvc/Mvc.Abstractions/src/PublicAPI.Unshipped.txt +++ b/src/Mvc/Mvc.Abstractions/src/PublicAPI.Unshipped.txt @@ -1 +1,3 @@ #nullable enable +Microsoft.AspNetCore.Mvc.ModelBinding.BindingInfo.ServiceKey.get -> object? +Microsoft.AspNetCore.Mvc.ModelBinding.BindingInfo.ServiceKey.set -> void diff --git a/src/Mvc/Mvc.Abstractions/test/ModelBinding/BindingInfoTest.cs b/src/Mvc/Mvc.Abstractions/test/ModelBinding/BindingInfoTest.cs index a9680cd9e78c..00d42c3b12c4 100644 --- a/src/Mvc/Mvc.Abstractions/test/ModelBinding/BindingInfoTest.cs +++ b/src/Mvc/Mvc.Abstractions/test/ModelBinding/BindingInfoTest.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.AspNetCore.Mvc.ModelBinding.Binders; +using Microsoft.Extensions.DependencyInjection; using Moq; namespace Microsoft.AspNetCore.Mvc.ModelBinding; @@ -286,4 +287,43 @@ public void GetBindingInfo_WithAttributesAndModelMetadata_PreserveEmptyBodyDefau Assert.NotNull(bindingInfo); Assert.Equal(EmptyBodyBehavior.Default, bindingInfo.EmptyBodyBehavior); } + + [Fact] + public void GetBindingInfo_WithFromKeyedServicesAttribute() + { + // Arrange + var key = new object(); + var attributes = new object[] + { + new FromKeyedServicesAttribute(key), + }; + var modelType = typeof(Guid); + var provider = new TestModelMetadataProvider(); + var modelMetadata = provider.GetMetadataForType(modelType); + + // Act + var bindingInfo = BindingInfo.GetBindingInfo(attributes, modelMetadata); + + // Assert + Assert.NotNull(bindingInfo); + Assert.Same(BindingSource.Services, bindingInfo.BindingSource); + Assert.Same(key, bindingInfo.ServiceKey); + } + + [Fact] + public void GetBindingInfo_ThrowsWhenWithFromKeyedServicesAttributeAndIFromServiceMetadata() + { + // Arrange + var attributes = new object[] + { + new FromKeyedServicesAttribute(new object()), + new FromServicesAttribute() + }; + var modelType = typeof(Guid); + var provider = new TestModelMetadataProvider(); + var modelMetadata = provider.GetMetadataForType(modelType); + + // Act and Assert + Assert.Throws(() => BindingInfo.GetBindingInfo(attributes, modelMetadata)); + } } diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/KeyedServicesModelBinder.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/KeyedServicesModelBinder.cs new file mode 100644 index 000000000000..f148986930b0 --- /dev/null +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/KeyedServicesModelBinder.cs @@ -0,0 +1,43 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; +using Microsoft.Extensions.DependencyInjection; + +namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders; + +internal class KeyedServicesModelBinder : IModelBinder +{ + private readonly object _key; + private readonly bool _isOptional; + + public KeyedServicesModelBinder(object key, bool isOptional) + { + _key = key ?? throw new ArgumentNullException(nameof(key)); + _isOptional = isOptional; + } + + public Task BindModelAsync(ModelBindingContext bindingContext) + { + var keyedServices = bindingContext.HttpContext.RequestServices as IKeyedServiceProvider; + if (keyedServices == null) + { + bindingContext.Result = ModelBindingResult.Failed(); + return Task.CompletedTask; + } + + var model = _isOptional ? + keyedServices.GetKeyedService(bindingContext.ModelType, _key) : + keyedServices.GetRequiredKeyedService(bindingContext.ModelType, _key); + + if (model != null) + { + bindingContext.ValidationState.Add(model, new ValidationStateEntry() { SuppressValidation = true }); + } + + bindingContext.Result = ModelBindingResult.Success(model); + return Task.CompletedTask; + } +} diff --git a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ServicesModelBinderProvider.cs b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ServicesModelBinderProvider.cs index 6dc30fb4952c..2ac35d51319f 100644 --- a/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ServicesModelBinderProvider.cs +++ b/src/Mvc/Mvc.Core/src/ModelBinding/Binders/ServicesModelBinderProvider.cs @@ -25,12 +25,17 @@ public class ServicesModelBinderProvider : IModelBinderProvider { // IsRequired will be false for a Reference Type // without a default value in a oblivious nullability context - // however, for services we shoud treat them as required + // however, for services we should treat them as required var isRequired = context.Metadata.IsRequired || (context.Metadata.Identity.ParameterInfo?.HasDefaultValue != true && !context.Metadata.ModelType.IsValueType && context.Metadata.NullabilityState == NullabilityState.Unknown); + if (context.BindingInfo.ServiceKey != null) + { + return new KeyedServicesModelBinder(context.BindingInfo.ServiceKey, !isRequired); + } + return isRequired ? _servicesBinder : _optionalServicesBinder; } diff --git a/src/Mvc/test/Mvc.FunctionalTests/KeyedServicesTests.cs b/src/Mvc/test/Mvc.FunctionalTests/KeyedServicesTests.cs new file mode 100644 index 000000000000..87f304163e7d --- /dev/null +++ b/src/Mvc/test/Mvc.FunctionalTests/KeyedServicesTests.cs @@ -0,0 +1,89 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.Http; + +namespace Microsoft.AspNetCore.Mvc.FunctionalTests; + +public class KeyedServicesTests : IClassFixture> +{ + public KeyedServicesTests(MvcTestFixture fixture) + { + Client = fixture.CreateDefaultClient(); + } + + public HttpClient Client { get; } + + [Fact] + public async Task ExplicitSingleFromKeyedServiceAttribute() + { + // Arrange + var okRequest = new HttpRequestMessage(HttpMethod.Get, "/services/GetOk"); + var notokRequest = new HttpRequestMessage(HttpMethod.Get, "/services/GetNotOk"); + + // Act + var okResponse = await Client.SendAsync(okRequest); + var notokResponse = await Client.SendAsync(notokRequest); + + // Assert + Assert.True(okResponse.IsSuccessStatusCode); + Assert.True(notokResponse.IsSuccessStatusCode); + Assert.Equal("OK", await okResponse.Content.ReadAsStringAsync()); + Assert.Equal("NOT OK", await notokResponse.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task ExplicitMultipleFromKeyedServiceAttribute() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "/services/GetBoth"); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.True(response.IsSuccessStatusCode); + Assert.Equal("OK,NOT OK", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task ExplicitSingleFromKeyedServiceAttributeWithNullKey() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "/services/GetKeyNull"); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.True(response.IsSuccessStatusCode); + Assert.Equal("DEFAULT", await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task ExplicitSingleFromKeyedServiceAttributeOptionalNotRegistered() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "/services/GetOptionalNotRegistered"); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.True(response.IsSuccessStatusCode); + Assert.Equal(string.Empty, await response.Content.ReadAsStringAsync()); + } + + [Fact] + public async Task ExplicitSingleFromKeyedServiceAttributeRequiredNotRegistered() + { + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "/services/GetRequiredNotRegistered"); + + // Act + var response = await Client.SendAsync(request); + + // Assert + Assert.False(response.IsSuccessStatusCode); + } +} diff --git a/src/Mvc/test/WebSites/BasicWebSite/Controllers/CustomServiceApiController.cs b/src/Mvc/test/WebSites/BasicWebSite/Controllers/CustomServiceApiController.cs new file mode 100644 index 000000000000..f4a2d432443d --- /dev/null +++ b/src/Mvc/test/WebSites/BasicWebSite/Controllers/CustomServiceApiController.cs @@ -0,0 +1,55 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Mvc; + +namespace BasicWebSite; + +[ApiController] +[Route("/services")] +public class CustomServicesApiController : Controller +{ + [HttpGet("GetOk")] + public ActionResult GetOk([FromKeyedServices("ok_service")] ICustomService service) + { + return service.Process(); + } + + [HttpGet("GetNotOk")] + public ActionResult GetNotOk([FromKeyedServices("not_ok_service")] ICustomService service) + { + return service.Process(); + } + + [HttpGet("GetBoth")] + public ActionResult GetBoth( + [FromKeyedServices("ok_service")] ICustomService s1, + [FromKeyedServices("not_ok_service")] ICustomService s2) + { + return $"{s1.Process()},{s2.Process()}"; + } + + [HttpGet("GetKeyNull")] + public ActionResult GetKeyNull([FromKeyedServices(null)] ICustomService service) + { + return service.Process(); + } + +# nullable enable + + [HttpGet("GetOptionalNotRegistered")] + public ActionResult GetOptionalNotRegistered([FromKeyedServices("no_existing_key")] ICustomService? service) + { + if (service != null) + { + throw new Exception("Service should not have been resolved"); + } + return string.Empty; + } + + [HttpGet("GetRequiredNotRegistered")] + public ActionResult GetRequiredNotRegistered([FromKeyedServices("no_existing_key")] ICustomService service) + { + return service.Process(); + } +} diff --git a/src/Mvc/test/WebSites/BasicWebSite/CustomService.cs b/src/Mvc/test/WebSites/BasicWebSite/CustomService.cs new file mode 100644 index 000000000000..6f7b0aaaa8b1 --- /dev/null +++ b/src/Mvc/test/WebSites/BasicWebSite/CustomService.cs @@ -0,0 +1,32 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using BasicWebSite.Models; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.AspNetCore.Mvc; + +namespace BasicWebSite; + +public interface ICustomService +{ + string Process(); +} + +public class OkCustomService : ICustomService +{ + public string Process() => "OK"; + public override string ToString() => Process(); +} + +public class BadCustomService : ICustomService +{ + public string Process() => "NOT OK"; + public override string ToString() => Process(); +} + +public class DefaultCustomService : ICustomService +{ + public string Process() => "DEFAULT"; + public override string ToString() => Process(); + public static DefaultCustomService Instance => new DefaultCustomService(); +} diff --git a/src/Mvc/test/WebSites/BasicWebSite/StartupWithoutEndpointRouting.cs b/src/Mvc/test/WebSites/BasicWebSite/StartupWithoutEndpointRouting.cs index f7b1b81e0a86..94a467baa003 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/StartupWithoutEndpointRouting.cs +++ b/src/Mvc/test/WebSites/BasicWebSite/StartupWithoutEndpointRouting.cs @@ -43,6 +43,9 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddHttpContextAccessor(); services.AddSingleton(); + services.AddKeyedSingleton("ok_service"); + services.AddKeyedSingleton("not_ok_service"); + services.AddSingleton(); services.AddScoped(); services.AddTransient(); services.AddScoped();