📑

React + FastAPIでファイルアップロード機能作ってみた

2024/09/16に公開

こんにちは、ぴよ太です🐤
久しぶりにブログ書きます!!

やったこと

React + FastAPIでファイルアップロード機能を作成してみました。GitHubにアップしています。
仕事でファイルアップロードの部分を作ることになりそうで、事前に把握しておこうと思ったのがきっかけです。基本的な内容とは思いますが、誰かの役に立てたらうれしいです🥹

出来上がったもの

構成

SPA風で作りました。最近はSPAの記事をあまり見ない。
Next.jsRemixフルスタックが多い気がしている。(NextjsのTutorialやってみたけど、ファイルシステムに基づいたルーティング便利。)

  • フロントエンド
    • Bun
    • React
    • Ant Design
    • TypeScript
  • バックエンド
    • Python
    • FastAPI

画面はReactの勉強もかねて作ってみました🙌

バックエンド

FastAPI | Request Filesをほぼ参考にしました。
FastAPIのドキュメントって分かりやすくて素晴らしいですね。
分かりやすいドキュメント書けるようになりたい…!!

ポイントはUploadFileasyncインターフェースを持っていて非同期という部分です。非同期なのでpath operation関数はasync defになります。

FastAPI | 並行処理とasync / awaitasyncを使う場合の説明があります。

TL;DR:
次のような、await を使用して呼び出すべきサードパーティライブラリを使用している場合:

results = await some_library()

以下の様に async def を使用してpath operation 関数を宣言します。

awaitをサポートしていない場合は、defにて path operation関数を宣言とのことです。

CRUD的な感じでファイル一覧取得 / ファイル保存 / ファイル削除を作りました。

import asyncio
from pathlib import Path
from uuid import uuid4, UUID

import requests
from fastapi import FastAPI, HTTPException, UploadFile, File
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()
DATA_DIRECTORY = Path(__file__).parent.parent / "data"

