[JavaScript] フロントエンドな人だって開発用のREST APIサーバーをNode.jsのExpressで作ってみたい

2016/08/20

こんにちは。きんくまです。

以前にREST APIサーバーのデモをPHPで作ったことがあります。
>> [php] フロントエンドな人だって開発用のREST APIサーバーをPHP+Slimで作ってみたい

今回はこの記事がアクセスが最近多いというのと、NodeだとどうやってREST APIサーバーを作るのか試してみたかったので作ってみましたという記事です。

作ったもの

Todoリストをつくってみました。

下は画像なので動きません。

express_todo_demo

テキスト入力欄に文字をいれると、そこからクライアント側からREST APIを通してサーバーと通信。TODO一覧を保存するというものです。

■ サーバー側
Express

■ クライアント側
React
Babel + ES2015

という構成になっています。

ソースコードは一式アップしました。

>> KinkumaDesign/REST-API-demo-using-express

Expressって何?

Expressというnodeモジュールがあります。
>> Express – Node.js web application framework

これを使うと、サーバー側で今回必要になる2つのサーバーが作れました。

1. Webサーバー
2. REST APIサーバー

1のWebサーバーというのは、いわゆるApacheみたいなものです。Node.jsでもWebサーバーを作れるモジュールはたくさんあるのですが、その内のひとつというわけです。(ていうか前に買った本にもこれが最初に載ってたからきっと有名なんだと思います)

2のREST APIサーバーというのは、REST APIのリクエストに対して、適切な処理をサーバー側で行い、レスポンスを返すものです。

Webサーバーをたてる

それで、ExpressでWebサーバーを立てるのはものすごく簡単になっていて、このコードだけでOKです。

static_web.js

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

app.use(express.static('public'));
app.listen(3002, function(){
    console.log('app listening on port 3002');
});

このコードを作ったあとにコマンドから実行してサーバーを立ち上げます。

node app/static_web.js

そのあとに、ブラウザで以下のアドレスにアクセスすると、public フォルダ以下の htmlファイルなどが普通のWebサイトのようにみられるようになるというわけでございます。
3002 というのは、上のコードで出てきている勝手にきめたポート番号です。通常は80番なのですが、ローカルのテスト用なので、適当な数値を当てています。

http://localhost:3002/

REST APIサーバーを作る

さて、ここからが本番です。
REST APIサーバーを作ります。

エンドポイント Method 説明
/todos GET TODO一覧の取得
/todos POST 新規TODOの作成
/todos/:id GET ひとつのTODOの取得
/todos/:id PUT ひとつのTODOの更新
/todos/:id DELETE ひとつのTODOの削除

Todo一覧を取得したときのJSONのサンプル

{
	"todos":[
		{
			"id":"95eec4a2-f2db-7232-004c-0813aa3f634a",
			"text":"apple",
			"complete":false
		},
		{
			"id":"14a244ed-7183-ab40-34ba-b309725f5013",
			"text":"dog",
			"complete":true
		},
		{
			"id":"b9a1df35-99d7-21ca-eb1f-8a9dbe25f808",
			"text":"car",
			"complete":false
		}
	]
}

いろいろと設定

ExpressでJSONをレスポンスする際に、クロスドメインに対応したり、REST APIに対応したりするのに最初にいろいろと設定してあげる必要がありました。

var express = require('express');
var fs = require('fs');
var bodyParser = require('body-parser');

var app = express();
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(function(req, res, next) {
    res.header("Access-Control-Allow-Origin", "*");
    res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
    res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
    next();
});

まず bodyParser っていうのが出てきます。これは、リクエストのbody部分を読み取るために必要です。
リクエストのbodyって何?ってことなんですが、HTTPのリクエストは、URLの他にデータを含めて送ることができる(問い合わせフォームとかでも名前とか送ってますよね?)ので、そいつを読み取るために必要みたい。

=====
余談だけど初心者のために、もうちょっとHTTPの通信説明。

HTTPの通信というのは、クライアントとサーバー間の通信です。

