Перейти к содержанию

Аутентификация, авторизация и работа с файлами

В этой главе мы продолжим развивать наше приложение, в основном затрагивая две отдельные темы: аутентификацию пользователей и работу с файлами.

Во-первых, мы реализуем многократно используемый плагин аутентификации JWT, который позволит нам управлять пользователями, аутентификацией и сессиями. Он также будет выступать в качестве уровня авторизации, защищая конечные точки нашего приложения от несанкционированного доступа. Мы также увидим, как декораторы могут раскрывать данные аутентифицированного пользователя внутри обработчиков маршрутов. Затем, перейдя к работе с файлами, мы разработаем специальный плагин, позволяющий пользователям импортировать и экспортировать свои задачи в формате CSV.

В этой главе мы узнаем о следующем:

  • Поток аутентификации и авторизации
  • Создание уровня аутентификации
  • Добавление уровня авторизации
  • Управление загрузками и скачиваниями

Технические требования

Для изучения этой главы вам понадобятся технические требования, упомянутые в предыдущих главах:

Еще раз напомним, что код проекта можно найти на GitHub.

Наконец, пришло время приступить к исследованию. В следующем разделе мы глубоко погрузимся в поток аутентификации в Fastify, понимая все части, необходимые для реализации полного решения.

Поток аутентификации и авторизации

Аутентификация и авторизация обычно являются сложными темами. В зависимости от случая использования конкретные стратегии могут быть или не быть осуществимыми. В этом проекте мы будем реализовывать уровень аутентификации с помощью JSON Web Tokens, широко известных как JWTs.

JWT

Это широко используемый стандарт аутентификации на основе токенов для веб- и мобильных приложений. Это открытый стандарт, который позволяет безопасно передавать информацию между клиентом и сервером. Каждый токен состоит из трех частей. Во-первых, заголовок содержит информацию о типе токена и криптографических алгоритмах, используемых для его подписи и шифрования. Затем в полезной нагрузке содержатся метаданные о пользователе. Наконец, подпись используется для проверки подлинности токена и гарантии того, что он не был подделан.

Прежде чем рассматривать реализацию в Fastify, давайте вкратце рассмотрим, как работает эта аутентификация. Во-первых, API должен предоставлять конечную точку для регистрации. Этот маршрут позволит пользователям создавать новые аккаунты на сервисе. После корректного создания учетной записи пользователь сможет выполнять аутентифицированные операции с сервером. Мы можем разбить их на семь шагов:

  1. Чтобы инициировать процесс аутентификации, пользователь предоставляет серверу свое имя пользователя и пароль через определенную конечную точку.
  2. Сервер проверяет учетные данные и, если они действительны, создает JWT, содержащий метаданные пользователя, используя общий секрет.
  3. Сервер возвращает токен клиенту.
  4. Клиент хранит JWT в безопасном месте. В браузере это обычно локальное хранилище или cookie.
  5. При последующих запросах к серверу клиент отправляет JWT в заголовке Authorization каждого HTTP-запроса.
  6. Сервер проверяет токен, проверяя подпись, и если подпись действительна, он извлекает метаданные пользователя из полезной нагрузки.
  7. Сервер использует идентификатор пользователя для поиска пользователя в базе данных.

Далее запрос обрабатывается уровнем авторизации. Сначала он должен проверить, есть ли у текущего пользователя необходимые разрешения на выполнение действия или доступ к указанному ресурсу. Затем, основываясь на результате операции проверки, сервер может ответить ресурсом или ошибкой HTTP Unauthorized. Существует множество стандартизированных способов реализации авторизации. В этой книге мы реализуем наше простое решение с нуля для наглядности.

Аутентификация и авторизация

Несмотря на то, что эти термины часто используются вместе, они выражают две совершенно разные концепции. Аутентификация описывает, кому разрешен доступ к сервису. С другой стороны, авторизация определяет, какие действия может выполнять пользователь после аутентификации.

Уровни авторизации и аутентификации имеют решающее значение для создания безопасных веб-приложений. Контроль доступа к ресурсам помогает предотвратить несанкционированный доступ и защитить конфиденциальные данные от возможных атак или утечек.

