【リクエストとレスポンスを追いながら】丁寧に理解するFastAPI

AI に関する世間の関心が高くなる中、アプリケーションを作成しようとすると必然的に機械学習のライブラリが豊富なPython が選択肢になり、Python でアプリを作るのであればそのフレームワークとしてFastAPI が有力な選択肢となります。本記事ではFastAPI について、簡単なアプリケーションを実装しながら学んでいきましょう。

目次

FastAPI とは


FastAPI は、Pythonで記述されたWeb API を構築するためのWebフレームワークです。シンプルで使いやすく、パフォーマンスに優れていながら、API ドキュメントの自動生成、セキュリティと認証の統合、依存性注入といった開発者にとって重要な機能もサポートしており、Python でAPI サーバーを作るうえでは非常に強力なフレームワークと言えます。

早速使うための準備をしていきましょう。

セットアップ

Python のインストールが済んでいる前提で解説を進めます。なお、FastAPI はPython 3.8 以上で動作します。

https://fastapi.tiangolo.com/#requirements

FastAPI を使うにあたって、合わせてUvicorn (またはHypercorn)もインストールする必要があります。インストール時には必要に応じてvenv などを有効にしてください。

pip install fastapi
pip install "uvicorn[standard]"

Uvicorn とはASGI (Asynchronous Server Gateway Interface)サーバーであり、一言でいえばFastAPI で作ったアプリケーションを実行するために必要なコンポーネントです。

ここで、少し寄り道してUvicorn のオプションを見てみましょう。

(venv) fastapi> uvicorn --help
Usage: uvicorn [OPTIONS] APP