代表的なクライアント -> Webブラウザ(IEとかChorme)
サーバー -> Webサーバーだったり、APIサーバーだったりして、それを何の言語で実装するかというのでさらにわかれます。PHPとか今回のNode.jsとか、Javaとか

で、クライアントとサーバーがネットワークを介して HTTP 通信をして情報をやりとりしてるわけですね。
クライアント側からリクエストを送って、サーバー側からレスポンスを返す。というのが基本の流れでございます。

1. クライアント -> HTTPリクエスト -> サーバー
2. サーバーで何か処理
3. クライアント <- HTTPレスポンス <- サーバー (1と向きが逆)

3のときに、レスポンスに結果が書いてあります。ステータスコードが200だと正常。404だとページが見つからないとかいうあれです。

それで、さきほどのリクエストとレスポンスの各HTTP通信というのは、さらにヘッダー部分とボディ部分に分かれます。

HTTP
——-
headers
body
——-

なのでリクエストヘッダ、リクエストボディ、レスポンスヘッダ、レスポンスボディ の4つがあります。

このへんはブラウザの開発者コンソールの Network タブを見れば確認できます。こんな感じに

http_req_res_dev_console

=====

次に、Access-Control-Allow なんたらっていうのが続いています。これはレスポンスヘッダにクロスドメイン越しにJSONを返せるようにしたり、REST APIなので、PUTやDELETEのメソッドも許可するよ。ということをやっています。

ルーティング

URLのリクエストに応じた、サーバー側の応答に対応するルーティングをします。
routeのあとのメソッド名がget, put, deleteとなっているのでわかりやすいですね。

app.route('/todos/:id')
    .get(function(req, res){
        onRouteGetTodo(req, res);
    })
    .put(function(req, res){
        onRoutePutTodo(req, res);
    })
    .delete(function(req, res){
        onRouteDeleteTodo(req, res);
    });

app.get('/todos', function(req, res){
    getTodoList().then(function(obj){
        res.json(obj);
    })
});

各処理の詳細は省略です。

今回はデータベースを使わずに、jsonをそのままファイルとして保存しています。
1点よくわからなかったのはbooleanのあつかいで、なぜか true, false ともに文字列でうけとっていました。 “True”, “false” みたいな。これがデフォルトなのか、設定で変えられるのかは不明。

あと、idに数値ではなくて、guidを使っています。
> Create GUID / UUID in JavaScript?

最後にポート番号を指定して、待機すればおしまいです。

app.listen(3001, function(){
    console.log('Example app listening on port 3001');
});

ターミナルでコマンドをうちます

node app/api.js

感想

クロスドメインでつまったりしたけど、基本的には問題なくできた。
課題として思いつくのは、

– ファイルの分割(いまは何も考えず1枚ファイルにしてしまっているので、もう少し役割ごとに分割したい)
– 値チェック(リクエストbodyの内容をそのまま使っちゃってるのでマズい)
– curlでターミナルからリクエストするとbody部分がなぜかうまく取得できない。結局原因は探らずそっ閉じ

とかでしょうか。

参考。全コードです

api.js

var express = require('express');
var fs = require('fs');
var bodyParser = require('body-parser');

var app = express();
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(function(req, res, next) {
    res.header("Access-Control-Allow-Origin", "*");
    res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
    res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
    next();
});

var jsonFilePath = 'data/todos.json';

var defaultJSON = {
    "todos":[]
};

var Todo = function(){
    this.id = guid();
    this.text = "";
    this.complete = false;
};

function checkJSONExists(){
    return new Promise(function(resolve){
        fs.stat(jsonFilePath, function(err, stats){
            if(err){
                resolve(false);
                return;
            }
            resolve(true);
        });
    });
}

function readJSON(){
    return new Promise(function(resolve){
        fs.readFile(jsonFilePath, function(err, data){
            if(err){
                resolve(defaultJSON);
                console.error('not found');
                return;
            }
            resolve(JSON.parse(data));
        });
    });
}

