Генерация PDF-отчетов с помощью ASP.Net и React

Не могу не упомянуть про один очень интересный доклад (и open-source шаблон!) с нашего 6-го .NET-митапа.

Коллега Семён Конончук рассказывал про очень часто встречающуюся задачу — генерацию отчетов.

И если отчеты для «внутреннего пользования»/мониторинга вполне можно генерировать какими-нибудь Графанами или другими html-инструментами, то к отчетам для пользователей совсем другие требования.

Они должны быть красивыми, для них создается специальный дизайн, и mrtg-like набор графиков (см ниже :)) вряд ли кого-то устроит

MRTG graph

Для решения задачи создания таких богатых PDF-отчетов (которые можно распечатать, а можно и просто в виде файлика куда-нибудь положить), а также переиспользования в отчетах кода из существующего фронтэнда и был создан шаблон проекта PDFGenerator. Если кратко, он возвращает PDF при вызове метода ASP.Net контроллера по http. Само содержимое отчета — на Реакте, данные для отчета собирает произвольным способом тот же ASP.Net сервис.

А вот и картинка для привлечения внимания, а для детального описания как оно работает — идите сразу на гитхаб.

img

Выложенный проект — это именно пример/шаблон. Если хотите использовать у себя — скачивайте, меняйте namespace на свои, запускайте — и радуйтесь :)

А если хотите узнать как мы к этому пришли и какие альтернативы рассматривали — вот вам и видос доклада. Приятного просмотра!

NSwag и react-query — автоматическая генерация hooks для вашего API.

Про использование NSwag и автогенерацию API-клиентов я уже писал несколько раз, у нас в МЦЦ это давно внедрено и используется (чаще всего мы генерируем axios-клиентов).

Однако в последнее время я всё чаще использую react-query — это очень удобная библиотека для кэширования и управления http-запросами. Она не заменяет axios/fetch и им подобные, а работает вместе с ними. Типичный сценарий использования react-query выглядит примерно так:

// объявление функции API-вызова. У нас обычно такие функции уже автогенерируются с помощью nswag
const getPostById = async (key, id) => {
  const { data } = await axios.get(
    `https://jsonplaceholder.typicode.com/posts/${id}`
  );
  return data;
};

// оборачивание этой функции в hook с использованием react-query
function useGetPostById(postId) {
  return useQuery(["post", postId], getPostById, {
    enabled: postId,
  });
}

const Post: React.FC<{ postId: number }> = (props) => {
  // вызов хука внури компонента
  const { status, data, error, isFetching } = useGetPostById(props.postId);
 // ...
}

Я не хочу описывать все плюсы работы с библиотекой (в документации написано намного лучше), но просто не могу не упомянуть про очень удобное кэширование, «магическое» обновление кэша, дедубликацию запросов (чтобы ушел только один запрос, когда вам в двух разных местах нужны одинаковые данные) и даже про работу с Suspense.

Вот про все эти плюсы говорить не буду, а расскажу об одном минусе. Код подобный вышеописанному приходится писать руками для каждого GET-запроса, и если API-вызовы у нас уже сгенерированы, то хук (а чаще еще и функцию-ключ к запросу ["post", postId]) приходится писать руками.

Довольно быстро меня это утомило, и в тот же момент пришла мысль — если мы автогенерируем axios-клиентов, то почему бы не автосгенерировать и это тоже?

Сказано — сделано, nswag основан на гибкой системе liquid-шаблонов, которые с легкостью можно переопределить. Так и родился на свет набор шаблонов nswag-react-query.

Использовать его очень просто. Добавляем в react-проект:

yarn add nswag-react-query nswag react-query

и вызываем автогенерацию (предварительно изменив URL swagger-описания и путь к результирующему файлу)

yarn nswag-react-query /input:https://petstore.swagger.io/v2/swagger.json /output:src/api/axios-client.ts /template:Axios /serviceHost:. /generateConstructorInterface:true /markOptionalProperties:true /generateOptionalParameters:true /nullValue:undefined

После этого хуки, подобные вышеописанным, оказываются авотматически сгенерированными и готовыми к использованию!

Смотрите пример, внедряйте у себя, и не тратьте время на написание рутинного кода!

TomskDotNet #6 — первый пост-коронавирусный митап и выход .NET 5!

Наконец-то! После почти года перерыва митапы потихоньку возвращаются!

