Как стать автором
Обновить

RESTful backend приложение. Базовый шаблон

Время на прочтение17 мин
Количество просмотров13K

Постановка задачи

Необходимо собрать базовый шаблон RESTful backend приложения на NodeJS + Express, который:

  • легко документируется

  • просто наполняется функционалом

  • позволяет легко настраивать защиту маршрутов

  • имеет простую встроенную автоматическую валидацию

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

Набор инструментов

Сердце нашего приложения – спецификация OpenApi 3.0. В нашем случае это описание API на языке разметки YAML, которое позволит автоматически генерировать и защищать маршруты и документировать API.

Для простоты возьмем MongoDB и mongoose, в целом ничего не помешает заменить эту связку на любую другую в своём шаблоне.

Passport.js – защита маршрутов, аутентификация и авторизация. Стратегия passport-jwt. Мы будем использовать jwt-access и refresh токены.

Первоначальная настройка

Инициализируем проект, запустив npm init или yarn init, я предпочитаю yarn.

Для начала стоит позаботиться об удобности разработки, стиле кода и допущениях.
За стиль кода у меня отвечают eslint и prettier.

В корне создаем конфиги для eslint и prettier. Для удобства разработки и сборки я использую nodemon, npm-run-all, rimraf, babel. Ниже мои настройки:

.eslintrc.json
{
    "env": {
        "node": true,
        "es2021": true
    },
    "extends": [
        "eslint:recommended",
        "airbnb-base",
        "prettier"
    ],
    "plugins": [
        "prettier"
    ],
    "rules": {
        "no-console": 0,
        "prettier/prettier": ["error"],
        "import/extensions": 0,
        "import/prefer-default-export": "off",
        "import/no-unresolved": 0,
        "no-duplicate-imports": ["error", { "includeExports": true }],
        "react/prop-types": 0,
        "no-underscore-dangle": 0,
        "no-param-reassign": ["error", { "props": false }],
        "no-case-declarations": 0,
        "no-plusplus": ["error", { "allowForLoopAfterthoughts": true }],
        "space-infix-ops": ["error", { "int32Hint": false }],
        "no-unused-vars": ["error", { "argsIgnorePattern": "next" }]
    }
}
.prettierrc
{
    "printWidth": 100,
    "singleQuote": true,
    "tabWidth": 4,
    "bracketSpacing": true,
    "endOfLine": "lf",
    "semi": true,
    "trailingComma": "none"
}
Добавьте в свой package.json
"dependencies": {
    "@babel/node": "^7.13.13",
    "body-parser": "^1.19.0",
    "connect": "^3.7.0",
    "cookie-parser": "^1.4.5",
    "cors": "^2.8.5",
    "dotenv": "^8.2.0",
    "express": "^4.17.1",
    "express-openapi-validator": "^4.12.6",
    "jsonwebtoken": "^8.5.1",
    "mongoose": "^5.12.2",
    "morgan": "^1.10.0",
    "passport": "^0.4.1",
    "passport-jwt": "^4.0.0",
    "swagger-routes-express": "^3.3.0",
    "swagger-ui-express": "^4.1.6",
    "uuid": "^8.3.2",
    "validator": "^13.5.2",
    "yamljs": "^0.3.0"
  },
  "devDependencies": {
    "@babel/cli": "^7.13.14",
    "@babel/core": "^7.13.14",
    "@babel/preset-env": "^7.13.12",
    "eslint": "^7.23.0",
    "eslint-config-airbnb-base": "^14.2.1",
    "eslint-config-prettier": "^8.1.0",
    "eslint-plugin-import": "^2.22.1",
    "eslint-plugin-prettier": "^3.3.1",
    "nodemon": "^2.0.7",
    "npm-run-all": "^4.1.5",
    "prettier": "^2.2.1",
    "rimraf": "^3.0.2"
  },
  "babel": {
    "presets": [
      "@babel/preset-env"
    ]
  },
  "scripts": {
    "transpile": "babel ./src --out-dir bin --copy-files",
    "clean": "rimraf bin",
    "build": "npm-run-all clean transpile",
    "server": "node ./bin/app.js",
    "dev": "npm-run-all build server",
    "start": "yarn dev",
    "watch": "nodemon"
  }
