在 ASP .NET Core 中在运行时设置模型绑定表单字段名称
Set model binding form field name at runtime in ASP .NET Core
与其对 DTO 的预期表单字段名称进行硬编码,它们是否有可能在 运行 时间 时动态/确定?
背景:我正在实现一个 webhook,它将使用 form-url 编码的数据调用(webhook 将使用的数据的形状不受我的控制)。
目前我的控制器动作的签名如下所示:
public async Task<IActionResult> PerformSomeAction([FromForm]SomeWebhookRequestDto request)
DTO 大部分具有如下属性:
[ModelBinder(Name = "some_property")]
[BindRequired]
public string SomeProperty { get; set; }
预先知道表单字段名称是 "some_property"(永远不会更改)
但是对于一些属性,我想在运行时间确定表单字段名称:
[ModelBinder(Name = "field[xxx]")]
[BindRequired]
public DateTime? AnotherProperty { get; set; }
注意xxx会被数字代替(会根据URL中的信息变化)。
请注意,如果可以的话,我宁愿避免实施自定义模型绑定器 - 看来我应该能够挂钩 IValueProvider
- 我已经尝试过这样做(添加了 IValueProviderFactory
,注册在位置 0) - 但似乎 [FromForm] 很贪心,所以我的 IValueProvider(Factory)
永远没有机会。
澄清几点:
- 这些请求都有相同的意图(它们都是要求我的 API 做一件特定事情的请求)
- 所有请求都具有相同的 语义 形状(假设有 10 个字段,所有 10 个字段都必须填充该字段的有效数据 - 日期应该去的日期, strings 字符串应该去的地方)。字段值的含义也是一致的。
- 对于名称必须在运行时确定的字段,字段名称将类似于"field[132]"或"field[130]"。这些字段的名称将取决于 URL 中提供的信息 - 我的 API 将执行查找以确定最终名称应该是什么。
- 可能会有大量这样的配置,因此为每个配置设置单独的端点是不可行的。
- 虽然以上有点像噩梦,但除了拒绝参加演出外 这是我无法控制的
您违反了一些好的 API 设计规则,这里只是简单地进行一般设计。
首先,DTO 的全部要点是以一种形式接受数据,因此您可以潜在地以另一种形式操纵它。换句话说,如果您在不同的请求中有不同的数据,则每种数据类型应该有不同的 DTO。
其次,API 的全部意义在于它是一个应用程序编程接口。就像编程中的实际接口一样,它定义了一个契约。客户端必须以定义的格式发送数据,否则服务器将拒绝它。时期。 API 没有责任接受客户端决定发送的任何 willy-nilly 数据并尝试对其进行处理;相反,遵守接口是客户的责任。
第三,如果您确实需要接受不同类型的数据,那么您的 API 需要额外的端点。每个端点应该处理一个资源。客户端不应该向同一个端点提交多种不同类型的资源。因此,应该不需要 "dynamic" 属性。
最后,如果情况只是所有数据都针对相同的资源类型,但只有部分数据可能随任何给定请求提交,您的 DTO 应该仍然 容纳所有潜在的属性。不需要在请求中提供所有可能的属性; modelbinder 将尽其所能。那么,您的操作应该接受 HTTP 方法 PATCH,根据定义,这意味着您只处理特定资源的一部分。
通过删除 [FromForm]
属性并实施 IValueProvider
+ IValueProviderFactory
.
解决
internal class CustomFieldFormValueProvider : IValueProvider
{
private static readonly Regex AliasedFieldValueRegex = new Regex("(?<prefix>.*)(?<fieldNameAlias>\%.*\%)$");
private readonly KeyValuePair<string, string>[] _customFields;
private readonly IRequestCustomFieldResolver _resolver;
private readonly ILogger _logger;
public CustomFieldFormValueProvider(IRequestCustomFieldResolver resolver, KeyValuePair<string, string>[] customFields) {
_resolver = resolver;
_customFields = customFields;
_logger = Log.ForContext(typeof(CustomFieldFormValueProvider));
}
public bool ContainsPrefix(string prefix) {
return AliasedFieldValueRegex.IsMatch(prefix);
}
public ValueProviderResult GetValue(string key) {
var match = AliasedFieldValueRegex.Match(key);
if (match.Success) {
var prefix = match.Groups["prefix"].Value;
var fieldNameAlias = match.Groups["fieldNameAlias"].Value;
// Unfortunately, IValueProvider::GetValue does not have an async variant :(
var customFieldNumber = Task.Run(() => _resolver.Resolve(fieldNameAlias)).Result;
var convertedKey = ConvertKey(prefix, customFieldNumber);
string customFieldValue = null;
try {
customFieldValue = _customFields.Single(pair => pair.Key.Equals(convertedKey, StringComparison.OrdinalIgnoreCase)).Value;
} catch (InvalidOperationException) {
_logger.Warning("Could not find a value for '{FieldNameAlias}' - (custom field #{CustomFieldNumber} - assuming null", fieldNameAlias, customFieldNumber);
}
return new ValueProviderResult(new StringValues(customFieldValue));
}
return ValueProviderResult.None;
}
private string ConvertKey(string prefix, int customFieldNumber) {
var path = prefix.Split('.')
.Where(part => !string.IsNullOrWhiteSpace(part))
.Concat(new[] {
"fields",
customFieldNumber.ToString()
})
.ToArray();
return path[0] + string.Join("", path.Skip(1).Select(part => $"[{part}]"));
}
}
public class CustomFieldFormValueProviderFactory : IValueProviderFactory
{
private static readonly Regex
CustomFieldRegex = new Regex(".*[\[]]?fields[\]]?[\[]([0-9]+)[\]]$");
public Task CreateValueProviderAsync(ValueProviderFactoryContext context) {
// Get the key/value pairs from the form which look like our custom fields
var customFields = context.ActionContext.HttpContext.Request.Form.Where(pair => CustomFieldRegex.IsMatch(pair.Key))
.Select(pair => new KeyValuePair<string, string>(pair.Key, pair.Value.First()))
.ToArray();
// Pull out the service we need
if (!(context.ActionContext.HttpContext.RequestServices.GetService(typeof(IRequestCustomFieldResolver)) is IRequestCustomFieldResolver resolver)) {
throw new InvalidOperationException($"No service of type {typeof(IRequestCustomFieldResolver).Name} available");
}
context.ValueProviders.Insert(0, new CustomFieldFormValueProvider(resolver, customFields));
return Task.CompletedTask;
}
}
与其对 DTO 的预期表单字段名称进行硬编码,它们是否有可能在 运行 时间 时动态/确定?
背景:我正在实现一个 webhook,它将使用 form-url 编码的数据调用(webhook 将使用的数据的形状不受我的控制)。
目前我的控制器动作的签名如下所示:
public async Task<IActionResult> PerformSomeAction([FromForm]SomeWebhookRequestDto request)
DTO 大部分具有如下属性:
[ModelBinder(Name = "some_property")]
[BindRequired]
public string SomeProperty { get; set; }
预先知道表单字段名称是 "some_property"(永远不会更改)
但是对于一些属性,我想在运行时间确定表单字段名称:
[ModelBinder(Name = "field[xxx]")]
[BindRequired]
public DateTime? AnotherProperty { get; set; }
注意xxx会被数字代替(会根据URL中的信息变化)。
请注意,如果可以的话,我宁愿避免实施自定义模型绑定器 - 看来我应该能够挂钩 IValueProvider
- 我已经尝试过这样做(添加了 IValueProviderFactory
,注册在位置 0) - 但似乎 [FromForm] 很贪心,所以我的 IValueProvider(Factory)
永远没有机会。
澄清几点:
- 这些请求都有相同的意图(它们都是要求我的 API 做一件特定事情的请求)
- 所有请求都具有相同的 语义 形状(假设有 10 个字段,所有 10 个字段都必须填充该字段的有效数据 - 日期应该去的日期, strings 字符串应该去的地方)。字段值的含义也是一致的。
- 对于名称必须在运行时确定的字段,字段名称将类似于"field[132]"或"field[130]"。这些字段的名称将取决于 URL 中提供的信息 - 我的 API 将执行查找以确定最终名称应该是什么。
- 可能会有大量这样的配置,因此为每个配置设置单独的端点是不可行的。
- 虽然以上有点像噩梦,但除了拒绝参加演出外 这是我无法控制的
您违反了一些好的 API 设计规则,这里只是简单地进行一般设计。
首先,DTO 的全部要点是以一种形式接受数据,因此您可以潜在地以另一种形式操纵它。换句话说,如果您在不同的请求中有不同的数据,则每种数据类型应该有不同的 DTO。
其次,API 的全部意义在于它是一个应用程序编程接口。就像编程中的实际接口一样,它定义了一个契约。客户端必须以定义的格式发送数据,否则服务器将拒绝它。时期。 API 没有责任接受客户端决定发送的任何 willy-nilly 数据并尝试对其进行处理;相反,遵守接口是客户的责任。
第三,如果您确实需要接受不同类型的数据,那么您的 API 需要额外的端点。每个端点应该处理一个资源。客户端不应该向同一个端点提交多种不同类型的资源。因此,应该不需要 "dynamic" 属性。
最后,如果情况只是所有数据都针对相同的资源类型,但只有部分数据可能随任何给定请求提交,您的 DTO 应该仍然 容纳所有潜在的属性。不需要在请求中提供所有可能的属性; modelbinder 将尽其所能。那么,您的操作应该接受 HTTP 方法 PATCH,根据定义,这意味着您只处理特定资源的一部分。
通过删除 [FromForm]
属性并实施 IValueProvider
+ IValueProviderFactory
.
internal class CustomFieldFormValueProvider : IValueProvider
{
private static readonly Regex AliasedFieldValueRegex = new Regex("(?<prefix>.*)(?<fieldNameAlias>\%.*\%)$");
private readonly KeyValuePair<string, string>[] _customFields;
private readonly IRequestCustomFieldResolver _resolver;
private readonly ILogger _logger;
public CustomFieldFormValueProvider(IRequestCustomFieldResolver resolver, KeyValuePair<string, string>[] customFields) {
_resolver = resolver;
_customFields = customFields;
_logger = Log.ForContext(typeof(CustomFieldFormValueProvider));
}
public bool ContainsPrefix(string prefix) {
return AliasedFieldValueRegex.IsMatch(prefix);
}
public ValueProviderResult GetValue(string key) {
var match = AliasedFieldValueRegex.Match(key);
if (match.Success) {
var prefix = match.Groups["prefix"].Value;
var fieldNameAlias = match.Groups["fieldNameAlias"].Value;
// Unfortunately, IValueProvider::GetValue does not have an async variant :(
var customFieldNumber = Task.Run(() => _resolver.Resolve(fieldNameAlias)).Result;
var convertedKey = ConvertKey(prefix, customFieldNumber);
string customFieldValue = null;
try {
customFieldValue = _customFields.Single(pair => pair.Key.Equals(convertedKey, StringComparison.OrdinalIgnoreCase)).Value;
} catch (InvalidOperationException) {
_logger.Warning("Could not find a value for '{FieldNameAlias}' - (custom field #{CustomFieldNumber} - assuming null", fieldNameAlias, customFieldNumber);
}
return new ValueProviderResult(new StringValues(customFieldValue));
}
return ValueProviderResult.None;
}
private string ConvertKey(string prefix, int customFieldNumber) {
var path = prefix.Split('.')
.Where(part => !string.IsNullOrWhiteSpace(part))
.Concat(new[] {
"fields",
customFieldNumber.ToString()
})
.ToArray();
return path[0] + string.Join("", path.Skip(1).Select(part => $"[{part}]"));
}
}
public class CustomFieldFormValueProviderFactory : IValueProviderFactory
{
private static readonly Regex
CustomFieldRegex = new Regex(".*[\[]]?fields[\]]?[\[]([0-9]+)[\]]$");
public Task CreateValueProviderAsync(ValueProviderFactoryContext context) {
// Get the key/value pairs from the form which look like our custom fields
var customFields = context.ActionContext.HttpContext.Request.Form.Where(pair => CustomFieldRegex.IsMatch(pair.Key))
.Select(pair => new KeyValuePair<string, string>(pair.Key, pair.Value.First()))
.ToArray();
// Pull out the service we need
if (!(context.ActionContext.HttpContext.RequestServices.GetService(typeof(IRequestCustomFieldResolver)) is IRequestCustomFieldResolver resolver)) {
throw new InvalidOperationException($"No service of type {typeof(IRequestCustomFieldResolver).Name} available");
}
context.ValueProviders.Insert(0, new CustomFieldFormValueProvider(resolver, customFields));
return Task.CompletedTask;
}
}