コモドドラゴンのイラスト

MoonBit で RESTful API サーバーを構築する

MoonBit は、WebAssembly や JavaScript にコンパイルできる新しいプログラミング言語です。この記事では、MoonBit を使用してシンプルな RESTful API サーバーを作成する方法を紹介します。

MoonBit は、WebAssembly や JavaScript にコンパイルできる新しいプログラミング言語です。Rust のような構文とパフォーマンスを持ちつつ、ガベージコレクションを採用し所有権やライフタイムといった概念がないという点が特徴です。

この記事では、MoonBit を使用してシンプルな RESTful API サーバーを構築する方法を紹介します。TODO アイテムを管理するための基本的な CRUD 操作を提供する API サーバーの作成を通じて、以下の内容をカバーします。

  • moonbitlang/async パッケージを使用して非同期処理による HTTP サーバー
  • パターンマッチングを活用したルーティング機能の実装
  • mattn/postgres パッケージを使用して PostgreSQL データベースと連携
  • 構造体とトレイトを活用してデータベース操作を抽象化

環境構築

まず、MoonBit のコンパイラをインストールします。公式のインストールガイドに従って、最新のバージョンをインストールしてください。

# macOS/Linux
curl -fsSL https://cli.moonbitlang.com/install/unix.sh | bash
# Windows
Set-ExecutionPolicy RemoteSigned -Scope CurrentUser; irm https://cli.moonbitlang.com/install/powershell.ps1 | iex

インストールが完了したら moon コマンドが使用できるようになります。ターミナルで以下のコマンドを実行して、インストールが成功したことを確認してください。

$ moon version
moon 0.1.20260119 (be99339 2026-01-19)
 
Feature flags enabled: rupes_recta
-> You're currently using the experimental build graph generator "Rupes Recta". If you encounter a problem, please verify whether it also reproduces with the legacy build (by setting NEW_MOON=0).

VSCode を使用している場合は、MoonBit 拡張機能をインストールするとよいでしょう。

https://marketplace.visualstudio.com/items?itemName=moonbit.moonbit-lang

MoonBit のプロジェクトを作成するには moon new コマンドを使用します。

moon new moonbit_rest_app

このコマンドにより、moonbit_rest_app というディレクトリが作成され、その中に基本的なプロジェクト構造が生成されます。

moonbit_rest_app
├── AGENTS.md
├── cmd
   └── main
       ├── main.mbt
       └── moon.pkg.json
├── LICENSE
├── moon.mod.json
├── moon.pkg.json
├── moonbit_rest_app_test.mbt
├── moonbit_rest_app.mbt
├── README.mbt.md
└── README.md -> README.mbt.md

RESTful API サーバーの作成

初めに、簡単な RESTful API サーバーを作成します。非同期でリクエストを処理するために moonbitlang/async パッケージを使用します。以下のコマンドで依存関係を追加します。

moon add moonbitlang/async

依存関係が追加されたら moon.mod.json ファイルの deps フィールドに "moonbitlang/async" が追加されます。

moon.mod.json
{
  "name": "username/moonbit_rest_app",
  "version": "0.1.0",
  "deps": {
    "moonbitlang/async": "0.16.4"
  },
  "readme": "README.mbt.md",
  "repository": "",
  "license": "Apache-2.0",
  "keywords": [],
  "description": ""
}

backend/main.mbt ファイルと backend/moon.pkg.json ファイルをそれぞれ作成し、最小限のサーバーを実装しましょう。main.mbt はアプリケーションのエントリーポイントとなるファイルです。moon.pkg.json はパッケージの設定ファイルであり、moon.pkg.json が存在することにより MoonBit のコンパイラはこのディレクトリをパッケージとして認識します。パッケージ名はディレクトリの名前が使用されます。

"is-main": true を指定するとこのパッケージが実行可能であることを示します。import フィールドで moonbitlang/async パッケージとそのサブパッケージをインポートするように指定します。

backend/moon.pkg.json
{
  "is-main": true,
  "import": [
    "moonbitlang/async",
    "moonbitlang/async/http",
    "moonbitlang/async/socket"
  ]
}

backend/main.mbt ファイルには以下のコードを記述します。

backend/main.mbt
///|
#cfg(target="native")
async fn server(listen_addr : @socket.Addr) -> Unit {
  @http.Server::new(listen_addr).run_forever((_request, _body, conn) => conn
    ..send_response(200, "OK")
    ..write("Hello, Moonbit!"))
}
 
///|
#cfg(target="native")
async fn main {
  let listen_addr = @socket.Addr::resolve("localhost", port=8080)
  println("Server running on http://localhost:8080")
  server(listen_addr)
}

#cfg(target="native") は、MoonBit の条件付きコンパイル属性です。この属性は、特定のコンパイルターゲット向けにのみコードをコンパイルするように指定します。#cfg(target="native") の場合、その直後の定義(関数や型など)はネイティブターゲットでコンパイルする場合にのみ含まれます。@socket@http パッケージがネイティブ環境のみ利用可能なため、これらのコードをネイティブターゲットに限定しています。

server 関数と main 関数は先頭に async キーワードが付与されており、非同期関数として定義されています。これにより、関数内で非同期操作を実行できるようになります。JavaScript や Rust と異なり非同期関数で他の非同期関数を呼び出す際に await キーワードは必要ありません。

@http.Server::run_forever(..) メソッドは、HTTP サーバーを起動し、リクエストを永続的に処理します。run_forever メソッドにはコールバック関数を渡し、リクエストが到着するたびにこの関数が呼び出されます。関数の引数として requestbody、および conn(接続オブジェクト)が渡されます。conn..send_response(...) メソッドで HTTP ステータスコードとステータスメッセージを送信し、conn..write(...) メソッドでレスポンスボディを書き込みます。. を 2 つ連続して使用するのは MoonBit のカスケード記法で、1 つのオブジェクトに対して複数のメソッドを連続して呼び出すことができます。

ここではすべてのリクエストに対して "Hello, Moonbit!" というレスポンスを返すようにしています。

main 関数では @socket.Addr::resolve(...) メソッドを使用してサーバーのアドレスを生成し、server 関数を呼び出してサーバーを起動します。

moon run コマンドを使用してサーバーを起動します。

$ moon run backend/main.mbt --target native

サーバーが起動したら curl コマンドでリクエストを送信してみましょう。

$ curl http://localhost:8080
Hello, Moonbit!

ルーティングを追加してパスごとに異なるレスポンスを返す

server 関数のコールバック関数内でリクエストのパスを確認し、パスごとに異なるレスポンスを返すようにします。リクエストのパスとメソッドはそれぞれ request.pathrequest.method プロパティで取得できます。パターンマッチングを使用してルーティングを実装します。

backend/main.mbt
///|
#cfg(target="native")
async fn server(listen_addr : @socket.Addr) -> Unit {
  @http.Server::new(listen_addr).run_forever((request, _body, conn) => match
    (request.meth, request.path) {
    // GET /
    (Get, "/") => conn..send_response(200, "OK")..write("Hello, Moonbit!")
    // GET /todos
    (Get, "/todos") => conn..send_response(200, "OK")..write("TODO List Route")
    // GET /todos/:id
    (Get, path) if path.has_prefix("/todos/") && path != "/todos/" => {
      let id = extract_path_param(path, "/todos/")
      conn..send_response(200, "OK")..write("Get Todo with ID: \{id}")
    }
    // 404 Not Found
    _ => conn..send_response(404, "Not Found")..write("404 - Route not found")
  })
}
 
///|
/// パスから指定されたプレフィックスの後の部分を抽出
fn extract_path_param(path : String, prefix : String) -> String raise {
  let prefix_len = prefix.length()
  path[prefix_len:].to_string()
}

MoonBit のパターンマッチング構文は match キーワードを使用します。ここではタプル (request.meth, request.path) を使用して HTTP メソッドとパスの組み合わせに基づいてルーティングを実装しています。request.method は HTTP メソッドを表す Enum 型であり、GetPost などのバリアントが用意されています。request.path はリクエストのパスを表す文字列で完全一致する文字列の場合にマッチします。

動的なパスパラメータ(例: /todos/:id)を処理するために、if ガードを使用して、パスが特定のプレフィックスで始まるかどうかを確認しています。if ガードでは (Get, path) if ... のように Get リクエストとすべてのパスをキャプチャする path 変数にマッチさせた上で条件を指定します。

extract_path_param 関数は、指定されたプレフィックスの後の部分を抽出するユーティリティ関数です。ここでは単純に文字列のスライスを使用してパスパラメータを取得しています。path[prefix_len:] のようにスライスを使用して文字列の一部を取得する場合エラーを発生させる可能性があるため、raise キーワードを使用してこの関数がエラーをスローする可能性があることを示す必要があります。

再度サーバーを起動し、異なるパスにリクエストを送信してみましょう。

$ moon run backend/main.mbt --target native
Server running on http://localhost:8080
$ curl http://localhost:8080/
Hello, Moonbit!
 
$ curl http://localhost:8080/todos
TODO List Route
 
$ curl http://localhost:8080/todos/42
Get Todo with ID: 42
 
$ curl http://localhost:8080/unknown
404 - Route not found

PostgreSQL データベースと連携する

API サーバーで作成した TODO アイテムを永続化するために、PostgreSQL データベースを使用します。まずは docker-compose.yml ファイルを作成し、PostgreSQL コンテナを起動します。

docker-compose.yml
version: '3.8'
services:
  db:
    image: postgres:15
    environment:
      POSTGRES_USER: moonbit
      POSTGRES_PASSWORD: moonbitpassword
      POSTGRES_DB: moonbit_db
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
 
volumes:
  pgdata:

docker-compose up -d コマンドでコンテナを起動します。

docker-compose up -d

MoonBit の PostgreSQL クライアントライブラリである mattn/postgres を使用します。moon add コマンドで依存関係を追加します。

moon add mattn/postgres

mattn/postgres パッケージを使用するためには PostgreSQL のネイティブライブラリが必要です。macOS を使用している場合は Homebrew で以下のコマンドを実行してインストールします。

brew install postgresql

バックエンドのコードを追加する前に、TODO アイテムを保存するテーブルを作成する Seed スクリプトを seed/seed.mbt ファイルに記述します。seed/moon.pkg.json ファイルも作成し、mattn/postgres パッケージをインポートするように指定します。また link.native フィールドでネイティブライブラリのリンクを有効にします。

seed/moon.pkg.json
{
  "is-main": true,
  "import": [
    {
      "path": "mattn/postgres"
    }
  ],
  "link": {
    "native": {
      "cc-link-flags": "-L/opt/homebrew/opt/postgresql@13/lib"
    }
  }
}

homebrew を使用して PostgreSQL をインストールした場合、mattn/postgres パッケージの seed/moon.pkg.json を直接編集して stub-cc-flagsstub-cc-link-flags を変更する必要がありました。もしかしたら別の方法で解決できるかもしれません。.mooncakes ディレクトリに依存パッケージがインストールされているので、そのファイルを直接編集しました。

.mooncakes/mattn/postgres/moon.pkg.json
{
  ...
  "stub-cc-flags": "-I/opt/homebrew/opt/postgresql@13/include",
  "stub-cc-link-flags": "-L/opt/homebrew/opt/postgresql@13/lib -lpq",
  ...
}

seed/main.mbt ファイルには以下のコードを記述します。

seed/main.mbt
///|
#cfg(target="native")
fn main {
  // PostgreSQL データベースに接続
  match
    @postgres.connect(
      "postgresql://moonbit:moonbitpassword@localhost/moonbit_db",
    ) {
    Ok(conn) => {
      // defer は関数の終了時に実行される
      defer conn.close()
      println("Connected to PostgreSQL database.")
 
      // todos テーブルを作成
      let create_table_query = "CREATE TABLE IF NOT EXISTS todos (id SERIAL PRIMARY KEY, title TEXT NOT NULL, completed BOOLEAN NOT NULL DEFAULT FALSE);"
      match conn.execute(create_table_query, ([] : Array[@postgres.Value])) {
        Ok(result) => result.free()
        Err(e) => {
          println("Error creating table: " + e.to_string())
          return
        }
      }
 
      // 初期データを挿入
      let insert_query = "INSERT INTO todos (title, completed) VALUES ('Sample Todo', FALSE);"
      match conn.execute(insert_query, ([] : Array[@postgres.Value])) {
        Ok(result) => result.free()
        Err(e) => {
          println("Error inserting data: " + e.to_string())
          return
        }
      }
 
      // データを取得して表示
      let select_query = "SELECT id, title, completed FROM todos;"
      match conn.query(select_query) {
        Ok(result) => {
          defer result.free()
          println("Query result:")
          for row in result.rows() {
            println("  " + row[0])
            println("  " + row[1])
            println("  " + row[2].to_string())
          }
        }
        Err(e) => println("Query error: " + e.to_string())
      }
    }
    Err(e) => println("Connection error: " + e.to_string())
  }
}

@postgres.connect(...) メソッドでデータベースに接続します。Result 型を返すため、match 式で成功時と失敗時の処理を分岐しています。接続に成功した場合は conn オブジェクトを使用して SQL クエリを実行します。conn.execute(...) メソッドでテーブルの作成やデータを挿入し、conn.query(...) メソッドでデータを取得します。

moon run コマンドで Seed スクリプトを実行します。

$ moon run seed/main.mbt --target native
 
Connected to PostgreSQL database.
Query result:
  1
  Sample Todo
  f

TodoTable 構造体を使ってデータベース接続を管理する

バックエンドのコードに戻りましょう。データベース接続を管理するために TodoTable 構造体を定義します。この構造体はデータベース接続オブジェクトをフィールドとして持ち、TODO アイテムの CRUD 操作をメソッドとして提供します。backend/table パッケージを新たに作成して、そこにテーブル操作関連のコードをまとめます。

まずは backend/table/moon.pkg.json ファイルを作成し、mattn/postgres, moonbitlang/core/json, moonbitlang/core/strconv パッケージをインポートするように指定します。また link.native フィールドでネイティブライブラリのリンクを有効にします。

backend/table/moon.pkg.json
{
  "import": [
    "moonbitlang/core/json",
    {
      "path": "mattn/postgres"
    },
    "moonbitlang/core/strconv"
  ],
  "link": {
    "native": {
      "cc-link-flags": "-L/opt/homebrew/opt/postgresql@13/lib"
    }
  }
}

backend/table/todo_table.mbt ファイルを作成し、以下のコードを記述します。まずは Todo 構造体と TodoTable 構造体を定義します。構造体の定義は struct キーワードを使用します。

backend/table/todo_table.mbt
///|
/// Todo データ構造
pub struct Todo {
  id : Int
  title : String
  completed : Bool
} derive(FromJson, ToJson)
 
///|
/// Todo のコンストラクタ
pub fn Todo::new(id : Int, title : String, completed : Bool) -> Todo {
  { id, title, completed }
}
 
///|
/// TodoTable - PostgreSQL での Todo テーブルの CRUD 操作
pub struct TodoTable {
  conn : @postgres.Connection
}

Todo 構造体は TODO アイテムのデータ構造を表し、FromJsonToJson トレイトを実装しています。それぞれのトレイトを実装することにより構造体は .to_json メソッドと from_json メソッドを持つようになり、JSON と構造体の相互変換が可能になります。

トレイトとは、構造体に実装すべきメソッドのセットを定義するインターフェイスのようなものです。FromJson トレイトの場合は from_json メソッドを実装する必要があると定義されています。

///|
/// Trait for types that can be converted from `Json`
pub(open) trait FromJson {
  from_json(Json, JsonPath) -> Self raise JsonDecodeError
}

通常トレイトは impl Trait for Struct 構文でインターフェイスを満たすメソッドを実装しますが、特定のトレイトは derive(...) 属性を使用することで自動的に実装できます。

構造体を初期化するメソッドに new メソッドとして定義されます。new メソッドは自身の構造体を返す静的メソッドとして実装します。new メソッドには pub 修飾子を付与して公開メソッドとして定義します。

TodoTable 構造体はデータベース接続オブジェクトをフィールドとして持つ構造体です。この構造体に CRUD 操作をメソッドとして実装します。構造体にメソッドを実装するには fn Struct::method_name 構文を使用します。初めに TodoTable::new メソッドを実装します。メソッドの引数に self: TodoTable を指定しない場合は静的メソッドとして定義されます。

backend/table/todo_table.mbt
///|
/// TodoTable の初期化
pub fn TodoTable::new() -> Result[TodoTable, String] {
  match
    @postgres.connect(
      "postgresql://moonbit:moonbitpassword@localhost/moonbit_db",
    ) {
    Ok(conn) => Ok({ conn, })
    Err(e) => Err("Failed to connect to database: " + e.to_string())
  }
}

TodoTable::new メソッドではデータベースに接続し、接続に成功した場合は TodoTable 構造体のインスタンスを返します。失敗した場合はエラーメッセージを返します。続いて TodoTable::close メソッドも実装しておきましょう。このメソッドは内部のパッケージのみで使用されるため pub 修飾子は不要です。

