(簡易)RESTサーバーを作る

## はじめに
RESTはサーバーとクライアント間のデータ通信に便利に使用出来ます。
RESTの使用法については、クライアントの作成法は各種言語・フレームワークで情報があります。
しかし、サーバー側については、簡単に用意出来るモックとして「JsonServer」、あるいはApache+PHPなどで構築する方法などの情報がありますが、不自由を受け入れるか手間をかける方法になっています。
(私が探せた範囲での事になります。)

今回は自由にサービスを設定出来て、1スクリプトで簡単に構築できる上記の中間に当たるサーバー構築をしてみました。
(WEBに公開出来るほどの堅牢性は求めず、自分のRaspberryPiのサービスを構築するぐらいが目的になります。)

## ソースコード

いきなリソースコードです。

```python:restserver.py
#!/usr/bin/env python3

import http.server
import json
import threading
import sys,os
import time

class RestHandler(http.server.BaseHTTPRequestHandler):
    def do_OPTIONS(self):
        # preflight request対応
        print( "options" )
        self.send_response(200)
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE')
        self.send_header('Access-Control-Allow-Headers', '*')
        self.end_headers()

    def do_POST(self):
        print( "post" )
        local_path = self.path.strip("/").split("/")
        # リクエスト取得
        content_len  = int(self.headers.get("content-length"))
        body = json.loads(self.rfile.read(content_len).decode('utf-8'))

        # レスポンス処理
        if( local_path[0] == "dat" ):
            if(os.path.getsize('./dat.json')):
              with open('./dat.json', 'r') as json_open:
                json_load = json.load(json_open)

              json_load.update(**body)
              json_wraite = json.dumps(json_load, sort_keys=False, indent=4, ensure_ascii=False)
            else:
              json_wraite = json.dumps(body, sort_keys=False, indent=4, ensure_ascii=False)

            with open('./dat.json', 'w') as json_open:
                json_open.write(json_wraite)

            self.send_response(200)
            self.send_header('Access-Control-Allow-Origin', '*')
            self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE')
            self.send_header('Access-Control-Allow-Headers', '*')
            self.end_headers()
            return
        else:
            print( "no" )
            print( self.path )
            return

    def do_GET(self):
        print( "get" )
        local_path = self.path.strip("/").split("/")
        # レスポンス処理
        if( local_path[0] == "dat" ):
            print( "dat" )
            if(os.path.getsize('./dat.json')):
              with open('./dat.json', 'r') as json_open:
                json_load = json.load(json_open)

            else:
              json_load = {}

            self.send_response(200)
            self.send_header('Access-Control-Allow-Origin', '*')
            self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE')
            self.send_header('Access-Control-Allow-Headers', '*')
            self.send_header('Content-type', 'application/json;charset=utf-8')
            self.end_headers()
            body_json = json.dumps(json_load, sort_keys=False, indent=4, ensure_ascii=False) 
            self.wfile.write(body_json.encode("utf-8"))
            return
        else:
            print( "no" )
            print( self.path )
            return

    def do_DELETE(self):
        print( "delete" )
        local_path = self.path.strip("/").split("/")
        if( local_path[0] == "dat" ):
            print( "dat" )

            with open('./dat.json', 'w') as file_open:
                pass

            self.send_response(200)
            self.send_header('Access-Control-Allow-Origin', '*')
            self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE')
            self.send_header('Access-Control-Allow-Headers', '*')
            self.end_headers()
            return
        else:
            print( "no" )
            print( self.path )
            return

def rest_server(port):
    httpd_rest = http.server.ThreadingHTTPServer(("", port), RestHandler)
    httpd_rest.serve_forever()


def main():
    # サーバ起動
    port_rest  = 3333
    try:
        t1 = threading.Thread(target=rest_server,  args=(port_rest,),  daemon = True)

        t1.start()

        while True: time.sleep(1)

    except (KeyboardInterrupt, SystemExit):
        print("exit")
        sys.exit()

if __name__ == "__main__":
  main()
```

PythonのBaseHTTPRequestHandlerのdo_OPTIONS/do_POST/do_GET/do_DELETEでRESTのパースを行う様にして、ポート3333でHTTPサーバーを立ています。
動作は単純で、カレントディレクトリにある「dat.json」ファイルの更新(POST)・内容取得(GET)・初期化(DELETE)を行います。

