Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Модуль 7 «Middleware. Валидация. Загрузка файлов» #7

Merged
merged 34 commits into from
Sep 10, 2024

Conversation

AntonovIgor
Copy link
Contributor

No description provided.

Добавим новый контроллер `OfferController`. Он будет отвечать за
обработку запросов создания, чтения и редактирования объявлений. Начнём с подготовки основы. Отнаследуемся от `Controller`. Затем определим первый маршрут — `/:offerId`. Чуть позже реализуем в нём получение объявления из базы данных, а пока сделаем заглушку.

Следующим шагом добавим новый элемент в `Component` и в контейнер.
После этого внедрим контроллер `OfferController` в `RestApplication` и
зарегистрируем в методе `_initControllers`.

WIP: Контроллер в разработке.
Внедрим в конструктор контроллера `OfferController` зависимость
`offerService`. Сервис потребуется для работы с объявлениями в базе.
Затем имплементируем обработчик `show`.

На этот раз данные для поиска объявления (идентификатор) клиент будет
передавать через параметры запроса. Все параметры, переданные клиентом,
доступны через объект `params`. Типизируем его. Для этого опишем тип
`ParamsOfferDetails`.

Получив идентификатор объявления от клиента, воспользуемся сервисом и
найдём объявление. Если объявление отсутствует — бросим ошибку `404`.
В противном случае отдадим клиенту найденное объявление.

Для тестирования добавим запрос в `queries.http`. Попробуйте выполнить
его и убедиться, что сервис отдаёт полную информацию об объявлении.

WIP: Контроллер `OfferController` в разработке.
Доработаем вывод информации. Для этого создадим `OfferRdo` и заодно
рассмотрим несколько новых техник. Начнём с категорий. Категории
соответствуют `OfferRdo`. Поэтому в качестве типа укажем массив
`OfferRdo`. Чтобы `class-transformer` смог автоматически заполнить
дочерние объекты, дополнительно укажем декоратор `@Type`.

С полем `userId` немного интересней. У нас поле называется `userId`,
но клиенту лучше отдавать более читабельный вариант — `user`. Чтобы
настроить маппинг на новое имя, укажем параметр для декоратора
`@Expose`. Теперь `userId` будет мапиться на `UserRdo`.
Разберёмся с получением списка объявлений. Для этого в контроллере `OfferController` добавим обработчик `index`. При обращении к ресурсу `GET /offers/` сервис будет отдавать весь список предложений.

Для проверки метода добавим новый запрос в `offers.http`.
Для создания объявления реализуем метод `create` в контроллере
`OfferController`. После создания сразу сделаем запрос и вернём
клиенту информацию о созданном объявлении.
Теперь реализуем поддержку удаления объявлений. Для этого создадим в
контроллере `OfferController` метод `delete`.

Для тестирования операции удаления добавим запрос `DELETE` в `offers.http`.
Разберём кейс с редактированием объявлений. Реализовать редактирование
с точки зрения API можно несколькими способами. Один из очевидных: реализовать обработку PUT-запросов. Метод `PUT` подразумевает полное
обновление.

Есть другой вариант, более гибкий — `PATCH`. Этот метод подразумевает
частичное обновление. Реализуем его в `OfferController`.

Для обновления документа в коллекции расширим интерфейс `OfferService`. Добавим метод `updateById`. Для обновления воспользуемся методом `findByIdAndUpdate`.

По традиции добавим запрос для проверки в `offer.http`.
Теперь реализуем метод для получения списка объявлений из определённой
категории. Для этого немного доработаем контроллер `CategoryController`.
Добавим метод `getOfferFromCategory`. В нём воспользуемся методом
`findByCategoryId`.

Обратите внимание на параметр `count`. Техническое задание определяет,
что пользователь может запросить любое количество объявлений из категории, но по умолчанию сервис возвращает `25` объявлений.

В `CategoryController` из параметров запроса извлечём опциональный
параметр `limit`. Если он задан, передадим в метод `findByCategoryId`.

