🔰

FlutterとRustでAWS S3エクスプローラを作る(2/2)

2023/03/20に公開

flutter_rust_bridgeを使ってFlutterとRustでアプリを作ってみるの続きになります。
Rustはまだまだ勉強中ですので参考程度にお願いします。

前回の記事はこちらになります。
https://zenn.dev/mytooyo_dev/articles/ee3c3fcbf69426

今回はAWS SDKを実際に使ってS3からバケット、オブジェクトへのアクセスを行っていきます。
必要な機能は大きく下記になります。

はじめに

まずは実装に必要となるパッケージをインストールします。 native/Cargo.toml[dependencies]にパッケージを追記します。VS Codeを利用していると
AWS SDK以外のパッケージは必要に応じて設定をお願いします。anyhowtokioは非同期処理を行うために必要となります。

native/Cargo.toml
[dependencies]
flutter_rust_bridge = "1"

aws-config = "0.54.1"
aws-credential-types = "0.54.1"
aws-sdk-s3 = "0.24.0"
aws-sdk-sts = "0.24.0"
aws-smithy-types = "0.54.4"
aws-types = "0.54.1"

anyhow = "1.0.69"
tokio = {version = "1", features = ["full"]}
serde = "1.0.152"
serde_json = "1.0.93"

Asyncの取り扱いについて

flutter_rust_bridgeで非同期を利用する場合のアプローチについては公式ドキュメントに記載があります。今回はその中のApproach 1(macro)を利用しています。
AWS SDKのリクエストはほぼ全て非同期となっておりますので、インタフェース部分ではこちらを用いることになります。

use anyhow;

#[tokio::main(flavor = "current_thread")]
async fn get() -> anyhow::Result<String> {
    let url = "https://link/to/file/download";
    let data = reqwest::get(url).await?.text().await?;
    Ok(data)
}

AWS Credential

AWS CLIを利用する時と同様、アクセスキーとシークレットアクセスキーを利用してリクエストを行います。
CLIの場合はaws configureを利用してキーの設定を行い、コマンドを実行することが多いと思います。
今回はiPad等のタブレットでの利用も考え、モバイル内でアクセスキーとシークレットアクセスキーおよび必要な情報を保存して利用するようにします。また、MFA認証にも対応するためAWS STSを利用して一時的に認証情報を払い出すようにします。

まずはアクセスキーとシークレットアクセスキーを指定してaws_types::SdkConfigを生成します

pub fn credential_provider(access_key_id: String, secret_access_key: String) -> aws_credential_types::Credentials {
    aws_credential_types::Credentials::new(
        access_key_id,
        secret_access_key,
        None,
        None,
        "flutter_s3_explorer",
    )
}

pub async fn get_aws_config(
    access_key_id: String,
    secret_access_key: String,
    region: String,
) -> aws_types::SdkConfig {

    let region_provider =
        RegionProviderChain::first_try(std::env::var(region).ok().map(aws_sdk_s3::Region::new))
            .or_default_provider()
            .or_else(aws_sdk_s3::Region::new("ap-northeast-1"));
    aws_config::from_env()
        .region(region_provider)
        .credentials_provider(credential_provider(access_key_id, secret_access_key))
        .load()
        .await
}

aws_types::SdkConfigを元にSTSクライアントを生成してセッショントークン取得のリクエストを行います。

#[derive(Debug, Clone)]
pub struct AWSProfile {
    pub name: String,
    pub region: String,
    pub access_key_id: String,
    pub secret_access_key: String,
    pub session_token: Option<String>,
    pub mfa_serial: Option<String>,
    pub expiration: Option<String>,
}

pub async fn get_sts_session_token(
    config: &aws_types::SdkConfig,
    mfa_serial: Option<String>,
    mfa_code: Option<String>,
) -> Result<AWSProfile, aws_sdk_sts::types::SdkError<aws_sdk_sts::error::GetSessionTokenError>> {
    // AWS Client
    let client = aws_sdk_sts::Client::new(config);

    // セッショントークン取得リクエスト
    let result = client
        .get_session_token()
        .set_duration_seconds(Some(43200))
        .set_serial_number(mfa_serial)
        .set_token_code(mfa_code)
        .send()
        .await;
}

戻り値はResult<GetSessionTokenOutput>なのでmatch等を使ってハンドリングをします。そちらについてはGitHubのリポジトリを参照ください。
get_session_token()の戻り値にアクセスキーとシークレットアクセスキー、セッショントークンがありますので、S3へのアクセスにはそちらを利用するようにします。AWSProfileをFlutterへの戻り値とし、以降のリクエストにはこちらのプロファイル情報をパラメータとして設定します。

ここまででセッショントークンを取得する準備ができましたので、native/src/api.rsにFlutterから呼び出す関数を定義します。aws::get_aws_configaws_types::SdkConfigを生成し、aws::get_sts_session_tokenでSTSセッショントークンを取得しています。

#[tokio::main(flavor = "current_thread")]
pub async fn get_aws_credential(
    profile: AWSProfile,
    mfa_code: Option<String>,
) -> anyhow::Result<AWSProfile> {
    let provider = aws::CredentialProvider::new(&profile.access_key_id, &profile.secret_access_key);
    let config = aws::get_aws_config(provider, profile.region.clone()).await;
    match aws::get_sts_session_token(&config, profile, mfa_code).await {
        Ok(p) => anyhow::Ok(p),
        Err(err) => {
            let err_str = format!("{:?}", err.source().unwrap());
            bail!(err_str);
        }
    }
}

以上がAWS Credential周りの処理になります。
IAMユーザに付与されているポリシーによってはSTS取得ができない場合や取得できてもS3へのアクセス権限がない場合は何もできませんので、403エラーが返却されましたらアクセス権限をご確認ください。また、今回はAssumeRoleについては考慮していません。今後必要になったら実装してみようと思っています。

S3リクエスト

以降はS3へのリクエストのサンプル実装を行っていきます。
S3クライアントの生成とDateTime変換は共通化しておりますので、その部分を先に定義しておきます。

/// S3 Client
async fn _init_client(profile: AWSProfile) -> aws_sdk_s3::client::Client {
    let provider = aws::CredentialProvider::new(profile.access_key_id, profile.secret_access_key)
        .set_token(profile.session_token);
    let s3_config = aws::get_aws_config(provider, profile.region).await;
    aws_sdk_s3::Client::new(&s3_config)
}

/// Parse date string from aws datetime
fn parse_datetime(datetime: &aws_smithy_types::DateTime) -> Option<String> {
    match std::time::SystemTime::try_from(*datetime) {
        Ok(dtime) => {
            if let Ok(time) = dtime.duration_since(UNIX_EPOCH) {
                Some(time.as_millis().to_string())
            } else {
                None
            }
        }
        Err(_) => None,
    }
}

バケットリストを取得

#[derive(serde::Serialize)]
pub struct S3Bucket {
    pub name: String,
    pub created_at: Option<String>,
    pub location: String,
}

pub async fn list_buckets(profile: AWSProfile) -> Result<Vec<S3Bucket>, aws_sdk_s3::Error> {
    let client = _init_client(profile).await;
    match client.list_buckets().send().await {
        Ok(res) => {
            let mut list = Vec::<S3Bucket>::new();
            let buckets = res.buckets().unwrap_or_default();
            for b in buckets {
                if let (Some(name), Some(creation)) = (b.name(), b.creation_date()) {
                    // ロケーション情報
                    let r = match client
                        .get_bucket_location()
                        .bucket(name.clone())
                        .send()
                        .await
                    {
                        Ok(v) => v,
                        Err(err) => return Err(err.into()),
                    };

                    let location = r.location_constraint().unwrap().as_str();
                    let created_at = parse_datetime(creation);
                    list.push(S3Bucket {
                        name: name.to_string(),
                        created_at,
                        location: location.to_string(),
                    });
                }
            }
            Ok(list)
        }
        Err(err) => {
            Err(err.into())
        }
    }
}

オブジェクト一覧を取得

#[derive(serde::Serialize)]
pub struct S3Object {
    pub key: String,
    pub last_modified: Option<String>,
    pub size: Option<i64>,
    pub storage_class: Option<String>,
    pub is_folder: bool,
}

/// S3バケット内のオブジェクトを取得
/// prefixを指定しないと全件取得してしまうため、指定のprefix内のデータのみ取得するようにする
pub async fn list_objects(
    profile: AWSProfile,
    bucket: S3Bucket,
    prefix: Option<String>,
) -> Result<Vec<S3Object>, aws_sdk_s3::Error> {
    let client = _init_client(profile).await;
    let mut bucket = client.list_objects_v2().bucket(&bucket.name).delimiter("/");
    if let Some(p) = &prefix {
        bucket = bucket.prefix(p);
    }
    let mut list = Vec::<S3Object>::new();
    let mut stream = bucket.into_paginator().send();
    // 取得したリストはページング処理されているため順番に取得
    while let Some(res) = stream.next().await {
        if res.is_err() {
            break;
        }
        let data = res.unwrap();
        let objects = data.contents().unwrap_or_default();
        for obj in objects {
            ...
            // オブジェクトに対する処理
            ...
        }
    }
    Ok(list)
}

オブジェクトダウンロード

#[derive(Clone)]
pub struct S3GetObjectConfig {
    pub save_dir: Option<String>,
    pub zip_for_folder: bool,
}

/// オブジェクトダウンロード
pub async fn get_object(
    profile: AWSProfile,
    bucket_name: String,
    prefix: String,
    config: S3GetObjectConfig,
) -> Result<Option<String>, aws_sdk_s3::Error> {
    let client = _init_client(profile).await;
    let res = client
        .get_object()
        .bucket(bucket_name)
        .key(&prefix)
        .send()
        .await?;

    // プレフィックスからファイル名を取得
    // 区切り文字でsplitした最後のアイテムのみ返却する
    let item = prefix.split("/").into_iter().collect::<Vec<&str>>();
    let file_name = item[item.len() - 1];
    let download_dir = super::utils::file::download_dir(config.save_dir, None);

    let p = &download_dir.join(file_name);
    let bytes = res.body.collect().await.unwrap().into_bytes().to_vec();
    match super::utils::file::save_file(bytes.to_vec(), &p) {
        Ok(p) => Ok(Some(p.as_path().to_str().unwrap().to_string())),
        Err(_) => Ok(None),
    }
}

オブジェクト登録

pub async fn upload_file(
    profile: AWSProfile,
    bucket_name: String,
    prefix: Option<String>,
    file_path: String,
) -> Result<bool, aws_sdk_s3::Error> {
    let client = _init_client(profile).await;
    let mut req = client.put_object().bucket(bucket_name);
    // ファイルを取得
    let path_buf = PathBuf::from(&file_path);
    let file_name = path_buf.file_name().unwrap().to_str().unwrap();
    // プレフィックスの指定がある場合は設定
    req = req.key(file_name);
    if let Some(p) = prefix {
        if p.len() > 0 {
            req = req.key(format!("{}{}", p, file_name));
        }
    }
    match ByteStream::from_path(Path::new(file_path.as_str())).await {
        Ok(b) => {
            req.body(b).send().await?;
        }
        Err(_) => {
            return Ok(false);
        }
    }
    Ok(true)
}

フォルダ作成

フォルダ作成はオブジェクトの登録と同じようにPutObjectを利用します。AWS S3では、登録するキーの最後は/(スラッシュ)の場合はフォルダ扱いとなります。Bodyデータはなしになります。

pub async fn create_folder(
    profile: AWSProfile,
    bucket_name: String,
    prefix: String,
) -> Result<bool, aws_sdk_s3::Error> {
    let client = _init_client(profile).await;
    // プレフィックスの最後が'/'ではない場合はエラー
    if !&prefix.ends_with('/') {
        return Ok(false);
    }
    client
        .put_object()
        .bucket(bucket_name)
        .key(prefix)
        .send()
        .await?;
    Ok(true)
}

