React + react-jsonschema-form にて、 JSON Schemaを元にしたフォームからデータを送信し、バックエンドのDjango REST frameworkに保存してみた

これは

の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 というフロントエンドだけの構成ではなく、バックエンドも必要になってきます。

そこで今回は

  • フロントエンド
    • React + RJSF
  • バックエンド

という構成でCRUDするアプリケーションを作り、どのような感じになるのかを見ていきます。

なお、今回の目的は「動くものを提供する」がメインです。そのため、異常系のハンドリングは軽めにしてあるので注意してください。

 
目次

 

環境

主なライブラリとそのバージョン、使用目的は以下の通りです。

  • フロントエンド
    • React 18.2.0
    • Vite.js 5.0.8
      • フロントエンドをビルドするため
    • TypeScript 5.2.2
    • react-jsonschema-form 5.15.0
      • JSON SchemaからReactのWebフォームを自動生成するため
    • Tanstack Router 1.0.0
      • フロントエンドのルーティングで使用するため
    • Tanstack Query 5.14.6
      • データフェッチライブラリとして使用するため
    • axios 1.6.2
      • バックエンドAPIとのリクエスト/レスポンスを扱うため
  • バックエンド
    • Python 3.11.7
    • Django 4.2.8
    • Django REST framework 3.14.0
    • django-vite 3.0.1
      • Djangoのテンプレート上でReactを動かすため
    • pytest 7.4.3
      • バックエンドのテストランナーとして使うため

 

アプリのディレクトリ・ファイル構成について

今回作成するアプリについて、最終的なディレクトリ・ファイル構成は以下の通りです。

なお、今回のアプリでは、やや手抜きをして

  • RJSF向けのJSON Schemaはフロントエンドのディレクトリ (frontend/src/jsonSchema) に置いた
  • .env ファイルはフロントエンド・バックエンドそれぞれで用意した

としています。

.
├── .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

また、DjangoREST 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 列に入れる」という処理が追加で必要になります。

今回はDRFModelSerializer を継承したシリアライザを使うことから、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 の中に namenote がある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から取得したデータを画面に表示するため
  • 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 を使うとフロントエンドとバックエンドでバリデーションの差異が出そうだったことから、自分で実装することにしました。

 

PythonJSON SchemaのValidatorライブラリについて

PythonJSON SchemaのValidatorライブラリはいくつかありました。
Python | Implementations | JSON Schema

その中で、JSON Schema draft-07 に対応しているライブラリは以下でした。

 
今回は速度よりも

  • 複数の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を元にしたバリデーション機能は、jsonschemajsonschema.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の場合、 namenote の両方に minLength を定義していることから、両項目が同時にエラーとなった時も1つしかエラーメッセージを取得できず、なかなか不便です。

 
そこで今回は、バリデーション時にすべてのエラーメッセージを取得できる iter_errors 関数を使うことにします。

 
そのため、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は世代分用意する
    • りんごの日記として表示を制御できるよう、 dependenciesif-then-else を使う
  • JSON Schemaのバリデーションは世代ごとに行う
  • 新規登録画面について
    • .env にある、現在の世代でデータを登録する
  • 編集画面について
    • DRF APIレスポンスの version に応じた JSON Schemaを読み込み、 RJSF の schema として設定する
      • これにより、1つのRJSFの Form コンポーネントで複数の世代を扱えるようにする
    • 入力欄を灰色とするために、 Form コンポーネントformContextDRF APIレスポンスの version を設定し、各widgetで現在のバージョンを把握できるようにする

で良さそうでした。

 

実装

方針に従って実装を行います。

なお、実装する上で検討したことなどは以下です。

 

登録した時の世代をDBの列 version へ登録する

Diaryモデルに version 列を追加します。

なお、既存のデータへの影響を防ぐため、デフォルトの値も指定しておきます。

class Diary(models.Model):
    # 追加
    version = models.CharField(max_length=50, null=False, default='2023_1224')

 

現在の世代は環境変数や .env で管理する

今回、現在の世代は環境変数で管理します。

また、環境変数への設定を用意にするため、設定内容は .env ファイルに記載しておきます。

.env ファイルを読み込み環境変数へ設定する処理ですが、以下のようにフロントエンド・バックエンドともに可能そうでした。

ちなみに、今回はフロントエンドとバックエンドの .env ファイルを分けています。

  • フロントエンド用
    • frontend/src.env ファイル
  • バックエンド用
    • ルート直下の .env ファイル

 

JSON Schemaは世代分用意する

今までのデータと今後のデータの両世代に対応できるよう、JSON Schemaは世代分用意します。

そこで、

  • 今までのデータ向けJSON Schemaは diary_2023_1224.json としてリネーム
  • 今後のデータ向けJSON Schemaは diary_2023_1225.json として作成

とします。

diary_2023_1225.json については、確実にリンゴの情報を入力してもらえるよう、

  1. 色を選択したら、名前を選択できる
  2. 名前で その他 を選択したら、名前の自由記入欄が表示・入力できる
  3. 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 をサポートしていないようで、この定義方法は使えませんでした。

そこで、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, ...}
}

 

バックエンド

Pythonjsonschema ライブラリも、適切な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に保存する時の「現在の世代」をどの場所で取得するか

案としては

  1. フロントエンドで .env から現在の世代を取得し、バックエンドに渡す
  2. バックエンドで .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 コンポーネントformContextDRF 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

 

おわりに

今回の記事では

  • JSON Schemaを元にRJSFで入力フォームを作り、Djangoでデータを保存できるようにする
  • JSON Schemaの修正が入った場合も対応できるようにする

というWebアプリを作ってきました。

なお、今回はデザイン面はほとんど何も手を付けていませんが、

などをカスタマイズすることで、好みのデザインのフォームを作ることもできます。

それぞれのカスタマイズ方法については、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