Python の jsonrpcserver ライブラリを使ったら Django が JSON-RPC の API サーバになった

- [JSON-RPC](https://www.jsonrpc.org/) は、ネットワーク越しに関数を呼び出すための、ステートレスで軽量なプロトコルの一種です
- [jsonrpcserver](https://jsonrpcserver.readthedocs.io/) は、JSON-RPC 準拠のサーバ機能を実装した Python のライブラリです

# なぜ、あえて JSON-RPC を使うのか

Web API は一般的に REST のスタイルで実装されることが多く、実際に Google で「django api server」で検索したら、結果のほとんどは [Django REST framework](https://www.django-rest-framework.org/) を紹介するページでした。

それに対して JSON-RPC の方式は古くて人気がありません。私自身も、[Zabbix API](https://www.zabbix.com/documentation/5.0/manual/api) が JSON-RPC でなかったら知る機会すらなかったと思います。

にもかかわらず今回 JSON-RPC を採用した理由は、以下のような要件によるものです。

- API 呼び出しは単なるトリガーとし、サーバ内部のロジックで各種データ処理を完結させたい
- クライアントには処理内容やデータ構造を知らせたくない
- このため、呼び出し側が対象（リソース）と操作（CRUD）を指定する RESTful なスタイルが馴染まない
- とは言え安易なオレオレ方式ではなく、何らかの標準プロトコルに準拠したい

以上の理由により、今回の要件には JSON-RPC がちょうどいいと判断しましたが、特に理由がなければ一般的な REST にするべきです。

# JSON-RPC とはどんな方式か

名前のとおり [JSON](http://www.json.org/json-ja.html) 形式のデータを使ってネットワーク越しの関数呼び出し（[RPC](https://ja.wikipedia.org/wiki/%E9%81%A0%E9%9A%94%E6%89%8B%E7%B6%9A%E3%81%8D%E5%91%BC%E5%87%BA%E3%81%97)）を行う、ステートレスで軽量なプロトコルの一種です。

以下のわずかな文章が JSON-RPC の仕様です。
[JSON-RPC 2.0 Specification](https://www.jsonrpc.org/specification)

仕様としてリクエスト／レスポンス／エラーのデータ形式が規定されていますが、逆にそれ以外の縛りはほとんどありません。
関数呼び出しをネットワークに乗せるための最低限の決め事、というイメージです。

## リクエストと正常終了時のレスポンス

### 例 1

以下のリクエストは、`"subtract"`（引き算）という名前の関数に`23, 42`という引数を渡して呼び出す例です。

```Json:例1-引き算のリクエスト
{
  "jsonrpc": "2.0",
  "method": "subtract",
  "params": [23, 42],
  "id": 1
}
```
`"method"`で関数名を、`"params"`で引数を指定します。
`"jsonrpc": "2.0"`は固定で、`"id"`は対応するリクエスト／レスポンスを特定するための数値です。

```Json:例1-引き算の正常レスポンス
{
  "jsonrpc": "2.0",
  "result": -19,
  "id": 1
}
```
正常終了時は、結果の値をレスポンスの`"result"`で取得することができます。
この例では`23 − 42`の計算結果として`-19`が返されました。

### 例 2

次のリクエストは`"division"`（割り算）という関数に辞書形式の引数を渡して呼び出す例です。

```Json:例2-割り算リクエスト
{
  "jsonrpc": "2.0",
  "method": "division",
  "params": {"divisor": 16, "dividend": 1080},
  "id": 2
}
```
```Json:例2-割り算の正常レスポンス
{
  "jsonrpc": "2.0",
  "result": {"quotient": 67, "remainder": 8},
  "id": 2
}
```
それぞれの引数に名前をつけたので、順序に関係なく`"dividend"`（被除数）と`"divisor"`（除数）を区別して`1080 ÷ 16`と計算することができました。
またこの例では戻り値も辞書形式にして、`"quotient"`（商）`"remainder"`（余り）という名前を付けて返しています。

## エラーのレスポンス

エラーが発生した場合のレスポンスは以下の形式になり、`"result"`の代わりに`"error"`が返ります。
`"error"`の値は以下の辞書形式で、エラーコードとエラーメッセージを返すことが規定されています。

```Json:エラーのレスポンス
{
  "jsonrpc": "2.0",
  "error": {"code": -32602, "message": "Invalid params"},
  "id": 3
}
```

次で使用する jsonrpcserver ライブラリは自動的にこの形式で、規格どおりのエラーコードとメッセージを返してくれます。
また HTTP ステータスも同様に、エラーの場合には自動的に 400～500番台の値を返してくれます。

# Django と jsonrpcserver の動作環境を Docker Compose で作る

Web フレームワークとして Django を使うことに決めていたので、Django で使える JSON-RPC サーバ用ライブラリとして [jsonrpcserver](https://jsonrpcserver.readthedocs.io/) を使用することにしました。
ここでは Docker Compose を使って、これらが動作する最低限の環境を作ります。

## 環境とバージョン

以下のソフトウェアで動作を確認しました。

- Windows 10 Home 20H2
- Docker Desktop 3.0.4
- Django 3.1.4
- jsonrpcserver 4.2.0

## ファイル／ディレクトリの準備

Docker Compose の公式ドキュメントを参考にして、jsonrpcserver が動作する最低限の環境を作ります。

- [Docker Compose » クイックスタート・ガイド：Docker Compose と Django](http://docs.docker.jp/compose/django.html)

ここでは`django_jrpc/`をベースとして、Docker Compose に必要な定義ファイルを準備します。
またサブディレクトリ`code/`を Django のプロジェクトディレクトリとして、コンテナ内から使用できるようにします。

```Shell:
django_jrpc/
├── code/
├── Dockerfile
├── docker-compose.yml
└── requirements.txt
```
### Dockerfile
Docker の定義です。Python の公式 Docker イメージを使用し、`/code/`を Django のプロジェクトディレクトリにします。

```Dockerfile:Dockerfile
FROM python:3.9
ENV PYTHONUNBUFFERED 1
WORKDIR /code
ADD requirements.txt /code/
RUN pip install -r requirements.txt
```

### requirements.txt
Dockerfile の`RUN`でインストールする pip パッケージのリストです。
今回は jsonrpcserver の動作に最低限必要な Django + jsonrpcserve と、後で使用する django-ipware だけになります。

```:requirements.txt
Django==3.1.4
jsonrpcserver==4.2.0
django-ipware==3.0.2
```

実際の環境を作るときには、リンターやフォーマッター、DB 接続用ライブラリなどもここに追加します。

### docker-compose.yml
Docker Compose  の定義です。

```Yaml:docker-compose.yml
version: "3"
services:
  web:
    build: .
    command: python3 manage.py runserver 0.0.0.0:8000
    volumes:
      - ./code:/code
    ports:
      - 8000:8000
```

**services:**
`services`の下の`web`（名前は任意）以下が今回 Django で作るサービスのコンテナ定義になります。
DBMS など別のサービスを追加する場合は、同様に新しいコンテナの定義を`services`の下に追加します。

**command:**
コンテナ起動時に自動実行するコマンドとして、Django の開発用サーバを起動するための `runserver`を指定します。
ここで使用される`/code/manage.py`というファイルは Django プロジェクトを作成すると自動的に生成されます。

**volumes:**
Dockerfile でコンテナ内に作るように定義した`/code/`ディレクトリ (`WORKDIR`) を、ホストの`django_jrpc/code/`にマウントします。

## イメージのビルドと Django プロジェクトの作成

初回は以下のコマンドを実行して「web」サービスのイメージをビルドして、一時的なコンテナの中で Django プロジェクトを作成します。

```Shell:
docker-compose run --rm web django-admin.py startproject rpcproject .
```

- `django_jrpc/`（docker-compose.yml があるディレクトリ）で実行する
- **web**: docker-compose.yml の`services:`で指定したサービス名
- **rpcproject**: 作成する Django プロジェクト名（任意）

これで`django_jrpc/code/`の下に`manage.py`と、プロジェクト名と同名のディレクトリ`rpcproject/`が生成されます。

- **manage.py**: Django プロジェクトに対する様々な操作を行うためのコマンドラインユーティリティ

```Shell:
django_jrpc/
├── code/
│   ├── rpcproject/
│   │   ├── __init__.py
│   │   ├── asgi.py
│   │   ├── settings.py
│   │   ├── urls.py
│   │   └── wsgi.py
│   └── manage.py
├── docker-compose.yml
├── Dockerfile
└── requirements.txt
```

## コンテナを起動して Django の開発サーバを実行する

以後は以下のコマンドでコンテナを起動できます。
docker-compose.yml の`command`で指定した`python3 manage.py runserver`により、Django の開発用サーバも起動します。

```Shell:
docker-compose up -d
```

Web ブラウザで`localhost:8000`にアクセスして以下の画面が表示されれば、Django プロジェクトの作成と開発用サーバの起動に成功しています。
![django-install-success.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/169872/cf6d95b8-31cd-124a-8020-a426e963c05e.png)

コンテナの状態と名前を以下のように確認することができます。

```Shell:
docker-compose ps
      Name                     Command               State           Ports
-----------------------------------------------------------------------------------
django_jrpc_web_1   python3 manage.py runserve ...   Up      0.0.0.0:8000->8000/tcp
```

表示されたコンテナ名を使って、以下のコマンドでコンテナの中に入ることができます。

```Shell:
docker exec -it django_jrpc_web_1 bash
```

コンテナ内の`/code/`ディレクトリは以下のようになりました。

```
/code/
├── rpcproject/
│   ├── __pycache__/
│   ├── __init__.py
│   ├── asgi.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── db.sqlite3
└── manage.py
```

# jsonrpcserver の使い方

## 情報源

このライブラリを使用するうえで困った点は、情報が少ないことでした。
公式のドキュメントとサンプルは以下で全部らしく、非公式の有効な情報も見つかりませんでした。

- [jsonrpcserver - Process JSON-RPC requests in Python](https://jsonrpcserver.readthedocs.io/)
- [GitHub - bcb/jsonrpcserver](https://github.com/bcb/jsonrpcserver)
- [examples in various frameworks #Django](https://jsonrpcserver.readthedocs.io/en/latest/examples.html#django)
- [guide to usage and configuration](https://jsonrpcserver.readthedocs.io/en/latest/api.html)

しかしコンパクトなライブラリなので、分からない部分はデバッガでライブラリ内にステップインして読むことで解決しました。勉強にもなって一石二鳥です。

## プロジェクトに追加・変更するファイル（共通）

### urls.py

プロジェクト作成時に生成されたファイル urls.py に、以下のコメントを付けた 2行を追記します。
これで URL のパスが`rpc/`のリクエストが、views.py の`jsonrpc`関数にルーティングされます。

```Python:urls.py
from django.contrib import admin
from django.urls import path

from .views import jsonrpc  # views.py から jsonrpc 関数をインポートする

urlpatterns = [
    path('admin/', admin.site.urls),
    path('rpc/', jsonrpc),  # URL のパスが 'rpc/' なら jsonrpc 関数にルーティングする
]
```

### views.py

`/code/rpcproject/`直下に以下のファイル views.py を追加し、urls.py からルーティングされたリクエストを処理する`jsonrpc`関数を定義します。

```Python:views.py
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from jsonrpcserver import method, dispatch

@csrf_exempt
def jsonrpc(request):
    response = dispatch(request=request.body.decode())
    return JsonResponse(
        response.deserialized(), status=response.http_status, safe=False
    )
```
`jsonrpc`関数はリクエストのボディを jsonrpcserver の`dispatch()`に渡します。
`dispatch()`内ではリクエストボディが精査されて、JSON リクエストの`"method"`キーで指定された値と同じ名前の関数に処理が振り分けられます。
参考: [jsonrpcserver Guide - Dispatch](https://jsonrpcserver.readthedocs.io/en/latest/api.html#dispatch)

## 例1: 引数なしの例

最初に、[公式サンプルの 'ping'](https://jsonrpcserver.readthedocs.io/en/latest/examples.html#django) の動作を確認します。
以下のように、引数なしで`"ping"`という関数を呼べば`"pong"`という文字列が返るだけのサンプルです。

```Json:例1-pingリクエスト
{
  "jsonrpc": "2.0",
  "method": "ping",
  "id": 1
}
```
```Json:例1-ping正常レスポンス（pong）
{
  "jsonrpc": "2.0",
  "result": "pong",
  "id": 1
}
```

`"method": "ping"`のリクエストを受け取るために、views.py に以下の`ping`関数を追加します。
`@method`デコレータを適用して、jsonrpcserver の`dispatch()`が振り分け先を見つけられるようにします。

```Python:views.py
@method
def ping():
    return "pong"
```

クライアント側は JSON を POST で送信できればいいので、今回はシェルスクリプトで`curl`コマンドを使用します。
関数名を`"method": "ping"`で指定して、JSON-RPC 形式のリクエストにして POST します。

```Bash:call_ping.sh
#!/bin/bash

content_type="Content-Type:application/json-rpc"
url="http://127.0.0.1:8000/rpc/"
payload=$(cat <<__EOT__
{
  "jsonrpc": "2.0",
  "method": "ping",
  "id": 1
}
__EOT__
)

curl -H ${content_type} -d "${payload}" ${url} 2>/dev/null | jq
```
スクリプトを実行して、以下のように`"result": "pong"`のレスポンスが返れば成功です。

```Json:レスポンス
{
  "jsonrpc": "2.0",
  "result": "pong",
  "id": 1
}
```

## jsonrpcserver ライブラリのエラー処理 (1)

JSON-RPC 仕様に以下のエラーが定義されています。
https://www.jsonrpc.org/specification#error_object

code|message|meaning
--:|:--|:--
-32700|Parse error|無効な JSON を受信し、JSON 文字列を解析中にエラーが発生した
-32600|Invalid Request|送信された JSON が有効なリクエストオブジェクトではない
-32601|Method not found|メソッドが存在しないか利用できない
-32602|Invalid params|メソッドのパラメータが無効
-32603|Internal error|JSON-RPC 内部エラー
-32000 to -32099|Server error|実装定義のサーバーエラー用に予約されている

検証のためにクライアントスクリプトの一部を改変して、`"ping"`の次の`,`を削除して不正な JSON にしてみます。

```Bash:call_ping.sh
  "method": "ping"
  "id": 1
```
結果は期待したとおり、`-32700 Parse error`になります。
`views.ping`関数にはエラーを処理するコードを何も書いていませんが、ライブラリが内部で判定して適切なエラーを返してくれました。

```Json:エラーレスポンス（無効なJSON）
{
  "jsonrpc": "2.0",
  "error": {
    "code": -32700,
    "message": "Invalid JSON"
  },
  "id": null
}
```

メソッド名を間違えて存在しないメソッド名にした場合も、期待したとおりに`-32601 Method not found`を返してくれます。

```Bash:call_ping.sh
  "method": "pinggggg",
```

```Json:エラーレスポンス（メソッドが見つからない）
{
  "jsonrpc": "2.0",
  "error": {
    "code": -32601,
    "message": "Method not found"
  },
  "id": 1
}
```

このように JSON-RPC で定義されたエラーをライブラリが自動的に処理してくれるので、API 開発者は正しい形式のリクエストを受け取った後の論理的な処理の開発に集中することができます。

## 例2: 引数ありの例

次は引数を受け取る関数`"division"`（割り算）を作ります。
`"params"`で 2つの引数`"dividend"`（被除数）と`"divisor"`（除数）を辞書形式で指定します。

```Json:例2-割り算リクエスト
{
  "jsonrpc": "2.0",
  "method": "division",
  "params": {
    "dividend": 1080,
    "divisor": 16
  },
  "id": 1
}
```

戻り値も`"quotient"`（商）`"remainder"`（余り）を辞書形式で返します。

```Json:例2-割り算の正常レスポンス
{
  "jsonrpc": "2.0",
  "result": {
    "quotient": 67,
    "remainder": 8
  },
  "id": 1
}
```

views.py に以下の`division`関数を追加します。

```Python:views.py
@method
def division(dividend, divisor):
    return {"quotient": dividend // divisor, "remainder": dividend % divisor}
```

クライアントのシェルスクリプトを作ります。
まず 2つの引数`"dividend"`（被除数）と`"divisor"`（除数）がキーワード引数として関数に渡されることを確認するために、わざと順序を逆にしました。

```Bash:call_division.sh
#!/bin/bash

content_type="Content-Type:application/json-rpc"
url="http://127.0.0.1:8000/rpc/"
payload=$(cat <<__EOT__
{
  "jsonrpc": "2.0",
  "method": "division",
  "params": {
    "divisor": 16,
    "dividend": 1080
  },
  "id": 1
}
__EOT__
)

curl -H ${content_type} -d "${payload}" ${url} 2>/dev/null | jq
```
スクリプトを実行すると、期待したとおり` "result": {"quotient": 67, "remainder": 8}`を含むレスポンスが返りました。

つまり引数を辞書形式で
`"params": {"divisor": 16, "dividend": 1080}`
と指定することは、Python のキーワード引数で
`(divisor=16, dividend=1080)`
と指定することと同じになり、名前が優先されて順序が無視されます。

次に引数をリスト形式で渡します。まず逆順のまま`[16, 1080]`にします。

```Bash:call_division.sh
  "params": [
    16,
    1080
  ],
```
結果は`"result": {"quotient": 0, "remainder": 16}`になったので、Python の位置引数で
`(16, 1080)`
と指定することと同じになりました。

## jsonrpcserver ライブラリのエラー処理 (2)

もう一度`"params"`の値を辞書形式に戻しますが、今度はわざと引数名を間違えて違う名前にします。

```Bash:call_division.sh
  "params": {
    "dividendddddddd": 1080,
    "divisor": 16
  },
```
結果は以下のとおり`-32602 Invalid parameters`で、不正な引数のエラーになりました。

```Json:エラーレスポンス（不正なパラメータ名）
{
  "jsonrpc": "2.0",
  "error": {
    "code": -32602,
    "message": "Invalid parameters"
  },
  "id": 1
}
```
これもエラー処理のコードを書く必要がなく、ライブラリが内部で判定して適切なエラーを返してくれました。


## 例3: コンテキストありの例

次はクライアントから渡される引数ではなく、`jsonrpc`関数が`dispatch()`を通して各関数にデータを渡すことができる「コンテキスト」を使います。
参考: [jsonrpcserver Guide - Context](https://jsonrpcserver.readthedocs.io/en/latest/api.html#context)

あまり使い道はないかもしれませんが、今回の例ではクライアントの IP アドレスを渡すために使用します。
リクエストから IP アドレスを取得するために、requirements.txt に記述した django-ipware を使用します。
参考: [Django でクライアントの IP アドレスを取得する](https://qiita.com/3244/items/0b47d3ad91968fe15eb9)

views.py を以下のように変更します。コメント部分が追加・変更箇所です。

```Python:views.py
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from jsonrpcserver import method, dispatch

# ライブラリを追加でインポート
import ipware

@csrf_exempt
def jsonrpc(request):
    # requestオブジェクトからクライアントのIPアドレスを抽出する
    ipaddress, _ = ipware.get_client_ip(request)

    # jsonrpcserver.dispatch関数の引数にコンテキストを追加する
    response = dispatch(request=request.body.decode(), context={'ipaddress': ipaddress})
    return JsonResponse(
        response.deserialized(), status=response.http_status, safe=False
    )

# 引数リストにcontextを追加する
@method
def division(context, dividend, divisor):
    # コンテキストからIPアドレスを取り出す
    ipaddress = context['ipaddress']
    # IPアドレスを戻り値に追加する
    return {"quotient": dividend // divisor, "remainder": dividend % divisor, "ipaddress": ipaddress}
```

例2 と同じ call_division.sh スクリプトを使って`division`メソッドを呼び出すと、IP アドレスが戻り値に追加されました。

```Json:レスポンス（コンテキストで渡された値を含む）
{
  "jsonrpc": "2.0",
  "result": {
    "quotient": 67,
    "remainder": 8,
    "ipaddress": "172.21.0.1"
  },
  "id": 1
}
```
# jsonrpcserver を使ってみて

以上のように、jsonrpcserver はリクエストの精査や各種エラー処理、指定された名前の関数への振り分けなどを自動的に処理してくれるので、開発者が API の中身の開発に集中できてコードの可読性も向上する、大変ありがたいライブラリです。

最初は情報が少ないことが難点でしたが、使い方が分かってしまえば簡単に使えるようにできています。

# 参考

- JSON-RPC 2.0 仕様
  - [JSON-RPC 2.0 Specification](https://www.jsonrpc.org/specification)
- jsonrpcserver 公式ドキュメントとサンプル
  - [jsonrpcserver - Process JSON-RPC requests in Python](https://jsonrpcserver.readthedocs.io/)
  - [GitHub - bcb/jsonrpcserver](https://github.com/bcb/jsonrpcserver)
  - [examples in various frameworks #Django](https://jsonrpcserver.readthedocs.io/en/latest/examples.html#django)
  - [guide to usage and configuration (jsonrpcserver Guide)](https://jsonrpcserver.readthedocs.io/en/latest/api.html)
- Docker Compose
  - [Docker Compose » クイックスタート・ガイド：Docker Compose と Django](http://docs.docker.jp/compose/django.html)
- Zabbix API ドキュメント
  - [Zabbix API](https://www.zabbix.com/documentation/5.0/manual/api)
- その他
  - [JSON-RPC - Wikipedia](https://ja.wikipedia.org/wiki/JSON-RPC)
  - [Django でクライアントの IP アドレスを取得する](https://qiita.com/3244/items/0b47d3ad91968fe15eb9)