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

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

Основное внимание в статье будет уделено автоматизации процесса создания документации API сервисов, которые разрабатываются с помощью фреймворков Express.js и Gin, используя подходящий для этой задачи инструмент - Swagger.

Введение

В качестве основного источника мотивации для написания данной статьи я избрал свой личный опыт разрешения проблем с быстрым и удобным документированием API сервисов, с которыми мне пришлось столкнуться при разработке сервисов на Express.js. Данным опытом я хотел бы поделиться с читателем, так как нахожу его полезным и, возможно, он принесёт пользу разработчикам, которые столкнулись с аналогичными проблемами.

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

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

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

Что такое Swagger и OpenAPI?

Swagger - это профессиональный набор инструментов для разработчиков API. Данный набор инструментов активно разрабатывается SmartBear Software и поддерживается сообществом открытого исходного кода (Open Source).

OpenAPI - это спецификация для описания API. На текущий момент времени актуальная версия OpenAPI - 3.1.0.

Swagger использует спецификацию OpenAPI для описания и документирования API, а инструменты Swagger позволяют использовать эту спецификацию для создания и тестирования API, а также для генерации клиентского кода.

Набор инструментов Swagger включает в себя следующие наиболее используемые инструменты:

  1. Swagger Editor - редактор для разработки API-интерфейсов в соответствии со спецификацией Open API

  2. Swagger UI - веб-приложение, позволяющая визуализировать определения спецификаций Open API в интерактивном пользовательском интерфейсе

  3. Swagger Codegen - создание серверных заглушек и клиентских SDK-пакетов на основе определений спецификаций Open API

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

Из вышеприведённого списка наиболее интересным инструментом является Swagger Editor, поскольку он предоставляет интерфейс для создания файла документации по спецификации Open API вручную.

Однако, перед началом рассмотрения инструмента Swagger Editor следует ответить на вопрос, а что же мы, собственно, собираемся документировать? API слишком общее понятие, следует конкретизировать что именно мы собираемся документировать. И в этом может помочь архитектурный паттерн Controller Service Repository.

Controller Service Repository

Controller Service Repository (CSR) - архитектурный паттерн, который помогает разделить ответственность между слоями приложения и соблюдать принципы SOLID.

Само определение паттерна содержит три важных элемента:

  1. Controller - слой классов, отвечающих за обработку запросов (в частности - пользовательских). В данных классах происходит получение запроса от клиента (request), валидация входных данных (если она подразумевалась), передача данных конкретному сервису, обработка ошибок и формирование ответа пользователю (response).

  2. Service - слой классов, отвечающих за бизнес-логику приложения. В сервисах отсутствуют прямые обращения к базе данных - данная функция делегируется репозиториям, которые завершают это перечисление.

  3. Repository - слой классов, отвечающих за сохранение и извлечение некоторого набора данных. В репозиториях не должно быть бизнес-логики. В методах репозитория должны лишь формироваться и выполняться запросы к базе данных.

Удобно представлять эти слои в виде сферы из разных слоёв, каждый из которых по цепочки находится "ближе к данным" (постепенное уменьшение уровня абстракции над взаимодействием с базой данных).

На рисунке ниже представлена данная модель.

Рисунок 1 - Модель архитектурного паттерна CSR
Рисунок 1 - Модель архитектурного паттерна CSR

Причём здесь данный архитектурный паттерн? Дело в том, что данный паттерн является одним из самых популярных на сегодняшний день и отлично подходит для объяснения момента, с которого начинается документирование API (определение этой границы).

Все API, как правило, определяются в объединении множества маршрутов и конкретных обработчиков в слое Controller (иными словами - привязка конкретного метода из любого класса слоя Controller, к конкретному маршруту).

Следовательно, в слое Controller и происходит документирование API. Возможны комбинации, но в основном это можно принять как правило. Это будет рассмотрено на практике более детально.

Swagger Editor

Как уже ранее упоминалось Swagger Editor позволяет визуализировать документацию в соответствии с описанием по спецификации OpenAPI.

Каким образом представлено данное описание? В формате YAML. Грубо говоря вся документация проекта будет представлена в виде одного файла с расширением yaml. Это очень удобно, ведь при необходимости этот документ можно перенести куда угодно, при этом он будет однозначно интерпретирован рассматриваемым набором инструментов, т.к. существует единый стандарт (а в случае проблем с соответствием стандарту инструменты будут выдавать сообщения об ошибках).

На рисунке ниже представлена работа Swigger Editor.

Рисунок 2 - Swagger Editor в действии
Рисунок 2 - Swagger Editor в действии

Данные, представленные в формате YAML, можно изменять и тогда в Swagger Editor будут отображаться все изменения.

Теоретически можно самостоятельно описать все API в своём сервисе с помощью данного инструмента и просто передавать файл с расширением yaml между разработчиками, чтобы они сами у себя запускали веб-приложение Swagger UI и указывали источником данных этот файл, однако данный подход крайне неудобен. Даже сейчас файл документации содержит порядка 800 строк, что довольно громоздко для количества маршрутов и их сложности, представленных в качестве базового примера в Swagger Editor.

Как можно решить проблему с ручным документированием API? Автоматизировать данный процесс. Тут мы приступаем к рассмотрению практической части.

Определение функциональных требований к сервисам

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

Один сервис будет использовать технологический стек Node.js, Express.js, JavaScript, а другой - Gin, Golang. Первый сервис будет иметь кодовое название NEJ, а второй - GG.

Опустим момент с моделированием базы данных каждого сервиса, что рекомендуется своевременно делать при более серьёзной разработке подобного рода сервисов.

Приступим к разработке сервиса с кодовым названием NEJ.

Документирование сервиса NEJ

Для начала изучим файловую структуру проекта.

Рисунок 3 - Файловая структура проекта NEJ
Рисунок 3 - Файловая структура проекта NEJ

В файловой структуре проекта определены следующие директории и файлы (только основные элементы):

  1. config - директория, содержащая конфигурационные файлы сервиса

  2. constants - директория с общими константами

  3. controllers - директория, содержащая контроллеры (слой Controller)

  4. db - директория с настройками подключения к базе данных и определением моделей

  5. docs - документация сервиса (*.yaml)

  6. dtos - директория, содержащая DTO

  7. exceptions - директория, содержащая основные ошибки сервиса (классы для обработки ошибок)

  8. logger - настройки логгера

  9. logs - логи сервиса

  10. middlewares - промежуточное программное обеспечение (используется для проверки токенов JWT и обработки исключений)

  11. routers - директория, содержащая конкретные привязки методов контроллеров с конкретными маршрутами (url-адресами)

  12. services - директория, содержащая сервисы (слой Services)

  13. utils - директория с утилитами

  14. *.env - файлы с переменными окружения

  15. generate-doc.js - скрипт, для автоматической генерации документации API

  16. index.js - точка входа в серверное приложение

  17. wipe-dependencies.js - скрипт, для автоматического обновления пакетов в package.json

Далее, опишем основные элементы данного сервиса, начиная с точки входа и скрипта для автоматического документирования.

Точка входа в NEJ

Код точки входа в серверное приложение выглядит следующим образом:

// Конфигурирование пакета dotenv, для обращения к переменным окружения
import dotenv from 'dotenv';
dotenv.config({ path: `.${process.env.NODE_ENV}.env` });

import express from "express";                                              // Подключение Express.js
import config from "config";                                                // Подключение config, для конфигурирования приложения
import logger from "./logger/logger.js";                                    // Подключение логгера
import cors from "cors";                                                    // Подключение cors'ов
import cookieParser from "cookie-parser";                                   // Подключение cookie-parses
import webApiConfig from "./config/web.api.json" assert { type: "json" };   // Подключение JSON-объекта
import { AuthRouteBase } from './constants/routes/auth.js';                 // Подключение базового маршрута авторизации (корневой route)
import AuthRouter from './routers/auth-routers.js';                         // Подключение роутеров авторизации
import errorMiddleware from './middlewares/error-middleware.js';            // Подключение промежуточного ПО для обработки ошибок
import db from "./db/index.js";                                             // Подключение к базе данных
import { fileURLToPath } from 'url';                                        // Подключение функции для конвертации URL в путь
import path, { dirname } from 'path';                                       // Подключение объекта для работы с путями и функции dirname
import YAML from 'yamljs';                                                  // Подключение объекта, для работы с YAML
import swaggerUi from 'swagger-ui-express';                                 // Подключение пакета swagger-ui-express
import ExpressSwaggerGenerator from 'express-swagger-generator';            // Подключение пакета express-swagger-generator
import swiggerOptions from './config/swigger.options.js';                   // Подключение настроек Swagger'a

// Получаем __dirname
const __dirname = dirname(fileURLToPath(import.meta.url));

// Загрузка файла документации
const swaggerDocument = YAML.load(path.join(__dirname, 'docs', 'docs.yaml'));

// Определение Express-приложения
const app = express();

