Создание API с использованием Express.js, Postgres, Sequelize CLI и Jest для модульного тестирования.

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

В этом руководстве мы рассмотрим, как создать простой API для электронной коммерции с использованием PostgreSQL, Express.js и Sequelize CLI.

Вот что мы рассмотрим:

  1. Что такое Sequelize?
  2. Установка Sequelize CLI
  3. Создание моделей сущностей с помощью CLI
  4. Написание модульных тестов с помощью Jest
  5. Развертывание на Heroku
  6. Создание ассоциаций с помощью Postgres и Sequelize CLI
  7. CI/CD с использованием Circle CI.
Что нужно знать, прежде чем читать дальше
  • Базовые знания о SQL
  • Основы Express.js
  • HTTP методы

Чтобы следовать этому руководству, на вашей машине для разработки должны быть установлены следующие инструменты:

  • Nodejs 14x или выше (я использую v14.17.3)
  • Yarn или NPM (я использую Yarn v1.22.15)
  • Текстовый редактор (я использую VS Code)
  • Локальная установка PostgreSQL

Что такое Sequelize?

Sequelize — это простой в использовании инструмент объектно-реляционного отображения (ORM) на JavaScript, который работает с базами данных SQL.

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

Установка Sequelize

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

mkdir sequelize-tutorial
cd sequelize-tutorial
yarn add sequelize cors pg morgan helmet && yarn add -D sequelize-cli nodemon dotenv
Вход в полноэкранный режим Выход из полноэкранного режима
  • sequelize — Sequelize — это основанный на обещаниях Node.js ORM инструмент для Postgres. В нем есть поддержка транзакций, отношения, нетерпеливая и ленивая загрузка, репликация чтения и многое другое.
  • cors — CORS — это пакет node.js для обеспечения промежуточного ПО Connect/Express, которое может быть использовано для включения CORS с различными опциями.
  • pg — Неблокирующий клиент PostgreSQL для Node.js.
  • morgan — Промежуточное ПО для регистрации HTTP-запросов для node.js.
  • helmet — Helmet помогает вам защитить ваши приложения Express путем установки различных HTTP-заголовков.
  • sequelize-cli — интерфейс командной строки (CLI) Sequelize.
  • nodemon — инструмент, который помогает разрабатывать приложения на базе node.js, автоматически перезапуская приложение node при обнаружении изменений файлов в каталоге.
  • dotenv — модуль, загружающий переменные окружения из файла .env в process.env.

Добавьте файлы .gitignore и .env в корень папки вашего проекта.

touch .gitignore .env
Вход в полноэкранный режим Выйдите из полноэкранного режима

Затем добавьте папку node_modules и файл .env в папку .gitignore.

/node_modules
.env
Войдите в полноэкранный режим Выход из полноэкранного режима

Прежде чем мы начнем

Создание наших сущностей

Наш API имеет две сущности

  • Продукт
  • Категория

Отношения

  • Один продукт принадлежит одной категории.
  • Одна категория имеет много продуктов.

Давайте перейдем непосредственно к коду. 🤩

  1. Инициализируйте проект Sequelize, затем откройте каталог в нашем редакторе кода (VS Code):