Создайте nodemon.json в корне
{
    "watch": ["src/*"],
    "ext": "js, json, yaml",
    "exec": "yarn run dev"
}

Установите зависимости, запустив npm или yarn.

Немного про безопасность

Я подготовил несколько диаграмм, чтобы пошагово разобрать подход, который мы реализуем. На всякий случай собрал их в PDF.

Логика такая:

  1. При успешной аутентификации клиент получает JWT access токен и защищённый http-only куки с refresh токеном.

  2. При каждом запросе клиент проверяет, не истек ли срок жизни токена доступа (JWT access token), если все ок, вставляет его в заголовок «Authorization», если токен истек, то сначала запрашивает новый токен доступа. Ниже более подробно про наш бэкенд.

Регистрация пользователя

  1. На Backend передаем в открытом виде(но только по HTTPS) e-mail, пароль, какие-то дополнительные данные, которые вам нужны (nickname для примера на диаграмме)

  2. Генерируем уникальную соль и хэшируем пароль с этой солью, после чего записываем в базу

Аутентификация

Полная версия изображения

  1. Клиент передает по HTTPS email и пароль

  2. Пытаемся получить пользователя из базы

  3. Получаем либо пользователя, либо undefined

  4. Если undefined возвращаем сообщение об ошибке, и не говорим неверна почта или пароль

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

  6. Если пароль введен верно, генерируем JWT токен доступа, с коротким сроком жизни и Refresh токен, который нужен для получения нового токена доступа, с более длительным сроком жизни. Это не показано на диаграмме, но refresh токен записывается в базу как одно из полей пользователя.

  7. Возвращаем пользователю JWT токен доступа и устанавливаем HTTP-only cookie (secure, т.к. у нас HTTPS).

Обновление JWT токена доступа

Полная версия изображения

  1. Обращаемся на маршрут обновления токена доступа

  2. Получаем из HTTP-only cookie refresh токен

  3. Если refresh токена нет – возвращаем ошибку

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

  5. Ищем пользователя по refresh токену.

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

  7. Если пользователь найден, то генерируем новую пару JWT токена доступа и refresh токена. Записываем refresh в базу

  8. И как в предыдущем разделе передаем токен доступа и записываем refresh токен в cookie

Выход из системы

Полная версия изображения

  1. Переход на маршрут выхода из системы. Внимание на диаграмме ошибка, маршрут что-то типа /user/logout

  2. Получаем из HTTP-only cookie refresh токен

  3. Если refresh токена нет – возвращаем ошибку

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

  5. Ищем пользователя по refresh токену.

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

  7. Удаляем у пользователя из базы refresh токен

  8. Сбрасываем cookie у клиента

Вспомогательные модули безопасности

Создайте следующую файловую структуру в корне проекта:

Для работы необходимо подготовить:

  • SSL сертификат и закрытый ключ к нему

  • Закрытый и публичный ключи для генерации JWT токена доступа

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

Если у вас нет SSL сертификата, можно сгенерировать свой, но использовать такой сертификат в боевом проекте не стоит, так как к self-signed сертификатам нет доверия.

Итак для генерации SSL сертификата и закрытого ключа можно воспользоваться openssl:

openssl req -x509 -sha256 -nodes -days 365 -newkey rsa:2048 -keyout ssl.key -out ssl.crt

Генерируем ключи для JWT:

ssh-keygen -t rsa -b 4096 -m PEM -f jwtPrivate.key
openssl rsa -in jwtPrivate.key -pubout -outform PEM -out jwtPublic.pem

ssh-keygen -t rsa -b 4096 -m PEM -f refreshPrivate.key
openssl rsa -in refreshPrivate.key -pubout -outform PEM -out refreshPublic.pem

Все ключи и сертификаты складываем в ./src/crypto/

Напишем несколько вспомогательных модулей:

./src/utils/cryptoHelper.js
import crypto from 'crypto';

/**
 * Валидация пароля
 */
export function validatePassword(password, hash, salt) {
    const hashCandidate = crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex');
    return hash === hashCandidate;
}

/**
 * Генерация соли и хэша пароля
 */
export function genHashWithSalt(password) {
    const salt = crypto.randomBytes(32).toString('hex');
    const hash = crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex');

    return {
        salt,
        hash
    };
}
./src/utils/jwtHelper.js
import fs from 'fs';
import path from 'path';
import jsonwebtoken from 'jsonwebtoken';

// настраиваем пути и читаем ключи
const jwtPrivate = path.join(__dirname, '../crypto/', 'jwtPrivate.pem');
const refreshPrivate = path.join(__dirname, '../crypto/', 'refreshPrivate.pem');
const refreshPublic = path.join(__dirname, '../crypto/', 'refreshPublic.pem');
const JWT_PRIV_KEY = fs.readFileSync(jwtPrivate, 'utf8');
const REFRESH_PRIV_KEY = fs.readFileSync(refreshPrivate, 'utf8');
const REFRESH_PUBLIC_KEY = fs.readFileSync(refreshPublic, 'utf8');

// выпуск JWT токена доступа
export function issueJWT(user) {
    const { _id } = user;
    const expiresIn = '10m';

    const payload = {
        uid: _id,
        iat: Math.floor(Date.now() / 1000)
    };

    const signedToken = jsonwebtoken.sign(payload, JWT_PRIV_KEY, { expiresIn, algorithm: 'RS256' });

    return {
        token: `Bearer ${signedToken}`,
        expires: expiresIn
    };
}

//выпуск JWT refresh токена
export function issueRefresh(user) {
    const { _id } = user;
    const expiresIn = '7d';

    const payload = {
        uid: _id,
        iat: Math.floor(Date.now() / 1000)
    };

    const signedToken = jsonwebtoken.sign(payload, REFRESH_PRIV_KEY, {
        expiresIn,
        algorithm: 'RS256'
    });

    return {
        token: signedToken,
        expires: expiresIn
    };
}

//валидация refresh токена 
export function isValidRefresh(token) {
    try {
        jsonwebtoken.verify(token, REFRESH_PUBLIC_KEY, { algorithm: 'RS256' });
    } catch (error) {
        return false;
    }
    return true;
}
./src/utils/passport.js
import { Strategy, ExtractJwt } from 'passport-jwt';
import fs from 'fs';
import path from 'path';
import mongoose from 'mongoose';
import { userSchema } from '../db/models/User';

const User = mongoose.model('User', userSchema);

const pathToKey = path.join(__dirname, '../crypto/', 'jwtPublic.pem');
const PUB_KEY = fs.readFileSync(pathToKey, 'utf8');

const options = {
    jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
    secretOrKey: PUB_KEY,
    algorithms: ['RS256']
};

export const strategy = (pass) => {
    pass.use(
        new Strategy(options, (jwtPayload, done) => {
            User.findOne({ _id: jwtPayload.uid }, (err, user) => {
                if (err) {
                    return done(err, false);
                }
                if (user) {
                    return done(null, user);
                }
                return done(null, null);
            });
        })
    );
};

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

./src/utils/securityMiddleware.js

Это промежуточная функция будет использоваться для защиты наших маршрутов

export const securityMiddleware = (req, res, next, passport, groups) => {
    passport.authenticate('jwt', { session: false }, (err, user) => {
        if (err) {
            return next(err);
        }
        if (!user) {
            return res.status(401).send('Unauthorized');
        }
        // добавляем в req поле user с определенным набором полей, отдавать здесь хэш, соль не надо.
        const { _id, email, nickname, group } = user;
        req.user = {
            _id,
            email,
            nickname,
            group
        };
        if (groups.includes(user.group)) {
            return next();
        }
        return res.status(403).send('Insufficient access rights');
    })(req, res, next);
};

Описание API

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

Этот файл будет использоваться валидатором запросов, генератором документации и генератором маршрутов.

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

./src/api/apiV1.yaml
openapi: 3.0.3
info:
    title: Passport test
    description: Test of passport.js
    version: 1.0.0
    license:
        name: MIT License
        url: https://opensource.org/licenses/MIT
paths:
# Тестовый публичный маршрут
    /test/ping:
        get:
            description: 'Returns pong'
            tags:
                - Test
            operationId: ping
            responses:
                '200':
                    description: OK
                    $ref: '#/components/responses/standardResponse'
                    
# Тестовый маршрут, для зарегистрированных пользователей                    
    /test/private:
        get:
            description: 'Testing private section'
            tags:
                - Test
            operationId: testPrivate
            security:
                - access: ['free']
            responses:
                '200':
                    $ref: '#/components/responses/standardResponse'

# Тестовый маршрут, для платных подписчиков
    /test/subscription:
        get:
            description: 'Testing subscribers section'
            tags:
                - Test
            operationId: testSubscription
            security:
                - access: ['subscriber']
            responses:
                '200':
                    $ref: '#/components/responses/standardResponse'

# Маршрут для регистрации пользователей
# обратите внимание на поле email, валидатор будет ожидать формат email
    /user/register:
        post:
            description: 'Register user'
            tags:
                - User
            operationId: userRegister
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            type: object
                            properties:
                                email:
                                    type: string
                                    format: email
                                nickname:
                                    type: string
                                password:
                                    type: string
                                    format: password
            responses:
                '200':
                    description: OK
                    $ref: '#/components/responses/standardResponse'
                '400':
                    description: Bad Request
                    $ref: '#/components/responses/standardResponse'

# Маршрут для аутентификации
    /user/login:
        post:
            description: 'Login user'
            tags:
                - User
            operationId: userLogin
            requestBody:
                required: true
                content:
                    application/json:
                        schema:
                            type: object
                            properties:
                                email:
                                    type: string
                                    format: email
                                password:
                                    type: string
                                    format: password
            responses:
                '200':
                    description: Returns boolean success state and jwt object
                    content:
                        application/json:
                            schema:
                                type: object
                                properties:
                                    success:
                                        type: boolean
                                    jwt:
                                        type: object
                '400':
                    description: Login failed
                    $ref: '#/components/responses/standardResponse'
                    
                   
    /user/refresh:
        get:
            description: 'Refresh token'
            tags:
                - User
            operationId: userRefreshToken
            responses:
                '200':
                    description: 'Token refreshed'
                    $ref: '#/components/responses/jwtResponse'
                '403':
                    description: 'Token refresh error'
                    $ref: '#/components/responses/standardResponse'

    /user/logout:
        get:
            description: 'Logout user. Remove cookie. Remove refresh token in DB'
            tags:
                - User
            operationId: userLogout
            responses:
                '200':
                    description: 'Successfully logged out'
                    $ref: '#/components/responses/standardResponse'
                '403':
                    description: 'You are not logged in to logout!'
                    $ref: '#/components/responses/standardResponse'                    
    /user/profile:
        get:
            description: 'Returns user object'
            tags:
                - User
            operationId: userProfile
            security:
                - access: [ 'free' ]
            responses:
                '200':
                    $ref: '#/components/responses/standardResponse'
                '403':
                    $ref: '#/components/responses/standardResponse'
components:
    responses:
        standardResponse:
            description: Returns boolean success state and string message
            content:
                application/json:
                    schema:
                        type: object
                        properties:
                            success:
                                type: boolean
                            message:
                                type: string
        jwtResponse:
            description: Returns boolean success state and jwt object
            content:
                application/json:
                    schema:
                        type: object
                        properties:
                            success:
                                type: boolean
                            jwt:
                                type: object

Модель пользователя и mongoose

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

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

.env
PORT = 3007
DB_HOST = localhost
DB_PORT = 27017
DB_NAME = passport
DB_USER = passport
DB_PASS = passport

Настройка подключения к БД

./src/db/db.js
import Mongoose from 'mongoose';

export const connect = async () => {
    const dbHost = process.env.DB_HOST;
    const dbPort = process.env.DB_PORT;
    const dbName = process.env.DB_NAME;
    const user = process.env.DB_USER;
    const pass = process.env.DB_PASS;

    const uri = `mongodb://${dbHost}:${dbPort}/${dbName}?authSource=dbWithCredentials`;

    await Mongoose.connect(uri, {
        authSource: dbName,
        user,
        pass,
        useNewUrlParser: true,
        useFindAndModify: true,
        useUnifiedTopology: true,
        useCreateIndex: true
    }).catch((err) => console.error(err));

    const db = Mongoose.connection;
    db.on('error', () => {
        throw new Error('Error connecting database');
    });
};

Модель нашего пользователя

./src/db/models/User.js
import mongoose from 'mongoose';

