Реализация HTTP Patch в ASP.Net Core 3

Классический набор операций над сущностями, который содержит любая система — это CRUD — Create, Read, Update, Delete. Реализация трех из них (Создания, Чтения и Удаления) в ASP.Net Core не вызывает проблем и контроллер REST API (с HTTP методами POST GET и DELETE) для этих операций можно легко и просто создать из встроенного шаблона Visual Studio. Четвертую операцию — обновление — шаблон тоже создает автоматически. Но есть одна тонкость.

По умолчанию для обновления шаблон использует операцию HTTP PUT. Давайте посмотрим на контроллер целиком, и на операцию PUT в отдельности, чтобы разобрать, что же с ней не так.


// PUT: api/Users/5
[HttpPut("{id}")]
public async Task<IActionResult> PutUser(int id, User user)
{
	_context.Entry(user).State = EntityState.Modified;

	await _context.SaveChangesAsync();
	
	return NoContent();
}

(автосгенерированный VisualStudio метод обновления сущности. Обработка ошибок убрана, как не относящаяся к сути вопроса)

К самому HTTP PUT нет никаких претензий, но и по определению и по коду видно, что PUT операция меняет объект целиком. Однако очень часто более полезной оказывается частичное изменение объекта (например, возможность изменить только имя у сущности User). Для этого как раз предназначен HTTP метод PATCH, и сам запрос в этом случае выглядит примерно так:

PATCH https://localhost:5001/api/Users/1
{
  "name": "Artur",
}

В этом и последующих примерах предположим, что сущность User выглядит как-то так:

public class User
{
	public int Id { get; set; }
	public string Name { get; set; }
	public int Age { get; set; }

	public User Mother { get; set; }
	public int? MotherId { get; set; }

	public User Father { get; set; }
	public int? FatherId { get; set; }
}

В теории все хорошо, а как же это будет реализовываться на практике с ASP.Net Core? Сигнатура метода, на первый взгляд, измениться не должна — мы по прежнему принимаем id пользователя и объект:

[HttpPatch("{id}")]
public async Task<IActionResult> PatchUser(int id, PatchUserDto patchUserDto)

А как же будет выглядеть тело метода? Какие значения из пришедшей DTO надо записать в сущность User‘a? Для не-nullable полей (например, int) можно сделать поля в PatchDto — nullable и обновлять только те значения, которые не null (например, поле int Age в DTO можно превратить в int? Age). Но у нас есть и nullable поля — string Name или int? ParentId. Для них непонятно, как определить — было ли это поле передано в DTO или нет. Очень показательный пример запроса:

PATCH https://localhost:5001/api/Users/1
{
  "fatherId": null,
}

fatherId передан в запросе, и значение User.FatherId надо обнулить, а, допустим, MotherId в запросе не передавался и, соответственно, обнулять его не надо. Однако в patchUserDto значения этих двух полей будут идентичными — null.

Как же решать эту типичную проблему? Как ни странно, публичных обсуждений в интернете не слишком много. Есть раз, два, три вопроса на stackoverflow (почитайте их, как минимум чтобы детальнее понимать, какую проблему мы решаем). Из предлагаемых решений:

  1. Вариация на тему nullable-полей: предлагается поля сделать тип Settable<T>, который имеет свойство IsSet == true, если поле присутствовало в http-запросе и false, если поля в запросе не было (и, соответственно, его обнулять у нашей сущности не надо).
    Выглядеть это будет как-то так:
public class SettablePatchUserDto
{
	public string Name { get; set; }

	public Settable<int> MotherId { get; set; }

	public Settable<int> FatherId { get; set; }
}

Решение неплохое, но есть и некоторые минусы:,

  • неприменим для строк
  • нетипичная работа с nullable значениями (к примеру, передать null в поле MotherId можно, но определять это нужно по MotherId.HasValue.
  • внедрение нового незнакомого типа (Settable<T>).

    2. Ответы на второй и третий вопросы в чем-то схожи. Они предлагают расширить PatchDto свойством HashSet<string> PropertiesInHttpRequest, которое будет содержать список свойст, которые были переданы в http-запросе. В этом случае можно будет легко и однозначно определить, нужно ли изменять свойство сущности в БД (в том числе в случае null значений).

Второй путь и мне показался более перспективным, и именно им мы и воспользовались. Любопытствующие могут сразу заглянуть в исходники на гитхаб, где выложен пример проекта с использованием PATCH метода.

Наиболее важными участками кода я поделюсь и здесь. Собственно, PATCH метод:

[HttpPatch("{id}")]
public async Task<IActionResult> PatchUser(int id, PatchUserDto patchUserDto)
{
	var user = await _context.Users.SingleAsync(x => x.Id == id);
	
	// could be as well automated with smth like Automapper if you'd like to
	user.Age = patchUserDto.IsFieldPresent(nameof(user.Age)) ? patchUserDto.Age : user.Age;
	user.Name = patchUserDto.IsFieldPresent(nameof(user.Name)) ? patchUserDto.Name : user.Name;
	user.FatherId = patchUserDto.IsFieldPresent(nameof(user.FatherId)) ? patchUserDto.FatherId : user.FatherId;
	user.MotherId = patchUserDto.IsFieldPresent(nameof(user.MotherId)) ? patchUserDto.MotherId : user.MotherId;
	
	await _context.SaveChangesAsync();
	
	return NoContent();
}

Чтобы этого добиться, нужно кастомизировать ContractResolver в Startup’e таким образом:

services
	.AddControllers()
	.AddNewtonsoftJson(options =>
	{
		options.SerializerSettings.ContractResolver = new PatchRequestContractResolver();
	});

Этот способ показал себя как понятный, очевидный и минимально интрузивный.

Мы его внедрили и это успешно работает в наших проектах. Я так же поделился этим способом на stackoverflow, прошу любить и лайкать :)

Опубликовать в Facebook
Опубликовать в Google Plus

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *