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

Создание RESTful API

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

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

Глава будет разделена на несколько основных разделов, начиная с определения маршрутов, затем подключения к источникам данных, реализации маршрутов, защиты конечных точек и применения принципа Не повторяйся (DRY) для повышения эффективности нашего кода.

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

  • Как объявлять и реализовывать маршруты с помощью плагинов Fastify
  • Как добавлять JSON-схемы для защиты конечных точек
  • Как загружать схемы маршрутов
  • Как использовать декораторы для реализации паттерна DRY

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

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

Все фрагменты кода для этой главы доступны на GitHub.

Итак, давайте приступим к работе и создадим надежное и эффективное приложение, которое можно будет использовать в качестве эталона для будущих проектов!

Конспект приложения

В этом разделе мы начнем создавать RESTful-приложение для работы с делами. Приложение позволит пользователям выполнять операции Создание, Чтение, Обновление и Удаление (CRUD) над своим списком дел, используя такие HTTP-методы, как GET, POST, PUT и DELETE. Помимо этих операций, мы реализуем одно настраиваемое действие для пометки задач как «выполненных».

Что такое RESTful?

Передача состояния представления (RESTful) — это архитектурный стиль для создания веб-сервисов, которые следуют четко определенным ограничениям и принципам. Это подход для создания масштабируемых и гибких веб-интерфейсов, которые могут использовать различные клиенты. В архитектуре RESTful ресурсы идентифицируются Uniform Resource Identifiers (URI). Операции, выполняемые с этими ресурсами, основаны на предопределенных методах HTTP (GET, POST, PUT, DELETE и т.д.). Каждый вызов API не имеет статических данных и содержит всю информацию, необходимую для выполнения операции.

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

Определение маршрутов

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

Следующий фрагмент routes/todos/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
'use strict';
module.exports = async function todoRoutes(fastify, _opts) {
    // [1]
    fastify.route({
        method: 'GET',
        url: '/',
        handler: async function listTodo(request, reply) {
            return { data: [], totalCount: 0 }; // [2]
        },
    });
    fastify.route({
        method: 'POST',
        url: '/',
        handler: async function createTodo(request, reply) {
            return { id: '123' }; // [3]
        },
    });
    fastify.route({
        method: 'GET',
        url: '/:id',
        handler: async function readTodo(request, reply) {
            return {}; // [4]
        },
    });
    fastify.route({
        method: 'PUT',
        url: '/:id',
        handler: async function updateTodo(request, reply) {
            reply.code(204); // [5]
        },
    });
    fastify.route({
        method: 'DELETE',
        url: '/:id',
        handler: async function deleteTodo(request, reply) {
            reply.code(204); // [6]
        },
    });
    fastify.route({
        method: 'POST',
        url: '/:id/:status',
        handler: async function changeStatus(
            request,
            reply
        ) {
            reply.code(204); // [7]
        },
    });
};

Наш модуль exports ([1]) представляет собой плагин Fastify под названием todoRoutes. Внутри него мы определили шесть маршрутов, пять для основных CRUD-операций и одно дополнительное действие для пометки задач как выполненных. Давайте вкратце рассмотрим каждый из них:

  • listTodo GET /: Реализует операцию List. Возвращает массив задач и общее количество элементов ([2]).
  • createTodo POST /: Реализует операцию Создать. Она создает задачу из данных тела request и возвращает id созданного элемента ([3]).
  • readTodo GET /:id: Реализует операцию Чтение. Возвращает задачу, соответствующую параметру :id ([4]).
  • updateTodo PUT /:id: Выполняет операцию Обновить. Она обновляет элемент дел, соответствующий параметру :id, используя данные тела request ([5]).
  • deleteTodo DELETE /:id: Реализует операцию Delete. Она удаляет задачу, соответствующую параметру :id ([6]).
  • changeStatus POST /:id/:status: Выполняет заказное действие. Оно помечает задачу как «выполненную» или «не выполненную» ([7]).

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

Теперь давайте посмотрим, как использовать этот модуль плагина в нашем приложении.

Регистрация маршрутов

Простое объявление плагина routes не добавляет никакой ценности нашему приложению. Поэтому перед использованием нам необходимо его зарегистрировать. К счастью, у нас уже есть все необходимое из предыдущей главы для автоматической регистрации маршрутов. Следующий отрывок из файла apps.js показывает важную часть:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// ...
fastify.register(AutoLoad, {
    dir: path.join(__dirname, 'routes'), // [1]
    indexPattern: /.*routes(\.js|\.cjs)$/i, // [2]
    ignorePattern: /.*\.js/,
    autoHooksPattern: /.*hooks(\.js|\.cjs)$/i,
    autoHooks: true,
    cascadeHooks: true,
    options: Object.assign({}, opts),
});
// ...

