Rust製のフロントエンドフレームワークYewでファイルアップロードを実装してみる

『[Rustその2 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/rust2)』の 20 日目の記事です。[Yew]( https://github.com/yewstack/yew) についてご紹介します。

Yew は Elm と React にインスパイアされたフレームワークとのことで、[The Elm Architecture](https://guide.elm-lang.jp/architecture/) で書けるようになっています。

[公式のサンプルのカウンターアプリ](https://github.com/yewstack/yew/blob/master/examples/counter/src/lib.rs)を見ていただくとわかると思うのですが、Elm で何か書いたことがある方にはスッと馴染めると思いますし、そうでない方でもシンプルなのでちょっと試していただけると嬉しいです。

ちなみに Elm も Yew も英語での意味では木材の一種で、Elm はニレ、Yew はイチイのようです。

# はじめに

さて、今回ご紹介するファイルアップロードですが、具体的には

『ファイルフィールドで選択されたファイルを Yew 側でいったんすべて読み込み、その内容を[筆者作のAPIサーバー](https://github.com/hioki-daichi/juniper-rocket-sample)の GraphQL エンドポイントに Mutation クエリで送信する』

という内容でやっていきます。

# 内容

## 1. プロジェクトの準備

まずは以下で `Hello, World!` を画面に表示します。

```sh
# プロジェクトを作成する。
$ cargo new fileupload-sample

# yew を使うように Cargo.toml の [dependencies] セクションに追記する。
$ echo 'yew = "0.10.0"' >> Cargo.toml

# src/main.rs に適当なコードを書く。
$ cat <<EOF > src/main.rs
use yew::prelude::*;
use yew::services::ConsoleService;

fn main() {
    yew::start_app::<Model>();
}

struct Model {
    #[allow(dead_code)]
    console: ConsoleService,
}

enum Msg {}

impl Component for Model {
    type Message = Msg;
    type Properties = ();

    fn create(_: Self::Properties, _link: ComponentLink<Self>) -> Self {
        Model {
            console: ConsoleService::new(),
        }
    }

    fn update(&mut self, msg: Self::Message) -> ShouldRender {
        match msg {}

        #[allow(unreachable_code)]
        true
    }

    fn view(&self) -> Html<Self> {
        html! {
            <div>
                { "Hello, World!" }
            </div>
        }
    }
}
EOF

# 起動する (事前に https://github.com/koute/cargo-web のインストールが必要)
$ cargo web start
```

この状態でブラウザで http://localhost:8000 を開くと以下のように表示されます。

<img width="334" alt="スクリーンショット 2019-12-14 16.34.32.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59587/a192088c-6651-6659-a07c-17138f3cba55.png">

## 2. ファイルフィールドの `onchange` イベントで `ChangeData::Files(FileList)` を受け取り `File` を取り出す

ファイルフィールドは `<input type="file" />` で表示できます。

```diff
--- fileupload-sample/src/main.rs
+++ fileupload-sample/src/main.rs
@@ -32,7 +32,7 @@ impl Component for Model {
     fn view(&self) -> Html<Self> {
         html! {
             <div>
-                { "Hello, World!" }
+                <input type="file" />
             </div>
         }
     }
```

<img width="322" alt="スクリーンショット 2019-12-14 16.36.38.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59587/4087ecc9-dfb5-a762-6b20-593efbd69cdf.png">

これだけだと何も起こらないため、`onchange` イベントを登録します。
`onchange` イベントでは `yew::html::ChangeData` [^1]が取得できるのですが、まずはそれをコンソールに出力してみます。

```diff
--- fileupload-sample/src/main.rs
+++ fileupload-sample/src/main.rs
@@ -10,7 +10,9 @@ struct Model {
     console: ConsoleService,
 }

-enum Msg {}
+enum Msg {
+    ChooseFile(ChangeData),
+}

 impl Component for Model {
     type Message = Msg;
@@ -23,7 +25,11 @@ impl Component for Model {
     }

     fn update(&mut self, msg: Self::Message) -> ShouldRender {
-        match msg {}
+        match msg {
+            Msg::ChooseFile(change_data) => {
+                self.console.log(format!("{:?}", change_data).as_str());
+            }
+        }

         #[allow(unreachable_code)]
         true
@@ -32,7 +38,7 @@ impl Component for Model {
     fn view(&self) -> Html<Self> {
         html! {
             <div>
-                <input type="file" />
+                <input type="file" onchange=|change_data| Msg::ChooseFile(change_data) />
             </div>
         }
     }
```

`Files(FileList(Reference(16)))` が出力されました。

![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59587/18048786-c639-c8e8-8d36-5e18336d25fc.gif)

以下の `yew::html::ChangeData` の定義を見てみます。`enum` になっています。コメントを読む限り、確かにファイルフィールドの時には `Files(FileList)` が取得できそうです。

> https://github.com/yewstack/yew/blob/c583f731c5e248da870c19c81baeaac6a64d49f2/src/html/listener.rs#L158-L170

```rust
/// A type representing change of value(s) of an element after committed by user
/// ([onchange event](https://developer.mozilla.org/en-US/docs/Web/Events/change)).
#[derive(Debug)]
pub enum ChangeData {
    /// Value of the element in cases of `<input>`, `<textarea>`
    Value(String),
    /// SelectElement in case of `<select>` element. You can use one of methods of SelectElement
    /// to collect your required data such as: `value`, `selected_index`, `selected_indices` or
    /// `selected_values`. You can also iterate throught `selected_options` yourself.
    Select(SelectElement),
    /// Files
    Files(FileList),
}
```

具体的には以下の箇所で `input` フィールドの `type` が `file` なら `ChangeData::Files` としているようです。

> https://github.com/yewstack/yew/blob/c583f731c5e248da870c19c81baeaac6a64d49f2/src/html/listener.rs#L104-L123

```rust
// Inspired by: http://package.elm-lang.org/packages/elm-lang/html/2.0.0/Html-Events
impl_action! {
// ...(省略)...
    onchange(event: ChangeEvent) -> ChangeData => |this: &Element, _| {
// ...(省略)...
        match this.node_name().as_ref() {
            "INPUT" => {
                let input: InputElement = this.clone().try_into().unwrap();
                let is_file = input.get_attribute("type").map(|value| {
                        value.eq_ignore_ascii_case("file")
                    })
                    .unwrap_or(false);
                if is_file {
                    let files: FileList = js!( return @{input}.files; )
                        .try_into()
                        .unwrap();
                    ChangeData::Files(files)
// ...(省略)...
```

ちなみに `impl_action!` マクロ自体は同ファイル内の https://github.com/yewstack/yew/blob/c583f731c5e248da870c19c81baeaac6a64d49f2/src/html/listener.rs#L9-L50 に定義されていました。

さて、先程の `Files(FileList)` の `FileList` の方ですが、こちらは Yew の依存する crate の [stdweb](https://github.com/koute/stdweb) 側で定義されていまして、Iterator を実装するように定義されています。

> https://github.com/koute/stdweb/blob/dff1e06086124fe79e3393a99ae8e2d424f5b2f1/src/webapi/file_list.rs

その `Item` の `File` は name にアクセスできるようでした。

> https://github.com/koute/stdweb/blob/8049357b33d49b306ae04a75ecc9a11a41f28b67/src/webapi/file.rs

さて、次からはこの stdweb の `File` を使うため、`if let` と `for` で取り出しておきます。

```diff
--- fileupload-sample/src/main.rs
+++ fileupload-sample/src/main.rs
@@ -27,7 +27,11 @@ impl Component for Model {
     fn update(&mut self, msg: Self::Message) -> ShouldRender {
         match msg {
             Msg::ChooseFile(change_data) => {
-                self.console.log(format!("{:?}", change_data).as_str());
+                if let ChangeData::Files(files) = change_data {
+                    for file in files {
+                        self.console.log(format!("{:?}", file).as_str());
+                    }
+                }
             }
         }
```

## 3. `ReaderService` で `File` を読み込む

前回の stdweb の `File` を `ReaderService` の `fn read_file(&mut self, file: File, callback: Callback<FileData>) -> ReaderTask` を使って読み込んでいきます。

```diff
--- fileupload-sample/src/main.rs
+++ fileupload-sample/src/main.rs
@@ -1,5 +1,5 @@
 use yew::prelude::*;
-use yew::services::ConsoleService;
+use yew::services::{reader::ReaderService, ConsoleService};

 fn main() {
     yew::start_app::<Model>();
@@ -8,6 +8,7 @@ fn main() {
 struct Model {
     #[allow(dead_code)]
     console: ConsoleService,
+    reader_service: ReaderService,
 }

 enum Msg {
@@ -21,6 +22,7 @@ impl Component for Model {
     fn create(_: Self::Properties, _link: ComponentLink<Self>) -> Self {
         Model {
             console: ConsoleService::new(),
+            reader_service: ReaderService::new(),
         }
     }

@@ -29,7 +31,8 @@ impl Component for Model {
             Msg::ChooseFile(change_data) => {
                 if let ChangeData::Files(files) = change_data {
                     for file in files {
-                        self.console.log(format!("{:?}", file).as_str());
+                        let callback = Callback::from(|_| panic!());
+                        let _reader_task = self.reader_service.read_file(file, callback);
                     }
                 }
             }
```

`read_file` では第2引数にコールバックを指定できます。このコールバックは、第1引数に指定したファイルの読み込みが完了した時点で呼ばれ、コールバックの引数は読み込んだファイルのデータになります。ここではいったん `panic!()` だけするようなコールバックを登録します。

実は上記の修正だけではだめで、以下のように `read_file` の戻り値の `ReaderTask` を登録する必要があります。[^2]

```diff
--- fileupload-sample/src/main.rs
+++ fileupload-sample/src/main.rs
@@ -1,5 +1,8 @@
 use yew::prelude::*;
-use yew::services::{reader::ReaderService, ConsoleService};
+use yew::services::{
+    reader::{ReaderService, ReaderTask},
+    ConsoleService,
+};

 fn main() {
     yew::start_app::<Model>();
@@ -9,6 +12,7 @@ struct Model {
     #[allow(dead_code)]
     console: ConsoleService,
     reader_service: ReaderService,
+    reader_tasks: Vec<ReaderTask>,
 }

 enum Msg {
@@ -23,6 +27,7 @@ impl Component for Model {
         Model {
             console: ConsoleService::new(),
             reader_service: ReaderService::new(),
+            reader_tasks: vec![],
         }
     }

@@ -32,7 +37,8 @@ impl Component for Model {
                 if let ChangeData::Files(files) = change_data {
                     for file in files {
                         let callback = Callback::from(|_| panic!());
-                        let _reader_task = self.reader_service.read_file(file, callback);
+                        let reader_task = self.reader_service.read_file(file, callback);
+                        self.reader_tasks.push(reader_task);
                     }
                 }
             }
```

これで想定通りコールバックの panic が出力されるようになりました。

![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59587/8e420019-72f5-284c-1328-3afa57e22943.gif)

さて、読み込んだデータを表示するようにしてみましょう。`ComponentLink`[^3] の `fn send_back<F, IN>(&mut self, function: F) -> Callback<IN> where F: Fn(IN) -> COMP::Message + 'static` を使います。

```diff
--- fileupload-sample/src/main.rs
+++ fileupload-sample/src/main.rs
@@ -1,6 +1,6 @@
 use yew::prelude::*;
 use yew::services::{
-    reader::{ReaderService, ReaderTask},
+    reader::{FileData, ReaderService, ReaderTask},
     ConsoleService,
 };

@@ -13,21 +13,24 @@ struct Model {
     console: ConsoleService,
     reader_service: ReaderService,
     reader_tasks: Vec<ReaderTask>,
+    link: ComponentLink<Self>,
 }

 enum Msg {
     ChooseFile(ChangeData),
+    FileLoaded(FileData),
 }

 impl Component for Model {
     type Message = Msg;
     type Properties = ();

-    fn create(_: Self::Properties, _link: ComponentLink<Self>) -> Self {
+    fn create(_: Self::Properties, link: ComponentLink<Self>) -> Self {
         Model {
             console: ConsoleService::new(),
             reader_service: ReaderService::new(),
             reader_tasks: vec![],
+            link,
         }
     }

@@ -36,12 +39,16 @@ impl Component for Model {
             Msg::ChooseFile(change_data) => {
                 if let ChangeData::Files(files) = change_data {
                     for file in files {
-                        let callback = Callback::from(|_| panic!());
+                        let callback = self.link.send_back(|file_data| Msg::FileLoaded(file_data));
                         let reader_task = self.reader_service.read_file(file, callback);
                         self.reader_tasks.push(reader_task);
                     }
                 }
             }
+
+            Msg::FileLoaded(file_data) => {
+                self.console.log(format!("{:?}", file_data).as_str());
+            }
         }
```

上記のように修正すると、以下のように `FileData` 構造体の `name` フィールドにファイル名が、`content` フィールドに `Vec<u8>` 型でファイルの内容が表示されます。

![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59587/1da009e5-8cdb-f9e0-3da7-b8ed1ed50d17.gif)

## 4. 読み込んだ FileData の content を Base64 エンコードして `FetchService` で送信する

読み込んだファイルの内容を `FetchService` の `fetch` メソッドで送信していきます。

その前に、今回は JSON で送るため、他にもやり方はいろいろあるのでしょうが、いったん Base64 エンコードしてから送ります。

まずは `Cargo.toml` に `base64 = "0.11.0"` を追記します。

```diff
--- fileupload-sample/Cargo.toml
+++ fileupload-sample/Cargo.toml
@@ -8,3 +8,4 @@ edition = "2018"

 [dependencies]
 yew = "0.10.0"
+base64 = "0.11.0"
```

そして、読み込んだファイルの内容に対して `base64::encode` を呼びます。

```diff
--- fileupload-sample/src/main.rs
+++ fileupload-sample/src/main.rs
@@ -47,7 +47,8 @@ impl Component for Model {
             }

             Msg::FileLoaded(file_data) => {
-                self.console.log(format!("{:?}", file_data).as_str());
+                let filename = file_data.name;
+                let encoded_data = base64::encode(&file_data.content);
             }
         }
```

さて、後は HTTP リクエストをするだけですが、今回のリクエスト先は[筆者作のAPIサーバー](https://github.com/hioki-daichi/juniper-rocket-sample)の `registerVideo(key: String!, data: String!)` という Mutation のエンドポイントです。

<details><summary>サーバー側の実装にご興味をお持ちの方はこちらをクリックしてください</summary><div>

以下の `registerVideo` field 内の処理が呼ばれます。内容としては以下です。

1. 渡された key と data をもとにして S3 に PutObject する。
2. 渡された key を持つようなレコードを videos テーブルに登録する。
3. 登録したファイルに対して Pre-Signed URL を発行した情報を返す。

> https://github.com/hioki-daichi/juniper-rocket-sample/blob/2f269b60918244d58134955b68a4409cc50e0a03/src/graphql/schema.rs#L17-L23

```rust
use crate::util::s3::put_object;
use crate::video::decorator::VideoDecorator;
use crate::video::model::Video;
use crate::Context;
use juniper::{FieldError, FieldResult, RootNode, Value};

//...(省略)...

graphql_object!(Mutation: Context |&self| {
    field registerVideo(&executor, key: String, data: String) -> FieldResult<VideoDecorator> {
        put_object("videos".to_owned(), key.clone(), base64::decode(&data).unwrap());
        let video = Video::create(executor.context(), key).map_err(|e| FieldError::new(e, Value::null()))?;
        Ok(VideoDecorator::from(&video))
    }
});
```

S3 に PutObject する箇所は以下です。[rusoto](https://github.com/rusoto/rusoto) を使っています。

> https://github.com/hioki-daichi/juniper-rocket-sample/blob/2f269b60918244d58134955b68a4409cc50e0a03/src/util/s3.rs#L6-L24

```rust
use rusoto_core::credential::AwsCredentials;
use rusoto_core::{ByteStream, Region};
use rusoto_s3::util::{PreSignedRequest, PreSignedRequestOption};
use rusoto_s3::{GetObjectRequest, PutObjectRequest, S3Client, S3};

//...(省略)...

pub fn put_object(bucket: String, key: String, data: Vec<u8>) {
    let client = S3Client::new(get_region());

    let body = Some(ByteStream::from(data));
    let content_type = Some("video/mp4".to_owned());

    let output = client
        .put_object(PutObjectRequest {
            bucket,
            key,
            body,
            content_type,
            ..Default::default()
        })
        .sync()
        .unwrap();

    println!("{:?}", output);
}

//...(省略)...

fn get_region() -> Region {
    Region::Custom {
        name: "minio".to_owned(),
        endpoint: "http://localhost:9001".to_owned(),
    }
}
```

データベースに保存している箇所は以下です。[diesel-rs/diesel](https://github.com/diesel-rs/diesel) を使っています。

> https://github.com/hioki-daichi/juniper-rocket-sample/blob/2f269b60918244d58134955b68a4409cc50e0a03/src/video/model.rs#L28-L39

```rust
use crate::schema::videos;
use crate::video::entity as E;
use crate::Context;
use diesel::prelude::*;

//...(省略)...

#[derive(Debug)]
pub struct Video {
    pub src: String,
}

#[derive(Insertable)]
#[table_name = "videos"]
pub struct NewVideo {
    pub src: String,
}

impl Video {
//...(省略)...
    pub fn create(context: &Context, src: String) -> Result<Video, String> {
        let conn = context.pool.get().map_err(|e| e.to_string())?;

        let new_video = NewVideo { src };

        let e_video: E::Video = diesel::insert_into(videos::table)
            .values(&new_video)
            .get_result(&conn)
            .map_err(|e| e.to_string())?;

        Ok(Video { src: e_video.src })
    }
```

以上、サーバー側の実装詳細でした。
</div></details>

先にレスポンスの型だけ定義しておきます。GraphQL の Mutation エンドポイントではレスポンスボディが `{ "data": { "<Mutation名>": <内容> } }` という形式になるため、それ用の型を定義しておきます。[^4]

まずは `Cargo.toml` に必要なクレートを追記します。

```diff
--- fileupload-sample/Cargo.toml
+++ fileupload-sample/Cargo.toml
@@ -9,3 +9,6 @@ edition = "2018"
 [dependencies]
 yew = "0.10.0"
 base64 = "0.11.0"
+failure = "0.1.6"
+serde = "1.0.103"
+serde_json = "1.0.44"
```

そして、`src/main.rs` を修正していきます。

```diff
--- fileupload-sample/src/main.rs
+++ fileupload-sample/src/main.rs
@@ -1,5 +1,10 @@
+use failure::Error;
+use serde::*;
+use yew::format::Json;
 use yew::prelude::*;
 use yew::services::{
+    fetch::Response,
     reader::{FileData, ReaderService, ReaderTask},
     ConsoleService,
 };
@@ -64,3 +69,21 @@ impl Component for Model {
         }
     }
 }
+
+type GraphQLResponse<T> = Response<Json<Result<ResponseData<T>, Error>>>;
+
+#[derive(Deserialize)]
+struct ResponseData<T> {
+    data: T,
+}
+
+#[derive(Deserialize)]
+#[allow(non_snake_case)]
+struct RegisterVideoResponse {
+    registerVideo: Video,
+}
+
+#[derive(Debug, Deserialize)]
+struct Video {
+    src: String,
+}
```

以上、レスポンスの定義でした。次はリクエストの処理を書いていきます。

`ReaderService` 同様 `FetchService` も Task (`FetchTask`) を返すため、それを一時的に格納する先として `fetch_task` を用意します。
また、`ReaderService` の `read_file` と同様に `FetchService` の `fetch` メソッドも第2引数にコールバックを取るのですが、こちらのコールバックも同様に `Msg` を返すように実装する必要があります。

以下がその修正です。駆け足になってしまい少々量が多いですが、これで最後です。

```diff
diff --git fileupload-sample/src/main.rs fileupload-sample/src/main.rs
index df50027..8093d2f 100644
--- fileupload-sample/src/main.rs
+++ fileupload-sample/src/main.rs
@@ -1,9 +1,10 @@
 use failure::Error;
 use serde::*;
+use serde_json::json;
 use yew::format::Json;
 use yew::prelude::*;
 use yew::services::{
-    fetch::Response,
+    fetch::{FetchService, FetchTask, Request, Response},
     reader::{FileData, ReaderService, ReaderTask},
     ConsoleService,
 };
@@ -18,11 +19,15 @@ struct Model {
     reader_service: ReaderService,
     reader_tasks: Vec<ReaderTask>,
     link: ComponentLink<Self>,
+    fetch_service: FetchService,
+    fetch_task: Option<FetchTask>,
 }
 
 enum Msg {
     ChooseFile(ChangeData),
     FileLoaded(FileData),
+    RequestCompleted(Video),
+    RequestFailed,
 }
 
 impl Component for Model {
@@ -35,6 +40,8 @@ impl Component for Model {
             reader_service: ReaderService::new(),
             reader_tasks: vec![],
             link,
+            fetch_service: FetchService::new(),
+            fetch_task: None,
         }
     }
 
@@ -53,6 +60,43 @@ impl Component for Model {
             Msg::FileLoaded(file_data) => {
                 let filename = file_data.name;
                 let encoded_data = base64::encode(&file_data.content);
+
+                let request_body = json!({
+                    "query":
+                        format!(
+                            "mutation {{ registerVideo(key: \"{}\", data: \"{}\") {{ src }} }}",
+                            filename, encoded_data
+                        )
+                });
+
+                let request = Request::post("http://localhost:8080/graphql")
+                    .header("Content-Type", "application/json")
+                    .body(Json(&request_body))
+                    .expect("Failed to build request.");
+
+                let callback =
+                    self.link
+                        .send_back(move |response: GraphQLResponse<RegisterVideoResponse>| {
+                            let (meta, Json(response_body)) = response.into_parts();
+                            if meta.status.is_success() {
+                                let video = response_body.unwrap().data.registerVideo;
+                                Msg::RequestCompleted(video)
+                            } else {
+                                Msg::RequestFailed
+                            }
+                        });
+
+                let fetch_task = self.fetch_service.fetch(request, callback);
+
+                self.fetch_task = Some(fetch_task);
+            }
+
+            Msg::RequestCompleted(video) => {
+                self.console.log(format!("{:?}", video).as_str());
+            }
+
+            Msg::RequestFailed => {
+                self.console.log("RequestFailed");
             }
         }
```

コードの修正は以上です。

APIサーバーを起動し、ファイルをアップロードした様子が以下です。APIサーバーはデータを受け取ると S3 に保存して Pre-Signed URL を生成してレスポンスを返すのですが、その URL がコンソールに出力されています。[^5]

![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/59587/c89b54fa-53ec-3fc8-0fe7-2759c1899583.gif)

以上です、おつかれさまでした！！ :tada:

# 雑感

- フロントエンドなのにコードを潜っても Rust のコードが出てくるのは楽しい。([時々JavaScriptが顔を出すこともある](https://github.com/yewstack/yew/blob/72ca3bb700d813f972b1ea3df6c12083ea3cb700/src/services/fetch.rs#L356-L407))
- The Elm Architecture はやはり気持ちがいい。
    - あるべきものがあるべき場所に存在するよう強制されるため、整いを感じる。
- 気軽にデバッグできなそうな気配が・・？？
    - ひとまず `con<Tab>` で `self.console.log(format!("{:?}", <cursor>).as_str());` が展開されるようにしてみたものの・・`fmt::Debug` を実装している値にしか使えない気が・・
    - Elm なら `Debug.log` でどんな値でもコンソールに出力してくれるらしい。
- Elm と違って Mutable に書けるのは入門時の戸惑いが減るかもしれない・・？？
    - Immutable な方が最終的には考えないといけないことが減ってよさそうな気もする・・？？
- `js!` マクロ内でサクッと JavaScript が書けるの便利 ([例](https://github.com/hioki-daichi/github-keys-qrencoder-frontend/blob/778373e943b24ecd201804859e2327c8a52ba640/src/main.rs#L132-L137)) だし、`html!` マクロ内の JSX-like なシンタックスもまったく違和感なかった。

[^1]: コード内では `use yew::prelude::*;` しているため `yew::html::Change` ではなく `ChangeData` で呼べます。([yew/src/lib.rs#L154](https://github.com/yewstack/yew/blob/72ca3bb700d813f972b1ea3df6c12083ea3cb700/src/lib.rs#L154),  [yew/src/lib.rs#L95-L96](https://github.com/yewstack/yew/blob/72ca3bb700d813f972b1ea3df6c12083ea3cb700/src/lib.rs#L95-L96))

[^2]: このひと手間が必要な理由、『Model の変更を検知して View をレンダリングする』という仕組み上、いったん Model にタスクを突っ込んでおいてコールバックが呼ばれたらタスクを取り除くことで Model に変更を加えて View を再びレンダリングさせたいからなんだろうか・・

[^3]: `ComponentLink` の仕組みもうちょっと調べたかった・・間に合わなかった・・裏でどうリンクさせているんだろうか・・

[^4]: きっとこんなことしなくても `schema.graphql` の内容から自動で組み立てられるはず・・

[^5]: 本記事ではコンソールへの表示まででしたが、受け取った Pre-Signed URL を `<video>` タグで表示する処理は [hioki-daichi/juniper-rocket-sample-frontend](https://github.com/hioki-daichi/juniper-rocket-sample-frontend/blob/master/src/main.rs) に記載していますので、そちらをご覧ください。