22 октября мы провели очередной — и очень неплохой — митап (фото и видео — традиционно на сайте). Лично я был очень удивлен такому большому количеству смельчаков, не побоявшихся прийти к нам в оффлайн. Следуя коронавирусным традициям, в этот раз у нас была и онлайн часть, и даже одному из «удаленных» участников достался сертификат от Jetbrains — наш приятный подарок за активность и хорошие вопросы.

У меня на этом митапе был небольшой вступительный доклад про грядущий выход .NET 5, а сейчас, спустя месяц, я могу поделиться и собственным опытом переезда на новую версию с 3.1. Мои небольшие пет-проджекты и чуть большая open-source библиотека авторизации переехали за пару вечеров без каких-либо проблем. По сравнению с миграцией 2.2 -> 3.0 (которая, пожалуй, заняла недели 2-3) — это просто сказка. Единственный breaking-change с которым мне пришлось столкнуться — это небольшое изменение в контексте авторизации. В остальном обновление свелось к чистке Startup.cs и изменению версий зависимостей в .csproj. Идеально! :)

Так что если задумываетесь над обновлением — не задумывайтесь :) В сети куча статей про 20% рост производительности, так что причин откладывать нет ну вообще никаких (ну, разве что помните, что .NET 5 — не LTS и обновляться до .NET 6 придется оперативно после его выхода).

Ну и смотрите видеозапись доклада, если интересно :)

How to create database & user in PostgreSQL

This is a quick reminder to myself about how to quickly create PostgreSQL database (along with the user & password). Since it’s quite usual for me that db name and user name are the same, I’m putting them the same in a script to make search&replace easier.

sudo -u postgres psql
postgres=# CREATE USER user_db_name WITH ENCRYPTED PASSWORD 'mypass';
postgres=# CREATE DATABASE user_db_name WITH OWNER user_db_name ENCODING='UTF-8' LC_COLLATE='en_US.utf8' LC_CTYPE='en_US.utf8' TEMPLATE template0;

usePreserveForm — React-хук для сохранения состояния формы при F5 (на основе react-hook-form)

Мы в МЦЦ Томск уже давно и с удовольствием используем react-hooks. В новых компонентах и проектах мы используем исключительно функциональные компоненты, и от оставшихся класс-компонентов тоже потихоньку отказываемся. При переходе на функциональные компоненты мы с удовольствием сменили и компонент для управления формами и валидацией. Мы остановились на react-hook-form.

Есть множество сравнений с Formik или Redux-Form, в которых react-hook-form безоговорочно выигрывает, прежде всего за счет использования неконтролируемых input’ов. Эта техническая деталь делает UX работы с формами в разы лучше: все ведь сталкивались с «тормозами» React’а при заполнении форм (например, когда CPU компьютера сильно нагружен чем-то другим)? Ну так вот с «неконтролируемыми» input’ами это просто невозможно :) Ну и кроме этого важного преимущества, react-hook-form просто приятен для разработчика — для работы с ним требуется минимум дополнительного кода.

Однако, одно из свойств redux-form при переходе на react-hook-form мы потеряли. Например, когда пользователь частично заполнил форму и нажал F5 — все заполненные поля обнулятся. Это особенно обидно, если форма большая и полей заполнено много. Такая потеря данных случается и не только при нажатии F5, а, например, при случайном закрытии браузера, или перезагрузки компьютера. Очень хотелось бы, чтобы пользовательский ввод в таких случаях сохранялся.

В redux-form такое поведение мы получаем «из коробки», здесь же пришлось добавить немного кода. Встречайте, мой первый npm-пакет use-preserved-form. Использовать пакет очень легко — просто вместо вызова useForm необходимо вызывать usePreservedForm.

Подробные инструкции можно также почитать в github-репозитории или посмотреть на пример вживую.

Приятного использования!

How to add ESLint/Prettier support to create-react-app

This is a short reminder for myself about adding ESLint support to a new create-react-app project with typescript (which is my default frontend template for now).

Instructions include setting up file watchers in VSCode and WebStorm, so all files are automatically formatted on save.

So, here we go.

yarn add @typescript-eslint/parser @typescript-eslint/eslint-plugin prettier eslint-config-prettier eslint-plugin-prettier

Extract the configs from archive into the folder of a project. This will set up watchers and default configs for both ESLint and Prettier.

Don’t forget to restart your IDE to get all the goodies.

Реализация 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 приложений) и не требует модификации самой библиотеки

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