function writeJSON(json){
    return new Promise(function(resolve, reject){
        fs.writeFile(jsonFilePath, JSON.stringify(json), function(err){
            if(err){
                console.error(err);
                reject(err);
                return;
            }
            resolve(json);
        })
    });
}

function getTodoList(){
    return new Promise(function(resolve){
        checkJSONExists().then(function(exists){
            if(exists){
                return readJSON();
            }else{
                resolve(defaultJSON);
            }
        }).then(function(json){
            resolve(json);
        });
    });
}

function getTodo(id){
    return new Promise(function(resolve){
        if(!id){
            resolve(null);
            return;
        }
        checkJSONExists().then(function(exists){
            if(exists){
                return readJSON();
            }else{
                resolve(null);
            }
        }).then(function(json){
            var todos = json.todos;
            if(!todos){
                resolve(null);
                return;
            }
            for(var i = 0, len = todos.length; i < len; i++){
                var todo = todos[i];
                if(todo.id == id){
                    resolve(todo);
                    return;
                }
            }
            resolve(null);
        });
    });
}

function sendBadStatusResponse(res, status){
    res.status(status).send({ msg:'Bad Request'});
}

function guid() {
    function s4() {
        return Math.floor((1 + Math.random()) * 0x10000)
            .toString(16)
            .substring(1);
    }
    return s4() + s4() + '-' + s4() + '-' + s4() + '-' +
        s4() + '-' + s4() + s4() + s4();
}

function onRouteGetTodo(req, res){
    getTodo(req.params.id).then(function(obj){
        var result = obj;
        if(result == null){
            result = {};
        }
        res.json(result);
    });
}

function onRoutePutTodo(req, res){
    var body = req.body;
    if(!body.text || body.text.length <= 0){
        sendBadStatusResponse(res, 400);
        return;
    }
    if(!(body.hasOwnProperty("complete")) ||
        !(body.complete == "true" || body.complete == "false")){
        sendBadStatusResponse(res, 400);
        return;
    }

    getTodoList().then(function(json){
        var todos = json.todos;
        for(var i = 0, len = todos.length; i < len; i++){
            var todo = todos[i];
            if(todo.id == req.params.id){
                todo.text = body.text;
                todo.complete = body.complete == "true";
            }
            todos[i] = todo;
        }
        json.todos = todos;
        writeJSON(json).then(function(){
            res.json(json);
        });
    })
}

function onRouteDeleteTodo(req, res){
    getTodoList().then(function(json){
        var todos = json.todos;
        for(var i = 0, len = todos.length; i < len; i++){
            var todo = todos[i];
            if(todo.id == req.params.id){
                todos.splice(i, 1);
                break;
            }
        }
        json.todos = todos;
        writeJSON(json).then(function(){
            res.json(json);
        });
    })
}

app.route('/todos/:id')
    .get(function(req, res){
        onRouteGetTodo(req, res);
    })
    .put(function(req, res){
        onRoutePutTodo(req, res);
    })
    .delete(function(req, res){
        onRouteDeleteTodo(req, res);
    });

app.get('/todos', function(req, res){
    //console.log(req.params);
    getTodoList().then(function(obj){
        res.json(obj);
    })
});

app.post('/todos', function(req, res){
    var body = req.body;
    console.log(body);
    var todo = new Todo();
    if(body.text || body.text.length > 0){
        todo.text = body.text;
    }else{
        sendBadStatusResponse(res, 400);
        return;
    }
    if(body.hasOwnProperty("complete") &&
       (body.complete == "true" || body.complete == "false")){
        todo.complete = body.complete == "true";
    }else{
        sendBadStatusResponse(res, 400);
        return;
    }
    getTodoList().then(function(json){
        json.todos.push(todo);
        return writeJSON(json);
    }).then(function(json){
        res.json(json);
    });
});

app.listen(3001, function(){
    console.log('Example app listening on port 3001');
});
LINEで送る
Pocket

自作iPhoneアプリ 好評発売中!
フォルメモ - シンプルなフォルダつきメモ帳
ジッピー電卓 - 消費税や割引もサクサク計算!

ページトップへ戻る