4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

FastAPIにおけるファイルアップロードとダウンロードの色んな方法それぞれの纏め

Last updated at Posted at 2024-09-12

はじめに

FastAPIを使う場合ファイルのアップロードや提供をさせる時に色んな書き方がありますね。調べてみても書き方はそれぞれ違うものがあってどんな書き方をしたらいいか迷ってしまいます。だからこの記事では纏めてみたいと思います。

この記事の説明はFastAPIを含めたPythonがメインですが、全体の仕様をわかりやすいように、htmlとJavaScriptで書いたウェブページのコードもここに載せて一緒に説明します。又、基本的に動けばいいという機能重視なのでcssを使わず地味なページとなります。

インストール

この記事は入門の記事ではないから書く必要ないかもしれませんが、一応必要なライブラリーのことも書いておきます。FastAPIを使う際にfastapiとuvicornは勿論ですが、フォームやファイルの扱いをしたい場合python-multipartもインストールしておく必要があります。

pip install fastapi uvicorn python-multipart

前提

今回のサンプルコードはFastAPIで書いたPythonコードをweb.pyに、htmlとJavaScriptで書いた静的ウェブページはindex.htmlに保存しますので、2つのファイルから基本的に構成されます。

web.py
index.html

そしてこのコマンド実行します。

uvicorn web:app --reload

そうしたらhttp://127.0.0.1:8000でウェブアプリにアクセスします。

ファイル提供させる方法

まずはファイルを提供する側から説明します。

FileResponseを使う

FastAPIで準備しておいたファイルをダウンロードとして提供したい時色んな方法がありますが、一番簡単なのはFileResponseを使うことです。これは名前の通りファイルという形でレスポンスするためのクラスです。

例えばxxx.txtというファイル(名前は適当)があってダウンロードのために準備する場合はこう簡単に書けます。

web.py
from fastapi import FastAPI,responses

app = FastAPI()

@app.get('/xxx')
async def download():
    return responses.FileResponse('xxx.txt',filename='fff.txt')

これでhttp://127.0.0.1:8000/xxxにアクセスしたらファイルダウンロードは始めてfff.txtという名前のファイルが保存されるはずです。

ここでxxx.txtはファイルのパスで、fff.txtダウンロードされる時に保存される名前です。

ここに注意するべきはfilename=はちゃんと書く必要があります。これがないとファイルダウンロードではなく、ただブラウザにそのファイルの内容を表示させるだけとなります。(この仕様はブラウザの設定にもよりますが)

URLから任意のファイルをダウンロードさせる

任意の名前のファイルを指定してその中のファイルを読み込ませてダウンロードさせることもできます。

例えばこう書きます。

web.py
from fastapi import FastAPI,responses

app = FastAPI()

@app.get('/download/{filename}')
async def download(filename: str):
    return responses.FileResponse(filename,filename=filename)

これでhttp://127.0.0.1:8000/download/yyy.txtにアクセスしたらyyy.txtファイルをダウンロードできます。

ページ内でダウンロードさせる

FastAPIだけの話ではないのですが、サーバーから提供されたファイルをページ移動せずにJavaScriptでダウンロードを始めさせる方法です。

まずPythonの方は静的ページを指定する部分を追加します。

web.py
from fastapi import FastAPI,responses,staticfiles

app = FastAPI()

@app.get('/download/{filename}')
async def download(filename: str):
    return responses.FileResponse(filename)

app.mount('/', staticfiles.StaticFiles(directory='.',html=True))

因みにfilename=は今回入れていません。JavaScriptで仕様を制御するから、データだけ渡せばいいです。

htmlとJavaScriptの方は、fetchを使う場合。

index.html
<button onclick="dload()">実行</button>
<script>
function dload() {
  fetch("/download/xxx.txt")
    .then((res) => res.blob())
    .then((blob) => {
      let a = document.createElement("a");
      a.href = URL.createObjectURL(blob);
      a.download = "fff.txt";
      a.click();
    });
}
</script>

わかりにくいからasyncawaitで書き換えた方がいいかも。

index.html
<button onclick="dload()">実行</button>
<script>
async function dload() {
  let blob = await (await fetch("/download/xxx.txt")).blob()
  let a = document.createElement("a");
  a.href = URL.createObjectURL(blob);
  a.download = "fff.txt";
  a.click();
}
</script>

代わりにaxiosを使う場合。

index.html
<button onclick="dload()">実行</button>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
async function dload() {
  let res = await axios.get("/download/xxx.txt", { responseType: "blob" })
  let a = document.createElement("a");
  a.href = URL.createObjectURL(res.data);
  a.download = "fff.txt";
  a.click();
}
</script>

