Node.js + MongoDBのRESTful API

モバイルアプリ開発者として、ユーザーデータや承認などを保存するためのバックエンドサービスが必要になることがよくあります。 もちろん、そのようなタスクにはBaaS(Parse、Backendlessなど)を使用できます。 しかし、あなたの決定は常により便利で実用的です。



それでも、私はまったく知られていない技術を勉強することに決めました。今では非常に人気があり、初心者が簡単に習得できるように位置付けられ、大規模プロジェクトを実装するために深い知識と経験を必要としません。 それでは、素人が自分の効果的で正しいバックエンドを書くことができるかどうか一緒にチェックしましょう。



この記事では、MongoDBと連携するためにExpress.jsフレームワークとMongoose.jsモジュールを使用したNode.js上のモバイルアプリケーション用のREST APIの構築について説明します。 アクセスを制御するために、OAuth2orizeおよびPassport.jsモジュールを使用したOAuth 2.0テクノロジーに頼ります。



私は絶対的な初心者の観点から書いています。 コードとロジックに関するフィードバックと修正を歓迎します!



内容


  1. Node.js + Express.js、シンプルなWebサーバー
  2. エラー処理
  3. RESTful APIエンドポイント、CRUD
  4. MongoDBおよびMongoose.js
  5. アクセス制御-OAuth 2.0、Passport.js




OSX、IDE- JetBrains WebStormで作業しています。



Ilya KantorスクリーンキャストでNode.jsの基礎を学びました。 ( そして、ここにハブに関する投稿があります



最終段階で完成したプロジェクトは、 GitHubで取得できます。 すべてのモジュールをインストールするには、プロジェクトフォルダーでnpm installコマンドを実行します。



1. Node.js + Express.js、シンプルなWebサーバー



Node.jsにはノンブロッキングI / Oがあります。これは、多くのクライアントがアクセスするAPIにとって便利です。 Express.jsは開発された軽量フレームワークであり、処理するすべてのパス(APIエンドポイント)をすばやく記述できます。 また、多くの便利なモジュールを見つけることができます。



単一のserver.jsファイルで新しいプロジェクトを作成します。 アプリケーションはExpress.jsに完全に依存するため、インストールします。 サードパーティのモジュールは、プロジェクトフォルダーで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にWebサーバーを作成します。 開こうとすると、メッセージCannot GET /



表示Cannot GET /



。 これは、まだ単一のルートを設定していないためです。 次に、いくつかのパスを作成し、基本的な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"))); //    ,     public/ (    index.html) 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.エラー処理



まず、便利な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"))); //    ,     public/ (    index.html) 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エンドポイント、CRUD



特定の「記事」(記事)を処理するためのパスを追加します。 便利な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



DBMSを選択して、私は再び何か新しいことを学びたいという欲求に導かれました。 MongoDBは、最も人気のあるNoSQLドキュメント指向DBMSです。 Mongoose.js-便利で機能的なドキュメントスキームを作成できるラッパー。



MongoDBをダウンロードしてインストールします。 npm i 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アクションを追加します。



 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に加えて、 mongod



コマンドでmongoDBを起動する必要があります。 mongo



データベースを操作するためのユーティリティ。サービス自体はmongod



です。 以前にデータベースに何かを作成する必要はありません。



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.アクセス制御-OAuth 2.0、Passport.js



アクセスを制御するために、OAuth 2を使用します。おそらくこれは冗長ですが、将来このアプローチにより他のOAuthプロバイダーとの統合が容易になります。 また、Node.jsのユーザーパスワードOAuth2フローの実用例は見つかりませんでした。

Passport.jsは、アクセス制御を直接監視します。 OAuth2サーバーの場合、同じ著者であるoauth2orizeのソリューションが役立ちます。 ユーザー、トークンはMongoDBに保存されます。

まず、必要なすべてのモジュールをインストールする必要があります。



次に、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;
      
      







仮想パスワードプロパティは、mongooseが便利なロジックをモデルに埋め込む方法の例です。 ハッシュ、アルゴリズム、ソルトについて-この記事ではなく、実装の詳細には触れません。



したがって、データベース内のオブジェクト:

  1. ユーザー-名前、パスワードハッシュ、パスワードのソルトを持つユーザー。
  2. クライアント-ユーザーに代わってアクセスが許可されるクライアントアプリケーション。 名前と秘密のコードがあります。
  3. AccessToken-クライアントアプリケーションに発行されるトークン(ベアラタイプ)は時間に制限があります。
  4. RefreshToken-別の種類のトークン。ユーザーにパスワードを再要求せずに、新しいベアラートークンを要求できます。




config.jsonで、トークンの有効期間を追加します。

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







OAuth2サーバーと認証ロジックを別々のモジュールに分離します。 Oauth.jsはpassport.jsの「戦略」を説明します。2つをOAuth2のユーザー名とパスワードのフローに接続し、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は、トークンの発行と更新を行います。 交換戦略の1つは、ユーザー名とパスワードのフローを使用してトークンを受信することです。別の戦略は、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 }) } );
      
      







たとえば、保護はローカルホストアドレス1337 / api / userInfoにあります。



認証メカニズムの動作を確認するには、データベースにユーザーとクライアントを作成する必要があります。 Node.jsでアプリケーションを提供します。これにより、必要なオブジェクトが作成され、コレクションから不要なオブジェクトが削除されます。 テスト中にトークンとユーザーのデータベースをすばやくクリアするのに役立ちます。1回の起動で十分だと思います:)



 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'
      
      







注意! OAuth 2仕様で暗示されているように、実稼働サーバーでは必ずHTTPSを使用し、正しいパスワードハッシュを忘れないでください。 この例でhttpsを実装することは難しくありません。ネットワークには多くの例があります。

すべてのコードがGitHubのリポジトリに含まれていることを思い出させてください。

動作させるには、ディレクトリでnpm install



を実行し、 mongod



node dataGen.js



(実行待ち)、 node server.js



を実行する必要がありnode server.js







記事の一部をさらに詳しく説明する必要がある場合は、コメントにその旨を明記してください。 レビューが利用可能になると、資料は処理および更新されます。



要約すると、node.jsはクールで便利なサーバーソリューションであると言えます。 ドキュメント指向のアプローチを採用したMongoDBは非常に珍しいですが、間違いなく便利なツールであり、その機能のほとんどはまだ使用していません。 Node.jsの周辺は非常に大きなコミュニティであり、多くのオープンソース開発があります。 たとえば、oauth2orizeとpassport.jsの作成者であるJared Hansonは、適切に保護されたシステムの実装を可能な限り簡単にする素晴らしいプロジェクトを実施しました。



All Articles