zerocal - A Serverless Calendar App in Rust Running on shuttle.rs

Matthias Endler

zerocal — shuttle.rs上で動くRust製サーバーレスカレンダーアプリ

たまに仲間と食事に行きます。こういう夜は本当に大切にしているのですが、一番うんざりするのが日程調整です。

グループにメッセージを送ります。
返事を待ちます。
日付を決めます。
誰かがカレンダーの招待を送ります。
ようやく実現します。

食事自体を除けば、そこに楽しいことは何もありません。

まともな人なら、こう思うでしょう。「日程調整アプリを使えばいいじゃないか」と。

いろいろ試してみました。どれもいまひとつです。どれも……過剰なのです。

ただ招待を送って、来たい人が来ればそれでいいのに。

  • わざわざあなたのカレンダー/日程調整/その他もろもろのアプリのためにアカウントを作りたくないのです。
  • 友達を追加したくないのです。
  • 友達の友達を追加したくないのです。
  • 友達の友達の友達を追加したくないのです。
  • 言いたいことは分かるでしょう。ただ招待を送って、あなたからの返事はなくてもいいのです。

内向的なオタクエンジニアの解決策

💡 そこで絶対に必要なのが、イベントを作成してリンク付きで招待を送れる、また別のカレンダーアプリです!意外でしたか?

あと、イベント作成にGoogleカレンダーは使いたくありません。信用していないからです。

まともな人なら誰でもそうするように、ターミナルからカレンダーの予定を作れる方法が欲しかったのです。

前回、仲間にそう持ちかけてみました。返ってきた答えは「よくわからないけど、問題を探している解決策みたいだね」でした。まあ、よく言うでしょう。ヒトデに道を尋ねるな、と。

言うより見せる

その夜、家に帰ってGETパラメータからカレンダーの予定を生成するウェブサイトを作りました。

これを使えば、コマンドラインから手軽にカレンダーイベントを作成できます。

> curl https://zerocal.shuttleapp.rs?start=2022-11-04+20:00&duration=3h&title=Birthday&description=paaarty
BEGIN:VCALENDAR
VERSION:2.0
PRODID:ICALENDAR-RS
CALSCALE:GREGORIAN
BEGIN:VEVENT
DTSTAMP:20221002T123149Z
CLASS:CONFIDENTIAL
DESCRIPTION:paaarty
DTEND:20221002T133149Z
DTSTART:20221002T123149Z
SUMMARY:Birthday
UID:c99dd4bb-5c35-4d61-9c46-7a471de0e7f4
END:VEVENT
END:VCALENDAR

あとはファイルに保存して、カレンダーアプリで開くだけです。

> curl https://zerocal.shuttleapp.rs?start=2022-11-04+20:00&duration=3h&title=Birthday&description=paaarty > birthday.ics
> open birthday.ics

ある意味、これは「サーバーレスカレンダーアプリ」です(笑)。サーバー側に状態はなく、その場でカレンダーイベントを生成して返すだけなのです。

作り方

URLに「shuttleapp.rs」が含まれていることにお気づきかもしれません。shuttle.rsでサイトをホスティングしているからです。

ShuttleはRustプロジェクト向けのホスティングサービスで、前からずっと試してみたかったのです。

素晴らしいaxumウェブフレームワークを使ってプロジェクトを初期化するために、次のコマンドを使いました。

cargo install cargo-shuttle
cargo shuttle init --axum --name zerocal zerocal

すると、始めるのに必要なものがすべて用意されていました。

use axum::{routing::get, Router};
use sync_wrapper::SyncWrapper;

async fn hello_world() -> &'static str {
  "Hello, world!"
}

#[shuttle_service::main]
async fn axum() -> shuttle_service::ShuttleAxum {
  let router = Router::new().route("/hello", get(hello_world));
  let sync_wrapper = SyncWrapper::new(router);

  Ok(sync_wrapper)
}

変更をさっとコミットしましょう。

git add .gitignore Cargo.toml src/
git commit -m "Hello World"

