最初に断っておきますが、私はRustの初心者です。久しぶりのRustに四苦八苦しながら、この記事中に出てくるプログラムを書きました。

私はPythonでWebアプリケーションのAPIを作っているときに、デコレータをあまり使いたくないと考えていました。RustのマクロとPythonのデコレータは別物ですが、同じく使わずに済むのであれば使わずに済ましたいと考えていました。RustのaxumというWebアプリケーションフレームワークが「Route requests to handlers with a macro free API.」と謳っていることを知ったので、これを使って何か作ってみることにしました。

お題はDropboxのAPIを真似た、ディレクトリのファイルの一覧、ファイルのアップロードとダウンロードを実装することとしました。

この記事中で実装しているAPIはファイルへのアクセス制限がなく、外部に公開するのはとても危険です。この記事を参考に実運用するプログラムに組み込む人は、この点に十分に注意して自身で安全のための変更を加えてください。

プロジェクト作成

まずはこの記事中で扱うRustのプロジェクトを作成します。Rustを使ってRustのプロジェクトを作るため、私はPodmanを使用します。Dockerを使ったり、Cargoを直接使う人は読み替えてください。

$ mkdir axum-file-api
$ cd axum-file-api
$ podman run --userns=keep-id --rm --volume="${PWD}:/srv/rust-file-api" --workdir="/srv/rust-file-api" rust:slim-buster cargo init --bin
$ podman run --userns=keep-id --rm --volume="${PWD}:/srv/rust-file-api" --workdir="/srv/rust-file-api" rust:slim-buster cargo run
   Compiling rust-file-api v0.1.0 (/srv/rust-file-api)
    Finished dev [unoptimized + debuginfo] target(s) in 1.09s
     Running `target/debug/rust-file-api`
Hello, world!

Rustのプロジェクトを作成とその実行ができました。

この時点でプロジェクトのCargo.tomlとsrc/main.rsはそれぞれ以下の内容になっているはずです。

Cargo.toml

[package]
name = "rust-file-api"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]

src/main.rs

fn main() {
    println!("Hello, world!");
}

rustcとcargoのバージョンも確認しておきます。

$ podman run --userns=keep-id --rm rust:slim-buster rustc --version
rustc 1.58.1 (db9d1b20b 2022-01-20)
$ podman run --userns=keep-id --rm rust:slim-buster cargo --version
cargo 1.58.0 (f01b232bc 2022-01-19)

Hello axum

Cargo.tomlのdependenciesにaxum関連のライブラリを記述します。CargoはPythonのPoetryやNode.jsのnpmのようにコマンドで依存ライブラリを管理できないので、Cargo.tomlを自分で編集します。

公式ドキュメントによると各ライブラリは最新版を指定すれば良いようです。

[dependencies]
axum = "<latest-version>"
hyper = { version = "<latest-version>", features = ["full"] }
tokio = { version = "<latest-version>", features = ["full"] }
tower = "<latest-version>"

<latest-version>と書いても最新版がインストールされるわけではないので、crates.ioで各ライブラリを検索して、最新のバージョン番号を入手します。新しいマイナーバージョンを受け入れるため、バージョン番号の指定をマイナーバージョンまでの指定にしています。

[dependencies]
axum = "0.4"
tokio = { version = "1.16", features = ["fs", "macros", "rt-multi-thread"] }

私がこの記事を書いた時点ではこうなりました。hyperとtowerについては、この記事中では扱わないので、依存ライブラリとしません。featuresは最低限にしました。

依存ライブラリを宣言したので、今後は未インストールの依存ライブラリがあるときにcargo runをすると、依存ライブラリのインストールが始まります。これまでのように、都度コンテナを起動していてはcargo runのたびに依存ライブラリのインストールが始まってしまいます。ここからはコンテナを起動したままにします。

$ podman run --userns=keep-id --net=host --rm --interactive --tty --volume="${PWD}:/srv/rust-file-api" --workdir="/srv/rust-file-api" rust:slim-buster bash

これでコンテナ内のbashが起動しました。

次にaxumを使ったHello Worldと返すだけのAPIを定義します。src/main.rsの内容を全て消して、以下の内容にします。

use axum::{routing, Router};

