Реализация 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, прошу любить и лайкать :)

NSwag vs Swashbuckle for Swagger, Typescript client API generation, and fighting undefined/nulls in DTO

I have already expressed my love with Swagger :) Over time, however, I met Swagger’s sister — NSwag — and fell in love with her even more :)

Long story short, NSwag doesn’t have an IFormFile issues I was solving in Swagger out of the box. But the reason I moved is actually a bit different. We wanted to use OpenAPI definitions for autogenerating clients for our API. Writing something like this:

export interface IFirmwareInfoDto {
    exists: boolean;
    versionString: string;
    releaseDate: string;
    deviceGeneration: DeviceGeneration;
}

async getAvailableFiles(options?: AxiosRequestConfig): Promise<IFirmwareInfoDto[]> {
    const response = await axios.get<IFirmwareInfoDto[]>(
        `/api/firmwareDownload/all`,
        {...defaultOpts, ...options},
    );
    return response.data;
},

by hand for every API action is not only tedious, but also error prone. Thank goodness, there are tools for automatic generation of those based on OpenAPI definitions. We used OpenAPI Generator initially (since Swashbuckle doesn’t have anything built-in), and it was good, but, as usual, devil was in the details. And the devil here was C# enum handling.


Actually, OpenAPI Generator had no issues with handling enum. It just doesn’t handle them at all :) There were just two options, both of which were affecting API itself: we could either express enum elements as string or as number. That was suboptimal. So, having a C# DTO and Action like this:

public class UserDto
{
	public string Name { get; set; }
	public DateTime BirthDate { get; set; }
	public Sex Sex { get; set; }
}

[HttpGet]
public UserDto Get(int id)
{
	return new UserDto()
	{
		Name = "Artur",
		BirthDate = new DateTime(1985, 5, 12),
		Sex = Sex.Male,
	};
}

We got the following Dto generated in Typescript

export interface UserDto {
    name?: string | null;
    birthDate?: Date;
    sex?: Sex;
}

export enum Sex {
    NUMBER_0 = 0,
    NUMBER_1 = 1
} 

We could get Sex enum generated like this:

export enum Sex {
    Male = 'Male',
    Female = 'Female'
} 

But only if we configure our API to behave the same way (i.e. you would receive the following JSON as a result of an http call

{
  'name': 'Artur',
  'birthDate': '2009-02-15T00:00:00Z',
  'sex': 'Male'
}

It’s a debatable topic, whether to use strings or ints for enums in the API, but in some cases (like Flags enums, where several values could be combined) ints are unavoidable.

So, we tried NSwag and thankfully, here’s what we have with it:

export enum Sex {
    Male = 0,
    Female = 1,
}

The key difference, is that NSwag has it’s own client generator, and it takes into account some of extensions that NSwag adds when generating API definition (i.e. string representation of enum values are stored in description fields).

Of course, NSwag also generates nice little (well, not so little :)) typescript client as well:

export class UserClient {
    constructor(baseUrl?: string, instance?: AxiosInstance) {
      // ...
    }

    get(id: number): Promise<UserDto> {
      // ...
    }
}

…and we have lived happily ever after with NSwag, unless we discovered another glitch. It’s called undefined. If we take a closer look at UserDto we could notice, that all properties are undefinable (this little ? sign next to the property name):

export interface IUserDto {
    name?: string | undefined;
    birthDate?: Date;
    sex?: Sex;
}

It’s not only inconvenient (because in typescript you would always have to check, if the value is really defined or not), but it’s also just wrong, because with aforementioned c# definition both birthDate and sex will always be defined.

Luckily, this could be fixed rather easy. We have introduced a special RequireValueTypesSchemaProcessor, that you could integrate into NSwag with oneliner:

options.SchemaProcessors.Add(new RequireValueTypesSchemaProcessor());

and DTOs will be automatically fixed for you:

export interface IUserDto {
    name: string;
    birthDate: Date;
    sex: Sex;
}

Go take a look at the sources if you want more details! You will also receive a bonus — the way to easily get back to generating undefinable for some DTOs where you particularly want that (e.g. for HTTP PATCH requests) :)

As usual, you could get complete C# project along with all generated client examples from github. Check out clients folder for already generated clients, or run yarn nswag-client or yarn swashbuckle-nswag-client to regenerate them.

Real-time синхронизация данных между ASP.NET и React

В сентябре прошлого года я выступал на крупнейшей томской конференции — «Городе АйТи» с докладом про синхронизацию данных.