В следующем разделе мы начнем с того места, на котором остановились в Главе 7, реализуя новый плагин для аутентификации на уровне приложения.

Построение слоя аутентификации

Поскольку нам нужно добавить новую нетривиальную функциональность в наше приложение, нам нужно реализовать в основном две части:

  • Плагин аутентификации для генерации токенов, проверки входящих запросов и отзыва старых или неиспользуемых токенов.
  • Куча новых маршрутов для обработки регистрации, аутентификации и жизненного цикла токенов.

Прежде чем перейти непосредственно к коду, необходимо сделать последнее замечание. В фрагментах кода этой главы мы будем использовать новый источник данных под названием userDataSource ([1]). Поскольку он раскрывает только методы createUser ([3]) и readUser ([2]), а его реализация тривиальна, мы не будем показывать его в этой книге. Однако полный код находится в файле ./routes/auth/autohooks.js в репозитории GitHub.

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

Плагин аутентификации

Сначала создайте файл ./plugins/auth.js в корневой папке проекта. Сниппет кода auth.js показывает реализацию плагина:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
const fp = require('fastify-plugin');
const fastifyJwt = require('@fastify/jwt'); // [1]
module.exports = fp(
    async function authenticationPlugin(fastify, opts) {
        const revokedTokens = new Map(); // [2]
        fastify.register(fastifyJwt, {
            // [3]
            secret: fastify.secrets.JWT_SECRET,
            trusted: function isTrusted(
                request,
                decodedToken
            ) {
                return !revokedTokens.has(decodedToken.jti);
            },
        });
        fastify.decorate(
            'authenticate',
            async function authenticate(request, reply) {
                // [4]
                try {
                    await request.jwtVerify(); // [5]
                } catch (err) {
                    reply.send(err);
                }
            }
        );
        fastify.decorateRequest('revokeToken', function () {
            //
            [6];
            revokedTokens.set(this.user.jti, true);
        });
        fastify.decorateRequest(
            'generateToken',
            async function () {
                // [7]
                const token = await fastify.jwt.sign(
                    {
                        id: String(this.user._id),
                        username: this.user.username,
                    },
                    {
                        jti: String(Date.now()),
                        expiresIn:
                            fastify.secrets.JWT_EXPIRE_IN,
                    }
                );
                return token;
            }
        );
    },
    {
        name: 'authentication-plugin',
        dependencies: ['application-config'],
    }
);

Мы создаем и экспортируем плагин Fastify, который предоставляет функции аутентификации с помощью декораторов и библиотеки JWT. Но сначала давайте рассмотрим детали реализации:

  • Нам требуется официальный пакет @fastify/jwt ([1]). Он обрабатывает низкоуровневые примитивы вокруг токенов и позволяет нам сосредоточиться только на логике, необходимой в нашем приложении.
  • Вообще говоря, всегда полезно отслеживать недействительные токены. revokedTokens создает экземпляр Map ([2]), чтобы отслеживать их. Позже мы будем использовать его для запрета недействительных токенов.
  • Мы регистрируем плагин @fastify/jwt на экземпляре Fastify ([3]), передавая переменную окружения JWT_SECRET и функцию isTrusted, которая проверяет, является ли токен доверенным. В следующем разделе мы добавим JWT_SECRET в конфигурацию нашего сервера, чтобы обеспечить ее наличие после загрузки.
  • Мы декорируем экземпляр Fastify функцией authenticate, чтобы убедиться в том, что токен клиента действителен, прежде чем разрешить доступ к защищенным маршрутам. Метод request.jwtVerify() ([5]) берется из @fastify/jwt. Если при проверке возникают ошибки, функция отвечает клиенту с указанием ошибки. В противном случае свойство request.user будет заполнено текущим пользователем.
  • Функция revokeToken добавляется к экземпляру Fastify ([6]). Она добавляет токен в карту недействительных токенов. В качестве ключа недействительности мы используем свойство jti.
  • Функция generateToken создает новый токен из данных пользователя ([7]). Затем мы декорируем запрос этой функцией, чтобы получить доступ к его контексту через ссылку this. Метод fastify.jwt.sign снова предоставляется библиотекой @fastify/jwt.

