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

Matthias Endler

zerocal — 以 Rust 打造、運行於 shuttle.rs 的 Serverless 行事曆應用程式

每隔一段時間,我和朋友們會相約吃晚餐。我很珍惜這些夜晚,但最糟的部分就是安排這些聚會!

我們在群組裡發訊息。
等待回覆。
決定日期。
有人發出行事曆邀請。
事情才總算敲定。

除了晚餐本身,其他過程一點都不有趣。

身為講道理的人,你可能會想:「為什麼不直接用個排程應用程式就好?」。

我試過很多,沒有一個好用。全部都……太複雜了

就讓我發個邀請,想來的人就來就好。

  • 不想為了你的行事曆/排程/什麼鬼應用程式去註冊帳號。
  • 不想還得把朋友一個個加進去。
  • 不想還得加朋友的朋友。
  • 不想還得加朋友的朋友的朋友。
  • 你懂我的意思:我只想發個邀請,然後就算你不回覆也沒關係。

書呆子內向工程師的解法

💡 我們絕對需要的,就是再多一個行事曆應用程式,讓我們可以建立活動並用連結發送邀請!你大概沒想到會是這個吧?

還有,我不想用 Google Calendar 來建立活動,因為 信任他們

像任何講道理的人一樣,我想要一個能直接從終端機建立行事曆項目的方法。

上次我就是這樣跟朋友們提案的。他們的回應是:「不知道耶,聽起來像是為了找問題而想出的解法。」但你知道大家怎麼說的:別問海星怎麼指路。

與其空談,不如直接展示

那天晚上我回家做了一個網站,可以透過 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

某種程度上,這算是個「Serverless(無伺服器)行事曆應用程式」,哈哈。伺服器上沒有任何狀態,它只是即時產生行事曆活動並回傳。

我是怎麼打造它的

你可能已經注意到網址裡有「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 crate(感謝 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 是一個 trait,讓我們可以回傳任何實作它的型別。
  • CalendarResponse 是一個包在 Calendar 外層並實作 IntoResponsenewtype wrapper(新類型包裝)

以下是 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 上使用。

現在我終於可以傳個行事曆活動連結給朋友們,約下一次的酒吧夜遊。他們一定會很感激的。yeahyeah

用 100 行 Rust 從零到行事曆

能再次寫些純 HTML 感覺真好。
打造小應用程式永遠不嫌膩。

快去 GitHub 上看看原始碼,幫我把它變得更好吧!🙏

這裡有一些點子:

  • ✅ 加入地點支援(例如 location=Berlinlocation=https://zoom.us/test)。感謝 sigaloid
  • 支援更多人類可讀的日期格式(例如 nowtomorrow)。
  • 支援重複發生的活動。
  • 支援時區。
  • 加入 Google Calendar 短連結(https://calendar.google.com/calendar/render?action=TEMPLATE&dates=20221003T224500Z%2F20221003T224500Z&details=&location=&text=)。
  • 加入從命令列建立行事曆活動的 Bash 範例指令。
  • 縮短網址(例如 zerocal.shuttleapp.rs/2022-11-04T20:00/3h/Birthday/Party)?

快去看看 issue tracker(問題追蹤),也歡迎發 PR!

原文由 Matthias Endler 發布

本文章由 muse-spark-1.2-contributor 進行翻譯