Для проверки обработчика добавим запрос в `offer.http`.
Теперь реализуем `CommentController`. В нём потребуется один обработчик
маршрута — `create`. Он отвечает за добавление нового комментария. При
реализации повторяем уже знакомую технику: проверяем наличие документа
с объявлением и если документ существует, создаём комментарий.

Обратите внимание, здесь мы используем метод `exists` у `OfferService`.
Этот метод мы ещё не разбирали и реализуем его в следующем шаге.

Создание комментария ничем не отличается от создания объявления —
вызываем метод `create` и передаём данные из тела запроса. Затем отдаём
пользователю результат. По традиции готовим отдельную DTO — `CommentDto`.

Чтобы воспользоваться `CommentController`, его необходимо добавить в
компоненты приложения (`AppComponent`) и в контейнер с зависимостями
(`main.ts`). После этого можно внедрять в качестве зависимости в
`application` и регистрировать в методе `registerRoutes`.

WIP. Линтер ругается на отсутствующий метод `exists` у `OfferService`.
У нас есть всё необходимое, чтобы получить список комментариев для
определённого объявления. Откроем контроллер `OfferController` и
реализуем в нём новый обработчик для маршрута `/:offerId/comments`.

Внутри обработчика необходимо выполнить проверку на существование
объявления. Если объявление не существует — бросаем ошибку. Иначе
выбираем комментарии по идентификатору объявления и возвращаем клиенту.

Для проверки обработчика заведём новый запрос в `comment.http`.
Обратите внимание, ответ на запрос получения списка комментариев
включает информацию о пользователе.

Раз уж мы добавили сервис комментариев в контроллер объявлений, добавим
для обработчика удаления объявления удаление соответсвтвующих комментариев.
Пришло время познакомиться с созданием собственных middleware. Слово
«middleware» часто переводят как «промежуточное программное
обеспечение». Этот термин не очень хорошо отражает суть. Middleware —
это промежуточный обработчик. Удобней всего думать о middleware как об
операторах, которые обслуживают конвейерную ленту. Каждый такой
оператор может что-то сделать с деталью, которая двигается по ленте.

За счёт middleware мы можем расширять функциональность обработчиков.
Один из примеров мы уже рассмотрели. В методе `_initMiddleware`
класса `RestApplication` мы глобально зарегистрировали middleware
`express.json`. Она проверяет входящий запрос и пытается разобрать тело в формате JSON. За счёт работы этой middleware, в наших обработчиках мы можем работать с данными от клиента как с обычным объектом.

Middleware могут быть и локальными. Работают они точно также, только
регистрируются не для всего приложения, а для определённого обработчика
маршрута. Это стандартная возможность Express. Чтобы её реализовать,
нам потребуется доработать абстрактный контроллер (`Controller`) и
интерфейс `RouteInterface`. Раз middleware можно применять только к
определённым обработчикам, нам нужно иметь возможность передавать
middleware (одну или несколько) при регистрации определённого маршрута.

Начнём с описания интерфейса `Middleware`. Все middleware будем делать в виде отдельных классов. По аналогии с командами CLI мы определим один единственный метод `execute`. В этом методе будет логика middleware и доступ к `Request`, `Response` и `NextFunction`. За счёт `NextFunction` мы сможем передать управление следующему обработчику.
Теперь обсудим как зарегистрировать middleware для отдельного
обработчика. Для этого следует собрать все middleware в массив и
указать этот массив в качестве обработчика (для определённого Router).
Ничего сложного. Остаётся только решить как передать список middleware
в метод `addRoute` класса `Controller`.

Для этого придётся пересмотреть и расширить интерфейс `Route`. Добавим новое свойство `middlewares` типа
`Middleware`. В качестве типа мы указали массив, так для одного обработчика маршрута может применять несколько middlewares. Обратите внимание, само свойство `middlewares` мы пометили
опциональным (`?`). Для некоторых обработчиков маршрутов middleware
может не требоваться. Кроме того, если `middlewares` сделать обязательным, то придётся обновлять все ранее определённые маршруты.
Передавать middleware при регистрации обработчиков маршрутов мы можем,
но чтобы они заработали, необходимо их зарегистрировать. Для этого
немного модифицируем метод `addRoute` в классе `BaseController`.

