LWCでAWS S3と連携する方法

## 1.目的
今回LWC中にAWSのSDK for javascriptでS3と連携する方法を共有します。

## 2.前提

### 2.1.AWS S3バケットCross-Origin Resource Sharing (CORS)の設定
```json
[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "HEAD",
            "GET",
            "PUT",
            "POST",
            "DELETE"
        ],
        "AllowedOrigins": [
            "*"
        ],
        "ExposeHeaders": [
            "ETag"
        ]
    }
]
```

### 2.2.Salesforce側静的リソースにAWSのSDKをアップロードする
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1767576/816d13f2-28f5-67c5-84ac-f4188ce873c3.png)

### 2.3.Salesforce側CSP 信頼済みサイトの設定
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1767576/a2e8aa58-c4fb-79ca-f1b5-c4117c2c7e03.png)

## 3.ソース構成図
```
lwc
 ├─fileuploadMock
 ├─fileuploadModal
 ├─progressbar
 └─utils
```
### fileuploadMock
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1767576/dcff2ff5-6939-a3d7-bbef-ff11a4a8ad9c.png)

```fileuploadMock.css
.container {
  background-color: #fff;    
  min-height:100%;
}

.wrapper {
  background-color: #cecece;
  overflow: scroll;
  width: 100%;
}
```
```fileuploadMock.html
<template>
    <template if:true={loading}>
        <lightning-spinner alternative-text="Loading" size="medium"></lightning-spinner>
    </template>
    <c-progressbar upload-file-name={uploadFileName} progress={progress} onabort={Abort}>
    </c-progressbar>
    <c-fileupload-modal title="ファイル追加" onselect={uploadHandler} onfolderchange={folderchangeHandler}></c-fileupload-modal>
    <lightning-card>
        <div class="slds-p-horizontal_small">
            <div class="slds-form">
                <div class="slds-form__row">
                    <div class="slds-form__item">
                        <button class="slds-button slds-button_brand" onclick={fileSelectorHandler}>ファイル追加</button>
                    </div>
                </div>
            </div>
        </div>
        <div slot="footer">
            <lightning-datatable hide-checkbox-column key-field="key" columns={columns} data={objectlist}
                onrowaction={handleRowAction}>
            </lightning-datatable>
        </div>
    </lightning-card>

</template>
```
```fileuploadMock.js
import { LightningElement, track } from 'lwc';
import { NavigationMixin } from 'lightning/navigation';
import { loadScript } from 'lightning/platformResourceLoader';
import AWS_SDK from '@salesforce/resourceUrl/aws_sdk';
import { showToast, dateFormat, fileSizeUnit } from 'c/utils';

const bucketName = "bucket-name";//バケット名
const region = "ap-northeast-1";//地域
const accessKeyId = "accessKeyId ";//アクセスID
const secretAccessKey = "secretAccessKey";//アクセスキー

export default class FileuploadMock extends NavigationMixin(LightningElement) {
    //ファイル非活性
    @track fileDisable;
    @track datas;
    @track objectlist = [];
    @track folder;
    @track loading;
    @track uploadFileName;
    @track progress;

    /**
     * 初期化AWS
     */
    async initAWS() {
        AWS.config.update({
            region: region,
            accessKeyId: accessKeyId,
            secretAccessKey: secretAccessKey
        });

        this.s3 = new AWS.S3({
            apiVersion: "2006-03-01",
            params: { Bucket: bucketName }
        });
        await this.listObjects();
    }



    /**
     * ファイルアップロード
     * @param {*} event 
     */
    async uploadHandler(event) {
        let input = event.detail;
        let files = input.files;
        if (files.length > 0) {
            try {
                // let result = await this.upload(files[0]);
                let result = await this.managedUpload(files[0], (progress) => {
                    this.uploadFileName = files[0].name;
                    this.progress = Math.floor(progress.loaded / progress.total * 100);
                    this.template.querySelector('c-progressbar').open();
                });

                // console.log(result);
                this.template.querySelector('c-progressbar').close();
                await this.listObjects();
                showToast(this, '', '成功にアップロードしました', 'success');
            } catch (err) {
                showToast(this, '', err.message, 'error');
                console.error("Error:", err);
            }
        }
    }

    /**
     * ファイルアップロードキャンセル
     */
    async Abort() {
        await this.request.abort();
    }


    /**
     * ファイルダウンロード
     * @param {*} event 
     */
    async fileDownload(fileKey) {
        try {
            this.loading = true;
            await this.downloadFile(fileKey);
        } catch (err) {
            showToast(this, '', err.message, 'error');
            console.error("Error:", err);
        } finally {
            this.loading = false;
        }
    }

    /**
     * ファイル削除
     * @param {string} fileKey 
     */
    async deleteFile(fileKey) {
        try {
            this.loading = true;
            await this.deleteObject(fileKey);
            await this.listObjects();
            showToast(this, '', '成功に削除しました', 'success');
        } catch (err) {
            showToast(this, '', err.message, 'error');
            console.error("Error:", err);
        } finally {
            this.loading = false;
        }
    }

    /**
     * ファイルリスト取得
     */
    async listObjects() {
        let data = await this.s3.listObjects().promise();
        console.log(data);
        this.objectlist = [];
        data.Contents.forEach(e => {
            let key = e.Key;
            let folder;
            let fileName;
            if (e.Size === 0) return;
            if (key.lastIndexOf('/') > -1) {
                fileName = key.substring(key.lastIndexOf('/') + 1, key.length);
                folder = './' + key.replace(fileName, '');
            } else {
                folder = './';
                fileName = key;
            }

            let fileType = fileName.split('.')[1];
            this.objectlist.push({
                key: key,
                folder: folder,
                fileName: fileName,
                fileType: fileType,
                LastModified: dateFormat(e.LastModified, 'YYYY/mm/dd HH:MM:SS'),
                Owner: e.Owner.DisplayName,
                Size: fileSizeUnit(e.Size),
                StorageClass: e.StorageClass
            })
        });
    }

    /**
     * ファイル取得処理
     * @param {string} fileKey キー
     * @param {string} fileName ファイル名
     */
    async downloadFile(fileKey) {
        let url = await this.getSignedUrlPromise('getObject', {
            Bucket: bucketName,
            Key: fileKey,
            Expires: 1
        });
        console.log(url);
        window.location.href = url;
    }

    /**
     * オブジェクト取得
     * @param {*} fileKey キー
     */
    getObject(fileKey) {
        return this.s3.getObject({ Key: fileKey }).promise();
    }

    /**
     * ファイル保存処理
     * @param {File(blob)} file ファイル
     */
    putObject(file) {
        const { folder } = this;
        let fileName = file.name;
        let fileKey;
        if (folder || folder === 0)
            fileKey = `${folder}/${fileName}`;
        else
            fileKey = fileName;
        return this.s3.putObject({ Key: fileKey, Body: file }).promise();
    }

    /**
     * ファイル削除処理
     * @param {string} fileKey ファイルキー
     */
    deleteObject(fileKey) {
        return this.s3.deleteObject({ Key: fileKey }).promise();
    }

    /**
     * ファイル保存処理(ビッグサイズ用)
     * @param {File(blob)} file ファイル
     */
    upload(file) {
        const { folder } = this;
        let fileName = file.name;
        let fileKey;
        if (folder || folder === 0)
            fileKey = `${folder}/${fileName}`;
        else
            fileKey = fileName;
        return this.s3.upload({ Key: fileKey, Body: file }).promise();
    }

    /**
     * URL発行
     * @param {*} action アクション：getObject,putObject,deleteObject
     * @param {*} fileKey ファイルキー 
     */
    getSignedUrlPromise(action, params) {
        return this.s3.getSignedUrlPromise(action, params);
    }

    /**
     * マルチファイルアップロード管理
     * @param {*} file 
     */
    managedUpload(file, progressCallBack) {
        const { folder } = this;
        let fileName = file.name;
        let fileKey;
        if (folder || folder === 0)
            fileKey = `${folder}/${fileName}`;
        else
            fileKey = fileName;

        this.request = new AWS.S3.ManagedUpload({
            partSize: 100 * 1024 * 1024,
            queueSize: 1,
            params: { Bucket: bucketName, Key: fileKey, Body: file }
        });
        this.request.on('httpUploadProgress', (progress) => {
            if (progressCallBack) progressCallBack(progress);
            else console.log('progress:', Math.floor(progress.loaded / progress.total * 100));
        });

        this.request.send((err, data) => {
            if (err)
                console.error(err);
            console.info(data);
        });
        return this.request.promise();
    }

    /**
     *  ファイル選択
     * @param {*} event 
     */
    fileSelectorHandler(event) {
        event.preventDefault();
        this.template.querySelector('c-fileupload-modal').open();
    }

    /**
     * フォルダ選択
     * @param {*} event 
     */
    folderchangeHandler(event) {
        this.folder = event.detail;
    }

    /**
     * RowAction
     * @param {*} event 
     */
    async handleRowAction(event) {
        const action = event.detail.action;
        const row = event.detail.row;
        switch (action.name) {
            case 'download':
                await this.fileDownload(row.key);
                break;
            case 'delete':
                await this.deleteFile(row.key);
                break;
        }
    }

    /**
     * 初期化
     */
    connectedCallback() {
        this.columns = [
            { label: 'フォルダー名', fieldName: 'folder' },
            { label: 'ファイル名', fieldName: 'fileName', },
            { label: 'タイプ', fieldName: 'fileType' },
            { label: '最新更新日', fieldName: 'LastModified', },
            { label: '所有者', fieldName: 'Owner', },
            { label: 'サイズ', fieldName: 'Size', },
            { label: 'ストレージクラス', fieldName: 'StorageClass', },
            {
                type: 'action',
                typeAttributes: {
                    rowActions: [
                        { label: 'ダウンロード', name: 'download' },
                        { label: '削除', name: 'delete' }
                    ],
                    menuAlignment: 'auto'
                }
            }
        ];
    }

    /**
     * aws-sdkロード
     */
    renderedCallback() {
        if (this.jsinit)
            return;
        this.jsinit = true;
        Promise.all([
                loadScript(this, AWS_SDK),
            ])
            .then(async() => {
                await this.initAWS();
            })
            .catch(error => {
                showToast(this, 'JSライブラリロードに失敗しました', error.message, 'error')
            });
    }
}
```
```fileuploadMock.js-meta.xml
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>52.0</apiVersion>
    <isExposed>true</isExposed>
    <targets>
        <target>lightning__Tab</target>
    </targets>
</LightningComponentBundle>
```
####fileuploadModal
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1767576/3dc8eb39-7e1b-d64f-c031-f43c35c09a55.png)