そうしたらhttp://127.0.0.1:8000/にアクセスするとこのように地味なボタンがある画面になります。

jikkou_botan.png

これを押したらダウンロードが始まります。

Responseを使う

以上説明したFileResponseは予め保存しておいたファイルをそのまま渡す方法です。でもその他にも例えばファイルを別々で準備したり読み込んで少しの処理したりしたい場合は使えません。この場合はResponseを使うという方法がいいです。

web.py
from fastapi import FastAPI,Response

app = FastAPI()

@app.get('/download')
async def download():
    return Response('ああぁ',headers={'Content-Disposition': f'attachment; filename="aaa.txt"'})

これでaaa.txtという「ああぁ」しかないテキストファイルができます。

ここでヘッダーのところにContent-Dispositionを入れてファイルとしてダウンロードさせることを示します。ファイルの名前もここに入れることになります。FileResponseの時filename=を入れるのと同じです。

又、ファイルを読み込んでResponseに使うこともできます。例えばこう書いたらFileResponseを使う時と同じ結果になります。

web.py
from fastapi import FastAPI,Response

app = FastAPI()

@app.get('/download/{filename}')
async def download(filename: str):
    with open(filename,'rb') as f:
        file = f.read()
    return Response(file,headers={'Content-Disposition': f'attachment; filename="{filename}"'})

これ例ならFileResponseを使った方が簡潔なのでResponseを使う必要ないかもしれません。

ASCIIでない名前の場合

Responseを使う時に一つ問題があります。実はこのままでは日本語などASCIIでない文字を名前にしたらエラーになります。

そうしないようにちょっと面倒ですが、このように文字を変換する必要があります。例えばこのように書き換えます。

web.py
from fastapi import FastAPI,Response
from urllib.parse import quote

app = FastAPI()

@app.get('/download/{filename}')
async def download(filename: str):
    with open(filename,'rb') as f:
        file = f.read()
    hd = {'Content-Disposition': f"attachment; filename*=utf-8''{quote(filename)}"}
    return Response(file,headers=hd)

StreamingResponseを使う

ストリームとしてダウンロードさせることもできます。この場合はStreamingResponseを使います。

使い方は色々ありますが、例えばこのようにファイルを読み込んでyield fromでジェネレーターを作るのです。

web.py
from fastapi import FastAPI,responses
from urllib.parse import quote

app = FastAPI()

@app.get('/download/{filename}')
async def download(filename: str):
    def iterstream():
        with open(filename,'rb') as f:
            yield from f
    hd = {'Content-Disposition': f"attachment; filename*=utf-8''{quote(filename)}"}
    return responses.StreamingResponse(iterstream(),headers=hd)

書き方はわかりにくいが、ResponseFileResponseで書いた場合と同じようにファイルダウンロードできます。

尚、yield fromを見て見慣れなくて意味わからないと感じる人も多いと思います。iterstream()関数はfromを使わずにこうやってforループで書き換えることもできます。

def iterstream():
    with open(filename,'rb') as f:
        for st in f:
            yield st

用は、この書き方をするとファイルの中身は一気に読み込まれるのではなく、何度も繰り返して読み込まれることになるのです。

アップロードを受け取る方法

次はブラウザからのファイルを受け取らせる方法です。

Requestを使う方法

まずは一番基本的な方法から説明します。それはRequestクラスとしてpostメソッドによって送られたデータを抽出することです。

例えばファイルを受け取ってすぐ保存するならこのように書きます。

web.py
from fastapi import FastAPI,Request,responses,staticfiles

app = FastAPI()

@app.post('/upload')
async def upload(request: Request):
    form = await request.form()
    file = form['ufile']
    bf = await file.read()
    with open(file.filename,'wb') as f:
        f.write(bf)
    return responses.RedirectResponse('/',status_code=301)

app.mount('/', staticfiles.StaticFiles(directory='.',html=True))

ファイルをアップロードするメインのindex.htmlはとりあえずJavaScriptすら書かずにただhtmlのフォームを使ってもいいです。

index.html
<form action="/upload" method="post" enctype="multipart/form-data">
  <input name="ufile" type="file"><br><br>
  <button type="submit">実行</button>
</form>

このようにファイル選択と実行するボタンしかないページができます。

upload_botan.png

ここでファイルを選択して実行したらフォームとしてファイルがサーバーへ送られて処理されます。

送られたフォームはFastAPIでRequestクラスのオブジェクトに含まれ、await request.form()で抽出できます。フォームの中で色々入れられますが、今回はファイルだけ。htmlでname="ufile"としたので、ここではform['ufile']で抽出するということになります。