// Опционально отображаем документацию Swagger версии 2
if (config.get("doc.swagger2") === true) {
    const expressSwaggerGenerator = ExpressSwaggerGenerator(app);
    expressSwaggerGenerator(swiggerOptions(__dirname));
}

app.use(express.json({ extended: true }));
app.use(cookieParser());

// Добавляем по маршруту /docs определённый контроллер (по /docs будет отображаться документация)
app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));

// Настройка cors-политик
app.use(cors({
    credentials: true,
    origin: webApiConfig['web_api'].map((value) => {
        return value;
    })
}));

// Установка маршрутов авторизации
app.use(AuthRouteBase, AuthRouter);

// Установка промежуточного ПО для обработки ошибок
app.use(errorMiddleware);

const PORT = config.get('port') || 5000;

/**
 * Запуск серверного приложения
 * @returns Экземпляр серверного приложения
 */
const start = () => {
    try {
        // Начало прослушивания входящих подключений
        const server = app.listen(PORT, () => console.log(`Сервер запущен с портом ${PORT}`));

        // Запись в логи
        logger.info({
            port: PORT,
            message: "Запуск сервера"
        });

        return server;
    } catch (e) {
        logger.error({
            message: e.message
        });

        process.exit(1);
    }
}

// Запуск сервера
const server = start();

Далее последуют объяснения кода из точки входа в NEJ.

Сперва происходит загрузка файла документации в формате YAML в переменную swaggerDocument

// Загрузка файла документации
const swaggerDocument = YAML.load(path.join(__dirname, 'docs', 'docs.yaml'));

Это даёт нам возможность визуализировать документацию с помощью пакета swagger-ui-express. Перейдём к рассмотрению привязки данного файла к Swagger UI

