Skip to content
8 changes: 8 additions & 0 deletions src/Mvc/Mvc.Abstractions/src/ModelBinding/BindingInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Linq;
using System.Reflection;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.Extensions.DependencyInjection;

namespace Microsoft.AspNetCore.Mvc.ModelBinding;

Expand Down Expand Up @@ -169,6 +170,13 @@ public Type? BinderType
break;
}

// Keyed services
foreach (var fromKeyedServicesAttribute in attributes.OfType<FromKeyedServicesAttribute>())
{
isBindingInfoPresent = true;
bindingInfo.BindingSource = BindingSource.KeyedServices;
}

return isBindingInfoPresent ? bindingInfo : null;
}

Expand Down
9 changes: 9 additions & 0 deletions src/Mvc/Mvc.Abstractions/src/ModelBinding/BindingSource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ public class BindingSource : IEquatable<BindingSource?>
isGreedy: true,
isFromRequest: false);

/// <summary>
/// A <see cref="BindingSource"/> for request keyed services.
/// </summary>
public static readonly BindingSource KeyedServices = new BindingSource(
"KeyedServices",
Resources.BindingSource_KeyedServices,
isGreedy: true,
isFromRequest: false);

/// <summary>
/// A <see cref="BindingSource"/> for special parameter types that are not user input.
/// </summary>
Expand Down
3 changes: 3 additions & 0 deletions src/Mvc/Mvc.Abstractions/src/Resources.resx
Original file line number Diff line number Diff line change
Expand Up @@ -183,4 +183,7 @@
<data name="ModelStateDictionary_MaxModelStateDepth" xml:space="preserve">
<value>The specified key exceeded the maximum ModelState depth: {0}</value>
</data>
<data name="BindingSource_KeyedServices" xml:space="preserve">
<value>KeyedServices</value>
</data>
</root>
21 changes: 21 additions & 0 deletions src/Mvc/Mvc.Abstractions/test/ModelBinding/BindingInfoTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -286,4 +287,24 @@ public void GetBindingInfo_WithAttributesAndModelMetadata_PreserveEmptyBodyDefau
Assert.NotNull(bindingInfo);
Assert.Equal(EmptyBodyBehavior.Default, bindingInfo.EmptyBodyBehavior);
}

[Fact]
public void GetBindingInfo_WithFromKeyedServicesAttribute()
{
// Arrange
var attributes = new object[]
{
new FromKeyedServicesAttribute(new object()),
};
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.KeyedServices, bindingInfo.BindingSource);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ public void Configure(MvcOptions options)
// Set up ModelBinding
options.ModelBinderProviders.Add(new BinderTypeModelBinderProvider());
options.ModelBinderProviders.Add(new ServicesModelBinderProvider());
options.ModelBinderProviders.Add(new KeyedServicesModelBinderProvider());
options.ModelBinderProviders.Add(new BodyModelBinderProvider(options.InputFormatters, _readerFactory, _loggerFactory, options));
options.ModelBinderProviders.Add(new HeaderModelBinderProvider());
options.ModelBinderProviders.Add(new FloatingPointTypeModelBinderProvider());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// 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;

/// <summary>
/// An <see cref="IModelBinder"/> which binds models from the request services when a model
/// has the binding source <see cref="BindingSource.KeyedServices"/>.
/// </summary>
public class KeyedServicesModelBinder : IModelBinder
{
internal bool IsOptional { get; set; }

internal object? Key { get; set; }

/// <inheritdoc />
public Task BindModelAsync(ModelBindingContext bindingContext)
{
ArgumentNullException.ThrowIfNull(bindingContext);

var requestServices = bindingContext.HttpContext.RequestServices as IKeyedServiceProvider;
if (requestServices == null)
{
bindingContext.Result = ModelBindingResult.Failed();
return Task.CompletedTask;
}

var model = IsOptional ?
requestServices.GetKeyedService(bindingContext.ModelType, Key) :
requestServices.GetRequiredKeyedService(bindingContext.ModelType, Key);

if (model != null)
{
bindingContext.ValidationState.Add(model, new ValidationStateEntry() { SuppressValidation = true });
}

bindingContext.Result = ModelBindingResult.Success(model);
return Task.CompletedTask;
}
}
Original file line number Diff line number Diff line change
@@ -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 System.Reflection;
using Microsoft.Extensions.DependencyInjection;

namespace Microsoft.AspNetCore.Mvc.ModelBinding.Binders;

/// <summary>
/// An <see cref="IModelBinderProvider"/> for binding from the <see cref="IKeyedServiceProvider"/>.
/// </summary>
public class KeyedServicesModelBinderProvider : IModelBinderProvider
{
/// <inheritdoc />
public IModelBinder? GetBinder(ModelBinderProviderContext context)
{
ArgumentNullException.ThrowIfNull(context);

if (context.BindingInfo.BindingSource != null &&
context.BindingInfo.BindingSource.CanAcceptDataFrom(BindingSource.KeyedServices))
{
// IsRequired will be false for a Reference Type
// without a default value in a oblivious nullability context
// 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);

var attribute = context.Metadata.Identity.ParameterInfo?.GetCustomAttribute<FromKeyedServicesAttribute>();
Copy link
Member Author

Choose a reason for hiding this comment

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

It would be great to get the key when constructing the BindingInfo, but it will require adding some properties in several places.


return new KeyedServicesModelBinder
{
IsOptional = !isRequired,
Key = attribute?.Key
};
}

return null;
}
}
51 changes: 51 additions & 0 deletions src/Mvc/test/Mvc.FunctionalTests/KeyedServicesTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// 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<MvcTestFixture<BasicWebSite.StartupWithoutEndpointRouting>>
{
public KeyedServicesTests(MvcTestFixture<BasicWebSite.StartupWithoutEndpointRouting> 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());

var response2 = await Client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "/services/GetBoth"));
Assert.True(response2.IsSuccessStatusCode);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// 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<string> GetOk([FromKeyedServices("ok_service")] ICustomService service)
{
return service.Process();
}

[HttpGet("GetNotOk")]
public ActionResult<string> GetNotOk([FromKeyedServices("not_ok_service")] ICustomService service)
{
return service.Process();
}

[HttpGet("GetBoth")]
public ActionResult<string> GetBoth(
[FromKeyedServices("ok_service")] ICustomService s1,
[FromKeyedServices("not_ok_service")] ICustomService s2)
Copy link
Member

Choose a reason for hiding this comment

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

Do we have any tests for optional and required services when the service is missing?

{
return $"{s1.Process()},{s2.Process()}";
}
}
25 changes: 25 additions & 0 deletions src/Mvc/test/WebSites/BasicWebSite/CustomService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// 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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ public void ConfigureServices(IServiceCollection services)
services.AddSingleton<IActionDescriptorProvider, ActionDescriptorCreationCounter>();
services.AddHttpContextAccessor();
services.AddSingleton<ContactsRepository>();
services.AddKeyedSingleton<ICustomService, OkCustomService>("ok_service");
services.AddKeyedSingleton<ICustomService, BadCustomService>("not_ok_service");
services.AddScoped<RequestIdService>();
services.AddTransient<ServiceActionFilter>();
services.AddScoped<TestResponseGenerator>();
Expand Down