Привет, друзья!


В этой небольшой заметке я расскажу вам о том, как генерировать и визуализировать документацию к 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




Адрес нашего APIhttp://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!




Комментарии (10)


  1. funca
    09.12.2021 18:29
    +1

    Вы используете это чисто для документирования? Мне интересно какие гарантии, что реализация в коде соответствует описанному в swagger и как это проверить.


    1. muturgan
      09.12.2021 19:46
      +2

      Совершенно правильный вопрос. При изменении вашего кода обычно сильно некогда/лень/забыл обновлять документацию и она стремительно устаревает. Меня приводит в дикий восторг @nestjs/swagger именно тем, что ты 1 раз пишешь исполняемый код, а актуальная документация (в основном полная) генерится автоматически без всяких стрёмных комментариев. Но нест это несколько другая история


      1. funca
        09.12.2021 22:44
        +1

        У нас используется подход API-First, когда сначала проектируем схемы, а уже потом пишем реализацию в коде. Есть мысли про контрактные тесты - где сравнивается исходная схема с автогенерируемой приложением. Но с инструментами пока не понятно.


        1. antirek
          10.12.2021 09:07

          неистово плюсую @nestjs/swagger )))

          поддерживать спеку и соответствующий код - та еще задача, как вариант, если у вас spec first, писать спеку, затем писать код, по которому генерится спека и сверять их на соответствие друг другу.

          до nestjs пользовался express-openapi, там в коде дополняешь объекты и по ним потом соответствующая swagger spec генерится, описание лежит рядом с кодом. удобно, как пример https://github.com/antirek/sonata/tree/master/api/manage


    1. aio350 Автор
      09.12.2021 20:21

      swagger-autogen многое умеет определять автоматически (конечные точки, параметры и тела запросов в общем виде, статус-коды ответов и т.д.). Понятно, что чем более подробное описание, тем сильнее оно подвержено устареванию (потому что подробное описание делается вручную). Вероятно, на этапе активной разработки приложения основное внимание следует уделять поддержанию в актуальном состоянии хотя бы основного описания с помощью doc (модели, операции и т.п.), а подробными комментариями снабжать только относительно стабильные роуты


  1. rqdkmndh
    09.12.2021 20:07

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


    1. aio350 Автор
      09.12.2021 20:12

      Не за что. Спецификация комментариев, как вы это называете, имеется в документации к swagger-autogen, начиная отсюда: https://github.com/davibaltar/swagger-autogen#endpoints


  1. id_roman00
    09.12.2021 20:07

    Пробовал в прошлом году swagger, но что-то у меня с ним не вышло. Всё равно прописывать всё, так или в комментариях. Надо углубиться.


    1. funca
      09.12.2021 22:18

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


  1. lifestar
    10.12.2021 07:53

    Визуальное представление у swagger неудобное для использования.
    Как увидел redoc, сразу на него перешли. Понимает спецификации в yaml и json