RESTful API на Node.js + MongoDB

в 7:49, , рубрики: api, express.js, json, mongodb, node.js, oauth2, passport.js, rest, метки: , , , , , , ,

Я, будучи разработчиком мобильных приложений, часто нуждаюсь в backend-сервисах для хранения пользовательских данных, авторизации и прочего. Конечно, для подобных задач можно использовать BaaS (Parse, Backendless, etc…). Но свое решение — это всегда более удобно и практично.

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

В данной статье будет рассмотрено построение REST API для мобильного приложения на Node.js с использованием фреймворка Express.js и модуля Mongoose.js для работы с MongoDB. Для контроля доступа прибегнем к технологии OAuth 2.0 с помощью модулей OAuth2orize и Passport.js.

Пишу с позиции абсолютного новичка. Рад любым отзывам и поправкам по коду и логике!

Содержание

  1. Node.js + Express.js, простой web-сервер
  2. Error handling
  3. RESTful API endpoints, CRUD
  4. MongoDB & Mongoose.js
  5. Access control — OAuth 2.0, Passport.js


Работаю я в OSX, IDE — JetBrains WebStorm.

Основы Node.js почерпнул в скринкастах Ильи Кантора, крайне рекомендую! (А вот и пост про них на хабре)

Готовый проект на последней стадии можно взять на GitHub. Для установки всех модулей, выполните команду npm install в папке проекта.

1. Node.js + Express.js, простой web-сервер

Node.js обладает неблокирующим вводом-выводом, это круто для API, к которому будет обращаться множество клиентов. Express.js — развитый, легковесный фреймворк, позволяющий быстро описать все пути (API endpoints), которые мы будет обрабатывать. Так же к нему можно найти множество полезных модулей.

Создаем новый проект с единственным файлом server.js. Так как приложение будет целиком полагаться на Express.js, установим его. Установка сторонних модулей происходит через Node Package Manager выполнением команды npm install modulename в папке проекта.

cd NodeAPI
npm i express

Express установится в папку node_modules. Подключим его к приложению:

var express         = require('express');
var app = express();

app.listen(1337, function(){
    console.log('Express server listening on port 1337');
});

Запустим приложение через IDE или консоль (node server.js). Данный код создаст веб-сервер на localhost:1337. Если попробовать его открыть — он выведет сообщение Cannot GET /. Это потому что мы еще не задали ни одного пути (route). Далее создадим несколько путей и произведем базовые настройки Express.

var express         = require('express');
var path            = require('path'); // модуль для парсинга пути
var app = express();

app.use(express.favicon()); // отдаем стандартную фавиконку, можем здесь же свою задать
app.use(express.logger('dev')); // выводим все запросы со статусами в консоль
app.use(express.bodyParser()); // стандартный модуль, для парсинга JSON в запросах
app.use(express.methodOverride()); // поддержка put и delete
app.use(app.router); // модуль для простого задания обработчиков путей
app.use(express.static(path.join(__dirname, "public"))); // отдаем статический index.html из папки public/

app.get('/api', function (req, res) {
    res.send('API is running');
});

app.listen(1337, function(){
    console.log('Express server listening on port 1337');
});

Теперь localhost:1337/api вернет наше сообщение. localhost:1337 отобразит index.html.

Тут мы переходим к обработке ошибок.

2. Error handling

Сперва подключим удобный модуль для логгинга Winston. Использовать мы его будем через свою обертку. Установим в корне проекта npm i winston, затем создадим папку libs/ и там файл log.js.

var winston = require('winston');

function getLogger(module) {
    var path = module.filename.split('/').slice(-2).join('/'); //отобразим метку с именем файла, который выводит сообщение

    return new winston.Logger({
        transports : [
            new winston.transports.Console({
                colorize:   true,
                level:      'debug',
                label:      path
            })
        ]
    });
}

module.exports = getLogger;

Мы создали 1 транспорт для логов — в консоль. Так же мы можем отдельно сортировать и сохранять сообщения, например, в базу данных или файл. Подключим логгер в наш server.js.

var express         = require('express');
var path            = require('path'); // модуль для парсинга пути
var log             = require('./libs/log')(module);
var app = express();