Благодаря настройке проекта из предыдущих глав, этот плагин будет автоматически зарегистрирован в главном экземпляре Fastify внутри ./apps.js на этапе загрузки.

Пока мы можем оставить этот файл без изменений, поскольку мы начнем использовать декорируем методы внутри нашего приложения в специальном разделе. Теперь пришло время добавить маршруты уровня аутентификации, и мы сделаем это в следующем подразделе.

Маршруты аутентификации

Пришло время реализовать способ взаимодействия пользователей с нашим уровнем аутентификации. Структура папки ./ routes/auth имитирует модуль todos, который мы изучали в Главе 7. Она содержит chemas, autohooks.js и routes.js. Для краткости мы рассмотрим в книге только routes.js. Остальной код прост и его можно найти в репозитории GitHub .

Поскольку код ./routes/auth/routes.js довольно длинный, мы разобьем его на отдельные фрагменты, по одному на определение маршрута. Но сначала, чтобы получить общее представление о плагине, следующий фрагмент содержит весь код, опуская реализации:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const fp = require('fastify-plugin');
const generateHash = require('./generate-hash'); // [1]
module.exports.prefixOverride = ''; // [2]
module.exports = fp(
    async function applicationAuth(fastify, opts) {
        fastify.post('/register', {
            // ... implementation omitted
        });
        fastify.post('/authenticate', {
            // ... implementation omitted
        });
        fastify.get('/me', {
            // ... implementation omitted
        });
        fastify.post('/refresh', {
            // ... implementation omitted
        });
        fastify.post('/logout', {
            // ... implementation omitted
        });
        async function refreshHandler(request, reply) {
            // ... implementation omitted
        }
    },
    {
        name: 'auth-routes',
        dependencies: ['authentication-plugin'], // [3]
        encapsulate: true,
    }
);

Начнем с того, что нам потребуется локальный модуль generate-hash.js ([1]). Мы не хотим хранить пароли пользователей в виде обычного текста, поэтому используем этот модуль для генерации хэша и соли для хранения в базе данных. Опять же, вы можете найти реализацию в репозитории GitHub. Далее, поскольку мы хотим отобразить пять маршрутов, объявленных в теле плагина, непосредственно на корневой путь, мы установили свойство prefixOverride в пустую строку и экспортировали его ([2]). Поскольку мы находимся внутри подпапки ./routes/auth, @fastify/autoload вместо этого смонтировал бы маршруты по пути /auth/. Кроме того, поскольку внутри наших объявлений маршрутов мы полагаемся на методы, которые декорируем в authentication-plugin, мы добавляем его в массив dependencies ([3]). Наконец, мы хотим переопределить поведение по умолчанию fastify-plugin, чтобы изолировать код этого плагина, и поэтому мы передаем true в опции encapsulate.

На этом общий обзор закончен. Далее мы рассмотрим маршрут register.

Маршрут «Регистрация»

Этот маршрут позволяет новым пользователям регистрироваться на нашей платформе. Давайте изучим его реализацию, рассмотрев следующий фрагмент:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
fastify.post('/register', {
    // [1.1]
    schema: {
        body: fastify.getSchema('schema:auth:register'), // [1.2]
    },
    handler: async function registerHandler(
        request,
        reply
    ) {
        const existingUser = await this.usersDataSource.readUser(
            request.body.username
        ); // [1.3]
        if (existingUser) {
            // [1.4]
            const err = new Error(
                'User already registered'
            );
            err.statusCode = 409;
            throw err;
        }
        const { hash, salt } = await generateHash(
            request.body.password
        ); // [1.5]
        try {
            const newUserId = await this.usersDataSource.createUser(
                {
                    // [1.6]
                    username: request.body.username,
                    salt,
                    hash,
                }
            );
            request.log.info(
                { userId: newUserId },
                'User registered'
            );
            reply.code(201);
            return { registered: true }; // [1.7]
        } catch (error) {
            request.log.error(
                error,
                'Failed to register user'
            );
            reply.code(500);
            return { registered: false }; // [1.8]
        }
    },
});