Мы в МЦЦ Томск сейчас ведем разработку CRM системы, в которой такая синхронизация нашла очень удачное применение. Как я и рассказывал в докладе, мы синхронизируем «словари» — редко меняющиеся значения списковых элементов, а также пользователей и роли — потому что обращение к ним требуется почти на каждом экране.

Конечно, в процессе внедрения столкнулись с некоторыми особенностями, о которых в докладе сказано не было:

  1. Мы синхронизируем DTO, но, например, UserDto зависит не только от сущности User БД. Например, она также содержит информацию о ролях. Соответственно, пришлось синхронизировать обновления User’ов при обновлении связанных сущностей.
  2. При инициализации синхронизации на стороне React необходимо следить за последовательностью действий. Мы делаем так:
    1. Стартуем SignalR-соединение и собираем все пришедшие изменения, но не применяем их.
    2. Запрашиваем текущие значения всех сущностей с Backend (GET /users).
    3. После получения списка сущностей применяем все полученные изменения
    4. Продолжаем синхронизацию в обычном режиме (применяем изменения как только они приходят
  3. Процедуру инициализации соединения повторяем при разрыве SignalR-подключения
  4. Не все изменения в сущностях можно получить до вызова SaveChanges(). Например, идентификаторы новых сущностей (для которых будет выполнена команда INSERT) недоступны до SaveChanges(). Таким образом необходимо производить дополнительную обработку списка изменений после SaveChanges.

Если интересны детали или обновленные примеры кода — пишите, обсудим!

А также можете скачать презентацию как приятный бонус :)

OAuth в SPA или неожиданные сложности интеграции логина через соцсети в React с Asp.Net Core

Это история про то, как казалось бы типичная задача интеграции входа через соц.сети в React/Asp.Net Core приложении может превратиться в длинную сагу и закончиться open-source библиотекой :)

Если читать лень, то можно сразу пойти на гитхаб, где и посмотреть весёлую гифку и прочую документацию по интеграции и использованию, ну а здесь я расскажу чуть подробнее :)


В Asp.Net Core существует замечательная встроенная интеграция с внешними провайдерами аутентификации (OAuth/OpenId и прочее нестандартное), а также сторонние плагины, поддерживающие аутентификацию даже через VK. Однако весь этот механизм подразумевает, что у вас обычное server-side приложение (с forms-авторизацией), и никаких собственных access_token’ов, которые привычны в SPA вам генерироваться не будет.

Вот вот мне и загорелось желание воспользоваться всем этим огромным количеством готовых решений и подружить его с SPA. Как схема работы должна выглядеть в идеале? Согласно AuthCode Flow (который считается рекомендуемым для использования в SPA), это должно выглядеть примерно так:

SPA получает AuthCode у стороннего провайдера и передает его на бэкэнд приложения. Бэкэнд проверяет верность кода, ищет этого пользователя в своей БД (и создает, если требуется) и возвращает на фронтенд access_token, с которым и происходят все дальнейшие запросы.

Для первого шага (получения AuthCode) в SPA существуют готовые реализации в виде, например, реакт-компонентов. Но под каждого OAuth-провайдера они разные, и подбор и настройка могут отнять достаточно много времени. После интеграции написанной библиотеки IdentityOAuthSpaExtensions (и настройки бэкэнд-части согласно инструкциям от майкрософта), запрос AuthCode из SPA будет состоять из двух частей:

  1. Создание обработчиков и подписка на события:
  2.     window.addEventListener("message", this.oAuthCodeReceived, false);
        function oAuthCodeReceived(message) {
            if (message.data && message.data.type === 'oauth-result') {
                if (data.code) {
                    externalAuthSuccess(data.provider, data.code);
                } else {
                    externalAuthError(data.provider, data.error, data.errorDescription);
                }
            }
        }
        function externalAuthSuccess(provider, code) {
            alert(`Provider: ${provider}, code: ${code}`);
        }
        function externalAuthError(provider, error, errorDescription) {
              alert(`Provider: ${provider}, error: ${error}, ${errorDescription}`);
        }
    
  3. Старт процедуры авторизации:
   window.open(`${window.location.protocol}//${window.location.hostname}:${window.location.port}/external-auth/challenge?provider=${provider}`, undefined, 'toolbar=no,menubar=no,directories=no,status=no,width=800,height=600');