1 つめの引数(レシーバー)に self: TodoTable を指定することでインスタンスメソッドとして定義されます。インスタンスメソッドでは self 引数を通じて構造体のフィールドにアクセスできます。レシーバーは慣例的に self と命名されます。

backend/table/todo_table.mbt
///|
/// データベース接続を閉じる
/// pub をつけないのでパッケージ内でのみ使用可能
fn TodoTable::close(self : TodoTable) -> Unit {
  self.conn.close()
}

TodoTable::create, TodoTable::get_by_id, TodoTable::get_all, TodoTable::update, TodoTable::delete メソッドもまとめてインスタンスメソッドとして実装します。

backend/table/todo_table.mbt
///|
/// Create: 新しい Todo を作成
pub fn TodoTable::create(
  self : TodoTable,
  title : String,
  completed : Bool,
) -> Result[Int, String] {
  defer self.close()
  match
    self.conn.execute(
      "INSERT INTO todos (title, completed) VALUES ($1, $2) RETURNING id",
      [title, completed.to_string()],
    ) {
    Ok(result) => {
      defer result.free()
      let rows = result.rows()
      if rows.length() > 0 {
        let id = @strconv.parse_int(rows[0][0]) catch { _ => 0 }
        Ok(id)
      } else {
        Err("No rows returned from INSERT")
      }
    }
    Err(e) => Err("Failed to create todo: " + e.to_string())
  }
}
 
///|
/// Read: ID で Todo を取得
pub fn TodoTable::get_by_id(self : TodoTable, id : Int) -> Result[Todo, String] {
  defer self.close()
  match
    self.conn.execute(
      "SELECT id, title, completed FROM todos WHERE id = $1::int",
      [id],
    ) {
    Ok(result) => {
      defer result.free()
      let rows = result.rows()
      if rows.length() > 0 {
        let row = rows[0]
        let todo_id = @strconv.parse_int(row[0]) catch { _ => 0 }
        let title = row[1]
        let completed = row[2] == "t" || row[2] == "true"
        Ok({ id: todo_id, title, completed })
      } else {
        Err("Todo not found with ID: " + id.to_string())
      }
    }
    Err(e) => Err("Failed to get todo: " + e.to_string())
  }
}
 
///|
/// Read: すべての Todo を取得
pub fn TodoTable::get_all(self : TodoTable) -> Result[Array[Todo], String] {
  defer self.close()
  let query = "SELECT id, title, completed FROM todos ORDER BY id;"
  match self.conn.query(query) {
    Ok(result) => {
      defer result.free()
      let todos : Array[Todo] = []
      for row in result.rows() {
        let id = @strconv.parse_int(row[0]) catch { _ => 0 }
        let title = row[1]
        let completed = row[2] == "t" || row[2] == "true"
        todos.push({ id, title, completed })
      }
      Ok(todos)
    }
    Err(e) => Err("Failed to get all todos: " + e.to_string())
  }
}
 
///|
/// Update: Todo を更新
pub fn TodoTable::update(
  self : TodoTable,
  id : Int,
  title : String,
  completed : Bool,
) -> Result[Unit, String] {
  defer self.close()
  match
    self.conn.execute(
      "UPDATE todos SET title = $1, completed = $2 WHERE id = $3",
      [title, completed, id],
    ) {
    Ok(result) => {
      result.free()
      Ok(())
    }
    Err(e) => Err("Failed to update todo: " + e.to_string())
  }
}
 
///|
/// Delete: ID で Todo を削除
pub fn TodoTable::delete(self : TodoTable, id : Int) -> Result[Unit, String] {
  defer self.close()
  match self.conn.execute("DELETE FROM todos WHERE id = $1", [id]) {
    Ok(result) => {
      result.free()
      Ok(())
    }
    Err(e) => Err("Failed to delete todo: " + e.to_string())
  }
}

conn.execute(...) メソッドで SQL クエリを実行し、結果を処理しています。パラメタライズドクエリを使用するために、SQL クエリ内で $1, $2 のようにプレースホルダを指定し、引数として値の配列を渡しています。defer キーワードを使用して、関数の終了時に接続を閉じるようにしています。

self.conn.execute(
  "INSERT INTO todos (title, completed) VALUES ($1, $2) RETURNING id",
  [title, completed],
)