Давайте разберем выполнение предыдущего фрагмента кода:

  • Во-первых, fastify.post используется для объявления нового маршрута для метода HTTP POST с путем /register ([1.1]).
  • Мы указываем схему тела запроса с помощью fastify.getSchema ([1.2]). В книге мы не увидим реализацию этой схемы, но ее, как обычно, можно найти в репозитории GitHub.
  • Переходя к деталям функции-обработчика, мы используем request.body.username для проверки того, зарегистрирован ли уже пользователь в приложении ([1.3]). Если да, то мы выбрасываем 409 HTTP-ошибку ([1.4]). В противном случае request.body.password передается функции generateHash для создания из него хэша и соли ([1.5]).
  • Затем мы используем эти переменные и request.body.username для вставки нового пользователя в БД ([1.6]).
  • Если в процессе создания не возникло ошибок, обработчик отвечает HTTP-кодом 201 и телом { registered: true } ([1.7]). С другой стороны, если ошибки есть, то ответ содержит 500 HTTP-код и { registered: false } тело ([1.8]).

В следующем разделе мы рассмотрим, как пользователи проходят аутентификацию на нашей платформе.

Маршрут аутентификации

Следующий маршрут в списке — это маршрут POST /authenticate. Он позволяет зарегистрированным пользователям сгенерировать новый JWT-токен, используя свой пароль. В следующем фрагменте показана реализация:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
fastify.post('/authenticate', {
    schema: {
        // [2.1]
        body: fastify.getSchema('schema:auth:register'),
        response: {
            200: fastify.getSchema('schema:auth:token'),
        },
    },
    handler: async function authenticateHandler(
        request,
        reply
    ) {
        const user = await this.usersDataSource.readUser(
            request.body.username
        );
        // [2.2]
        if (!user) {
            // [2.3]
            // if we return 404, an attacker can use this to find
            // out which users are registered
            const err = new Error(
                'Wrong credentials provided'
            );
            err.statusCode = 401;
            throw err;
        }
        const { hash } = await generateHash(
            request.body.password,
            user.salt
        ); // [2.4]
        if (hash !== user.hash) {
            // [2.5]
            const err = new Error(
                'Wrong credentials provided'
            );
            err.statusCode = 401;
            throw err;
        }
        request.user = user; // [2.6]
        return refreshHandler(request, reply); // [2.7]
    },
});

Давайте разберем выполнение кода:

  • Мы снова используем схемы аутентификации, которые мы объявили в специальной папке ([2.1]), для защиты и ускорения полезной нагрузки тела маршрута и ответа.
  • Затем, внутри функции-обработчика, мы считываем данные пользователя из базы данных, используя свойство request.body.username ([2.2]).
  • Если пользователь не найден в системе, мы возвращаем 401 вместо 404 с сообщением Wrong credentials provided, чтобы злоумышленники не смогли узнать, какие пользователи зарегистрированы ([2.3]).
  • Теперь мы можем использовать свойство user.salt, полученное из базы данных, для генерации нового хэша ([2.4]). Затем сгенерированный хэш сравнивается с хэшем, сохраненным в источнике данных при регистрации пользователя.
  • Если они не совпадают, функция выбрасывает ту же самую ошибку 401, используя оператор throw ([2.5]).
  • С другой стороны, если проверка прошла успешно, то теперь аутентифицированный пользователь присоединяется к объекту запроса для дальнейшей обработки ([2.6]).
  • Наконец, обработчик вызывает функцию refreshHandler, передавая в качестве аргументов request и reply ([2.7]).

Реализацию refreshHandler мы увидим в следующем разделе, где мы рассмотрим маршрут /refresh.

Маршрут Refresh

После аутентификации маршрут refresh позволяет нашим пользователям генерировать больше токенов, не предоставляя свои имена и пароли. Поскольку мы уже видели, что используем ту же логику внутри маршрута authenticate, мы перенесли обработчик этого маршрута в отдельную функцию. В следующем блоке кода показаны эти детали:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
fastify.post('/refresh', {
    onRequest: fastify.authenticate, // [3.1]
    schema: {
        headers: fastify.getSchema(
            'schema:auth:token-header'
        ),
        response: {
            200: fastify.getSchema('schema:auth:token'),
        },
    },
    handler: refreshHandler, // [3.2]
});
async function refreshHandler(request, reply) {
    const token = await request.generateToken(); // [3.3]
    return { token };
}