## OPTIONS? PUTは?

RESTは通常以下4種のリクエストを受け付けます。

* GET　...　取得
* POST　...　追加
* PUT　　...　更新
* DELETE ...　削除

これに従って作成すべきですが、PUTはPOSTで十分に代用出来るので用意しない事にしました。

4種に含まれていない「OPTIONS」についてはブラウザの仕様によっては、POSTリクエストなどに先行してOPTIONSリクエストが発行されます。
このOPTIONSリクエストに対する応答のヘッダーに含まれる「Access-Control-Allow-Methods」にはサーバーが対応出来るメソッドが記載されており、POSTが含まれていた場合、OPTIONSリクエストに続いてPOSTリクエストが発行されます。
この動作はpreflight(プリフライト)と呼ばれ、ブラウザの動作ですので、サーバーサイドからリクエストさせるなどの回避方法はありますが、自分でサーバーを用意するならOPTIONSリクエストに対応させた方が簡単なので対応させました。

## 関数説明
各巻数について解説していきます。
### do_OPTIONS
```python:do_OPTIONS
    def do_OPTIONS(self):
        # preflight request対応
        print( "options" )
        self.send_response(200)
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE')
        self.send_header('Access-Control-Allow-Headers', '*')
        self.end_headers()
```

前項で説明したOPTIONSリクエストの応答を作成します。
単純にレスポンスを返すだけです。

### do_POST
```python:do_POST
    def do_POST(self):
        print( "post" )
        local_path = self.path.strip("/").split("/")
        # リクエスト取得
        content_len  = int(self.headers.get("content-length"))
        body = json.loads(self.rfile.read(content_len).decode('utf-8'))

        # レスポンス処理
        if( local_path[0] == "dat" ):
            if(os.path.getsize('./dat.json')):
              with open('./dat.json', 'r') as json_open:
                json_load = json.load(json_open)

              json_load.update(**body)
              json_wraite = json.dumps(json_load, sort_keys=False, indent=4, ensure_ascii=False)
            else:
              json_wraite = json.dumps(body, sort_keys=False, indent=4, ensure_ascii=False)

            with open('./dat.json', 'w') as json_open:
                json_open.write(json_wraite)

            self.send_response(200)
            self.send_header('Access-Control-Allow-Origin', '*')
            self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
            self.send_header('Access-Control-Allow-Headers', '*')
            self.end_headers()
            return
        else:
            print( "no" )
            print( self.path )
            return
```

POST処理はPOSTリクエストで受け取ったJSONデータをファイルに書き込みます。
上から順に処理を追っていきます。

```python
local_path = self.path.strip("/").split("/")
```
self.pathにはリクエストのURLデータが入っています。
これを"/"で分割して配列に保持させます。
例えば、「servername/aaa/bbb/ccc/」というリクエストだった場合、local_pathは以下の様になります。
local_path[0]='aaa'
local_path[1]='bbb'
local_path[2]='ccc'

```python
content_len  = int(self.headers.get("content-length"))
body = json.loads(self.rfile.read(content_len).decode('utf-8'))
```

リクエストに付随して受信したjsonデータをパースして辞書型データに格納する処理です。

```python
# レスポンス処理
if( local_path[0] == "dat" ):
    if(os.path.getsize('./dat.json')):
      with open('./dat.json', 'r') as json_open:
        json_load = json.load(json_open)

      json_load.update(**body)
      json_wraite = json.dumps(json_load, sort_keys=False, indent=4, ensure_ascii=False)
    else:
      json_wraite = json.dumps(body, sort_keys=False, indent=4, ensure_ascii=False)

    with open('./dat.json', 'w') as json_open:
        json_open.write(json_wraite)

    self.send_response(200)
    self.send_header('Access-Control-Allow-Origin', '*')
    self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
    self.send_header('Access-Control-Allow-Headers', '*')
    self.end_headers()
    return
```
次の段階ではlocal_path[0]を確認し、「dat」だった場合のAPIの定義になります。
サンプルではlocal_path[0]のみ使用していますが、local_path[1]、local_path[2]を順次確認していく様にすることでAPIを定義していく事になります。
残りは単純なファイル操作になります。
最初にファイルサイズを確認している理由は、json.load()に空ファイルを読み込ませるとエラーになるため、ファイルが空だった場合は受信したデータをそのままファイルに書き込む様にするためです。
(このあたりのファイル操作は冗長で泥臭い書き方にしています。)