Этот фрагмент кода использует плагин под названием @fastify/autoload для автоматической загрузки маршрутов и хуков из указанной директории.

Мы указали папку routes ([1]) в качестве пути, где находятся наши маршруты, а затем определили шаблон регулярного выражения ([2]) для идентификации файлов маршрутов. Поэтому, чтобы Fastify выбрал наш предыдущий файл routes.js, мы должны сохранить его в файле ./routes/todos/routes.js.

Вам может быть интересно, почему мы добавили эту подпапку todos в наш путь. У AutoLoad есть еще одно замечательное поведение — она автоматически загружает все подпапки указанного пути, используя имя папки в качестве префикса пути для маршрутов, которые мы определяем. Наши обработчики будут иметь префикс todos при регистрации в приложении Fastify. Эта возможность помогает нам организовать наш код в подпапках, не заставляя нас определять префикс вручную. Давайте сделаем пару вызовов маршрутов нашего приложения, чтобы привести несколько конкретных примеров.

Нам нужно открыть два терминала: первый — для запуска приложения, второй — для выполнения вызовов с помощью curl.

В первом терминале перейдите в корень проекта и введите npm start, как показано здесь:

1
2
3
$ npm start
{"level":30, "time":1679152261083, "pid":92481, "hostname": "dev.
local", "msg": "Server listening at http://127.0.0.1:3000"}

Теперь, когда сервер запущен, мы можем оставить первый терминал открытым и перейти ко второму. Мы готовы к выполнению вызовов API:

1
2
3
4
$ curl http://127.0.0.1:3000/todos
{"data":[],"totalCount":0}%
$ curl http://127.0.0.1:3000/todos/1
{}%

В предыдущем фрагменте видно, что мы сделали два вызова. В первом мы успешно вызвали обработчик listTodo, а во втором — readTodo.

Источник данных и модель

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

Благодаря системе плагинов Fastify мы можем использовать клиент базы данных внутри нашего плагина маршрута, поскольку экземпляр, который мы получаем в качестве первого аргумента, декорируем свойством mongo. Кроме того, мы можем присвоить коллекцию 'todos' локальной переменной и использовать ее в обработчиках маршрутов:

1
2
3
4
5
6
7
8
'use strict';
module.exports = async function todoAutoHooks(
    fastify,
    _opts
) {
    const todos = fastify.mongo.db.collection('todos');
    // ... rest of the route plugin implementation ...
};

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

1
2
3
4
5
6
7
8
interface Todo
   _id: ObjectId, // [1]
   id: ObjectId, // [2]
   title: string, // [3]
   done: boolean, // [4]
   createdAt: Date, // [5]
   modifiedAt: Date, // [6]
}

Давайте посмотрим на свойства, которые мы только что определили:

  • _id ([1]) и id ([2]) имеют одинаковое значение. Мы добавляем свойство id, чтобы не раскрывать никакой информации о нашей базе данных. Свойство _id определяется и используется в основном серверами MongoDB.
  • Свойство title ([3]) является настраиваемым пользователем и содержит название задачи.
  • Свойство done ([4]) сохраняет статус задачи. Задача завершена, если ее значение равно true. В противном случае задача все еще находится в процессе выполнения.
  • Свойства createdAt ([5]) и modifiedAt ([6]) автоматически добавляются приложением для отслеживания времени создания и последнего изменения элемента.

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

Реализация маршрутов

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

Уникальные идентификаторы

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

Мы начнем с createTodo, поскольку наличие элементов, сохраненных в базе данных, поможет нам реализовать и протестировать другие обработчики.

createTodo

Как следует из названия, эта функция позволяет пользователям создавать новые задачи и сохранять их в базе данных. Следующий фрагмент кода определяет маршрут, который обрабатывает POST запрос, когда пользователь переходит по пути /todos/:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
fastify.route({
    method: 'POST',
    url: '/',
    handler: async function createTodo(request, reply) {
        // [1]
        const _id = new this.mongo.ObjectId(); // [2]
        const now = new Date(); // [3]
        const createdAt = now;
        const modifiedAt = now;
        const newTodo = {
            _id,
            id: _id,
            ...request.body, // [4]
            done: false,
            createdAt,
            modifiedAt,
        };
        await todos.insertOne(newTodo); // [5]
        reply.code(201); // [6]
        return { id: _id };
    },
});