Для начала позаботимся о привязке контекста и оборачивании middleware
в `asyncHandler`. Для этого трансформируем массив `middlewares`.
Обратите внимание, что мы используем оператор `?`, чтобы не делать
проверку существования свойства `middlewares` в блоке `if`. Таким
образом, в `middlewares` будет либо `undefined` (если middlewares не
были переданы), либо массив из функций `execute` (middlewares).

Затем заведём отдельную переменную, в которой сохраним общую цепочку
обработчиков (вместе с middlewares). Если middleware есть, то копируем
на них ссылки, а в конце добавляем оригинальный обработчик. В противном
случае используем обработчик. Последним шагом заменим `wrapperAsyncHandler` на
`allHandlers`.
Попробуем решить практическую задачу с помощью middleware. Для
получения информации по объявлению (или любой другой сущности) клиент
должен отправить её идентификатор. Идентификатор (`ObjectId`) — это
специально-подготовленная уникальная строка.

Какие с ней сложности? Перед тем как получить сущность, мы делаем
проверку на наличие. Если сущности нет, то возвращаем соответствующий
код ошибки. Однако в этом месте есть проблема. Код проверки в
`OfferController` нормально отработает, если клиент отравит валидный
идентификатор. Идентификатор, который может быть преобразован к типу
`ObjectId`. Если выполнить преобразование не получится, произойдёт
ошибка.

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

Создадим первую middleware `ValidateObjectIdMiddleware`. Она должна
реализовать интерфейс `Middleware`. В методе `execute` выполним проверку с помощью метода `isValid` объекта `ObjectId`. Если проверка пройдена, вызовем функцию `next`, чтобы передать эстафетную палочку следующему обработчику. В противном случае бросим ошибку с кодом `400`.

Поскольку параметр для передачи идентификатора может называться как
угодно, предусмотрим передачу имени через конструктор. Для этого в middleware опишем приватное поле `param`.
Для маршрута `GET /offers/:offerId` подключим middleware `ValidateObjectIdMiddleware`. Для этого определим свойство `middlewares` в объекте, который передаём в метод `addRoute`. В конструктор `ValidateObjectIdMiddleware` передадим название параметра, в котором клиент передаёт идентификатор объявления.

Попробуйте протестировать пример и убедиться, что передав в качестве
идентификатора любую строку, формируется корректный код ошибки (`400`).
Повторим опыт и подключим middleware для проверки `ObjectId` во все контроллеры.
Закончим основные задачи по контроллеру `OfferController`: добавим обработчики маршрутов для получения новых и обсуждаемых объявлений.
Есть разные способы провалидирвать входные данные от клиента: написать
все проверки вручную или воспользоваться специализированными пакетами.
Для валидации данных доступно несколько разных пакетов. Один из известных — joi.

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

Помимо joi есть и другие пакеты для валидации. Например, class-validator. Этот пакет применяется в фреймворке Nest.js и позволяет описать правила валидации декларативно. Принцип тот же,
что у joi. Только class-validator не требует определения отдельной сущности в виде схемы (как у joi), а позволяет задать правила валидации декларативно, с помощью декораторов.

Мы используем DTO, поэтому class-validator поможет немного сэкономить на коде. Все необходимые декораторы мы сможем применить прямо на DTO.

Начнём с установки зависимостей. Установим пакет class-validator в
основные зависимости.
Опишем правила валидации для `CreateOfferDto`. Напомним, с помощью него мы создаём новые объявления. Чтобы указать правила валидации, мы должны
воспользоваться одним из множества декораторов.

Например, если мы хотим ограничить длину поля, то для этого `class-validator` предлагает декораторы `@MinLength` и `@MaxLength`. Другой кейс: проверка строки, которая содержит дату. Для этого есть декоратор `@IsDateString`. Ещё один полезный декоратор — `@IsMongoId`.
С его помощью можно проверить поля, где должен передаваться идентификатор
записи (`ObjectId`) на корректность.

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