export const userSchema = new mongoose.Schema(
    {
        email: {
            type: String,
            required: true,
            unique: true
        },
        nickname: {
            type: String,
            required: true,
            unique: true
        },
        hash: {
            type: String,
            required: true
        },
        salt: {
            type: String,
            required: false
        },
        refreshToken: {
            type: Object
        },
        group: {
            type: String
        }
    },
    { versionKey: false }
);

Обработка эндпоинтов

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

Контроллер user.js содержит функции, логика работы которых подробно описана в диаграммах в разделе про безопасность. Здесь без особых комментариев, код должен быть вполне понятен.

./src/api/controllers/user.js
import mongoose from 'mongoose';

import { userSchema } from '../../db/models/User';
import * as cryptoHelper from '../../utils/cryptoHelper';
import * as jwtHelper from '../../utils/jwtHelper';

const User = mongoose.model('User', userSchema);

async function sendAndSetTokens(req, res, user) {
    const jwt = jwtHelper.issueJWT(user);
    const refresh = jwtHelper.issueRefresh(user);

    user.refreshToken = refresh;
    await user.save();

    res.cookie('refreshToken', refresh, {
        secure: true,
        httpOnly: true
    });
    res.status(200).json({
        success: true,
        jwt: {
            token: jwt.token,
            expiresIn: jwt.expires
        }
    });
}

// удаление refresh токена из базы
async function resetRefresh(user) {
    user.refreshToken = '';
    await user.save();
}

// Сброс куки, путем установки пустого значения и короткого срока жизни
function resetCookie(req, res) {
    res.cookie('refreshToken', '', {
        maxAge: 1000,
        secure: true,
        httpOnly: true
    });
    res.status(200).json({
        success: true,
        message: 'Successfully logged out'
    });
}

export function userRegister(req, res, next) {
    const { email, nickname, password } = req.body;
    const saltHash = cryptoHelper.genHashWithSalt(password);
    const { salt, hash } = saltHash;

    const newUser = new User({
        email,
        nickname,
        hash,
        salt,
        group: 'free'
    });

    newUser
        .save()
        .then(() => {
            res.status(200).json({ success: true, message: 'User registered' });
        })
        .catch((err) => {
            if (err.code === 11000 || err.code === 11001) {
                res.status(409).json({
                    success: false,
                    message: `E-mail ${email} already registered, try another or log in.`
                });
            } else {
                res.status(400).json({
                    success: false,
                    message: err.message
                });
            }
        });
}

export function userLogin(req, res, next) {
    User.findOne({ email: req.body.email })
        .then(async (user) => {
            if (!user) {
                res.status(401).json({
                    success: false,
                    message: 'Wrong login or password'
                });
            }
            const isValid = cryptoHelper.validatePassword(req.body.password, user.hash, user.salt);
            if (isValid) {
                await sendAndSetTokens(req, res, user);
            } else {
                res.status(401).json({
                    success: false,
                    message: 'Wrong login or password'
                });
            }
        })
        .catch((err) => next(err));
}

export function userRefreshToken(req, res, next) {
    const refreshCandidate = req.cookies.refreshToken;
    if (refreshCandidate) {
        if (jwtHelper.isValidRefresh(refreshCandidate.token)) {
            User.findOne({ refreshToken: refreshCandidate })
                .then(async (user) => {
                    await sendAndSetTokens(req, res, user);
                })
                .catch(() => {
                    res.status(403).json({
                        success: false,
                        message: 'Invalid Refresh Token!'
                    });
                });
        } else {
            res.status(403).json({
                success: false,
                message: 'Invalid Refresh Token!'
            });
        }
    } else {
        res.status(401).json({
            success: false,
            message: 'Refresh Token Empty!'
        });
    }
}

export function userLogout(req, res, next) {
    const refreshCandidate = req.cookies.refreshToken;
    if (refreshCandidate) {
        if (jwtHelper.isValidRefresh(refreshCandidate.token)) {
            User.findOne({ refreshToken: refreshCandidate })
                .then(async (user) => {
                    await resetRefresh(user);
                    resetCookie(req, res);
                })
                .catch((err) => next(err));
        } else {
            res.status(401).json({
                success: false,
                message: 'You are not logged in to logout!'
            });
        }
    } else {
        res.status(401).json({
            success: false,
            message: 'Refresh Token Empty!!'
        });
    }
}