```fileuploadModal.html
<template>
    <!--Use template if:true to display/hide popup based on isModalOpen value-->
    <template if:true={_isModalOpen}>
        <!-- Modal/Popup Box LWC starts here -->
        <section role="dialog" tabindex="-1" aria-modal="true" class="slds-modal slds-fade-in-open"
            style="z-index:9001">
            <div class="slds-modal__container" style="width: auto;max-width: fit-content;">
                <!-- Modal/Popup Box LWC header here -->
                <header class="slds-modal__header">
                    <button class="slds-button slds-button_icon slds-modal__close slds-button_icon-inverse"
                        title="Close" onclick={close}>
                        <lightning-icon icon-name="utility:close" alternative-text="close" variant="inverse"
                            size="small">
                        </lightning-icon>
                        <span class="slds-assistive-text">Close</span>
                    </button>
                    <h2 class="slds-text-heading_medium slds-hyphenate">{title}</h2>
                </header>
                <!-- Modal/Popup Box LWC body starts here -->
                <div class="slds-modal__content slds-p-around_medium" style="height:50%">
                    <lightning-input label="パス" name="path" onchange={commonChange}></lightning-input>
                    <lightning-input type="file" label="ファイルアップロード" onchange={uploadHandler}>
                    </lightning-input>
                </div>
                <!-- Modal/Popup Box LWC footer starts here -->
                <footer class="slds-modal__footer">
                    <button class="slds-button slds-button_neutral" onclick={close}>キャンセル</button>
                    <!-- <button class="slds-button slds-button_brand" onclick={confirmHandle}>ファイル追加</button> -->
                </footer>
            </div>
        </section>
        <div class="slds-backdrop slds-backdrop_open"></div>
    </template>
</template>
```
```fileuploadModal.js
import { LightningElement, track, api } from 'lwc';
export default class FileUploadModal extends LightningElement {

    @api name;
    @track path;
    //表示フラグ
    @track _isModalOpen;

    /**
     * 共通Change処理
     * @param {*} event 
     */
    commonChange(event) {
        let name = event.target.name;
        let value = event.target.value;
        this[name] = value;
        this.dispatchEvent(new CustomEvent("folderchange", {
            detail: value,
            composed: true,
            bubbles: true,
            cancelable: true,
        }));
    }

    /**
     * ファイルアップロード
     * @param {*} event 
     */
    uploadHandler(event) {
        let changenEvent = new CustomEvent("select", {
            detail: event.target,
            composed: true,
            bubbles: true,
            cancelable: true,
        });
        this.dispatchEvent(changenEvent);
    }

    /**
     * モーダル開く
     */
    @api
    open() {
        this._isModalOpen = true;
    }

    /**
     * モーダル閉じる
     */
    close(e) {
        e.preventDefault();
        this._isModalOpen = false;
    }
}
```
```fileuploadModal.js-meta.xml
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>52.0</apiVersion>
    <isExposed>false</isExposed>
</LightningComponentBundle>
```
#### progressbar
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1767576/8f5701e8-e9d4-f4a7-2c45-e81f735c724a.png)