Options:
  --host TEXT                     Bind socket to this host.  [default:
                                  127.0.0.1]
  --port INTEGER                  Bind socket to this port. If 0, an
                                  available port will be picked.  [default:    
                                  8000]
  --uds TEXT                      Bind to a UNIX domain socket.
  --fd INTEGER                    Bind to socket from this file descriptor.    
  --reload                        Enable auto-reload.
  --reload-dir PATH               Set reload directories explicitly, instead   
                                  of using the current working directory.      
  --reload-include TEXT           Set glob patterns to include while watching  
                                  for files. Includes '*.py' by default;       
                                  these defaults can be overridden with        
                                  `--reload-exclude`. This option has no       
                                  effect unless watchfiles is installed.       
  --reload-exclude TEXT           Set glob patterns to exclude while watching  
                                  for files. Includes '.*, .py[cod], .sw.*,    
                                  ~*' by default; these defaults can be        
                                  overridden with `--reload-include`. This     
                                  option has no effect unless watchfiles is    
                                  installed.
  --reload-delay FLOAT            Delay between previous and next check if     
                                  application needs to be. Defaults to 0.25s.  
                                  [default: 0.25]
  --workers INTEGER               Number of worker processes. Defaults to the  
                                  $WEB_CONCURRENCY environment variable if     
                                  available, or 1. Not valid with --reload.    
  --loop [auto|asyncio|uvloop]    Event loop implementation.  [default: auto]  
  --http [auto|h11|httptools]     HTTP protocol implementation.  [default:     
                                  auto]
  --ws [auto|none|websockets|wsproto]
                                  WebSocket protocol implementation.
                                  [default: auto]
  --ws-max-size INTEGER           WebSocket max size message in bytes
                                  [default: 16777216]
  --ws-max-queue INTEGER          The maximum length of the WebSocket message  
                                  queue.  [default: 32]
  --ws-ping-interval FLOAT        WebSocket ping interval in seconds.
                                  [default: 20.0]
  --ws-ping-timeout FLOAT         WebSocket ping timeout in seconds.
                                  [default: 20.0]
  --ws-per-message-deflate BOOLEAN
                                  WebSocket per-message-deflate compression    
                                  [default: True]
  --lifespan [auto|on|off]        Lifespan implementation.  [default: auto]    
  --interface [auto|asgi3|asgi2|wsgi]
                                  Select ASGI3, ASGI2, or WSGI as the
                                  application interface.  [default: auto]      
  --env-file PATH                 Environment configuration file.
  --log-config PATH               Logging configuration file. Supported        
                                  formats: .ini, .json, .yaml.
  --log-level [critical|error|warning|info|debug|trace]
                                  Log level. [default: info]
  --access-log / --no-access-log  Enable/Disable access log.
  --use-colors / --no-use-colors  Enable/Disable colorized logging.
  --proxy-headers / --no-proxy-headers
                                  Enable/Disable X-Forwarded-Proto,
                                  X-Forwarded-For, X-Forwarded-Port to
                                  populate remote address info.
  --server-header / --no-server-header
                                  Enable/Disable default Server header.        
  --date-header / --no-date-header
                                  Enable/Disable default Date header.
  --forwarded-allow-ips TEXT      Comma separated list of IPs to trust with    
                                  proxy headers. Defaults to the
                                  $FORWARDED_ALLOW_IPS environment variable    
                                  if available, or '127.0.0.1'.
  --root-path TEXT                Set the ASGI 'root_path' for applications    
                                  submounted below a given URL path.
  --limit-concurrency INTEGER     Maximum number of concurrent connections or  
                                  tasks to allow, before issuing HTTP 503      
                                  responses.
  --backlog INTEGER               Maximum number of connections to hold in     
                                  backlog
  --limit-max-requests INTEGER    Maximum number of requests to service        
                                  before terminating the process.
  --timeout-keep-alive INTEGER    Close Keep-Alive connections if no new data  
                                  is received within this timeout.  [default:  
                                  5]
  --timeout-graceful-shutdown INTEGER
                                  Maximum number of seconds to wait for        
                                  graceful shutdown.
  --ssl-keyfile TEXT              SSL key file
  --ssl-certfile TEXT             SSL certificate file
  --ssl-keyfile-password TEXT     SSL keyfile password
  --ssl-version INTEGER           SSL version to use (see stdlib ssl
                                  module's)  [default: 17]
  --ssl-cert-reqs INTEGER         Whether client certificate is required (see  
                                  stdlib ssl module's)  [default: 0]
  --ssl-ca-certs TEXT             CA certificates file
  --ssl-ciphers TEXT              Ciphers to use (see stdlib ssl module's)     
                                  [default: TLSv1]
  --header TEXT                   Specify custom default HTTP response
                                  headers as a Name:Value pair
  --version                       Display the uvicorn version and exit.        
  --app-dir TEXT                  Look for APP in the specified directory, by  
                                  adding this to the PYTHONPATH. Defaults to   
                                  the current working directory.
  --h11-max-incomplete-event-size INTEGER
                                  For h11, the maximum number of bytes to      
                                  buffer of an incomplete event.
  --factory                       Treat APP as an application factory, i.e. a  
                                  () -> <ASGI app> callable.
  --help                          Show this message and exit.

非常にたくさんのオプションがありますが、例えば待ち受けるポート番号やSSL のバージョン、タイムアウト値など、FastAPI のようなアプリケーションのフレームワークとは切り離すことのできるサーバー側の実装をUnicorn は担っていると言えます。

早速簡単なアプリケーションを作って動作を試してみましょう。main.py を作成します。

from fastapi import FastAPI

app = FastAPI()

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

作成し保存したらuvicorn コマンドでサーバーを立ち上げます。

uvicorn main:app --reload

ブラウザを開いて、http://127.0.0.1:8000/ にアクセスしてみると、以下のようなJSON レスポンスが表示されるはずです。

{"message": "Hello World"}

これで、FastAPIを使用した最初の「Hello World」アプリケーション作成され、実行できました。

ルーティング

先ほどのHello World アプリケーションにおいて、FastAPI 独特の記法(デコレータ)として @app.get("/") が挙げられます。このようなデコレータをうまく使うことで、API のエンドポイントを制御していきます。

例えば先ほどのアプリにおいて、@app.get("/") から@app.get("/hello") に変えてみましょう。すると、http://127.0.0.1:8000/ ではNot Found が返され、http://127.0.0.1:8000/hello でないとアクセスできないことが確認できます。

同様に、POST やDELETE などのHTTP メソッドについてもデコレータで制御できます。以下のような簡単なタスク管理アプリケーションを実装してみましょう。先ほどのmain.py を以下に変更します。

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import List, Optional
from uuid import uuid4, UUID

app = FastAPI()

class Task(BaseModel):
    id: Optional[UUID] = None
    description: str
    completed: bool = False

tasks = []

# タスク一覧の取得
@app.get("/tasks/", response_model=List[Task])
async def read_tasks():
    return tasks

# 新しいタスクの追加
@app.post("/tasks/", response_model=Task)
async def create_task(task: Task):
    task.id = uuid4()
    tasks.append(task)
    return task

# タスクの削除
@app.delete("/tasks/{task_id}", response_model=Task)
async def delete_task(task_id: UUID):
    for task in tasks:
        if task.id == task_id:
            tasks.remove(task)
            return task
    raise HTTPException(status_code=404, detail="Task not found")

同様に実行します。

uvicorn main:app --reload

http://127.0.0.1:8000/tasks にアクセスしても、[] と空のJSON が返ってきます。これはタスクがまだ何もないからです。そこで、POST リクエストを送ってタスクの追加を試します。curl してもいいのですが、せっかくなので自動生成されるAPI ドキュメントから実行してみましょう。http://127.0.0.1:8000/docs にアクセスしてみます。

このAPI ドキュメントからAPI リクエストを実行することができます。

POST リクエストのBody には、アプリケーションで定義したTask クラスのid などのフィールドがあらかじめ定義されています。UUID も自動で生成されているので、ここではdescription をwashing に変更してExecute してみます。

コード200 が返ってきているので、期待通りにタスクが追加されているはずです。GET もドキュメントから試してみましょう。

追加したwashing タスクが取得できていることが確認できます。さらなるタスクの追加や、タスクの削除などもぜひ試してみてください。DELETE 時にはあえて異なるUUID を入力し、404 エラーが返ってくることも試してみると面白いかもしれません。

リクエストとレスポンスの基本

API とは、いうなればどのようなリクエストがクライアントから送信され、どのようなレスポンスをサーバーが返すか、というシステムです。先ほどのタスク管理アプリを例に、もう少しこの点を深堀してみましょう。

以下のPOST リクエストのコードに注目します。

@app.post("/tasks/", response_model=Task)
async def create_task(task: Task):
    task.id = uuid4()
    tasks.append(task)
    return task

ここで、create_task 関数の引数であるtask はコード中に定義された以下のTask クラス型を持ちます。

class Task(BaseModel):
    id: Optional[UUID] = None
    description: str
    completed: bool = False

つまり、POST リクエストを送信する際は、このようなid やdescription、comleted といったフィールドを持たなければならず、かつuuid、str、bool などの型をそれぞれ持たなければなりません。このような型から外れてしまうと、以下のようにPOST リクエストは失敗します。

このようなバリデーション機能により、API サーバーに対して一貫した方法で安全にデータを渡すことができます。このバリデーションはimport しているPydantic によるもので、FastAPI の重要なコンポーネントです。せっかくなので、もう少しPydantic のBaseModel について説明します。BaseModel により、バリデーションやデータの整形を行うことができます。

以下のコードを別のファイルで作成し、実行してみましょう(Uvicorn は使わず、普通にpython ファイル名 で実行)。

from pydantic import BaseModel

class User(BaseModel):
    name: str
    age: int

user = User(name="test", age=20)
# データのJSON への整形
print(user.json())

このようなコードで、たとえばname をダブルクォートで囲まず数字を入れたときはFastAPI の時と同様にエラーが発生します。

さて、ここでUser クラスはBaseModel クラスを継承しています。継承しているので、BaseModel のコンストラクタである__init__ メソッドを持ち、__init__ で定義されている self.pydantic_validator.validate_python でバリデーションエラーが発生します。

    def __init__(self, /, **data: Any) -> None:  # type: ignore
        """Create a new model by parsing and validating input data from keyword arguments.

        Raises [`ValidationError`][pydantic_core.ValidationError] if the input data cannot be
        validated to form a valid model.

        `self` is explicitly positional-only to allow `self` as a field name.
        """
        # `__tracebackhide__` tells pytest and some other tools to omit this function from tracebacks
        __tracebackhide__ = True
        self.__pydantic_validator__.validate_python(data, self_instance=self)

https://github.com/pydantic/pydantic/blob/d77a9403603cfc125b9ff14ea9a45ae15f86b6ed/pydantic/main.py

内部的にはRust で書かれたpydantic-core のSchemaValidator を呼び出しています。例えばstring のチェックでは以下のソースコードが該当しますが、検証には正規表現を使っていることが分かります。

https://github.com/pydantic/pydantic-core/blob/683c5a31a8ec04bb0959b71f2e44a7ab92de1e2f/src/validators/string.rs

話が脱線しましたが、要するにPydantic のBaseModel を使って、クライアントからのリクエストを検証しているわけです。

レスポンスはどうでしょうか?response_model=Task とあるように、これもPydantic による検証が行われます。ここではリクエストとレスポンスは同じ型ですが、変えることもできます。詳細は下記ドキュメントを参照してください。

https://fastapi.tiangolo.com/tutorial/response-model

リクエストとレスポンスの応用

フロントエンドとの連携を意識して、FastAPI をもう少し様々な形式のリクエストやレスポンスに対応するように調整してみましょう。ここでは、フォームとファイルアップロードについて説明します。

フォーム

フォーム(Form)とはその名の通りHTML Formから送信されたデータをここでは指します。典型的にはテキストボックスに入力された値、例えばサイトの問い合わせ欄にあるテキストボックスに質問を記載して送信ボタンを押すと、通常フォームとしてデータがクライアントからWeb サーバーに送信されます。つまり、一般的にはフォームとしてPOST リクエストがクライアントから送信されるわけです。先ほどまではAPI ドキュメントからJSON 形式で送信しましたが、実際にフロントエンドまで含めたアプリケーションを作る場合は、フォームとしてまずデータを受け取り、その後JSON に整形し、FastAPI などのサーバーに送信するほかに、フォームとしてそのまま送信することもできます。

ここで、フォームデータをPOST で送るとき、送信されるデータの形式を示すヘッダのContent-Type にはapplication/x-www-form-urlencoded または multipart/form-data が与えられます。前者はフォームの一般的な値、後者はファイル送信を含む場合の値です。JSON の場合はapplication/json ですので、フォームデータを送信する場合とJSON を送信する場合にはHTTP リクエストのContent-Type が異なります。

先ほどのタスク管理アプリに戻りましょう。先ほどはJSON としてAPI ドキュメントからタスクデータを送信していましたが、今度はフォームとしてデータを送信できるように修正してみましょう。事前にpython-multipart をインストールしておきます。

pip install python-multipart

その後、タスク管理アプリを以下のように修正します。

from fastapi import FastAPI, HTTPException, Form
from pydantic import BaseModel
from typing import List, Optional
from uuid import uuid4, UUID
app = FastAPI()

class Task(BaseModel):
    id: Optional[UUID] = None
    description: str
    completed: bool = False

tasks = []

# タスク一覧の取得
@app.get("/tasks/", response_model=int)
async def read_tasks():
    return 1

# 新しいタスクの追加
@app.post("/tasks/", response_model=Task)
async def create_task(description: str = Form(...), completed: bool = Form(False)):
    task = Task(id=uuid4(), description=description, completed=completed)
    tasks.append(task)
    return task

# タスクの削除
@app.delete("/tasks/{task_id}", response_model=Task)
async def delete_task(task_id: UUID):
    for task in tasks:
        if task.id == task_id:
            tasks.remove(task)
            return task
    raise HTTPException(status_code=404, detail="Task not found")

これまでと同様にFastAPI を起動すると、API ドキュメントのRequest Body に設定するContent Type がapplication/json からapplication/x-www-form-urlencoded に変わっており、実際の送信形式や開発者ツールから見られるリクエストヘッダのContent Type も変更されていることが分かります。なお、レスポンスはJSON で返ってきています。

これで一般的なフォームデータをFastAPI に対して送信し、処理できることが分かりました。

ファイルアップロード

ファイルも実際にはフォームデータとしてアップロードされます。つまり、リクエストのContent Type がmultipart/form-data となります。application/x-www-form-urlencoded とmultipart/form-data はエンコードの方法が異なり、前者はアルファベット以外の文字がパーセントエンコーディング されていることから、バイナリデータを含むような送信には向いていません。なお、パーセントエンコーディングについては、先ほどのフォームデータ送信の際に日本語を入力すればどのようにエンコードされているかが分かります。下記スクリーンショットはdescription に「あ」を入れた場合です。「あ」という文字が%E3%81%82 にエンコードされていますね。

またまた話がそれましたが、multipart/form-data というContent Type を指定することで、データのこのようなエンコーディングを防ぎ、ブロックデータとして直接送信することができます。試してみましょう。

from fastapi import FastAPI, HTTPException, Form, File, UploadFile
from pydantic import BaseModel
from typing import List, Optional
from uuid import uuid4, UUID
app = FastAPI()

class Task(BaseModel):
    id: Optional[UUID] = None
    description: str
    completed: bool = False

tasks = []

# タスク一覧の取得
@app.get("/tasks/", response_model=int)
async def read_tasks():
    return 1

# 新しいタスクの追加
@app.post("/tasks/", response_model=Task)
async def create_task(description: str = Form(...), completed: bool = Form(False)):
    task = Task(id=uuid4(), description=description, completed=completed)
    tasks.append(task)
    return task

# タスクの削除
@app.delete("/tasks/{task_id}", response_model=Task)
async def delete_task(task_id: UUID):
    for task in tasks:
        if task.id == task_id:
            tasks.remove(task)
            return task
    raise HTTPException(status_code=404, detail="Task not found")

@app.post("/files/")
async def create_file(file: Annotated[bytes, File()]):
    return {"file_size": len(file)}


@app.post("/uploadfile/")
async def create_upload_file(file: UploadFile):
    return {"filename": file.filename}

実行すると、API ドキュメントからファイルをアップロードできるようになっているはずです。

ちなみに、今回2つのAPI を追加しています。つまり、入力の形式として、Annotated[bytes, File()] のパターンとUploadFile のパターンです。この2つの違いをもう少し詳しく見てみましょう。

前者のAnnotated[bytes, File()] としてファイルを受け取る場合、バイナリデータとしてファイルを受信し、アップロードされたコンテンツはすべてメモリ上に保存されます。バイナリデータとして受け取るわけですから、ファイル名などの取得もできません。UploadFile で受け取る場合は、メタデータの取得も簡単に行えたり、巨大なサイズのファイルでもメモリ制限に引っかかることがなかったりと、より柔軟な形式と言えます。下記コードは、受け取ったファイルをmain.py と同じディレクトリに保存しますが、Annotated[bytes, File()] はファイル名がハードコードされているのに対し、UploadFile ではアップロードされたファイル名を取得して保存しています。

import os # 追加

@app.post("/files/")
async def create_file(file: Annotated[bytes, File()]):
    # main.py と同じディレクトリにファイルをfile.txt という名前で保存
    path = os.path.join("./", "file.txt")
    with open(path, "wb") as f:
        f.write(file)
    return {"file_size": len(file)}

@app.post("/uploadfile/")
async def create_upload_file(file: UploadFile):
    with open(file.filename, "wb") as f:
        content = await file.read()
        f.write(content)
    return {"filename": file.filename}

UploadFile の方が何かと扱いやすいので、特に理由がなければ、ファイルはUploadFile として受け取った方が良いと思います。

エラーハンドリング

最後に、エラーハンドリングについて簡単に学びます。先ほどまでのアップロードシステムはセキュリティ的に非常に脆弱です。そのままだとあらゆるファイルのアップロードを許可することになりますので、エラーハンドリングを行うことでセキュリティを強化してみましょう。

まずは、画像以外のアップロードを許可しないようにしてみます。create_upload_file を以下のように修正します。

@app.post("/uploadfile/")
async def create_upload_file(file: UploadFile = File(...)):
    # 画像ファイルのMIMEタイプを許可するリスト
    allowed_mime_types = ["image/jpeg", "image/png"]
    # ファイルが許可されたMIMEタイプの1つであることを確認
    if file.content_type not in allowed_mime_types:
        raise HTTPException(status_code=400, detail=f"File type not allowed. Allowed types: {', '.join(allowed_mime_types)}")

    return {"filename": file.filename, "content_type": file.content_type, "file_size": file.size}

ここで、MIME タイプとは簡単に言うとアップロードされたファイルがどのようなデータの種類か、を示します。これはリクエストのContent-Type ヘッダによって決められます。multipart/form-data とは別に、例えばjpg をアップロードする場合には、type=image/jpeg といったヘッダも付くため、これでもってファイル形式を判断できます。

リクエストのContent-Type を見るということは、実はいくらでも偽装できる余地があるということです。実際、例えばAPI ドキュメントからWindows のexe ファイルの拡張子をjpg にした場合でも、実はMIME はimage/jpeg とみなされ、エラーは発生しません。手元にあったWireshark の実行ファイルの拡張子をjpg に変更して試してみると下記のようにjpeg とみなされてしまっていることが分かります。

これを防ぐために、今回はpython-magic というライブラリを活用してマジックナンバーを用いたファイル種判別も組み合わせてみます。マジックナンバーとはファイル先頭のファイルタイプに共通するデータを指します。例えばJPEG は通常FF D8 FF E0 から始まりますので、これをもとにファイルを判定します。ほかのファイルについては例えば下記を参照してください。

https://en.wikipedia.org/wiki/List_of_file_signatures

python-magic はpip でインストールできますが、libmagic に依存するため、こちらのインストールも忘れないでください。例えばWindows の場合は以下の通りです。

pip install python-magic-bin
pip install python-magic
import magic # 追加

@app.post("/uploadfile/")
async def create_upload_file(file: UploadFile = File(...)):
    # 画像ファイルのMIMEタイプを許可するリスト
    allowed_mime_types = ["image/jpeg", "image/png"]
    buffer = await file.read()
    magic_mime_type = magic.from_buffer(buffer, True)
    await file.seek(0)
    # ファイルが許可されたMIMEタイプの1つであることを確認
    if file.content_type not in allowed_mime_types or magic_mime_type not in allowed_mime_types:
        raise HTTPException(status_code=400, detail=f"File type not allowed. Allowed types: {', '.join(allowed_mime_types)}")

    return {"filename": file.filename, "content_type": file.content_type, "file_size": file.size}

これで、拡張子が偽装されていた場合でもエラーが正しく発生するはずです。

ではマジックナンバーがHEX エディタ等で偽装されていた場合はどうでしょうか?……いたちごっこ感が強いですね。

このようなファイルの安全性の検証についてはいろいろな手法があり、限度もあるわけですが、最近登場した面白い方法として、AI をもとにファイル種を判別するgoogle のmagika があります。軽量でアプリケーションに組み込みやすく、精度も99%を達成したということで、ファイル種判別にはかなり使えるのではないかと思います。ちなみにデモサイトで簡単に試せます。

from magika import Magika # 追加

@app.post("/uploadfile/")
async def create_upload_file(file: UploadFile = File(...)):
    # 画像ファイルのMIMEタイプを許可するリスト
    allowed_mime_types = ["image/jpeg", "image/png"]
    buffer = await file.read()
    magic_mime_type = magic.from_buffer(buffer, True)
    m = Magika()
    magika_mime_type = m.identify_bytes(buffer).output.mime_type
    await file.seek(0)
    print(f"file.content_type: {file.content_type}, magic_mime_type: {magic_mime_type}, magika_mime_type: {magika_mime_type}")
    # ファイルが許可されたMIMEタイプの1つであることを確認
    if not (file.content_type in allowed_mime_types and 
            magic_mime_type in allowed_mime_types and
            magika_mime_type in allowed_mime_types):
        raise HTTPException(status_code=400, detail=f"File type not allowed. Allowed types: {', '.join(allowed_mime_types)}")

    return {"filename": file.filename, "content_type": file.content_type, "file_size": file.size}

なお、拡張子を偽装したファイルがどう見えるかログに吐いてみましたが、マジックナンバーとmagika 両方で実行ファイルであることが分かります。

file.content_type: image/jpeg, magic_mime_type: application/x-dosexec, magika_mime_type: application/x-dosexec

ここまで画像ファイルのみを許可するAPI を作成してきましたが、ファイルサイズの判定も重要です。ついでに、巨大な画像をアップロードされないようにエラーハンドリングを追加しましょう。10MB 以上の画像のアップロードに対してエラーを出してみます。

@app.post("/uploadfile/")
async def create_upload_file(file: UploadFile = File(...)):
    # 画像ファイルサイズの上限を設定
    if file.size > 10 * 1024 * 1024:
        raise HTTPException(status_code=400, detail="File size too large. Max 10MB allowed.")
    # 画像ファイルのMIMEタイプを許可するリスト
    allowed_mime_types = ["image/jpeg", "image/png"]
    buffer = await file.read()
    magic_mime_type = magic.from_buffer(buffer, True)
    m = Magika()
    magika_mime_type = m.identify_bytes(buffer).output.mime_type
    await file.seek(0)
    print(f"file.content_type: {file.content_type}, magic_mime_type: {magic_mime_type}, magika_mime_type: {magika_mime_type}")
    # ファイルが許可されたMIMEタイプの1つであることを確認
    if not (file.content_type in allowed_mime_types and 
            magic_mime_type in allowed_mime_types and
            magika_mime_type in allowed_mime_types):
        raise HTTPException(status_code=400, detail=f"File type not allowed. Allowed types: {', '.join(allowed_mime_types)}")

    return {"filename": file.filename, "content_type": file.content_type, "file_size": file.size}

このように、様々な例外判定とそれに対応するメッセージを作成することで、堅牢で使いやすいAPI を作成できます。

最後に一点補足すると、通常、API ドキュメントから直接ファイルをアップロードすることはなく、フロントエンドのアプリケーションからFastAPI にデータが送信されます。つまり、このような不適切なファイルの検証は当然フロントエンドで行い、そもそも送信されること自体を防ぐべきです。が、何らかの理由で送信されてしまった場合に何も対処をしていないと困りますので、API サーバー側でもこのようなエラーハンドリングは行うべきだと思います。ただ、完全な防御というものは存在しないので、不必要にアップロードされたファイルを保存しない、ましてそのまま実行しないなど、仮に悪意のあるファイルがアップロードされたときのことも考えた、リスク低減を加味したアプリケーションの設計をすべきです。

最後に、最終的に出来上がったアプリケーションのソースコードを共有します。

from fastapi import FastAPI, HTTPException, Form, File, UploadFile
from pydantic import BaseModel
from typing import List, Optional
from uuid import uuid4, UUID
from typing import Annotated
import magic
import os 
from magika import Magika

app = FastAPI()

class Task(BaseModel):
    id: Optional[UUID] = None
    description: str
    completed: bool = False

tasks = []

# タスク一覧の取得
@app.get("/tasks/", response_model=int)
async def read_tasks():
    return 1

# 新しいタスクの追加
@app.post("/tasks/", response_model=Task)
async def create_task(description: str = Form(...), completed: bool = Form(False)):
    task = Task(id=uuid4(), description=description, completed=completed)
    tasks.append(task)
    return task

# タスクの削除
@app.delete("/tasks/{task_id}", response_model=Task)
async def delete_task(task_id: UUID):
    for task in tasks:
        if task.id == task_id:
            tasks.remove(task)
            return task
    raise HTTPException(status_code=404, detail="Task not found")

@app.post("/files/")
async def create_file(file: Annotated[bytes, File()]):
    # main.py と同じディレクトリにファイルを保存
    path = os.path.join("./", "file.txt")
    with open(path, "wb") as f:
        f.write(file)
    return {"file_size": len(file)}


@app.post("/uploadfile/")
async def create_upload_file(file: UploadFile = File(...)):
    # 画像ファイルサイズの上限を設定
    if file.size > 10 * 1024 * 1024:
        raise HTTPException(status_code=400, detail="File size too large. Max 10MB allowed.")
    # 画像ファイルのMIMEタイプを許可するリスト
    allowed_mime_types = ["image/jpeg", "image/png"]
    buffer = await file.read()
    magic_mime_type = magic.from_buffer(buffer, True)
    m = Magika()
    magika_mime_type = m.identify_bytes(buffer).output.mime_type
    await file.seek(0)
    print(f"file.content_type: {file.content_type}, magic_mime_type: {magic_mime_type}, magika_mime_type: {magika_mime_type}")
    # ファイルが許可されたMIMEタイプの1つであることを確認
    if not (file.content_type in allowed_mime_types and 
            magic_mime_type in allowed_mime_types and
            magika_mime_type in allowed_mime_types):
        raise HTTPException(status_code=400, detail=f"File type not allowed. Allowed types: {', '.join(allowed_mime_types)}")

    return {"filename": file.filename, "content_type": file.content_type, "file_size": file.size}

まとめ

本記事では、簡単なアプリケーションの作成を通して、FastAPI の基本について学びました。FastAPI はドキュメントがチュートリアル付きでとても充実しているので、次のステップとしてはドキュメントを読みながら必要な機能を学習しつつ、実際にアプリケーションを作ってみるとよいかと思います。