export function userProfile(req, res, next) {
    if (req.user) {
        res.status(200).json(req.user);
    }
}

Контроллер test.js – набор простейших функций, для проверки работы авторизации и работы незащищенного маршрута.

./src/api/controllers/test.js
export function ping(req, res) {
    res.json({
        success: true,
        message: 'Pong'
    });
}

export function testSubscription(req, res) {
    res.status(200).json({
        success: true,
        message: 'You are subscriber!'
    });
}

export function testPrivate(req, res) {
    res.status(200).json({
        success: true,
        message: 'You are in Private!'
    });
}

Осталось экспортировать все это разом в ./src/api/controllers/index.js

export * from './test';
export * from './user';

Собираем все воедино

Нам осталось собрать все в кучу и написать точку входа. Для этого мы напишем server.js и положим его в ./src/utils и app.js, который положим в ./src

На этих файлах остановимся подробнее. Начнем с импортов, что для чего нужно:

  • express – сам наш сервер

  • cookieParser – промежуточное ПО, которое позволит нам работать с куки

  • swaggerUI – интерфейс документации, который строится на основании описания API в yaml файле.

  • swagger-routes-express – автоматическая генерация маршрутов (линковка эндпоинтов к функциям контроллеров на основании того же yaml файла API)

  • yaml – работа с yaml файлами

  • express-openapi-validator – простой валидатор запросов (может и ответы валидировать, но я не включал. Включается элементарно изменением значения в true)

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

  • cors – установка заголовков CORS, чтобы не делать ручками. Немного подробнее поговорим ниже.

  • passport – та самая библиотека, которая упрощает нам работу по защите маршрутов

  • дальше подключаем контроллеры, базу, стратегию passport.

Теперь первым делом инициализируем нашу стратегию, передав ей объект passport:

strategy(passport);

Подключаемся к БД:

db.connect()
    .then(() => console.log('MongoDB connected'))
    .catch((error) => console.error(error));

Загружаем и выводим в консоль информацию по API:

const yamlSpecFile = './bin/api/apiV1.yaml';
const apiDefinition = YAML.load(yamlSpecFile);

const apiSummary = summarise(apiDefinition);
console.info(apiSummary);

Инициализируем инстанс express:

const server = express();

Настройка сервера

// подключаем логирование с помощью morgan
server.use(morgan('dev'));
// позволяем себе читать параметры из url
server.use(express.urlencoded({ extended: true }));

// это промежуточное по позволяет парсить входящие запросы с application/json
server.use(express.json());

// позволяет работать с куки
server.use(cookieParser());
// настройка CORS. В боевом проекте стоит указать адреса, для которых будет доступен наш БЭК
//var corsOptions = {
//  origin: 'http://example.com',
//  optionsSuccessStatus: 200 // some legacy browsers (IE11, various SmartTVs) choke on 204
//}
// cors(corsOptions)
// Более подробно смотрите документацию пакета на npmjs.com
server.use(cors());
// инициализируем passport.js
server.use(passport.initialize());

Автоматическая валидация запросов

// Чтобы включить валидацию ответов, поправьте параметр validateResponses
// обратите внимание, что здесь мы указываем yaml файл API 
const validatorOptions = {
    coerceTypes: false,
    apiSpec: yamlSpecFile,
    validateRequests: true,
    validateResponses: false
};
server.use(OpenApiValidator.middleware(validatorOptions));

// Кастомизация ошибок, если валидация не пройдена
server.use((err, req, res, next) => {
    res.status(err.status).json({
        error: {
            type: 'request_validation',
            message: err.message,
            errors: err.errors
        }
    });
});

Самый главный участок – генерация маршрутов и их защита.

Коннектору передается объект, в который мы импортировали все функции контроллеров и описание API. На основании этих данных он линкует и создает маршруты, которые в стандартной документации и гайдах выглядят как

