第6回は、第5回の続きとして、投稿アプリにREST APIを導入し、後続の回で利用できるようにします。
この記事は会員限定です。会員登録(無料)すると全てご覧いただけます。
本連載のサンプルコードをGitHubで公開しています。こちらからダウンロードしてみてください。
今回は、これまで作成してきたactix-postsアプリに、Web APIを追加します。追加するAPIはRESTful APIに準拠したものであり、アプリに倣って以下の5個とします。それぞれ、入力、出力ともにJSON文字列とします。
全て、パスを/apiから始めるようにして、既存のハンドラー関数と競合しないようにします。また、更新と削除のHTTPメソッドは、それぞれPUTとDELETEに変更しています。URLパターンのその他の部分は、これまでと同様です。
また、API用のデフォルトのハンドラー関数api_not_foundも用意します。
RESTful APIとは、REST(REpresentational State Transfer)の原則に準拠して設計、構築されたWebシステムの呼び出しインタフェースを指します。RESTは、分散型システムにおけるソフトウェア連携に適した設計原則の考え方に基づいています。具体的には、分かりやすいURLが用いられる、HTTPメソッドの使い方が統一されている、ステートレスである、といったことを満たしていることが挙げられます。
APIは新しいハンドラー関数として実装するので、src/main.rsファイルにルーティングルール登録部分をリスト1のように追記します。APIのパスは/api/〜となっているので、これをスコープという方法でまとめます。
…略… App::new() …略… .service( web::scope("/api") (1) .service(handler::api_index) (2) .service(handler::api_show) .service(handler::api_create) .service(handler::api_update) .service(handler::api_destroy) .default_service(web::to(handler::api_not_found)) ) .default_service(web::to(handler::not_found)) …略…
注目すべきは、(1)のscopeメソッドです。scopeメソッドを使うと、パスの共通部(スコープ)を指定して複数のルーティングルールをグループ化することができます。この場合は、パスが「/api」で始まるものをまとめています。ハンドラー関数の注釈でパスの指定が冗長になるのを防ぎ、保守性を向上させるための仕組みです。
scopeメソッドにチェインさせるserviceメソッドの使い方は、(2)のようにこれまでと変わりません。「/api」下にあるものとして、ハンドラー関数の注釈を調べに行きます。
APIのレスポンスはJSON文字列なので、レスポンスの構造体を定義し、serdeクレートのSerializeトレイトを使ってシリアライズします。レスポンスの構造体は各APIハンドラー関数で共有します。リスト2がその定義です。
#[derive(Serialize, Debug)] (1) enum ResponseContent { (2) Items(Vec<data::Message>), Item(data::Message), Reason(String), None, } #[derive(Serialize, Debug)] (1) struct ApiResponse { (3) status: String, result: ResponseContent, }
(1)は、構造体(列挙体)にSerializeトレイトを実装する指定です。この指定により、作成したレスポンスデータを簡単にJSON文字列としてシリアライズして返すことができます。
(2)は、レスポンスの本体部分ResponseContentで、APIによって内容が異なるために列挙体(enum)としています。順番に、Message構造体のベクター(Items)、Message構造体単体(Item)、エラー情報などの文字列(Reason)、値なし(None)としています。
(3)は、レスポンスの全体であるApiResponseで、処理結果("OK"または"Error")を示すstatusフィールド、そしてResponseContentであるresultフィールドとなっています。
APIにはHTTP PUT、DELETEメソッドによるものが含まれるので、そのための関数put、deleteをリスト3のようにsrc/handler.rsファイル冒頭のuse文に追加します。
…略… use actix_web::{Responder, HttpResponse, web, get, put, post, delete}; …略…
ルーティングルールに追加した関数は、それぞれ既存のnot_found関数、index関数などを複製して作成します。このうち、api_not_found関数をリスト4に示します。
pub async fn api_not_found() -> impl Responder { let response = ApiResponse { (1) status: "Error".to_string(), (2) result: ResponseContent::Reason("API not found".to_string()), (3) }; HttpResponse::Ok().json(response) (4) }
これは、リスト3のレスポンス構造体の基本的な使い方となっています。
(1)からのインスタンス生成で、(2)でstatusフィールドに"Error"を設定、(3)で文字列を返すためのReason列挙子をメッセージとともにresultフィールドに設定しています。
(4)のようにインスタンスをjsonメソッドで返せばレスポンスは自動的にJSON文字列(application/json)となります。各API関数では、(2)と(3)の内容をそれぞれ生成して返します。
リスト5は、api_indexハンドラー関数とapi_createハンドラー関数です。他のハンドラー関数も同様なので配布サンプルを参照してください。
#[get("/posts")] (1) pub async fn api_index() -> impl Responder { (2) info!("Called index API"); let posts = data::get_all(); let mut ary = Vec::new(); (3) for item in &posts { ary.push(item.clone()); } let response = ApiResponse { status: "OK".to_string(), result: ResponseContent::Items(ary), }; HttpResponse::Ok().json(response) } …略… #[post("/posts/create")] (1) pub async fn api_create(params: web::Json<data::Message>) -> impl Responder { (2) info!("Called create API"); let now: DateTime<Local> = Local::now(); let mut message = data::Message { …略… }; message = data::create(message); let response = ApiResponse { status: "OK".to_string(), result: ResponseContent::Item(message), }; HttpResponse::Ok().json(response) } …略…
(1)は、ルーティングルールの注釈ですが、指定する内容は基本的に非APIのハンドラー関数と同様です。main関数にてスコープとして「/api」が指定されているので、この部分をパスから省略できます。仮に、パスが「/api」から「/service」などに変わっても、この部分には手を入れる必要はありません。
(2)は、それぞれの関数のシグネチャです。テンプレートやフラッシュメッセージのデータを受け取る引数は除去され、連載第3回のときと同様の状態になります。ただし、api_create関数(とapi_update関数)については、受け取る引数がフォームではなくJSONデータ直接となるので、引数の型がJson<data::Message>となる点に注意してください。引数から各フィールドを受け取る方法は全く同じなので変更の必要はありません。
(3)は、api_index関数にのみ特有の、レスポンスを配列のJSONデータで返すためのコードです。配列はベクターで作成し、個々のレコードをpushメソッドで追加します。
その他、レスポンス構造体を生成してjsonメソッドで返す点は、リスト4のapi_not_found関数と同様です。
ターミナルを開き、cargo runコマンドでアプリが正しくビルド、実行できることを確認したら、APIが正しく機能するかどうかテストしましょう。ターミナルを新しく開き、curlコマンドを使います。まずは、一覧の取得です。結果は長くなりますので途中で省略しています。
% curl http://localhost:8000/api/posts {"status":"OK","result":{"Items":[{"id":5,"posted":"2023-12-03 09:52:41","sender":"Shino","content":"やだ!\r\nナイス突っ込み!!"},…略…]}}
次は、個別投稿の表示です。
% curl http://localhost:8000/api/posts/1 {"status":"OK","result":{"Item":{"id":1,"posted":"2023-10-10 01:23:45","sender":"Nao","content":"こんにちは。\nまたRustやってます。\n久しぶりなので苦労してます。"}}}
新規作成では、登録データをJSON形式で渡す必要があります。この際、content-typeヘッダも忘れずに指定します。登録データがレスポンスとして返るので、そのまま利用できます。
% curl http://localhost:8000/api/posts/create -X POST -H "content-type: text/json;" -d "{\"id\":0,\"posted\":\"\",\"sender\":\"Yuutan\",\"content\":\"まさかそこを突っ込まれるとは思わなかったっス。\"}" {"status":"OK","result":{"Item":{"id":6,"posted":"2024-01-14 07:10:06","sender":"Yuutan","content":"まさかそこを突っ込まれるとは思わなかったっス。"}}}
更新もHTTPメソッドがPUTになる他は新規作成と同様です。
% curl http://localhost:8000/api/posts/update -X PUT -H "content-type: text/json;" -d "{\"id\":6,\"posted\":\"2024-01-14 08:10:06\",\"sender\":\"Yuutan\",\"content\":\"まさかそこを突っ込まれるとは思わなかったっス。\nちょっと動揺しているッス。\"}" {"status":"OK","result":{"Item":{"id":6,"posted":"2024-01-14 08:10:06","sender":"Yuutan","content":"まさかそこを突っ込まれるとは思わなかったっス。\nちょっと動揺しているッス。"}}}
最後は削除です。HTTPメソッドがDELETEになります。
% curl http://localhost:8000/api/posts/6/delete -X DELETE {"status":"OK","result":"None"}
最近ではサポートも減っているようですが、Web APIの中にはXML(Extensible Markup Language)を返すことも依然としてあるようです。ということで、ここではAPIのJSONサポートに加えてXMLサポートをアプリに付加してみます。フォーマットは、クエリパラメーターを以下のように指定します。
/api/posts?fotmat=xml 指定がない、xml以外ならJSONとする
なお、Actix WebにはリクエストボディーがXMLである場合のサポートがないので、レスポンスのみをXML化する例として紹介します。なお、XML形式のリクエストボディーを受け取るためにactix-xmlクレートが紹介されていますが、リクエストボディーがXML一択になってしまうので、今回は利用しません。
シリアライズとデシリアライズのためのSerdeクレートは紹介済みですが、これにXMLサポートを付加するserde-xml-rsクレートをプロジェクトに追加します。
% cargo add serde-xml-rs
serde-xml-rsは、本稿作成時点の最新版(0.6.0)でもベクター(Vec<T>)をサポートしないので、プルリクエストを反映したフォーク版を使用するように、Cargo.tmlの当該行をリスト6のように書き換えます。
actix-xml = "0.2.0" …略… serde-xml-rs = { git = "https://github.com/adrianbenavides/serde-xml-rs.git", rev = "fc2d35e5e2d08c4e8c8dee13449ff3ce98de46c1" }
レスポンスのフォーマットはクエリパラメーターで指定するので、それをハンドラー関数の引数で受け取るための構造体をリスト7のように定義します。
#[derive(Deserialize)] struct Queries { format: Option<String>, }
Queries構造体は、クエリパラメーターformatに相当するフィールドを持つのみです。型をOption<String>としているのは、クエリパラメーターが省略された場合に対応できるようにするためです。型を直接Stringとすると、指定されなかったときに404エラーとなります。
ハンドラー関数の修正に先立ち、serde_xml_rsの関数を使うためのuse文をリスト8のように追加しておきます。
…略… use serde_xml_rs::{from_str, to_string}; …略…
レスポンス形式の仕分けは各ハンドラー関数で共通になるので、クエリパラメーターとレスポンスの構造体を受け取って生成する関数build_responseにまとめてしまいます(リスト9)。
fn build_response(value: &Option<String>, response: &ApiResponse) (1) -> impl Responder { if let Some(format) = value { (2) match format.as_str() { (3) "xml" => { HttpResponse::Ok().content_type("application/xml; charset=utf-8") .body(serde_xml_rs::to_string(&response).unwrap()) (4) }, _ => HttpResponse::Ok().json(response), } } else { HttpResponse::Ok().json(response) } }
(1)では、引数にクエリパラメーターの値とレスポンスデータを受け取ってResponderを返す関数build_responseを定義しています。
(2)では、クエリパラメーターが指定されているかどうかで処理を分けています。指定されていれば、(3)のmatch式でパラメーターの値によって返す形式を分けています。
(4)はXML形式で返す場合の記述であり、レスポンスヘッダのcontent_typeにappllication/xmlを指定し、serde_xml_rsクレートのto_stringメソッドでXML文字列にシリアライズしたものをボディーとして返しています。
それら以外はJSON文字列として返すのはこれまでと同様です。
build_response関数を使って、レスポンスを仕分けるapi_index関数をリスト10に示します。
#[get("/posts")] pub async fn api_index(query: web::Query<Queries>) -> impl Responder { (1) info!("Called index API"); let param = query.into_inner(); (2) let posts = data::get_all(); …略… build_response(¶m.format, &response) (3) }
(1)では、関数の引数にweb::Query<Queries>を追加してクエリパラメーターを受け取れるようにしています。それを(2)でinto_innerメソッドで取得するのはパスパラメーターやフォームと同様です。
(3)では、リスト8に示したbuild_response関数の呼び出しに戻り値を置き換えています。これで、レスポンス形式の切り替えができるようになりました。
api_show関数、api_destory関数でも同様なので、配布サンプルを参照してください。
最後に、XML対応になったAPIをテストします。同じくcurlコマンドで、個別投稿を表示させてみます。URLがクエリパラメーターを含むので、全体を二重引用符で囲むことに注意してください。実行結果が以下のようにXML形式になっていれば成功です。formatパラメーターをjsonにしたり省略したりした場合にJSON形式になることも確認してください。なお、XML形式ではシリアライズ前のデータに改行が含まれる場合、それはそのまま出力に現れるようです。
% curl "http://localhost:8000/api/posts/1?format=xml" <ApiResponse><status>OK</status><result><Item><Message><id>1</id><posted>2023-10-10 01:23:45</posted><sender>Nao</sender><content>こんにちは。 またRustやってます。 久しぶりなので苦労してます。</content></Message></Item></result></ApiResponse>
今回は、第5回の続きとして、投稿アプリにREST APIも追加しました。JSON形式に加えて、XML形式でも返せるように拡張しました。
次回は、このWeb APIを利用して、UI作成フレームワーク「Dioxus」でアプリのフロントエンド部分を実装します。
WINGSプロジェクト
有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティー(代表山田祥寛)。主にWeb開発分野の書籍/記事執筆、翻訳、講演等を幅広く手掛ける。2021年10月時点での登録メンバーは55人で、現在も執筆メンバーを募集中。興味のある方は、どしどし応募頂きたい。著書、記事多数。
・サーバーサイド技術の学び舎 - WINGS(https://wings.msn.to/)
・RSS(https://wings.msn.to/contents/rss.php)
・X: @WingsPro_info(https://x.com/WingsPro_info)
・Facebook(https://www.facebook.com/WINGSProject)
Copyright © ITmedia, Inc. All Rights Reserved.