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

Matthias Endler

zerocal - shuttle.rs에서 돌아가는 Rust 서버리스 캘린더 앱

원문은 Matthias Endler님이 에 게재했습니다. 이 블로그 구독하기

가끔 친구들과 저녁 식사를 함께 한다. 그 저녁 시간 자체는 소중하지만, 가장 힘든 건 일정을 잡는 일이다!

단체 채팅방에 메시지를 보낸다.
답장을 기다린다.
날짜를 정한다.
누군가 캘린더 초대를 보낸다.
마침내 일이 성사된다.

저녁 식사 자체를 빼면 그 과정 중 즐거운 건 하나도 없다.

상식적인 사람이라면 이렇게 생각할 것이다: “그냥 스케줄링 앱을 쓰면 되지 않아?”.

나도 여러 앱을 써봤다. 하나같이 별로였다. 전부… 과하다!

그냥 초대장을 보내고, 오고 싶은 사람이 오면 되는 거 아닌가.

  • 캘린더/스케줄링/뭐시기 앱을 쓰자고 계정을 만들고 싶지 않다.
  • 친구들을 등록하고 싶지 않다.
  • 친구의 친구를 등록하고 싶지 않다.
  • 친구의 친구의 친구까지 등록하고 싶지 않다.
  • 무슨 말인지 알겠지: 그냥 초대장을 보내고 당신한테서 아무 답도 받지 않고 싶을 뿐이다.

괴짜 내향형 엔지니어의 해법

💡 우리에게 정말 필요한 건 이벤트를 만들고 그 이벤트 링크로 초대를 보낼 수 있는 또 하나의 캘린더 앱이다! 전혀 예상 못 했지?

아, 그리고 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는 이를 구현한 어떤 타입이든 반환할 수 있게 해주는 트레이트다.
  • CalendarResponseCalendar를 감싸 IntoResponse를 구현한 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)
}

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

  // ...
}

조금 더 다듬고 나니 웹 1.0 감성이 물씬 풍기는 근사한 폼이 완성됐다:

폼

이게 전부다! 이제 캘린더 이벤트를 만들 수 있는 작은 웹 앱이 생겼다. 거의 다 됐다. 아직 배포만 남았다.

배포하기

cargo shuttle deploy

그게 전부다. 이렇게 간단하다. 이를 가능하게 해준 shuttle.rs 분들에게 감사드린다.

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

이제 다음 펍 크롤을 위한 캘린더 이벤트 링크를 친구들에게 드디어 보낼 수 있다. 분명 고마워할 것이다.그러겠지그러겠지

0에서 100줄의 Rust로 만드는 캘린더

다시 간결한 HTML을 작성하니 기분이 좋다.
작은 앱을 만드는 일은 언제나 즐겁다.

GitHub에서 소스 코드를 확인하고 더 좋게 만드는 데 도와주세요! 🙏

몇 가지 아이디어는 다음과 같다:

  • ✅ 장소 지원 추가 (예: location=Berlin 또는 location=https://zoom.us/test). sigaloid님 감사합니다.
  • 더 읽기 쉬운 날짜 형식 지원 추가 (예: now, tomorrow).
  • 반복 이벤트 지원 추가.
  • 시간대 지원 추가.
  • 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)?

이슈 트래커를 확인하고 PR을 자유롭게 열어 주세요!

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.

댓글