server.use('/route/to/something', controllerFunction...

у нас этого не будет.

Также обратите внимание на объект security, объекты subscriber и free, это поля из yaml файла описания api, в разделе security acess. Промежуточному ПО здесь мы передаем стандартный набор для middleware + объект paspport + массив групп, которым разрешен доступ к маршрутам, отмеченным определенным уровнем доступа.

const connect = connector(api, apiDefinition, {
    onCreateRoute: (method, descriptor) => {
        console.log(
            `Method ${method} of endpoint ${descriptor[0]} linked to ${descriptor[1].name}`
        );
    },
    security: {
        subscriber: (req, res, next) => {
            securityMiddleware(req, res, next, passport, ['subscriber', 'admin']);
        },
        free: (req, res, next) => {
            securityMiddleware(req, res, next, passport, ['free', 'subscriber', 'admin']);
        }
    }
});

Осталось обернуть наш сервер коннектором и экспортировать

connect(server);

module.exports = server;
./src/utils/server.js
import express from 'express';
import cookieParser from 'cookie-parser';
import swaggerUi from 'swagger-ui-express';

import { connector, summarise } from 'swagger-routes-express';
import YAML from 'yamljs';
import * as OpenApiValidator from 'express-openapi-validator';
import morgan from 'morgan';
import cors from 'cors';
import passport from 'passport';
import * as api from '../api/controllers';
import * as db from '../db/db';
import { securityMiddleware } from './securityMiddleware';

import { strategy } from './passport';

strategy(passport);

// connect to DB
db.connect()
    .then(() => console.log('MongoDB connected'))
    .catch((error) => console.error(error));

// load API definition
const yamlSpecFile = './bin/api/apiV1.yaml';
const apiDefinition = YAML.load(yamlSpecFile);

const apiSummary = summarise(apiDefinition);
console.info(apiSummary);

const server = express();


server.use(morgan('dev'));
server.use(express.urlencoded({ extended: true }));
server.use(express.json());
server.use(cookieParser());

server.use(cors());

server.use(passport.initialize());

// API Documentation
server.use('/api-docs', swaggerUi.serve, swaggerUi.setup(apiDefinition, { explorer: false }));

// Automatic validation
const validatorOptions = {
    coerceTypes: false,
    apiSpec: yamlSpecFile,
    validateRequests: true,
    validateResponses: false
};
server.use(OpenApiValidator.middleware(validatorOptions));

// error customization, if request is invalid
server.use((err, req, res, next) => {
    res.status(err.status).json({
        error: {
            type: 'request_validation',
            message: err.message,
            errors: err.errors
        }
    });
});

// Automatic routing based on api definition
const connect = connector(api, apiDefinition, {
    onCreateRoute: (method, descriptor) => {
        console.log(
            `Method ${method} of endpoint ${descriptor[0]} linked to ${descriptor[1].name}`
        );
    },
    security: {
        subscriber: (req, res, next) => {
            securityMiddleware(req, res, next, passport, ['subscriber', 'admin']);
        },
        free: (req, res, next) => {
            securityMiddleware(req, res, next, passport, ['free', 'subscriber', 'admin']);
        }
    }
});

connect(server);

module.exports = server;

Осталась точка входа – app.js. Здесь все достаточно просто, распишу все в комментариях.

import https from 'https';
import fs from 'fs';
import * as dotenv from 'dotenv';

import server from './utils/server';

// помещаем в process.env переменные из .env файла
dotenv.config();
const { PORT } = process.env;

// загружаем сертификат и закрытый ключ
const privateKey = fs.readFileSync('./bin/crypto/ssl.key');
const certificate = fs.readFileSync('./bin/crypto/ssl.crt');

const options = { key: privateKey, cert: certificate };

// создаем HTTPS сервер
const app = https.createServer(options, server);

// запускаем на порту, который указали в .env файле
app.listen(PORT, () => {
    console.info(`Listening on https://localhost:${PORT}`);
    console.info(`Open https://localhost:${PORT}/api-docs for documentation`);
});

Основная информация взята из этих статей:

https://losikov.medium.com/part-2-express-open-api-3-0-634385c97a4e

https://medium.com/swlh/everything-you-need-to-know-about-the-passport-jwt-passport-js-strategy-8b69f39014b0

Спасибо за внимание, надеюсь кому-то этот лонгрид поможет .

Теги:
Хабы:
Всего голосов 6: ↑4 и ↓2+2
Комментарии9

Публикации

Истории

Работа

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань