白と薄いピンク色の桜のイラスト

Bun の Web フレームワーク ElysiaJS のチュートリアル

ElysiaJS は Bun の Web フレームワークです。高いパフォーマンスと、シンプルな API や厳格な型チェックにより生産性に優れているという特徴があります。また柔軟性が高く、多くのプラグインを組み合わせて利用できます。この記事では、ElysiaJS を使って簡単なタスク管理アプリの Web API を作成してみます。

ElysiaJS は Bun の Web フレームワークです。高いパフォーマンスと、シンプルな API や厳格な型チェックにより生産性に優れているという特徴があります。また柔軟性が高く、多くのプラグインを組み合わせて利用できます。

この記事では、ElysiaJS を使って簡単なタスク管理アプリの Web API を作成してみます。

プロジェクトの作成

ElysiaJS は Bun で動作するために作られたフレームワークです。まずは Bun をインストールしましょう。

curl https://bun.sh/install | bash

bun create コマンドを実行して EliysiaJS のプロジェクトを作成します。

bun create elysia eliysiajs-task-api

eliysiajs-task-api に移動して src/index.ts ファイルを開くと、以下のようなコードが生成されています。

src/index.ts
import { Elysia } from "elysia";
 
const app = new Elysia().get("/", () => "Hello Elysia").listen(3000);
 
console.log(
  `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`,
);

以下のコマンドで、アプリケーションを起動してみましょう。

bun run dev

http://localhost:3000 にアクセスすると、Hello Elysia と表示されていることが確認できます。

curl http://localhost:3000
Hello Elysia

タスク一覧の取得

それでは、タスク管理アプリの API を作成していきましょう。まずは、タスク一覧を取得するための API を作成します。物事を簡単に進めるために、DB などはまだ使わずにハードコーディングされたタスク一覧を返します。

ElysiaJS は Express や Fastify とよく似ているルーティング API を提供しています。 new Eliysia() でインスタンスとして作成した app に対して、getpost といった、HTTP メソッドに合わせたメソッドを呼び出すことで、ルーティングを定義できます。get メソッドの第一引数には、ルーティングのパスを指定します。第二引数には、ルーティングにマッチした際に実行される Handler 関数を指定します。

例として GET メソッドで /tasks にアクセスした際に、タスク一覧を返す API を作成してみましょう。ルーティングの定義例にならい、src/index.ts を以下のように変更します。

src/index.ts
import { Elysia } from "elysia";
 
const tasks = [
  { id: "1", name: "Buy milk", status: "done" },
  { id: "2", name: "Buy eggs", status: "done" },
  { id: "3", name: "Buy bread", status: "pending" },
];
 
const app = new Elysia()
  .get("/tasks", () => {
 
  return {
    tasks,
  };
})
 
app.listen(3000);
 
console.log(
  `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`
);

Handler 関数の戻り値は ElysiaJS によって Response オブジェクトにマッピングされます。上記のコード例のように Handler がオブジェクトを返した場合、以下のようなコードに変換されます。

new Response(JSON.stringify({ tasks }), {
  headers: { "Content-Type": "application/json" },
});

ElysiaJS の特徴として、メソッドチェーンによる方法を推奨している点があげられます。これは、.state() などで新たに Context に注入された型情報を、後続のメソッドに伝えるためです。メソッドチェーンを使用しない場合には、型情報が失われてしまいます。

const app = new Elysia();
 
app.state("build", 1);
 
// build 型は存在しない
app.get("/", ({ store: { build } }) => build);

実際に API をコールして結果を確認してみましょう。GUI の API クライアントとして HTTPie を利用します。上部の入力欄に http://localhost:3000/tasks と入力して、Send ボタンをクリックします。

確かに application/json でタスク一覧が返ってきていることが確認できます。

タスクの作成

続いてタスクを作成するための API を作成します。/tasks に POST リクエストが送信された場合に、リクエストボディの内容で新しいタスクを作成します。Handler 関数の引数として Context オブジェクトを受け取ります。Context オブジェクトには、リクエストボディやクエリパラメータ、パスパラメータなどの情報が含まれています。

body パラメータを利用して、リクエストボディの内容を取得します。

src/index.ts
app.post("/tasks", ({ body }) => {
  console.log(body);
 
  return {
    message: "Task created",
  };
});

作成したエンドポイントに対して、リクエストを送信してみましょう。以下の JSON をリクエストボディとして送信します。

{
  "name": "Go to the gym",
  "status": "pending"
}

HTTPie を使ってリクエストを送信する場合には、入力欄の左側にあるセレクトボックスで POST を選択し、http://localhost:3000/tasks と入力します。また、左側のパネルで Body を選択してから、形式を Text に変更します。リクエストボディを入力してから、Send ボタンをクリックしましょう。

{ "message": "Task created" } というレスポンスが返ってきていることが確認できます。

アプリケーションを起動しているターミナルには、リクエストボディの内容が出力されていることが確認できます。

🦊 Elysia is running at localhost:3000
{
  name: "Go to the gym",
  status: "pending"
}

ボディリクエストに対するバリデーション

Context オブジェクトの body パラメータはデフォルトでは unknown 型ですので、このまま扱うには少々不便です。ElysiaJS では @sinclair/typebox による型スキーマ機能を提供しています。以下のように、ルートメソッドの第 3 引数でスキーマを定義することで、ルート単位でリクエストボディのバリデーションを行うことができます。

