0
1

AWS LambdaとAWS Amplifyを使ってカレンダーアプリを作った

Last updated at Posted at 2023-10-11

はじめに

カレンダーを作った話のあと、やっぱりAPIとして生やしたくなりますね。
オレオレカレンダーAPIを生やしてしまえば、改造したく成ったら勝手に改造できるし、他人様のAPI使うよりも便利なはずなので(EOLも自分で決められるし)QoL最高だなと思ったのでやってみた。

カレンダーの記事は以下を参照してください。

え?

  • 過去の祝日が考慮されてない?
  • 出来事に応じて祝日が追加されない?

オープンソースの強みを活かしましょう!

やりたいこと

  • LambdaにカレンダーAPIを生やす
    • {"year":2023,"month":6}みたいなリクエストをPOSTで送る
    • 第N週とか第N月曜日とか、めんどい系の処理をしなきゃいけない情報はまるっと載せてJSONでレスポンスする。
    • 1月まるっと取ってLocalStorageにキャッシュしとけばいい。
    • 特定の年をまるっと送る みたいなAPIは今回は対象外とする。
      • 必要があれば最初は当月だけ取得して、周辺の月は裏で遅延アクセスしておけばよさそう。
      • 一気に12ヶ月分取るよりもUIのレスポンスがサクサクして良さげですね。
  • Amplifyにフロントを生やす
    • アクセスしてきたシステムのブラウザから日時情報(年/月)を取得して、1ヶ月間のカレンダーを表示する
    • 月の切り替えみたいなめんどくs高尚な機能はこの記事の範囲外とする

AWSのアカウントを作る

作ってないから作らないといかんのですよ。
とりあえず作るだけ作ります。

LambdaにAWSのアカウント作ってデプロイするまで

1. AWSのアカウントを作る

何はともあれ作りましょう。無いと作業ができません。

2. Lambda関数を作る

APIのエンドポイントを作るための作業です。

項目 設定 理由
関数名 calender-api Rustのプロジェクト名と合わせないとデプロイできません
ランタイム Amazon Linux2でユーザ独自のブートストラップを提供する Rustはカスタムランタイムを使うためです
アーキテクチャ arm64 安いのでw

image.png

これ以外の項目は操作しません

3. aws cliをインストールする

以下のコマンドをPowershellで叩きます。(コマンドプロンプトでもいいかも)
msiexec.exe /i https://awscli.amazonaws.com/AWSCLIV2.msi

4. IAMユーザを作る(IAM: Identity and Access Management)

  • 権限なしアカウントを一旦作っておく
  • https://us-east-1.console.aws.amazon.com/iamv2/home#/users
  • 「インラインポリシー」のエディタに以下を投入する
lambda-deploy-iam_policy.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "lambda:CreateFunction",
                "lambda:UpdateFunctionCode",
                "lambda:TagResource",
                "lambda:GetLayerVersion",
                "lambda:GetFunction",
                "lambda:UpdateFunctionConfiguration",
                "lambda:PublishVersion",
                "iam:CreateRole",
                "iam:AttachRolePolicy",
                "iam:UpdateAssumeRolePolicy",
                "iam:PassRole"
            ],
            "Resource": "*",
            "Condition": {
                "BoolIfExists": {
                    "aws:MultiFactorAuthPresent": "true"
                }
            }
        }
    ]
}

5. アクセスキーを作る(IAM>ユーザ> <作成したユーザ名>)

セキュリティ認証情報 > アクセスキー(画面下部にスクロールする)> アクセスキーを作成 ボタンをクリックする

6. aws configureを実行する

この作業で上記で作成したポリシーのアクセスキー情報とリージョン名、フォーマット(json)を設定する。これをやらないとトークンが紐づかないのでデプロイできない。

項目 設定内容
AWS Access Key ID アクセスキー情報から取得する
AWS Secret Access Key アクセスキー情報から取得する
Default region name ap-northeast-1
Default output format json

7. Rustのプロジェクトを作る

cargo lambda コマンドは以下を参照してください

実行するとこんな感じになります(日本語訳は後述します)