В результате этого ваше SPA получит AuthCode стороннего провайдера. В дальнейшем с ним можно делать что угодно ( :)), но в нашем случае, мы хотим получить access_token от нашего бэкэнда, чтобы в дальнейшем все http вызовы совершать с этим access_token’ом. Для этого в библиотеке существует возможность проверки AuthCode («), а также (рекомендуемая) интеграция с IdentityServer в виде extension grant’a. Описание интеграции очень подробно описано на гитхабе

Итоговая схема выглядит как-то так:

Из основных плюсов библиотеки:

  1. Единая точка входа и общий интерфейс интеграции любых Auth-провайдеров (не нужно менять SPA, меняется лишь одна переменная — имя провайдера — при открытии URL авторизации)
  2. Использование стороннего кода для взаимодействия с OAuth (саму библиотеку не придется обновлять, если в сторонних OAuth что-то изменится).
  3. Добавление новых провайдеров происходит стандартным способом (по инструкции для server-side приложений) и не требует модификации самой библиотеки

Пользуйтесь, задавайте вопросы и рассказывайте об успешных сценариях внедрения!

Второй митап .Net разработчиков TomskDotNet#2

Наш второй митап по .NET состоялся!

Немного митапной статистики:

  • 3 докладчика;
  • 80+ участников;
  • 100 печенек и целая куча других вкусностей;
  • 30 банок колы и других напитков;
  • 5 кружек с лабиринтами;
  • 2 отлично проведенных часа жизни и многое многое другое…

Фоточки и всё такое можно посмотреть в нашей группе ВК.

К сожалению, во время митапа возникли технические сложности, которые не позволили нам записать доклады в полной мере. Зато у нас есть презентации докладчиков.

Вот они:

Присоединяйтесь к нам 31 января, на TomskDotNet #3!

Доклад про Entity Framework Core на TomskDotNet#1

В конце ноября мы открыли сезон митапов TomskDotNet, и на первом из них я рассказал про особенности Entity Framework Core, типичные ошибки при работе с этой ORM, оптимизацию запросов и мониторинг.

Презентацию можно посмотреть ниже или скачать по ссылке.


С удовольствием приглашаю вас на наши последующие митапы (ближайший из которых — 20 марта).

Митап .Net разработчиков TomskDotNet#1

29 ноября в Точке Кипения состоялся первый томский митап .NET программистов :)
Организацией, рекламой, фирменными печеньками, сладостями, кока-колой и прочими безобразиями занималась компания МЦЦ Томск (в том числе и я :)).

Что же всё таки было? А вот что:

Видео и презентации по ссылкам выше :)

Всем знаний — и присоединяйтесь к нам 31 января, на TomskDotNet #2!

Доклад про WebAssembly на Городе IT

10 сентября выступил на секции Enterprise конференции Город IT с докладом по WebAssembly.
С моей точки зрения, WebAssembly — это очень перспективное направление, хоть и не готовое к продакшену прямо сейчас. Но нам — разработчикам — просто необходимо внимательно следить за технологиями, которые уже в ближайшее время могут радикально изменить ситуацию в мире фронтэнда.
В докладе рассказывал про появление и развитие WebAssembly, языки и фреймворки, которые его используют, и показал возможности применения Майкрософтоской библиотеки Blazor для разработки SPA с использованием любых существующих .net библиотек (netstandard).
Спасибо всем участникам конференции за интерес и плодотворную дискуссию!

Презентация — уже сегодня на Slideshare, надеюсь на скорое появление видео (ждём организаторов!)
WebAssembly.pptx

OpenApiGenerator: fixing multiple file upload for JS clients

Remember, I love swagger! And since we have it anyway, it’s kinda dumb to write requests to that API manually.
Why would anyone want to come up with something like axios.get(‘/user/123/payments?fromDate=2018-01-01’) without intellisense, type-checking and compile-time verifications? What if someday you change that API’s route?

OpenApiGenerator is a great solution to this. You could easily get typed-clients for your API in almost any language, and make requests as easy as UserApi.get(123, dateFrom).
But that’s a well-known thing, let’s get to the point of this article :)
OpenApiGenerator plays well with almost anything, but file arrays. File arrays were introduced to OpenApi recently, so not all the tools support them yet.
So, let’s say you have a backend method that accepts an array of files (files will be passed with the same name):

[HttpPost()]
public IActionResult UploadFiles(IFormFile[] files)