src/index.ts
import { Elysia, t } from "elysia";
 
app.post("/tasks", ({ body }) => {
  console.log(body);
 
  return {
    message: "Task created",
  };
}, {
  body: t.Object({
    name: t.String(),
    status: t.Union([t.Literal("done"), t.Literal("pending"), t.Literal("in-progress")]),
  })
});

スキーマを定義することで、body パラメータの型が unknown 型ではなくスキーマで定義された型に推論されていることがわかります。

name パラメータを省略するなど、スキーマに定義されていない値をリクエストボディに含めた場合には、400 Bad Request が返されます。

Invalid body, 'name': Expected string
 
Expected: {
  "name": "",
  "status": "done"
}
 
Found: {
  "status": "pending"
}

body のスキーマを定義できたので、リクエストボディを保存する処理を実装してみましょう。tasks 配列に新しいタスクを追加して、新たに作成したタスクを返します。

src/index.ts
app.post("/tasks", ({ body }) => {
  const newTask = {
    id: tasks.length + 1,
    name: body.name,
    status: body.status,
  }
 
  tasks.push(newTask);
 
  return {
    task: newTask,
  };
}, {
  body: t.Object({
    name: t.String(),
    status: t.Union([t.Literal("done"), t.Literal("pending"), t.Literal("in-progress")]),
  })
});

タスクを作成した後に、GET /tasks でタスク一覧を取得してみましょう。追加したタスクが含まれていることが確認できます。

{
  "tasks": [
    {
      "id": "1",
      "name": "Buy milk",
      "status": "done"
    },
    {
      "id": "2",
      "name": "Buy eggs",
      "status": "done"
    },
    {
      "id": "3",
      "name": "Buy bread",
      "status": "pending"
    },
    {
      "id": "4",
      "name": "Go to Gym",
      "status": "pending"
    }
  ]
}

Task モデルの作成

先程はルートレベルでスキーマを作成して利用していましたが、このままではスキーマの再利用ができません。ElysiaJS では .model メソッドでスキーマを 1 つの場所で定義して、後から文字列で参照が可能です。src/tasks/task.model.ts を作成して、以下のようにスキーマを定義します。

src/tasks/task.model.ts
import { Elysia, t } from "elysia";
import { Static } from "@sinclair/typebox";
 
const status = t.Union([t.Literal("done"), t.Literal("pending"), t.Literal("in-progress")])
 
const task = t.Object({
  id: t.String(),
  name: t.String(),
  status,
})
 
// Static は型スキーマから TypeScript の型を生成するヘルパー関数
export type Task = Static<typeof task>
 
const taskDto = t.Object({
  name: t.String(),
  status,
})
 
export type TaskDto = Static<typeof taskDto>
 
const app = new Elysia();
export const taskModel = app.model({
  "task.task": task,
  "task.tasks": t.Array(task),
  "task.taskDto": taskDto,
})

taskModel には、task.task という名前で task スキーマが登録されています。同じ名前のスキーマを複数回登録すると ElysiaJS は例外を投げるため、. 区切りでプレフィックスを付けることが推奨されています。ここで生成したモデルは src/index.ts から app.use() の引数として渡すことで、スキーマを参照できるようになります。

スキーマは body だけでなく、response に対しても定義できます。response のスキーマを定義することで、誤ったレスポンスを返すことを防ぐことができます。

src/index.ts
import { Elysia } from "elysia";
import { Task, taskModel } from "./tasks/task.model";
 
const tasks: Task[] = [
  { id: "1", name: "Buy milk", status: "done" },
  { id: "2", name: "Buy eggs", status: "done" },
  { id: "3", name: "Buy bread", status: "pending" },
];
 
const app = new Elysia()
  .use(taskModel)
  .get("/tasks", () => {
      return {
        tasks,
      };
    }, {
      response: "task.tasks",
    }
  )
  .post("/tasks", ({ body }) => {
      const newTask: Task = {
        id: tasks.length + 1,
        name: body.name,
        status: body.status,
      };
 
      tasks.push(newTask);
 
      return {
        task: newTask,
      };
    }, {
      body: "task.taskDto",
      response: "task.task",
    }
  );

app.use() で登録したモデルにアクセスするためには、必ずメソッドチェーンの形式でルートを呼び出さなければいけないことに注意してください。

sqlite3 を使ったデータベースの操作

ここまではタスクを保存するために、メモリ上の配列を利用していました。しかし、メモリ上に保存されたデータはアプリケーションを再起動すると消えてしまいます。データを永続化するために、sqlite3 を使ったデータベースの操作を実装してみましょう。

Bun ではビルドインのモジュールで sqlite3 のドライバーを提供しています。型による安全性は提供されていないものの、簡単な CRUD 操作を実装するには十分でしょう。

まずは、データベースのテーブルを作成するために、src/db/create-tables.ts を作成します。

src/db/create-tables.ts
import { Database } from "bun:sqlite";
 
// { create: true } は DB が存在しない場合に作成するオプション
const db = new Database("db.sqlite", { create: true });
 
db.exec(`
  CREATE TABLE IF NOT EXISTS tasks (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    status TEXT NOT NULL
  )
`);
 
