Практическое руководство. Привязка параметров из нескольких источников

В настоящее время я пытаюсь создать веб-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)

Так что это не идеально, но пока работает хорошо.


person Oliver    schedule 14.09.2017    source источник


Ответы (1)


Это выглядит как правильный выбор:

[HttpPost]
[Produces(typeof(DetailsResponse))]
public async Task<IActionResult> Post([FromRoute]int masterId, [FromBody]DetailCreateRequest request) {
    //...
}

Но если у вас есть проблемы с проверкой модели домена, создайте собственный объект Dto без основного идентификатора. В противном случае вы можете использовать пользовательское связывание модели, а затем работать с аргументами из контекстов действия и привязки.

person Merchezatter    schedule 14.09.2017