formタグの代わりにJavaScriptでFormDataを作る

上述htmlのformタグを使うという方法ではページ移動が行われますが、そうではなくJavaScriptで実行してデータだけ送ることもできます。そうしたら元のページに戻るためのRedirectResponseも必要ないです。代わりに何かレスポンスをreturnこともできますが、特に何もなければreturnなしでいいです。

FastAPIの方はちょっと書き直します。

web.py
from fastapi import FastAPI,Request,staticfiles

app = FastAPI()

@app.post('/upload')
async def upload(request: Request):
    form = await request.form()
    file = form['ufile']
    bf = await file.read()
    with open(file.filename,'wb') as f:
        f.write(bf)

app.mount('/', staticfiles.StaticFiles(directory='.',html=True))

index.htmlの方はこうやってFormDataで書きます。

index.html
<input id="ufile" type="file"><br><br>
<button onclick="upload()">実行</button>
<script>
function upload() {
  let inputelm = document.getElementById("ufile");
  let file = inputelm.files[0];
  if (file) {
    let formdata = new FormData();
    formdata.append("ufile", file);
    fetch("/upload", {
      method: "post",
      body: formdata,
    });
    inputelm.value = "";
  }
}
</script>

formタグがなくても見た目は変わりません。ファイル選択できて、実行をクリックしたらファイルが送られます。複雑になりますが、ページ移動せずにデータを送ることができます。

UploadFileを使う

上述の方法よりも簡潔で、フォームのことを意識せずにすぐファイルを扱うことができます。それはrequest: Requestufile: UploadFileに書き換えることです。

web.py
from fastapi import FastAPI,UploadFile,staticfiles

app = FastAPI()

@app.post('/upload')
async def upload(ufile: UploadFile):
    bf = await ufile.read()
    with open(ufile.filename,'wb') as f:
        f.write(bf)

app.mount('/', staticfiles.StaticFiles(directory='.',html=True))

index.htmlの方は上の例から変更ないので省略します。

responseresponseでこの名前でなければならないのに対し、ここでのufileは固定の名前ではなく、フォームの中で指定した任意の名前です。今回の例ではformdata.append("ufile", file);のところで決まったのです。

型ヒントUploadFileを指定したことでFastAPIの中で自動的にフォームの中のファイルを抽出してくれるのです。そのおかげで書き方は簡潔で便利です。

bytesとして受け取る

UploadFileを使う方法よりも更に簡潔な方法があります。それはUploadFileの代わりにbytes = File()を書くことです。そうしたら直接にバイトデータとして取得することになって、こうなるとawaitを使う必要もなく、asyncも省略できます。

ただしこの方法ではファイルのデータしか取得できなくてファイルの名前などの情報が取得できないので、本当にファイルの中身しか必要としない場合だけ使った方がいいでしょう。例えば名前は既に決まっている場合です。

そうしたらこう書きます。

web.py
from fastapi import FastAPI,File,staticfiles

app = FastAPI()

@app.post('/upload')
def upload(ufile: bytes = File()):
    with open('xxx.txt','wb') as f:
        f.write(ufile)

app.mount('/', staticfiles.StaticFiles(directory='.',html=True))

又、その他にAnnotatedを使う書き方もありますが、結果は同じなので必要ありません。一応比べられるようにその書き方もここに載せておきます。

web.py
from fastapi import FastAPI,File,staticfiles
from typing import Annotated

app = FastAPI()

@app.post('/upload')
def upload(ufile: Annotated[bytes,File()]):
    with open('xxx.txt','wb') as f:
        f.write(ufile)

app.mount('/', staticfiles.StaticFiles(directory='.',html=True))

ファイルの名前を取得したいならrequest: Requestと一緒に使ってもいいです。

例えばこう書きます。

web.py
from fastapi import FastAPI,Request,File,staticfiles

app = FastAPI()

@app.post('/upload')
async def upload(request: Request,ufile: bytes = File()):
    form = await request.form()
    with open(form['ufile'].filename,'wb') as f:
        f.write(ufile)

app.mount('/', staticfiles.StaticFiles(directory='.',html=True))

データタイプやサイズを調べて対応する

ファイルと一緒に送られる情報はファイル名の他にも色々あります。例えばデータタイプやサイズです。これを調べてどう対応するか決めることもできます。

例えば50kB以内のjpegpngしか受け取って、それ以外エラーを返すということもできます。

web.py
from fastapi import FastAPI,UploadFile,File,staticfiles,HTTPException

