Open16

FastAPI + Prisma で REST API を作る

Coji MizoguchiCoji Mizoguchi

Remixだけでアプリ作ってたんですが、Sentence Transformers を使いたくなって FastAPI で embedding サーバを立てたんですが、だったらいっそのことバックエンド を Python に移したくなりました。

そうすると Migration が課題になります。個人的に SQLAlchemy はドキュメントが読みにくくて最悪なので避けてるので、どうしたものか、と考えながら調べていたら Python 版の Prisma があったのでした。
https://prisma-client-py.readthedocs.io/

というわけで、FastAPI + Prisma で REST API を立ててみる実験です。

Coji MizoguchiCoji Mizoguchi

まず rye init でプロジェクト作ってそこに移ります。

$ rye init fastapi-prisma-test
success: Initialized project in /Users/coji/progs/spike/python/fastapi-prisma-test
  Run `rye sync` to get started

cd fastapi-prisma-test
Coji MizoguchiCoji Mizoguchi

必要なパッケージをインストールして sync します。

$ rye add fastapi "uvicorn[standard]" prisma
Added fastapi>=0.103.0 as regular dependency
Added uvicorn[standard]>=0.23.2 as regular dependency
Added prisma>=0.10.0 as regular dependency

で sync でインストール

$ rye sync
Initializing new virtualenv in /Users/coji/progs/spike/python/fastapi-prisma-test/.venv
Python version: cpython@3.11.3
Generating production lockfile: /Users/coji/progs/spike/python/fastapi-prisma-test/requirements.lock
Generating dev lockfile: /Users/coji/progs/spike/python/fastapi-prisma-test/requirements-dev.lock
Installing dependencies
Looking in indexes: https://pypi.org/simple/
Obtaining file:///. (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 6))
  Installing build dependencies ... done
  Checking if build backend supports build_editable ... done
  Getting requirements to build editable ... done
  Preparing editable metadata (pyproject.toml) ... done
Collecting annotated-types==0.5.0 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 1))
  Using cached annotated_types-0.5.0-py3-none-any.whl (11 kB)
Collecting anyio==3.7.1 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 2))
  Using cached anyio-3.7.1-py3-none-any.whl (80 kB)
Collecting certifi==2023.7.22 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 3))
  Using cached certifi-2023.7.22-py3-none-any.whl (158 kB)
Collecting click==8.1.7 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 4))
  Using cached click-8.1.7-py3-none-any.whl (97 kB)
Collecting fastapi==0.103.0 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 5))
  Using cached fastapi-0.103.0-py3-none-any.whl (66 kB)
Collecting h11==0.14.0 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 7))
  Using cached h11-0.14.0-py3-none-any.whl (58 kB)
Collecting httpcore==0.17.3 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 8))
  Using cached httpcore-0.17.3-py3-none-any.whl (74 kB)
Collecting httptools==0.6.0 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 9))
  Using cached httptools-0.6.0-cp311-cp311-macosx_10_9_universal2.whl (233 kB)
Collecting httpx==0.24.1 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 10))
  Using cached httpx-0.24.1-py3-none-any.whl (75 kB)
Collecting idna==3.4 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 11))
  Using cached idna-3.4-py3-none-any.whl (61 kB)
Collecting jinja2==3.1.2 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 12))
  Using cached Jinja2-3.1.2-py3-none-any.whl (133 kB)
Collecting markupsafe==2.1.3 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 13))
  Using cached MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl (17 kB)
Collecting nodeenv==1.8.0 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 14))
  Using cached nodeenv-1.8.0-py2.py3-none-any.whl (22 kB)
Collecting prisma==0.10.0 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 15))
  Using cached prisma-0.10.0-py3-none-any.whl (107 kB)
Collecting pydantic==2.3.0 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 16))
  Using cached pydantic-2.3.0-py3-none-any.whl (374 kB)
Collecting pydantic-core==2.6.3 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 17))
  Using cached pydantic_core-2.6.3-cp311-cp311-macosx_11_0_arm64.whl (1.6 MB)
Collecting python-dotenv==1.0.0 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 18))
  Using cached python_dotenv-1.0.0-py3-none-any.whl (19 kB)
Collecting pyyaml==6.0.1 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 19))
  Using cached PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl (167 kB)
Collecting setuptools==68.1.2 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 20))
  Using cached setuptools-68.1.2-py3-none-any.whl (805 kB)
Collecting sniffio==1.3.0 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 21))
  Using cached sniffio-1.3.0-py3-none-any.whl (10 kB)
Collecting starlette==0.27.0 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 22))
  Using cached starlette-0.27.0-py3-none-any.whl (66 kB)
Collecting tomlkit==0.12.1 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 23))
  Using cached tomlkit-0.12.1-py3-none-any.whl (37 kB)
Collecting typing-extensions==4.7.1 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 24))
  Using cached typing_extensions-4.7.1-py3-none-any.whl (33 kB)