db.close();

DB を作成するスクリプトを package.jsonscripts フィールドに追加します。

package.json
{
  "scripts": {
    "create-table": "bun src/db/create-table.ts"
  }
}

以下のコマンドで DB を作成します。

bun run create-tables

正常にテーブルの作成に成功した場合には、db.sqlite というファイルが作成されていることが確認できます。

Task Repository の作成

src/tasks/task.repository.ts を作成して、以下のようにデータベースの操作を実装します。

src/tasks/task.repository.ts
import { Database } from "bun:sqlite";
import { Task, TaskDto } from "./task.model";
 
const db = new Database("db.sqlite");
 
const getAllQuery = db.prepare("SELECT * FROM tasks");
const getTaskByIdQuery = db.prepare("SELECT * FROM tasks WHERE id = ?");
const insertQuery = db.prepare("INSERT INTO tasks (id, name, status) VALUES (?, ?, ?)");
 
export const TaskRepository = {
  /**
   * すべてのタスクを取得する
   */
  getAll() {
    return getAllQuery.all() as Task[];
  },
 
  /**
   * タスクを作成する
   */
  create(taskDto: TaskDto) {
    const id = crypto.randomUUID();
    insertQuery.run(id, taskDto.name, taskDto.status);
    const record = getTaskByIdQuery.get(id);
 
    if (!record) {
      throw new Error("Task not found");
    }
 
    return record as Task;
  }
}

Task Repository を Context に登録する

作成した Task Repository をそのまま利用しても良いのですが、データベースを操作するクラスはテストの容易性や関心の分離などの目的のために Dependency Injection(DI) パターンがよく利用されます。ElysiaJS では、State や Decorate を利用して、DI 近いことを実現できます。

statedecorate を利用した DI を実装する前に、メインのアプリケーションのインスタンスと、それぞれのルートを定義するインスタンスを分離しておきましょう。src/tasks/index.ts ファイルを作成して、今まで src/index.ts に記述していたルート定義を移動します。ここでは ElysiaJS のインスタンスとして taskRoute を export します。

src/tasks/index.ts
import { Elysia } from "elysia";
import { Task, taskModel } from "./task.model";
 
const tasks: Task[] = [
  { id: 1, name: "Buy milk", status: "done" },
  { id: 2, name: "Buy eggs", status: "done" },
  { id: 3, name: "Buy bread", status: "pending" },
];
 
export const taskRoute = new Elysia()
  .use(taskModel)
  .get("/tasks", () => {
      return {
        tasks,
      };
    }, {
      response: "task.tasks",
    }
  )
  .post("/tasks", ({ body }) => {
      const newTask: Task = {
        id: tasks.length + 1,
        name: body.name,
        status: body.status,
      };
 
      tasks.push(newTask);
 
      return {
        task: newTask,
      };
    }, {
      body: "task.taskDto",
      response: "task.task",
    }
  );

src/index.ts では、taskRoute をインポートして、app.use() でルートを登録するシンプルな実装になります。

src/index.ts
import { Elysia } from "elysia";
import { taskRoute } from "./tasks";
 
const app = new Elysia()
  .use(taskRoute)
 
app.listen(3000);
 
console.log(
  `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`
);

メインアプリケーションのインスタンスにより .decorate() で登録したオブジェクトには、サブモジュールはアクセスできません。型情報がサブモジュールに伝わらないためです。

サブモジュールから statedecorate で登録したオブジェクトにアクセスするため、statedecorate のみを含んだプラグインを作成し、サブモジュールごとにプラグインを適用します。src/setup.ts を作成して、以下のように .decorate()TaskRepository を登録します。

src/setup.ts
import Elysia from "elysia";
import { TaskRepository } from "./tasks/task.repository";
 
export const setup = new Elysia({ name: "setup" })
  .decorate({
    taskRepository: TaskRepository,
  })

この setup プラグインは TaskRepository が必要なサブモジュールごとに use() を使って適用します。

src/tasks/index.ts
import { Elysia } from "elysia";
import { taskModel } from "./task.model";
import { setup } from "../setup";
 
export const taskRoute = new Elysia()
  .use(taskModel)
  .use(setup)

.decorate() で登録したオブジェクトは、Handler 関数の引数である Context からアクセスできます。taskRepository を利用して、データベースにタスクを保存する処理を実装してみましょう。

src/tasks/index.ts
export const taskRoute = new Elysia()
  .use(taskModel)
  .use(setup)
  .get("/tasks", ({ taskRepository }) => {
    const tasks = taskRepository.getAll();
    return {
      tasks,
    };
  }, {
    response: "task.tasks",
  })
  .post("/tasks", ({ body, taskRepository }) => {
    const newTask = taskRepository.create(body);
 
    return {
      task: newTask,
    };
  }, {
    body: "task.taskDto",
    response: "task.task",
  });

いくつかタスクを作成してから、タスク一覧で取得できるか確認してみましょう。

認証機能

ユーザーが自分が作成したタスクのみを取得・更新できるように、認証機能を実装しましょう。ElysiaJS により提供されている JWT Plugin を利用して、JWT トークンを使った認証機能を実装します。

ユーザーを登録する

認証機能を実装する前に、まずはユーザーを登録してデータベースに保存する処理が必要です。以下の 3 つの作業を行います。

  • ユーザーのスキーマを定義する
  • AuthRepository でユーザーの登録・ログインを行うデータベースの操作を実装する
  • データベースのテーブル定義を変更して、ユーザーテーブルを追加する

ユーザーのスキーマを定義する

まずはユーザーのスキーマを定義します。src/auth/auth.model.ts を作成して、以下のコードを記述します。

src/auth/auth.model.ts
import { Elysia, t } from "elysia";
 
const user = t.Object({
  id: t.String(),
  username: t.String(),
  password: t.String(),
})
 
export type User = Static<typeof user>
 
const userDto = t.Object({
  username: t.String(),
  password: t.String(),
})
 
export type UserDto = Static<typeof userDto>
 
const app = new Elysia();
export const authModel = app.model({
  "auth.user": user,
  "auth.userDto": userDto,
})

AuthRepository でユーザーの登録・ログインを行うデータベースの操作を実装する

src/auth/auth.repository.ts を作成して、以下のようにユーザーの登録やログインを行う処理を実装します。

src/auth/auth.repository.ts
import { Database } from "bun:sqlite";
import { User, UserDto } from "./auth.model";
 
const db = new Database("db.sqlite");
 
const getUserByIdQuery = db.prepare("SELECT * FROM users WHERE id = ?");
const getUserByUsernameQuery = db.prepare("SELECT * FROM users WHERE username = ?");
const insertQuery = db.prepare("INSERT INTO users (id, username, password) VALUES (?, ?, ?)");
 
type Result = {
  success: true,
  user: User,
} | {
  success: false,
  message: string,
}
 
export const AuthRepository = {
  /**
   * ユーザーを登録する
   */
  async create(userDto: UserDto): Promise<Result> {
    // 既に同じユーザー名のユーザーが存在する場合にはエラーを返す
    const existingUser = getUserByUsernameQuery.get(userDto.username);
    if (existingUser) {
      return {
        success: false,
        message: "User already exists",
      }
    }
    const id = crypto.randomUUID();
    // パスワードをハッシュ化
    const hashedPassword = await Bun.password.hash(userDto.password);
    insertQuery.run(id, userDto.username, hashedPassword);
    const record = getUserByIdQuery.get(id);
 
    if (!record) {
      return {
        success: false,
        message: "Unable to create user"
      }
    }
 
    return {
      success: true,
      user: record as User,
    }
  },
 
  /**
   * ユーザーログイン
   */
  async login(userDto: UserDto): Promise<Result> {
    // ユーザー名からユーザーを取得
    const record = getUserByUsernameQuery.get(userDto.username) as User | null;
 
    // 存在しないユーザー名の場合にはエラーを返す
    if (!record) {
      return {
        success: false,
        message: "User not found",
      }
    }
 
    // パスワードを検証
    const isValid = await Bun.password.verify(userDto.password, record.password);
 
    // パスワードが一致しない場合にはエラーを返す
    if (!isValid) {
      return {
        success: false,
        message: "User not found",
      }
    }
 
    return {
      success: true,
      user: record,
    }
  }
}

パスワードをハッシュ化する処理は、Bun のビルドイン関数として Bun.password.hash() が提供されています。パスワードを検証するためには、Bun.password.verify() 関数を利用します。

作成した AuthRepositorysrc/setup.ts 内で decorate として登録します。

src/setup.ts
import Elysia from "elysia";
import { TaskRepository } from "./tasks/task.repository";
import { AuthRepository } from "./auth/auth.repository";
 
export const setup = new Elysia({ name: "setup" })
  .decorate({
    taskRepository: TaskRepository,
  })
  .decorate({
    authRepository: AuthRepository,
  })

データベースのテーブル定義を変更して、ユーザーテーブルを追加する

最後にデータベースのテーブル定義を変更します。src/db/create-table.tsusers テーブルを作成し、tasks テーブルに user_id カラムを追加するように変更します。

src/db/create-table.ts
import { Database } from "bun:sqlite";
 
// { create: true } は DB が存在しない場合に作成するオプション
const db = new Database("db.sqlite", { create: true });
 
db.exec(`
  CREATE TABLE IF NOT EXISTS tasks (
    id TEXT PRIMARY KEY,
    name TEXT NOT NULL,
    status TEXT NOT NULL
  );
`);
 
db.exec(`
  CREATE TABLE IF NOT EXISTS users (
    id TEXT PRIMARY KEY,
    username TEXT NOT NULL,
    password TEXT NOT NULL
  );
 
  ALTER TABLE tasks ADD COLUMN user_id TEXT;
`);
 
db.close();

再度 bun run create-tables を実行して、テーブルの作成に成功したことを確認しましょう。

bun run create-tables

ユーザー登録 API の作成

src/auth/index.ts を作成して、ユーザー登録 API を実装します。

src/auth/index.ts
import { Elysia } from "elysia";
import { authModel } from "./auth.model";
import { setup } from "../setup";
 
export const authRoute = new Elysia()
  .use(authModel)
  .use(setup)
  .post("/auth/signup", async ({ body, authRepository, set }) => {
    const result = await authRepository.create(body);
 
    if (result.success === false) {
      set.status = 400;
      return {
        message: result.message,
      }
    }
 
    set.status = 201;
    return {
      message: "User created successfully",
    };
  }, {
    body: "auth.userDto",
  })