Warning

コード例では conn.execute(...) メソッドを使用していますが、v0.10.4 では実行時に動作しない問題がありました。代わりに conn.query(...) メソッドを使用して文字列結合でパラメータを埋め込むことができますが、SQL インジェクションのリスクがあるため本番環境では決して使用しないでください。

@strconv.parse_int メソッドを使用して文字列を整数に変換しています。@strconv.parse_int メソッドはエラーを raise する可能性があります。raise される可能性がある関数に対してエラーハンドリングを行わなければコンパイルエラーとなります。ここではエラーが発生した場合にデフォルト値 0 を返すようにしています。

let id = @strconv.parse_int(rows[0][0]) catch { _ => 0 }

TODO API エンドポイントの実装

backend/main.mbt ファイルに戻り、TODO API エンドポイントを実装します。TodoTable 構造体をインポートし、各エンドポイントで対応するメソッドを呼び出します。backend/moon.pkg.json ファイルにも backend/table パッケージをインポートするように指定します。

backend/moon.pkg.json
{
  "is-main": true,
  "import": [
    "moonbitlang/async",
    "moonbitlang/async/http",
    "moonbitlang/async/socket",
    "moonbitlang/core/json",
    "moonbitlang/core/strconv",
    "username/moonbit_rest_app/backend/table"
  ]
}

/todos エンドポイントに対する GET リクエストで TodoTable::get_all メソッドを呼び出し、すべての TODO アイテムを取得して JSON 形式でレスポンスを返すようにします。

backend/main.mbt
///|
#cfg(target="native")
async fn server(listen_addr : @socket.Addr) -> Unit {
  @http.Server::new(listen_addr).run_forever((request, _body, conn) => match
    (request.meth, request.path) {
          // GET /todos - すべての Todo を取得
    (Get, "/todos") =>
      match @table.TodoTable::new() {
        Ok(table) =>
          match table.get_all() {
            Ok(todos) =>
              conn
              ..send_response(200, "OK", extra_headers={
                "Content-Type": "application/json",
              })
              ..write(todos.to_json())
            Err(e) =>
              conn
              ..send_response(500, "Internal Server Error")
              ..write("Error: " + e)
          }
        Err(e) =>
          conn
          ..send_response(500, "Internal Server Error")
          ..write("Database connection error: " + e)
      }
    // その他のルーティング...
    _ => conn..send_response(404, "Not Found")..write("404 - Route not found")
  })
}

Todo[] 配列を JSON 形式に変換するために to_json() メソッドを使用しています。to_json() メソッドは ToJson トレイトを実装している型に対して使用できます。send_response メソッドの extra_headers 引数でレスポンスヘッダーに Content-Type: application/json を追加して、クライアントに JSON 形式のレスポンスであることを通知しています。

サーバーを再起動し、curl コマンドで /todos エンドポイントにリクエストを送信してみましょう。

$ moon run backend/main.mbt --target native
Server running on http://localhost:8080
$ curl http://localhost:8080/todos
[{"id":1,"title":"Sample Todo","completed":false}]

GET /todos/:id エンドポイントも同様に実装します。@strconv.parse_int メソッドを使用してパスパラメータを整数に変換し、TodoTable::get_by_id メソッドを呼び出して指定された ID の TODO アイテムを取得します。

backend/main.mbt
///|
#cfg(target="native")
async fn server(listen_addr : @socket.Addr) -> Unit {
  @http.Server::new(listen_addr).run_forever((request, _body, conn) => match
    (request.meth, request.path) {
    // ...省略
 
    // GET /todos/:id - ID で Todo を取得
    (Get, path) if path.has_prefix("/todos/") && path != "/todos/" => {
      let id_str = extract_path_param(path, "/todos/")
      try @strconv.parse_int(id_str) catch {
        _ =>
          conn
          ..send_response(400, "Bad Request")
          ..write("Invalid ID: " + id_str)
      // noraise はエラーが発生しなかったときにのみ実行されるブロック
      } noraise {
        id =>
          match @table.TodoTable::new() {
            Ok(table) =>
              match table.get_by_id(id) {
                Ok(todo) =>
                  conn
                  ..send_response(200, "OK", extra_headers={
                    "Content-Type": "application/json",
                  })
                  ..write(todo.to_json())
                Err(e) =>
                  conn..send_response(404, "Not Found")..write("Error: " + e)
              }
            Err(e) =>
              conn
              ..send_response(500, "Internal Server Error")
              ..write("Database connection error: " + e)
          }
      }
    }
  })
}

