これは
- react-jsonschema-formのカレンダー | Advent Calendar 2023 - Qiita
- Djangoのカレンダー | Advent Calendar 2023 - Qiita
の25日目の記事です。
ここまでの react-jsonschema-form (RJSF) の Advent Calendar では、JSON Schema を元にしてフォームを作ったりカスタマイズしてきました。
rjsf-team/react-jsonschema-form: A React component for building Web forms from JSON Schema.
ただ、実際のアプリケーションとなると、React + RJSF というフロントエンドだけの構成ではなく、バックエンドも必要になってきます。
そこで今回は
という構成でCRUDするアプリケーションを作り、どのような感じになるのかを見ていきます。
なお、今回の目的は「動くものを提供する」がメインです。そのため、異常系のハンドリングは軽めにしてあるので注意してください。
目次
- 環境
- アプリのディレクトリ・ファイル構成について
- 今回作るアプリについて
- データベース設計
- Djangoのセットアップ
- django-viteを使い、Djangoのテンプレートの上でReactを動かす
- フロントエンドとバックエンドの疎通確認をする
- RJSFでフォームを生成し、バックエンドと連携してCRUDする
- バックエンド(DRF)にて、JSON Schemaを元にしたバリデーションを行う
- アプリに改修が入ったため、JSON Schemaを世代管理することでフォームの内容を制御する
- おわりに
- ソースコード
環境
主なライブラリとそのバージョン、使用目的は以下の通りです。
- フロントエンド
- バックエンド
アプリのディレクトリ・ファイル構成について
今回作成するアプリについて、最終的なディレクトリ・ファイル構成は以下の通りです。
なお、今回のアプリでは、やや手抜きをして
としています。
. ├── .env ├── api │ ├── __init__.py │ ├── apps.py │ ├── serializers.py │ ├── tests │ │ ├── __init__.py │ │ └── test_serializers.py │ ├── urls.py │ └── views.py ├── config │ ├── __init__.py │ ├── asgi.py │ ├── settings.py │ ├── test_settings.py │ ├── urls.py │ └── wsgi.py ├── db.sqlite3 ├── diary │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_diary_version.py │ │ ├── __init__.py │ ├── models.py │ ├── tests.py │ └── views.py ├── frontend │ └── src │ ├── components/ │ ├── hooks/ │ ├── jsonSchemas/ │ ├── .env │ ├── main.tsx │ └── vite-env.d.ts ├── manage.py ├── package-lock.json ├── package.json ├── pyproject.toml ├── templates │ └── index.html ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts
今回作るアプリについて
"「日記を書けるWebアプリがほしい。他のシステムでDjangoを使っているので、今回もDjangoでアプリを作ってほしい。」という要望を受け、アプリケーションを作る必要が出てきた" という物語でアプリを作っていきます。
まずはどんな構成で作るかを考えていきます。
データベースまわりについて
まだまだ要望がふわっとしているため、データベース上に正規化したデータは置かないこととします。
その代わり、JSON型の列を用意し、そこへデータを入れておくことにします。
ただ、単なるJSONだと型が分かりづらいことから、JSON Schema を使ってJSONの構造を定義することにします。
フロントエンドとバックエンドの構成について
フロントエンドについては、幸いなことに、JSON Schema を元にReactでフォームを自動生成するライブラリ react-jsonschema-form
(RJSF) があり、これを使うことで作業量を減らせそうです。
Introduction | react-jsonschema-form
一方、バックエンドを担当するDjangoには JSONField
を使うことでJSONをうまく扱えそうでした。
JSONField | Model field reference | Django documentation | Django
また、DjangoでREST APIを作ることから、Django REST framwork (DRF) を使うことにします。
Home - Django REST framework
データベース設計
今回は以下の列を用意します。
- name
- 各列のタイトル的な列
- content
- JSONを入れておく列
- updated_at
- 更新日時を入れておく列
Djangoのモデル的にはこんな感じです。
class Diary(models.Model): name = models.CharField(max_length=50, blank=True, null=False) content = models.JSONField(blank=True, null=False) updated_at = models.DateTimeField(auto_now=True)
Djangoのセットアップ
ふつうのDjangoのセットアップのため、実施したコマンドなどを残しておくことにとどめます。
作業のログ
# Pythonの環境を作る $ python -m venv env $ source env/bin/activate # パッケージをインストール (env)$ pip install 'django<5' djangorestframework django-vite ... Successfully installed asgiref-3.7.2 django-4.2.8 django-vite-3.0.1 djangorestframework-3.14.0 pytz-2023.3.post1 sqlparse-0.4.4 # Djangoのセットアップをする $ django-admin startproject config . $ python manage.py startapp api $ python manage.py startapp diary # セットアップが終わったら、以下を修正していく - diary/model.py - diary/admin.py - api/serializer.py - api/views.py - api/urls.py - confing/settings.py - config/urls.py # マイグレーションを適用する $ python manage.py makemigrations $ python manage.py migrate # Admin画面を利用できるようにする $ python manage.py createsuperuser Username (leave blank to use 'thinkami'): admin Email address: admin@example.com Password: pass12345 Password (again): pass12345 Superuser created successfully.
動作確認
コマンドラインで curl
を使って動作確認を行います。
# Djangoを起動して、Admin画面からデータを登録する $ python manage.py runserver # http://localhost:8000/admin/ にアクセスして、データ登録 # curlで動作確認する $ curl http://localhost:8000/api/diaries/ [{"id":1,"content":{"foo":"bar"},"updated_at":"2023-12-23T11:31:59.043458+09:00"}]
ここまでのコミット
ここまでの作業は以下のコミットになります。
https://github.com/thinkAmi-sandbox/drf_rjsf-example/commit/b37c9ca34a21aa172d15f6b9972ba023790ff1e9
django-viteを使い、Djangoのテンプレートの上でReactを動かす
去年のAdvent Calendarの記事同様、今回のReactもDjangoテンプレート上で動かします。
django-vite を使って、Django の template 上で React を動かしてみた - メモ的な思考的な
そこで、詳しい解説は去年の記事に譲り、ここでは作業のログと作業していて気になったことをメモするだけにとどめます。
なお、作業の結果は以下のコミットになります。
https://github.com/thinkAmi-sandbox/drf_rjsf-example/commit/61eb92e797be839dd16f3ddaf65be2c4cee9ef45
ちなみに、フロントエンドのルーターは去年と変えています。今年は、2023/12/23に v1.0.0 が出たばかりの Tanstack Router を使います。
TanStack Router | React Router, Solid Router, Svelte Router, Vue Router
差し替えた理由ですが、後述の通りデータ取得ライブラリに Tanstack Query を使っていることから、「Tanstack シリーズで統一しよう」程度です。
作業のログ
# django-viteをインストールする $ pip install django-vite # django-viteのREADMEに従い、ふつうにVite.jsでReactをセットアップする $ npm create vite@latest Need to install the following packages: create-vite@5.1.0 Ok to proceed? (y) y ✔ Project name: … frontend ✔ Select a framework: › React ✔ Select a variant: › TypeScript Scaffolding project in /home/thinkami/dev/projects/django/drf/drf_rjsf/frontend... Done. Now run: cd frontend npm install npm run dev # frontendディレクトリの中にアプリを作る $ cd frontend/ $ npm i $ npm run dev # localhost:5173 にアクセスして、Vite + React が起動することを確認 # frontendディレクトリの中から、必要なファイルをルート直下へ移動 $ mv package.json ../ $ mv package-lock.json ../ $ mv tsconfig.json ../ $ mv tsconfig.node.json ../ $ mv vite.config.ts ../ # 不要なファイルを削除 $ rm -rf node_modules/ $ rm -rf public/ $ rm -rf src/assets/ $ rm index.html $ rm App.tsx $ rm App.css $ rm index.css $ rm README.md # ルートディレクトリに戻って、必要なものをインストール $ cd .. $ npm i # config系のファイルを修正 $ npm i --save-dev @types/node - vite.config.ts - confing/settings.py # django-cors-headersを使ってCORS対策をする $ pip install django-cors-headers - confing/settings.py # Tanstack Router 関係をインストールする $ npm i @tanstack/react-router @tanstack/router-devtools # frontend/src の中にあるファイルを修正 - main.tsx - Tanstack Routerのルーティングを書く - components/pages/Home.tsx - Djangoのテンプレート上でReactが動くか確認するためのコンポーネント # 必要な設定を追加 templates/index.html config/urls.py config/settings.py vite.config.ts
作業をしていて気になったこと
去年に比べてdjango-viteのバージョンが上がったこともあり、設定が一部変更となっていました。
去年の django-vite 2.0.2 では
{% if debug %} <script type="module"> import RefreshRuntime from 'http://localhost:5173/@react-refresh' RefreshRuntime.injectIntoGlobalHook(window) window.$RefreshReg$ = () => {} window.$RefreshSig$ = () => (type) => type window.__vite_plugin_react_preamble_installed__ = true </script> {% vite_hmr_client %} {% vite_asset 'main.tsx' %} {% else %} {% vite_asset 'main.tsx' %} {% endif %}
のような、開発環境と本番環境で分岐した処理を書いていました。
一方、今年の django-vite 3.0.1 では、django-vite側で開発・本番環境を判断してくれるようになりました。
Add template tag to enable react-refresh by BrandonShar · Pull Request #53 · MrBin99/django-vite
そのため、実装する内容が
{% vite_react_refresh %} {% vite_hmr_client %} {% vite_asset 'main.tsx' %}
のようにスッキリしました。
なお、 vite_react_refresh
は他のタグよりも上に書く必要があります。
READMEで説明されている順番( vite_react_refresh
が一番下) で書いてしまうと、django-vite 2.0.2 でも発生した
Uncaught Error: @vitejs/plugin-react can't detect preamble. Something is wrong. See https://github.com/vitejs/vite-plugin-react/pull/11#discussion_r430879201
というエラーメッセージが表示されます。
動作確認
実装が終わったので、動作確認をします。
コマンドラインから
- Django dev server
- vite dev server
を起動すると、以下の画面が表示されました。Djangoのテンプレート上でReactが動いているようです。
ここまでのコミット
ここまでの作業は以下のコミットになります。
https://github.com/thinkAmi-sandbox/drf_rjsf-example/pull/1/commits/61eb92e797be839dd16f3ddaf65be2c4cee9ef45
フロントエンドとバックエンドの疎通確認をする
ここまでで、ReactをDjangoのテンプレート上で動かす環境ができました。
ただ、まだ React <---> DRF API 間での疎通確認ができていません。
そこで、疎通確認の第一歩として、DjangoのAdmin画面から登録したデータをReactで表示してみます。
今回、フロントエンドからDRF APIよりデータを取得するためのライブラリとしてTanstack Query + axios を使います。
必要なライブラリをインストールします。
$ npm i @tanstack/react-query axios
Tanstack Query + axios による実装
準備ができたので
- Tanstack Query + axios でDRF API へアクセス
- 取得したデータを一覧で表示
- 個々のデータにはリンクを置き、クリックすると Tanstack Router の機能により詳細画面へ遷移
を実装します。
import {useQuery} from "@tanstack/react-query"; import axios from "axios"; import {Link} from "@tanstack/react-router"; type ApiResponse = { id: number, name: string, content: string, updated_at: string } const BASE_URL = 'http://localhost:8000' export const Index = () => { const {isLoading, data} = useQuery({ queryKey: ['diaries'], queryFn: async () => { const response = await axios.get<ApiResponse[]>(`${BASE_URL}/api/diaries/`) return response.data } }) if (isLoading) return <div>Loading</div> return ( <> <h1>一覧</h1> <ul> {data.map((d)=> { // search propsが必須なので、空の関数を渡しておく return <li key={d.id}><Link search={() => {}} to={`${BASE_URL}/${d.id}`}>{d.name}</Link></li> })} </ul> </> ) }
あとは、 main.tsx
にて
- Tanstack Routerのルーティングを設定する
- Tanstack QueryのQueryClientを設定する
などします。
なお、QueryClientの設定は以下です。Tanstack Routerの RouterProvider
の外側で QueryClientProvider
を定義しています。
const queryClient = new QueryClient() ReactDOM.createRoot(document.getElementById('root')!).render( <StrictMode> <QueryClientProvider client={queryClient}> <RouterProvider router={router} /> </QueryClientProvider> </StrictMode>, )
動作確認
実装が終わったので、ブラウザで表示してみると、以下のようにDRF APIのデータが画面へと表示されました。
ここまでのコミット
ここまでの作業は以下のコミットになります。
https://github.com/thinkAmi-sandbox/drf_rjsf-example/commit/b41f855fe17b351faca0221d45de96dbda97d83a
RJSFでフォームを生成し、バックエンドと連携してCRUDする
ここまで、フロントエンドとバックエンドの疎通確認ができました。
次は、JSON Schemaを元にRJSFでフォームを作成し、CRUDしてみます。
実装
バックエンドの実装
今回のバックエンドでは、フロントエンドから渡されたデータをそのまま保存するのではなく、「JSONから name
に該当するデータを抜き出し、テーブルの name
列に入れる」という処理が追加で必要になります。
今回はDRFの ModelSerializer
を継承したシリアライザを使うことから、CRUD時に処理を差し込める箇所がないか、Classy DRFのページで探してみます。
Django REST Framework 3.14 -- Classy DRF
すると create
メソッドと update
メソッドをオーバーライドすることで処理を差し込めそうでした。
そこで、メソッドのオーバーライドでは
- 元々のModelSerializerから実装を移植する
- 今回のアプリでは不要なM2Mの処理を削除する
- 「JSONから
name
に該当するデータを抜き出し、テーブルのname
列に入れる」処理を追加する
を行います。
class DiarySerializer(serializers.ModelSerializer): def create(self, validated_data): ModelClass = self.Meta.model target_data = deepcopy(validated_data) target_data['name'] = validated_data['content']['name'] try: instance = ModelClass._default_manager.create(**target_data) except TypeError: tb = traceback.format_exc() msg = ( 'Got a `TypeError` when calling `%s.%s.create()`. ' 'This may be because you have a writable field on the ' 'serializer class that is not a valid argument to ' '`%s.%s.create()`. You may need to make the field ' 'read-only, or override the %s.create() method to handle ' 'this correctly.\nOriginal exception was:\n %s' % ( ModelClass.__name__, ModelClass._default_manager.name, ModelClass.__name__, ModelClass._default_manager.name, self.__class__.__name__, tb ) ) raise TypeError(msg) return instance def update(self, instance, validated_data): for attr, value in validated_data.items(): setattr(instance, attr, value) if attr == 'content': setattr(instance, 'name', value['name']) instance.save() return instance
いったん、バックエンドはこれで完成です。
フロントエンドの実装
続いてフロントエンドの実装です。
まずは必要なライブラリをインストールします。
$ npm i @rjsf/core @rjsf/utils @rjsf/validator-ajv8 @rjsf/mui --save
フロントエンドでは
- RJSFで使うJSON Schemaの作成
- フロントエンドの各画面の実装
を行います。
RJSFで使うJSON Schemaの作成
今回は以下のような content
の中に name
と note
があるJSON Schemaを用意しました。
また、JSON Schemaによるバリデーションの挙動も確認できるよう、各項目に最低入力文字数 minLength
を設定しています。
{ "$schema": "http://json-schema.org/draft-07/schema", "#id": "https://example.com/thinkami.json", "title": "Diary", "type": "object", "required": ["content"], "properties": { "content": { "type": "object", "required": ["name", "note"], "additionalProperties": false, "properties": { "name": { "type": "string", "minLength": 3 }, "note": { "type": "string", "minLength": 5 } } } } }
新規作成画面と更新画面の作成
新規作成画面と更新画面ですが、ほとんど同じです。
違いは以下です。
- DRF APIのエンドポイント
- Tanstack Queryで取得したデータを、RJSFのFormコンポーネントの props
formData
二設定 - DRF APIで保存が成功した後の挙動
- 新規作成画面は更新画面へ遷移
- 更新画面は一覧画面へ遷移
ここでは、データ取得とデータ保存のある更新画面の実装を掲載します。新規登録画面については、Githubのソースコードを参照してください。
type FormData = { content: { name: string note: string } } type ApiResponse = { id: number, name: string, content: FormData, updated_at: string } export const Edit = () => { const {diaryId} = useParams({strict: false}) const {updateDiary, fetchDiary} = useDiary() const {isLoading, data} = fetchDiary<ApiResponse>(diaryId) const {mutate} = updateDiary(diaryId) const navigate = useNavigate() const [formData, setFormData] = useState<FormData>() const {uiSchema} = useUiSchema() // https://rjsf-team.github.io/react-jsonschema-form/docs/api-reference/form-props#onsubmit const handleSubmit = ({formData}, _event) => { mutate(formData.content, { onSuccess: () => { navigate({to: '/'}) } }) } useEffect(() => { if (!data) return setFormData( { content: { name: data.content.name, note: data.content.note } } ) }, [data]) if (isLoading) return <div>Loading</div> return ( <> <Stack direction="row" sx={{textAlign: "center", mb: 5}} alignItems='center' justifyContent={'space-between'}> <h2>編集</h2> <div> <Link search={() => { }} to="/">戻る</Link> </div> </Stack> <Form<FormData> schema={jsonSchema} uiSchema={uiSchema} validator={validator} formData={formData} onSubmit={handleSubmit}/> </> ) }
一覧画面の作成
一覧画面では
- Djangoに保存されているデータを一覧で表示
- 新規作成画面へのリンクを作成
- データごとに、更新画面へのリンクを作成
- データごとに、削除ボタンを作成
- 削除ボタンクリック後は、データの最コミコミを実施
を実装しています。
const BASE_URL = 'http://localhost:8000' export const Index = () => { const {fetchDiaries, deleteDiary} = useDiary() const {isLoading, data} = fetchDiaries<ApiResponse[]>() const {mutate} = deleteDiary() const queryClient = useQueryClient() const handleDelete = (diaryId) => { mutate(diaryId, { onSuccess: () => { queryClient.invalidateQueries({queryKey: [BASE_QUERY_KEY]}) } }) } if (isLoading) return <div>Loading</div> return ( <> <Stack direction="row" sx={{textAlign: "center"}} alignItems='center' justifyContent={'space-between'}> <h2>一覧</h2> <div> <Link search={() => {}} to={`${BASE_URL}/new`}>新規作成する</Link> </div> </Stack> <List> {data.map((d) => { // search propsが必須なので、空の関数を渡しておく return ( <ListItem key={d.id}> <Stack direction="row" sx={{textAlign: "center"}} alignItems='center' spacing={4}> <Button variant={'outlined'} sx={{mr: 10}} onClick={() => handleDelete(d.id)}>削除</Button> <Link search={() => { }} to={`${BASE_URL}/${d.id}/edit`}>{d.name}</Link> </Stack> </ListItem> ) })} </List> </> ) }
動作確認
フロントエンドとバックエンドの実装が終わったため、動作確認します。
一覧画面のリンクをクリックすると、新規作成画面へ遷移します。
続いて、新規作成画面で保存すると、更新画面へ遷移します。
更新画面から戻るボタンをクリックすると、一覧画面へ遷移します。
ここまでのコミット
ここまでの作業は以下のコミットになります。記事ではソースコードを省略しているものもあるため、詳しくはこちらを参照してください。
https://github.com/thinkAmi-sandbox/drf_rjsf-example/commit/287eb8003a8c60cad672617e98ad4434bdc30757
バックエンド(DRF)にて、JSON Schemaを元にしたバリデーションを行う
ここまでで一通り完成したものの、バックエンド(DRF)にはバリデーションがありません。
そのため、フロントエンドではJSON Schema + RJSFによりバリデーションエラーになるデータも、curlを使うと登録できてしまいます。
$ curl 'http://localhost:8000/api/diaries/' \ -H 'Accept: application/json, text/plain, */*' \ -H 'Accept-Language: ja' \ -H 'Connection: keep-alive' \ -H 'Content-Type: application/json' \ -H 'Origin: http://localhost:8000' \ -H 'Referer: http://localhost:8000/new' \ -H 'Sec-Fetch-Dest: empty' \ -H 'Sec-Fetch-Mode: cors' \ -H 'Sec-Fetch-Site: same-origin' \ -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' \ -H 'sec-ch-ua: "Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"' \ -H 'sec-ch-ua-mobile: ?0' \ -H 'sec-ch-ua-platform: "Windows"' \ --data-raw '{"content":{"name":"1","note":"2"}}' \ --compressed # レスポンスがあり、登録が成功している {"id":15,"name":"1","content":{"name":"1","note":"2"},"updated_at":"2023-12-25T12:19:07.664113+09:00"}
そのため、バックエンド(DRF)にもバリデーションを追加します。
ただ、バックエンドのバリデーションはフロントエンドと内容を揃えたいです。
そこで、JSON Schemaを元にしたバリデーションをバックエンドにも実装します。
ライブラリの選定
バリデーションを手軽に実装するため、既存のライブラリで使えるものがないかを調査・選定します。
drf-jsonschema-serializer は JSON Schema draft 2020-12 のみ対応していた
JSON Schemaに従ってバリデーションできるシリアライザのライブラリとして drf-jsonschema-serializer
があります。
maykinmedia/drf-jsonschema-serializer: JSON schema integration for Django REST Framework
ただ、実装を見てみたところ、JSON Schemaの draft 2020-12
でバリデーションをしていました。
https://github.com/maykinmedia/drf-jsonschema-serializer/blob/main/drf_jsonschema_serializer/fields.py#L34
一方、フロントエンドのRJFSが対応しているJSON Schemaのバージョンは draft-07
です。
JSON Schema supporting status | Internals | react-jsonschema-form
そのため、 drf-jsonschema-serializer
を使うとフロントエンドとバックエンドでバリデーションの差異が出そうだったことから、自分で実装することにしました。
Python製JSON SchemaのValidatorライブラリについて
Python製JSON SchemaのValidatorライブラリはいくつかありました。
Python | Implementations | JSON Schema
その中で、JSON Schema draft-07
に対応しているライブラリは以下でした。
- python-jsonschema/jsonschema: An implementation of the JSON Schema specification for Python
- horejsek/python-fastjsonschema: Fast JSON schema validator for Python.
- jsonschema-rs/bindings/python at master · Stranger6667/jsonschema-rs
今回は速度よりも
- 複数のJSON Schemaのバージョンに対応していること
- 枯れていること
を優先し、 jsonschema
を使うことにしました。
python-jsonschema/jsonschema: An implementation of the JSON Schema specification for Python
実装
今回はバックエンドのバリデーションなので、バックエンド側のみ実装します。
また、curlでの動作確認は手間なので、pytestによるテストコードも実装します。
まずは必要なライブラリをインストールします。
$ pip install jsonschema pytest
JSON Schema + jsonschema によるバリデーションをSerializerへ追加
DRFでは、バリデーションはSerializerへ追加します。
今回追加したいJSON Schemaを元にしたバリデーション機能は、jsonschema
の jsonschema.validate()
関数が使えそうに見えました。
しかし、 validate()
関数の場合、JSON Schemaの複数箇所でエラーになったとしても、一番マッチする1項目のみエラーメッセージが取得できるようです。
validate() will first verify that the provided schema is itself valid, since not doing so can lead to less obvious error messages and fail in less obvious or consistent ways.
If you know you have a valid schema already, especially if you intend to validate multiple instances with the same schema, you likely would prefer using the jsonschema.protocols.Validator.validate method directly on a specific validator (e.g. Draft202012Validator.validate).
https://python-jsonschema.readthedocs.io/en/stable/validate/#the-basics
今回のJSON Schemaの場合、 name
と note
の両方に minLength
を定義していることから、両項目が同時にエラーとなった時も1つしかエラーメッセージを取得できず、なかなか不便です。
そこで今回は、バリデーション時にすべてのエラーメッセージを取得できる iter_errors
関数を使うことにします。
- iter_errors | jsonschema.protocols - jsonschema 4.20.1.dev17+g03ec8d2 documentation
- Show all errors in json schema using json validate using python - Stack Overflow
そのため、Serializerでは validate
メソッドをオーバーライドし、validatorの iter_errors
によるバリデーションを追加します。
class DiarySerializer(serializers.ModelSerializer): def validate(self, attrs): with open(settings.BASE_DIR / 'frontend' / 'src' / 'jsonSchemas' / 'diary.json') as f: json_schema = json.load(f) errors = Draft7Validator(json_schema).iter_errors(attrs) messages = [e.message for e in errors] if messages: raise serializers.ValidationError(messages) return attrs
動作確認
このバリデーションを実装することにより、curlが失敗するようになりました。
$ curl 'http://localhost:8000/api/diaries/' -H 'Accept: application/json, text/plain, */*' -H 'Accept-Language: ja' -H 'Connection: keep-alive' -H 'Content-Type: application/json' -H 'Origin: http://localhost:8000' -H 'Referer: http://localhost:8000/new' -H 'Sec-Fetch-Dest: empty' -H 'Sec-Fetch-Mode: cors' -H 'Sec-Fetch-Site: same-origin' -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' -H 'sec-ch-ua: "Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"' -H 'sec-ch-ua-mobile: ?0' -H 'sec-ch-ua-platform: "Windows"' --data-raw '{"content":{"name":"1","note":"2"}}' --compressed {"non_field_errors":["'1' is too short","'2' is too short"]}
また、pytestによるテストコードも書きましたが、ここでは省略します。
ここまでのコミット
ここまでの作業は以下のコミットになります。
https://github.com/thinkAmi-sandbox/drf_rjsf-example/commit/134af38466c8f145f8221377ec694c5bfdf7fcc0
アプリに改修が入ったため、JSON Schemaを世代管理することでフォームの内容を制御する
これで完成と思いきや、次の要望がやってきたとします。
- 今までは自由な日記だったが、今後はリンゴに関する日記にしたい
- リンゴの色・名前を選択した後に、記録を残したい
- とはいえ、今までのデータは残しておきたいので、ここまでのデータの読み込み・書き込みできるようにしておきたい
- ただし、「これ以上はメンテナンスしない」を示すため、今までのデータについては入力欄を灰色にしたい
なかなか厄介な要望ですが、JSON Schemaを世代管理すれば何とかなりそうでした。
実装方針
要望を満たすための実装方針としては、
- 登録した時の世代をDBの列
version
へ登録する - 現在の世代は環境変数や
.env
で管理する - JSON Schemaは世代分用意する
- りんごの日記として表示を制御できるよう、
dependencies
やif-then-else
を使う
- りんごの日記として表示を制御できるよう、
- JSON Schemaのバリデーションは世代ごとに行う
- 新規登録画面について
.env
にある、現在の世代でデータを登録する
- 編集画面について
で良さそうでした。
実装
方針に従って実装を行います。
なお、実装する上で検討したことなどは以下です。
登録した時の世代をDBの列 version へ登録する
Diaryモデルに version
列を追加します。
なお、既存のデータへの影響を防ぐため、デフォルトの値も指定しておきます。
class Diary(models.Model): # 追加 version = models.CharField(max_length=50, null=False, default='2023_1224')
現在の世代は環境変数や .env で管理する
今回、現在の世代は環境変数で管理します。
また、環境変数への設定を用意にするため、設定内容は .env
ファイルに記載しておきます。
.env
ファイルを読み込み環境変数へ設定する処理ですが、以下のようにフロントエンド・バックエンドともに可能そうでした。
- フロントエンドをビルドするVite.jsでは特に追加することなく設定可能
- Djangoでは、パッケージを追加することで設定可能
- Djangoで
.env
ファイルを扱えそうな方法は以下の2つがある python-dotenv
はメンテナンスされてそうだが、django-dotenv
は最近動きがない- 今回は
django-dotenv
を使う
- Djangoで
ちなみに、今回はフロントエンドとバックエンドの .env
ファイルを分けています。
- フロントエンド用
frontend/src
の.env
ファイル
- バックエンド用
- ルート直下の
.env
ファイル
- ルート直下の
JSON Schemaは世代分用意する
今までのデータと今後のデータの両世代に対応できるよう、JSON Schemaは世代分用意します。
そこで、
とします。
diary_2023_1225.json
については、確実にリンゴの情報を入力してもらえるよう、
- 色を選択したら、名前を選択できる
- 名前で
その他
を選択したら、名前の自由記入欄が表示・入力できる note
については、どの場合でも入力できる
とします。
なお、1と2の両方で if-then-else
を使うには、 allOf
+ if-then-else
にて定義すれば良さそうでした。
jsonschema - Multiple If-Then-Else not validating for JSON Schema - Stack Overflow
しかし、RJSF(が依存するライブラリ)では allOf
+ if-then-else
をサポートしていないようで、この定義方法は使えませんでした。
- All of if then else warning · Issue #3445 · rjsf-team/react-jsonschema-form
- RJSFのissue
- Add if then else support by mokkabonna · Pull Request #27 · mokkabonna/json-schema-merge-allof
- RJSFが依存しているライブラリのプルリク
そこで、dependencies
+ if-then-else
を使って実現しました。
{ "$schema": "http://json-schema.org/draft-07/schema", "#id": "https://example.com/thinkami_2023_1225.json", "title": "Diary", "$comment": "version 2023_1225", "type": "object", "required": ["content"], "definitions": { "note": { "type": "string", "minLength": 5 } }, "properties": { "content": { "type": "object", "required": ["color", "name", "note"], "properties": { "color": { "type": "string", "oneOf": [ { "const": "黄" }, { "const": "緑" }, { "const": "赤" } ] } }, "dependencies": { "color": { "oneOf": [ { "required": ["name"], "properties": { "color": { "const": "黄" }, "name": { "type": "string", "oneOf": [ { "const": "シナノゴールド" }, { "const": "トキ" }, { "const": "その他" } ] } } }, { "required": ["name"], "properties": { "color": { "const": "緑" }, "name": { "type": "string", "oneOf": [ { "const": "ブラムリー" }, { "const": "グラニースミス" }, { "const": "その他" } ] } } }, { "required": ["name"], "properties": { "color": { "const": "赤" }, "name": { "type": "string", "oneOf": [ { "const": "フジ" }, { "const": "秋映" }, { "const": "その他" } ] } } } ] }, "name": { "if": { "properties": { "name": { "const": "その他" } } }, "then": { "required": ["variety", "note"], "properties": { "variety": { "type": "string" }, "note": { "$ref": "#/definitions/note" } } }, "else": { "required": ["note"], "not": { "required": ["variety"] }, "properties": { "note": { "$ref": "#/definitions/note" } } } } } } } }
JSON Schemaのバリデーションは世代ごとに行う
フロントエンド
フロントエンドの場合、RJSFのFormコンポーネントに適切なJSON Schemaを渡すことで適切なバリデーションが実行されます。
そこで今回は以下のようなカスタムフックを作成し、指定したバージョンに応じたJSON Schemaを返すようにしました。
import jsonSchema_2023_1224 from "@/jsonSchemas/diary_2023_1224.json" import jsonSchema_2023_1225 from "@/jsonSchemas/diary_2023_1225.json" export const useSchema = () => { const importJsonSchema = (version: string) => { switch (version) { case '2023_1225': return jsonSchema_2023_1225 default: return jsonSchema_2023_1224 } } return {importJsonSchema, ...} }
バックエンド
Pythonの jsonschema
ライブラリも、適切なJSON Schemaを渡せば適切なバリデーションが実行されます。
そこで、loadするJSON Schemaファイルを変えられるメソッドを追加します。
class DiarySerializer(serializers.ModelSerializer): BASE_JSON_SCHEMA_FILE_DIR = settings.BASE_DIR / 'frontend' / 'src' / 'jsonSchemas' def get_json_schema_file_name(self): # instanceがあるとき == データを変更する時なので、instance.versionのJSON Schemaを使う if self.instance: return self.BASE_JSON_SCHEMA_FILE_DIR / f'diary_{self.instance.version}.json' # instanceがないときは新規作成なので、最新のJSON Schemaを使って良い return self.BASE_JSON_SCHEMA_FILE_DIR / f'diary_{settings.CURRENT_JSON_SCHEMA_VERSION}.json'
新規登録画面について
DBに保存する時の「現在の世代」をどの場所で取得するか
案としては
- フロントエンドで
.env
から現在の世代を取得し、バックエンドに渡す - バックエンドで
.env
から現在の世代を取得する
のどちらが思い浮かびました。
ただ、フロントエンドで誤った現在の世代を指定された場合に、 version
列の情報と content
列の情報がズレてしまうため、案2とするのが良さそうでした。
そこで、Serializerの create
メソッドの中で案2を実装します。
class DiarySerializer(serializers.ModelSerializer): BASE_JSON_SCHEMA_FILE_DIR = settings.BASE_DIR / 'frontend' / 'src' / 'jsonSchemas' def get_json_schema_file_name(self): if self.instance: return self.BASE_JSON_SCHEMA_FILE_DIR / f'diary_{self.instance.version}.json' return self.BASE_JSON_SCHEMA_FILE_DIR / f'diary_{settings.CURRENT_JSON_SCHEMA_VERSION}.json' def validate(self, attrs): with open(settings.BASE_DIR / 'frontend' / 'src' / 'jsonSchemas' / 'diary.json') as f: with open(self.get_json_schema_file_name()) as f: json_schema = json.load(f) errors = Draft7Validator(json_schema).iter_errors(attrs) messages = [e.message for e in errors] if messages: raise serializers.ValidationError(messages) return attrs def create(self, validated_data): ModelClass = self.Meta.model target_data = deepcopy(validated_data) target_data['name'] = validated_data['content']['name'] variety = validated_data['content'].get('variety') target_data['name'] = variety or validated_data['content']['name'] target_data['version'] = settings.CURRENT_JSON_SCHEMA_VERSION
編集画面で入力欄の背景色(通常 or 灰色) はどのようにして切り替えるか
実装方針でも書いた通り
入力欄を灰色とするために、
Form
コンポーネントのformContext
にDRF APIレスポンスのversion
を設定し、各widgetで現在のバージョンを把握できるようにする
と、RJSFの formContext
を使えば切り替えられそうです。
formContext
の使い方については20日目の記事に記載しましたので、そちらをご確認ください。
react-jsonschema-formにて、formContextを使ってWidgetやFieldに値を渡してみた - メモ的な思考的な
今回の場合、例えば New コンポーネントで
// formContextオブジェクトに現在のバージョン属性 `version` を追加する const formContext = { version: import.meta.env.VITE_CURRENT_JSON_SCHEMA_VERSION } <Form<FormData> schema={importCurrentJsonSchema()} uiSchema={uiSchema} validator={validator} onSubmit={handleSubmit} widgets={widgets} formContext={formContext} // formContextを渡す />
と formContext
にバージョン情報を渡します。
次に、widgetの方でバージョン情報を受け取り、その情報を元に描画します。
export default function MyTextWidget<T = any, S extends StrictRJSFSchema = RJSFSchema, F extends FormContextType = any>( props: WidgetProps<T, S, F> ) { const { options, registry, formContext } = props; const BaseInputTemplate = getTemplate<'BaseInputTemplate', T, S, F>('BaseInputTemplate', registry, options); // formContextからversionを取り出す const version = formContext.version if (version !== import.meta.env.VITE_CURRENT_JSON_SCHEMA_VERSION) { return <BaseInputTemplate {...props} style={{background: 'lightgray'}} />; } return <BaseInputTemplate {...props} />; }
動作確認
古いデータは灰色の入力欄で表示されます。
一方、新しいデータはこのようになります。
ここまでのコミット
ここではソースコードをそこそこ省略したため、不明な点については以下のコミットを参照してください。
https://github.com/thinkAmi-sandbox/drf_rjsf-example/commit/a9da4ffb0767b60562aa67572ca5e5c02a7922db
おわりに
今回の記事では
というWebアプリを作ってきました。
なお、今回はデザイン面はほとんど何も手を付けていませんが、
- Widget
- Field
- Template
などをカスタマイズすることで、好みのデザインのフォームを作ることもできます。
それぞれのカスタマイズ方法については、Advent Calendarの他の記事を見ていただければと思います。
以上より、DB構造が固まらなかったりDB構造の変更が多い場合であっても、
という構成でCRUD可能なWebアプリが作れると伝われば幸いです。
ソースコード
Githubにあげました。
https://github.com/thinkAmi-sandbox/drf_rjsf-example
今回のプルリクはこちら。
https://github.com/thinkAmi-sandbox/drf_rjsf-example/pull/1