
Привет, друзья!
В этой небольшой заметке я расскажу вам о том, как генерировать и визуализировать документацию к API с помощью Swagger.
Мы разработаем простой Express-сервер, способный обрабатывать стандартные CRUD-запросы, с фиктивной базой данных, реализованной с помощью lowdb.
Затем мы подробно опишем наше API, сгенерируем JSON-файл с описанием и визуализируем его.
Так, например, будет выглядеть описание POST-запроса к нашему API:

Если вам это интересно, прошу под кат.
Подготовка и настройка проекта
Создаем директорию, переходим в нее и инициализируем Node.js-проект:
mkdir express-swagger cd express-swagger yarn init -yp # or npm init -y
Устанавливаем зависимости:
yarn add express lowdb cross-env nodemon # or npm i ...
cross-env— утилита для платформонезависимой установки значений переменных среды окружения;nodemon— утилита для запуска сервера для разработки, который автоматически перезапускается при изменении файлов, за которыми ведется наблюдение.
Структура проекта:
- db - data.json - фиктивные данные - index.js - инициализация БД - routes - todo.routes.js - роуты - swagger - этой директорией мы займемся позже - server.js - код сервера
Определяем тип сервера (модуль) и команды для его запуска в package.json:
"type": "module", "scripts": { "dev": "cross-env NODE_ENV=development nodemon server.js", "start": "cross-env NODE_ENV=production node server.js" }
Команда dev запускает сервер для разработки, а start — для продакшна.
База данных, роуты и сервер
Наши фиктивные данные будут выглядеть так (db/data.json):
[ { "id": "1", "text": "Eat", "done": true }, { "id": "2", "text": "Code", "done": true }, { "id": "3", "text": "Sleep", "done": true }, { "id": "4", "text": "Repeat", "done": false } ]
Структура данных — массив объектов. Каждый объект состоит из идентификатора (строка), текста (строка) и индикатора выполнения (логическое значение) задачи.
Инициализация БД (db/index.js):
import { join, dirname } from 'path' import { fileURLToPath } from 'url' import { Low, JSONFile } from 'lowdb' // путь к текущей директории const _dirname = dirname(fileURLToPath(import.meta.url)) // путь к файлу с фиктивными данными const file = join(_dirname, 'data.json') const adapter = new JSONFile(file) const db = new Low(adapter) export default db
Давайте определимся с архитектурой API.
Реализуем следующие конечные точки:
- GET
/— получение всех задач - GET
/:id— получение определенной задачи по ее идентификатору. Запрос должен содержать параметр —idсуществующей задачи - POST
/— создание новой задачи. Тело запроса (req.body) должно содержать объект с текстом новой задачи ({ text: 'test' }) - PUT
/:id— обновление определенной задачи по ее идентификатору. Тело запроса должно содержать объект с изменениями ({ changes: { done: true } }). Запрос должен содержать параметр —idсуществующей задачи - DELETE
/:id— удаление определенной задачи по ее идентификатору. Запрос должен содержать параметр —idсуществующей задачи
Приступаем к реализации (routes/todo.routes.js):
import { Router } from 'express' import db from '../db/index.js' const router = Router() // роуты export default router
GET /
router.get('/', async (req, res, next) => { try { // инициализируем БД await db.read() if (db.data.length) { // отправляем данные клиенту res.status(200).json(db.data) } else { // сообщаем об отсутствии задач res.status(200).json({ message: 'There are no todos.' }) } } catch (e) { // фиксируем локацию возникновения ошибки console.log('*** Get all todos') // передаем ошибку обработчику ошибок next(e) } })
GET /:id
router.get('/:id', async (req, res, next) => { // извлекаем id из параметров запроса const id = req.params.id try { await db.read() if (!db.data.length) { return res.status(400).json({ message: 'There are no todos' }) } // ищем задачу с указанным id const todo = db.data.find((t) => t.id === id) // если не нашли if (!todo) { return res .status(400) .json({ message: 'There is no todo with provided ID' }) } // если нашли res.status(200).json(todo) } catch (e) { console.log('*** Get todo by ID') next(e) } })
POST /
router.post('/', async (req, res, next) => { // извлекаем текст из тела запроса const text = req.body.text if (!text) { return res.status(400).json({ message: 'New todo text must be provided' }) } try { await db.read() // создаем новую задачу const newTodo = { id: String(db.data.length + 1), text, done: false } // помещаем ее в массив db.data.push(newTodo) // фиксируем изменения await db.write() // возвращаем обновленный массив res.status(201).json(db.data) } catch (e) { console.log('*** Create todo') next(e) } })
PUT /:id
router.put('/:id', async (req, res, next) => { // извлекаем id Из параметров запроса const id = req.params.id if (!id) { return res .status(400) .json({ message: 'Existing todo ID must be provided' }) } // извлекаем изменения из тела запроса const changes = req.body.changes if (!changes) { return res.status(400).json({ message: 'Changes must be provided' }) } try { await db.read() // ищем задачу const todo = db.data.find((t) => t.id === id) // если не нашли if (!todo) { return res .status(400) .json({ message: 'There is no todo with provided ID' }) } // обновляем задачу const updatedTodo = { ...todo, ...changes } // обновляем массив const newTodos = db.data.map((t) => (t.id === id ? updatedTodo : t)) // перезаписываем массив db.data = newTodos // фиксируем изменения await db.write() res.status(201).json(db.data) } catch (e) { console.log('*** Update todo') next(e) } })
DELETE /:id
router.delete('/:id', async (req, res, next) => { // извлекаем id из параметров запроса const id = req.params.id if (!id) { return res .status(400) .json({ message: 'Existing todo ID must be provided' }) } try { await db.read() const todo = db.data.find((t) => t.id === id) if (!todo) { return res .status(400) .json({ message: 'There is no todo with provided ID' }) } // фильтруем массив const newTodos = db.data.filter((t) => t.id !== id) db.data = newTodos await db.write() res.status(201).json(db.data) } catch (e) { console.log('*** Remove todo') next(e) } })
Сервер (server.js):
import express from 'express' import router from './routes/todo.routes.js' // экземпляр Express-приложения const app = express() // парсинг JSON, содержащегося в теле запроса app.use(express.json()) // обработка роутов app.use('/todos', router) app.get('*', (req, res) => { res.send('Only /todos endpoint is available.') }) // обработка ошибок app.use((err, req, res, next) => { console.log(err) const status = err.status || 500 const message = err.message || 'Something went wrong. Try again later' res.status(status).json({ message }) }) // запуск сервера app.listen(3000, () => { console.log('? Server ready') })
Запускаем сервер для разработки:
yarn dev # or npm run dev

Адрес нашего API — http://localhost:3000/todos
Проверяем работоспособность сервера. Для этого я воспользуюсь Postman.
GET /

GET /:id

POST /

PUT /:id

DELETE /:id

Отлично. С этой задачей мы справились. Теперь сделаем работу с API доступной (и поэтому легкой) для любого пользователя посредством описания конечных точек, принимаемых параметров, тел запросов и возвращаемых ответов (частично мы это уже сделали при проектировании архитектуры API).
Описание и визуализация API
Для генерации документации к API мы будем использовать библиотеку swagger-autogen, а для визуализации — swagger-ui-express. Устанавливаем эти пакеты:
yarn add swagger-autogen swagger-ui-express # or npm i ...
Приступаем к реализации генерации описания (swagger/index.js):
import { join, dirname } from 'path' import { fileURLToPath } from 'url' import swaggerAutogen from 'swagger-autogen' const _dirname = dirname(fileURLToPath(import.meta.url)) // const doc = ... // путь и название генерируемого файла const outputFile = join(_dirname, 'output.json') // массив путей к роутерам const endpointsFiles = [join(_dirname, '../server.js')] swaggerAutogen(/*options*/)(outputFile, endpointsFiles, doc).then(({ success }) => { console.log(`Generated: ${success}`) })
Документация генерируется на основе значения переменной doc и специальных комментариев в коде роутов.
Описываем API с помощью doc:
const doc = { // общая информация info: { title: 'Todo API', description: 'My todo API' }, // что-то типа моделей definitions: { // модель задачи Todo: { id: '1', text: 'test', done: false }, // модель массива задач Todos: [ { // ссылка на модель задачи $ref: '#/definitions/Todo' } ], // модель объекта с текстом новой задачи Text: { text: 'test' }, // модель объекта с изменениями существующей задачи Changes: { changes: { text: 'test', done: true } } }, host: 'localhost:3000', schemes: ['http'] }
Описываем роуты с помощью специальных комментариев.
GET /
router.get('/', async (req, res, next) => { // описание роута // #swagger.description = 'Get all todos' // возвращаемый ответ /* #swagger.responses[200] = { // описание ответа description: 'Array of all todos', // схема ответа - ссылка на модель schema: { $ref: '#/definitions/Todos' } } */ // код роута })
GET /:id
router.get('/:id', async (req, res, next) => { // #swagger.description = 'Get todo by ID' // параметр запроса /* #swagger.parameters['id'] = { // описание параметра description: 'Existing todo ID', // тип параметра type: 'string', // является ли параметр обязательным? required: true } */ /* #swagger.responses[200] = { description: 'Todo with provided ID', schema: { $ref: '#/definitions/Todo' } } */ // код роута })
POST /
router.post('/', async (req, res, next) => { // #swagger.description = 'Create new todo' // тело запроса /* #swagger.parameters['text'] = { in: 'body', description: 'New todo text', type: 'object', required: true, schema: { $ref: '#/definitions/Text' } } */ /* #swagger.responses[201] = { description: 'Array of new todos', schema: { $ref: '#/definitions/Todos' } } */ // код роута })
PUT /:id
router.put('/:id', async (req, res, next) => { // #swagger.description = 'Update existing todo' /* #swagger.parameters['id'] = { description: 'Existing todo ID', type: 'string', required: true } */ /* #swagger.parameters['changes'] = { in: 'body', description: 'Existing todo changes', type: 'object', required: true, schema: { $ref: '#/definitions/Changes' } } */ /* #swagger.responses[201] = { description: 'Array of new todos', schema: { $ref: '#/definitions/Todos' } } */ // код роута })
DELETE /:id
router.delete('/:id', async (req, res, next) => { // #swagger.description = 'Remove existing todo' /* #swagger.parameters['id'] = { description: 'Existing todo ID', type: 'string', required: true } */ /* #swagger.responses[201] = { description: 'Array of new todos or empty array', schema: { $ref: '#/definitions/Todos' } } */ // код роута })
Это лишь небольшая часть возможностей по документированию API, предоставляемых swagger-autogen.
Добавляем в package.json команду для генерации документации:
"gen": "node ./swagger/index.js"
Выполняем ее:
yarn gen # or npm run gen
Получаем файл swagger/output.json примерно такого содержания:
{ "swagger": "2.0", "info": { "title": "Todo API", "description": "My todo API", "version": "1.0.0" }, "host": "localhost:3000", "basePath": "/", "schemes": [ "http" ], "paths": { "/todos/": { "get": { "description": "Get all todos", "parameters": [], "responses": { "200": { "description": "Array of all todos", "schema": { "$ref": "#/definitions/Todos" } } } }, // другие роуты } }, "definitions": { "Todo": { "type": "object", "properties": { "id": { "type": "string", "example": "1" }, "text": { "type": "string", "example": "test" }, "done": { "type": "boolean", "example": false } } }, // другие модели } }
Круто. Но как нам это нарисовать? Легко.
Возвращаемся к коду сервера:
import fs from 'fs' import swaggerUi from 'swagger-ui-express'
Определяем путь к файлу с описанием API:
const swaggerFile = JSON.parse(fs.readFileSync('./swagger/output.json'))
Определяем конечную точку /api-doc, при доступе к которой возвращается визуальное представление нашей документации:
app.use('/api-doc', swaggerUi.serve, swaggerUi.setup(swaggerFile))
swagger-ui-express предоставляет широкие возможности по кастомизации визуального представления.
Результат
На всякий случай перезапускаем сервер для разработки и переходим по адресу http://localhost:3000/api-doc.
Общий вид

GET /

GET /:id

POST /

PUT /:id

DELETE /:id

Модели

Пожалуй, это все, чем я хотел поделиться с вами в этой заметке.
Надеюсь, вам было интересно и вы не зря потратили время.
Благодарю за внимание и happy coding!