Обратите внимание на применение декоратора для поля `categories`. В
этом поле находится массив идентификаторов категорий. Чтобы
провалидировать каждый элемент с помощью валидатора `@IsMongoId` можно
указать в объекте настроек `each: true`. Таким образом, валидатор будет
применён для каждого элемента массива.
Следующим шагом следует решить, где выполнять проверку валидации
данных. Удобней всего это делать в middleware. Если DTO не получилось
провалидировать, можно сразу вернуть ошибку и обработка запроса на
этом прервётся. Клиент получит всю необходимую информацию об ошибке.

Создадим новую middleware `ValidateDtoMiddleware`. Класс должен
реализовать интерфейс `MiddlewareInterface`. Для выполнения проверки необходимо вызвать функцию `validate` из пакета `class-validator`.
Аргументом необходимо передать экземпляр класса, который необходимо провалидировать.

Здесь у нас появляется первая сложность. Чтобы создать экземпляр класса, необходимо знать, какой именно. Делать отдельную middleware для каждой DTO неразумно. Поэтому нужную DTO будем передавать через конструктор. Предусмотрим для этого соответствующее приватное поле `dto`. С созданием экземпляра класса сложностей нет. Для этого мы можем воспользоваться функцией `plainToInsurance` из пакета `class-transformer`. Мы уже использовали её в проекте.

После этого можно передать экземпляр созданного класса в функцию
`validate`. Результатом выполнения станет массив ошибок или пустой массив. Мы делаем проверку, если в массиве что-то есть, значит есть ошибки. Поэтому нужно отправить их клиенту. Пока отправляем массив целиком, чуть позже мы сократим объём передаваемой информации.

Если ошибок нет, то нужно передать работу следующему обработчику или middleware. Вызываем функцию `next`.
Чтобы протестировать валидацию, подключим `ValidationDtoMiddleware` в `OfferController`. Воспользуемся этим middleware для обработчика `create`. Для этого в middlewares передадим экземпляр
`ValidationDtoMiddleware`. Информацию о валидируемом DTO передадим в конструктор.

Запустите проект и попробуйте отправить новое объявление. Специально допустите несколько ошибок при заполнении.
Аналогичным образом реализуем валидацию для обработчика регистрации
новых пользователей. Навесим валидаторы для полей класса `CreateUserDto`, а затем подключим `ValidationDtoMiddleware` в обработчик `create` контроллера `UserController`.
Аналогичным образом поступим с `LoginUserDto`. Добавим декораторы для
валидации и подключим `ValidationDtoMiddleware` к обработчику маршрута.
Аналогичным образом поступим с `CreateCategoryDto`. Добавим декораторы
для валидации и подключим `ValidationDtoMiddleware` к обработчику
маршрута.
Добавим валидацию для последнего DTO — `UpdateOfferDto`. Здесь применяется тот же самый приём, что и раньше. За одним лишь исключением: к каждому полю добавляется декоратор `@isOptional`. Мы реализуем метод `PATCH`, поэтому клиент может прислать только определённые поля для изменения. Соответственно, если каких-то полей не хватает, то это не
является ошибкой.

Обратите внимание, что для обработчика `update` в `OfferController` мы
подключаем вторую middleware. Первая валидирует параметр `offerId`, а
вторая данные в DTO.
 Повторим предыдущий опыт и добавим валидацию для `CreateCommentDto`.
В обработчиках маршрутах, где в пути используется идентификатор документа в БД нам приходится делать одну и ту же проверку: проверить существование документа, а только потом выполнять основную операцию. В этом нет ничего страшного, но код контроллеров немного раздувается и появляется дополнительный шум.

Можно немного сократить объём кода, если перенести задачу проверки сущности в `middleware`. Проверим на практике. Создадим новую middleware `DocumentExistsMiddleware`.

Чтобы из middleware проверить наличие документа в базе нам потребуется
определённый сервис. Общего интерфейса для всех сервисов у нас нет. Можно перечислить все созданные сервисы, но этот вариант не сильно гибкий. Придётся поддерживать список сервисов в актуальном виде, да ещё проследить, чтобы у всех сервисов был метод `exists`.