origins = [
    "*",
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=origins,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

@app.get("/")
def read_root():
    return {"Hello": "World"}


@app.get("/files/")
def get_files():
    """ファイルの一覧を取得する"""
    filenames = [filepath.name for filepath in DATA_DIRECTORY.glob("*")]
    return JSONResponse({"filenames": filenames})


@app.post("/files/upload")
async def save_upload_file(file: UploadFile = File(...)):
    """アップロードされたファイルを保存する

    Args:
        file (UploadFile, optional): _description_. Defaults to File(...).
    """
    file_extension = file.filename.split(".")[-1]  # 拡張子を取得
    file_id = uuid4().hex
    unique_filename = file_id + "." + file_extension  # ファイル名を生成

    # 保存先のパスを生成
    save_path = DATA_DIRECTORY / Path(unique_filename)

    # ファイルの保存
    try:
        contents = await file.read()
        with open(save_path, "wb") as f:
            f.write(contents)

        return JSONResponse(
            {
                "message": "File uploaded successfully",
                "file_id": file_id,
                "filename": file.filename,
            },
            status_code=201,
        )

    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.delete("/files/{file_id}")
def delete_file(file_id: UUID):
    """ファイルを削除する

    Args:
        file_id (UUID): _description_
    """
    filepaths = [filepath for filepath in DATA_DIRECTORY.glob(f"{file_id.hex}.*")]

    if len(filepaths) == 0:
        raise HTTPException(status_code=404, detail="File not found")
    elif len(filepaths) > 1:
        raise HTTPException(status_code=500, detail="Multiple files found")
    else:
        filepaths[0].unlink()

    return JSONResponse(
        {
            "message": "File deleted successfully",
            "file_id": file_id.hex,
        },
        status_code=204,
    )

Swaggerにてファイルアップロードの動作確認。
簡単に動作確認できるの便利ですね。

フロントエンド

単純なアップロードボタンだとつまらないので、ドラッグ&ドロップでアップロードできるようにフロントエンドを作成しました。

ChatPDFのアップロード画面を真似してみました。Ant Designを使っているみたいなので、UIコンポーネントライブラリはAnt Designにしました。
簡単にオシャレなUI作れるいいですね😚

ディレクトリ構成

frontend/src
├── App.tsx
├── assets
│   └── react.svg
├── components
│   ├── DropArea.tsx
│   ├── MyPDFsArea.tsx
│   └── TitleArea.tsx
├── index.css
├── main.tsx
└── vite-env.d.ts

ドラッグ&ドロップの部分

全部を説明するのは大変なので、アップロードに関わるドラッグ&ドロップの部分だけ紹介です!
Ant Designにすでにドラッグ&ドロップのコンポーネントが用意されてました...助かる~。

import { useState } from 'react';
import { Button, Card, Upload, Popover, Input, Space, Progress } from 'antd';
import { InboxOutlined, SendOutlined } from '@ant-design/icons';
import type { UploadProps } from 'antd';
import axios, { AxiosProgressEvent } from 'axios';

interface uploadFile {
  name: string;
  progress: number;
}

const DropArea = () => {
  const [uploadFile, setUploadFile] = useState<uploadFile | null>(null);
  const [url, setUrl] = useState<string>('');

  const customRequest: UploadProps["customRequest"] = async (options) => {
    const { file } = options;
    console.log("file:", file);
    const formData = new FormData();
    formData.append('file', file);

    // ファイルをアップロードする
    axios.post('http://localhost:8000/files/upload', formData, {
      headers: {
        'Content-Type': 'multipart/form-data'
      },
      onUploadProgress: (progressEvent: AxiosProgressEvent) => {
        if (file instanceof File) {
          setUploadFile({
            name: file.name,
            progress: progressEvent.progress as number
          });
        }
      }
    })
  }

  const downloadFilebyURL = async () => {
    if (!url) return;
    const response = await axios.get('http://localhost:8000/files/download', {
      params: { url },
    });
    console.log('response:', response);
  }

  const popoverContent = (
    <>
      <Space.Compact style={{ width: '500px' }}>
        <Input
          placeholder='https://example.com/sample.pdf'
          value={url}
          onChange={(e) => setUrl(e.target.value)}
        />
        <Button type="primary" onClick={downloadFilebyURL}>
          <SendOutlined />
        </Button>
      </Space.Compact>
    </>
  );

  return (
    <>
      <Card bordered style={{ maxWidth: '993px', width: '100%', margin: '0 auto' }} styles={{ body: { padding: '8px' } }}>
        {/* ドラッグ&ドロップによるアップロードするエリア */}
        <Upload.Dragger
          style={{ padding: '16px 0' }}
          customRequest={customRequest}
          showUploadList={false}
        >
          <p className="ant-upload-drag-icon">
            <InboxOutlined />
          </p>
          <p className="ant-upload-text" style={{ fontWeight: 'normal', marginBottom: '22px', marginTop: '-10px' }}>
            <span>Drop PDF here</span>
          </p>
        </Upload.Dragger>
        <div className='ant-upload-option' style={{ position: 'absolute', left: '18px', right: '18px' }}>
          <div style={{ color: 'GrayText', position: 'relative', top: '-30px', right: '0px', fontSize: '12px', display: 'flex', justifyContent: 'space-between', gap: '8px' }}>
            {/* エクスプローラーからアップロードするボタン */}
            <div style={{ display: 'flex', gap: '8px' }}>
              <Upload customRequest={customRequest} showUploadList={false}>
                <Button type="link" style={{ margin: 0, padding: 0 }}>
                  Browse my Computer
                </Button>
              </Upload>
            </div>
            {/* URLを入力してインターネット上のPDFを指定するボタン */}
            <div>
              {/* https://ant.design/components/popover */}
              <Popover content={popoverContent} trigger="click" placement='bottomRight'>
                <Button type="link" style={{ margin: 0, padding: 0 }}>
                  From URL
                </Button>
              </Popover>
            </div>
          </div>
        </div>
      </Card>
      {/* アップロード中のプログレスバー表示 */}
      {uploadFile && (
        <Card bordered style={{ maxWidth: '993px', width: '100%', margin: '0 auto', marginTop: '16px' }} styles={{ body: { padding: '8px' } }}>
          <p>Uploading: {uploadFile.name}</p>
          <Progress percent={Math.ceil(uploadFile.progress * 100)} />
        </Card>
      )}
    </>
  )
};

export default DropArea;

躓いた部分

Ant Designの使い方です。。。
(バックエンドよりフロントエンドに躓くことが多かったです。。。)

  1. Cardコンポーネントのbodyのスタイル変更

    Cardコンポーネントの中にUploadコンポーネントを含める形になっているのですが、Cardコンポーネントの細かい幅が調整できず苦労しました。。

    Cardコンポーネントに、styleとは別にstylesという引数があり、
    そちらでCardの各種スタイルを調整できるようです。ちゃんとドキュメントに書いてありました

  2. アップロード時のプログレスバーの表示

    デフォルトでUploadコンポーネントに搭載されているのですが、
    表示する位置やスタイルを変更したい場合どうするのだろうと悩みました。。

    Youtubeが参考になりました。axiosでプログレス用のハンドラーがあるんですね。便利。

バックエンドはファイルサイズの上限設定やエラーハンドリングに力を入れ始めたら躓くことが多くなるのだろうと思っています。

まとめ

ChatPDFっぽい画面が出来たので、このままChatPDFを作っていこうかなと思っています。そうするとバックエンドも今よりだいぶ複雑になり勉強にもなるのかなと考えています。FastAPIのBackgroud Taskとか使ってみたい。。!

一人前のエンジニアになれるように日々精進してまいります🐣💪

Discussion