authRepository.crate() を呼び出してユーザーの登録処理を行います。同じユーザー名が既に存在するなど、ユーザーの作成に失敗した場合には 400 Bad Request を返却します。のステータスコードを設定するためには、set オブジェクトを変更します。

作成したユーザー登録 API を有効にするために、src/index.ts ファイルを変更して authRouteapp.use() で登録します。

src/index.ts
import { Elysia } from "elysia";
import { taskRoute } from "./tasks";
import { authRoute } from "./auth";
 
const app = new Elysia()
  .use(taskRoute)
  .use(authRoute)
 
app.listen(3000);

実際にユーザーを登録してみましょう。以下の JSON をリクエストボディとして送信します。

{
  "username": "john",
  "password": "password"
}

{ message: "User created successfully" } というレスポンスが返ってきていることが確認できます。

ログイン API の作成

続いてログイン API を実装します。ログイン時には JWT トークンを発行して、Cookie に保存します。bun add コマンドで JWT プラグインと Cookie プラグインをインストールします。

bun add @elysiajs/jwt @elysiajs/cookie

authRoute で JWT プラグインと Cookie プラグインを使用するように修正しましょう。

src/auth/index.ts
import { Elysia } from "elysia";
import { authModel } from "./auth.model";
import { setup } from "../setup";
import { cookie } from '@elysiajs/cookie'
import { jwt } from '@elysiajs/jwt'
 