// Добавляем по маршруту /docs определённый контроллер (по /docs будет отображаться документация)
app.use('/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument));

На данном коде происходит привязка Swagger UI к маршруту "/docs". Когда пользователь переходит по этому маршруту, будет показан интерфейс Swagger UI с предоставленной документацией, которая описывает активности и доступные ресурсы API этого сервиса. Swagger UI обеспечивает удобное взаимодействие с API, позволяя отправлять запросы и просматривать ответы в реальном времени.

На рисунке ниже представлена работа по данному маршруту Swagger UI.

Рисунок 4 - Документация проекта NEJ со спецификацией OpenAPI 3
Рисунок 4 - Документация проекта NEJ со спецификацией OpenAPI 3

Как видно из рисунка всё действительно работает, более того - отображается документация в спецификации OpenAPI 3.

Внимательный читатель может отметить в коде точки входа, что есть отдельный опциональный блок, который позволяет увидеть документацию в более ранней спецификации (OpenAPI 2).

Однако, зачем это необходимо? Ответ прост - демонстрация исходной документации Swagger, которая была первоначально сгенерирована. Дело в том, что документация по умолчанию генерируется по спецификации OpenAPI 2, т.к. это особенности используемого пакета.

Для автоматической генерации документации по контроллерам был использован пакет express-swagger-generator, который не пользуется большой популярностью в статьях подобного характера (Swagger, Express.js).

Данный пакет отлично справился с задачей автоматической генерации документации. Ссылка на официальную документацию.

Рисунок 5 - Документация проекта NEJ со спецификацией OpenAPI 2
Рисунок 5 - Документация проекта NEJ со спецификацией OpenAPI 2

Приступим к обзору скрипта, который производит автоматическую генерацию документации в спецификацию OpenAPI 2, а затем конвертирует её в спецификацию OpenAPI 3.

Скрипт автоматической генерации документации API

Ниже представлен скрипт автоматической генерации документации API

import express from "express";                                      // Подключение Express.js (формальность)
import ExpressSwaggerGenerator from 'express-swagger-generator';    // Подключение пакета для автоматического генерирования документации
import swiggerOptions from './config/swigger.options.js';           // Подключение опций для Swagger
import { fileURLToPath } from 'url';                                
import { dirname } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
import jsonToYaml from 'json2yaml';                                 // Подключение конвертера из JSON в YAML
import fs from 'fs';
import swaggerConverter from 'swagger2openapi';                     // Подключение конвертера документации Swagger2 в OpenAPI 3

// Привязка генератора к конкретному экземпляру приложения Express
const expressSwaggerGenerator = ExpressSwaggerGenerator(express());

// Генерирование документации по определённым настройкам
const swaggerDoc = expressSwaggerGenerator(swiggerOptions(__dirname));

// Синхронная запись данных в файл документации
fs.writeFileSync('./docs/docs_swagger2.yaml', jsonToYaml.stringify(swaggerDoc));

// Процесс конвертации документации в формате Swagger 2 в документацию формата OpenAPI 3
swaggerConverter.convertObj(swaggerDoc, {}, (err, options) => {
    if (err) {
        console.error(err);
    } else {
        // Конвертация JSON в YAML
        const output = jsonToYaml.stringify(options.openapi);

        // Запись результата конвертации документации в файл (он в дальнейшем и используется по умолчанию для вывода документации)
        fs.writeFileSync('./docs/docs.yaml', output);
        process.exit(0);
    }
});

Немного разъясним работу данного скрипта. Он запускается через отдельный скрипт в package.json, а именно - с помощью npm run generate:doc

"scripts": {
    "start": "cross-env NODE_ENV=production nodemon index.js",
    "start:dev": "nodemon index.js",
    "dev": "cross-env NODE_ENV=development concurrently \"npm run start:dev\"",
    "generate:doc": "node generate-doc.js",
    "__comment upd pkg__": "Скрипт запускающий процесс обновления пакетов",
    "update:packages:windows": "node wipe-dependencies.js && rd /s node_modules && npm update --save-dev && npm update --save",
    "update:packages:linux": "node wipe-dependencies.js && rm -r node_modules && npm update --save-dev && npm update --save"
  }

После генерации документации в файл docs_swagger2.yaml добавляется сгенерированные данные в формате yaml, согласно спецификации OpenAPI 2

Содержимое файла docs_swagger2.yaml
---
info:
  description: "Данный сервис определяет основные пользовательские функции"
  title: "Основной игровой сервис"
  version: "1.0.0"
  contact:
    email: "swdaniel@yandex.ru"
host: "localhost:5000"
basePath: "/"
produces:
  - "application/json"
  - "application/xml"
schemes:
  - "http"
  - "https"
securityDefinitions:
  JWT:
    type: "apiKey"
    in: "header"
    name: "Authorization"
    description: ""
externalDocs:
  description: "Ссылка на внешнюю документацию"
  url: "http://localhost:5000/api-docs"
swagger: "2.0"
paths:
  /auth/sign-up:
    post:
      parameters:
        - name: "input"
          in: "body"
          description: "Входные данные"
          required: true
          schema:
            $ref: "#/definitions/SignUpDto"
      description: "Регистрация пользователя"
      tags:
        - "Авторизация (пользователь)"
      responses:
        200:
          description: "Авторизационные данные пользователя"
          schema:
            $ref: "#/definitions/AuthDto"
        default:
          description: "Ошибка запроса"
          schema:
            $ref: "#/definitions/ApiError"
  /auth/sign-in:
    post:
      parameters:
        - name: "input"
          in: "body"
          description: "Входные данные"
          required: true
          schema:
            $ref: "#/definitions/SignInDto"
      description: "Авторизация пользователя"
      tags:
        - "Авторизация (пользователь)"
      responses:
        200:
          description: "Авторизационные данные пользователя"
          schema:
            $ref: "#/definitions/AuthDto"
        default:
          description: "Ошибка запроса"
          schema:
            $ref: "#/definitions/ApiError"
  /auth/logout:
    post:
      parameters:
        - name: "input"
          in: "body"
          description: "Входные данные"
          required: true
          schema:
            $ref: "#/definitions/LogoutDto"
      description: "Выход пользователя из системы"
      tags:
        - "Авторизация (пользователь)"
      responses:
        200:
          description: "Флаг, определяющий успех операции выхода пользователя из системы"
          schema:
            $ref: "#/definitions/SuccessDto"
        default:
          description: "Ошибка запроса"
          schema:
            $ref: "#/definitions/ApiError"
  /auth/management/sign-in:
    post:
      parameters:
        - name: "input"
          in: "body"
          description: "Входные данные"
          required: true
          schema:
            $ref: "#/definitions/SignInDto"
      description: "Авторизация пользователя"
      tags:
        - "Авторизация (для управляющего сайта)"
      responses:
        200:
          description: "Авторизационные данные пользователя"
          schema:
            $ref: "#/definitions/AuthDto"
        default:
          description: "Ошибка запроса"
          schema:
            $ref: "#/definitions/ApiError"
  /auth/management/logout:
    post:
      parameters:
        - name: "input"
          in: "body"
          description: "Входные данные"
          required: true
          schema:
            $ref: "#/definitions/LogoutDto"
      description: "Авторизация пользователя"
      tags:
        - "Авторизация (для управляющего сайта)"
      responses:
        200:
          description: "Флаг, определяющий успех операции выхода пользователя из системы"
          schema:
            $ref: "#/definitions/SuccessDto"
        default:
          description: "Ошибка запроса"
          schema:
            $ref: "#/definitions/ApiError"
  /auth/activate:
    post:
      parameters:
        - name: "input"
          in: "body"
          description: "Входные данные"
          required: true
          schema:
            $ref: "#/definitions/ActivationLinkDto"
      description: "Выход пользователя из системы"
      tags:
        - "Авторизация (пользователь)"
      responses:
        200:
          description: "Флаг, определяющий успех операции подтверждения пользователя"
          schema:
            $ref: "#/definitions/SuccessDto"
        default:
          description: "Ошибка запроса"
          schema:
            $ref: "#/definitions/ApiError"
  /auth/refresh/token:
    post:
      parameters:
        - name: "input"
          in: "body"
          description: "Входные данные"
          required: true
          schema:
            $ref: "#/definitions/RefreshDto"
      description: "Выход пользователя из системы"
      tags:
        - "Авторизация (пользователь)"
      responses:
        200:
          description: "Авторизационные данные пользователя"
          schema:
            $ref: "#/definitions/AuthDto"
        default:
          description: "Ошибка запроса"
          schema:
            $ref: "#/definitions/ApiError"
definitions:
  ActivationLinkDto:
    required:
      - "activation_link"
    properties:
      activation_link:
        type: "string"
        description: ""
  AttributeDto:
    required:
      - "read"
      - "write"
      - "update"
      - "delete"
    properties:
      read:
        type: "boolean"
        description: ""
      write:
        type: "boolean"
        description: ""
      update:
        type: "boolean"
        description: ""
      delete:
        type: "boolean"
        description: ""
  AuthDto:
    required:
      - "tokens"
      - "users_id"
      - "type_auth"
      - "refresh_token"
      - "attributes"
    properties:
      tokens:
        $ref: "#/definitions/TokenDto"
      users_id:
        type: "number"
        description: ""
      type_auth:
        type: "number"
        description: ""
      refresh_token:
        $ref: "#/definitions/ModuleDto"
      attributes:
        $ref: "#/definitions/AttributeDto"
  LogoutDto:
    required:
      - "users_id"
      - "access_token"
      - "refresh_token"
      - "type_auth"
    properties:
      users_id:
        type: "number"
        description: ""
      access_token:
        type: "string"
        description: ""
      refresh_token:
        type: "string"
        description: ""
      type_auth:
        type: "number"
        description: ""
  ModuleDto:
    required:
      - "player"
      - "judge"
      - "creator"
      - "moderator"
      - "manager"
      - "admin"
      - "super_admin"
    properties:
      player:
        type: "boolean"
        description: ""
      judge:
        type: "boolean"
        description: ""
      creator:
        type: "boolean"
        description: ""
      moderator:
        type: "boolean"
        description: ""
      manager:
        type: "boolean"
        description: ""
      admin:
        type: "boolean"
        description: ""
      super_admin:
        type: "boolean"
        description: ""
  RefreshDto:
    required:
      - "refresh_token"
      - "type_auth"
    properties:
      refresh_token:
        type: "string"
        description: ""
      type_auth:
        type: "number"
        description: ""
  SignInDto:
    required:
      - "email"
      - "password"
    properties:
      email:
        type: "string"
        description: ""
      password:
        type: "string"
        description: ""
  SignUpDto:
    required:
      - "email"
      - "password"
      - "phone_num"
      - "location"
      - "date_birthday"
      - "nickname"
      - "name"
      - "surname"
    properties:
      email:
        type: "string"
        description: ""
      password:
        type: "string"
        description: ""
      phone_num:
        type: "string"
        description: ""
      location:
        type: "string"
        description: ""
      date_birthday:
        type: "string"
        description: ""
      nickname:
        type: "string"
        description: ""
      name:
        type: "string"
        description: ""
      surname:
        type: "string"
        description: ""
  TokenDto:
    required:
      - "access_token"
      - "refresh_token"
    properties:
      access_token:
        type: "string"
        description: ""
      refresh_token:
        type: "string"
        description: ""
  SuccessDto:
    required:
      - "success"
    properties:
      success:
        type: "boolean"
        description: ""
  ApiError:
    required:
      - "message"
      - "errors"
    properties:
      message:
        type: "string"
        description: ""
      errors:
        type: "array"
        items:
          $ref: "#/definitions/FieldError"
  FieldError:
    required:
      - "type"
      - "value"
      - "msg"
      - "path"
      - "location"
    properties:
      type:
        type: "string"
        description: ""
      value:
        type: "string"
        description: ""
      msg:
        type: "string"
        description: ""
      path:
        type: "string"
        description: ""
      location:
        type: "string"
        description: ""
responses: {}
parameters: {}
tags:
  - name: "Авторизация (пользователь)"
    description: "Функции для авторизации пользователя"
  - name: "Авторизация (для управляющего сайта)"
    description: "Функция для авторизации пользователя"

Также в скрипте идёт процесс конвертации файла с спецификацией OpenAPI 2, в файл с спецификацией OpenAPI 3

// Процесс конвертации документации в формате Swagger 2 в документацию формата OpenAPI 3
swaggerConverter.convertObj(swaggerDoc, {}, (err, options) => {
    if (err) {
        console.error(err);
    } else {
        // Конвертация JSON в YAML
        const output = jsonToYaml.stringify(options.openapi);

        // Запись результата конвертации документации в файл (он в дальнейшем и используется по умолчанию для вывода документации)
        fs.writeFileSync('./docs/docs.yaml', output);
        process.exit(0);
    }
});

Соответственно после конвертации появится файл docs.yaml, в котором будет содержимое уже в спецификации OpenAPI 3.0

Содержимое файла docs.yaml
---
openapi: "3.0.0"
info:
  description: "Данный сервис определяет основные пользовательские функции"
  title: "Основной игровой сервис"
  version: "1.0.0"
  contact:
    email: "swdaniel@yandex.ru"
externalDocs:
  description: "Ссылка на внешнюю документацию"
  url: "http://localhost:5000/api-docs"
paths:
  /auth/sign-up:
    post:
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SignUpDto"
        description: "Входные данные"
        required: true
      description: "Регистрация пользователя"
      tags:
        - "Авторизация (пользователь)"
      responses:
        200:
          description: "Авторизационные данные пользователя"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthDto"
            application/xml:
              schema:
                $ref: "#/components/schemas/AuthDto"
        default:
          description: "Ошибка запроса"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
            application/xml:
              schema:
                $ref: "#/components/schemas/ApiError"
  /auth/sign-in:
    post:
      requestBody:
        $ref: "#/components/requestBodies/SignInDto"
      description: "Авторизация пользователя"
      tags:
        - "Авторизация (пользователь)"
      responses:
        200:
          description: "Авторизационные данные пользователя"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthDto"
            application/xml:
              schema:
                $ref: "#/components/schemas/AuthDto"
        default:
          description: "Ошибка запроса"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
            application/xml:
              schema:
                $ref: "#/components/schemas/ApiError"
  /auth/logout:
    post:
      requestBody:
        $ref: "#/components/requestBodies/LogoutDto"
      description: "Выход пользователя из системы"
      tags:
        - "Авторизация (пользователь)"
      responses:
        200:
          description: "Флаг, определяющий успех операции выхода пользователя из системы"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessDto"
            application/xml:
              schema:
                $ref: "#/components/schemas/SuccessDto"
        default:
          description: "Ошибка запроса"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
            application/xml:
              schema:
                $ref: "#/components/schemas/ApiError"
  /auth/management/sign-in:
    post:
      requestBody:
        $ref: "#/components/requestBodies/SignInDto"
      description: "Авторизация пользователя"
      tags:
        - "Авторизация (для управляющего сайта)"
      responses:
        200:
          description: "Авторизационные данные пользователя"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthDto"
            application/xml:
              schema:
                $ref: "#/components/schemas/AuthDto"
        default:
          description: "Ошибка запроса"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
            application/xml:
              schema:
                $ref: "#/components/schemas/ApiError"
  /auth/management/logout:
    post:
      requestBody:
        $ref: "#/components/requestBodies/LogoutDto"
      description: "Авторизация пользователя"
      tags:
        - "Авторизация (для управляющего сайта)"
      responses:
        200:
          description: "Флаг, определяющий успех операции выхода пользователя из системы"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessDto"
            application/xml:
              schema:
                $ref: "#/components/schemas/SuccessDto"
        default:
          description: "Ошибка запроса"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
            application/xml:
              schema:
                $ref: "#/components/schemas/ApiError"
  /auth/activate:
    post:
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ActivationLinkDto"
        description: "Входные данные"
        required: true
      description: "Выход пользователя из системы"
      tags:
        - "Авторизация (пользователь)"
      responses:
        200:
          description: "Флаг, определяющий успех операции подтверждения пользователя"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SuccessDto"
            application/xml:
              schema:
                $ref: "#/components/schemas/SuccessDto"
        default:
          description: "Ошибка запроса"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
            application/xml:
              schema:
                $ref: "#/components/schemas/ApiError"
  /auth/refresh/token:
    post:
      requestBody:
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/RefreshDto"
        description: "Входные данные"
        required: true
      description: "Выход пользователя из системы"
      tags:
        - "Авторизация (пользователь)"
      responses:
        200:
          description: "Авторизационные данные пользователя"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthDto"
            application/xml:
              schema:
                $ref: "#/components/schemas/AuthDto"
        default:
          description: "Ошибка запроса"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ApiError"
            application/xml:
              schema:
                $ref: "#/components/schemas/ApiError"
tags:
  - name: "Авторизация (пользователь)"
    description: "Функции для авторизации пользователя"
  - name: "Авторизация (для управляющего сайта)"
    description: "Функция для авторизации пользователя"
servers:
  - url: "http://localhost:5000"
  - url: "https://localhost:5000"
components:
  requestBodies:
    SignInDto:
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/SignInDto"
      description: "Входные данные"
      required: true
    LogoutDto:
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/LogoutDto"
      description: "Входные данные"
      required: true
  securitySchemes:
    JWT:
      type: "apiKey"
      in: "header"
      name: "Authorization"
      description: ""
  schemas:
    ActivationLinkDto:
      required:
        - "activation_link"
      properties:
        activation_link:
          type: "string"
          description: ""
    AttributeDto:
      required:
        - "read"
        - "write"
        - "update"
        - "delete"
      properties:
        read:
          type: "boolean"
          description: ""
        write:
          type: "boolean"
          description: ""
        update:
          type: "boolean"
          description: ""
        delete:
          type: "boolean"
          description: ""
    AuthDto:
      required:
        - "tokens"
        - "users_id"
        - "type_auth"
        - "refresh_token"
        - "attributes"
      properties:
        tokens:
          $ref: "#/components/schemas/TokenDto"
        users_id:
          type: "number"
          description: ""
        type_auth:
          type: "number"
          description: ""
        refresh_token:
          $ref: "#/components/schemas/ModuleDto"
        attributes:
          $ref: "#/components/schemas/AttributeDto"
    LogoutDto:
      required:
        - "users_id"
        - "access_token"
        - "refresh_token"
        - "type_auth"
      properties:
        users_id:
          type: "number"
          description: ""
        access_token:
          type: "string"
          description: ""
        refresh_token:
          type: "string"
          description: ""
        type_auth:
          type: "number"
          description: ""
    ModuleDto:
      required:
        - "player"
        - "judge"
        - "creator"
        - "moderator"
        - "manager"
        - "admin"
        - "super_admin"
      properties:
        player:
          type: "boolean"
          description: ""
        judge:
          type: "boolean"
          description: ""
        creator:
          type: "boolean"
          description: ""
        moderator:
          type: "boolean"
          description: ""
        manager:
          type: "boolean"
          description: ""
        admin:
          type: "boolean"
          description: ""
        super_admin:
          type: "boolean"
          description: ""
    RefreshDto:
      required:
        - "refresh_token"
        - "type_auth"
      properties:
        refresh_token:
          type: "string"
          description: ""
        type_auth:
          type: "number"
          description: ""
    SignInDto:
      required:
        - "email"
        - "password"
      properties:
        email:
          type: "string"
          description: ""
        password:
          type: "string"
          description: ""
    SignUpDto:
      required:
        - "email"
        - "password"
        - "phone_num"
        - "location"
        - "date_birthday"
        - "nickname"
        - "name"
        - "surname"
      properties:
        email:
          type: "string"
          description: ""
        password:
          type: "string"
          description: ""
        phone_num:
          type: "string"
          description: ""
        location:
          type: "string"
          description: ""
        date_birthday:
          type: "string"
          description: ""
        nickname:
          type: "string"
          description: ""
        name:
          type: "string"
          description: ""
        surname:
          type: "string"
          description: ""
    TokenDto:
      required:
        - "access_token"
        - "refresh_token"
      properties:
        access_token:
          type: "string"
          description: ""
        refresh_token:
          type: "string"
          description: ""
    SuccessDto:
      required:
        - "success"
      properties:
        success:
          type: "boolean"
          description: ""
    ApiError:
      required:
        - "message"
        - "errors"
      properties:
        message:
          type: "string"
          description: ""
        errors:
          type: "array"
          items:
            $ref: "#/components/schemas/FieldError"
    FieldError:
      required:
        - "type"
        - "value"
        - "msg"
        - "path"
        - "location"
      properties:
        type:
          type: "string"
          description: ""
        value:
          type: "string"
          description: ""
        msg:
          type: "string"
          description: ""
        path:
          type: "string"
          description: ""
        location:
          type: "string"
          description: ""

Чтобы убедиться в работоспособности данной документации можно вставить содержимое файла docs.yaml в Swagger Editor

Рисунок 6 - Демонстрация факта корректной генерации документации API
Рисунок 6 - Демонстрация факта корректной генерации документации API

Используемые пакеты

Все используемые пакеты в сервисе NEJ представлены в удалённом репозитории.

Содержимое файла package.json
{
  "name": "express-swagger",
  "version": "0.0.1",
  "description": "Основной сервер",
  "main": "index.js",
  "type": "module",
  "author": {
    "name": "Solopov Daniil <swdaniel@yandex.ru>"
  },
  "scripts": {
    "start": "cross-env NODE_ENV=production nodemon index.js",
    "start:dev": "nodemon index.js",
    "dev": "cross-env NODE_ENV=development concurrently \"npm run start:dev\"",
    "generate:doc": "node generate-doc.js",
    "__comment upd pkg__": "Скрипт запускающий процесс обновления пакетов",
    "update:packages:windows": "node wipe-dependencies.js && rd /s node_modules && npm update --save-dev && npm update --save",
    "update:packages:linux": "node wipe-dependencies.js && rm -r node_modules && npm update --save-dev && npm update --save"
  },
  "keywords": [
    "postgresql",
    "react",
    "nodejs",
    "express"
  ],
  "license": "ISC",
  "dependencies": {
    "bcryptjs": "^2.4.3",
    "config": "^3.3.9",
    "cookie-parser": "^1.4.6",
    "cors": "^2.8.5",
    "dotenv": "^16.0.3",
    "express": "^4.18.2",
    "express-validator": "^7.0.1",
    "googleapis": "^118.0.0",
    "json2yaml": "^1.1.0",
    "jsonwebtoken": "^9.0.0",
    "node-fetch": "^3.3.1",
    "node-geocoder": "^4.2.0",
    "node-unique-id-generator": "^0.1.0",
    "nodemailer": "^6.9.1",
    "pg": "^8.10.0",
    "pg-hstore": "^2.3.4",
    "sequelize": "^6.31.0",
    "socket.io": "^4.6.1",
    "swagger-jsdoc": "^6.2.8",
    "swagger-ui-express": "^4.6.3",
    "swagger2openapi": "^7.0.8",
    "uuid": "^9.0.0",
    "winston": "^3.8.2",
    "yamljs": "^0.3.0"
  },
  "devDependencies": {
    "concurrently": "^8.0.1",
    "cross-env": "^7.0.3",
    "express-swagger-generator": "^1.1.17",
    "nodemon": "^2.0.22",
    "oas3-tools": "^2.2.3",
    "sequelize-cli": "^6.6.0",
    "swagger-autogen": "^2.23.1"
  }
}

Перейдём к функциональным особенностям пакета express-swagger-generator.

Настройки для Swagger'a

Все настройки для Swagger'а, которые используются при генерации документации по спецификации OpenAPI 2 представлены в файле /config/swagger.options.js

// Функция для формирования настроек генерации документации
const options = (dirname) => {
    return {
        // Определения для Swagger'a
        swaggerDefinition: {
            // Блок информации
            info: {
                description: 'Данный сервис определяет основные пользовательские функции',  // Описание
                title: 'Основной игровой сервис',                                           // Название
                version: '1.0.0',                                                           // Версия
                contact: {                                                                  // Контакты
                    email: "swdaniel@yandex.ru"
                }
            },
            host: 'localhost:5000',                                                         // Основной хост
            basePath: '/',                                                                  // Базовый путь
            produces: [                                                                     
                "application/json",
                "application/xml"
            ],
            schemes: ['http', 'https'],
            securityDefinitions: {                                                          // Определения безопасности
                JWT: {
                    type: 'apiKey',
                    in: 'header',
                    name: 'Authorization',
                    description: "",
                }
            },
            externalDocs: {                                                                 // Ссылка на внешнюю документацию
                description: 'Ссылка на внешнюю документацию',
                url: 'http://localhost:5000/api-docs'
            },
        },
        // Маршрут, по которому будет доступна документация в браузере
        route: {
            url: '/docs/swagger2',
            docs: '/swagger.json',
        },
        basedir: dirname,
        // Файлы, которые будут просматриваться генератором и которые будут влиять на конечный результат
        files: ['./routers/*.js', './dtos/**/*.js', './models/**/*.js', './exceptions/*.js']
    };
}

export default options;

С помощью options подключаемый модуль express-swagger-generator понимает, каким образом генерировать документацию, какие файлы учитывать (по каком файлам делать обход), по какому маршруту нужно выводить документацию, определяет основную информацию на странице документации и так далее.

Документирование API

Ранее мы уже определили, что документирование API начинается с контроллеров, а если быть точнее - роутеров, которые связывают конкретные адреса с контроллерами. Пора продемонстрировать каким образом пакет express-swagger-generator помогает составить документацию, используя классический JSDoc.

Разберём документирование API на примере POST-запроса на регистрацию нового пользователя.

/**
 * Регистрация пользователя
 * @route POST /auth/sign-up
 * @group Авторизация (пользователь) - Функции для авторизации пользователя
 * @param {SignUpDto.model} input.body.required Входные данные
 * @returns {AuthDto.model} 200 - Авторизационные данные пользователя
 * @returns {ApiError.model} default - Ошибка запроса
 */
router.post(
    AuthRoute.signUp, // Константа конкретного адреса
    [
        // Валидация входных данных
        check('email', 'Введите корректный email').isEmail(),
        check('password', 'Минимальная длина пароля должна быть 6 символов, а максимальная длина пароля - 32 символа')
            .isLength({ min: 6, max: 32 }),
        check('phone_num', 'Некорректный номер телефона').isMobilePhone("ru-RU"),
        check('location', 'Максимальная длина местоположение не может быть меньше 3 символов')
            .isLength({ min: 3 }),
        check('date_birthday', "Некорректная дата рождения").isDate({
            format: "YYYY-MM-DD"
        }),
        check('nickname', 'Минимальная длина для никнейма равна 2 символам')
            .isLength({ min: 2 }),
        check('name', 'Минимальная длина для имени равна 2 символам')
            .isLength({ min: 2 }),
        check('surname', 'Минимальная длина для фамилии равна 2 символам')
            .isLength({ min: 2 })
    ],
    authController.signUp // Конкретный метод контроллера
);

Можно заметить, что описание для данного API представлено в виде последовательности комментариев:

/**
 * Регистрация пользователя
 * @route POST /auth/sign-up
 * @group Авторизация (пользователь) - Функции для авторизации пользователя
 * @param {SignUpDto.model} input.body.required Входные данные
 * @returns {AuthDto.model} 200 - Авторизационные данные пользователя
 * @returns {ApiError.model} default - Ошибка запроса
 */

Эти комментарии по структуре похожи на JSDoc-комментарии, однако их интерпретация используемым пакетом осуществляется по своему.

Дадим разъяснения данным комментариям:

  1. В первой строке многострочного комментария представлено описание конкретного API

  2. @ route POST /auth/sign-up - привязка API к текущему адресу в документации

  3. @ group Авторизация ... - соотнесение данного API к конкретной группе (аналогично, что и tag)

  4. @ param {SignUpDto.model} input.body.required - определение модели входных данных (без необходимости в ссылках #ref, по спецификации OpenAPI)

  5. @ returns {AuthDto.model} 200 - описание модели выходных данных (при успешной обработке запроса)

  6. @ return {ApiError.model} default - обработка всех ошибок по умолчанию

Каким образом формируются модели? Приведу пример с моделью SignUpDto:

/**
 * @typedef SignUpDto
 * @property {string} email.required
 * @property {string} password.required
 * @property {string} phone_num.required
 * @property {string} location.required
 * @property {string} date_birthday.required
 * @property {string} nickname.required
 * @property {string} name.required
 * @property {string} surname.required
 */
class SignUpDto {
    email;          // Email-адрес
    password;       // Пароль
    phone_num;      // Номер телефона
    location;       // Локация
    date_birthday;  // День рождения
    nickname;       // Никнейм
    name;           // Имя
    surname;        // Фамилия

    constructor(model) {
        this.email = model.email;
        this.password = model.password;
        this.phone_num = model.phone_num;
        this.location = model.location;
        this.date_birthday = model.date_birthday;
        this.nickname = model.nickname;
        this.name = model.name;
        this.surname = model.surname;
    }
}

export default SignUpDto;

Интересует именно описание в многострочном комментарии:

/**
 * @typedef SignUpDto
 * @property {string} email.required
 * @property {string} password.required
 * @property {string} phone_num.required
 * @property {string} location.required
 * @property {string} date_birthday.required
 * @property {string} nickname.required
 * @property {string} name.required
 * @property {string} surname.required
 */

Дадим пояснение данному определению:

  1. @ typedef SignUpDto - определение модели (схемы) SignUpDto (на которую потом можно будет ссылаться)

  2. @ property {string} email.required - определение параметра email, с типом string

  3. Остальное - по аналогии со вторым элементом из списка

Также можно сделать более сложное определение:

import TokenDto from "./token-dto.js";
import ModuleDto from "./module-dto.js";
import AttributeDto from "./attribute-dto.js";

/**
 * @typedef AuthDto
 * @property {TokenDto.model} tokens.required
 * @property {number} users_id.required
 * @property {number} type_auth.required
 * @property {ModuleDto.model} refresh_token.required
 * @property {AttributeDto.model} attributes.required
 */
class AuthDto {
    tokens;         // Токены
    users_id;       // Идентификатор пользователя
    type_auth;      // Тип авторизации
    modules;        // Модель
    attributes;     // Атрибуты

    constructor(model){
        this.tokens = model.tokens;
        this.users_id = model.tokens;
        this.type_auth = model.type_auth;
        this.modules = model.modules;
        this.attributes = model.attributes;
    }
}

export default AuthDto;

Дадим пояснения тому, что есть в многострочном комментарии:

  1. @ typedef AuthDto - создание модели AuthDto

  2. @ property {TokenDto.model} tokens.required - создание параметра в модели, которое по сути является другой моделью (вложенность схем)

  3. Далее - по аналогии

Необязательно многострочные комментарии с определением моделей держать рядом с моделями, можно держать их и отдельно. Например, следующее определение будет работать корректно:

/**
 * @typedef SignUpDto
 * @property {string} email.required
 * @property {string} password.required
 * @property {string} phone_num.required
 * @property {string} location.required
 * @property {string} date_birthday.required
 * @property {string} nickname.required
 * @property {string} name.required
 * @property {string} surname.required
 */

/**
 * Регистрация пользователя
 * @route POST /auth/sign-up
 * @group Авторизация (пользователь) - Функции для авторизации пользователя
 * @param {SignUpDto.model} input.body.required Входные данные (всё ок)
 * @returns {AuthDto.model} 200 - Авторизационные данные пользователя
 * @returns {ApiError.model} default - Ошибка запроса
 */
router.post(
    AuthRoute.signUp, // Константа конкретного адреса
    [
        // Валидация входных данных
        check('email', 'Введите корректный email').isEmail(),
        check('password', 'Минимальная длина пароля должна быть 6 символов, а максимальная длина пароля - 32 символа')
            .isLength({ min: 6, max: 32 }),
        check('phone_num', 'Некорректный номер телефона').isMobilePhone("ru-RU"),
        check('location', 'Максимальная длина местоположение не может быть меньше 3 символов')
            .isLength({ min: 3 }),
        check('date_birthday', "Некорректная дата рождения").isDate({
            format: "YYYY-MM-DD"
        }),
        check('nickname', 'Минимальная длина для никнейма равна 2 символам')
            .isLength({ min: 2 }),
        check('name', 'Минимальная длина для имени равна 2 символам')
            .isLength({ min: 2 }),
        check('surname', 'Минимальная длина для фамилии равна 2 символам')
            .isLength({ min: 2 })
    ],
    authController.signUp // Конкретный метод контроллера
);

Однако, рекомендуется держать определение моделей в многострочных комментариях держать с их фактическим местоположением, ради достижения модульности, ведь в настройках этот момент учтён.

Документирование сервиса GG

Изучим файловую структуру проекта GG

Рисунок 7 - Файловая структура проекта GG
Рисунок 7 - Файловая структура проекта GG

Приведу пояснения по файловой структуре проекта (только основные элементы):

  1. cmd - директория, в которой определена точка входа в серверное приложение (main.go)

  2. config - директория с файлами конфигурации

  3. database - директория, в которой содержатся схемы и дампы базы данных

  4. docs - директория, в которой располагается сгенерированная документация

  5. logs - логи сервера

  6. pkg - основные пакеты сервиса

  7. server.go - определение сервера

Точка входа в GG

Код точки входа в серверное приложение GG выглядит следующим образом:

package main

import (
	"context"
	"fmt"
	mainserver "main-server"
	"main-server/config"
	initConfigure "main-server/config"
	handler "main-server/pkg/handler"
	repository "main-server/pkg/repository"
	"main-server/pkg/service"
	"os"
	"os/signal"
	"syscall"

	"github.com/casbin/casbin/v2"
	gormadapter "github.com/casbin/gorm-adapter/v3"
	"github.com/joho/godotenv"
	_ "github.com/lib/pq"
	"github.com/sirupsen/logrus"
	"github.com/spf13/viper"
	"gorm.io/driver/postgres"
	"gorm.io/gorm"
)

// @title Основной сервис
// @version 1.0
// description Основной сервис

// @host localhost:5000
// @BasePath /

// @securityDefinitions.apikey ApiKeyAuth
// @in header
// @name Authorization

func main() {

	// Инициализация конфигурации сервера
	if err := initConfig(); err != nil {
		logrus.Fatalf("error initializing configs: %s", err.Error())
	}

	// Инициализация переменных внешней среды
	if err := godotenv.Load(); err != nil {
		logrus.Fatalf("error loading env variable: %s", err.Error())
	}

	// Инициализация логгера
	openLogFiles, err := initConfigure.InitLogrus()
	if err != nil {
		logrus.Error("Ошибка при настройке параметров логгера. Вывод всех ошибок будет осуществлён в консоль")
	}

	// Закрытие всех открытых файлов в результате настройки логгера
	defer func() {
		for _, item := range openLogFiles {
			item.Close()
		}
	}()

	// Создание нового подключения к БД
	db, err := repository.NewPostgresDB(repository.Config{
		Host:     viper.GetString("db.host"),
		Port:     viper.GetString("db.port"),
		Username: viper.GetString("db.username"),
		DBName:   viper.GetString("db.dbname"),
		SSLMode:  viper.GetString("db.sslmode"),
		Password: os.Getenv("DB_PASSWORD"),
	})

	// Создание строки DNS
	dns := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=%s",
		viper.GetString("db.host"),
		viper.GetString("db.username"),
		os.Getenv("DB_PASSWORD"),
		viper.GetString("db.dbname"),
		viper.GetString("db.port"),
		viper.GetString("db.sslmode"),
	)

	// Получение адаптера после открытия подключения к базе данных через gorm
	dbAdapter, err := gorm.Open(postgres.New(postgres.Config{
		DSN: dns,
	}), &gorm.Config{})

	// Создание нового адаптера c кастомной таблицей
	adapter, err := gormadapter.NewAdapterByDBWithCustomTable(dbAdapter, &config.AcRule{}, viper.GetString("rules_table_name"))

	if err != nil {
		logrus.Fatalf("failed to initialize adapter by db with custom table: %s", err.Error())
	}

	// Определение нового объекта enforcer, по модели PERM
	enforcer, err := casbin.NewEnforcer(viper.GetString("paths.perm_model"), adapter)

	if err != nil {
		logrus.Fatalf("failed to initialize new enforcer: %s", err.Error())
	}

	if err != nil {
		logrus.Fatalf("failed to initialize db: %s", err.Error())
	}

	// Инициализация OAuth2 сервисов
	config.InitOAuth2Config()
	config.InitVKAuthConfig()

	// Dependency Injection
	repos := repository.NewRepository(db, enforcer)
	service := service.NewService(repos)
	handlers := handler.NewHandler(service)

	srv := new(mainserver.Server)

	go func() {
		if err := srv.Run(viper.GetString("port"), handlers.InitRoutes()); err != nil {
			logrus.Fatalf("error occured while running http server: %s", err.Error())
		}
	}()

	logrus.Print("Main Server Started")

	// Реализация Graceful Shutdown
	// Блокировка функции main с помощью канала os.Signal
	quit := make(chan os.Signal, 1)

	// Запись в канал, если процесс, в котором выполняется приложение
	// получит сигнал SIGTERM или SIGINT
	signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)

	// Чтение из канала, блокирующая выполнение функции main
	<-quit

	logrus.Print("Rental Housing Main Server Shutting Down")

	if err := srv.Shutdown(context.Background()); err != nil {
		logrus.Errorf("error occured on server shutting down: %s", err.Error())
	}

	if err := db.Close(); err != nil {
		logrus.Errorf("error occured on db connection close: %s", err.Error())
	}
}