Этот маршрут — первый, защищенный слоем аутентификации. Для ее обеспечения мы используем хук fastify. authenticate onRequest, который мы создали в разделе Плагин аутентификации ([3.1]). Функция обработчика маршрута — refreshHandler ([3.2]), которая генерирует новый JWT-токен и возвращает его в качестве ответа. Наконец, обработчик вызывает метод generateToken, который декорируем в объекте запроса плагином аутентификации ([3.3]), а затем возвращает его значение клиенту. Маршрут аутентифицирован, поскольку мы генерируем новый токен из запроса, вызванного уже авторизованными пользователями.

Пришло время рассмотреть, как мы аннулируем пользовательские токены, и мы сделаем это в следующем разделе.

Маршрут выхода из системы

До сих пор мы не использовали карту revokedTokens и метод запроса revokeToken, которые мы создали в разделе Плагин аутентификации. Однако реализация выхода из системы опирается на них. Давайте перейдем к коду:

1
2
3
4
5
6
7
fastify.post('/logout', {
    onRequest: fastify.authenticate, // [4.1]
    handler: async function logoutHandler(request, reply) {
        request.revokeToken(); // [4.2]
        reply.code(204); // [4.3]
    },
});

Поскольку мы хотим, чтобы только аутентифицированные пользователи аннулировали свои токены, маршрут /logout снова защищен хуком аутентификации ([4.1]). Если аутентификация запроса прошла успешно, функция-обработчик отзывает текущий токен, вызывая метод request.revokenToken ([4.2]), который прикреплен к объекту запроса плагином аутентификации, разработанным нами ранее. Этот вызов добавляет токен в карту revokedTokens, используемую внутренним плагином @fastify/jwt для определения недействительных записей. Процесс отзыва токена гарантирует, что токен не сможет быть использован для аутентификации, даже если злоумышленнику удастся его получить. Наконец, обработчик отправляет клиенту пустой ответ 204, указывающий на успешный выход из системы ([4.3]).

На этом раздел о маршрутах аутентификации завершен. В следующем разделе мы реализуем уровень авторизации.

Добавление уровня авторизации

Теперь, когда у нас есть все элементы аутентификации, мы можем перейти к реализации уровня авторизации нашего приложения. Чтобы адекватно защитить наши конечные точки, нам нужно сделать две основные вещи с модулем ./routes/todos из Главы 7:

  • Добавить уровень аутентификации в ./routes/todos/routes.js.
  • Обновите источник данных о делах внутри ./routes/todos/autohook.js.

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

Добавление слоя аутентификации

Начнем с более простой задачи. Как мы уже говорили, это быстрое дополнение к коду Глава 7, которое мы можем увидеть в следующем фрагменте:

1
2
3
4
module.exports = async function todoRoutes(fastify, _opts) {
    fastify.addHook('onRequest', fastify.authenticate); // [1]
    // omitted route implementations from chapter 7
};

Чтобы защитить наши маршруты дел, мы добавляем хук onRequest fastify.authenticate ([1]), который мы ранее использовали для маршрутов аутентификации. Этот хук будет проверять, есть ли в входящем запросе HTTP-заголовок аутентификации, и после его проверки добавит в запрос информационный объект user.

Обновление источника данных о делах

Поскольку наше приложение работает только с одним типом сущностей, уровень авторизации реализовать просто. Идея заключается в том, чтобы запретить пользователям получать доступ к задачам, принадлежащим другим пользователям, и изменять их. До этого момента мы могли рассматривать наше приложение как однопользовательское:

  • Каждая созданная нами задача не имеет никакой ссылки на пользователя, который ее создал.
  • Каждая операция Read, Update и Delete может быть выполнена над каждым элементом любым пользователем.

Как мы уже говорили, правильным местом для решения этих проблем является декоратор mongoDataSource, который мы реализовали в Глава 7. Поскольку у нас теперь два источника данных, один для пользователей, а другой для пунктов дел, мы переименуем mongoDataSource в todosDataSource, чтобы лучше отразить его обязанности. Поскольку нам нужно изменить все методы, чтобы добавить надлежащий уровень авторизации, фрагмент кода получился бы слишком длинным. Вместо того чтобы показать его полностью, в следующем фрагменте показаны изменения только для listTodos и createTodos. Все изменения можно найти в файле ./routes/todos/autohooks.js в репозитории GitHub этой главы:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// ... omitted for brevity
module.exports = fp(async function todoAutoHooks(
    fastify,
    opts
) {
    // ... omitted for brevity
    fastify.decorateRequest('todosDataSource', null); // [1]
    fastify.addHook('onRequest', async (request, reply) => {
        // [2]
        request.todosDataSource = {
            // [3]
            // ... omitted for brevity
            async listTodos({
                filter = {},
                projection = {},
                skip = 0,
                limit = 50,
            } = {}) {
                if (filter.title) {
                    filter.title = new RegExp(
                        filter.title,
                        'i'
                    );
                } else {
                    delete filter.title;
                }
                filter.userId = request.user.id; // [4]
                const data = todos
                    .find(filter, {
                        projection: {
                            ...projection,
                            _id: 0,
                        },
                        limit,
                        skip,
                    })
                    .toArray();
                return data;
            },
            async createTodo({ title }) {
                const _id = new fastify.mongo.ObjectId();
                const now = new Date();
                const userId = request.user.id; // [5]
                const {
                    insertedId,
                } = await todos.insertOne({
                    _id,
                    userId,
                    title,
                    done: false,
                    id: _id,
                    createdAt: now,
                    modifiedAt: now,
                });
                return insertedId;
            },
            // ... omitted for brevity
        };
    });
});

Вместо того чтобы декорировать экземпляр Fastify, как мы это делали в Глава 7, теперь мы переносим логику в объект request. Это изменение позволяет легко получить доступ к объекту user, который наш уровень аутентификации прикрепляет к запросу. Позже мы будем использовать эти данные во всех методах todosDataSource.

Давайте рассмотрим код подробнее:

  • Сначала мы декорируем запрос с помощью todosDataSource, устанавливая его значение в null ([1]). Это делается для оптимизации скорости: если приложение узнает о существовании свойства todosDataSource в начале жизненного цикла запроса, то его создание будет происходить быстрее.
  • Затем мы добавляем хук onRequest ([2]), который будет вызван после того, как fastify.authentication уже добавит пользовательские данные.
  • Внутри хука новый объект, содержащий реализации источников данных, присваивается свойству todosDataSource в запросе ([3]).
  • Далее, listTodos теперь использует request.user.id в качестве поля фильтра ([4]), чтобы вернуть только те данные, которые принадлежат текущему пользователю.
  • Чтобы этот фильтр работал, мы должны добавить свойство userId во вновь созданные задачи ([5]).

Как мы уже говорили, для краткости мы опускаем остальные методы, но они следуют той же схеме, используя userId в качестве фильтра. Опять же, полный код присутствует в репозитории GitHub.

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

Управление загрузками и скачиваниями

Нам нужно добавить еще две функции в наше приложение, и мы сделаем это, разработав специальный плагин Fastify. Первый позволит нашим пользователям загружать CSV-файлы для массового создания задач. Для этого мы будем опираться на две внешние зависимости:

  • @fastify/multipart для загрузки файлов
  • csv-parse для парсинга CSV.

Второй плагин будет предоставлять конечную точку для загрузки элементов в виде CSV-файла. И снова нам понадобится внешняя библиотека csv-stringify для сериализации объектов и создания документа.