app.use(express.favicon()); // отдаем стандартную фавиконку, можем здесь же свою задать
app.use(express.logger('dev')); // выводим все запросы со статусами в консоль
app.use(express.bodyParser()); // стандартный модуль, для парсинга JSON в запросах
app.use(express.methodOverride()); // поддержка put и delete
app.use(app.router); // модуль для простого задания обработчиков путей
app.use(express.static(path.join(__dirname, "public"))); // отдаем статический index.html из папки public/

app.get('/api', function (req, res) {
    res.send('API is running');
});

app.listen(1337, function(){
    log.info('Express server listening on port 1337');
});

Наше информационное сообщение теперь красиво отдельно выводится в консоль. Добавим обработку ошибок 404 и 500.

app.use(function(req, res, next){
    res.status(404);
    log.debug('Not found URL: %s',req.url);
    res.send({ error: 'Not found' });
    return;
});

app.use(function(err, req, res, next){
    res.status(err.status || 500);
    log.error('Internal error(%d): %s',res.statusCode,err.message);
    res.send({ error: err.message });
    return;
});

app.get('/ErrorExample', function(req, res, next){
    next(new Error('Random error!'));
});

Теперь, если нет доступных путей, Express вернет наше сообщение. При внутренней ошибке приложения сработает так же наш обработчик, это можно проверить, обратившись по адресу localhost:1337/ErrorExample.

3. RESTful API endpoints, CRUD

Добавим пути для обработки неких «статей»(articles). На хабре есть прекрасная статья, объясняющая, как правильно делать удобное API. Логикой их наполнять пока не будем, сделаем это в следующем шаге, с подключением базы данных.

app.get('/api/articles', function(req, res) {
    res.send('This is not implemented now');
});

app.post('/api/articles', function(req, res) {
    res.send('This is not implemented now');
});

app.get('/api/articles/:id', function(req, res) {
    res.send('This is not implemented now');
});

app.put('/api/articles/:id', function (req, res){
    res.send('This is not implemented now');    
});

app.delete('/api/articles/:id', function (req, res){
    res.send('This is not implemented now');
});

Для тестирования post/put/delete посоветую замечательную обертку над cURL — httpie. Далее я буду приводить примеры запросов именно с использованием этого инструмента.

4. MongoDB & Mongoose.js

Выбирая СУБД, я руководствовался опять-таки стремлением изучить что-то новое. MongoDB — самая популярная NoSQL документ-ориентированная СУБД. Mongoose.js — обертка, позволяющая создавать удобные и функциональные схемы документов.

Скачиваем и устанавливаем MongoDB. Устанавливаем mongoose: npm i mongoose. Работу с бд я выделил в файл libs/mongoose.js.

var mongoose    = require('mongoose');
var log         = require('./log')(module);

mongoose.connect('mongodb://localhost/test1');
var db = mongoose.connection;

db.on('error', function (err) {
    log.error('connection error:', err.message);
});
db.once('open', function callback () {
    log.info("Connected to DB!");
});

var Schema = mongoose.Schema;

// Schemas
var Images = new Schema({
    kind: {
        type: String,
        enum: ['thumbnail', 'detail'],
        required: true
    },
    url: { type: String, required: true }
});

var Article = new Schema({
    title: { type: String, required: true },
    author: { type: String, required: true },
    description: { type: String, required: true },
    images: [Images],
    modified: { type: Date, default: Date.now }
});

// validation
Article.path('title').validate(function (v) {
    return v.length > 5 && v.length < 70;
});

var ArticleModel = mongoose.model('Article', Article);

module.exports.ArticleModel = ArticleModel;

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

На данном этапе предлагаю подключить модуль nconf для хранения пути к БД в нем. Так же в конфиг сохраним порт, по которому создается сервер. Модуль устанавливается командой npm i nconf. Оберткой будет libs/config.js

var nconf = require('nconf');

nconf.argv()
    .env()
    .file({ file: './config.json' });

module.exports = nconf;

Отсюда следует, что мы должны создать config.json в корне проекта.

{
    "port" : 1337,
    "mongoose": {
        "uri": "mongodb://localhost/test1"
    }
}

Изменения mongoose.js (только в шапке):

var config      = require('./config');

mongoose.connect(config.get('mongoose:uri'));

Изменения server.js:

var config          = require('./libs/config');

app.listen(config.get('port'), function(){
    log.info('Express server listening on port ' + config.get('port'));
});

Теперь добавим CRUD actions в наши существующие пути.

var log             = require('./libs/log')(module);
var ArticleModel    = require('./libs/mongoose').ArticleModel;

app.get('/api/articles', function(req, res) {
    return ArticleModel.find(function (err, articles) {
        if (!err) {
            return res.send(articles);
        } else {
            res.statusCode = 500;
            log.error('Internal error(%d): %s',res.statusCode,err.message);
            return res.send({ error: 'Server error' });
        }
    });
});

app.post('/api/articles', function(req, res) {
    var article = new ArticleModel({
        title: req.body.title,
        author: req.body.author,
        description: req.body.description,
        images: req.body.images
    });

    article.save(function (err) {
        if (!err) {
            log.info("article created");
            return res.send({ status: 'OK', article:article });
        } else {
            console.log(err);
            if(err.name == 'ValidationError') {
                res.statusCode = 400;
                res.send({ error: 'Validation error' });
            } else {
                res.statusCode = 500;
                res.send({ error: 'Server error' });
            }
            log.error('Internal error(%d): %s',res.statusCode,err.message);
        }
    });
});

app.get('/api/articles/:id', function(req, res) {
    return ArticleModel.findById(req.params.id, function (err, article) {
        if(!article) {
            res.statusCode = 404;
            return res.send({ error: 'Not found' });
        }
        if (!err) {
            return res.send({ status: 'OK', article:article });
        } else {
            res.statusCode = 500;
            log.error('Internal error(%d): %s',res.statusCode,err.message);
            return res.send({ error: 'Server error' });
        }
    });
});

app.put('/api/articles/:id', function (req, res){
    return ArticleModel.findById(req.params.id, function (err, article) {
        if(!article) {
            res.statusCode = 404;
            return res.send({ error: 'Not found' });
        }

        article.title = req.body.title;
        article.description = req.body.description;
        article.author = req.body.author;
        article.images = req.body.images;
        return article.save(function (err) {
            if (!err) {
                log.info("article updated");
                return res.send({ status: 'OK', article:article });
            } else {
                if(err.name == 'ValidationError') {
                    res.statusCode = 400;
                    res.send({ error: 'Validation error' });
                } else {
                    res.statusCode = 500;
                    res.send({ error: 'Server error' });
                }
                log.error('Internal error(%d): %s',res.statusCode,err.message);
            }
        });
    });
});

app.delete('/api/articles/:id', function (req, res){
    return ArticleModel.findById(req.params.id, function (err, article) {
        if(!article) {
            res.statusCode = 404;
            return res.send({ error: 'Not found' });
        }
        return article.remove(function (err) {
            if (!err) {
                log.info("article removed");
                return res.send({ status: 'OK' });
            } else {
                res.statusCode = 500;
                log.error('Internal error(%d): %s',res.statusCode,err.message);
                return res.send({ error: 'Server error' });
            }
        });
    });
});

Благодаря Mongoose и описанным схемам — все операции предельно понятны. Теперь, кроме node.js следует запустить mongoDB командой mongod. mongo — утилита для работы с БД, сам сервис — monod. Создавать предварительно в базе ничего не нужно.

Примеры запросов с помощью httpie:

http POST http://localhost:1337/api/articles title=TestArticle author='John Doe' description='lorem ipsum dolar sit amet' images:='[{"kind":"thumbnail", "url":"http://habrahabr.ru/images/write-topic.png"}, {"kind":"detail", "url":"http://habrahabr.ru/images/write-topic.png"}]'

http http://localhost:1337/api/articles

http http://localhost:1337/api/articles/52306b6a0df1064e9d000003

http PUT http://localhost:1337/api/articles/52306b6a0df1064e9d000003 title=TestArticle2 author='John Doe' description='lorem ipsum dolar sit amet' images:='[{"kind":"thumbnail", "url":"http://habrahabr.ru/images/write-topic.png"}, {"kind":"detail", "url":"http://habrahabr.ru/images/write-topic.png"}]'

http DELETE http://localhost:1337/api/articles/52306b6a0df1064e9d000003

Проект на данном этапе можно взглянуть на GitHub.

5. Access control — OAuth 2.0, Passport.js

Для контроля доступа я прибегну к OAuth 2. Возможно, это избыточно, но в дальнейшем такой подход облегчает интеграцию с другими OAuth-провайдерами. К тому же, я не нашел рабочих примеров user-password OAuth2 flow для Node.js.
Непосредственно за контролем доступа будет следить Passport.js. Для OAuth2-сервера пригодится решение от того же автора — oauth2orize. Пользователи, токены будут храниться в MongoDB.
Сперва нужно установить все модули, которые нам потребуются:

  • Faker
  • oauth2orize
  • passport
  • passport-http
  • passport-http-bearer
  • passport-oauth2-client-password

Затем, в mongoose.js нужно добавить схемы для пользователей и токенов:

var crypto      = require('crypto');

// User
var User = new Schema({
    username: {
        type: String,
        unique: true,
        required: true
    },
    hashedPassword: {
        type: String,
        required: true
    },
    salt: {
        type: String,
        required: true
    },
    created: {
        type: Date,
        default: Date.now
    }
});

User.methods.encryptPassword = function(password) {
    return crypto.createHmac('sha1', this.salt).update(password).digest('hex');
    //more secure - return crypto.pbkdf2Sync(password, this.salt, 10000, 512);
};

User.virtual('userId')
    .get(function () {
        return this.id;
    });

User.virtual('password')
    .set(function(password) {
        this._plainPassword = password;
        this.salt = crypto.randomBytes(32).toString('base64');
        //more secure - this.salt = crypto.randomBytes(128).toString('base64');
        this.hashedPassword = this.encryptPassword(password);
    })
    .get(function() { return this._plainPassword; });


User.methods.checkPassword = function(password) {
    return this.encryptPassword(password) === this.hashedPassword;
};

var UserModel = mongoose.model('User', User);

// Client
var Client = new Schema({
    name: {
        type: String,
        unique: true,
        required: true
    },
    clientId: {
        type: String,
        unique: true,
        required: true
    },
    clientSecret: {
        type: String,
        required: true
    }
});

var ClientModel = mongoose.model('Client', Client);

// AccessToken
var AccessToken = new Schema({
    userId: {
        type: String,
        required: true
    },
    clientId: {
        type: String,
        required: true
    },
    token: {
        type: String,
        unique: true,
        required: true
    },
    created: {
        type: Date,
        default: Date.now
    }
});

var AccessTokenModel = mongoose.model('AccessToken', AccessToken);

// RefreshToken
var RefreshToken = new Schema({
    userId: {
        type: String,
        required: true
    },
    clientId: {
        type: String,
        required: true
    },
    token: {
        type: String,
        unique: true,
        required: true
    },
    created: {
        type: Date,
        default: Date.now
    }
});

var RefreshTokenModel = mongoose.model('RefreshToken', RefreshToken);

module.exports.UserModel = UserModel;
module.exports.ClientModel = ClientModel;
module.exports.AccessTokenModel = AccessTokenModel;
module.exports.RefreshTokenModel = RefreshTokenModel;

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

В config.json добавим время жизни токена:

{
    "port" : 1337,
    "security": {
        "tokenLife" : 3600
    },
    "mongoose": {
        "uri": "mongodb://localhost/testAPI"
    }
}

Выделим в отдельные модули сервер OAuth2 и логику авторизации. В oauth.js описаны «стратегии» passport.js, мы подключаем 3 из них — 2 на OAuth2 username-password flow, 1 на проверку токена.

var config                  = require('./config');
var passport                = require('passport');
var BasicStrategy           = require('passport-http').BasicStrategy;
var ClientPasswordStrategy  = require('passport-oauth2-client-password').Strategy;
var BearerStrategy          = require('passport-http-bearer').Strategy;
var UserModel               = require('./mongoose').UserModel;
var ClientModel             = require('./mongoose').ClientModel;
var AccessTokenModel        = require('./mongoose').AccessTokenModel;
var RefreshTokenModel       = require('./mongoose').RefreshTokenModel;

passport.use(new BasicStrategy(
    function(username, password, done) {
        ClientModel.findOne({ clientId: username }, function(err, client) {
            if (err) { return done(err); }
            if (!client) { return done(null, false); }
            if (client.clientSecret != password) { return done(null, false); }

            return done(null, client);
        });
    }
));