/* Инициализация файлов конфигурации */
func initConfig() error {
	viper.AddConfigPath("config")
	viper.SetConfigName("config")

	return viper.ReadInConfig()
}

В данной точке входа основной интерес вызывает множество комментариев, которые задают базовые настройки для вывода Swagger-документации (аналогично замыканию options из сервиса NEJ).

// @title Основной сервис
// @version 1.0
// description Основной сервис

// @host localhost:5000
// @BasePath /

// @securityDefinitions.apikey ApiKeyAuth
// @in header
// @name Authorization

В данных комментариях содержится та же информация, что и содержалось в options для сервиса NEJ.

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

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

Рисунок 8 - Содержимое директории pkg
Рисунок 8 - Содержимое директории pkg

В директории присутствует каталог handler (обработчики, они же - контроллеры), repository и service. Минимум необходимого для CSR наблюдается.

Если связь контроллеров с определённым адресом формируется в handler, то там и следует описывать API.

Документирование GG

Приведу пример документирования одного из маршрутов сервиса - авторизация пользователя.

// @Summary Авторизация пользователя
// @Tags API для авторизации и регистрации пользователя
// @Description Авторизация пользователя
// @ID auth-sign-in
// @Accept  json
// @Produce  json
// @Param input body userModel.UserSignInModel true "credentials"
// @Success 200 {object} userModel.TokenAccessModel "data"
// @Failure 400,404 {object} httpModel.ResponseMessage
// @Failure 500 {object} httpModel.ResponseMessage
// @Failure default {object} httpModel.ResponseMessage
// @Router /auth/sign-in [post]
func (h *AuthHandler) signIn(c *gin.Context) {
    // Определение входной модели
	var input userModel.UserSignInModel

    // Связывание переменной входной модели с данными из пользовательского запроса
	if err := c.BindJSON(&input); err != nil {
		utilContext.NewErrorResponse(c, http.StatusBadRequest, "invalid input body")
		return
	}

    // Передача данных в слой сервисов
	data, err := h.services.Authorization.LoginUser(input)
	if err != nil {
		utilContext.NewErrorResponse(c, http.StatusBadRequest, err.Error())
		return
	}

	// Добавление токена обновления в http only cookie
	c.SetCookie(viper.GetString("environment.refresh_token_key"), data.RefreshToken,
		30*24*60*60*1000, "/", viper.GetString("environment.domain"), false, true)
	c.SetSameSite(config.HTTPSameSite)

    // Отправка ответа клиенту
	c.JSON(http.StatusOK, userModel.TokenAccessModel{
		AccessToken: data.AccessToken,
	})
}