```progressbar.html
<template>
  <template if:true={isShow}>
    <section role="dialog" tabindex="-1" class="slds-modal slds-fade-in-open" aria-modal="true" style="z-index:9002">
      <div class="slds-modal__container" style="width: auto;max-width: 50rem;">
        <header class="slds-modal__header">
          <button class="slds-button slds-button_icon slds-modal__close slds-button_icon-inverse" title="Close"
            onclick={cancel}>
            <lightning-icon icon-name="utility:close" alternative-text="close" variant="inverse" size="small">
            </lightning-icon>
            <span class="slds-assistive-text">Close</span>
          </button>
          <h2 class="slds-modal__title slds-hyphenate">ファイルをアップロード</h2>
        </header>
        <div class="slds-modal__content slds-p-around_medium"
          style="display: grid;grid-template-columns: 0.5fr 3fr 3fr 0.5fr; height: 5rem;overflow-y: hidden;">
          <div style="align-self: center;">
            <span class="slds-icon_container slds-icon-doctype-xml">
            </span>
          </div>
          <div style="align-self: center;">
            <span>{uploadFileName} <br /><b>{progress}%</b></span>
          </div>
          <div class="slds-progress-bar slds-progress-bar_circular"
            style="align-self: center;height: 0.6rem;width: 20rem;">
            <span class="slds-progress-bar__value" style={barStyle}></span>
          </div>
          <div style="align-self: center;margin-left: 0.2rem;">
            <span class={barClass}>
            </span>
          </div>
        </div>
        <footer class="slds-modal__footer">
          <span style="float: left;"></span>
          <button class="slds-button slds-button_brand" onclick={cancel}>キャンセル</button>
        </footer>
      </div>
    </section>
    <div class="slds-backdrop slds-backdrop_open"></div>
  </template>
</template>
```
```progressbar.js
import { LightningElement, track, api } from 'lwc';

const bar_cancel_class = 'slds-icon_container slds-icon_container_circle slds-icon-action-description slds-icon-standard-password';
const bar_success_class = 'slds-icon_container slds-icon_container_circle slds-icon-action-description slds-icon-text-success';
// const approval_flag = myResource + 'action-sprite/svg/symbols.svg#approval';
// const remove_flag = myResource + 'action-sprite/svg/symbols.svg#remove';
export default class Fileupload extends LightningElement {

    @api uploadFileName;
    @api progress = 0;
    @track isShow;

    /**
     * ProgressBar
     */
    get barStyle() {
        return `width:${this.progress}%`;
    }

    get barClass() {
        return this.progress >= 100 ? bar_success_class : bar_cancel_class;
    }

    /**
     * キャンセル
     */
    cancel(e) {
        e.preventDefault();
        this.dispatchEvent(new CustomEvent('abort', {
            detail: true
        }));
        this.close();
    }

    @api
    close() {
        this.isShow = false;
    }

    @api
    open() {
        this.isShow = true;
    }
}
```
```progressbar.js-meta.xml
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>52.0</apiVersion>
    <isExposed>false</isExposed>
</LightningComponentBundle>
```
#### utils
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1767576/82c5d06d-37d2-6212-d4c5-36f0479d5114.png)