passport.use(new ClientPasswordStrategy(
    function(clientId, clientSecret, done) {
        ClientModel.findOne({ clientId: clientId }, function(err, client) {
            if (err) { return done(err); }
            if (!client) { return done(null, false); }
            if (client.clientSecret != clientSecret) { return done(null, false); }

            return done(null, client);
        });
    }
));

passport.use(new BearerStrategy(
    function(accessToken, done) {
        AccessTokenModel.findOne({ token: accessToken }, function(err, token) {
            if (err) { return done(err); }
            if (!token) { return done(null, false); }

            if( Math.round((Date.now()-token.created)/1000) > config.get('security:tokenLife') ) {
                AccessTokenModel.remove({ token: accessToken }, function (err) {
                    if (err) return done(err);
                });
                return done(null, false, { message: 'Token expired' });
            }

            UserModel.findById(token.userId, function(err, user) {
                if (err) { return done(err); }
                if (!user) { return done(null, false, { message: 'Unknown user' }); }

                var info = { scope: '*' }
                done(null, user, info);
            });
        });
    }
));

За выдачу и обновление токена отвечает oauth2.js. Одна exchange-стратегия — на получение токена по username-password flow, еще одна — на обмен refresh_token.

var oauth2orize         = require('oauth2orize');
var passport            = require('passport');
var crypto              = require('crypto');
var config              = require('./config');
var UserModel           = require('./mongoose').UserModel;
var ClientModel         = require('./mongoose').ClientModel;
var AccessTokenModel    = require('./mongoose').AccessTokenModel;
var RefreshTokenModel   = require('./mongoose').RefreshTokenModel;

// create OAuth 2.0 server
var server = oauth2orize.createServer();

// Exchange username & password for access token.
server.exchange(oauth2orize.exchange.password(function(client, username, password, scope, done) {
    UserModel.findOne({ username: username }, function(err, user) {
        if (err) { return done(err); }
        if (!user) { return done(null, false); }
        if (!user.checkPassword(password)) { return done(null, false); }

        RefreshTokenModel.remove({ userId: user.userId, clientId: client.clientId }, function (err) {
            if (err) return done(err);
        });
        AccessTokenModel.remove({ userId: user.userId, clientId: client.clientId }, function (err) {
            if (err) return done(err);
        });

        var tokenValue = crypto.randomBytes(32).toString('base64');
        var refreshTokenValue = crypto.randomBytes(32).toString('base64');
        var token = new AccessTokenModel({ token: tokenValue, clientId: client.clientId, userId: user.userId });
        var refreshToken = new RefreshTokenModel({ token: refreshTokenValue, clientId: client.clientId, userId: user.userId });
        refreshToken.save(function (err) {
            if (err) { return done(err); }
        });
        var info = { scope: '*' }
        token.save(function (err, token) {
            if (err) { return done(err); }
            done(null, tokenValue, refreshTokenValue, { 'expires_in': config.get('security:tokenLife') });
        });
    });
}));

// Exchange refreshToken for access token.
server.exchange(oauth2orize.exchange.refreshToken(function(client, refreshToken, scope, done) {
    RefreshTokenModel.findOne({ token: refreshToken }, function(err, token) {
        if (err) { return done(err); }
        if (!token) { return done(null, false); }
        if (!token) { return done(null, false); }

        UserModel.findById(token.userId, function(err, user) {
            if (err) { return done(err); }
            if (!user) { return done(null, false); }

            RefreshTokenModel.remove({ userId: user.userId, clientId: client.clientId }, function (err) {
                if (err) return done(err);
            });
            AccessTokenModel.remove({ userId: user.userId, clientId: client.clientId }, function (err) {
                if (err) return done(err);
            });

            var tokenValue = crypto.randomBytes(32).toString('base64');
            var refreshTokenValue = crypto.randomBytes(32).toString('base64');
            var token = new AccessTokenModel({ token: tokenValue, clientId: client.clientId, userId: user.userId });
            var refreshToken = new RefreshTokenModel({ token: refreshTokenValue, clientId: client.clientId, userId: user.userId });
            refreshToken.save(function (err) {
                if (err) { return done(err); }
            });
            var info = { scope: '*' }
            token.save(function (err, token) {
                if (err) { return done(err); }
                done(null, tokenValue, refreshTokenValue, { 'expires_in': config.get('security:tokenLife') });
            });
        });
    });
}));


