Skip to content

Support Keyed Services in MVC #50145

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
20 changes: 20 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 @@ -35,6 +36,7 @@ public BindingInfo(BindingInfo other)
PropertyFilterProvider = other.PropertyFilterProvider;
RequestPredicate = other.RequestPredicate;
EmptyBodyBehavior = other.EmptyBodyBehavior;
ServiceKey = other.ServiceKey;
}

/// <summary>
Expand Down Expand Up @@ -89,6 +91,11 @@ public Type? BinderType
/// </summary>
public EmptyBodyBehavior EmptyBodyBehavior { get; set; }

/// <summary>
/// Get or sets the value used as the key when looking for a keyed service
/// </summary>
public object? ServiceKey { get; set; }

/// <summary>
/// Constructs a new instance of <see cref="BindingInfo"/> from the given <paramref name="attributes"/>.
/// <para>
Expand Down Expand Up @@ -169,6 +176,19 @@ public Type? BinderType
break;
}

// Keyed services
if (attributes.OfType<FromKeyedServicesAttribute>().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;
}

Expand Down
2 changes: 2 additions & 0 deletions src/Mvc/Mvc.Abstractions/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
#nullable enable
Microsoft.AspNetCore.Mvc.ModelBinding.BindingInfo.ServiceKey.get -> object?
Microsoft.AspNetCore.Mvc.ModelBinding.BindingInfo.ServiceKey.set -> void
Copy link
Member

Choose a reason for hiding this comment

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

@benjaminpetit here's the modification to the API file that the build is complaining about. Normally we don't change these files in release branches, but since this is the RC1 branch it's fine. Ping me or a manager when this is approved & CI failures are down to just this, and we can force-merge it for you (note I'll be OOF for a week after about 3:30 PST today)

40 changes: 40 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,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<NotSupportedException>(() => BindingInfo.GetBindingInfo(attributes, modelMetadata));
}
}
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 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
89 changes: 89 additions & 0 deletions src/Mvc/test/Mvc.FunctionalTests/KeyedServicesTests.cs
Original file line number Diff line number Diff line change
@@ -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<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());
}

[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);
}
}
Original file line number Diff line number Diff line change
@@ -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<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()}";
}

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

# nullable enable

[HttpGet("GetOptionalNotRegistered")]
public ActionResult<string> 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<string> GetRequiredNotRegistered([FromKeyedServices("no_existing_key")] ICustomService service)
{
return service.Process();
}
}
32 changes: 32 additions & 0 deletions src/Mvc/test/WebSites/BasicWebSite/CustomService.cs
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ 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.AddSingleton<ICustomService, DefaultCustomService>();
services.AddScoped<RequestIdService>();
services.AddTransient<ServiceActionFilter>();
services.AddScoped<TestResponseGenerator>();
Expand Down