Наибольший интерес представляют комментарии:

// @Summary Авторизация пользователя
// @Tags API для авторизации и регистрации пользователя
// @Description Авторизация пользователя
// @ID auth-sign-in
// @Accept  json
// @Produce  json
// @Param input body userModel.UserSignInModel true "credentials"
// @Success 200 {object} userModel.TokenAccessModel "data"
// @Failure 400,404 {object} httpModel.ResponseMessage
// @Failure 500 {object} httpModel.ResponseMessage
// @Failure default {object} httpModel.ResponseMessage
// @Router /auth/sign-in [post]

Здесь структура отличается от той, которая была использована в сервисе NEJ. В данном случае используется однострочный комментарий.

Поясним некоторые параметры при описании API:

  1. @ Summary - название запроса в документации

  2. @ Tags - теги для группировки API

  3. @ Description - описание запроса в документации

  4. @ ID - идентификатор API

  5. @ Accept - данные, которые будут приниматься на входе запроса

  6. @ Produce - формат данных, которые будут возвращаться в ответе

  7. @ Param input body userModel.UserSignInModel - параметры запроса

  8. @ Success 200 {object} userModel.TokenAccessModel "data" - код ответа, формат данных и описание

  9. @ Failure 400,404 {object} httpModel.ResponseMessage - код ответа, формат данных и описание

  10. @ Router /auth/sign-in [post] - путь запроса и метод запроса(HTTP)