コードをデプロイするには、shuttleのアカウント登録が必要です。登録はhttps://www.shuttle.rs/loginからできます。

GitHubアカウントへのアクセス許可を求められます。

そして、

cargo shuttle login

最後に、

cargo shuttle deploy

では、zerocal.shuttleapp.rsにアクセスしてみましょう。

Hello World!

最初のバージョンのデプロイは5分もかかりませんでした。素晴らしいですね!これで自作カレンダーアプリの準備は完了です。

アプリを書く

カレンダーイベントの作成には、icalendarクレートを使いました(素晴らしいライブラリを作ってくれたhoodieさんに感謝!)。iCalendarは、ほとんどのカレンダーアプリでサポートされているカレンダーイベント作成の標準規格です。

cargo add icalendar
cargo add chrono # For date and time parsing

デモ用のカレンダーイベントを作ってみましょう。

let event = Event::new()
  .summary("test event")
  .description("here I have something really important to do")
  .starts(Utc::now())
  .ends(Utc::now() + Duration::days(1))
  .done();

とてもシンプルです。

ファイルを返すには!?

カレンダーイベントができたので、ユーザーに返す必要があります。でも、どうやってファイルとして返すのでしょうか。

axumでファイルを動的に返す方法の例がこちらにあります。

async fn calendar() -> impl IntoResponse {
  let ical = Calendar::new()
    .push(
      // add an event
      Event::new()
        .summary("It works! 😀")
        .description("Meeting with the Rust community")
        .starts(Utc::now() + Duration::hours(1))
        .ends(Utc::now() + Duration::hours(2))
        .done(),
    )
    .done();

  CalendarResponse(ical)
}

ここで注目すべきポイントがいくつかあります。

  • カレンダーファイルはイベントのコレクションなので、イベントをコレクションを表すCalendarオブジェクトでラップします。
  • impl IntoResponseは、それを実装したあらゆる型を返せるようにするトレイトです。
  • CalendarResponseは、IntoResponseを実装したCalendarnewtypeラッパーです。

こちらがCalendarResponseの実装です。

/// Newtype wrapper around Calendar for `IntoResponse` impl
#[derive(Debug)]
pub struct CalendarResponse(pub Calendar);

impl IntoResponse for CalendarResponse {
  fn into_response(self) -> Response {
    let mut res = Response::new(boxed(Full::from(self.0.to_string())));
    res.headers_mut().insert(
      header::CONTENT_TYPE,
      HeaderValue::from_static("text/calendar"),
    );
    res
  }
}

新しいResponseオブジェクトを作成し、Content-TypeヘッダーをiCalendarファイル用の正しいMIMEタイプであるtext/calendarに設定して、レスポンスを返しているだけです。

日付のパースを追加する

この部分は少し強引なので、ざっと目を通すだけで大丈夫です。クエリ文字列から日付と期間をパースする必要があります。dateparserを使いました。本当にたくさんの日付フォーマットに対応しているからです。

async fn calendar(Query(params): Query<HashMap<String, String>>) -> impl IntoResponse {
  let mut event = Event::new();
  event.class(Class::Confidential);

  if let Some(title) = params.get("title") {
    event.summary(title);
  } else {
    event.summary(DEFAULT_EVENT_TITLE);
  }
  if let Some(description) = params.get("description") {
    event.description(description);
  } else {
    event.description("Powered by zerocal.shuttleapp.rs");
  }

  if let Some(start) = params.get("start") {
    let start = dateparser::parse(start).unwrap();
    event.starts(start);
    if let Some(duration) = params.get("duration") {
      let duration = humantime::parse_duration(duration).unwrap();
      let duration = chrono::Duration::from_std(duration).unwrap();
      event.ends(start + duration);
    }
  }

  if let Some(end) = params.get("end") {
    let end = dateparser::parse(end).unwrap();
    event.ends(end);
    if let Some(duration) = params.get("duration") {
      if params.get("start").is_none() {
        let duration = humantime::parse_duration(duration).unwrap();
        let duration = chrono::Duration::from_std(duration).unwrap();
        event.starts(end - duration);
      }
    }
  }

  let ical = Calendar::new().push(event.done()).done();

  CalendarResponse(ical)
}

nowtomorrowのようなフォーマットにも対応できると良いのですが、それはまた別の機会にします。

試してみましょう。

> cargo shuttle run # This starts a local dev server
> curl 127.0.0.1:8000?start=2022-11-04+20:00&duration=3h&title=Birthday&description=Party
*🤖 bleep bloop, calendar file created*

いい感じです、動きました!

ブラウザで開くと、カレンダーに新しいイベントが作成されます。

もちろんChromeでも動きますが、あなたはオープンウェブを支援していますよね?
もちろんChromeでも動きますが、あなたはオープンウェブを支援していますよね?

そして、ターミナルでカレンダーイベントを作らない変わった人たちのためにも、ウェブサイトにフォームを追加しましょう。

フォームを追加する

<form>
  <table>
    <tr>
      <td>
        <label for="title">Event Title</label>
      </td>
      <td>
        <input type="text" id="title" name="title" value="Birthday" />
      </td>
    </tr>
    <tr>
      <td>
        <label for="desc">Description</label>
      </td>
      <td>
        <input type="text" id="desc" name="desc" value="Party" />
      </td>
    </tr>
    <tr>
      <td><label for="start">Start</label></td>
      <td>
        <input type="datetime-local" id="start" name="start" />
      </td>
    </tr>
    <tr>
      <td><label for="end">End</label></td>
      <td>
        <input type="datetime-local" id="end" name="end" />
      </td>
    </tr>
  </table>
</form>

calendar関数を少し修正して、クエリ文字列が空の場合はフォームを返すようにしました。

async fn calendar(Query(params): Query<HashMap<String, String>>) -> impl IntoResponse {
  // if query is empty, show form
  if params.is_empty() {
    return Response::builder()
      .status(200)
      .body(boxed(Full::from(include_str!("../static/index.html"))))
      .unwrap();
  }

  // ...
}

さらに少し調整して、web 1.0らしい味わいのある素敵なフォームができました。

フォーム
フォーム

これで完成です!カレンダーイベントを作成できる小さなウェブアプリができました。まあ、あと一歩です。まだデプロイする必要があります。

デプロイ

cargo shuttle deploy

はい、これだけです。本当にこれだけで簡単です。これを可能にしてくれたshuttle.rsの皆さんに感謝します。

カレンダーアプリはzerocal.shuttleapp.rsで公開されています。

これでようやく、次のパブ巡りのカレンダーイベントへのリンクを友人たちに送れます。きっと喜んでくれるはずです。たぶんたぶん

ゼロから100行のRustでカレンダーまで

久しぶりに素のHTMLを書くのは本当に気持ちがいいですね。
小さなアプリを作るのは何度やっても飽きません。

ソースコードはGitHubで公開しています。ぜひ改善にご協力ください! 🙏

アイデアをいくつか挙げておきます。

  • ✅ 場所のサポートを追加(例: location=Berlinlocation=https://zoom.us/test)。sigaloidさん、ありがとうございます。
  • より人間が読みやすい日付フォーマットのサポートを追加(例: nowtomorrow)。
  • 繰り返しイベントのサポートを追加。
  • タイムゾーンのサポートを追加。
  • Googleカレンダーの短縮リンクを追加(https://calendar.google.com/calendar/render?action=TEMPLATE&dates=20221003T224500Z%2F20221003T224500Z&details=&location=&text=)。
  • コマンドラインからカレンダーイベントを作成するサンプルbashコマンドを追加。
  • URLを短縮(例: zerocal.shuttleapp.rs/2022-11-04T20:00/3h/Birthday/Party)?

issueトラッカーをチェックして、気軽にPRを送ってください!

原文は Matthias Endler により に公開されました。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。