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();