Полное определение обработчиков для системы авторизации
package auth

import (
	"fmt"
	config "main-server/config"
	middlewareConstant "main-server/pkg/constant/middleware"
	pathConstant "main-server/pkg/constant/path"
	utilContext "main-server/pkg/handler/util"
	httpModel "main-server/pkg/model/http"
	userModel "main-server/pkg/model/user"
	"net/http"

	"github.com/gin-gonic/gin"
	uuid "github.com/satori/go.uuid"
	"github.com/spf13/viper"
)

// @Summary Регистрация нового пользователя
// @Tags API для авторизации и регистрации пользователя
// @Description Регистрация нового пользователя
// @ID auth-sign-up
// @Accept  json
// @Produce  json
// @Param input body userModel.UserSignUpModel true "account info"
// @Success 200 {object} userModel.TokenAccessModel "data"
// @Failure 400,404 {object} httpModel.ResponseMessage
// @Failure 500 {object} httpModel.ResponseMessage
// @Failure default {object} httpModel.ResponseMessage
// @Router /auth/sign-up [post]
func (h *AuthHandler) signUp(c *gin.Context) {
	var input userModel.UserSignUpModel
	if err := c.BindJSON(&input); err != nil {
		utilContext.NewErrorResponse(c, http.StatusBadRequest, err.Error())
		return
	}

	data, err := h.services.Authorization.CreateUser(input)
	if err != nil {
		utilContext.NewErrorResponse(c, http.StatusInternalServerError, err.Error())
		return
	}

	// Добавление токена обновления в http only cookie
	c.SetCookie(viper.GetString("environment.refresh_token_key"), data.RefreshToken,
		30*24*60*60*1000, "/", viper.GetString("environment.domain"), false, true)
	c.SetSameSite(config.HTTPSameSite)

	c.JSON(http.StatusOK, userModel.TokenAccessModel{
		AccessToken: data.AccessToken,
	})
}