$ cargo lambda new calender-api
Is this function an HTTP function? Yes
 [type `yes` if the Lambda function is triggered by an API Gateway, Amazon Load Balancer(ALB), or a Lambda URL]

 Which service is this function receiving events from? Amazon Api Gateway HTTP Api
  I don't know yet
  Amazon Elastic Application Load Balancer (ALB)
> Amazon Api Gateway REST Api
  Amazon Api Gateway HTTP Api
  Amazon Api Gateway Websockets
  AWS Lambda function URLs

日本語訳すると、こんなことを聞かれている
Q1. HTTP関数を作りますか?
A1. HTTP経由で叩く。つまりAPI Gatewayから叩かれることを想定しているのでYesとします。

Q2. このサービスはどのような方式で使用されますか?
A2. Amazon Api Gateway REST Apiで利用する

REST Apiのほうが後々認証を噛ませたりできて高機能なので、とりあえず選んでおくとします。
機能少なくていいよ!であればHTTP Apiで良いと思います。

8. APIの口を作ってビルドする

ARM用の機械語を生成するので引数に--arm64をつける
cargo lambda build --release --arm64

9. デプロイする

Lambda関数と、Rustのプロジェクト名を一致させます。
その名前を第3引数に指定します。
cargo lambda deploy calender-api

10. API Gatewayをトリガに設定する

API Gatewayを実行のトリガにすることで、Lambdaの結果をAPIとして叩けるようになります。
image.png

11. CORSを設定する

実はここまでの手順でできたと思うわけですが、実際はフロントからAPIを呼び出すときに
Webブラウザが「このAPIはオリジンが違うから呼ばない!」と言う判断をしてしまいます。
これをクロスオリジン制約というセキュリティの機能になります。

この制限を回避するために、以下のような設定をします。

設定項目 設定内容 意味
Access-Control-Allow-Origin * アクセス元オリジンの制限
Access-Control-Allow-Headers * 許可するヘッダ
Access-Control-Allow-Methods POST,OPTIONS 許可するメソッド。OPTIONSを許可している理由はプリフライトリクエストを許可するためです。許可しないとブラウザはAPIへアクセスできません。

image.png

セキュリティの小ネタ

今回は無条件に Access-Control-Allow-Origin: *を付ける設定をしますが、これは以下の条件を満たすからです。

  • 認証トークンを持ち回ってアクセスするAPIではない
  • どこから、誰でも無許可で見ていい
  • 一言でいうと:このAPIは大した情報を扱っていない

から、アクセス元オリジンをすべて許可しています。
以下のようなページでは絶対に真似しないでください。

  • Webブラウザも利用するAPIである場合
  • 認証後のページから呼び出されるAPIである場合
  • 認証された人しかアクセスできないデータを読み出す(新規作成を含む)、または書き込む(削除を含む)APIである場合

このような場合は、確実にオリジンの制限をしたいはずなのでACAOヘッダでアクセス元オリジンを限定してください。
また、Originヘッダを基に動的にACAOヘッダを変更する場合は、正規表現でのフィルタを用いずにURLパーサを使用することを推奨します。
(徳丸本は正規表現を推奨していますが、正規表現はマジでダメです。方向性は間違ってませんが、複雑なプロダクトコードに入れるとすると普通にシクります。ちゃんとしたURLパーサでパースしましょう。)
サードパーティページから認証アクセスが必要であれば、別途許可Originを柔軟に設定できる設計を追加しましょう。

Webフロントを作る

WebフロントもRustで作りましょう!
Reactっぽく作れるYewというフレームワークが有るので、それを使います!

フロント構成

ツール 名称
ビルドツール Trunk
DOM操作フレームワーク Yew 0.20.0
CSSフレームワーク materialize

インストール方法や利用方法は各ツールのリファレンスを参照してください。

ディレクトリ構成

image.png

index.htmlの中身

テンプレートが必要なので簡単なテンプレートを書いておきます。
Initialize関数はmatelialize cssを初期化するためのものです。
ただし、即時実行関数では有りません。
Rust側から叩かないとDOMがクリアされてしまい動かなくなります。
そのため、あえて即時実行関数でなくしています。