При вызове маршрута функция-обработчик ([1]) генерирует новый уникальный идентификатор ([2]) для элемента дел и устанавливает даты создания и модификации ([3]) на текущее время. Затем обработчик создает новый объект дел из тела запроса ([4]). Затем объект вставляется в базу данных с помощью коллекции todos, которую мы создали в начале работы над плагином маршрутов ([5]). Наконец, функция отправляет ответ с кодом состояния 201 ([6]), указывающим на то, что ресурс был создан, и телом, содержащим ID вновь созданного элемента.

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

В первом терминале запустите сервер:

1
2
3
$ npm start
{"level":30, "time":1679152261083, "pid":92481, "hostname": "dev.
local", "msg": "Server listening at http://127.0.0.1:3000"}

Теперь во втором случае мы можем использовать curl для выполнения запроса:

1
2
3
$ curl -X POST http://localhost:3000/todos -H "Content-Type:
application/json" -d '{"title": "my first task"}'
{"id": "64172b029eb96017ce60493f"}%

Мы видим, что приложение вернуло id только что созданного элемента. Поздравляем! Вы реализовали свой первый рабочий маршрут!

В следующем подразделе мы будем читать список задач из базы данных!

listTodo

Теперь, когда наш первый элемент сохранен в базе данных, давайте реализуем маршрут со списком. Он позволит нам вывести список всех задач с их общим количеством.

Мы можем начать непосредственно с выдержки из routes/todos/routes.js:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
fastify.route({
    method: 'GET',
    url: '/',
    handler: async function listTodo(request, reply) {
        const { skip, limit, title } = request.query; // [1]
        const filter = title
            ? { title: new RegExp(title, 'i') }
            : {}; // [2]
        const data = await todos
            .find(filter, {
                limit,
                skip,
            }) // [3]
            .toArray();
        const totalCount = await todos.countDocuments(
            filter
        );
        return { data, totalCount }; // [4]
    },
});

Внутри функции listTodo объект запроса используется для извлечения параметров запроса ([1]), таких как skip, limit и title. Параметр title используется для создания фильтра регулярных выражений для поиска элементов дел, названия которых частично совпадают с параметром title ([2]). Если параметр title не указан, то filter будет пустым объектом, возвращающим все пункты.

Затем переменная data заполняется элементами дел, которые соответствуют filter, путем вызова todos.find() и передачи его в качестве параметра. Кроме того, передаются параметры запроса limit и skip, чтобы реализовать правильную распаковку ([3]). Поскольку драйвер MongoDB возвращает курсор, мы преобразуем результат в массив с помощью метода toArray().

Пагинация

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

Переменная totalCount вычисляется путем вызова todos.countDocuments() с тем же объектом filter, чтобы клиент API мог правильно реализовать пагинацию. Наконец, функция-обработчик возвращает объект, содержащий массив данных и число totalCount ([4]).

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

В первом терминале запустите сервер:

1
2
3
$ npm start
{"level":30, "time":1679152261083, "pid":92481, "hostname": "dev.
local", "msg": "Server listening at http://127.0.0.1:3000"}

Теперь во втором случае мы можем использовать curl для выполнения запроса:

1
2
3
4
5
$ curl http://127.0.0.1:3000/todos
{"data":[{"_id": "64172b029eb96017ce60493f", "title": "my
first task", "done":false, "id": "64172b029eb96017ce60493f",
"createdAt": "2023-03-19T15:32:18.314Z", "modifiedAt":
"2023-03-19T15:32:18.314Z"}], "totalCount":1}%

Мы видим, что все работает, как и ожидалось, и «моя первая задача» является единственным элементом, возвращаемым в массиве data. Кроме того, totalCount правильно равно 1.

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

readTodo

Этот RESTful-маршрут позволяет клиентам получить из базы данных один элемент дел, основываясь на его уникальном идентификаторе id. Следующий фрагмент иллюстрирует реализацию функции-обработчика:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
fastify.route({
    method: 'GET',
    url: '/:id', // [1]
    handler: async function readTodo(request, reply) {
        const todo = await todos.findOne(
            {
                _id: new this.mongo.ObjectId(
                    request.params.id
                ),
                // [2]
            },
            { projection: { _id: 0 } }
        ); // [3]
        if (!todo) {
            reply.code(404);
            return { error: 'Todo not found' }; // [4]
        }
        return todo; // [5]
    },
});

Синтаксис /:id в свойстве url ([1]) указывает на то, что этот параметр маршрута будет заменен определенным значением, когда клиент вызовет этот маршрут. На самом деле, функция-обработчик сначала извлекает этот id из объекта request.params и создает из него новый ObjectId, используя this.mongo.ObjectId() ([2]). Затем он использует метод findOne коллекции todos для получения задачи с совпадающим _id. Мы исключаем поле _id из результата, используя опцию projection, чтобы не разглашать информацию об используемом нами сервере базы данных ([3]). На самом деле, MongoDB — единственный, кто использует поле _id в качестве первичной ссылки.

Если подходящий элемент дел найден, он возвращается в качестве ответа ([5]). В противном случае обработчик устанавливает код состояния HTTP на 404 и возвращает объект ошибки с сообщением о том, что задача не найдена ([4]).

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

1
2
3
4
$ curl http://127.0.0.1:3000/todos/64172b029eb96017ce60493f
{"title": "my first task", "done":false, "id":
"64172b029eb96017ce60493f", "createdAt": "2023-03-19T15:32:18.314Z",
"modifiedAt": "2023-03-19T15:32:18.314Z"}%

И снова все работает, как и ожидалось. Нам удалось передать ID задачи, которую мы добавили в базу данных, в качестве параметра маршрута и получить в качестве ответа задачу с заголовком «моя первая задача».

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

updateTodo

Следующий фрагмент кода добавляет маршрут, который обрабатывает запросы PUT для обновления задачи, уже сохраненной в базе данных:

 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
fastify.route({
    method: 'PUT',
    url: '/:id', // [1]
    handler: async function updateTodo(request, reply) {
        const res = await todos.updateOne(
            {
                _id: new fastify.mongo.ObjectId(
                    request.params.id
                ),
            }, // [2]
            {
                $set: {
                    ...request.body, // [3]
                    modifiedAt: new Date(),
                },
            }
        );
        if (res.modifiedCount === 0) {
            // [4]
            reply.code(404);
            return { error: 'Todo not found' };
        }
        reply.code(204); // [5]
    },
});

Мы снова используем параметр :id, чтобы определить, какой элемент пользователь хочет изменить ([1]).

Внутри обработчика маршрута мы используем метод клиента MongoDB updateOne() для обновления элемента дел в базе данных. Мы снова используем свойство request.params.id, чтобы создать объект фильтрации для соответствия задаче с указанным _id ([2]). Затем мы используем оператор $set для частичного обновления элемента новыми значениями из request.body. Мы также устанавливаем свойство modifiedAt в текущее время ([3]).

После завершения обновления проверяется свойство modifiedCount результата, чтобы узнать, было ли обновление успешным ([4]). Если ни один документ не был изменен, возвращается ошибка 404. Если обновление прошло успешно, то выдается код состояния 204, указывающий на успешное завершение обновления без возврата тела ([5]).

Запустив сервер обычным способом, мы можем протестировать только что реализованный маршрут с помощью терминала и curl:

1
2
3
$ curl -X PUT http://localhost:3000/todos/64172b029eb96017ce60493f
-H "Content-Type: application/json" -d '{"title": "my first task
updated"}'

На этот раз мы передаем аргумент -X в curl, чтобы использовать HTTP-метод PUT. Затем в теле запроса мы изменяем название наших задач и передаем уникальный ID задачи в качестве параметра route. Одна вещь, которая может вызвать замешательство, — это то, что сервер не вернул тело запроса, но, глядя на возвращаемое значение updateTodo, это не должно быть сюрпризом.

Мы можем проверить, правильно ли был обновлен элемент дел, вызвав маршрут readTodo:

1
2
3
4
$ curl http://127.0.0.1:3000/todos/64172b029eb96017ce60493f
{"title": "my first task updated", "done":false, "id":
"64172b029eb96017ce60493f", "createdAt": "2023-03-19T15:32:18.314Z",
"modifiedAt": "2023-03-19T17:41:09.520Z"}%

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

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

deleteTodo

Следуя соглашениям RESTful, следующий фрагмент кода определяет маршрут Fastify, который позволяет пользователю удалить задачу, передавая ее уникальный :id в качестве параметра запроса:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
fastify.route({
    method: 'DELETE',
    url: '/:id', // [1]
    handler: async function deleteTodo(request, reply) {
        const res = await todos.deleteOne({
            _id: new fastify.mongo.ObjectId(
                request.params.id
            ),
        }); // [2]
        if (res.deletedCount === 0) {
            // [3]
            reply.code(404);
            return { error: 'Todo not found' };
        }
        reply.code(204); // [4]
    },
});

Объявив HTTP-метод DELETE, мы передаем параметр :id в качестве пути маршрута, чтобы можно было определить, какой элемент нужно удалить ([1]).

Внутри функции deleteTodo мы создаем фильтр из свойства request.params.id ([2]) и передаем его методу deleteOne коллекции todos для удаления задач с этим уникальным идентификатором. После возврата этого вызова мы проверяем, действительно ли элемент был удален из базы данных. Если ни один документ не был удален, обработчик возвращает ошибку 404 ([3]). С другой стороны, если удаление прошло успешно, мы возвращаем пустое тело с кодом состояния 204, чтобы показать, что операция завершилась успешно ([4]).

Тестирование вновь добавленного маршрута, как всегда, очень простое — мы используем тот же терминал и curl, что и для предыдущих маршрутов.

Запустив сервер в одном терминале, мы выполняем следующую команду в другом:

1
2
3
$ curl -X DELETE http://localhost:3000/todos/64172b029eb96017ce60493f
$ curl http://127.0.0.1:3000/todos/64172b029eb96017ce60493f
{"error": "Todo not found"}%

Здесь мы выполняем два разных вызова. Первый удаляет сущность в базе данных и возвращает пустой ответ. Второй, напротив, проверяет, удалил ли предыдущий вызов ресурс. Поскольку он возвращает ошибку not found, мы уверены, что удалили его.

Ни одно приложение для составления списка дел не будет полным без возможности отмечать задачи как «выполненные» или перемещать их обратно в «прогресс», и именно это мы и добавим.

changeStatus

Это наш первый маршрут, который не следует принципам CRUD. Вместо этого он представляет собой пользовательскую логику, выполняющую определенную операцию над одной задачей. Следующий отрывок из routes/todos/routes.js показывает действие POST, которое при вызове помечает задачу как «выполненную» или «не выполненную», в зависимости от ее состояния. Это первый маршрут, в котором используются два разных параметра запроса:

 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
fastify.route({
    method: 'POST',
    url: '/:id/:status', // [1]
    handler: async function changeStatus(request, reply) {
        const done = request.params.status === 'done'; // [2]
        const res = await todos.updateOne(
            {
                _id: new fastify.mongo.ObjectId(
                    request.params.id
                ),
            },
            {
                $set: {
                    done,
                    modifiedAt: new Date(),
                },
            }
        ); // [3]
        if (res.modifiedCount === 0) {
            // [4]
            reply.code(404);
            return { error: 'Todo not found' };
        }
        reply.code(204); // [5]
    },
});

Наш маршрут ожидает два параметра в URL — :id, уникальный идентификатор элемента дел, и :status, который указывает, должна ли текущая задача быть отмечена как «выполненная» или «не выполненная» ([1]).

Функция-обработчик сначала проверяет значение параметра status, чтобы определить новое значение свойства done ([2]). Затем она использует метод updateOne() для обновления свойств done и modifiedAt элемента в базе данных ([3]). Если обновление прошло успешно, функция-обработчик возвращает ответ 204 No Content ([5]). С другой стороны, если элемент не найден, функция-обработчик возвращает ответ 404 Not Found с сообщением об ошибке ([4]).

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ curl -X POST http://localhost:3000/todos/641826ecd5e0cccc313cda86/
done
$ curl http://localhost:3000/todos/641826ecd5e0cccc313cda86
{"id": "641826ecd5e0cccc313cda86", "title": "my first task",
"done":true, "createdAt": "2023-03-20T09:27:08.986Z", "modifiedAt":
"2023-03-20T09:27:32.902Z"}%
$ curl -X POST http://localhost:3000/todos/641826ecd5e0cccc313cda86/
undone
$ curl http://localhost:3000/todos/641826ecd5e0cccc313cda86
{"id": "641826ecd5e0cccc313cda86", "title": "my first task",
"done":false, "createdAt": "2023-03-20T09:27:08.986Z", "modifiedAt":
"2023-03-20T09:56:06.995Z"}

В выводе терминала мы устанавливаем свойство done элемента в true, передавая done в качестве параметра :status запроса. Затем мы вызываем маршрут GET для одного элемента, чтобы проверить, эффективно ли операция изменяет статус. Затем, чтобы отменить процесс и пометить задачу как еще не выполненную, мы снова вызываем маршрут done, передавая undone в качестве параметра запроса статуса. Наконец, мы проверяем, что все работает так, как ожидалось, и снова вызываем обработчик readTodo.

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

Защита конечных точек

До сих пор каждый объявленный нами маршрут не выполнял никакой проверки на вводимые пользователем данные. Это нехорошо, и мы, как разработчики, должны всегда проверять и обеззараживать входные данные API, которые мы предоставляем. В нашем случае все обработчики createTodo и updateTodo затронуты этой проблемой безопасности. Фактически, мы берем request.body и передаем его прямо в базу данных.

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

1
2
3
4
5
6
7
$ curl -X POST http://localhost:3000/todos -H "Content-Type:
application/json" -d '{"title": "awesome task", "foo": "bar"}'
{"id": "6418214ad5e0cccc313cda85"}%
$ curl http://127.0.0.1:3000/todos/6418214ad5e0cccc313cda85
{"id": "6418214ad5e0cccc313cda85", "title": "awesome task", "foo":
"bar", "done":false, "createdAt": "2023-03-20T09:03:06.324Z",
"modifiedAt": "2023-03-20T09:03:06.324Z"}%

В предыдущем фрагменте терминала мы выполнили две команды curl. В первой из них при создании элемента вместо того, чтобы передать только title, мы также передаем свойство foo. Посмотрев на возвращаемый результат, мы видим, что команда вернула ID созданной сущности. Теперь мы можем проверить, что сохранилось в базе данных, вызвав маршрут readTodo. К сожалению, в выводе мы видим, что мы также сохранили "foo": "bar" в базе данных. Как уже говорилось ранее, это проблема безопасности, и мы никогда не должны позволять пользователям писать напрямую в базу данных.

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

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

Загрузка схем маршрутов

Перед реализацией схем давайте добавим выделенную папку, чтобы лучше организовать нашу кодовую базу. Мы можем сделать это внутри пути ./routes/todos/. Более того, мы хотим автоматически загружать их из папки `schemas. Чтобы сделать это, нам нужно следующее:

  • Специальный плагин в папке schemas.
  • Определение схем, которые мы хотим использовать
  • Плагин autohooks, который будет загружать все автоматически, когда модуль todos будет зарегистрирован на экземпляре Fastify.

Мы подробно рассмотрим их в следующих подразделах.

Загрузчик схем

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
'use strict';
const fp = require('fastify-plugin');
module.exports = fp(async function schemaLoaderPlugin(
    fastify,
    opts
) {
    // [1]
    fastify.addSchema(require('./list-query.json')); // [2]
    fastify.addSchema(require('./create-body.json'));
    fastify.addSchema(require('./create-response.json'));
    fastify.addSchema(require('./status-params.json'));
});

Давайте разберем этот простой плагин:

  • Мы определили плагин Fastify с именем schemaLoaderPlugin, который загружает JSON-схемы ([1])
  • Мы вызвали метод Fastify addSchema несколько раз, передавая путь к каждому JSON-файлу в качестве аргумента ([2])

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

Теперь мы можем приступить к реализации первой схемы валидации тела ответа.

Валидация тела запроса createTodo

Приложение будет использовать эту схему при создании задачи. С помощью этой схемы мы хотим добиться двух вещей:

  1. Предотвратить добавление пользователями неизвестных свойств к сущности
  2. Сделать свойство title обязательным для каждой задачи.

Давайте посмотрим на код create-body.json:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
    "type": "object",
    "$id": "schema:todo:create:body", // [1]
    "required": ["title"], // [2]
    "additionalProperties": false, // [3]
    "properties": {
        "title": {
            "type": "string" // [4]
        }
    }
}

Схема относится к типу object и, даже будучи небольшой по объему, добавляет множество ограничений к допустимым входным данным:

  • $id используется для уникальной идентификации схемы во всем приложении; его можно использовать для ссылок на нее в других частях кода ([1]).
  • Ключевое слово required указывает, что свойство title является обязательным для данной схемы. Любой объект, не содержащий его, не будет считаться корректным по отношению к данной схеме ([2]).
  • Ключевое слово additionalProperties имеет значение false ([3]), означающее, что любые свойства, не определенные в объекте properties, будут считаться недействительными по отношению к данной схеме и отбрасываться.
  • Единственным допустимым свойством является title типа string ([4]). Валидатор попытается преобразовать title в строку на этапе проверки тела.

В разделе Использование схем мы увидим, как прикрепить это определение к правильному маршруту. Теперь мы перейдем к защите параметров пути запроса.

Валидация параметров запроса changeStatus

На этот раз мы хотим проверить параметры пути запроса вместо тела запроса. Это позволит нам быть уверенными в том, что вызов содержит правильные параметры с правильным типом. В следующем файле status-params.json показана реализация:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
{
    "type": "object",
    "$id": "schema:todo:status:params", // [1]
    "required": ["id", "status"], // [2]
    "additionalProperties": false,
    "properties": {
        "id": {
            "type": "string" // [3]
        },
        "status": {
            "type": "string",
            "enum": ["done", "undone"] // [4]
        }
    }
}

Давайте посмотрим, как работает эта схема:

  • Поле $id определяет еще один уникальный идентификатор для этой схемы ([1]).
  • В данном случае у нас есть два обязательных параметра — id и status ([2]).
  • Свойство id должно быть строкой ([3]), а status — строкой, значение которой может быть «выполнено» или «не выполнено» ([4]). Никакие другие свойства не допускаются.

Далее мы рассмотрим, как проверить параметры запроса на примере listTodos.

Валидация запроса listTodos

На данный момент должно быть понятно, что все схемы подчиняются одним и тем же правилам. Схема запроса не является исключением. Однако в фрагменте list-query.json мы впервые используем ссылку на схему:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
{
    "type": "object",
    "$id": "schema:todo:list:query", // [1]
    "additionalProperties": false,
    "properties": {
        "title": {
            "type": "string" // [2]
        },
        "limit": {
            "$ref": "schema:limit#/properties/limit" // [3]
        },
        "skip": {
            "$ref": "schema:skip#/properties/skip"
        }
    }
}

Теперь мы можем разобрать этот фрагмент на части:

  • Как обычно, свойство $id присваивает схеме уникальный идентификатор, на который можно ссылаться в других местах кода ([1]).
  • Свойство title имеет тип string и является необязательным ([2]). Оно может быть отфильтровано по частичному заголовку элемента дел. Если свойство не передано, фильтр будет создан пустым.
  • Свойство limit задает максимальное количество возвращаемых элементов и определяется ссылкой на схему schema schema:limit ([3]). Свойство skip также определяется ссылкой на схему schema schema:skip и используется для пагинации. Эти схемы настолько общие, что используются во всем проекте.

Теперь пришло время взглянуть на последний тип схемы — схему ответа.

Определение тела ответа createTodo

Определение тела ответа маршрута дает два основных преимущества:

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

В файле create-response.json показана реализация:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
    "type": "object",
    "$id": "schema:todo:create:response", // [1]
    "required": ["id"], // [2]
    "additionalProperties": false,
    "properties": {
        "id": {
            "type": "string" // [3]
        }
    }
}

Давайте рассмотрим структуру этой схемы:

  • И снова $id — уникальный идентификатор для этой схемы ([1])
  • Объект ответа имеет одно required ([2]) свойство id типа string ([3])

Эта схема ответа завершает текущий раздел об определениях схем. Теперь пришло время узнать, как использовать и регистрировать эти схемы.

Добавление плагина Autohooks

И снова мы можем воспользоваться расширяемостью и системой плагинов, которую Fastify предоставляет разработчикам. Для начала вспомним из Главы 6, что мы уже зарегистрировали экземпляр @fastify/autoload в нашем приложении. Следующий отрывок из файла app.js показывает соответствующие части:

1
2
3
4
5
6
7
8
9
fastify.register(AutoLoad, {
    dir: path.join(__dirname, 'routes'),
    indexPattern: /.*routes(\.js|\.cjs)$/i,
    ignorePattern: /.*\.js/,
    autoHooksPattern: /.*hooks(\.js|\.cjs)$/i, // [1]
    autoHooks: true, // [2]
    cascadeHooks: true, // [3]
    options: Object.assign({}, opts),
});

Для целей данного раздела нам важны три свойства:

  • autoHooksPattern ([1]) используется для указания шаблона регулярного выражения, который соответствует именам файлов хуков в директории routes. Эти файлы будут автоматически загружены и зарегистрированы как хуки для соответствующих маршрутов.
  • autoHooks ([2]) включает автоматическую загрузку этих файлов хуков.
  • cascadeHooks ([3]) гарантирует, что хуки будут выполняться в правильном порядке.

После этого краткого напоминания мы можем перейти к реализации нашего плагина autohook.

Реализация плагина Autohook

Из autoHooksPattern в предыдущем разделе мы узнали, что наш плагин можно поместить в файл с именем autohooks.js в директории ./routes/todos, и он будет автоматически зарегистрирован командой @fastify/autoload. Следующий фрагмент содержит содержимое плагина:

1
2
3
4
5
6
7
8
9
'use strict';
const fp = require('fastify-plugin');
const schemas = require('./schemas/loader'); // [1]
module.exports = fp(async function todoAutoHooks(
    fastify,
    opts
) {
    fastify.register(schemas); // [2]
});

Мы начинаем импортировать плагин загрузчика схем, который мы определили в предыдущем разделе ([1]). Затем, внутри тела плагина, мы регистрируем его ([2]). Одной этой строки достаточно, чтобы загруженные схемы стали доступны в приложении. Фактически, плагин прикрепляет их к экземплярам Fastify, чтобы сделать их легкодоступными.

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

Использование схем

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

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

Следующий фрагмент кода присоединяет схемы к определению маршрута:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
fastify.route({
    method: 'POST',
    url: '/',
    schema: {
        body: fastify.getSchema('schema:todo:create:body'), // [1]
        response: {
            201: fastify.getSchema(
                'schema:todo:create:response'
            ), // [2]
        },
    },
    handler: async function createTodo(request, reply) {
        // ...omitted for brevity
    },
});

Мы добавляем свойство schema в определение маршрута. Оно содержит объект с двумя полями:

  • Свойство body опции schema указывает схему JSON, по которой должно проверяться тело запроса ([1]). Здесь мы используем fastify.getSchema('schema:todo:create:body'), которая извлекает JSON-схему для тела запроса из коллекции схем, используя идентификатор, указанный нами в объявлении.
  • Свойство response опции chema задает JSON-схему для ответа клиенту ([2]). Оно устанавливается в объект с единственным ключом 201, который определяет JSON-схему для ответа на успешное создание, поскольку именно этот код мы использовали в обработчике. И снова мы используем fastify.getSchema('schema:todo:create:response'), чтобы получить JSON-схему для ответа из коллекции схем.

Если теперь мы попытаемся передать неизвестное свойство, валидатор схемы отделит его от тела. Давайте поэкспериментируем с этим, используя терминал и curl:

1
2
3
4
5
6
7
$ curl -X POST http://localhost:3000/todos -H "Content-Type:
application/json" -d '{"title": "awesome task", "foo": "bar"}'
{"id":"6418671d625e3ba28a056013"}%
$ curl http://localhost:3000/todos/6418671d625e3ba28a056013
{"id":"6418671d625e3ba28a056013","title":"awesome task","done":fa
lse,"createdAt":"2023-03-20T14:01:01.658Z","modifiedAt":"2023-03-
20T14:01:01.658Z"}%

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

На этом мы практически завершаем наше глубокое погружение в разработку RESTful API с помощью Fastify. Однако есть еще одна важная вещь, которая может сделать нашу кодовую базу более удобной, о которой мы должны упомянуть, прежде чем двигаться дальше.

Не повторяйтесь

Определение логики приложения внутри маршрутов подходит для простых приложений, таких как в нашем примере. Однако в реальном мире, когда нам нужно использовать нашу логику в приложении в нескольких маршрутах, было бы неплохо определить эту логику только один раз и повторно использовать ее в разных местах. И снова Fastify нас выручает.

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

 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
'use strict';
const fp = require('fastify-plugin');
const schemas = require('./schemas/loader');
module.exports = fp(async function todoAutoHooks(
    fastify,
    opts
) {
    // [1]
    const todos = fastify.mongo.db.collection('todos'); // [2]
    fastify.register(schemas);
    fastify.decorate('mongoDataSource', {
        // [3]
        // ...
        async createTodo({ title }) {
            // [4]
            const _id = new fastify.mongo.ObjectId();
            const now = new Date();
            const { insertedId } = await todos.insertOne({
                _id,
                title,
                done: false,
                id: _id,
                createdAt: now,
                modifiedAt: now,
            });
            return insertedId;
        },
        // ...
    });
});

Давайте разберем реализацию:

  • Мы обернули наш плагин с помощью fastify-plugin, чтобы открыть источник данных для других диапазонов плагина ([1]).
  • Поскольку мы больше не будем обращаться к коллекции MongoDB из маршрутов, мы перенесли ссылку на нее сюда ([2]).
  • Мы декорируем экземпляр Fastify объектом mongoDataSource ([3]), который имеет несколько методов, включая createTodo.
  • Мы перенесли логику создания элемента, которая находилась в обработчике маршрута, сюда ([4]). Функция возвращает insertedId, который мы можем использовать для заполнения тела элемента, чтобы вернуть его клиентам.

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
fastify.route({
    method: 'POST',
    url: '/',
    schema: {
        body: fastify.getSchema('schema:todo:create:body'),
        response: {
            201: fastify.getSchema(
                'schema:todo:create:response'
            ),
        },
    },
    handler: async function createTodo(request, reply) {
        const insertedId = await this.mongoDataSource.createTodo(
            request.body
        ); // [1]
        reply.code(201);
        return { id: insertedId }; // [2]
    },
});

Тело нашего обработчика — это однострочник. Его новая обязанность — принимать request.body ([1]) и передавать его в метод источника данных createTodo. После того как этот вызов вернется, он возьмет уникальный идентификатор и передаст его клиенту ([2]). Даже на этом простом примере должно быть понятно, насколько мощной является эта функция. С ее помощью мы можем сделать наш код многократно используемым во всех частях приложения.

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

Резюме

В этой главе мы шаг за шагом узнали, как реализовать RESTful API в Fastify. Сначала мы использовали мощную систему плагинов для инкапсуляции определений маршрутов. Затем мы защитили наши маршруты и доступ к базе данных с помощью определений схем. Наконец, мы перенесли логику приложения в специальный плагин, используя декораторы. Это позволило нам следовать паттерну DRY и сделать наше приложение более удобным для обслуживания.

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

Комментарии