yarn sequelize-cli init
code .
Войдите в полноэкранный режим Выйти из полноэкранного режима
  1. Настройте проект на использование базы данных Postgres в качестве диалекта SQL. Перейдите к config.json в директории /config и измените код на следующий:
{
  "development": {
    "username": "postgres",
    "password": null,
    "database": "db_dev",
    "host": "127.0.0.1",
    "dialect": "postgres"
  },

  "test": {
    "username": "postgres",
    "password": null,
    "database": "database_test",
    "host": "127.0.0.1",
    "dialect": "postgres"
  },

  "production": {
    "use_env_variable": "DATABASE_URL",
    "dialect": "postgres",
    "dialectOptions": {
      "ssl": {
        "rejectUnauthorized": false
      }
    }
  }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Поскольку мы все еще находимся в режиме разработки проекта, добавьте поля имени пользователя, базы данных и пароля в объект development.
В моем случае имя пользователя — postgres, пароль — null (я не использую пароль — плохая практика, я знаю) и база данных — db_dev.

  • После выполнения вышеописанного шага создайте базу данных для проекта с помощью этой команды:
yarn sequelize-cli db:create
Войти в полноэкранный режим Выйти из полноэкранного режима

Определение моделей

Наша цель — связать продукты с категориями, к которым они относятся.

Давайте создадим модель Product.

$ yarn sequelize-cli model:generate --name Product --attribute name:string,quantity:integer,inStock:boolean,productImage:string,expiryDate:date
Войти в полноэкранный режим Выход из полноэкранного режима

Ниже приведено содержимое созданного файла миграции.

'use strict';

module.exports = {
  async up(queryInterface, Sequelize) {
    await queryInterface.createTable('Products', {
      id: {
        allowNull: false,
        primaryKey: true,
        type: Sequelize.UUID,
        defaultValue: Sequelize.UUIDV4,
      },
      name: {
        type: Sequelize.STRING,
        trim: true,
      },
      quantity: {
        type: Sequelize.INTEGER,
        trim: true,
        allowNull: false,
      },
      price: {
        type: Sequelize.INTEGER,
        allowNull: false,
        trim: true,
      },
      inStock: {
        type: Sequelize.BOOLEAN,
        defaultValue: false,
      },
      productImage: {
        type: Sequelize.STRING,
        allowNull: false,
        trim: true,
      },
      expiryDate: {
        type: Sequelize.DATE,
        allowNull: false,
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE,
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE,
      },
    });
  },

  async down(queryInterface, Sequelize) {
    await queryInterface.dropTable('Products');
  },
};
Войти в полноэкранный режим Выход из полноэкранного режима

Обратите внимание, что я использую UUID вместо типа данных integer для поля id.

О том, как использовать UUID, читайте здесь.

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

Когда вы выполняете команду model:generate, Sequelize генерирует файл модели и миграцию с указанными атрибутами.
Теперь мы можем запустить миграцию, чтобы создать таблицу Products в нашей базе данных.

yarn sequelize-cli db:migrate
Вход в полноэкранный режим Выход из полноэкранного режима

Давайте сгенерируем seed-файл для модели Product.

yarn sequelize-cli seed:generate --name product
Войдите в полноэкранный режим Выход из полноэкранного режима

Новый файл с именем -product.js был создан в папке /seeders. Скопируйте и вставьте приведенный ниже код, который является образцом данных продуктов.

'use strict';

module.exports = {
  async up(queryInterface, Sequelize) {
    await queryInterface.bulkInsert(
      'Products',
      [
        {
          id: '1373772c-125c-42d0-81ae-a6d020fcbe21',
          name: 'Bread',
          quantity: 4,
          inStock: true,
          productImage:
            'https://res.cloudinary.com/morelmiles/image/upload/v1649765314/download_nwfpru.jpg',
          // Store the price in cents e.g if price is $5, multiply by 100 cents e.g 5 * 100 = 500 cents
          price: 500,
          expiryDate: new Date(),
          createdAt: new Date(),
          updatedAt: new Date(),
        },
        {
          id: '9df55a7c-772c-459f-a21b-933a96981ca6',
          name: 'Milk',
          quantity: 4,
          inStock: true,
          productImage:
            'https://res.cloudinary.com/morelmiles/image/upload/v1647356184/milk_ckku96.jpg',
          // Store the price in cents e.g if the price is $5, multiply by 100 cents e.g 5 * 100 = 500 cents
          price: 100,
          expiryDate: new Date(),
          createdAt: new Date(),
          updatedAt: new Date(),
        },
      ],
      {}
    );
  },

  async down(queryInterface, Sequelize) {
    await queryInterface.bulkDelete('Products', null, {});
  },
};
Вход в полноэкранный режим Выйти из полноэкранного режима

Это все для модели Products на данный момент.

Создайте модель Category

$ yarn sequelize-cli model:generate --name Category --attributes name:string
Войдите в полноэкранный режим Выйти из полноэкранного режима

Ниже приведен файл миграции для модели Category.

'use strict';

module.exports = {
  async up(queryInterface, Sequelize) {
    await queryInterface.createTable('Categories', {
      id: {
        allowNull: false,
        primaryKey: true,
        type: Sequelize.UUID,
        defaultValue: Sequelize.UUIDV4,
      },
      name: {
        type: Sequelize.STRING,
        trim: true,
        allowNull: false,
      },
      createdAt: {
        allowNull: false,
        type: Sequelize.DATE,
      },
      updatedAt: {
        allowNull: false,
        type: Sequelize.DATE,
      },
    });
  },
  async down(queryInterface, Sequelize) {
    await queryInterface.dropTable('Categories');
  },
};
Войти в полноэкранный режим Выход из полноэкранного режима

Давайте создадим файл посева для модели Category.

yarn sequelize-cli seed:generate --name category
Войти в полноэкранный режим Выход из полноэкранного режима

Ниже приведен файл с записями сеялки.

'use strict';

module.exports = {
  async up(queryInterface, Sequelize) {
    await queryInterface.bulkInsert(
      'Categories',
      [
        {
          id: 'a52467a3-3a71-45c4-bf1c-9ace5ad3668f',
          name: 'Confectionaries',
          createdAt: new Date(),
          updatedAt: new Date(),
        },
        {
          id: '33a9e6e0-9395-4f6c-b1cd-3cf1f87e195a',
          name: 'Drinks',
          createdAt: new Date(),
          updatedAt: new Date(),
        },
      ],
      {}
    );
  },

  async down(queryInterface, Sequelize) {
    await queryInterface.bulkDelete('Categories', null, {});
  },
};
Вход в полноэкранный режим Выход из полноэкранного режима

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

Подробнее об ассоциациях: здесь

Замените код в директории models/product на приведенный ниже:

'use strict';

const { Model } = require('sequelize');

module.exports = (sequelize, DataTypes) => {
  class Product extends Model {
    static associate(models) {
      Product.belongsTo(models.Category, {
        foreignKey: 'categoryId',
      });
    }
  }

  Product.init(
    {
      name: DataTypes.STRING,
      quantity: DataTypes.INTEGER,
      inStock: DataTypes.BOOLEAN,
      productImage: DataTypes.STRING,
      price: DataTypes.INTEGER,
      expiryDate: DataTypes.DATE,
    },
    {
      sequelize,
      modelName: 'Product',
    }
  );
  return Product;
};
Войти в полноэкранный режим Выйти из полноэкранного режима

Замените код в каталоге models/category на приведенный ниже:

'use strict';

const { Model } = require('sequelize');

module.exports = (sequelize, DataTypes) => {
  class Category extends Model {
    static associate(models) {
      Category.hasMany(models.Product, {
        foreignKey: 'categoryId',
        onDelete: 'CASCADE',
      });
    }
  }
  Category.init(
    {
      name: DataTypes.STRING,
    },
    {
      sequelize,
      modelName: 'Category',
    }
  );
  return Category;
};
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь добавим внешний ключ в файл миграции Product.
Перейдите в подпапку /migrations и добавьте приведенный ниже код в файл, который заканчивается create-product.js:

categoryId: {
        type: Sequelize.UUID,
        allowNull: false,
        onDelete: 'CASCADE',
        references: {
          model: 'Categories',
          key: 'id',
          as: 'categoryId'
        }
      },
Вход в полноэкранный режим Выйти из полноэкранного режима

Приведенная выше строка кода добавляет внешний ключ categoryId к таблице Products в нашей базе данных.

Чтобы добавить колонку categoryId в таблицу Products, проверьте скрипты в файле package.json.
В файле package.json есть скрипт под названием db:reset. Видели его? Он поможет нам сбросить базу данных, создать базу данных снова, запустить миграции и добавить данные сеялок в нашу базу данных.
Запустите его.

yarn db:reset
Войдите в полноэкранный режим Выход из полноэкранного режима

Если вы откроете PSQL и запустите SELECT * FROM "Products";, вы увидите обновленную таблицу Products.

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

Мы уже установили наши зависимости. Если вы еще не установили, сделайте это сейчас:

yarn add cors express  helmet morgan && yarn add -D dotenv nodemon
Войдите в полноэкранный режим Выйти из полноэкранного режима
Добавление контроллеров.

Создайте новый каталог с именем controllers и создайте три файла: category.js, index.js и product.js.

touch category.js index.js product.js
Войдите в полноэкранный режим Выйти из полноэкранного режима

Откройте category.js в папке controllers и вставьте приведенный ниже код.

const { Category, Product } = require('../models');

/**
 * Creates a new category
 * @param {*} req
 * @param {*} res
 * @returns Object
 */
const createCategory = async (req, res) => {
  try {
    const category = await Category.create(req.body);
    return res.status(201).json({
      category,
    });
  } catch (error) {
    return res.status(500).json({ error: error.message });
  }
};

/**
 * Fetches all categories
 * @param {*} req
 * @param {*} res
 * @returns Object
 */
const getAllCategories = async (req, res) => {
  try {
    const categories = await Category.findAll({
      order: [['createdAt', 'DESC']],
    });
    return res.status(200).json({ categories });
  } catch (error) {
    return res.status(500).send(error.message);
  }
};

/**
 * Gets a single category by it's id
 * @param {*} req
 * @param {*} res
 * @returns boolean
 */
const getCategoryById = async (req, res) => {
  try {
    const { id } = req.params;

    const category = await Category.findOne({
      where: { id: id },
      order: [['createdAt', 'DESC']],
    });

    if (category) {
      return res.status(200).json({ category });
    }

    return res
      .status(404)
      .send('Category with the specified ID does not exist');
  } catch (error) {
    return res.status(500).send(error.message);
  }
};

/**
 * Updates a single category by it's id
 * @param {*} req
 * @param {*} res
 * @returns boolean
 */
const updateCategory = async (req, res) => {
  try {
    const { id } = req.params;
    const [updated] = await Category.update(req.body, { where: { id: id } });

    if (updated) {
      const updatedCategory = await Category.findOne({
        where: { id: id },
        include: [
          {
            model: Product,
          },
        ],
      });
      return res.status(200).json({ category: updatedCategory });
    }

    throw new Error('Category not found ');
  } catch (error) {
    return res.status(500).send(error.message);
  }
};

/**
 * Deletes a single category by it's id
 * @param {*} req
 * @param {*} res
 * @returns Boolean
 */
const deleteCategory = async (req, res) => {
  try {
    const { id } = req.params;
    const deleted = await Category.destroy({
      where: {
        id: id,
      },
    });

    if (deleted) {
      return res.status(204).send('Category deleted');
    }

    throw new Error('Category not found ');
  } catch (error) {
    return res.status(500).send(error.message);
  }
};

module.exports = {
  createCategory,
  getAllCategories,
  getCategoryById,
  updateCategory,
  deleteCategory,
};
Войти в полноэкранный режим Выйти из полноэкранного режима

Теперь перейдите к файлу product.js и вставьте следующий код:

const { Product } = require('../models');

/**
 * Creates a new product
 * @param {*} req
 * @param {*} res
 * @returns Object
 */
const createProduct = async (req, res) => {
  try {
    const product = await Product.create(req.body);

    return res.status(201).json({
      product,
    });
  } catch (error) {
    return res.status(500).json({ error: error.message });
  }
};

/**
 * Fetches all products
 * @param {*} req
 * @param {*} res
 * @returns Object
 */
const getAllProducts = async (req, res) => {
  try {
    const products = await Product.findAll({ order: [['createdAt', 'DESC']] });

    return res.status(200).json({ products });
  } catch (error) {
    return res.status(500).send(error.message);
  }
};

/**
 * Gets a single product by it's id
 * @param {*} req
 * @param {*} res
 * @returns boolean
 */
const getProductById = async (req, res) => {
  try {
    const { id } = req.params;
    const product = await Product.findOne({
      where: { id: id },
    });

    if (product) {
      return res.status(200).json({ product });
    }

    return res.status(404).send('Product with the specified ID does not exist');
  } catch (error) {
    return res.status(500).send(error.message);
  }
};
/**
 * Updates a single product by it's id
 * @param {*} req
 * @param {*} res
 * @returns boolean
 */
const updateProductById = async (req, res) => {
  try {
    const { id } = req.params;
    const product = await Product.update(req.body, {
      where: { id: id },
    });

    if (product) {
      const updatedProduct = await Product.findOne({ where: { id: id } });
      return res.status(200).json({ product: updatedProduct });
    }
    throw new Error('product not found');
  } catch (error) {
    return res.status(500).send(error.message);
  }
};

/**
 * Deletes a single product by it's id
 * @param {*} req
 * @param {*} res
 * @returns boolean
 */
const deleteProductById = async (req, res) => {
  try {
    const { id } = req.params;

    const deletedProduct = await Product.destroy({
      where: { id: id },
    });

    if (deletedProduct) {
      return res.status(204).send('Product deleted successfully ');
    }

    throw new Error('Product not found');
  } catch (error) {
    return res.status(500).send(error.message);
  }
};

module.exports = {
  createProduct,
  getAllProducts,
  getProductById,
  deleteProductById,
  updateProductById,
};
Вход в полноэкранный режим Выйти из полноэкранного режима

Откройте файл index.js в текстовом редакторе и вставьте следующий код:

//Exports the entity controllers in a single object

const productController = require('./product');
const categoryController = require('./category');

module.exports = {
  productController,
  categoryController,
};
Войти в полноэкранный режим Выйти из полноэкранного режима

Отлично! Молодцы, что дочитали до конца…

Теперь мы собираемся создать маршруты для различных операций контроллера (я имею в виду HTTP методы 😉).

Создайте новую директорию и назовите ее routes. Внутри директории routes создайте новый файл с именем index.js.

mkdir routes
cd routes
touch index.js
Войдите в полноэкранный режим Выйдите из полноэкранного режима

Внутри каталога routes/index.js вставьте следующий код .

const router = require('express').Router();

// Controller imports
const { categoryController, productController } = require('../controllers');

// Category routes
router.get('/v1/categories', categoryController.getAllCategories);
router.post('/v1/categories', categoryController.createCategory);
router.get('/v1/categories/:id', categoryController.getCategoryById);
router.put('/v1/categories/:id', categoryController.updateCategory);
router.delete('/v1/categories/:id', categoryController.deleteCategory);

// Product routes
router.get('/v1/products', productController.getAllProducts);
router.post('/v1/products', productController.createProduct);
router.get('/v1/products/:id', productController.getProductById);
router.put('/v1/products/:id', productController.updateProductById);
router.delete('/v1/products/:id', productController.deleteProductById);

module.exports = router;
Вход в полноэкранный режим Выйти из полноэкранного режима

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

[ПРОДОЛЖЕНИЕ СЛЕДУЕТ]

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *