Skip to content

Commit 07ae114

Browse files
committed
ASP.NET Core model binder behaves similar to JSON/MessagePack converters when checking for "AllowDefaultStructs" and null
1 parent 273d292 commit 07ae114

File tree

6 files changed

+274
-120
lines changed

6 files changed

+274
-120
lines changed
Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,11 @@
1-
using System.Runtime.CompilerServices;
2-
using Microsoft.Extensions.Logging;
3-
41
namespace Thinktecture.AspNetCore.ModelBinding;
52

63
/// <summary>
74
/// Model binder for implementations of string-based Smart Enums Value Objects with a key member.
85
/// </summary>
96
/// <typeparam name="T">Type of the value object.</typeparam>
107
/// <typeparam name="TValidationError">Type of the validation error.</typeparam>
8+
[Obsolete("Use 'ValueObjectModelBinder' instead")]
119
public sealed class TrimmingSmartEnumModelBinder<T, TValidationError> : ValueObjectModelBinderBase<T, string, TValidationError>
1210
where T : IValueObjectFactory<T, string, TValidationError>
13-
where TValidationError : class, IValidationError<TValidationError>
14-
{
15-
/// <summary>
16-
/// Initializes a new instance of <see cref="ValueObjectModelBinder{T,TKey,TValidationError}"/>.
17-
/// </summary>
18-
/// <param name="loggerFactory">Logger factory.</param>
19-
public TrimmingSmartEnumModelBinder(
20-
ILoggerFactory loggerFactory)
21-
: base(loggerFactory)
22-
{
23-
}
24-
25-
/// <inheritdoc />
26-
[MethodImpl(MethodImplOptions.AggressiveInlining)]
27-
protected override string Prepare(string key)
28-
{
29-
return key.Trim();
30-
}
31-
}
11+
where TValidationError : class, IValidationError<TValidationError>;
Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
1-
using System.Runtime.CompilerServices;
2-
using Microsoft.Extensions.Logging;
3-
41
namespace Thinktecture.AspNetCore.ModelBinding;
52

63
/// <summary>
@@ -12,26 +9,4 @@ namespace Thinktecture.AspNetCore.ModelBinding;
129
public sealed class ValueObjectModelBinder<T, TKey, TValidationError> : ValueObjectModelBinderBase<T, TKey, TValidationError>
1310
where T : IValueObjectFactory<T, TKey, TValidationError>
1411
where TKey : notnull
15-
where TValidationError : class, IValidationError<TValidationError>
16-
{
17-
/// <summary>
18-
/// Initializes a new instance of <see cref="ValueObjectModelBinder{T,TKey,TValidationError}"/>.
19-
/// </summary>
20-
/// <param name="loggerFactory">Logger factory.</param>
21-
public ValueObjectModelBinder(
22-
ILoggerFactory loggerFactory)
23-
: base(loggerFactory)
24-
{
25-
}
26-
27-
/// <summary>
28-
/// Prepares the key before validation.
29-
/// </summary>
30-
/// <param name="key">Key to prepare.</param>
31-
/// <returns>Prepared key.</returns>
32-
[MethodImpl(MethodImplOptions.AggressiveInlining)]
33-
protected override TKey Prepare(TKey key)
34-
{
35-
return key;
36-
}
37-
}
12+
where TValidationError : class, IValidationError<TValidationError>;
Lines changed: 105 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
using System.Runtime.CompilerServices;
1+
using System.ComponentModel;
2+
using System.Runtime.ExceptionServices;
23
using Microsoft.AspNetCore.Mvc.ModelBinding;
3-
using Microsoft.AspNetCore.Mvc.ModelBinding.Binders;
4-
using Microsoft.Extensions.Logging;
54

65
namespace Thinktecture.AspNetCore.ModelBinding;
76

@@ -11,49 +10,135 @@ namespace Thinktecture.AspNetCore.ModelBinding;
1110
/// <typeparam name="T">Type of the value object.</typeparam>
1211
/// <typeparam name="TKey">Type of the key member.</typeparam>
1312
/// <typeparam name="TValidationError">Type of the validation error.</typeparam>
14-
public abstract class ValueObjectModelBinderBase<T, TKey, TValidationError> : SimpleTypeModelBinder
13+
public abstract class ValueObjectModelBinderBase<T, TKey, TValidationError> : IModelBinder
1514
where T : IValueObjectFactory<T, TKey, TValidationError>
1615
where TKey : notnull
1716
where TValidationError : class, IValidationError<TValidationError>
1817
{
18+
private static readonly Type _type = typeof(T);
19+
private static readonly Type _keyType = typeof(TKey);
20+
private static readonly TKey? _keyDefaultValue = default;
1921
private static readonly bool _mayReturnInvalidObjects = typeof(IValidatableEnum).IsAssignableFrom(typeof(T));
22+
private static readonly bool _disallowDefaultValues = typeof(IDisallowDefaultValue).IsAssignableFrom(typeof(T));
23+
24+
private readonly TypeConverter? _keyConverter;
2025

2126
/// <summary>
2227
/// Initializes a new instance of <see cref="ValueObjectModelBinder{T,TKey,TValidationError}"/>.
2328
/// </summary>
24-
/// <param name="loggerFactory">Logger factory.</param>
25-
protected ValueObjectModelBinderBase(
26-
ILoggerFactory loggerFactory)
27-
: base(typeof(TKey), loggerFactory)
29+
protected ValueObjectModelBinderBase()
2830
{
31+
var converter = TypeDescriptor.GetConverter(typeof(TKey));
32+
_keyConverter = converter.CanConvertFrom(typeof(string)) ? converter : null;
2933
}
3034

3135
/// <inheritdoc />
32-
protected override void CheckModel(ModelBindingContext bindingContext, ValueProviderResult valueProviderResult, object? model)
36+
public Task BindModelAsync(ModelBindingContext bindingContext)
3337
{
34-
if (model is not TKey key)
38+
ArgumentNullException.ThrowIfNull(bindingContext);
39+
40+
var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
41+
bindingContext.ModelState.SetModelValue(bindingContext.ModelName, valueProviderResult);
42+
43+
try
3544
{
36-
base.CheckModel(bindingContext, valueProviderResult, model);
45+
var value = valueProviderResult.FirstValue;
46+
47+
if (bindingContext.ModelMetadata.ConvertEmptyStringToNull)
48+
value = value.TrimOrNullify();
49+
50+
if (value is null)
51+
{
52+
var isNullable = Nullable.GetUnderlyingType(bindingContext.ModelType) == _type;
53+
54+
if (isNullable || (bindingContext.ModelType.IsClass && !_disallowDefaultValues))
55+
{
56+
bindingContext.Result = ModelBindingResult.Success(null);
57+
bindingContext.ModelState.MarkFieldValid(bindingContext.ModelName);
58+
return Task.CompletedTask;
59+
}
60+
}
61+
62+
object? key;
63+
64+
if (_keyType == typeof(string))
65+
{
66+
key = value;
67+
}
68+
else
69+
{
70+
if (_keyConverter is null)
71+
{
72+
bindingContext.ModelState.TryAddModelError(bindingContext.ModelName, $"Cannot convert a string to type \"{typeof(T).Name}\".");
73+
return Task.CompletedTask;
74+
}
75+
76+
if (value is null && _keyType.IsValueType)
77+
{
78+
bindingContext.ModelState.TryAddModelError(bindingContext.ModelName, $"Cannot convert null to type \"{typeof(T).Name}\".");
79+
return Task.CompletedTask;
80+
}
81+
82+
key = _keyConverter.ConvertFrom(
83+
null,
84+
valueProviderResult.Culture,
85+
value!);
86+
}
87+
88+
if (key is null)
89+
{
90+
if (_disallowDefaultValues)
91+
{
92+
bindingContext.ModelState.TryAddModelError(bindingContext.ModelName, $"Cannot convert null to type \"{typeof(T).Name}\" because it doesn't allow default values.");
93+
return Task.CompletedTask;
94+
}
95+
96+
bindingContext.Result = ModelBindingResult.Success(default(T));
97+
bindingContext.ModelState.MarkFieldValid(bindingContext.ModelName);
98+
return Task.CompletedTask;
99+
}
100+
101+
CheckKey(bindingContext, valueProviderResult, (TKey)key);
102+
103+
return Task.CompletedTask;
104+
}
105+
catch (Exception exception)
106+
{
107+
var isFormatException = exception is FormatException;
108+
109+
if (!isFormatException && exception.InnerException != null)
110+
{
111+
// TypeConverter throws System.Exception wrapping the FormatException, so we capture the inner exception.
112+
exception = ExceptionDispatchInfo.Capture(exception.InnerException).SourceException;
113+
}
114+
115+
bindingContext.ModelState.TryAddModelError(
116+
bindingContext.ModelName,
117+
exception,
118+
bindingContext.ModelMetadata);
119+
120+
// Were able to find a converter for the type but conversion failed.
121+
return Task.CompletedTask;
122+
}
123+
}
124+
125+
private void CheckKey(ModelBindingContext bindingContext, ValueProviderResult valueProviderResult, TKey key)
126+
{
127+
if (_disallowDefaultValues && key.Equals(_keyDefaultValue))
128+
{
129+
bindingContext.ModelState.TryAddModelError(bindingContext.ModelName, $"Cannot convert null to type \"{typeof(T).Name}\" because it doesn't allow default values.");
37130
return;
38131
}
39132

40-
key = Prepare(key);
41133
var validationError = T.Validate(key, valueProviderResult.Culture, out var obj);
42134

43135
if (validationError is null || _mayReturnInvalidObjects)
44136
{
137+
bindingContext.ModelState.MarkFieldValid(bindingContext.ModelName);
45138
bindingContext.Result = ModelBindingResult.Success(obj);
46139
return;
47140
}
48141

49142
bindingContext.ModelState.TryAddModelError(bindingContext.ModelName, validationError.ToString() ?? $"There is no item of type '{typeof(T).Name}' with the identifier '{key}'.");
50143
}
51-
52-
/// <summary>
53-
/// Prepares the key before validation.
54-
/// </summary>
55-
/// <param name="key">Key to prepare.</param>
56-
/// <returns>Prepared key.</returns>
57-
[MethodImpl(MethodImplOptions.AggressiveInlining)]
58-
protected abstract TKey Prepare(TKey key);
59144
}

