Что любят программисты? - Писать код. Однако, если программист только и делает, что пишет код, результат получается довольно печальным. Для достижения наилучшего результата инженерам необходимо общаться друг с другом. И документация - один из лучших способов такого общения.
Примером хорошей документации, которая упрощает жизнь разработчикам бэкенда, фронтенда и особенно тестировщикам - Swagger: описание входных и выходных моделей, параметры запросов, переключение окружение, наследование и расширение моделей. Но как говорил Бенджен Старк "Я научился не слышать всё, что идёт до слова НО", и это НО - комбинация Swagger и node.js
Есть несколько подходов к реализации связки Node.js + Swagger. Первый из них - использовать модуль swagger-node. Работа с ним крайне проста:
$> npm install -g swagger
$> swagger project create hello-world
$> swagger project edit
… some magic and controllers editing…
$> swagger project start
Но у этого подхода есть и серьезный минус. Необходимая для построения документации информация (входные и выходные параметры и фильтры) должна быть описана в контроллере, код которого раздувается и становится тяжелым для восприятия. Не лучший подход для серьезного проекта.
Второй способ, который более совместим с крупными проектами - Swagger UI. Он может быть может быть использован в качестве модуля в уже существующем приложении, или же запущен в собственном Docker контейнере:
$> npm install -g swagger-ui swagger-dist
// app.js
const express = require('express')
const pathToSwaggerUi = require('swagger-ui').absolutePath()
const app = express()
app.use(express.static(pathToSwaggerUi))
app.listen(3000)
При использовании Docker контейнера можно воспользоваться готовым, запустив его с указанием расположения файла документации, но я хочу применить подход, который позволяет произвести более тонкую настройку. Стоит начать с создания Docker контейнера, в который нужно включить статичный Swagger UI и nginx. Для окончания завершения настройки связки Приложения + Swagger остается сделать 2 вещи: уточнить ендпоинт для загрузки и подготовить саму документацию. Если первая операция достаточно простая, то вторая требует единый файл конфигурации.
Недостаток сваггера в ”чистом” виде - отсутствие поддержки наследования моделей и сложный и не оптимальный механизм разбиения на модули. Модульность - единственный способ организации кода и документации в проектах больше чем “Hello World”.
Проблему модульности можно решить двумя путями: использование сторонних библиотек и написанием собственных решений. Самая популярная библиотека дает возможность использовать встроенный механизм ссылок $ref: ‘../path/to/some/file’
что, несомненно, удобно, но ограничивает в ссылках на описание модели внутри этого же файла.
Самописные решения, как вы можете сперва подумать, будут значительно уступать, но давайте не делать поспешных выводов. Swagger-документация состоит из нескольких больших разделов, описанных в JSON формате:
paths:
/api/login/:
/api/user/:
/api/some-model/:
definitions:
LoginResponseModel:
LoginPayloadModel:
UserModel:
И взглянув на такое представление мы можем сделать вывод, что удобным способом сборки документации из файлов будет аналог git merge
(а программисты любят git и системы контроля версий). Просто следует разместить куски документации в отдельном файле.
Генератор документации можно разделить на несколько логических частей. Первая - подготовительная, в которой подключим необходимые библиотеки и “заголовок” для документации сваггера.
const _ = require('lodash');
const fs = require('fs');
const yaml = require('js-yaml');
const header = {
swagger: '2.0',
info: {
version: '1.0.0',
title: 'Some Title',
description: 'Some funny description'
},
schemes: ['http', 'https'],
basePath: '/api'
};
Следующая необходимая часть - функция, которая из, переданного в качестве аргумента, пути считает список файлов, произведёт их обработку и объединит в конечный файл документации.
/* object */ function readDocs(dir /* string */) {
const files = fs.readdirSync(dir);
const docs = {};
files.forEach(file => {
if (! /\.yaml$/.test(file)) {
/* do nothing */
return;
}
const fullpath = path.join(dir, file)
const data = yaml.load(fs.readFileSync(fullpath, 'utf-8').toString());
addGeneratedFields(data);
_.merge(docs, data);
});
return docs;
}
Функция addGeneratedFields
, упомянутая выше добавляет ко всем объвленным эндпоинтам документации обязательные поля, такие как заголовок Authorization
, переменные для доступа к элементам ваших коллекций. В перспективе вы сохраните на этом немало времени и строк кода, ведь не придётся копировать из одного места в другое, а каждый такой параметр будет описан единообразно. А добавление новых, обязательных для всех параметров станет проще и быстрее.
/* void */ function addGeneratedFields(data /* object */) {
const authParam = {
in: 'header',
name: 'Authorization',
description: 'Authorization token using bearer schema',
required: true, type: 'string'
};
const pathRequiredParam = (name /* string */) => ({
in: 'path',
name: name,
required: true,
type: 'string',
description: 'Path parameter'
});
if (! ('paths' in data)) {
return;
}
for (const path in data.paths) {
for (const method in data.paths[path]) {
const parameters = data.paths[path][method].parameters || [];
const pathParams = path.match(/{([\w\s\|]+)}/gi);
if (pathParams) {
/* path params exists */
for (const pathParam of pathParams.reverse()) {
const paramName = pathParam.replace('{', '').replace('}', '')
parameters.unshift(pathRequiredParam(paramName));
}
}
parameters.unshift(authParam);
data.paths[path][method].parameters = parameters;
}
}
}
И последняя необходимая часть - сохранение всего это в файл. Для этого лучше воспользоваться механизмом fs.createWriteStream
и stream.write
из ядра Node.js.
module.exports = function GenerateDocs(dir /* string */, outFile /* string */) {
const stream = fs.createWriteStream(outFile, { encoding: 'utf-8', flags: 'w' });
stream.once('open', fd => {
stream.write('# This file is generated\n');
stream.write(yaml.dump(header));
stream.write(yaml.dump(readDocs(dir)));
stream.end();
});
}
Менее 100 строк добавляют модульности вашей документации. Конечно их можно расширять и улучшать:
- добавить разбиение на поддиректории
- добавить к имени модели название файла, если документация очень большая и вы боитесь пересечения имён
- реализовать механизм наследования моделей Swagger
$allOf
,$oneOf
,$anyOf
limit
/offset
/page
для списковых роутов
Посмотрев на результат, вы бы могли подумать, что это изобретение велосипеда. И - да, и - нет. Наработки, вроде этой, помогают произвести более тонкую настройку написания документации принятой в команде (и в вашем случае процесс может быть совсем другим). Можно сказать, что это необходимое зло, но которое упрощает и делает приятнее процесс написания документации. А со временем "локальный велосипед" можем перерости в более гибку библиотеку.
Также хотелось бы высказать пару мыслей вокруг документации. Документация - это не только Swagger, но гораздо более широкое понятие, которое помогает во многих ситуациях: описание процессов, закрпеление договоренностей, пошаговые инструкции. Создавая документацию информация предстает в сжатом виде, и прописывая её, она лучше запоминается.