はじめに
カレンダーを作った話のあと、やっぱり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 |
これ以外の項目は操作しません
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
- 「インラインポリシー」のエディタに以下を投入する
{
"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として叩けるようになります。
11. CORSを設定する
実はここまでの手順でできたと思うわけですが、実際はフロントからAPIを呼び出すときに
Webブラウザが「このAPIはオリジンが違うから呼ばない!」と言う判断をしてしまいます。
これをクロスオリジン制約というセキュリティの機能になります。
この制限を回避するために、以下のような設定をします。
設定項目 | 設定内容 | 意味 |
---|---|---|
Access-Control-Allow-Origin | * | アクセス元オリジンの制限 |
Access-Control-Allow-Headers | * | 許可するヘッダ |
Access-Control-Allow-Methods | POST,OPTIONS | 許可するメソッド。OPTIONSを許可している理由はプリフライトリクエストを許可するためです。許可しないとブラウザはAPIへアクセスできません。 |
セキュリティの小ネタ
今回は無条件に 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 |
インストール方法や利用方法は各ツールのリファレンスを参照してください。
ディレクトリ構成
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で変わるかも)
このフォルダをzipで固めます
zipの中身
デプロイしてみる
AWS Amplifyコンソールを開きます。
今回は、先程固めたzipファイルを投入してデプロイするので「ドラッグアンドドロップ」を選びます。
完成したところ
おわりに
自分で生やしたAPIを使ってAmplifyにフロントを生やすだけの記事でした。
ただ、Rustだけでやってる人は居なかったので書いてみた感じです。
Rustはいいぞ~
バックエンドもフロントエンドもネイティブアプリもOSも全部同じ言語で書けちゃう!
フルスタックエンジニア向けの言語ですね!(ぐるぐる目)
それではこんなところで~!