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

Matthias Endler

zerocal - 一个用 Rust 编写、运行在 shuttle.rs 上的无服务器日历应用

每隔一段时间,我和朋友们会聚在一起吃顿晚饭。我很珍惜这些夜晚,但最糟糕的部分就是安排这些聚会!

我们在群里发一条消息。
等待回复。
定下日期。
有人发出日历邀请。
事情总算办成了。

这一切都不好玩,除了那顿晚饭。

作为一个通情达理的人,你可能会想:“为什么不直接用一个日程安排应用呢?”

我试过很多这类应用。没有一个好用。它们全都……太复杂了

让我发个邀请,谁想来就来,不就行了吗。

  • 不想为你的日历/日程/随便什么应用注册账号。
  • 不想添加我的朋友。
  • 不想添加我朋友的朋友。
  • 不想添加我朋友的朋友的朋友。
  • 你懂的:我只想发个邀请,然后不用再管你的任何回应。

书呆子内向工程师的解决方案

💡 我们绝对需要的是又一个日历应用,它可以创建活动并发送带有该活动链接的邀请!你肯定没想到吧?

哦对了,我不想用 Google 日历来创建活动,因为信任他们

像任何一个通情达理的人一样,我希望有一种能从终端创建日历条目的方法。

上次我就是这么向朋友们推销这个想法的。他们的回答是:“不知道,听起来像是为一个不存在的问题寻找解决方案。”不过你知道人们常说的:永远不要问海星问路。

Show, don't tell(做出来,别光说)

那天晚上我回到家,做了一个网站,可以通过 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 Web 框架初始化项目,我使用了:

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 # 用于日期和时间解析

我们来创建一个演示用的日历活动:

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 的一个newtype 包装器,它实现了 IntoResponse

下面是 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。然后返回这个响应。

添加日期解析

这部分有点 hacky,所以你可以随意略过。我们需要从查询字符串中解析日期和时长。我用了 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 # 这会启动一个本地开发服务器
> 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 光辉的漂亮小表单:

表单
表单

就这样!我们现在有了一个能创建日历活动的小 Web 应用。嗯,差不多吧。我们还需要部署它。

部署

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 发布

本文章由 stealth/ox-alpha 进行翻译