API 経由で DB を操作したい!「DB API」を作ってみた

Cloudflare D1 Database という、SQLite を Cloudflare Workers から呼び出して使えるサービスがある。他にも MongoDB Atlas だったり、Vercel Storage などのように、ちょっとした Functions から呼び出して利用できるようなストレージサービスはいくつかある。

ただしこれらは、DB 接続のための Serverless Functions が必要だったり、そのために DB 接続パスワードの秘匿化 (環境変数での注入) が必要だったりで、ローカルからすんなり呼び出しづらい。

MongoDB Atlas と、Vercel Storage についてはローカルからの呼び出しに対応しているようだが、D1 に関しては Cloudflare Workers 経由でしか呼び出せないので、実行環境が制限されるところも難点だ。

大したデータを置きたいワケじゃないけど、RDB ないしは JSON データをちょこっと管理できるようなストレージが欲しいんじゃ…。できれば curl で API を叩いて DB の読み出しができるようなモノが欲しい……。


というワケで作りました。その名も DB API

API ドキュメントのページを見てもらうと大体分かると思うけど、JSON ファイルを DB 代わりに用いる「JSON DB」と、SQLite の2つに対応している。

JSON DB の方は、lowdb というシンプルなツールを利用して JSON ファイルを読み書きしている。微妙に TypeScript での import に対応していなかったので、ソースコードを一部改変して直接取り込んで利用させてもらっている。

SQLite の方は素直に npm の sqlite3 パッケージを利用している。


これらのデータ永続化層に対して、NestJS で API サーバを作っている。DB 自体の作成・削除にはマスターパスワードが必要になっていて、そこで DB ごとの名前と接続パスワードを決める。

DB が作成できたら、DB 名と接続パスワードを書きつつ、SQL ないしは API エンドポイントを指定することで CRUD ができるようになっている。

これらを curl で実現しようとすると、以下のように叩ける。

# JSON DB を1つ作成する (マスターパスワードが必要)
$ curl -X POST -d '{ "credential": "【マスターパスワード】", "db_name": "example", "db_credential": "【接続パスワード】" }' -H 'Content-Type: application/json' http://localhost:5001/json-db/create-db

# 作成されている JSON DB の名前を列挙する (マスターパスワードが必要)
$ curl -X POST -d '{ "credential": "【マスターパスワード】" }' -H 'Content-Type: application/json' http://localhost:5001/json-db/list-db-names

# 作成した JSON DB に対してデータを投入する (`id` プロパティが自動採番して付与される)
$ curl -X POST -d '{ "db_name": "example", "db_credential": "【接続パスワード】", "item": { ... } }' -H 'Content-Type: application/json' http://localhost:5001/json-db/create

# JSON DB の内容を全件出力する
$ curl -X POST -d '{ "db_name": "example", "db_credential": "【接続パスワード】" }' -H 'Content-Type: application/json' http://localhost:5001/json-db/find-all

一方、SQLite の方は sqlite3 パッケージの API (runallget) がほぼそのままむき出しになっている。

# SQLite DB を1つ作成する (マスターパスワードが必要)
$ curl -X POST -d '{ "credential": "【マスターパスワード】", "db_name": "example", "db_credential": "【接続パスワード】" }' -H 'Content-Type: application/json' http://localhost:5001/sqlite/create-db

# 作成されている SQLite DB の名前を列挙する (マスターパスワードが必要)
$ curl -X POST -d '{ "credential": "【マスターパスワード】" }' -H 'Content-Type: application/json' http://localhost:5001/sqlite/list-db-names

# 作成した SQLite DB に対してデータを投入する (CREATE TABLE・INSERT・UPDATE・DELETE の時は `run` を利用する)
$ curl -X POST -d '{ "db_name": "example", "db_credential": "【接続パスワード】", "sql": "INSERT INTO my_table (name) VALUES (?)", "params": [\"Example Name\"] }' -H 'Content-Type: application/json' http://localhost:5001/sqlite/run

# SQLite DB に対して SELECT を投げる (複数件取得は `all`・1件取得は `get` を利用する)
$ curl -X POST -d '{ "db_name": "example", "db_credential": "【接続パスワード】", "sql": "SELECT * FROM my_table" }' -H 'Content-Type: application/json' http://localhost:5001/sqlite/all

全ての通信は POST メソッドで行う。RESTful な API ではない。

マスターパスワードも、DB 接続パスワードも、平文でリクエストボディの中に漂うので、その点セキュアではない。ついでに DB 名と DB 接続パスワードを管理する「マスター DB」を JSON DB と SQLite とで作成するのだが、その中身も平文のまま保存しているので全然セキュアではない。w


NestJS の実装としては特段変わったことはしていない。ただ今回、API ドキュメントを出力するために Swagger モジュール @nestjs/swagger を入れたので、そこだけ特殊かな。

Swagger ドキュメントの生成は main.ts で行う。

import * as fs from 'node:fs/promises';
import * as path from 'node:path';

import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';

import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  // Swagger を生成する
  const swaggerConfig = new DocumentBuilder()
    .setTitle('DB API')
    .setVersion('0.0.0')
    .build();
  const swaggerDocument = SwaggerModule.createDocument(app, swaggerConfig);
  
  // Swagger のページを `/` で公開し、JSON と YAML 版も公開する
  SwaggerModule.setup('', app, swaggerDocument, {
    jsonDocumentUrl: 'swagger/json',
    yamlDocumentUrl: 'swagger/yaml'
  });
  
  // Swagger JSON ファイルを書き出す
  await fs.mkdir(path.resolve(__dirname, '../docs'), { recursive: true });
  await fs.writeFile(path.resolve(__dirname, '../docs/swagger.json'), JSON.stringify(swaggerDocument), 'utf-8');
  
  // …省略…
}

SwaggerModule.setup() を使うと、NestJS サーバ上に Swagger API ドキュメントのページをデプロイできる。便利な半面、API 一覧と仕様が外部公開されることになるので、あんまり大っぴらにしたくない API サーバを作る時は要注意。

SwaggerModule.createDocument() の実体は JSON データなので、コレを .json ファイルに保存し、次のようなシングル HTML で読み込むようにすると、前述の GitHub Pages で公開している API ドキュメントが作れる。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>API</title>
    <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui.css">
  </head>
  <body>
    <div id="swagger-ui"></div>
    <script src="https://unpkg.com/swagger-ui-dist@5.11.0/swagger-ui-bundle.js" crossorigin></script>
    <script>
      window.addEventListener('load', () => {
        window.ui = SwaggerUIBundle({
          url: './swagger.json',  // 生成した swagger.json を読み込む
          dom_id: '#swagger-ui',
        });
      });
    </script>
  </body>
</html>

あとは @nestjs/swagger 向けに、Controller の各メソッドに色々とデコレーターを書いた。パラメータ類の description とか example とかその辺。コレがめんどくさかった。


npm の sqlite3 パッケージはコールバック関数形式なので、node:utilutil:promisify で Promise 化して利用した。内部的に this を多用するため、以下の記事で解説されているように call()bind() を用いる必要がある。

それでも一つうまくいかなかったのは Database#run() メソッドの結果取得で、コレだけ this 自身を resolve() したかったため、素直に new Promise() でラップした。

const db = new Database('./EXAMPLE.sqlite3');

const result = await new Promise((resolve, reject) => {
  db.run(sql, params, function(error) {  // ← `this` を取りたいのでアロー関数などにはできない
    if(error) reject(error);
    resolve(this);  // コレで `lastID` と `changes` プロパティが拾える
  });
});

こんなところかな。個人のサーバにデプロイしておいて、よしなに使えばいいと思う。パスワードが平文で気になるよ、って人はテキトーに暗号化すれば良いんじゃないでしょうか… (投げやり)