最後のレスポンスを作成する部分はOPTIONSと同じで、エラー処理は考慮していません。

```python
else:
    print( "no" )
    print( self.path )
    return
```
URLが想定外のものが来た場合です。
本来であれば404エラーなど返すべきでしょうが、公開サービスでもないので大胆に省略しています。

### do_GET
```python:do_GET
    def do_GET(self):
        print( "get" )
        local_path = self.path.strip("/").split("/")
        # レスポンス処理
        if( local_path[0] == "dat" ):
            print( "dat" )
            if(os.path.getsize('./dat.json')):
              with open('./dat.json', 'r') as json_open:
                json_load = json.load(json_open)

            else:
              json_load = {}

            self.send_response(200)
            self.send_header('Access-Control-Allow-Origin', '*')
            self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE')
            self.send_header('Access-Control-Allow-Headers', '*')
            self.send_header('Content-type', 'application/json;charset=utf-8')
            self.end_headers()
            body_json = json.dumps(json_load, sort_keys=False, indent=4, ensure_ascii=False) 
            self.wfile.write(body_json.encode("utf-8"))
            return
        else:
            print( "no" )
            print( self.path )
            return
```
基本部分はPOSTと同じです。
リクエストからのJSONデータ取得をなくし、ファイルを読み出し、そのデータをレスポンスに載せる形になります。

```python
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE')
self.send_header('Access-Control-Allow-Headers', '*')
self.send_header('Content-type', 'application/json;charset=utf-8')
self.end_headers()
body_json = json.dumps(json_load, sort_keys=False, indent=4, ensure_ascii=False) 
self.wfile.write(body_json.encode("utf-8"))
```
POSTとの違いはヘッダーにContent-typeを追加し、body部分にデータを書き込んでいます。

### do_DELETE
```python:do_DELETE
    def do_DELETE(self):
        print( "delete" )
        local_path = self.path.strip("/").split("/")
        if( local_path[0] == "dat" ):
            print( "dat" )

            with open('./dat.json', 'w') as file_open:
                pass

            self.send_response(200)
            self.send_header('Access-Control-Allow-Origin', '*')
            self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE')
            self.send_header('Access-Control-Allow-Headers', '*')
            self.end_headers()
            return
        else:
            print( "no" )
            print( self.path )
            return
```

DELETEはシンプルに対象ファイルをw属性で開いて何もせずに閉じる事で空ファイルにしています。

### rest_server
```python:rest_server
def rest_server(port):
    httpd_rest = http.server.ThreadingHTTPServer(("", port), RestHandler)
    httpd_rest.serve_forever()
```
単純に引数で指定されたポートでサーバーを起動するだけの関数です。

### main
```python:main
def main():
    # サーバ起動
    port_rest  = 3333
    try:
        t1 = threading.Thread(target=rest_server,  args=(port_rest,),  daemon = True)

        t1.start()

        while True: time.sleep(1)

    except (KeyboardInterrupt, SystemExit):
        print("exit")
        sys.exit()
```

サーバーの起動を別スレッドで行う様にしています。
また、キーボードからの終了割り込み(Ctrl+C)を受信した際にプログラムが終了する様にしています。

## 動作確認

スクリプトを起動すると、ポート3333でサーバーが立ち上がりますので、以下のコマンドを実行してみて下さい。

```
curl -X POST -H 'Content-Type:application/json' -d '{"key":"val"}' localhost:3333/dat
curl -X GET localhost:3333/dat
curl -X DELETE localhost:3333/dat
```

コマンドはサーバーと同じマシンから行うとしてlocalhostにしています。
別マシンで行う場合はサーバーになるマシンのIPアドレスなどで試して下さい。

## 終わり

ひとまずRESTサーバーは構築出来ました。
APIを追加したければパスの解析をどんどん入れ子にして対応出来ます。
また、別コマンドを呼び出す事も簡単にできますので、システム管理にも活用できるように拡張する事も出来ます。

どうぞお試し下さい。