<!doctype html>
<html lang="en">

<head>
    <meta charset="utf-8">
    <title>Yew Sample App</title>
    <!--Import Google Icon Font-->
    <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
    <!-- Compiled and minified CSS -->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
    <!--Let browser know website is optimized for mobile-->
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script>
</head>

<body>
    <script>
        function Initialize() {
            var el = document.querySelector('.tabs');
            var instance = M.Tabs.init(el, {});
        }
    </script>
</body>

</html>

Rust側のコード

javascript側の初期化コードを呼び出すFFIコード

FFI: foreign function interface

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_name = Initialize)]
    fn init();
}

#[derive(PartialEq, Properties)]
pub struct ScriptInitProps {}

#[function_component]
pub fn ScriptInit(props: &ScriptInitProps) -> Html {
    let ScriptInitProps {} = props;
    use_effect_with_deps(
        move |_| {
            // ページが呼び出されたらJS側の初期化コードを実行する
            init();
            || {}
        },
        (),
    );
    html! {}
}

メイン画面の構成

タブ毎にページが開かれる形になっており、4番目のタブにカレンダーを実装します。
matelialized cssのタブ機能を使ってみたく成ったと言う理由が一番強いです。
本質では有りませんので1番目に乗せても良いですし、むしろタブ機能を使わなくても良いです。

#[derive(Default, PartialEq, Properties)]
pub struct AppProps {}

#[function_component]
pub fn App(props: &AppProps) -> Html {
    let AppProps {} = props;
    let state_change = Callback::from(move |checked| {});
    let now = Local::now();
    html! {
        <div class="container">
            <Navigate />
            <div id="test1" class="col s12"></div>
            <div id="test2" class="col s12"></div>
            <div id="test3" class="col s12">{"Test 3"}</div>
            <div id="test4" style="position:relative" class="col s12">
                <Calender year={now.year()} month={now.month()} />
            </div>
            <ScriptInit />
        </div>
    }
}

use wasm_bindgen::prelude::*;
#[wasm_bindgen(start)]
pub fn rust_entry() {
    wasm_logger::init(wasm_logger::Config::default());
    yew::Renderer::<App>::new().render();
}

カレンダーのページ

#[derive(Debug, Deserialize, Serialize, PartialEq)]
pub enum HolidayKind {
    Weekday = 0,
    PublicHoliday = 1,
    NationalHoliday = 2,
}
impl std::fmt::Display for HolidayKind {
    fn fmt(&self, f: &mut __private::Formatter<'_>) -> std::fmt::Result {
        let d = match &self {
            HolidayKind::NationalHoliday => "NationalHoliday",
            HolidayKind::PublicHoliday => "PublicHoliday",
            HolidayKind::Weekday => "Weekday",
        };
        write!(f, "{}", d)
    }
}

// Lambdaで作ったAPIから情報を受け取る構造体
#[derive(Debug, Deserialize, Serialize, PartialEq)]
pub struct ApiResponse {
    year: i32,
    month: u32,
    days: Vec<DayInfo>,
}
// 日単位の情報
#[derive(Debug, Deserialize, Serialize, PartialEq)]
struct DayInfo {
    day: u32,
    day_of_week: u32,
    holiday_str: String,
    holiday_kind: HolidayKind,
}

impl Default for ApiResponse {
    fn default() -> Self {
        ApiResponse {
            year: 0,
            month: 1,
            days: vec![DayInfo::default()],
        }
    }
}

impl Default for DayInfo {
    fn default() -> Self {
        DayInfo {
            day: 0,
            day_of_week: 0,
            holiday_str: "".to_owned(),
            holiday_kind: HolidayKind::Weekday,
        }
    }
}