Collecting uvicorn==0.23.2 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 25))
  Using cached uvicorn-0.23.2-py3-none-any.whl (59 kB)
Collecting uvloop==0.17.0 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 26))
  Using cached uvloop-0.17.0-cp311-cp311-macosx_10_9_universal2.whl (2.1 MB)
Collecting watchfiles==0.20.0 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 27))
  Using cached watchfiles-0.20.0-cp37-abi3-macosx_11_0_arm64.whl (407 kB)
Collecting websockets==11.0.3 (from -r /var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/tmpccc8_ama (line 28))
  Using cached websockets-11.0.3-cp311-cp311-macosx_11_0_arm64.whl (121 kB)
Building wheels for collected packages: fastapi-prisma-test
  Building editable for fastapi-prisma-test (pyproject.toml) ... done
  Created wheel for fastapi-prisma-test: filename=fastapi_prisma_test-0.1.0-py3-none-any.whl size=1198 sha256=b3be16bf85dd5cb6ae335345b8362d3beb0bf580ac883a9dbe3aef0ce628023f
  Stored in directory: /private/var/folders/69/7bdjxpf97kg_0f6xdbt1lgz80000gn/T/pip-ephem-wheel-cache-yt4kdrs9/wheels/97/54/f5/d849319cdfa096e074df352654ee2e7c919da8951f090690c6
Successfully built fastapi-prisma-test
Installing collected packages: websockets, watchfiles, uvloop, uvicorn, typing-extensions, tomlkit, starlette, sniffio, setuptools, pyyaml, python-dotenv, pydantic-core, pydantic, prisma, nodeenv, markupsafe, jinja2, idna, httpx, httptools, httpcore, h11, fastapi-prisma-test, fastapi, click, certifi, anyio, annotated-types
Successfully installed annotated-types-0.5.0 anyio-3.7.1 certifi-2023.7.22 click-8.1.7 fastapi-0.103.0 fastapi-prisma-test-0.1.0 h11-0.14.0 httpcore-0.17.3 httptools-0.6.0 httpx-0.24.1 idna-3.4 jinja2-3.1.2 markupsafe-2.1.3 nodeenv-1.8.0 prisma-0.10.0 pydantic-2.3.0 pydantic-core-2.6.3 python-dotenv-1.0.0 pyyaml-6.0.1 setuptools-68.1.2 sniffio-1.3.0 starlette-0.27.0 tomlkit-0.12.1 typing-extensions-4.7.1 uvicorn-0.23.2 uvloop-0.17.0 watchfiles-0.20.0 websockets-11.0.3
Done!

以降は venv 環境でやるので rye shell にしときます。

$ rye shell
Spawning virtualenv shell from /Users/coji/progs/spike/python/fastapi-prisma-test/.venv
Leave shell with 'exit'
Coji MizoguchiCoji Mizoguchi

スキーマファイルを用意します。とりあえず適当に。

prisma/schema.prisma