```utils.js

import { ShowToastEvent } from 'lightning/platformShowToastEvent';

/**
 * メッセージ表示
 * @param {window} that 
 * @param {string} title タイトール
 * @param {string} message メッセージ
 * @param {string} variant タイプ info、success、warning、error
 */
export const showToast = (that, title, message, variant) => {
    const event = new ShowToastEvent({
        title: title,
        message: message,
        variant: variant,
    });
    that.dispatchEvent(event);
}

/**
 * デートフォマート
 * @param {Date} date date
 * @param {string} fmt format
 * @returns {string} StringDate
 */
export const dateFormat = (date, fmt = 'YYYY/mm/dd') => {
    let ret;
    const opt = {
        'Y+': date.getFullYear().toString(), // 年
        'm+': (date.getMonth() + 1).toString(), // 月
        'd+': date.getDate().toString(), // 日
        'H+': date.getHours().toString(), // 時
        'M+': date.getMinutes().toString(), // 分
        'S+': date.getSeconds().toString() // 秒
    };
    for (let k in opt) {
        ret = new RegExp('(' + k + ')').exec(fmt);
        if (ret) {
            fmt = fmt.replace(ret[1], (ret[1].length == 1) ? (opt[k]) : (opt[k].padStart(ret[1].length, '0')))
        };
    };
    return fmt;
}

/**
 * YYYY/MM/DD⇒Mon Nov 27 2017 20:30:00 GMT+0900 (JST)に変換
 * @param {string} dataStr stringDate
 * @returns {Date} Date
 */
export const datePrase = (dataStr) => {
    return new Date(dataStr);
}

/**
 * デートフォマート
 * @param {string} date strData
 * @param {string} fmt format
 * @returns {string} StringDate
 */
export const strDateFormat = (strData, fmt = 'YYYY/mm/dd HH:MM:SS') => {
    return dateFormat(datePrase(strData), fmt);
}

/**
 * ファイルサイズ変換
 * @param {*} size バイト
 * @returns 変換後のサイズ
 */
export const fileSizeUnit = (size) => {
    // 1 KB = 1024 Byte
    const kb = 1024
    const mb = Math.pow(kb, 2)
    const gb = Math.pow(kb, 3)
    const tb = Math.pow(kb, 4)
    const pb = Math.pow(kb, 5)
    const round = (size, unit) => {
        return Math.round(size / unit * 100.0) / 100.0
    }

    if (size >= pb) {
        return round(size, pb) + 'PB'
    } else if (size >= tb) {
        return round(size, tb) + 'TB'
    } else if (size >= gb) {
        return round(size, gb) + 'GB'
    } else if (size >= mb) {
        return round(size, mb) + 'MB'
    } else if (size >= kb) {
        return round(size, kb) + 'KB'
    }
    return size + 'バイト'
}
```
```utils.js-meta.xml
<?xml version="1.0" encoding="UTF-8"?>
<LightningComponentBundle xmlns="http://soap.sforce.com/2006/04/metadata">
    <apiVersion>52.0</apiVersion>
    <isExposed>false</isExposed>
</LightningComponentBundle>
```

## 4.Salesforce側動作確認
### 4.1.Salesforce側Lightning コンポーネントタブを作成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1767576/ec207822-8224-406c-1df1-cd1d344d9fde.png)

### 4.2.タブを開く
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1767576/f32fb168-fe71-8a0d-8df4-0391d2d28323.png)

### 4.3.ファイル追加
![スクリーンショット 2021-07-28 121057.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1767576/428267bc-e713-b027-fad2-de0ff54bfde2.png)
### 4.4.ファイルダウンロード
![スクリーンショット 2021-07-28 121134.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1767576/6e2fa66f-feab-3c69-dc62-905fa6aacfa4.png)
### 4.5.ファイル削除
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1767576/e04c73af-8878-3389-d241-efe61efef9a9.png)


## 5.参考

https://github.com/aws/aws-sdk-js

https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/getting-started-browser.html

https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/ManageCorsUsing.html

https://developer.salesforce.com/docs/component-library/bundle/lightning-platform-resource-loader/documentation

https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/S3.html