export const authRoute = new Elysia()
  .use(authModel)
  .use(setup)
  .use(cookie())
  .use(jwt({
    // 本番環境では秘密鍵を環境変数から取得するなど、安全な方法で管理してください
    secret: "super-secret-key",
  }))
  .post("/auth/signup", async ({ body, authRepository, set }) => {

ユーザーがログインするためのルートを /auth/signin に作成します。

src/auth/index.ts
export const authRoute = new Elysia()
  .use(authModel)
  .use(setup)
  .use(jwt({
    secret: "super-secret-key",
  }))
  .post("/auth/signin", async ({ jwt, body, authRepository, set, setCookie }) => {
    // ユーザーのログイン処理
    const result = await authRepository.login(body);
 
    // ログインに失敗したら、400 Bad Request を返却
    if (result.success === false) {
      set.status = 400;
      return {
        message: result.message,
      }
    }
 
    // ログインに成功したら、JWT トークンを発行
    const token = await jwt.sign({
      id: result.user.id,
      username: result.user.username,
    });
 
    // JWT トークンを Cookie に保存
    setCookie("token", token, {
      httpOnly: true,
      maxAge: 15 * 60, // 15 minutes
      path: "/",
    });
 
    return {
      message: "User logged in successfully",
    };
  }, {
    body: "auth.userDto",
    response: t.Object({
      message: t.String(),
    }),
  });

authRepository.login() を呼び出して、ユーザーのログイン処理を行います。ログインに失敗した場合には 400 Bad Request を返却します。ユーザーが存在する場合には、JWT トークンを発行し setCookie() メソッドでクッキーを登録します。先程作成したユーザーでログイン API を実行してみましょう。

{
  "username": "john",
  "password": "password"
}

{ message: "User logged in successfully" } というレスポンスが返ってきていることが確認できます。また、レスポンスヘッダーを確認すると、Set-Cookie に JWT トークンが含まれていることが確認できます。この Cookie は後ほど利用します。

isAuthenticated プラグインの作成

ユーザーがログインしているかどうかを判定するためのプラグインを作成します。src/auth/isAuthenticated.ts を作成して、以下のように isAuthenticated プラグインを定義します。

src/auth/isAuthenticated.ts
import cookie from "@elysiajs/cookie";
import jwt from "@elysiajs/jwt";
import { Elysia } from "elysia";
import { setup } from "../setup";
 
export const isAuthenticated = new Elysia()
  .use(setup)
  .use(cookie())
  .use(jwt({
    secret: "super-secret-key",
  }))
  .derive(async ({ cookie, jwt, authRepository }) => {
    // Cookie が存在するか
    if (!cookie?.token) {
      return {
        success: false,
        message: "Unauthorized",
      };
    }
    // JWT トークンを検証
    const token = await jwt.verify(cookie.token);
    if (!token) {
      return {
        success: false,
        message: "Unauthorized",
      };
    }
 
    // ユーザーが存在するか
    const user = authRepository.getUserById(token.id);
    if (!user) {
      return {
        success: false,
        message: "Unauthorized",
      };
    }
 
    // すべての検証をパスしたならば、ユーザー
    return {
      success: true,
      message: "Authorized",
      user,
    };
  });

このプラグインは以下の 3 つの検証を行い、ユーザーがログインしているかどうかを判定します。

  • Cookie が存在し、JWT トークンが含まれているか
  • JWT トークンを検証
  • ユーザーが存在するか

.derive() メソッドは、statedecorate のように Context をカスタマイズするためのメソッドです。.derive() で返却した値は後続のルートで Context に追加され利用できます。

タスクの一覧・作成 API の修正

まずはユーザーに紐づいたタスクを取得・作成できるように TaskRepository を修正しましょう。getAll() メソッドの引数に userId を追加して、取得条件に user_id を追加します。また、SELECT 句では user_id を取得しないように修正します。

src/tasks/task.repository.ts
const db = new Database("db.sqlite");
 
const getAllQuery = db.prepare("SELECT id, name, status FROM tasks WHERE user_id = ?");
const getTaskByIdQuery = db.prepare("SELECT id, name, status FROM tasks WHERE id = ?");
const insertQuery = db.prepare("INSERT INTO tasks (id, name, status) VALUES (?, ?, ?)");
 
export const TaskRepository = {
  /**
   * すべてのタスクを取得する
   */
  getAll(userId: string) {
    return getAllQuery.all(userId) as Task[];
  },

create() メソッドの引数に userId を追加して、タスクの作成時に user_id を保存します。

src/tasks/task.repository.ts
const db = new Database("db.sqlite");
 
const getAllQuery = db.prepare("SELECT id, name, status FROM tasks WHERE user_id = ?");
const getTaskByIdQuery = db.prepare("SELECT * FROM tasks WHERE id = ?");
const insertQuery = db.prepare("INSERT INTO tasks (id, name, status, user_id) VALUES (?, ?, ? ?)");
 
export const TaskRepository = {
  /**
   * タスクを作成する
   */
  create(taskDto: TaskDto, userId: string) {
    const id = crypto.randomUUID();
    insertQuery.run(id, taskDto.name, taskDto.status, userId);
    const record = getTaskByIdQuery.get(id);
 
    if (!record) {
      throw new Error("Task not found");
    }
 
    return record as Task;
  }
}

これで UserRepository の修正は完了です。一旦型エラーが発生する状況になりますが、後ほど修正します。

taskRoute において先程作成した isAuthenticated プラグインを利用して、ユーザーがログインしているかどうかを判定し、Contextuser オブジェクトを利用できるようにします。

src/tasks/index.ts
import { Elysia } from "elysia";
import { taskModel } from "./task.model";
import { setup } from "../setup";
import { isAuthenticated } from "../auth/isAuthenticated";
 
export const taskRoute = new Elysia()
  .use(taskModel)
  .use(setup)
  .use(isAuthenticated)
  .get("/tasks", ({ taskRepository }) => {

isAuthenticated プラグインが user オブジェクトを返さない場合には、401 Unauthorized を返却します。この処理は /tasks のすべてのルートに対して実装することになります。

複数のルートに対して同じ処理を実装する場合には、app.guard() メソッドを利用できます。app.guard() メソッド内で beforeHandle 関数を定義することによって、各ルートの Handler 関数が実行される前のフックを定義できます。

src/tasks/index.ts
export const taskRoute = new Elysia()
  .use(taskModel)
  .use(setup)
  .use(isAuthenticated)
  .guard({
    beforeHandle: [({ user, set }) => {
      if (!user) {
        set.status = 401;
        return {
          message: "Unauthorized",
        }
      }
    }]
  })
  .get("/tasks", ({ taskRepository }) => {

それでは、タスクの取得と作成のルートを修正しましょう。isAuthenticated プラグインにより、user オブジェクトが Context に追加されているため、Handler 関数の引数から user オブジェクトを取得できます。取得した user オブジェクトの user.idTaskRepository.getAll().create() メソッドに渡します。

src/tasks/index.ts
export const taskRoute = new Elysia()
  .use(taskModel)
  .use(setup)
  .use(isAuthenticated)
  .guard({
    beforeHandle: [({ user, set }) => {
      if (!user) {
        set.status = 401;
        return {
          message: "Unauthorized",
        }
      }
    }]
  })
  .get("/tasks", ({ taskRepository, user }) => {
    const tasks = taskRepository.getAll(user!.id);
    return {
      tasks,
    };
    }, {
      response: "task.tasks",
    }
  )
  .post("/tasks", ({ body, taskRepository, user }) => {
    const newTask = taskRepository.create(body, user!.id);
 
    return {
      task: newTask,
    };
  }, {
      body: "task.taskDto",
      response: "task.task",
    }
  );

これでタスクの一覧・作成 API の修正は完了です。実際にタスクを作成してみましょう。Cookie に JWT トークンが含まれていない場合には、401 Unauthorized が返却されることが確認できます。

先程ログイン API をコールした際に取得した Cookie を Cookie ヘッダーに含めてリクエストを送信します。HTTPie を使用している場合には、Headers を選択した後に、nameCookievalue に取得した Cookie を入力します。

同様にヘッダーに Cookie をセットした状態で、タスクの一覧を取得してみましょう。ユーザーに紐づいたタスクの一覧が取得できることが確認できます。

別のユーザーを作成してログインした後に、Cookie の値を入れ替えて再度タスクの一覧を取得してみてください。他のユーザーが作成したタスクは取得できないことが確認できます。

テスト

最後に、作成した API のテストを実装します。Bun には bun:test モジュールがビルドインで提供されていますので、追加のライブラリのインストールを必要せずにテストを実行できます。bun:testJest に似た API を提供しています。

package.jsonscripts にテストを実行するコマンドを追加しましょう。

package.json
{
  "scripts": {
    "test": "bun test",
    "test:watch": "bun test --watch",
    "dev": "bun run --watch src/index.ts",
    "create-table": "bun src/db/create-table.ts"
  },
}

テストではデータベースを使用するので、お互いのテストでデータベースの状態が影響しないように、テストごとにデータベースを初期化するメソッドを用意しておきます。db/test-utils.ts ファイルを作成して、すべてのテーブルのデータを削除する cleanUpDatabase() 関数を実装します。

db/test-utils.ts
import { Database } from "bun:sqlite";
 
const db = new Database("db.sqlite");
 
export const cleanUpDatabase = () => {
  db.exec(`
    DELETE FROM tasks;
  `);
  db.exec(`
    DELETE FROM users;
  `);
};

もう 1 つ、ユーザーを生成するファクトリー関数も作成しておきましょう。

db/test-utils.ts
export const createUser = async ({
  username = "test-user",
  password = "password",
}) => {
  const hashedPassword = await Bun.password.hash(password);
  db.exec(`
    INSERT INTO users (id, username, password) VALUES (?, ?, ?);
  `, [crypto.randomUUID(), username, hashedPassword]);
}

認証機能のテスト

tests/e2e/auth.test.ts を作成して、認証機能のテストを実装しましょう。

tests/e2e.test.ts
import { afterAll, beforeAll, describe, it, expect } from "bun:test";
import { app } from "../../src";
import { cleanUpDatabase } from "../../src/db/test-utils";
 
describe("auth", () => {
  // テストの前後でデータベースを初期化
  beforeAll(() => {
    cleanUpDatabase();
  })
 
  // ユーザーの作成 API のテスト
  it("should create a user", async () => {
    // Elysia インスタンスの .handle() メソッドを利用して、API を呼び出す
    // .handle() メソッドは Request オブジェクトを受け取り、Response オブジェクトを返却する
    const response = await app
      .handle(new Request("http://localhost/auth/signup", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          },
        body: JSON.stringify({
          username: "alice",
          password: "password",
        })
      }))
 
    const body = await response.json();
 
    expect(response.status).toEqual(201);
    expect(body).toEqual({
      message: "User created successfully",
    });
  });
 
  // 同名のユーザーを作成できないことを確認するテスト
  it("should not create a user with the same username", async () => {
    const response = await app
      .handle(new Request("http://localhost/auth/signup", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          username: "alice",
          password: "password",
        })
      }))
 
    const body = await response.json();
 
    expect(response.status).toEqual(400);
    expect(body).toEqual({
      message: "User already exists",
    });
  });
 
  afterAll(() => {
    cleanUpDatabase();
  })
});

beforeAll()afterAll() フックを利用して、テストの前後でデータベースを初期化します。Elysia インスタンスの .handle() メソッドは Request オブジェクトを受け取り、Response オブジェクトを返却します。これにより、HTTP リクエストをシミュレートできます。この .handle() メソッドを利用して、API の呼び出しをテストしています。

Elysia インスタンスである appsrc/index.ts から import するために、src/index.tsappexport するように修正します。

src/index.ts
import { Elysia } from "elysia";
import { taskRoute } from "./tasks";
import { authRoute } from "./auth";
 
export const app = new Elysia()
  .use(taskRoute)
  .use(authRoute);
 
app.listen(3000);
 
console.log(
  `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`
);

ここで、src/index.ts から app を import したことで 1 つ問題が生じます。src/index.ts ファイルを import したことによる副作用で、app.listen(3000) が実行されてしまい、サーバーが起動してしまうのです。

この問題を解決するために、Bun.main 変数を利用します。Bun.main 変数はエントリーポイントとなっているファイルの絶対パスを保持しています。import.meta.path 変数と比較することで、ファイルが直接実行されているか、他のファイルから import されているかを判定できます。ファイルが直接実行されている場合のみ app.listen(3000) を実行するように修正します。

src/index.ts
export const app = new Elysia()
  .use(taskRoute)
  .use(authRoute);
 
if (import.meta.path === Bun.main) {
  app.listen(3000);
 
  console.log(
    `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`
  );
}

bun run test コマンドを実行して、テストが実行されることを確認します。

bun run test
bun run test
$ bun test
bun test v1.0.1 (31aec4eb)
 
tests/e2e/auth.test.ts:
✓ auth > should create a user [76.37ms]
✓ auth > should not create a user with the same username [0.25ms]
 
tests/e2e/tasks.test.ts:
 
 2 pass
 0 fail
 4 expect() calls
Ran 2 tests across 2 files. [141.00ms]

すべてのテストがパスしたことが確認できました。同様に、ログイン API のテストを実装しましょう。

tests/e2e/auth.test.ts
describe("auth", () => {
  it("should login a user", async () => {
    const response = await app
      .handle(new Request("http://localhost/auth/signin", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          username: "alice",
          password: "password",
        })
      }))
    const body = await response.json();
 
    expect(response.status).toEqual(200);
    expect(body).toEqual({
      message: "User logged in successfully",
    });
    // ログインに成功した場合、Cookie に JWT トークンが含まれていることを確認
    expect(response.headers.get("set-cookie")).toMatch(/token=.+;/);
  });
 
  it("should not login a user with wrong password", async () => {
    const response = await app
      .handle(new Request("http://localhost/auth/signin", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          username: "alice",
          password: "wrong-password",
        })
      }))
    const body = await response.json();
 
    expect(response.status).toEqual(400);
    expect(body).toEqual({
      message: "User not found",
    });
  });
 
  it("should not login a user with wrong username", async () => {
    const response = await app
      .handle(new Request("http://localhost/auth/signin", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          username: "wrong-username",
          password: "password",
        })
      }))
    const body = await response.json();
 
    expect(response.status).toEqual(400);
    expect(body).toEqual({
      message: "User not found",
    });
  });
});

