筆者は、Astroベースで作成した静的サイトをCloudflare Pagesで運営しています。
今回そこに置いてある記事に「いいねボタン」を設置しようと思い立ちました。
今回の記事はAPI実装編のvol.1です。
仕様等を記載したのは前回の記事で、こちらです。
なんとなくRustを使おうかなという気分になったので、APIはRustで実装します。
コードを書き始める前の準備
Cloudflare WorkersのRustのテンプレートがあるので、そちらを準備します。
cargo generate cloudflare/workers-rs
で、
npx wrangler dev
を使って実行するわけですが、このままだとエラーになるため、以下のとおり、Cargo.toml
を修正して、wasm-bindegen
のバージョンを指定します。
また、Cloudflare D1を使用するため、workerのfeaturesにd1を追加します。
[dependencies]
- worker = { version="0.2.0", features=['http', 'axum'] }
+ worker = { version="0.2.0", features=['http', 'axum', 'd1'] }
worker-macros = { version="0.2.0", features=['http'] }
axum = { version = "0.7", default-features = false }
tower-service = "0.3.2"
console_error_panic_hook = { version = "0.1.1" }
+ wasm-bindgen = "=0.2.92"
そして、wrangler.toml
にd1情報を追加しておきます。
今回は既につくってあるデータベースを使います。
[[d1_databases]]
binding = "DB" # i.e. available in your Worker on env.DB
database_name = "workers-rs-d1"
database_id = "****"
もし、データベースを新規作成する場合は以下のような感じ。
npx wrangler d1 create workers-rs-d1
データベースにテーブルを作成します。
db/schema.sql
を作成し、SQLを書きます。
仮データも追加しておきます。
-- DROP TABLE IF EXISTS iinesLog2024;
-- DROP TABLE IF EXISTS iinesTotal2024;
CREATE TABLE IF NOT EXISTS IinesLog2024
(Id INTEGER PRIMARY KEY,
ipAddress TEXT,
clickedUrl TEXT,
clickedButton TEXT,
clickedDate TEXT);
CREATE TABLE IF NOT EXISTS IinesTotal2024
(id TEXT PRIMARY KEY,
clickedUrl TEXT,
clickedButton TEXT,
totalIine INTEGER);
INSERT INTO IinesLog2024 (id, ipAddress, clickedUrl, clickedButton, clickedDate) VALUES
(1, "000.000.000.000", "http://localhost:8080/blog", "article-001", "2024-09-01"),
(2, "000.111.000.111", "http://localhost:8080/blog", "article-002", "2024-09-02");
INSERT INTO IinesTotal2024 (id, clickedUrl, clickedButton, totalIine) VALUES
(1, "http://localhost:8080/blog", "article-001", 1),
(2, "http://localhost:8080/blog", "article-002", 1);
正しく作成できているかを以下で確認します。
npx wrangler d1 execute workers-rs-d1 --local --command="SELECT * FROM IinesLog2024"
npx wrangler d1 execute workers-rs-d1 --local --command="SELECT * FROM IinesTotal2024"
コードを書く
テンプレートそのままのコードは以下のようになっています。
axumを使うのは初めてなので、色々調べながら書いていきます。
use axum::{routing::get, Router};
use tower_service::Service;
use worker::*;
fn router() -> Router {
Router::new().route("/", get(root))
}
#[event(fetch)]
async fn fetch(
req: HttpRequest,
_env: Env,
_ctx: Context,
) -> Result<axum::http::Response<axum::body::Body>> {
console_error_panic_hook::set_once();
Ok(router().call(req).await?)
}
pub async fn root() -> &'static str {
"Hello Axum!"
}
GET
まずは、記事の現在のいいね数を取得するGETリクエスト処理を書きます。
http://127.0.0.1:8787/iines?url=http://localhost:8080/blog&button=article-001
リクエストを送るURLは上記のようにクエリパラメータを付加します。
実際は各値をエンコードした状態で送信します。
では、現在のコードを見てみます。
ルーティング
fn router() -> Router {
Router::new().route("/", get(root))
}
この部分でURLを判別して振り分けるみたいですね。
じゃあ、ここに存在しないURLだとどうなるんだろうということで、http://127.0.0.1:8787/tameshi
にアクセスして見ると、
[wrangler:inf] GET /tameshi 404 Not Found (6ms)
と、なりました。なるほど。
複数ルートを指定したい場合はどう書けばいいんだろう?
axumのドキュメントを確認します。
let app = Router::new()
.route("/", get(root))
.route("/users", get(list_users).post(create_user))
.route("/users/:id", get(show_user))
.route("/api/:version/users/:id/action", delete(do_users_action))
.route("/assets/*path", get(serve_asset));
こうでいいらしいです。
チェーン上でつなげて書けるのは明快でいいですね。
では、こう書けば、
fn router() -> Router {
Router::new()
.route("/", get(root))
.route("/tameshi", get(root))
}
理解した通りになりました。
[wrangler:inf] GET /tameshi 200 OK (25ms)
では、今回のURLは
http://127.0.0.1:8787/iines?url=http://localhost:8080/blog&button=article-001
なので、
fn router() -> Router {
Router::new()
.route("/", get(root))
.route("/iines", get(iines))
}
pub async fn iines() -> &'static str {
"Hello Iine!!!"
}
と、しましょう。
[wrangler:inf] GET /iines 200 OK (27ms)
OKです。
Hello Iine!!!
も表示されました。
クエリパラメータ
次はクエリパラメータの処理の仕方です。
ここを読みます。
serdeが必要らしいので追加します。
cargo add serde
axumのquery featureも使うらしいので、Cargo.toml
に書きます。
- axum = { version = "0.7", default-features = false}
+ axum = { version = "0.7", default-features = false, features=['query'] }
そして、Exampleコードを参考にして、適当に処理を書いてみます。
use axum::{extract::Query, routing::get, Router};
use tower_service::Service;
use worker::*;
use serde::Deserialize;
fn router() -> Router {
Router::new()
.route("/", get(root))
.route("/iines", get(iines))
}
#[event(fetch)]
async fn fetch(
req: HttpRequest,
_env: Env,
_ctx: Context,
) -> Result<axum::http::Response<axum::body::Body>> {
console_error_panic_hook::set_once();
Ok(router().call(req).await?)
}
#[derive(Deserialize)]
struct Params {
url: String,
button: String,
}
async fn root() -> &'static str {
"Hello Axum!"
}
async fn iines(query: Query<Params>) -> &'static str {
console_log!("{:?}", query.url);
console_log!("{:?}", query.button);
"Hello Iine!!!"
}
これで、
http://127.0.0.1:8787/iines?url=http://localhost:8080/blog&button=article-001
にアクセスします。
"http://localhost:8080/blog"
"article-001"
[wrangler:inf] GET /iines 200 OK (43ms)
OKです。
何ごともなく上記コードを書いたように見えますが、ブラウザで表示されるメッセージにクエリパラメータを表示するにはどうやるんだ、とか、コンソールに表示するには?とか、わたわたと試行錯誤しています。
まだ、所有権とか色々とわかってません。
ひとまず、クエリパラメータが取得できたので、次にデータベースへのアクセス処理を書きます。
データベースからのデータ取得
このあたりから、わけわからん度が高まります。
今回は、ブログ系の記事ではなく、公式ドキュメントを参照して実装するという小さな隠れ目標があるため、首をかしげながら実装します。
試行錯誤の結果、以下のようにして、データベースからデータを取得できました。
#[allow(non_snake_case)]
use axum::{extract::Query, routing::get, Router};
use tower_service::Service;
use worker::*;
use serde::Deserialize;
#[derive(serde::Deserialize, Debug)]
struct Log {
id: u64,
ipAddress: String,
clickedUrl: String,
clickedButton: String,
clickedDate: String,
}
fn router() -> Router {
Router::new()
.route("/", get(root))
.route("/iines", get(iines))
}
#[event(fetch)]
async fn fetch(
req: HttpRequest,
env: Env,
_ctx: Context,
) -> Result<axum::http::Response<axum::body::Body>> {
console_error_panic_hook::set_once();
let db = env.d1("DB");
let response = db?.prepare("SELECT * FROM IinesLog2024").run().await?;
console_log!("{:?}", response.results::<Log>());
Ok(router().call(req).await?)
}
#[derive(Deserialize)]
struct Params {
url: String,
button: String,
}
async fn root() -> &'static str {
"Hello Axum!"
}
async fn iines(query: Query<Params>) -> &'static str {
console_log!("{:?}", query.url);
console_log!("{:?}", query.button);
"Hello Iine!!!"
}
↓ 取得したデータ
Ok([Log { id: 1, ipAddress: "000.000.000.000", clickedUrl: "http://localhost:8080/blog", clickedButton: "article-001", clickedDate: "2024-09-01" }, Log { id: 2, ipAddress: "000.111.000.111", clickedUrl: "http://localhost:8080/blog", clickedButton: "article-002", clickedDate: "2024-09-02" }])
"http://localhost:8080/blog"
"article-001"
[wrangler:inf] GET /iines 200 OK (52ms)
データベースへのアクセス
簡単にポイントを書き留めておきます。
let db = env.d1("DB");
D1への接続はEnv構造体からおこないます。
"DB"
はwrangler.toml
に書いた以下の記載からです。
binding = "DB" # i.e. available in your Worker on env.DB
SQL実行
そして、よくあるようにSQL文を準備して実行します。
今回のSQL文であればexec
で直接実行してもよさそうですが、なんとなくprepare
を使って、それから実行するパターンを採用します。
let response = db?.prepare("SELECT * FROM IinesLog2024").run().await?;
run()
までだと、Futureとかいう型になったため、確かこうだったよな……という記憶でawait
をつけました。
たぶん、JavaScriptのプロミスみたいなものなのでしょう。
取得したデータのお試し表示
console_log!("{:?}", response.results::<Log>().unwrap());
このあたりは、コードを書いていると表示されるチップスを参考にして、適当に書きます。
上記の記載から、D1Result
型のresults()
から取得したデータを取得できそうです。
試行錯誤1
console_log!("{:?}", response.results());
↓ 実行結果
error[E0283]: type annotations needed
--> src\lib.rs:35:35
|
35 | console_log!("{:?}", response.results());
| ^^^^^^^ cannot infer type of the type parameter `T` declared on the method `results`
試行錯誤2
どうやら型情報を求めているようです。
テーブルのデータ構造を構造体で定義します。
struct Log {
id: u64,
ipAddress: String,
clickedUrl: String,
clickedButton: String,
clickedDate: String,
}
そして、こう書いてみます。
console_log!("{:?}", response.results<Log>());
↓ 実行結果
error: comparison operators cannot be chained
--> src\lib.rs:34:42
|
34 | console_log!("{:?}", response.results<Log>());
| ^ ^
|
help: use `::<...>` instead of `<...>` to specify lifetime, type, or const arguments
|
34 | console_log!("{:?}", response.results::<Log>());
| ++
正しい書き方を教えてくれました。
試行錯誤3
console_log!("{:?}", response.results::<Log>());
↓ 実行結果
error[E0277]: the trait bound `for<'a> Log: Deserialize<'a>` is not satisfied
--> src\lib.rs:34:45
|
34 | console_log!("{:?}", response.results::<Log>());
| ------- ^^^ the trait `for<'a> Deserialize<'a>` is not implemented for `Log`
| |
| required by a bound introduced by this call
|
= note: for local types consider adding `#[derive(serde::Deserialize)]` to your `Log` type
= note: for types from other crates check whether the crate offers a `serde` feature flag
= help: the following other types implement trait `Deserialize<'de>`:
&'a [u8]
&'a std::path::Path
&'a str
()
(T,)
(T0, T1)
(T0, T1, T2)
(T0, T1, T2, T3)
and 144 others
#[derive(serde::Deserialize)]
をLog
構造体につけろと言っているようです。
試行錯誤4
#[derive(serde::Deserialize)]
struct Log {
id: u64,
ipAddress: String,
clickedUrl: String,
clickedButton: String,
clickedDate: String,
}
↓ 実行結果
error[E0277]: `Log` doesn't implement `Debug`
--> src\lib.rs:35:26
|
35 | console_log!("{:?}", response.results::<Log>());
| ^^^^^^^^^^^^^^^^^^^^^^^^^ `Log` cannot be formatted using `{:?}`
|
= help: the trait `Debug` is not implemented for `Log`, which is required by `std::result::Result<Vec<Log>, worker::Error>: Debug`
= note: add `#[derive(Debug)]` to `Log` or manually `impl Debug for Log`
= help: the trait `Debug` is implemented for `std::result::Result<T, E>`
= note: this error originates in the macro `format_args` which comes from the expansion of the macro `console_log` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider annotating `Log` with `#[derive(Debug)]`
|
8 + #[derive(Debug)]
9 | struct Log {
|
For more information about this error, try `rustc --explain E0277`.
Debug実装が必要だから、#[derive(Debug)]
もつけろと言っています。
試行錯誤5
#[derive(serde::Deserialize, Debug)]
struct Log {
id: u64,
ipAddress: String,
clickedUrl: String,
clickedButton: String,
clickedDate: String,
}
↓ 実行結果
Ok([Log { id: 1, ipAddress: "000.000.000.000", clickedUrl: "http://localhost:8080/blog", clickedButton: "article-001", clickedDate: "2024-09-01" }, Log { id: 2, ipAddress: "000.111.000.111", clickedUrl: "http://localhost:8080/blog", clickedButton: "article-002", clickedDate: "2024-09-02" }])
"http://localhost:8080/blog"
"article-001"
よさそうです。
Result型をそのままデバッグ表示しているので、OKも含まれていますが、まずはいいことにしましょう。
後は、GETの実装としては、クエリパラメータの情報を元にしたSQL文を実行しデータを取得、それをAPI呼び出し元に返せばOKです。
細々書いていたら記事が長くなったので、ここで記事を分割します。