// @Summary Загрузка пользовательского изображения
// @Tags API для авторизации и регистрации пользователя
// @Description Загрузка пользовательского изображения
// @ID auth-sign-up-upload-image
// @Accept  json
// @Produce  json
// @Param input body userModel.UserSignUpModel true "account info"
// @Success 200 {object} userModel.TokenAccessModel "data"
// @Failure 400,404 {object} httpModel.ResponseMessage
// @Failure 500 {object} httpModel.ResponseMessage
// @Failure default {object} httpModel.ResponseMessage
// @Router /auth/sign-up/upload/image [post]
func (h *AuthHandler) uploadProfileImage(c *gin.Context) {
	form, err := c.MultipartForm()
	if err != nil {
		utilContext.NewErrorResponse(c, http.StatusBadRequest, err.Error())
		return
	}

	// Получение информации о файле из формы
	images := form.File["file"]
	profileImage := images[len(images)-1]
	filepath := pathConstant.PUBLIC_USER + uuid.NewV4().String()

	_, err = h.services.Authorization.UploadProfileImage(c, filepath)
	if err != nil {
		utilContext.NewErrorResponse(c, http.StatusInternalServerError, err.Error())
		return
	}

	// Загрузка файла на сервер
	c.SaveUploadedFile(profileImage, filepath)

	c.JSON(http.StatusOK, httpModel.ResponseStatus{
		Status: "Изображение профиля пользователя было обновлено",
	})
}

// @Summary Авторизация пользователя
// @Tags API для авторизации и регистрации пользователя
// @Description Авторизация пользователя
// @ID auth-sign-in
// @Accept  json
// @Produce  json
// @Param input body userModel.UserSignInModel true "credentials"
// @Success 200 {object} userModel.TokenAccessModel "data"
// @Failure 400,404 {object} httpModel.ResponseMessage
// @Failure 500 {object} httpModel.ResponseMessage
// @Failure default {object} httpModel.ResponseMessage
// @Router /auth/sign-in [post]
func (h *AuthHandler) signIn(c *gin.Context) {
	var input userModel.UserSignInModel
	if err := c.BindJSON(&input); err != nil {
		utilContext.NewErrorResponse(c, http.StatusBadRequest, "invalid input body")
		return
	}

	data, err := h.services.Authorization.LoginUser(input)
	if err != nil {
		utilContext.NewErrorResponse(c, http.StatusBadRequest, err.Error())
		return
	}

	// Добавление токена обновления в http only cookie
	c.SetCookie(viper.GetString("environment.refresh_token_key"), data.RefreshToken,
		30*24*60*60*1000, "/", viper.GetString("environment.domain"), false, true)
	c.SetSameSite(config.HTTPSameSite)

	c.JSON(http.StatusOK, userModel.TokenAccessModel{
		AccessToken: data.AccessToken,
	})
}

// @Summary Авторизация пользователя через VK (no work)
// @Tags API для авторизации и регистрации пользователя
// @Description Авторизация пользователя через VK (no work)
// @ID auth-sign-in-vk
// @Accept  json
// @Produce  json
// @Param input body userModel.UserSignInModel true "credentials"
// @Success 200 {object} userModel.TokenAccessModel "data"
// @Failure 400,404 {object} httpModel.ResponseMessage
// @Failure 500 {object} httpModel.ResponseMessage
// @Failure default {object} httpModel.ResponseMessage
// @Router /auth/sign-in/vk [post]
func (h *AuthHandler) signInVK(c *gin.Context) {
	var input userModel.UserSignInModel

	if err := c.BindJSON(&input); err != nil {
		utilContext.NewErrorResponse(c, http.StatusBadRequest, "invalid input body")
		return
	}

	data, err := h.services.Authorization.LoginUser(input)
	if err != nil {
		utilContext.NewErrorResponse(c, http.StatusBadRequest, err.Error())
		return
	}

	// Добавление токена обновления в http only cookie
	c.SetCookie(viper.GetString("environment.refresh_token_key"), data.RefreshToken,
		30*24*60*60*1000, "/", viper.GetString("environment.domain"), false, true)
	c.SetSameSite(config.HTTPSameSite)

	c.JSON(http.StatusOK, userModel.TokenAccessModel{
		AccessToken: data.AccessToken,
	})
}

// @Summary Авторизация пользователя через Google OAuth2
// @Tags API для авторизации и регистрации пользователя
// @Description Авторизация пользователя через Google OAuth2
// @ID auth-sign-in-oauth2
// @Accept  json
// @Produce  json
// @Param input body userModel.GoogleOAuth2Code true "credentials"
// @Success 200 {object} userModel.TokenAccessModel "data"
// @Failure 400,404 {object} httpModel.ResponseMessage
// @Failure 500 {object} httpModel.ResponseMessage
// @Failure default {object} httpModel.ResponseMessage
// @Router /auth/sign-in/oauth2 [post]
func (h *AuthHandler) signInOAuth2(c *gin.Context) {
	var input userModel.GoogleOAuth2Code

	if err := c.BindJSON(&input); err != nil {
		utilContext.NewErrorResponse(c, http.StatusBadRequest, "invalid input body")
		return
	}

	// For fast tests
	/*token, _ := configs.AppOAuth2Config.GoogleLogin.Exchange(c, input.Code)
	_, _ = google_oauth2.RevokeToken(token.AccessToken)
	return*/

	data, err := h.services.Authorization.LoginUserOAuth2(input.Code)
	if err != nil {
		utilContext.NewErrorResponse(c, http.StatusBadRequest, err.Error())
		return
	}

	// Добавление токена обновления в http only cookie
	c.SetCookie(viper.GetString("environment.refresh_token_key"), data.RefreshToken,
		30*24*60*60*1000, "/", viper.GetString("environment.domain"), false, true)
	c.SetSameSite(config.HTTPSameSite)

	c.JSON(http.StatusOK, userModel.TokenAccessModel{
		AccessToken: data.AccessToken,
	})
}

// @Summary Обновление токена доступа
// @Tags API для авторизации и регистрации пользователя
// @Description Обновление токена доступа
// @ID auth-refresh
// @Accept  json
// @Produce  json
// @Param Authorization header string true "Токен доступа для текущего пользователя" example(Bearer access_token)
// @Success 200 {object} userModel.TokenAccessModel "data"
// @Failure 400,404 {object} httpModel.ResponseMessage
// @Failure 500 {object} httpModel.ResponseMessage
// @Failure default {object} httpModel.ResponseMessage
// @Router /auth/refresh [post]
func (h *AuthHandler) refresh(c *gin.Context) {

	// Получение токена обновления из файла cookie
	refreshToken, err := c.Cookie(viper.GetString("environment.refresh_token_key"))

	fmt.Println(refreshToken)

	if err != nil {
		utilContext.NewErrorResponse(c, http.StatusUnauthorized, err.Error())
		return
	}

	// Получение дополнительной авторизационной информации пользователя (после работы middleware цепочки)
	accessToken, _ := c.Get(middlewareConstant.ACCESS_TOKEN_CTX)
	authTypeValue, _ := c.Get(middlewareConstant.AUTH_TYPE_VALUE_CTX)
	tokenApi, _ := c.Get(middlewareConstant.TOKEN_API_CTX)

	// Обновление токена доступа
	data, err := h.services.Authorization.Refresh(userModel.TokenLogoutDataModel{
		AccessToken:   accessToken.(string),
		RefreshToken:  refreshToken,
		AuthTypeValue: authTypeValue.(string),
		TokenApi:      tokenApi.(*string),
	}, refreshToken)

	if err != nil {
		utilContext.NewErrorResponse(c, http.StatusUnauthorized, err.Error())
		return
	}

	// Установка нового токена обновления (необходимо, если токен обновления изменился)
	c.SetCookie(viper.GetString("environment.refresh_token_key"), data.RefreshToken,
		30*24*60*60*1000, "/", viper.GetString("environment.domain"), false, true)
	c.SetSameSite(config.HTTPSameSite)

	c.JSON(http.StatusOK, userModel.TokenAccessModel{
		AccessToken: data.AccessToken,
	})
}

