В настоящее время я пытаюсь создать веб-API на основе asp.net core 2.0, и я хотел бы создать вложенный маршрут. В случае запроса на размещение он отправляет часть информации в маршруте, а другую часть в теле.
Требования
Желаемый URL для вызова будет
https://localhost/api/v1/master/42/details
Если мы хотим создать новую деталь ниже нашего мастера 42, я ожидаю отправки данных деталей в теле, в то время как идентификатор мастера выходит из маршрута.
curl -X POST --header 'Content-Type: application/json' \
--header 'Accept: application/json' \
-d '{ \
"name": "My detail name", \
"description": "Just some kind of information" \
}' 'https://localhost/api/v1/master/42/details'
Исходящий ответ на запрос будет
{
"name": "My detail name",
"description": "Just some kind of information",
"masterId": 42,
"id": 47
}
и URL-адрес местоположения в заголовке ответа, например
{
"location": "https://localhost/api/v1/master/42/details/47
}
Проделанная работа
Чтобы заставить это работать, я создал этот контроллер:
[Produces("application/json")]
[Route("api/v1/master/{masterId:int}/details")]
public class MasterController : Controller
{
[HttpPost]
[Produces(typeof(DetailsResponse))]
public async Task<IActionResult> Post([FromBody, FromRoute]DetailCreateRequest request)
{
if(!ModelState.IsValid)
return BadRequest(ModelState);
var response = await Do.Process(request);
return CreatedAtAction(nameof(Get), new { id = response.Id }, response);
}
}
Который использует эти классы:
public class DetailCreateRequest
{
public int MasterId { get; set; }
public string Name { get; set; }
public string Description { get; set; }
}
public class DetailResponse
{
public int Id { get; set; }
public int MasterId { get; set; }
public string Name { get; set; }
public string Description { get; set; }
}
Эта проблема
Пока что большая часть вещей работает так, как ожидалось. Единственное, что действительно не работает, — это слияние MasterId из маршрута с DetailCreateRequest
, исходящим из тела.
Первая попытка: используйте два атрибута параметра
Я попытался объединить эти две вещи с помощью этого вызова действия:
public async Task<IActionResult> Post([FromBody, FromRoute]DetailCreateRequest request)
Но входящий объект имел только MasterId
нуля. Если я изменю порядок двух атрибутов, тогда будет взят только идентификатор из маршрута, а все значения в теле будут проигнорированы (поэтому кажется, что выигрывает первый атрибут).
Вторая попытка: используйте два разных параметра в действии
Другой подход, который я пробовал, заключался в вызове действия:
public async Task<IActionResult> Post([FromRoute]int masterId, [FromBody]DetailCreateRequest request)
Во-первых, это выглядит нормально, потому что теперь у меня есть оба значения в действии контроллера. Но моя большая проблема с этим подходом — проверка модели. Как вы можете видеть в приведенном выше коде, я проверяю ModelState.IsValid
, который был заполнен с помощью некоторых проверок из FluentValidation
, но эти проверки не могут быть выполнены на самом деле, потому что объект не был создан правильно из-за отсутствующего идентификатора мастера.
(Не работает) Идея: создать собственный атрибут с параметрами слияния
Пытался реализовать что-то вроде этого:
public async Task<IActionResult> Post([FromMultiple(Merge.FromBody, Merge.FromRoute)]DetailCreateRequest request)
Если бы у нас уже было что-то подобное, это было бы здорово. Порядок аргументов внутри атрибута определяет порядок, в котором произойдет слияние (и возможная перезапись).
Я уже начал с реализации этого атрибута и создания скелета для нужных IValueProvider
и IValueProviderFactory
. Но, кажется, это довольно много работы. Особенно найти все изящные детали, чтобы это работало без проблем со всем конвейером ядра asp.net и другими библиотеками, которые я использую (например, чванство через swashbuckle).
Итак, мой вопрос будет заключаться в том, существует ли уже какой-то механизм в ядре asp.net для достижения такого слияния, или если кто-нибудь знает об уже существующем решении или о хорошем примере того, как реализовать такого зверя.
Решение на данный момент: Custom ModelBinder
Получив ответ от Merchezatter, я изучаю, как создать связывание пользовательской модели и придумал эту реализацию:
public class MergeBodyAndValueProviderBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
if (bindingContext == null)
throw new ArgumentNullException(nameof(bindingContext));
var body = bindingContext.HttpContext.Request.Body;
var type = bindingContext.ModelMetadata.ModelType;
var instance = TryCreateInstanceFromBody(body, type, out bool instanceChanged);
var bindingFlags = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
var setters = type.GetProperties(bindingFlags).Where(property => property.CanWrite);
foreach (var setter in setters)
{
var result = bindingContext.ValueProvider.GetValue(setter.Name);
if (result != ValueProviderResult.None)
{
try
{
var value = Convert.ChangeType(result.FirstValue, setter.PropertyType);
setter.SetMethod.Invoke(instance, new[] { value });
instanceChanged = true;
}
catch
{ }
}
}
if (instanceChanged)
bindingContext.Result = ModelBindingResult.Success(instance);
return Task.CompletedTask;
}
private static object TryCreateInstanceFromBody(Stream body, Type type, out bool instanceChanged)
{
try
{
using (var reader = new StreamReader(body, Encoding.UTF8, false, 1024, true))
{
var data = reader.ReadToEnd();
var instance = JsonConvert.DeserializeObject(data, type);
instanceChanged = true;
return instance;
}
}
catch
{
instanceChanged = false;
return Activator.CreateInstance(type);
}
}
}
Он пытается десериализовать тело в желаемый тип объекта, а затем пытается применить дополнительные значения из доступных поставщиков значений. Чтобы заставить эту привязку модели работать, мне пришлось украсить целевой класс атрибутом ModelBinderAttribute и сделать MasterId внутренним, чтобы swagger не объявлял об этом, а JsonConvert не десериализовал его:
[ModelBinder(BinderType = typeof(MergeBodyAndValueProviderBinder))]
public class DetailCreateRequest
{
internal int MasterId { get; set; }
public string Name { get; set; }
public string Description { get; set; }
}
В моем контроллере параметры метода действия по-прежнему содержат флаг [FromBody]
, потому что он используется swagger для объявления о том, как метод может быть вызван, но он никогда не будет вызываться, потому что мой связыватель модели имеет более высокий приоритет.
public async Task<IActionResult> Post([FromBody]DetailCreateRequest request)
Так что это не идеально, но пока работает хорошо.