FastAPIとsqlite3による簡単なウェブサイトを実装する

最近FastAPIというPythonのウェブフレームワークのことを聞いて興味を持って自分も使ってみたくなって勉強し始めて、実際に使えるウェブサイトの実装までできました。

この記事ではFastAPIの基本的な使い方を説明しながらデータベースを扱う小さなウェブサイトを実装するコードを解説して、そして実行の結果の画像も載せます。

# はじめに
私は元々responderを使っていたのですが、今responderを使っている人は殆どいないらしいです。このFastAPIとresponderは色々似ていますが、responderと比べてFastAPIの方がどの方面から見ても上なので、今更responderを使う理由はもうないとも言えますね。

パフォーマンスについてこの記事に書いてあります。
- [PythonのWeb frameworkのパフォーマンス比較 (Django, Flask, responder, FastAPI, japronto)](https://qiita.com/bee2/items/0ad260ab9835a2087dae
)

私は以前responderを勉強した時にこんな記事を書きました。
- [responderとsqlite3で簡単なウェブサイトを作ってみたり](https://qiita.com/phyblas/items/0f5a7413e6ad4b1685a0)

今回はFastAPIであの時の記事と似ているウェブサイトを作ることになります。前回の記事も一緒に読んだらresponderとFastAPIの違いを比較することもできます。

Fast**API**という名前だけど、作れるのAPIだけでなく、jinja2テンプレートを使うことで普通のhtmlウェブサイトも簡単に作れます。


# 準備

## 作るものの纏め

今回作るのはこういうウェブサイトです。
- 店などの客の「名前」と「年齢」を登録して閲覧したり編集したり削除したりできるサイト
- サイトを操るのはたった一つのpythonファイル
- データベースに収めるのはたった2列しかないテーブル
- 各ページはjinja2のテンプレートによって成される
- 構成するページは3つ
- - 全部のデータを閲覧したり追加したりダウンロードしたりできるインデックスページ
- - データを編集できるページ
- - エラーが出た時に出現するページ
- スタイルシートも簡単なcssファイルだけど一応準備しておく
- formsとinputでデータを入力と編集する
- javascriptの出番はない


## モジュールのインストール

まずは今回で必要なモジュールをインストールするのです。必要なのは
- fastapi
- uvicorn
- jinja2
- multipart

もしただAPIを作りたいなら`fastapi`と`uvicorn`だけで十分かもしれませんが、今回htmlテンプレートも使うので`jinja2`もインストールする必要があります。また、フォームを扱うために`multipart`が必要です。

どれも簡単にpipでインストールできます。

```
pip install fastapi uvicorn jinja2 python-multipart
```

その他に`sqlite3`も使いますが、これはpythonの組み込みモジュールなので特にインストールする必要ありません。

# ファイル
サイトは全部ただこの5つのファイルから成されることになります。.pyと.cssそれぞれ一つで、テンプレートの.htmlは3つ。
```
├── web.py
├── tem
│   ├── index.html
│   ├── kyaku.html
|   └── error.html
├── stt
|   └── css.css
```

## データベース

SQLでこんな簡単なデータベースを作ります。
|内容|列名|データ型|
|---|---|---|
|名前|namae|text|
|年齢|nenrei|integer|

今回の目標はただちゃんとデータベースに接続してやり取りする例を挙げたいだけなので、簡単のために2列しかないテーブルにします。

このテーブルはSQLコードにするとこんな感じになります。

```sql
create table kyaku (namae text,nenrei integer, primary key (namae));
```

又、今回は直接sqlite3モジュールを使っていますが、sqlalchemyを使う場合も多いです。
sqlalchemyによる実装の例はこれらの記事を参考に：
- [Fast API を一からまとめる](https://qiita.com/raisack8/items/6bd25f34db45e3fdab31)
- [FastAPI と SQLalchemy を使って、Authlib による OIDC 認証と MySQL にユーザーを登録する](https://qiita.com/s_sei/items/97c7d9085b902e643801)
- [SQLAlchemyを用いたFastAPIチュートリアル](https://qiita.com/yslius/items/f5c65b73ca7f6d950f6c)
- [FastAPI入門 〜環境構築からMySQL連携まで〜](https://qiita.com/hkyo/items/65321d7015121ccf369f)
- [FastAPIを用いたAPI開発テンプレート](https://qiita.com/abek21/items/7739163085899b257cb8)


## 環境
OSとPythonとモジュールのバージョンが違ってもあまり問題ないと思いますが一応今回で試した時の環境を書いておきます。
- windows 11
- python 3.11.5
- conda 23.9.0
- fastapi 0.108.0
- jinja2 3.1.2
- starlette 0.32.0.post1


# コード
次は各ファイルの中のコードの説明となります。

## html（jinja2）
各ページを構成するjinja2テンプレートのhtmlファイル。
### index.html
まずはルート（root）にあるインデックスページ。
```index.html
<head>
    <meta charset="utf-8">
    <title>とあるfastAPIのウェブサイト</title>
    <link rel="stylesheet" href="/stt/css.css" type="text/css" media="all">
</head>

<body style="background-color: #cfe8f8;">
    <h3>登録した客</h3>
    <ul style="border: solid #914 2px;">
        {% for k in kyaku %}
        <li>
            <a href="/kyaku/{{ k[0] }}">{{ k[0] }}</a>　{{ k[1] }}歳
        </li>
        {% endfor %}
        {% if not kyaku %}
            <br>まだ何も登録されていない<br><br>
        {% endif %}
    </ul>
    <h3>新しい客を登録する</h3>
    <form action="/touroku" method="post">
        <div>名前 <input type="text" name="namae"></div>
        <div>年齢 <input type="text" name="nenrei"><br></div>
        <div><input type="submit" value="登録"></div>
    </form>
    <br>
    <a href="/csv">データを.csvにしてダウンロードする</a>
</body>
```

基本的に3つに分けられています。
- 登録されたデータの列挙
- 新しいデータを登録するフォーム
- データを纏めてダウンロードするリンク


### kyaku.html
次は、指定の名前の客のデータを表示したり編集したりするためのページ。
```kyaku.html
<head>
    <meta charset="utf-8">
    <title>{{ namae }}のページ</title>
    <link rel="stylesheet" href="/stt/css.css" type="text/css" media="all">
</head>

<body style="background-color: #ddf4be;">
    <form action="/koushin/{{ namae }}" method="post">
        <div>名前: <input type="text" name="namae" value="{{ namae }}"></div>
        <div>年齢: <input type="text" name="nenrei" value="{{ nenrei }}"></div>
        <input type="submit" value="更新">
    </form>
    <form action="/sakujo" method="post">
        <input type="hidden" name="namae" value="{{ namae }}">
        <input type="submit" value="削除">
    </form>
    <div><a href="/">戻る</a></div>
</body>
```

### error.html
何かの間違いが起きたら出てくる簡単なページ。
```error.html
<head>
    <meta charset="utf-8">
    <title>エラーのページ</title>
</head>

<body style="background-color: #f6d7d7;">
    {{error_message}}
</body>
```

## css
### css.css
ウェブサイトを綺麗に装飾する部分。
```css.css
div,li,h3 {
    padding: 2px;
    font-size: 20px;
}
input {
    border: solid #149 2px;
    font-size: 19px;
}
form {
    margin: 3;
}
```

## python
### web.py
全てのサイトの動作を操るpythonコードです。

複雑なサイトなら色々なファイルに分裂することが多いかしれませんが、今回は小さくて簡単なサイトなので、分ける必要なく一つのファイルにします。

全部のルーティングのコントローラーと、データベースに接続するコードは全てここに書いてあります。
```web.py
import os,sqlite3
from fastapi import FastAPI, Request, Response
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.responses import RedirectResponse

dbfile = 'dbfile.db' # SQLデータを保存するファイル

# 初めて実行した時、新たにテーブルを作っておく
if(not os.path.exists(dbfile)):
    with sqlite3.connect(dbfile) as conn:
        sql_create = '''
            create table kyaku (
                namae text,
                nenrei integer,
                primary key (namae)
            )
        '''
        conn.execute(sql_create)

app = FastAPI()
app.mount(path="/stt", app=StaticFiles(directory='stt')) # cssファイルを収める場所
jintem = Jinja2Templates(directory='tem') # jinja2テンプレートのhtmlファイルを収める場所

# インデックスページ
@app.get('/')
def index(request: Request):
    with sqlite3.connect(dbfile) as conn:
        sql_select = '''
            select * from kyaku
        ''' # 全ての客のデータを取得する
        kyaku_lis = conn.execute(sql_select).fetchall()
        param = {'request': request, 'kyaku': kyaku_lis}
        return jintem.TemplateResponse('index.html',param)

# 各データ表示と編集のページ
@app.get('/kyaku/{namae}')
def kyaku(request: Request,namae: str):
    with sqlite3.connect(dbfile) as conn:
        sql_select = '''
            select * from kyaku where namae==?
        ''' # その名前を持つキャラのデータを取る
        kyaku = conn.execute(sql_select,[namae]).fetchone()
        if(kyaku):
            param = {'request': request,
                     'namae': namae,
                     'nenrei': kyaku[1]
            }
            return jintem.TemplateResponse('kyaku.html',param)
        else:
            param = {'request': request,
                     'error_message': 'このページは存在しない'
            }
            return jintem.TemplateResponse('error.html',param) # 存在しない名前が入れられる場合、エラーページへ

# 新しいデータ登録する処理
@app.post('/touroku')
async def touroku(request: Request):
    try:
        with sqlite3.connect(dbfile) as conn:
            form = await request.form() # フォームに記入されたデータを取得
            namae = form['namae'] # 名前
            nenrei = int(form['nenrei']) # 年齢
            sql_insert = '''
                insert into kyaku (namae,nenrei)
                values (?,?)
            ''' # 新しいデータ追加
            conn.execute(sql_insert,(namae,nenrei))
        return RedirectResponse(url='/',status_code=303) # 完成したらインデックスページに戻る
    except Exception as err:
        param = {'request': request,
                 'error_message': f'エラー：{type(err)} {err}'
        }
        return jintem.TemplateResponse('error.html',param) # なにか間違いがある場合

# データ更新する処理
@app.post('/koushin/{namae}')
async def koushin(request: Request, namae: str):
    try:
        with sqlite3.connect(dbfile) as conn:
            form = await request.form()
            namae_x = form['namae'] # 新しい名前
            nenrei = int(form['nenrei'])
            sql_update = '''
                update kyaku set namae=?,nenrei=? where namae==?
            ''' # データ更新
            conn.execute(sql_update,(namae_x,nenrei,namae))
        return RedirectResponse(url=f'/kyaku/{namae_x}',status_code=303) # 完成したら新しい名前でデータ表示のページに戻る
    except Exception as err:
        param = {'request': request,
                 'error_message': f'エラー：{type(err)} {err}'
        }
        return jintem.TemplateResponse('error.html',param) # なにか間違いがある場合

# データ削除する処理
@app.post('/sakujo')
async def sakujo(request: Request):
    try:
        with sqlite3.connect(dbfile) as conn:
            form = await request.form()
            namae = form['namae']
            sql_delete = '''
                delete from kyaku where namae==?
            ''' # データ削除
            conn.execute(sql_delete,[namae])
        return RedirectResponse(url='/',status_code=303) # インデックスページに戻る
    except Exception as err:
        param = {'request': request,
                 'error_message': f'エラー：{type(err)} {err}'
        }
        return jintem.TemplateResponse('error.html',param) # なにか間違いがある場合

# データのダウンロード
@app.get('/csv')
def csv():
    with sqlite3.connect(dbfile) as conn:
        data = conn.execute('select * from kyaku').fetchall() # 全部データを読み込む
        data = '名前,年齢\n'+'\n'.join([d[0]+','+str(d[1]) for d in data]) # データをcsvに
        header = {'Content-Disposition': 'attachment; filename=data.csv'}
        return Response(content=data,headers=header,media_type='text/csv')
```

ルーティングは6つあります。

その中の3つはget：
|名前|ルーティング|役目|
|---|---|---|
|index|/|インデックスのページ|
|kyaku|/kyaku/{namae}|各データのページ|
|csv|/csv|データをダウンロードする処理|

ここで`{namae}`の部分は任意の客の名前です。

後3つはpost：
|名前|ルーティング|役目|
|---|---|---|
|touroku|/touroku|データを追加する処理|
|koushin|/koushin/{namae}|データを更新する処理|
|sakujo|/sakujo|データを削除する処理|

これらはボタンを押したことによってアクセスするもので、SQLデータベースとやり取りした後すぐリダイレクトするのでhtmlテンプレートを準備する必要がない。

本来削除の作業はdeleteメソッドでやるべきだと思っていましたが、FastAPIでは削除もpostを使います。これについてこの記事も参考。
- [FastAPIでPOSTメソッドを使って無理やりDELETEする](https://hackmd.io/@nutmeg/H1akefSzY)

postではフォームの中のデータを取得するためにawaitが必要なのでdefの前にasyncが置かれて非同期処理となります。

その違いについてこの記事に解説があります。
- [FastAPIの"def"と"async def"って結局「どっちを使えば良いんじゃろう？」](https://qiita.com/ffggss/items/e4c06f86fb28a62948e0)
- [FastAPIのrouterにはasync def/defのいずれも指定できるが、適当に決めてはならない](https://zenn.dev/tk_resilie/articles/fastapi_async_sync)


# 実行と結果
コードの準備が完成したら、次はコマンドプロンプトかターミナルでこのコマンドを実行するのです。
```
uvicorn web:app --reload
```

そしてブラウザーで http://127.0.0.1:8000/ にアクセスしましょう。

### インデックスページ
何かの間違いがなければこのようなページは表示されるはずです。（ここではMicrosoft Edgeで）

![q01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/4532c4e1-ee43-7e10-ba0d-900d75e357ca.png)

まだデータが入っていないので、まずは追加してみます。

![q02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/dadf9150-10ba-be4d-bfef-9f6d3aad5d93.png)

名前と年齢を入力して「登録」ボタンをクリックすると、データが追加されます。そして
試しにもう一つ追加しますね。

![q03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/056f934f-fa27-065b-8aab-960fbf0f7645.png)

そうしたらこうなります。

![q04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/4c92eeef-c245-d04f-b960-1ea8fe27d900.png)

そして下にある「データを.csvにしてダウンロードする」のリンクをクリックしたら、データは.csvファイルに書かれてすぐダウンロードが始まって、こういうファイルを得られます。

```data.csv
名前,年齢
山田ほげ,20
田中ふが,25
```

### 各データのページ
次は名前のリンクをクリックしてみたらこんなページに入ります。
![q05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/e8261843-4107-89e5-fb44-6f10f34505eb.png)

ここで編集して「更新」ボタンを押したら実際に更新されます。

### エラーのページ

上の例では`kyaku/`の次は存在する名前だから正しく表示されますが、試しに存在しない名前を入れたら、例えば http://127.0.0.1:8000/kyaku/xxx 、そうしたらこんなエラーページが出ます。

![q06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/3234ec81-4879-1283-3772-40d8e57e0940.png)

又は、例えば「年齢」に何も入れないまま「更新」ボタンを押したらこのようなエラーページが出てきます。

![q07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/fcd6cab7-4612-7387-b5d8-1c47ad4090b9.png)


### docs
FastAPIの特徴の一つはSwagger UIのドキュメントのページが自動的に生成されることです。読む方法は http://127.0.0.1:8000/docs にアクセスすること。

今回作ったサイトのdocsこんなページになります。

![q08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/202903/14004b85-1820-ddac-aea3-8ea587552388.png)

このサイトにどんなページがあるか簡単に纏められていてすごい便利です。


# 参考

今回FastAPIの勉強のために色んな記事読みました。ここで参考になった記事を纏めておきます。
- [\[FastAPI\] Python製のASGI Web フレームワーク FastAPIに入門する](https://qiita.com/bee2/items/75d9c0d7ba20e7a4a0e9)
- [FastAPI APIを使ってみる](https://qiita.com/dbd_fish/items/098079078ce5333185bc)
- [FastAPIで作るWebアプリ - 基本](https://qiita.com/sand/items/bba0913fe4504c612b3a)
- [FastAPIのシンプルなサンプルコードの紹介](https://qiita.com/SaitoTsutomu/items/6fd5cd835a4b904a5a3e)
- [FastAPI 色々なレスポンスまとめ](https://qiita.com/aKuad/items/d213dc1f7075ab2dd69a)
- [FastAPIでJinja2を使用してHTMLを返し、CSSとJavaScriptを読み込む方法](https://qiita.com/maccha349/items/0ab256cb6e42b86296c6)
- [FastAPI でデータをダウンロードさせる](https://qiita.com/hoto17296/items/5624231b8d44b8f1275e)
- [FastAPIを使ってCRUD APIを作成する](https://qiita.com/t-iguchi/items/d01b24fed05db43fd0b8)
- [FastAPIを使ってファイルアップロード機能を作成する](https://qiita.com/t-iguchi/items/2680dfbe16e960dbcfdf)
- [FastAPIでOpenCV Streaming](https://qiita.com/Gyutan/items/d3dbdc675ce7cb5cf077)
- [【Python】REST API でファイルをアップロードする (FastAPI)](https://qiita.com/XPT60/items/1ae2bb99e81bd8c8bc98)
- [Django vs FastAPI比較：どちらにする？](https://apidog.com/jp/blog/django-vs-fastapi/)

# 終わりに
以上FastAPIの使う簡単な例でした。かなり簡単に実装できて、使い勝手がいいです。その上パーフォーマンスのことも優秀らしいです。それだけでなく、Pythonで書くフレームワークということは、機械学習や画像処理などPythonの得意分野のモジュールと連携しやすいというのは他の言語で書いたフレームワークと比べて圧倒的なメリットでもあります。これからも人気が高まっていくでしょう。