generator client {
  provider             = "prisma-client-py"
  recursive_type_depth = 5
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model Message {
  id        String   @id @default(uuid())
  text      String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

pytyon 版ならでは、なのは、provider = "prisma-client-py" ですね。

Coji MizoguchiCoji Mizoguchi

マイグレーションします。

$ prisma migrate dev
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": SQLite database "database.db" at "file:data/database.db"

SQLite database database.db created at file:data/database.db

✔ Enter a name for the new migration: … message
Applying migration `20230828133755_message`

The following migration(s) have been created and applied from new schema changes:

migrations/
  └─ 20230828133755_message/
    └─ migration.sql

Your database is now in sync with your schema.

✔ Generated Prisma Client Python (v0.10.0) to ./.venv/lib/python3.11/site-packages/prisma in
 73ms

普通に Prisma だ!

生成されたマイグレーションファイルがこちら。普通です。

prisma/migrations/20230828133755_message/migtaion.sql
-- CreateTable
CREATE TABLE "Message" (
    "id" TEXT NOT NULL PRIMARY KEY,
    "text" TEXT NOT NULL,
    "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updatedAt" DATETIME NOT NULL
);

Coji MizoguchiCoji Mizoguchi

FastAPI で REST API つくります。

server.py
from fastapi import FastAPI
from prisma import Prisma

app = FastAPI()


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


@app.post("/message")
async def add_message(message: str):
    prisma = Prisma()
    await prisma.connect()
    result = await prisma.message.create(data={"text": message})
    await prisma.disconnect()
    return result


@app.get("/message")
async def get_messages(take: int = 10, offset: int = 0):
    prisma = Prisma()
    await prisma.connect()
    message_count = await prisma.message.count()
    messages = await prisma.message.find_many(take, offset, order={"createdAt": "desc"})
    await prisma.disconnect()
    return {"message_count": message_count, "messages": messages}


@app.get("/message/{id}")
async def get_message(id: str):
    prisma = Prisma()
    await prisma.connect()
    result = await prisma.message.find_unique(where={"id": id})
    await prisma.disconnect()
    return result

エディタでの型補完が効いてて快適です。
connect / disconnect が都度必要なのかな?これはなんとかならないか。

追記: なんとかなりました

Coji MizoguchiCoji Mizoguchi

APIサーバ起動コマンドを pyproject.toml の末尾に追加します。

pyproject.toml
[tool.rye.scripts]
start = { cmd = 'uvicorn server:app --reload --host 0.0.0.0 --port 8000', env = { ENV = 'development' } }
Coji MizoguchiCoji Mizoguchi

というわけでサーバを起動します。

$ rye run start
INFO:     Will watch for changes in these directories: ['/Users/coji/progs/spike/python/fastapi-prisma-test']
INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
INFO:     Started reloader process [56191] using WatchFiles
INFO:     Started server process [56193]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
Coji MizoguchiCoji Mizoguchi

メッセージリスト取得します。

curl -X 'GET' \
  'http://localhost:8000/message?take=10&offset=0' \
  -H 'accept: application/json'
{
  "message_count": 1,
  "messages": [
    {
      "id": "b03d51b7-330d-4a39-bc0f-9d00da2c08df",
      "text": "Hello, World!",
      "createdAt": "2023-08-28T13:56:09.146000Z",
      "updatedAt": "2023-08-28T13:56:09.146000Z"
    }
  ]
}

ok

Coji MizoguchiCoji Mizoguchi

個別の取得をします

curl -X 'GET' \
  'http://localhost:8000/message/b03d51b7-330d-4a39-bc0f-9d00da2c08df' \
  -H 'accept: application/json'

レスポンス

{
  "id": "b03d51b7-330d-4a39-bc0f-9d00da2c08df",
  "text": "Hello, World!",
  "createdAt": "2023-08-28T13:56:09.146000Z",
  "updatedAt": "2023-08-28T13:56:09.146000Z"
}

okですね。

Coji MizoguchiCoji Mizoguchi

データベースのスキーマをみてみましょう。

$ sqlite3 prisma/data/database.db 
SQLite version 3.39.5 2022-10-14 20:58:05
Enter ".help" for usage hints.
sqlite> .schema
CREATE TABLE IF NOT EXISTS "_prisma_migrations" (
    "id"                    TEXT PRIMARY KEY NOT NULL,
    "checksum"              TEXT NOT NULL,
    "finished_at"           DATETIME,
    "migration_name"        TEXT NOT NULL,
    "logs"                  TEXT,
    "rolled_back_at"        DATETIME,
    "started_at"            DATETIME NOT NULL DEFAULT current_timestamp,
    "applied_steps_count"   INTEGER UNSIGNED NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS "Message" (
    "id" TEXT NOT NULL PRIMARY KEY,
    "text" TEXT NOT NULL,
    "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updatedAt" DATETIME NOT NULL
);

まあそのとおりですね。

中身も一応。

sqlite> select * from "Message";
b03d51b7-330d-4a39-bc0f-9d00da2c08df|Hello, World!|1693230969146|1693230969146

SQLite で日時が unix timestamp になってるの、そのまま prisma です。

Coji MizoguchiCoji Mizoguchi

というわけで今回のリポジトリです。
https://github.com/coji/fastapi-prisma-test

感想:

  1. まんま Prisma でした。普通に使えそうなのでこれでいきます。SQLAlchemy + Alembic いらん!
  2. RESTハンドラの中で毎回 connect / disconnect するのめんどい。ここなんとかできたら快適なんだけど。
  3. Typescript と違って Pytyon は snake case なので SQL と相性良くて嬉しい気がする。(SQL で "Message"とクオートするのだるい。map はもっとだるいし忘れそうで怖かった)
  4. find / find_many で select で個別カラム指定するのが TS と違って Pydantec 的なのでモデルクラス作らないといけないみたいで、とてもだるい&めんどいからパフォーマンスがきになるところ以外は全部そのまま取り出したくなっちゃう。不用意なおもらししそうで怖い。
  5. async / await になるのかあ。そうすると同じハンドラで Sentence Transformers で embedding まとめてやるとか、ヘビーな処理がやりづらいなあ。バックグラウンドジョブになっちゃうかな?
Coji MizoguchiCoji Mizoguchi

FastAPI の起動時・終了時に connect / disconnect するようにしたら良いだけでした。楽!

server.py
from fastapi import FastAPI
from prisma import Prisma

app = FastAPI()
prisma = Prisma()


@app.on_event("startup")
async def startup():
    await prisma.connect()


@app.on_event("shutdown")
async def shutdown():
    await prisma.disconnect()


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


@app.post("/message")
async def add_message(message: str):
    return await prisma.message.create(data={"text": message})


@app.get("/message")
async def get_messages(take: int = 10, offset: int = 0):
    message_count = await prisma.message.count()
    messages = await prisma.message.find_many(take, offset, order={"createdAt": "desc"})
    return {"message_count": message_count, "messages": messages}


@app.get("/message/{id}")
async def get_message(id: str):
    return await prisma.message.find_unique(where={"id": id})