コード生成

一通りRustのコードを書いたらflutter_rust_bridge_codegenを使ってRustおよびDartのコードを生成します。
コマンドは前回書いたのと同じです。Rustのコードを修正した際はこちらのコマンドを実行する必要があります。

flutter_rust_bridge_codegen \
  -r native/src/api.rs \
  -c ios/Runner/bridge_generated.h \
  -e macos/Runner/ \
  --dart-output lib/bridge/bridge_generated.dart \
  --dart-decl-output lib/bridge/bridge_definitions.dart

Flutterと連携

Rustのコードばかりになってしまいましたが、AWS S3のリクエストを行う処理は一部ですが以上となります。
次はFlutterから呼び出して一覧の取得を行う部分を簡単に紹介します。Flutter側ではflutter_riverpodを利用しています。

S3バケットリスト取得

FutureProviderを利用してバケットの一覧を取得します。 FutureProvider ではAsyncValueが返却されるため、エラー、ローディングと合わせて実装していきます。今回はエラー部分はContainer()を返してしまっていますが、本来はエラー文言やアイコンを表示すべきかと思います。。

final s3BucketListProvider = FutureProvider<List<S3Bucket>>(
  (ref) async {
    // プロファイル情報を取得
    final profile = ref.watch(awsProfileSelectedProvider);
    if (profile == null) return [];
    // 期限切れの場合は空を返却
    if (profile.expired) return [];
    return await api.s3ListBuckets(profile: profile.toNative);
  },
);

class S3BucketListWidget extends ConsumerWidget {

    
    Widget build(BuildContext context, WidgetRef ref) {
        final asyncValue = ref.watch(s3BucketListProvider);
        return asyncValue.when(
            data: (list) {
                return ListView.builder(
                    shrinkWrap: true,
                    padding: const EdgeInsets.only(top: 8, bottom: 8),
                    itemCount: list.length,
                    itemBuilder: (context, index) {
                        return _listItem(list[index], selected);
                    },
                );
            },
            error: (ex, stackTrace) => Container(),
            loading: () => const Center(
                child: Align(
                    alignment: Alignment.topCenter,
                    child: Padding(
                        padding: EdgeInsets.only(top: 40),
                        child: CircularProgressIndicator(),
                    ),
                ),
            ),
        );
    }
}

オブジェクト一覧等のリスト取得についてはバケットリスト取得と同じようなコードで実装可能かと思います。

コード全体

コードがそこそこの量ありますので、GitHubをご確認いただければと思います。
https://github.com/mytooyo/flutter_s3_explorer

最後に

最後までお読みいただきありがとうございます。

前に一度TAURIを利用して同様のS3エクスプローラ機能を作ったのですが、、知識不足が過ぎて Vue で綺麗にコードを書けなかったです。なので改めてFlutterとRustで作成してみました。

ダウンロードやアップロードの時間がかかる処理はStreamを利用して進捗状況を表示すべきとは思いますが、今回はサンプル&自分自身で使うことを目的としたため少し雑な部分はありますがご了承ください。

アプリアイコンについて

あまり使い慣れていないグラフィックデザインツールでアイコンも作ってみましたが
なかなか上手くいかずバランスがあまり良くない気がします。。
バケット部分も含めてすべて自分で作成してみています。デザイナーではないので少し雑な部分はありますが、そこも勉強していこうと思っています。。

title.png

参考

FlutterとRustで実装していく中で、参考にさせていただきました。
誠にありがとうございます。
上手く活用できていない部分はあるかもしれませんので、今後とも勉強させていただきます。
とても参考になりましたので、皆さんも是非読んでみてください。

https://zenn.dev/yukinarit/articles/b39cd42820f29e

あまり更新頻度は高くないですが、GitHubにいくつかコードをあげているのでお時間ある方は少し覗いていただけると有り難いです。

https://github.com/mytooyo

Discussion