Хотя в книге мы разделим код на два фрагмента, полный код можно найти в файле ./routes/todos/files/routes.js. Давайте рассмотрим следующий фрагмент, который содержит логику загрузки файлов и массового создания элементов:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
const fastifyMultipart = require('@fastify/multipart');
const { parse: csvParse } = require('csv-parse');
// ... omitted for brevity
await fastify.register(fastifyMultipart, {
    // [1]
    attachFieldsToBody: 'keyValues',
    sharedSchemaId: 'schema:todo:import:file', // [2]
    async onFile(part) {
        // [3]
        const lines = [];
        const stream = part.file.pipe(
            csvParse({
                // [4]
                bom: true,
                skip_empty_lines: true,
                trim: true,
                columns: true,
            })
        );
        for await (const line of stream) {
            // [5]
            lines.push({
                title: line.title,
                done: line.done === 'true',
            });
        }
        part.value = lines; // [6]
    },
    limits: {
        fieldNameSize: 50,
        fieldSize: 100,
        fields: 10,
        fileSize: 1_000_000,
        files: 1,
    },
});

fastify.route({
    method: 'POST',
    url: '/import',
    handler: async function listTodo(request, reply) {
        const inserted = await request.todosDataSource.createTodos(
            request.body.todoListFile
        ); // [7]
        reply.code(201);
        return inserted;
    },
});
// ... omitted for brevity

Давайте пройдемся по выполнению кода:

  • Сначала мы регистрируем плагин @fastify/multipart в экземпляре Fastify ([1]).
  • Чтобы получить доступ к содержимому загружаемого файла непосредственно из request.body, мы передаем опции attachFieldsToBody и sharedSchemaId ([2]).
  • Далее мы указываем свойство опции onFile ([3]) для обработки входящих потоков. Эта функция будет вызываться для каждого файла во входящем запросе.
  • Затем мы используем библиотеку csvParse для преобразования файла в поток строк ([4]).
  • Цикл for await перебирает каждую разобранную строку ([5]) и преобразует данные из каждой строки, добавляя их в массив lines, после чего мы присваиваем массиву значение part.value ([6]).
  • Наконец, благодаря опциям, которые мы передали в @fastify/multipart, мы можем получить доступ к массиву lines непосредственно из request.body.todoListFile и использовать его в качестве аргумента для метода createTodos ([7]).

И снова мы опускаем реализацию createTodos, которую можно найти в репозитории GitHub.

Теперь мы можем перейти к конечной точке для экспорта задач. В следующем фрагменте показана реализация:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
fastify.route({
    method: 'GET',
    url: '/export',
    schema: {
        querystring: fastify.getSchema(
            'schema:todo:list:export'
        ),
    },
    handler: async function listTodo(request, reply) {
        const { title } = request.query;
        const cursor = await request.todosDataSource.listTodos(
            {
                // [1]
                filter: { title },
                skip: 0,
                limit: undefined,
                asStream: true, // [2]
            }
        );
        reply.header(
            'Content-Disposition',
            'attachment;filename="todo-list.csv"'
        );
        reply.type('text/csv'); //[3]
        return cursor.pipe(
            csvStringify({
                // [4]
                quoted_string: true,
                header: true,
                columns: [
                    'title',
                    'done',
                    'createdAt',
                    'updatedAt',
                    'id',
                ],
                cast: {
                    boolean: (value) =>
                        value ? 'true' : 'false',
                    date: (value) => value.toISOString(),
                },
            })
        );
    },
});

Мы вызываем метод listTodos объекта request.todosDataSource ([1]), чтобы получить список дел, которые соответствуют необязательному параметру title. Если заголовок не передан, то метод вернет все элементы. Более того, благодаря нашему уровню аутентификации, мы знаем, что они будут автоматически отфильтрованы на основе текущего пользователя. Параметр asStream имеет значение true для обработки случаев, когда данные могут быть массивными ([2]). В заголовке Content-Disposition указывается, что ответ является вложением с именем файла todo-list.csv ([3]). Наконец, поток курсора передается в функцию csvStringify для преобразования данных в CSV-файл, который затем возвращается в качестве тела ответа ([4]).

С помощью этого последнего раздела мы значительно расширили возможности нашего приложения, позволив пользователям эффективно импортировать и экспортировать свои задачи.

Резюме

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

В следующей главе мы узнаем, как сделать наше приложение надежным в производстве. Мы будем использовать инструменты, интегрированные в Fastify, чтобы тщательно протестировать наши конечные точки. Мы хотим избежать сбоев в работе наших пользователей из-за некачественного кода, запущенного в производство.

Комментарии