async fn hello_world() -> String {
    "Hello World\n".to_string()
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/", routing::get(hello_world));

    axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

コンテナの中で実行します。

$ cargo run
...
ライブラリのインストールやコンパイル
...
Running `target/debug/rust-file-api`

起動したら、別のターミナルからcurlでアクセスします。

$ curl localhost:3000
Hello World

先程書いたプログラムが動作して、Hello Worldと返ってくることが確認できました。

ファイルの一覧

Dropboxのfiles/list_folderの簡略版を作ります。このAPIはJSONで渡されたパスのファイルの一覧をJSONで返します。APIが受け取るのはpathのみ、APIが返すのはnameのみとします。エラーの場合も大幅に簡略化し、存在しないディレクトリのファイルの一覧が要求されたとき、bodyが空のHTTP Status Code 4040 Not Foundを返すこととします。

ファイル一覧のAPIは、JSONを受け取りJSONを返します。このJSONのためのstructを定義するのですが、まずはstructとJSONの変換をしてくれるライブラリ、serdeをインストールします。Cargo.tomlの[dependencies]に追記します。

serde = { version = "1.0", features = ["derive"] }

structとJSONの変換ができるようになったので、src/main.rsにstructを定義します。

use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize)]
struct ListFolder {
    path: String,
}

#[derive(Debug, Serialize)]
struct Metadata {
    name: String,
}

#[derive(Debug, Serialize)]
struct MetadataEntriesResponse {
    entries: Vec<Metadata>,
}

ListFolderがAPIが受け取るJSONのためのstructです。MetadataEntriesResponseとMetadataが返すためのものです。

今定義したstructを使って、ファイルの一覧の要求に答える関数を同じくsrc/main.rsにstructを定義します。

use std::fs;
use std::io;
use std::path::Path;

use axum::http::StatusCode;
use axum::{response::IntoResponse, Json};

fn read_dir<P: AsRef<Path>>(path: P) -> io::Result<Vec<String>> {
    let file_names = fs::read_dir(&path)?
        .filter_map(|entry| -> Option<String> {
            let entry = entry.ok()?;
            if entry.file_type().ok()?.is_file() {
                Some(entry.file_name().to_string_lossy().into_owned())
            } else {
                None
            }
        })
        .collect();
    Ok(file_names)
}

async fn list_folder(Json(input): Json<ListFolder>) -> impl IntoResponse {
    if let Ok(file_names) = read_dir(&input.path) {
        let entries = file_names
            .into_iter()
            .map(|name| Metadata { name })
            .collect::<Vec<Metadata>>();
        Ok(Json(MetadataEntriesResponse { entries }))
    } else {
        Err(StatusCode::NOT_FOUND)
    }
}

ルーティングを変更して、/files/list_folderへのPOSTでlist_folder関数が呼ばれるようにします。

-    let app = Router::new().route("/", routing::get(hello_world));
+    let app = Router::new().route("/files/list_folder", routing::post(list_folder));

hello_world関数は使わなくなったので削除します。

/files/list_folderへのPOSTの動作を確認するために、コンテナの中で実行します。

$ cargo run

Hello Worldのときからcargo runを止めていなければ、そのcargo runを止めてからcargo runします。

ホストからcurlでアクセスします。

$ curl -X POST localhost:3000/files/list_folder --header "Content-Type: application/json" --data '{"path": "."}'
{"entries":[{"name":".bash_history"},{"name":".gitignore"},{"name":"Cargo.toml"},{"name":"Cargo.lock"}]}

想定通り、cargo runしたディレクトリにあるファイルの一覧が返ってきました。.bash_historyが含まれているのは、私がpodman runのときに--workdirにこのプロジェクトのディレクトリを指定しているからです。私と違うやり方をしている場合は存在しない場合があります。

ここまででファイルの一覧APIが完成しました。

現時点のCargo.tomlです。

[package]
name = "rust-file-api"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
axum = "0.4"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.16", features = ["fs", "macros", "rt-multi-thread"] }

現時点のsrc/main.rsです。

use std::fs;
use std::io;
use std::path::Path;

use axum::http::StatusCode;
use axum::{response::IntoResponse, routing, Json, Router};
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize)]
struct ListFolder {
    path: String,
}

#[derive(Debug, Serialize)]
struct Metadata {
    name: String,
}

#[derive(Debug, Serialize)]
struct MetadataEntriesResponse {
    entries: Vec<Metadata>,
}

fn read_dir<P: AsRef<Path>>(path: P) -> io::Result<Vec<String>> {
    let file_names = fs::read_dir(&path)?
        .filter_map(|dir_entry| -> Option<String> {
            let entry = dir_entry.ok()?;
            if entry.file_type().ok()?.is_file() {
                Some(entry.file_name().to_string_lossy().into_owned())
            } else {
                None
            }
        })
        .collect();
    Ok(file_names)
}

async fn list_folder(Json(input): Json<ListFolder>) -> impl IntoResponse {
    if let Ok(file_names) = read_dir(&input.path) {
        let entries = file_names
            .into_iter()
            .map(|name| Metadata { name })
            .collect::<Vec<Metadata>>();
        Ok(Json(MetadataEntriesResponse { entries }))
    } else {
        Err(StatusCode::NOT_FOUND)
    }
}

#[tokio::main]
async fn main() {
    let app = Router::new().route("/files/list_folder", routing::post(list_folder));

    axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

ファイルのダウンロード

Dropboxのfiles/downloadの簡略版を作ります。

JSONでダウンロードするファイルのパスを受け取り、そのパスのファイルをレスポンスボディで返します。指定されたパスのファイルが存在しない場合、レスポンスのステータスコードを404 Not Foundとします。繰り返しになりますが、アクセスを制限する機能を実装していません。このまま外部公開しないでください。

ファイル一覧API同様、structから定義します。今回はレスポンスにJSONを使わないので、定義するのは受け取るリクエスト用のものだけです。

#[derive(Debug, Deserialize)]
struct Download {
    path: String,
}

ファイルをレスポンスとするためにtokio-utilを使うので、これをCargo.tomlのdependenciesに加えます。

tokio-util = { version = "0.7", features=["io"] }

src/main.rsに追加する関数です。

use axum::body::StreamBody;
use axum::http::{header, StatusCode};
use axum::response::{Headers, IntoResponse};
use tokio_util::io::ReaderStream;

async fn download(Json(input): Json<Download>) -> impl IntoResponse {
    let stream_body = match tokio::fs::File::open(&input.path).await {
        Ok(file) => StreamBody::new(ReaderStream::new(file)),
        _ => return Err(StatusCode::NOT_FOUND),
    };
    let file_name = match input.path.rsplit_once("/") {
        Some((_, file_name)) => file_name,
        _ => &input.path,
    };
    let content_disposition = format!("attachment; filename=\"{}\"", file_name);

    let headers = Headers([
        (header::CONTENT_TYPE, "application/octet-stream".to_string()),
        (header::CONTENT_DISPOSITION, content_disposition),
    ]);

    Ok((headers, stream_body))
}

Routerにdownloadを追加します。

let app = Router::new()
    .route("/files/list_folder", routing::post(list_folder))
    .route("/files/download", routing::post(download));

それでは動作確認のために実行します。

$ cargo run

curlを使って、Cargo.tomlをダウンロードするリクエストを送ってみます。今回はレスポンスヘッダーも見たかったので--verboseを与えます。

curl -X POST localhost:3000/files/download --header "Content-Type: application/json" --data '{"path": "Cargo.toml"}' --verbose
...
< content-type: application/octet-stream
< content-disposition: attachment; filename="Cargo.toml"
...
[package]
name = "rust-file-api"
version = "0.1.0"
edition = "2021"
...

curlでは分かりにくいですが、content-disposition: attachment; filename="Cargo.toml"とヘッダーにあり、レスポンスのボディはCargo.tomlの内容なので、ファイルがダウンロードできることが確認できました。

現時点のCargo.tomlです。

[package]
name = "rust-file-api"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
axum = "0.4"
serde = { version = "1.0", features = ["derive"] }
tokio = { version = "1.16", features = ["fs", "macros", "rt-multi-thread"] }
tokio-util = { version = "0.7", features=["io"] }

現時点のsrc/main.tsです。

use std::fs;
use std::io;
use std::path::Path;

use axum::body::StreamBody;
use axum::http::{header, StatusCode};
use axum::response::{Headers, IntoResponse};
use axum::{routing, Json, Router};
use serde::{Deserialize, Serialize};
use tokio_util::io::ReaderStream;

#[derive(Debug, Deserialize)]
struct ListFolder {
    path: String,
}

#[derive(Debug, Serialize)]
struct Metadata {
    name: String,
}

#[derive(Debug, Serialize)]
struct MetadataEntriesResponse {
    entries: Vec<Metadata>,
}

#[derive(Debug, Deserialize)]
struct Download {
    path: String,
}

fn read_dir<P: AsRef<Path>>(path: P) -> io::Result<Vec<String>> {
    let file_names = fs::read_dir(&path)?
        .filter_map(|dir_entry| -> Option<String> {
            let entry = dir_entry.ok()?;
            if entry.file_type().ok()?.is_file() {
                Some(entry.file_name().to_string_lossy().into_owned())
            } else {
                None
            }
        })
        .collect();
    Ok(file_names)
}

async fn list_folder(Json(input): Json<ListFolder>) -> impl IntoResponse {
    if let Ok(file_names) = read_dir(&input.path) {
        let entries = file_names
            .into_iter()
            .map(|name| Metadata { name })
            .collect::<Vec<Metadata>>();
        Ok(Json(MetadataEntriesResponse { entries }))
    } else {
        Err(StatusCode::NOT_FOUND)
    }
}

async fn download(Json(input): Json<Download>) -> impl IntoResponse {
    let stream_body = match tokio::fs::File::open(&input.path).await {
        Ok(file) => StreamBody::new(ReaderStream::new(file)),
        _ => return Err(StatusCode::NOT_FOUND),
    };
    let file_name = match input.path.rsplit_once("/") {
        Some((_, file_name)) => file_name,
        _ => &input.path,
    };
    let content_disposition = format!("attachment; filename=\"{}\"", file_name);

    let headers = Headers([
        (header::CONTENT_TYPE, "application/octet-stream".to_string()),
        (header::CONTENT_DISPOSITION, content_disposition),
    ]);

    Ok((headers, stream_body))
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/files/list_folder", routing::post(list_folder))
        .route("/files/download", routing::post(download));

    axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

ファイルのアップロード

いよいよ最後のAPIです。Dropboxのfiles/uploadの簡略版を作ります。 これまでのAPIは引数をリクエストのボディにJSONで渡していました。今回のAPIのリクエストボディはアップロードするファイルなので、APIの他の引数のためにリクエストボディを使えません。

Content-upload endpoints

These endpoints accept file content in the request body, so their arguments are instead passed as JSON in the Dropbox-API-Arg request header or arg URL parameter. These endpoints are on the content.dropboxapi.com domain.

https://www.dropbox.com/developers/documentation/http/documentation#formats

こういったAPIの場合Dropboxでは、Dropbox-API-Argというリクエストヘッダーもしくは、argというURLクエリを使うことになっています。

今回はURLクエリにのみ対応することにします。まとめる、今から作るAPIはリクエストボディでアップロードするファイルを受け取り、そのファイルをURLクエリのargで渡されたJSONで指定されたpathに保存します。リクエストボディにアップロードするファイルがそのまま入っています。multipart/form-dataではありません。

これまで通り、まずはstructを定義します。

#[derive(Debug, Deserialize)]
struct Upload {
    path: String,
}

今回はURLクエリでJSONを受け取るので、自分でJSONをデシリアライズしないといけません。JSONをデシリアライズするためにserde_jsonをCargo.tomlの[dependencies]に加えます。

serde_json = "1.0"

それではアップロードを受け付けるAPIを定義します。

今回はuseの変更が多いのでdiffで掲載します。

-use std::fs;
-use std::io;
+use std::collections::HashMap;
+use std::fs::{self, File};
+use std::io::{self, Write};
use std::path::Path;

-use axum::body::StreamBody;
+use axum::body::{Bytes, StreamBody};
+use axum::extract::{Json, Query};
use axum::http::{header, StatusCode};
use axum::response::{Headers, IntoResponse};
-use axum::{routing, Json, Router};
+use axum::{routing, Router};

こちらが関数です。

async fn upload(Query(query): Query<HashMap<String, String>>, body: Bytes) -> impl IntoResponse {
    let upload = match query.get("arg") {
        Some(arg) => match serde_json::from_str::<Upload>(arg) {
            Ok(upload) => upload,
            _ => return Err(StatusCode::BAD_REQUEST)
        },
        _ => return Err(StatusCode::BAD_REQUEST),
    };
    let mut file = match File::create(upload.path) {
        Ok(file) => file,
        _ => return Err(StatusCode::BAD_REQUEST),
    };

    if !file.write_all(&body).is_ok() || !file.flush().is_ok() {
        return Err(StatusCode::INTERNAL_SERVER_ERROR);
    };

    Ok(StatusCode::CREATED)
}

繰り返しですが、このAPI(参考にしたDropboxのAPIも同様)はリクエストボディでファイルを直接受け取ります。そのため、bodyがBytes型となります。

忘れずにRouterに登録します。

let app = Router::new()
    .route("/files/list_folder", routing::post(list_folder))
    .route("/files/download", routing::post(download))
    .route("/files/upload", routing::post(upload));

それでは動作確認のために実行します。

$ cargo run

curlを使って、Cargo.tomlをnewfileという名前でアップロードしてみます。

curl -X POST 'localhost:3000/files/upload?arg=%7B%22path%22%3A+%22newfile%22%7D' --data-binary @Cargo.toml

%7B%22path%22%3A+%22newfile%22%7D{"path": "newfile"}をURLエンコードしたものです。

これでnewfileという名前でCargo.tomlが保存されたはずです。catコマンドでファイルを確認してもよいのですが、せっかくダウンロードのAPIを作ったので、これを使って確認してみます。

% curl -X POST localhost:3000/files/download --header "Content-Type: application/json" --data '{"path": "newfile"}'
[package]
name = "rust-file-api"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
axum = "0.4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.16", features = ["fs", "macros", "rt-multi-thread"] }
tokio-util = { version = "0.7", features=["io"] }

newfileという名前でCargo.tomlが保存されていることが確認できました。

最後に完成したsrc/main.rsを掲載します。Cargo.tomlの内容はこの段落のすぐ上にあるcurlの結果を参照してください。

use std::collections::HashMap;
use std::fs::{self, File};
use std::io::{self, Write};
use std::path::Path;

use axum::body::{Bytes, StreamBody};
use axum::extract::{Json, Query};
use axum::http::{header, StatusCode};
use axum::response::{Headers, IntoResponse};
use axum::{routing, Router};
use serde::{Deserialize, Serialize};
use tokio_util::io::ReaderStream;

#[derive(Debug, Deserialize)]
struct ListFolder {
    path: String,
}

#[derive(Debug, Serialize)]
struct Metadata {
    name: String,
}

#[derive(Debug, Serialize)]
struct MetadataEntriesResponse {
    entries: Vec<Metadata>,
}

#[derive(Debug, Deserialize)]
struct Download {
    path: String,
}

#[derive(Debug, Deserialize)]
struct Upload {
    path: String,
}

fn read_dir<P: AsRef<Path>>(path: P) -> io::Result<Vec<String>> {
    let file_names = fs::read_dir(&path)?
        .filter_map(|dir_entry| -> Option<String> {
            let entry = dir_entry.ok()?;
            if entry.file_type().ok()?.is_file() {
                Some(entry.file_name().to_string_lossy().into_owned())
            } else {
                None
            }
        })
        .collect();
    Ok(file_names)
}

async fn list_folder(Json(input): Json<ListFolder>) -> impl IntoResponse {
    if let Ok(file_names) = read_dir(&input.path) {
        let entries = file_names
            .into_iter()
            .map(|name| Metadata { name })
            .collect::<Vec<Metadata>>();
        Ok(Json(MetadataEntriesResponse { entries }))
    } else {
        Err(StatusCode::NOT_FOUND)
    }
}

async fn download(Json(input): Json<Download>) -> impl IntoResponse {
    let stream_body = match tokio::fs::File::open(&input.path).await {
        Ok(file) => StreamBody::new(ReaderStream::new(file)),
        _ => return Err(StatusCode::NOT_FOUND),
    };
    let file_name = match input.path.rsplit_once("/") {
        Some((_, file_name)) => file_name,
        _ => &input.path,
    };
    let content_disposition = format!("attachment; filename=\"{}\"", file_name);

    let headers = Headers([
        (header::CONTENT_TYPE, "application/octet-stream".to_string()),
        (header::CONTENT_DISPOSITION, content_disposition),
    ]);

    Ok((headers, stream_body))
}

async fn upload(Query(query): Query<HashMap<String, String>>, body: Bytes) -> impl IntoResponse {
    let upload = match query.get("arg") {
        Some(arg) => match serde_json::from_str::<Upload>(arg) {
            Ok(upload) => upload,
            _ => return Err(StatusCode::BAD_REQUEST),
        },
        _ => return Err(StatusCode::BAD_REQUEST),
    };
    let mut file = match File::create(upload.path) {
        Ok(file) => file,
        _ => return Err(StatusCode::BAD_REQUEST),
    };

    if !file.write_all(&body).is_ok() || !file.flush().is_ok() {
        return Err(StatusCode::INTERNAL_SERVER_ERROR);
    };

    Ok(StatusCode::CREATED)
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/files/list_folder", routing::post(list_folder))
        .route("/files/download", routing::post(download))
        .route("/files/upload", routing::post(upload));

    axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

完成

以上でこの記事中で作るDropbox風のファイル操作APIは完成です。

axumに限らず私にとって、初めてRustでのWeb APIの作成でした。リクエストで渡された引数を関数が受け取る部分が好みです。