タスク管理機能のテスト

最後に、タスクの一覧・作成 API のテストを実装します。これらの API をコールする際には、Cookie に JWT トークンを含める必要があるので、beforeAll() フックでログイン API を呼び出して JWT トークンを取得します。

tests/e2e/tasks.test.ts
import { afterAll, beforeAll, describe, it, expect } from "bun:test";
import { app } from "../../src";
import { cleanUpDatabase, createUser } from "../../src/db/test-utils";
 
describe("tasks", () => {
  // ログイン API をコールした際に取得した Cookie を保持する変数
  let cookie: string;
 
  beforeAll(async () => {
    cleanUpDatabase();
    // あらかじめダミーのユーザーを作成しておく
    await createUser({
      username: "alice",
      password: "password",
    });
    // ダミーユーザーでログイン API をコールして JWT トークンを取得
    const response = await app
      .handle(new Request("http://localhost/auth/signin", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          username: "alice",
          password: "password",
        })
      }))
    // ヘッダーに含まれる Cookie を取得
    cookie = response.headers.get("set-cookie")!;
  })
 
  // クッキーがヘッダーに含まれていない場合には、
  // 401 Unauthorized が返却されることを確認
  it("should not create a task without a cookie", async () => {
    const response = await app
      .handle(new Request("http://localhost/tasks", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          name: "Buy milk",
          status: "in-progress",
        })
      }))
    const body = await response.json();
 
    expect(response.status).toEqual(401);
    expect(body).toEqual({
      message: "Unauthorized",
    });
  });
 
  // ボディリクエストのバリデーションのテスト
  it("should create a task", async () => {
    const response = await app
      .handle(new Request("http://localhost/tasks", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          // Cookie にログイン API で取得したクッキーを含める
          Cookie: cookie
        },
        body: JSON.stringify({
          name: "Buy milk",
          status: "in-progress",
        })
      }))
    const body = await response.json();
 
    expect(response.status).toEqual(200);
    expect(body).toEqual({
      task: {
        id: expect.any(String),
        name: "Buy milk",
        status: "in-progress",
      }
    });
  });
 
  it("should validate task dto", async () => {
    const response = await app
      .handle(new Request("http://localhost/tasks", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Cookie: cookie
        },
        body: JSON.stringify({
          name: "Buy milk",
          status: "invalid-status",
        })
      }))
    const body = await response.text();
 
    expect(response.status).toEqual(400);
    expect(body).toEqual("Invalid body, 'status': Expected value of union\n\nExpected: {\n  \"name\": \"\",\n  \"status\": \"done\"\n}\n\nFound: {\n  \"name\": \"Buy milk\",\n  \"status\": \"invalid-status\"\n}");
  });
 
  it("should not get tasks without a cookie", async () => {
    const response = await app
      .handle(new Request("http://localhost/tasks", {
        method: "GET",
        headers: {
          "Content-Type": "application/json",
        },
      }))
    const body = await response.json();
 
    expect(response.status).toEqual(401);
    expect(body).toEqual({
      message: "Unauthorized",
    });
  });
 
  it("should get tasks", async () => {
    const response = await app
      .handle(new Request("http://localhost/tasks", {
        method: "GET",
        headers: {
          "Content-Type": "application/json",
          Cookie: cookie
        },
      }))
    const body = await response.json();
 
    expect(response.status).toEqual(200);
    expect(body).toEqual({
      tasks: [
        {
          id: expect.any(String),
          name: "Buy milk",
          status: "in-progress"
        }
      ]
    });
  });
});

テストの実装が完了したら、bun run test コマンドを実行して、テストがパスすることを確認します。

$ bun test
bun test v1.0.1 (31aec4eb)
 
tests/e2e/auth.test.ts:
✓ auth > should create a user [84.16ms]
✓ auth > should not create a user with the same username [0.23ms]
 
tests/e2e/tasks.test.ts:
✓ tasks > should not create a task without a cookie [0.44ms]
✓ tasks > should create a task [1.52ms]
✓ tasks > should validate task dto [1.73ms]
✓ tasks > should not get tasks without a cookie [0.14ms]
✓ tasks > should get tasks [0.26ms]
 
 7 pass
 0 fail
 14 expect() calls
Ran 7 tests across 2 files. [279.00ms]

まとめ

お疲れ様でした。これで、ElysiaJS を使ってタスク管理アプリを作成するチュートリアルは終了です。このチュートリアルでは、ElysiaJS の基本的な使い方を学びました。

ElysiaJS のシンプルな API と型チェックによる快適な開発を体験できたかと思います。この記事で紹介したコードは以下のレポジトリで公開しています。ぜひ、ご参考にしてください。


Contributors

> GitHub で修正を提案する
この記事をシェアする
はてなブックマークに追加

関連記事