/todos/1 エンドポイントにリクエストを送信してみましょう。

$ curl http://localhost:8080/todos/1
{"id":1,"title":"Sample Todo","completed":false}

新しい TODO アイテムを作成する POST /todos エンドポイントも実装します。リクエストボディから JSON データを取得する必要があるので、JSON をパースしてフィールドを抽出する parse_create_todo_body 関数も実装します。

backend/main.mbt
fn parse_create_todo_body(body : String) -> Result[(String, Bool), String] {
  // @json.parse を使ってJSONをパース
  let json = @json.parse(body) catch {
    e => return Err("Failed to parse JSON: " + e.to_string())
  }
 
  // オブジェクトナビゲーションでフィールドを抽出
  let title = if json is { "title": String(str), .. } { str } else { "" }
  let completed = if json is { "completed": True, .. } { true } else { false }
  Ok((title, completed))
}

moonlang/core/json パッケージの @json.parse(...) メソッドを使用して JSON 文字列をパースし Json 型として取得します。Json 型に対してパターンマッチングを使用してフィールドを抽出します。is キーワードを使用して特定のパターンにマッチさせることができます。例えば if json is { "title": String(str), .. } のように記述すると、json オブジェクトが title フィールドを持ち、その値が文字列である場合にマッチし、str 変数に title フィールドの値がバインドされます。.. はワイルドカードパターンで、他のフィールドは無視することを示します。

POST /todos エンドポイントを実装します。.run_forever(...) メソッドのコールバック関数の第 2 引数 body はリクエストボディを表す文字列です。body.read_all() メソッドを使用してリクエストボディの内容を io.Data 型として取得し、.text() メソッドで文字列に変換します。

レスポンスは Todo 構造体を作成してから to_json() メソッドを使用して JSON 形式に変換して返しています。

backend/main.mbt
///|
#cfg(target="native")
async fn server(listen_addr : @socket.Addr) -> Unit {
  @http.Server::new(listen_addr).run_forever((request, body, conn) => match
    (request.meth, request.path) {
    // ...省略
    // POST /todos - 新しい Todo を作成
    (Post, "/todos") => {
      let body_str = body.read_all().text()
      match parse_create_todo_body(body_str) {
        Ok((title, completed)) =>
          match @table.TodoTable::new() {
            Ok(table) =>
              match table.create(title, completed) {
                Ok(id) => {
                  let todo = @table.Todo::new(id, title, completed)
                  conn
                  ..send_response(201, "Created", extra_headers={
                    "Content-Type": "application/json",
                  })
                  ..write(todo.to_json())
                }
                Err(e) =>
                  conn
                  ..send_response(500, "Internal Server Error")
                  ..write("Error: " + e)
              }
            Err(e) =>
              conn
              ..send_response(500, "Internal Server Error")
              ..write("Database connection error: " + e)
          }
        Err(parse_error) =>
          conn
          ..send_response(400, "Bad Request")
          ..write("Invalid request body: " + parse_error)
      }
    }
    // その他のルーティング...
    _ => conn..send_response(404, "Not Found")..write("404 - Route not found")
  })
}

curl コマンドで POST /todos エンドポイントにリクエストを送信してみましょう。

$ curl -X POST http://localhost:8080/todos \
    -H "Content-Type: application/json" \
    -d '{"title": "New Todo Item", "completed": true}'
{"id":2,"title":"New Todo Item","completed":true}

最後に、DELETE /todos/:id エンドポイントを実装します。リクエストパスから ID を抽出し、TodoTable::delete メソッドを呼び出して指定された ID の TODO アイテムを削除します。

backend/main.mbt
///|
#cfg(target="native")
async fn server(listen_addr : @socket.Addr) -> Unit {
  @http.Server::new(listen_addr).run_forever((request, _body, conn) => match
    (request.meth, request.path) {
    // ...省略
    // DELETE /todos/:id - Todo を削除
    (Delete, path) if path.has_prefix("/todos/") && path != "/todos/" => {
      let id_str = extract_path_param(path, "/todos/")
      try @strconv.parse_int(id_str) catch {
        _ =>
          conn
          ..send_response(400, "Bad Request")
          ..write("Invalid ID: " + id_str)
      } noraise {
        id =>
          match @table.TodoTable::new() {
            Ok(table) =>
              match table.delete(id) {
                Ok(_) =>
                  conn
                  ..send_response(200, "OK", extra_headers={
                    "Content-Type": "application/json",
                  })
                  ..write({"message":"Todo with ID \{id} deleted successfully."}.to_json())
                Err(e) =>
                  conn
                  ..send_response(500, "Internal Server Error")
                  ..write("Error: " + e)
              }
            Err(e) =>
              conn
              ..send_response(500, "Internal Server Error")
              ..write("Database connection error: " + e)
          }
      }
    }
    // 404 Not Found
    _ => conn..send_response(404, "Not Found")..write("404 - Route not found")
  })
}

curl コマンドで DELETE /todos/:id エンドポイントにリクエストを送信してみましょう。

$ curl -X DELETE http://localhost:8080/todos/2
{"message":"Todo with ID 2 deleted successfully."}
 
$ curl http://localhost:8080/todos
[{"id":1,"title":"Sample Todo","completed":false}]

まとめ

  • moonbitlang/async パッケージを使用して非同期処理による HTTP サーバーを実装した。@http.Server::new(...).run_forever(...) メソッドでサーバーを起動し、非同期コールバック関数でリクエストを処理する
  • match キーワードを使用して HTTP メソッドとパスの組み合わせに基づいてルーティングを実装した。if ガードを使用して動的なパスパラメータを処理できる
  • mattn/postgres パッケージを使用して PostgreSQL データベースと連携した。@postgres.connect(...) メソッドでデータベースに接続し、conn.query(...) メソッドや conn.execute(...) メソッドで SQL クエリを実行する
  • 構造体とトレイトを活用してデータベース操作を抽象化した。fn Struct::method_name 構文で構造体にメソッドを実装する。引数の最初に self: Struct を指定することでインスタンスメソッドとして定義できる
  • moonbitlang/core/json パッケージを使用して JSON データのパースと生成を行った。@json.parse(...) メソッドで JSON 文字列をパースし、to_json() メソッドで構造体や配列を JSON 形式に変換する。構造体に FromJsonToJson トレイトを実装することで JSON との相互変換が容易になる

参考

記事の理解度チェック

以下の問題に答えて、記事の理解を深めましょう。

MoonBit の非同期関数を定義するキーワードは何ですか?

  • await

    もう一度考えてみましょう

    `await` は非同期関数内で他の非同期関数を呼び出す際に使用しますが、関数の定義には使用しません。

  • defer

    もう一度考えてみましょう

    `defer` は関数の終了時に実行されるコードブロックを指定するためのキーワードであり、非同期関数の定義には使用しません。

  • async

    正解!

    `async` キーワードを使用して非同期関数を定義します。

  • キーワードは不要

    もう一度考えてみましょう

MoonBit の `obj..method` の用途として正しいものはどれですか?

  • 1 つのオブジェクトに対して複数のメソッドを連続して呼び出す

    正解!

    `.` を 2 つ連続して使用するカスケード記法により、1 つのオブジェクトに対して複数のメソッドを連続して呼び出すことができます。

  • メソッドの呼び出しを改行できる

    もう一度考えてみましょう

    改行は関係なく、カスケード記法は複数のメソッドを連続して呼び出すための構文です。

  • エラーを無視してメソッドを呼び出す

    もう一度考えてみましょう

  • 非同期メソッドを呼び出す

    もう一度考えてみましょう

`#cfg(target="native")` 属性の役割は何ですか?

  • ネイティブターゲットでコンパイルする場合にのみコードを含める

    正解!

    `#cfg(target="native")` は条件付きコンパイル属性で、ネイティブターゲット向けにのみコードをコンパイルします。`@socket` や `@http` パッケージがネイティブ環境のみ利用可能なため使用されています。

  • コードの実行速度を最適化する

    もう一度考えてみましょう

    最適化のための属性ではなく、条件付きコンパイルのための属性です。

  • デバッグ情報を追加する

    もう一度考えてみましょう

    デバッグ情報の追加ではなく、特定のターゲット向けにコードをコンパイルするための属性です。

  • ネイティブライブラリをリンクする

    もう一度考えてみましょう

    ライブラリのリンクは `link` フィールドで設定します。`#cfg` は条件付きコンパイルのための属性です。