#[derive(PartialEq, Properties)]
pub struct CalenderProps {
    year: i32,
    month: u32,
}
// 土曜は青、日曜・祝日は赤、それ以外は黒とする。CSS文字列を生成する。
fn day_color(day: &DayInfo) -> String {
    format!(
        "color: {};",
        match day.holiday_kind {
            HolidayKind::Weekday => "black",
            HolidayKind::NationalHoliday => "red",
            HolidayKind::PublicHoliday => {
                if day.day_of_week == 0 {
                    "red"
                } else {
                    "blue"
                }
            }
        }
    )
}

// 日によって描画する場所を決定する(カレンダーの1セル1セルの描画箇所を計算する)
fn day_to_point(day: &DayInfo, start: u32) -> (u32, u32) {
    if day.day == 0 {
        return (0, 0);
    }
    (day.day_of_week * 100, ((day.day + start - 1) / 7) * 80)
}
// pointに対してオフセットを足すだけの関数
fn day_to_point_add(point: (u32, u32), point_add: (u32, u32)) -> (u32, u32) {
    (point.0 + point_add.0, point.1 + point_add.1)
}

// pointからCSS文字列を生成する。
fn point_to_css(point: (u32, u32)) -> String {
    format!("position:absolute; left:{}px; top:{}px;", point.0, point.1)
}

use gloo_net::http::Request;
#[function_component]
pub fn Calender(props: &CalenderProps) -> Html {
    let cprops = CalenderProps {
        year: props.year,
        month: props.month,
    };
    let storage = format!("calender_{}_{}", cprops.year, cprops.month);
    let response = use_state_eq(|| ApiResponse::default());
    let resp = response.clone();
    use_effect_with_deps(
        move |_| match gloo_storage::LocalStorage::get(&storage) {
            Ok(calender) => resp.set(calender),
            Err(_) => {
                wasm_bindgen_futures::spawn_local(async move {
                    let res:ApiResponse = Request::post("https://<APIのドメイン>/default/calender-api")
                        .header("Content-Type", "application/json")
                        .body(format!("{{\"year\":{},\"month\":{}}}",cprops.year,cprops.month)).send().await.unwrap().json().await.unwrap();
                    let _ = gloo_storage::LocalStorage::set(&storage, &res);
                    resp.set(res);
                });
            }
        },
        (),
    );
    let data = response;
    let start = data.days[0].day_of_week;
    html! {
        <div>
            <div>
            {[("日","red"),("月","black"),("火","black"),("水","black"),("木","black"),("金","black"),("土","blue")].iter().enumerate().map(|(i,wd)|html!(
                <span style={format!("color:{}; {}",wd.1,point_to_css((i as u32*100,0)))}>{wd.0}</span>
            )).collect::<Html>()}
            </div>
            <div style="position:relative; top:30px">
            {data.days.iter().map(|res|{html!{
                <div>
                <span style={format!("{} {}",day_color(&res),point_to_css(day_to_point(&res,start)))}>{res.day}</span>
                <span style={format!("{} {}",day_color(&res),point_to_css(day_to_point_add(day_to_point(&res,start),(0,20))))}>{&res.holiday_str}</span>
                </div>}}).collect::<Html>()}
            </div>
        </div>
    }
}

ビルドしましょう

trunk build --release

ビルドすると、distフォルダにhtmlとjsとwasmが生成されるはずです(もしかするとYewのVerupで変わるかも)
image.png

このフォルダをzipで固めます

image.png
image.png

zipの中身

固めた内容が入っていれば成功です!
image.png

デプロイしてみる

AWS Amplifyコンソールを開きます。

適当にピッピとアプリを作ります。
image.png

「ウェブアプリケーションをホスト」を選びます
image.png

image.png

今回は、先程固めたzipファイルを投入してデプロイするので「ドラッグアンドドロップ」を選びます。
image.png

デプロイが完了しました。
image.png

完成したところ

image.png

おわりに

自分で生やしたAPIを使ってAmplifyにフロントを生やすだけの記事でした。
ただ、Rustだけでやってる人は居なかったので書いてみた感じです。

Rustはいいぞ~
バックエンドもフロントエンドもネイティブアプリもOSも全部同じ言語で書けちゃう!
フルスタックエンジニア向けの言語ですね!(ぐるぐる目)

それではこんなところで~!

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1