FastAPI を使って PC のファイルを別の場所にコピーするだけの無駄機能を作る

## デモサイト

推奨ブラウザは Google Chrome です。  
アクセスに時間がかかる場合がありますので、あらかじめご了承ください。

https://copy-file-muda.onrender.com

:::note alert
ファイルの内容をサーバに保存するなどの処理は行っていませんが、サーバを経由しますので、機密情報等をアップロードしないようにお願いいたします。
:::

ソースコードはこちらです。

https://github.com/noritakaIzumi/copy-file-muda

## モチベーション

FastAPI でファイルのリクエストを学習しているときに思いつきました。

## 概要

ユーザがアップロードしたファイルを別のディレクトリにダウンロードします。

![flow.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/486784/3b3b4018-df70-c37a-f227-c35d8e5e677f.png)

## ソースコード

ファイル構造は以下のようになっています。

```text
app
├── main.py (送られてきたファイルをそのまま返却する処理)
├── static
│   └── js
│       └── index.js (ファイルをアップロードしてダウンロードする処理)
└── templates
    └── index.html (ファイルをアップロードする画面)
```

### 送られてきたファイルをそのまま返却する処理

```python:main.py
import os.path
from io import BytesIO

from fastapi import FastAPI, UploadFile
from starlette.requests import Request
from starlette.responses import StreamingResponse, HTMLResponse, Response
from starlette.staticfiles import StaticFiles
from starlette.templating import Jinja2Templates

current_dir = os.path.dirname(__file__)

app = FastAPI()
app.mount("/static", StaticFiles(directory=f"{current_dir}/static"), name="static")
templates = Jinja2Templates(directory=f"{current_dir}/templates")


@app.get("/", response_class=HTMLResponse)
def copy_file_form(request: Request) -> Response:
    return templates.TemplateResponse(request, name="index.html", context={})


@app.post("/copy_file/")
async def copy_file(file: UploadFile) -> Response:
    return StreamingResponse(
        content=BytesIO(await file.read(-1)),
        status_code=200,
        headers={"content-disposition": f"attachment; filename={file.filename}"},
    )
```

`copy_file_form` は画面表示用 API です。  
`copy_file` でアップロードされたファイルを読み取り、ストリームレスポンスに流しています。

### ファイルをアップロードする画面

```jinja:templates/index.html
<html lang="en">
<head>
    <title>Muda File Copy</title>
</head>
<body>
<form id="form" action="{{ url_for('copy_file') }}" method="post" enctype="multipart/form-data">
    <input id="input-file" type="file" name="file">
    <input id="btn-copy" type="submit" value="Copy">
</form>
</body>
<script src="{{ url_for('static', path='/js/index.js') }}"></script>
```

フォームは `copy_file` 関数の `/copy_file/` へ POST メソッドで送られます。

### ファイルをアップロードしてダウンロードする処理

```js:static/js/index.js
(() => {
    if (!("showSaveFilePicker" in window)) {
        alert("window.showSaveFilePicker is not implemented.")
        document.querySelectorAll("#form input").forEach(elem => {
            elem.disabled = true
        })
        return false
    }

    const showSaveFilePicker = window.showSaveFilePicker

    class FileNotSelectedException extends Error {
    }

    document.getElementById("btn-copy").addEventListener("click", async (evt) => {
        evt.preventDefault()

        let writable
        try {
            const elementById = document.getElementById("input-file");
            if (!elementById.value) {
                // noinspection ExceptionCaughtLocallyJS
                throw new FileNotSelectedException()
            }
            const saveFilePicker = await showSaveFilePicker({
                startIn: "downloads",
                suggestedName: elementById.value.split('\\').pop(),
            })
            writable = await saveFilePicker.createWritable()
        } catch (e) {
            if (e instanceof DOMException) {
                console.warn(e)
                return false
            }
            if (e instanceof FileNotSelectedException) {
                alert("File is not specified.")
                return false
            }
            alert("An error occurred. Please check console.")
        }
        if (!writable) {
            return false
        }

        let ok
        try {
            const form = document.getElementById("form")
            await fetch(form.action, {
                method: "POST",
                body: new FormData(form)
            }).then(async r => {
                await writable.write(await r.blob())
                ok = true
            }).catch(reason => {
                console.error(reason)
                alert("An error occurred. Please check console.")
            })
        } finally {
            await writable.close()
        }
        if (ok) {
            alert("Succeeded to copy a file!")
        }
    })
})()
```

今回はファイル保存先を決めるために `window.showSaveFilePicker` 関数を使用して保存ウィンドウを開きます。  
保存ウィンドウを開くときに、最初はダウンロードフォルダが開くようにし、ファイル名にはアップロードしたファイルと同じファイル名が指定されているようにします。

```js
            const saveFilePicker = await showSaveFilePicker({
                startIn: "downloads",
                suggestedName: elementById.value.split('\\').pop(),
            })
```

`saveFilePicker` から書き込み可能なファイルストリームを開いたら、最初に用意した API に POST メソッドでリクエストを送り、返ってきたボディを書き込みます。

```js
            const form = document.getElementById("form")
            await fetch(form.action, {
                method: "POST",
                body: new FormData(form)
            }).then(async r => {
                await writable.write(await r.blob())
                ok = true
            })
```

:::note warn
現在、`window.showSaveFilePicker` は実験的な機能となっており、対応ブラウザが限られています。詳しくは [こちら](https://developer.mozilla.org/en-US/docs/Web/API/Window/showSaveFilePicker#browser_compatibility) をご覧ください。
:::

## 所感

(Windows) Ctrl + C -> フォルダ移動 -> Ctrl + V で終わるものをわざわざ複雑に作るのもたまには楽しいですね。