OpenApiGenerator will provide you with a proper JS method, but it won’t work and will generate something like:

Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryIk1aWkovTmf7LSJX

------WebKitFormBoundaryIk1aWkovTmf7LSJX
Content-Disposition: form-data; name="files"

[object File],[object File]
------WebKitFormBoundaryIk1aWkovTmf7LSJX--

Of course, that isn’t what we want. However, it’s quite easy to monkey-patch the generated ApiClient to achieve what we want. I know monkey-patching is dirty and you should probably fix this with inheritance&overriding, but I’ll leave this to you :) So, here’s the patch:

//this is to make ApiClient correctly handle array of files
const oldBuildCollectionParam = ApiClient.instance.buildCollectionParam;
ApiClient.instance.buildCollectionParam = function(param, collectionFormat) {
    if (param == null) {
        return null;
    }
    if (param.length > 0) {
        if (param[0] instanceof File) {
            return param;
        }
    }
    return oldBuildCollectionParam.call(this, param, collectionFormat);
};

How to make Swagger correctly handle file uploads (IFormFile) in Asp.Net Core

I love Swagger (OpenAPI). It’s a really nice way to share your API definitions, and it is so easy to integrate it into ASP.Net Core applications, that there’s literally no excuse not to use it.
The only glitch in Swagger’s integration is the lack of IFormFile support. So, if you’d like to upload a file you typically write the following action:

[HttpPost()]
public IActionResult UploadFile(IFormFile file)

And that’s how it’s gonna be displayed in Swagger-UI (the real problem is, of course, in wrong swagger.json definition). As you could see, there’s everything but an ability to upload a file (actually, Swagger exposed all properties of IFormFile, which is completely useless).
Swagger

There’s a nice discussion on Stackoverflow regarding this subject, and a couple of answers that solve the problem either via hardcoding or putting an attribute on an action. That didn’t sound like a generic solution for me, so I went down and implemented an Operation Filter that fixes the issue without any additional work.
The integration is quite simple (as with any other Swagger filter)

services.AddSwaggerGen(c => c.OperationFilter<FileUploadOperation>());

And here’s the actual filter. It handles IFormFile action parameters, IFormFile within other classes and even arrays(or any IEnumerables) of IFormFile.

    /// <summary>
    /// adds an ability to upload files via Swagger (and autogenerated js client)
    /// </summary>
    public class FileUploadOperation : IOperationFilter
    {
        public void Apply(Operation operation, OperationFilterContext context)
        {
            if (context.ApiDescription.HttpMethod != "POST")
                return;

            if (operation.Parameters == null)
                operation.Parameters = new List<IParameter>();

            var isFormFileFound = false;

            //try to find IEnumerable<IFormFile> parameters or IFormFile nested in other classes
            foreach (var parameter in operation.Parameters)
            {
                if (parameter is NonBodyParameter nonBodyParameter)
                {
                    var methodParameter =
                    context.ApiDescription.ParameterDescriptions.FirstOrDefault(x => x.Name == parameter.Name);
                    if (methodParameter != null)
                    {
                        if (typeof(IFormFile).IsAssignableFrom(methodParameter.Type))
                        {
                            nonBodyParameter.Type = "file";
                            nonBodyParameter.In = "formData";
                            isFormFileFound = true;
                        }
                        else if (typeof(IEnumerable<IFormFile>).IsAssignableFrom(methodParameter.Type))
                        {
                            nonBodyParameter.Items.Type = "file";
                            nonBodyParameter.In = "formData";
                            isFormFileFound = true;
                        }
                    }
                }
            }



            //try to find IFormFile parameters of method
            var formFileParameters = context.ApiDescription.ActionDescriptor.Parameters
                .Where(x => x.ParameterType == typeof(IFormFile)).ToList();
            foreach (var apiParameterDescription in formFileParameters)
            {
                operation.Parameters.Add(new NonBodyParameter
                {
                    Name = apiParameterDescription.Name,
                    In = "formData",
                    Description = "Upload File",
                    Required = true,
                    Type = "file"
                });
            }

            if (formFileParameters.Any())
            {
                foreach (var propertyInfo in typeof(IFormFile).GetProperties())
                {
                    var parametersWithTheSameName = operation.Parameters.Where(x => x.Name == propertyInfo.Name);
                    operation.Parameters.RemoveRange(parametersWithTheSameName);
                }
            }


            if (isFormFileFound)
                operation.Consumes.Add("multipart/form-data");
        }
    }