// token endpoint
exports.token = [
    passport.authenticate(['basic', 'oauth2-client-password'], { session: false }),
    server.token(),
    server.errorHandler()
]

Для подключения этих модулей, следует добавить в server.js:

var oauth2          = require('./libs/oauth2');

app.use(passport.initialize());

require('./libs/auth');

app.post('/oauth/token', oauth2.token);

app.get('/api/userInfo',
    passport.authenticate('bearer', { session: false }),
        function(req, res) {
            // req.authInfo is set using the `info` argument supplied by
            // `BearerStrategy`.  It is typically used to indicate scope of the token,
            // and used in access control checks.  For illustrative purposes, this
            // example simply returns the scope in the response.
            res.json({ user_id: req.user.userId, name: req.user.username, scope: req.authInfo.scope })
        }
);

Для примера защита стоит на адресе localhost:1337/api/userInfo.

Чтобы проверить работу механизма авторизации — следует создать пользователя и клиента в БД. Приведу приложение на Node.js, которое создаст необходимые объекты и удалит лишние из коллекций. Помогает быстро очистить базу токенов и пользователей при тестировании, вам, думаю, достаточно будет одного запуска :)

var log                 = require('./libs/log')(module);
var mongoose            = require('./libs/mongoose').mongoose;
var UserModel           = require('./libs/mongoose').UserModel;
var ClientModel         = require('./libs/mongoose').ClientModel;
var AccessTokenModel    = require('./libs/mongoose').AccessTokenModel;
var RefreshTokenModel   = require('./libs/mongoose').RefreshTokenModel;
var faker               = require('Faker');

UserModel.remove({}, function(err) {
    var user = new UserModel({ username: "andrey", password: "simplepassword" });
    user.save(function(err, user) {
        if(err) return log.error(err);
        else log.info("New user - %s:%s",user.username,user.password);
    });

    for(i=0; i<4; i++) {
        var user = new UserModel({ username: faker.random.first_name().toLowerCase(), password: faker.Lorem.words(1)[0] });
        user.save(function(err, user) {
            if(err) return log.error(err);
            else log.info("New user - %s:%s",user.username,user.password);
        });
    }
});

ClientModel.remove({}, function(err) {
    var client = new ClientModel({ name: "OurService iOS client v1", clientId: "mobileV1", clientSecret:"abc123456" });
    client.save(function(err, client) {
        if(err) return log.error(err);
        else log.info("New client - %s:%s",client.clientId,client.clientSecret);
    });
});
AccessTokenModel.remove({}, function (err) {
    if (err) return log.error(err);
});
RefreshTokenModel.remove({}, function (err) {
    if (err) return log.error(err);
});

setTimeout(function() {
    mongoose.disconnect();
}, 3000);

Если вы создали данные скриптом, до следующие команды для авторизации вам так же подойдут. Напомню, что я использую httpie.

http POST http://localhost:1337/oauth/token grant_type=password client_id=mobileV1 client_secret=abc123456 username=andrey password=simplepassword

http POST http://localhost:1337/oauth/token grant_type=refresh_token client_id=mobileV1 client_secret=abc123456 refresh_token=TOKEN

http http://localhost:1337/api/userinfo Authorization:'Bearer TOKEN'

Внимание! На production-сервере обязательно используйте HTTPS, это подразумевается спецификацией OAuth 2. И не забудьте про правильное хэширование паролей. Реализовать https на данном примере несложно, в сети много примеров.
Напомню, что весь код содержится в репозитории на GitHub.
Для работы необходимо выполнить npm install в директории, запустить mongod, node dataGen.js (дождаться выполнения), а затем node server.js.

Если какую-то часть статьи стоит описать более подробно, пожалуйста, укажите на это в комментариях. Материал будет перерабатываться и дополняться по мере поступления отзывов.

Подводя итог, хочу сказать, что node.js — классное, удобное серверное решение. MongoDB с документ-ориентированным подходом — очень непривычный, но несомненно полезный инструмент, большинства возможностей которого я еще не использовал. Вокруг Node.js — очень большое коммьюнити, где есть множество open-source разработок. Например, создатель oauth2orize и passport.js — Jared Hanson сделал замечательне проекты, которые максимально облегчают реализацию правильно защищенных систем.

Автор: BenderRodriguez

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js