type LogoutOutputModel struct {
	IsLogout bool `json:"is_logout"`
}

// @Summary Выход из аккаунта
// @Tags API для авторизации и регистрации пользователя
// @Description Выход из аккаунта
// @ID auth-logout
// @Accept  json
// @Produce  json
// @Param Authorization header string true "Токен доступа для текущего пользователя" example(Bearer access_token)
// @Success 200 {object} LogoutOutputModel "data"
// @Failure 400,404 {object} httpModel.ResponseMessage
// @Failure 500 {object} httpModel.ResponseMessage
// @Failure default {object} httpModel.ResponseMessage
// @Router /auth/logout [post]
func (h *AuthHandler) logout(c *gin.Context) {
	refreshToken, err := c.Cookie(viper.GetString("environment.refresh_token_key"))

	if err != nil {
		utilContext.NewErrorResponse(c, http.StatusBadRequest, err.Error())
		return
	}

	accessToken, _ := c.Get(middlewareConstant.ACCESS_TOKEN_CTX)
	authTypeValue, _ := c.Get(middlewareConstant.AUTH_TYPE_VALUE_CTX)
	tokenApi, _ := c.Get(middlewareConstant.TOKEN_API_CTX)

	data, err := h.services.Authorization.Logout(userModel.TokenLogoutDataModel{
		AccessToken:   accessToken.(string),
		RefreshToken:  refreshToken,
		AuthTypeValue: authTypeValue.(string),
		TokenApi:      tokenApi.(*string),
	})

	if err != nil {
		utilContext.NewErrorResponse(c, http.StatusInternalServerError, err.Error())
		return
	}

	if data {
		c.SetCookie(viper.GetString("environment.refresh_token_key"), "",
			30*24*60*60*1000, "/", viper.GetString("environment.domain"), false, true)
		c.SetSameSite(config.HTTPSameSite)
	}

	c.JSON(http.StatusOK, LogoutOutputModel{
		IsLogout: data,
	})
}

// @Summary Активация аккаунта по почте
// @Tags API для авторизации и регистрации пользователя
// @Description Активация аккаунта по почте
// @ID auth-activate
// @Accept  json
// @Produce  json
// @Success 200 {object} LogoutOutputModel "data"
// @Failure 400,404 {object} httpModel.ResponseMessage
// @Failure 500 {object} httpModel.ResponseMessage
// @Failure default {object} httpModel.ResponseMessage
// @Router /auth/activate [get]
func (h *AuthHandler) activate(c *gin.Context) {
	_, err := h.services.Activate(c.Params.ByName("link"))

	if err != nil {
		utilContext.NewErrorResponse(c, http.StatusBadRequest, err.Error())
		return
	}

	c.HTML(http.StatusOK, "account_activate.html", gin.H{
		"title": "Подтверждение аккаунта",
	})
}

// @Summary Запрос на смену пароля пользователем
// @Tags API для авторизации и регистрации пользователя
// @Description Запрос на смену пароля пользователем
// @ID auth-recovery-password
// @Accept  json
// @Produce  json
// @Param input body userModel.UserEmailModel true "credentials"
// @Success 200 {object} httpModel.ResponseMessage "data"
// @Failure 400,404 {object} httpModel.ResponseMessage
// @Failure 500 {object} httpModel.ResponseMessage
// @Failure default {object} httpModel.ResponseMessage
// @Router /auth/recovery/password [post]
func (h *AuthHandler) recoveryPassword(c *gin.Context) {
	var input userModel.UserEmailModel

	if err := c.BindJSON(&input); err != nil {
		utilContext.NewErrorResponse(c, http.StatusBadRequest, "invalid input body")
		return
	}

	_, err := h.services.Authorization.RecoveryPassword(input.Email)
	if err != nil {
		utilContext.NewErrorResponse(c, http.StatusInternalServerError, err.Error())
		return
	}

	c.JSON(http.StatusOK, httpModel.ResponseMessage{
		Message: "На Вашу почту была отправлена ссылка с подтверждением изменения пароля",
	})
}

// @Summary Изменение пароля пользователем
// @Tags API для авторизации и регистрации пользователя
// @Description Изменение пароля пользователем
// @ID auth-reset-password
// @Accept  json
// @Produce  json
// @Param input body userModel.ResetPasswordModel true "credentials"
// @Success 200 {object} httpModel.ResponseMessage "data"
// @Failure 400,404 {object} httpModel.ResponseMessage
// @Failure 500 {object} httpModel.ResponseMessage
// @Failure default {object} httpModel.ResponseMessage
// @Router /auth/reset/password [post]
func (h *AuthHandler) resetPassword(c *gin.Context) {
	var input userModel.ResetPasswordModel

	if err := c.BindJSON(&input); err != nil {
		utilContext.NewErrorResponse(c, http.StatusBadRequest, "invalid input body")
		return
	}

	_, err := h.services.Authorization.ResetPassword(input)
	if err != nil {
		utilContext.NewErrorResponse(c, http.StatusInternalServerError, err.Error())
		return
	}

	c.JSON(http.StatusOK, httpModel.ResponseMessage{
		Message: "Пароль был успешно изменён!",
	})
}

Для генерации документации достаточно выполнить команду swag init -g cmd/main.go

Также важно, чтобы перед генерацией документации был определён маршрут, по которому будет осуществлён вывод документации, сгенерированной локально:

package handler

import (
	middlewareConstant "main-server/pkg/constant/middleware"
	authHandler "main-server/pkg/handler/auth"
	serviceHandler "main-server/pkg/handler/service"

	"github.com/gin-contrib/cors"
	"github.com/gin-gonic/gin"
	"github.com/spf13/viper"

	_ "main-server/docs"

	service "main-server/pkg/service"

	_ "github.com/swaggo/files"
	swaggerFiles "github.com/swaggo/files"
	_ "github.com/swaggo/gin-swagger"
	ginSwagger "github.com/swaggo/gin-swagger"
)

type Handler struct {
	services *service.Service
}

func NewHandler(services *service.Service) *Handler {
	return &Handler{services: services}
}

/* Инициализация маршрутов */
func (h *Handler) InitRoutes() *gin.Engine {
	router := gin.New()

	// Установка максимального размера тела Multipart
	router.MaxMultipartMemory = 50 << 20 // 50 MiB

	// Установка статической директории
	router.Static("/public", "./public")

	// Установка глобального каталога для хранения HTML-страниц
	router.LoadHTMLGlob("pkg/template/*")

	// Установка CORS-политик
	router.Use(cors.New(cors.Config{
		//AllowAllOrigins: true,
		AllowOrigins:     []string{viper.GetString("client_url")},
		AllowMethods:     []string{"POST", "GET"},
		AllowHeaders:     []string{"Origin", "Content-type", "Authorization"},
		AllowCredentials: true,
	}))

	// URL: /swagger/index.html
	router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))

	// Инициализация списка обработчиков в цепочке middleware
	middleware := make(map[string]func(c *gin.Context))
	middleware[middlewareConstant.MN_UI] = h.userIdentity
	middleware[middlewareConstant.MN_UI_LOGOUT] = h.userIdentityLogout

	// Инициализация маршрутов для сервиса service
	service := serviceHandler.NewServiceHandler(router, h.services)
	service.InitRoutes(&middleware)

	// Инициализация маршрутов для сервиса auth
	auth := authHandler.NewAuthHandler(router, h.services)
	auth.InitRoutes(&middleware)

	return router
}

Код добавления просмотра документации по определённому пути выглядит следующим образом:

// URL: /swagger/index.html
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))

При запуске сервиса мы получим возможность просмотра документации онлайн.

Рисунок 9 - Просмотр документации сервиса GG онлайн (аналогично сервису NEJ)
Рисунок 9 - Просмотр документации сервиса GG онлайн (аналогично сервису NEJ)

Вывод

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

Был предложен ряд пакетов, которые можно использовать для решения этой задачи как на Express.js (swagger-express-generate, swagger-ui-express), так и на Gin (swag). Читатель может обратиться к исходному коду и основываясь на данных примерах реализовать документирование API на своих сервисах.

Используя инструментарий Swagger можно значительно ускорить процесс создания документации API сервисов, не нагромождая при этом код лишними параметрами спецификации OpenAPI.

Ссылки на использованные источники

  1. Сервис NEJ

  2. Сервис GG

  3. Controller Service Repository

  4. Шаблон для работы с swagger-express-generate

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