src/Thinktecture.Runtime.Extensions.AspNetCore/AspNetCore/ModelBinding/ValueObjectModelBinderProvider.cs

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
using System.Reflection;
22
using Microsoft.AspNetCore.Mvc.ModelBinding;
3-
using Microsoft.Extensions.DependencyInjection;
4-
using Microsoft.Extensions.Logging;
53
using Thinktecture.Internal;
64

75
namespace Thinktecture.AspNetCore.ModelBinding;
@@ -11,20 +9,28 @@ namespace Thinktecture.AspNetCore.ModelBinding;
119
/// </summary>
1210
public sealed class ValueObjectModelBinderProvider : IModelBinderProvider
1311
{
14-
private readonly bool _trimStringBasedEnums;
1512
private readonly bool _skipBindingFromBody;
1613

14+
/// <summary>
15+
/// Initializes new instance of <see cref="ValueObjectModelBinderProvider"/>.
16+
/// </summary>
17+
/// <param name="skipBindingFromBody">Indication whether to skip model binding if the raw value comes from request body.</param>
18+
public ValueObjectModelBinderProvider(bool skipBindingFromBody = true)
19+
{
20+
_skipBindingFromBody = skipBindingFromBody;
21+
}
22+
1723
/// <summary>
1824
/// Initializes new instance of <see cref="ValueObjectModelBinderProvider"/>.
1925
/// </summary>
2026
/// <param name="trimStringBasedEnums">Indication whether to trim string-values before parsing them.</param>
2127
/// <param name="skipBindingFromBody">Indication whether to skip model binding if the raw value comes from request body.</param>
28+
[Obsolete("Use constructor without 'trimStringBasedEnums' parameter instead")]
2229
public ValueObjectModelBinderProvider(
23-
bool trimStringBasedEnums = true,
30+
bool trimStringBasedEnums,
2431
bool skipBindingFromBody = true)
32+
: this(skipBindingFromBody)
2533
{
26-
_trimStringBasedEnums = trimStringBasedEnums;
27-
_skipBindingFromBody = skipBindingFromBody;
2834
}
2935

3036
/// <inheritdoc />
@@ -56,12 +62,9 @@ public ValueObjectModelBinderProvider(
5662
}
5763

5864
var validationErrorType = type.GetCustomAttribute<ValueObjectValidationErrorAttribute>()?.Type ?? typeof(ValidationError);
59-
var loggerFactory = context.Services.GetRequiredService<ILoggerFactory>();
6065

61-
var modelBinderType = _trimStringBasedEnums && metadata?.IsEnumeration == true && keyType == typeof(string)
62-
? typeof(TrimmingSmartEnumModelBinder<,>).MakeGenericType(type, validationErrorType)
63-
: typeof(ValueObjectModelBinder<,,>).MakeGenericType(type, keyType, validationErrorType);
64-
var modelBinder = Activator.CreateInstance(modelBinderType, loggerFactory)
66+
var modelBinderType = typeof(ValueObjectModelBinder<,,>).MakeGenericType(type, keyType, validationErrorType);
67+
var modelBinder = Activator.CreateInstance(modelBinderType)
6568
?? throw new Exception($"Could not create an instance of type '{modelBinderType.Name}'.");
6669

6770
return (IModelBinder)modelBinder;

test/Thinktecture.Runtime.Extensions.AspNetCore.Tests/AspNetCore/ModelBinding/ValueObjectModelBinderProviderTests/GetBinder.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,24 @@ public void Should_return_binder_for_int_based_enum()
1818
binder.Should().BeOfType<ValueObjectModelBinder<IntegerEnum, int, ValidationError>>();
1919
}
2020

21+
[Fact]
22+
public void Should_return_binder_for_int_based_struct()
23+
{
24+
var binder = GetModelBinder<TestSmartEnum_Struct_IntBased_Validatable>();
25+
binder.Should().BeOfType<ValueObjectModelBinder<TestSmartEnum_Struct_IntBased_Validatable, int, ValidationError>>();
26+
}
27+
28+
[Fact]
29+
public void Should_return_binder_for_int_based_struct_nullable()
30+
{
31+
var binder = GetModelBinder<TestSmartEnum_Struct_IntBased_Validatable?>();
32+
binder.Should().BeOfType<ValueObjectModelBinder<TestSmartEnum_Struct_IntBased_Validatable, int, ValidationError>>();
33+
}
34+
2135
[Fact]
2236
public void Should_return_binder_for_string_based_enum()
2337
{
24-
GetModelBinder<TestEnum>().Should().BeOfType<TrimmingSmartEnumModelBinder<TestEnum, ValidationError>>();
38+
GetModelBinder<TestEnum>().Should().BeOfType<ValueObjectModelBinder<TestEnum, string, ValidationError>>();
2539
}
2640

2741
[Fact]
@@ -56,7 +70,7 @@ public void Should_return_binder_for_keyless_enum_with_factory()
5670
public void Should_return_string_base_binder_specified_by_ValueObjectFactoryAttribute_smart_enum()
5771
{
5872
var binder = GetModelBinder<EnumWithFactory>();
59-
binder.Should().BeOfType<TrimmingSmartEnumModelBinder<EnumWithFactory, ValidationError>>();
73+
binder.Should().BeOfType<ValueObjectModelBinder<EnumWithFactory, string, ValidationError>>();
6074
}
6175

6276
[Fact]

0 commit comments

Comments
 (0)