app = FastAPI()

@app.post('/upload')
async def upload(ufile: UploadFile = File()):
    if(ufile.size>50*1024): # 50kB以内
        raise HTTPException(status_code=400, detail='太すぎる!')
    if(ufile.content_type not in ['image/jpeg','image/png']):
        raise HTTPException(status_code=400, detail='jpgとpngしか受け取らない')
    else:
        bf = await ufile.read()
        with open(ufile.filename,'wb') as f:
            f.write(bf)
        return 'アップロード完了'

app.mount('/', staticfiles.StaticFiles(directory='.',html=True))

index.htmlの方もエラーを表示するように書きます。

index.html
<input id="ufile" type="file"><br><br>
<button onclick="upload()">実行</button>
<script>
function upload() {
  let inputelm = document.getElementById("ufile");
  let file = inputelm.files[0];
  if (file) {
    let formdata = new FormData();
    formdata.append("ufile", file);
    fetch("/upload", {
      method: "post",
      body: formdata,
    }).then(async (res) => {
      if (!res.ok)
        alert("エラー:" + (await res.json()).detail);
    });
    inputelm.value = "";
  }
}
</script>

尚、content_type属性は拡張子を元に自動的に追加されたが、中身は違う場合もあります。その場合他の方法で対策する必要がありますが、それについては別の話なので割愛します。

複数ファイルをアップロードさせる

単体のUploadFileの代わりにリストのlist[UploadFile]に書き換えたら複数のファイルをリストとして受け取ることができます。

そして例えばこのようにforループで一つずつ保存することができます。

web.py
from fastapi import FastAPI,UploadFile,staticfiles

app = FastAPI()

@app.post('/upload')
async def upload(lis_file: list[UploadFile]):
    for file in lis_file:
        bf = await file.read()
        with open(file.filename,'wb') as f:
            f.write(bf)

app.mount('/', staticfiles.StaticFiles(directory='.',html=True))

index.htmlの方も複数ファイル選択できるように書き直します。

index.html
<input id="lis_file" type="file" multiple><br><br>
<button onclick="upload()">実行</button>
<script>
function upload() {
  let inputelm = document.getElementById("lis_file");
  if (inputelm.files.length) {
    let formdata = new FormData();
    Array.from(inputelm.files).forEach((file) => {
      formdata.append("lis_file", file);
    });
    fetch("/upload", {
      method: "post",
      body: formdata,
    });
    inputelm.value = "";
  }
}
</script>

その他にも、list[UploadFile]を使わずにrequest: Requestを使うこともできます。

web.py
from fastapi import FastAPI,Request,staticfiles

app = FastAPI()

@app.post('/upload')
async def upload(request: Request):
    form = await request.form()
    for file_ in form._list:
        file = file_[1]
        bf = await file.read()
        with open(file.filename,'wb') as f:
            f.write(bf)

app.mount('/', staticfiles.StaticFiles(directory='.',html=True))

list[UploadFile]を使った方が簡潔でわかりやすいですね。

フォームを使わずにblobとして扱う

以上のやり方では全部フォームを通じてアップロードする方法ですが、実はフォームを使わずにデータだけそのまま送る方法もあります。ただしこの場合ファイル名などのデータはヘッダーに入れる必要があります。

使う例です。FastAPI側はrequest: Requestで受け取って、.body()でファイルデータを抽出します。名前の方は.headers['file-name']から。

web.py
from fastapi import FastAPI,Request,staticfiles

app = FastAPI()

@app.post('/upload')
async def upload(request: Request):
    bf = await request.body()
    name = request.headers['file-name']
    with open(name,'wb') as f:
        f.write(bf)

app.mount('/', staticfiles.StaticFiles(directory='.',html=True))

index.htmlの方はFormDataを作成せずに単にinputタグの中のファイルをそのままfetchに渡します。ただしheadersでは"file-name"属性にファイルの名前を入れます。

index.html
<input id="ufile" type="file"><br><br>
<button onclick="upload()">実行</button>
<script>
function upload() {
  let inputelm = document.getElementById("ufile");
  let file = inputelm.files[0];
  if (file) {
    fetch("/upload", {
      method: "post",
      body: file,
      headers: { "file-name": file.name }
    });
    inputelm.value = "";
  }
}
</script>

尚、ここで"file-name"は任意の名前ではなく、固定です。"filename"などにしたら読み込めなくなります。ヘッダーの属性は何もかも勝手に入れられるわけではないからです。

この方法では一つしか送れないので、複数のファイルを送りたい場合やはりフォームに纏め送る必要があります。

参考&もっと読む

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?