Оптимальный создать отдельный интерфейс `DocumentExistsInterface` и в нём описать метод `exists`. Поскольку метод `exists` не привязан к определённой сущности, мы сможем все интерфейсы сервисов, где требуется реализация метода — унаследовать от него.
Расширим интерфейс и подключим новую middleware для обработчика `getComments` в контроллере `OfferController`.
…ler`.

Пройдём по всем методам контроллера `OfferController` и подключим `DocumentExistsMiddleware` к тем обработчикам, где требуется выполнять проверку на существование документа в базе данных.

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

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

Мы будем хранить файлы на сервере, в отдельной директории. Создадим в корне проекта новую директорию, например `uploads` и добавим в схему конфигурации новую переменную окружения `UPLOAD_DIRECTORY`. В этой переменной передадим путь к директории, в которую следует сохранять файлы от клиентов.

Поскольку файлы нет смысла помещать в систему контроля версий сразу внесём исключение в `.gitignore`.
Файлы в директории `upload` должны быть доступны снаружи. То есть после
загрузки файла клиент может обратиться к этому файлу через GET-запрос. Чтобы решить эту задачу, в Express есть встроенная middleware — `express.static`. Она позволяет отдавать клиенту
статические файлы. В нашем случае это любой файл из директории `upload`.

Зарегистрируем эту `express.static` в качестве глобальной middleware в методе `_initMiddleware` класса `RestApplication`.

Стоит сразу отметить, что для production варианта — это не лучший вариант. Если подразумевается интенсивная работа (получение файлов клиентом), то лучше поручить выполнение этой задачи веб-серверу.

Например, nginx. Это эффективней, чем загружать Node.js.

P.S. Запустите проект и поместите любой файл в директорию `upload`.
Затем откройте браузер и попробуйте обратиться к этому файлу. Например, `http://localhost:4000/upload/yourfile.jpg`. Вы должны увидеть содержимое файла.
Теперь обсудим непосредственно загрузку файлов. Express для решения этой задачи нам ничего не предоставляет. Поэтому нам придётся обратиться к вспомогательному пакету — `multer`. Это ещё одна
middleware, которая умеет разбирать запрос от клиента и извлекать из него файлы. Также multer решает задачу сохранения файла под уникальным именем. Более подробно про multer можно почитать в учебнике.

Также нам потребуется пакет mime-types. Первый поможет решить задачу с определением расширения для файла на основании MIME-типа, а вторым воспользуемся для генерации уникального имени файла. multer умеет сам генерировать такие имена, но мы рассмотрим ситуацию, когда может потребоваться изменить алгоритм формирования уникального имени.
Обработка запроса с целью извлечения файлов выполним в отдельной
middleware. Создадим `UploadFileMiddleware`. Не забудьте, middleware должна имплементировать интерфейс `Middleware`.

В методе `execute` реализуем создание экземпляра `diskStorage`.

`multer` абстрагируется от места хранения файлов. Для этого он предоставляет абстракцию в виде хранилища (`storage`). Из коробки доступно два варианта хранилищ: `diskStorage` и `memoryStorage`. Мы решили сохранять файлы на диске, поэтому воспользуемся первым вариантом.

Создадим хранилище и заполним свойства `destination` и `filename`. В первом следует указать путь для сохранения файлов. Путь будем передавать через конструктор (`uploadDirectory`). А вот свойство `filename` более сложное. Ему мы можем задать функцию, в которой можно
реализовать свой алгоритм для именования полученных файлов.

По умолчанию `multer` сохраняет все файлы без расширения, а в качестве имени использует уникальную строку. Мы можем переопределить это поведение. Расширение получим на основании `file.mimetype`, а в качестве имени воспользуемся уникальной строкой, полученной с помощью `randomUUID()` из встроенного модуля `crypto`.

Последним шагом воспользуемся объектом `multer` и получим готовую middleware, сохраним её в `uploadSingleFileMiddleware``. Затем исполним.
Теперь воспользуемся `UploadFileMiddleware` в `UserController`.

Создадим новый обработчик `uploadAvatar` и подключим к нему созданную middleware.

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

Для проверки отправки файла добавим новый запрос в `user.http`.
@AntonovIgor AntonovIgor merged commit 84a7a7b into main Sep 10, 2024
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant