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

Matthias Endler

zerocal - shuttle.rs에서 실행되는 Rust 기반 서버리스 캘린더 앱

가끔 친구들과 저녁을 먹으러 만납니다. 이런 저녁 시간을 소중히 여기지만, 최악인 부분은 바로 약속을 잡는 일입니다!

단체 메시지를 보냅니다.
답장을 기다립니다.
날짜를 정합니다.
누군가 캘린더 초대를 보냅니다.
드디어 일이 성사됩니다.

저녁 식사만 빼면 그 어느 것도 재미없습니다.

합리적인 사람이라면 이렇게 생각할 법합니다. “그냥 일정 조율 앱을 쓰면 되잖아?”

많이 써 봤습니다. 어느 것도 좋지 않았습니다. 전부… 과합니다!

그냥 초대를 보내고, 오고 싶은 사람은 오게 해 주세요.

  • 당신의 캘린더/일정 관리/뭐 그런 앱을 쓰려고 계정을 만들고 싶지 않습니다.
  • 친구를 추가하고 싶지 않습니다.
  • 친구의 친구를 추가하고 싶지 않습니다.
  • 친구의 친구의 친구를 추가하고 싶지 않습니다.
  • 무슨 말인지 아시겠죠. 그냥 초대장을 보내고, 당신에게서는 아무 답장도 받지 않고 싶습니다.

내향적인 너드 엔지니어의 해결책

💡 우리에게 정말 필요한 건 이벤트를 만들고 그 이벤트 링크가 담긴 초대장을 보낼 수 있는 또 다른 캘린더 앱입니다! 이런 전개는 예상 못 하셨죠?

아, 그리고 이벤트를 만들 때 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

어떤 의미에서는 “서버리스 캘린더 앱”입니다, 하하. 서버에 상태가 없고, 즉석에서 캘린더 이벤트를 생성해 반환할 뿐입니다.

만든 방법

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는 이를 구현한 어떤 타입이든 반환할 수 있게 해 주는 트레이트입니다.
  • CalendarResponseIntoResponse를 구현하는 Calendar 주변의 newtype 래퍼입니다.

다음은 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)
}

now, tomorrow처럼 더 많은 날짜 형식을 지원하면 좋겠지만, 그건 다음에 하겠습니다.

테스트해 봅시다.

> 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();
  }

  // ...
}

조금 더 다듬으니 웹 1.0의 영광을 온전히 간직한 근사한 작은 폼이 생겼습니다.

폼

이게 전부입니다! 이제 캘린더 이벤트를 만들 수 있는 작은 웹 앱이 생겼습니다. 음, 거의요. 아직 배포해야 합니다.

배포

cargo shuttle deploy

네, 그게 전부입니다. 이렇게 쉽습니다. 이를 가능하게 해 준 shuttle.rs 팀에게 감사드립니다.

이제 캘린더 앱은 zerocal.shuttleapp.rs에서 이용할 수 있습니다.

이제 다음 펍 크롤을 위한 캘린더 이벤트 링크를 드디어 친구들에게 보낼 수 있습니다. 분명 좋아하겠죠.그러겠지그러겠지

Rust 100줄로 캘린더 만들기

다시 평범한 HTML을 쓰니 기분이 참 좋습니다.
작은 앱을 만드는 일은 언제나 즐겁습니다.

GitHub에서 소스 코드를 확인하고, 더 나아지도록 도와주세요! 🙏

몇 가지 아이디어가 있습니다.

  • ✅ 위치 지원 추가(예: location=Berlin 또는 location=https://zoom.us/test). sigaloid에게 감사드립니다.
  • 더 사람이 읽기 쉬운 날짜 형식 지원 추가(예: now, tomorrow).
  • 반복 이벤트 지원 추가.
  • 시간대 지원 추가.
  • Google Calendar 짧은 링크 추가(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)?

이슈 트래커를 확인하고, 부담 없이 PR을 열어 주세요!

원문은 Matthias Endler님이 에 게재했습니다.

이 글은 gpt-5.6-terra 모델을 사용해 번역했습니다.