DRF勉強(vol.2) バックエンド作成

## はじめに

DRF(Django REST framework)の勉強のため、以下書籍を参考にサンプル作成します。  
最終的には書籍にあるVue+DRFの作成を目指します。

1. 現場で使えるDjangoの教科書
2. 現場で使えるDjango REST Frameworkの教科書

Vol.1ではバックエンドプロジェクトの作成と初期設定まで行いました。 
Vol.2ではバックエンドのプログラムを作成していきます。

### 環境

- Intel Mac 13.4.1(c)
- Python 3.11.4
- Django 4.2.3
- DRF 3.14.0

### 前提

- pyenvがインストールされていること
- poetryがインストールされていること

## モデル

### モデルとは

- Django ORMで利用するクラスのこと。
- DBのテーブルとカラム定義とモデルクラスとクラス変数を対応させる形で作成する。
  - (通常)はDBの1つのテーブルに対して1つ作成する。
- `django.db.models.Model`を継承して作成する。
- `Meta.db_table`にテーブル名を指定する。
  - 未指定の場合は「\[アプリケーション名\]\_\[モデルクラス名(スネークケース)\]」になる。
- テーブルのカラムに対するフィールドをFieldクラスで定義する。
  - `django.db.models.fields.Field`のサブクラスを利用する。
  - DB上の制約や表示名、バリデーションはフィールドオプションで指定する。

### 作成するモデルのクラス定義

shopアプリケーションにBookモデルクラスを作成します。

```python:shop/models.py
from django.db import models

import uuid


class Book(models.Model):
    class Meta:
        db_table = "book"

    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    title = models.CharField(verbose_name="タイトル", max_length=20)
    price = models.IntegerField(verbose_name="価格", null=True, blank=True)
    created_at = models.DateTimeField(verbose_name="登録日時", auto_now_add=True)

    def __str__(self) -> str:
        return self.title
```

<details>
<summary>指定したフィールドオプション</summary>

| オプション | 説明 |
|---|---|
| primary_key | 主キーとなるフィールドの場合`True`を設定する |
| default | レコード登録時に値が指定されなかった場合のデフォルト値 |
| editable | 管理画面、その他の画面に表示するか否か |
| verbose_name | フィールド名 |
| max_length | 文字列の最大文字数、CharFieldの場合は指定必須 |
| null | Null許可 |
| blank | フォーム入力時に入力必須とするか否か、必須にしたい場合`False`を指定する |
| auto_now_add | オブジェクトが最初に作成される場合に、現在日時を自動設定する |
</details>

### マイグレーション

作成したモデル定義をDBに適用します。

```sh
# マイグレーションファイルの作成
$ ./manage.py makemigrations shop

# DBに適用
$ ./manage.py migrate
```

### REPLでモデルを試す

Python対話シェルを利用してBookモデルを試してみます。

```shell
❯ ./manage.py shell
>>> from shop.models import Book

# 全件取得
>>> Book.objects.all();
<QuerySet []>

# レコード登録
>>> book = Book(title='テスト', price=100)
>>> book.save()

# 登録したレコードの確認
>>> book.id
UUID('0723d0ee-3903-4b07-8c41-190f0b8bec9e')
>>> print(book)
テスト
>>> vars(book)
{'_state': <django.db.models.base.ModelState object at 0x109798c50>, 'id': UUID('0723d0ee-3903-4b07-8c41-190f0b8bec9e'), 'title': 'テスト', 'price': 100, 'created_at': datetime.datetime(2023, 7, 24, 1, 57, 24, 638531, tzinfo=datetime.timezone.utc)}

# 再度 全件取得
>>> Book.objects.all()
<QuerySet [<Book: テスト>]>

# 条件指定で検索
>>> Book.objects.filter(title='テスト')
<QuerySet [<Book: テスト>]>

# 1件取得
>>> Book.objects.filter(title='テスト').get()
<Book: テスト>

# 1件取得でレコードがない場合 → DoesNotExist例外が発生する
>>> book = Book.objects.filter(title='テスト2').get()
Traceback (most recent call last):
   ・・・
shop.models.Book.DoesNotExist: Book matching query does not exist.
```

## シリアライザー

### シリアライザーとは

- APIのリクエスト/レスポンスとオブジェクトを相互変換するためのクラス。
- シリアライザーのインスタンス作成時、「`data`に入力データ」「`instance`にDBから取得したモデルオブジェクト」を指定する。
- シリアライズ、デシリアライズ、バリデーションを行う。
- JSONの構造がモデルのフィールド定義がベースな場合、`rest_framework.serializers.ModelSerializer`を継承して作成する。

### 作成するシリアライザーのクラス定義

apiアプリケーションにBookモデル用のシリアライザークラスを作成します。

```python:apiv1/serializers.py
from rest_framework import serializers

from shop.models import Book


class BookSerializer(serializers.ModelSerializer):
    class Meta:
        model = Book
        fields = ["id", "title", "price"]
```

### REPLでシリアライザーを試す

Python対話シェルを利用してBook用シリアライザーを試してみます。

#### 共通

以降の手順で共通となるモジュールインポート部分になります。

```shell
❯ ./manage.py shell
Python 3.11.4 (main, Jun 20 2023, 16:59:59) [Clang 14.0.3 (clang-1403.0.22.14.1)] on darwin
>>> from io import BytesIO
>>> from rest_framework.parsers import JSONParser
>>> from rest_framework.renderers import JSONRenderer
>>> from apiv1.serializers import BookSerializer
>>> from shop.models import Book
```

#### データ登録の場合

データ登録での リクエスト受信 → DBに保存 → レスポンス返却のイメージになります。

```shell
# リクエストデータ相当を作成
>>> input = JSONParser().parse(BytesIO('{"title":"テスト2","price":100}'.encode()))
>>> input
{'title': 'テスト2', 'price': 100}

# シリアライザーの作成
>>> serializer = BookSerializer(data=input)

# バリデーション
>>> serializer.is_valid()
True

# 検証済みデータの確認(`is_valid()`実行前だとAssertionErrorになる)
>>> serializer.validated_data
OrderedDict([('title', 'テスト2'), ('price', 100)])

# オブジェクトの生成(モデルの場合は永続化まで行われる)
>>> serializer.save()
<Book: テスト2>

# 生成したオブジェクトの取得(`save()`前は取得できない)
>>> serializer.instance
<Book: テスト2>

# レスポンス作成
>>> serializer.data
{'id': 'cc45c34e-d09b-4ab0-8b27-f92bd0427b2d', 'title': 'テスト2', 'price': 100}
>>> JSONRenderer().render(serializer.data)
b'{"id":"cc45c34e-d09b-4ab0-8b27-f92bd0427b2d","title":"\xe3\x83\x86\xe3\x82\xb9\xe3\x83\x882","price":100}'
```

#### データ更新の場合

データ更新(PATCH)での リクエスト受信 → DBに保存 → レスポンス返却のイメージになります。

```shell
# リクエストデータ相当を作成
>>> input = JSONParser().parse(BytesIO('{"id":"cc45c34e-d09b-4ab0-8b27-f92bd0427b2d", "price":1000}'.encode()))
>>> input
{'id': 'cc45c34e-d09b-4ab0-8b27-f92bd0427b2d', 'price': 1000}


# モデル取得
>>> instance = Book.objects.get(pk="cc45c34e-d09b-4ab0-8b27-f92bd0427b2d")
>>> instance
<Book: テスト2>

# シリアライザーの作成 (リクエストの内容だけ更新したいので `partial=True`を指定)
>>> serializer = BookSerializer(instance=instance, data=input, partial=True)

# バリデーション
>>> serializer.is_valid()
True

# オブジェクトの生成(モデルの場合は永続化まで行われる)
>>> serializer.save()
<Book: テスト2>

# 生成したオブジェクトの取得
>>> serializer.instance
<Book: テスト2>
>>> serializer.instance.price
1000

# レスポンス作成
>>> JSONRenderer().render(serializer.data)
b'{"id":"cc45c34e-d09b-4ab0-8b27-f92bd0427b2d","title":"\xe3\x83\x86\xe3\x82\xb9\xe3\x83\x882","price":1000}'
```

#### バリデーションエラーになる場合

```shell
# 不正なインプット(価格がの文字列)
>>> input = JSONParser().parse(BytesIO('{"title":"テスト2","price":"文字列"}'.encode()))

# バリデーション
>>> serializer = BookSerializer(data=input)
>>> serializer.is_valid()
False
>>> serializer.errors
{'price': [ErrorDetail(string='A valid integer is required.', code='invalid')]}

# バリデーションで例外をスロー
>>> serializer.is_valid(raise_exception=True)
Traceback (most recent call last):
   ・・・
rest_framework.exceptions.ValidationError: {'price': [ErrorDetail(string='A valid integer is required.', code='invalid')]}
```

## ビュー

### ビューとは

- リクエストオブジェクトを受け取り、レスポンスオブジェクトを返却するクラス。
- 以下のいずれかを継承して作成する。
  - `rest_framework.viewsets.ModelViewSet`(または `ReadOnlyModelViewSet`)
    - 利用シーン: 単一モデルのCRUD処理を実装する場合に利用。
  - `rest_framework.generics.CreateAPIView`などの「汎用APIView」
    - 利用シーン: 単一モデルの一部アクションのみを実装する場合に利用。
  - `rest_framework.views.APIView`
    - 利用シーン: モデルを扱わない、または 同時に複数のモデルを扱う場合などに利用。

##### ModelViewSet系ビューの対応アクション

| クラス | 一覧<br>GET | 詳細<br>GET | 登録<br>POST | 更新<br>PUT | 一部更新<br>PATCH | 削除<br>DELETE|
|---|:-:|:-:|:-:|:-:|:-:|:-:|
| ModelViewSet | ○ | ○ | ○ | ○ | ○ | ○ |
| ReadOnlyModelViewSet | ○ | ○ | - | - | - | - |

### 作成するビューのクラス定義

```py: apiv1/views.py
from rest_framework import viewsets

from shop.models import Book
from .serializers import BookSerializer


class BookViewSet(viewsets.ModelViewSet):
    queryset = Book.objects.all()
    serializer_class = BookSerializer
```

### ルーティング設定の追加

作成したビューへのルーティングを設定します。

#### アプリケーションへのルート設定

`apiv1/urls.py`を作成し、apiv1アプリケーションにルート設定を行います。

```python:apivi/urls.py
from django.urls import path, include
from rest_framework import routers

from . import views

router = routers.DefaultRouter()
router.register("books", views.BookViewSet)

app_name = "apiv1"
urlpatterns = [path("", include(router.urls))]
```

#### プロジェクトへのルート設定

`config/urls.py`を修正して、アプリケーションのルートを設定します。

```diff:config/urls.py
  from django.contrib import admin
- from django.urls import path
+ from django.urls import path, include
  
  urlpatterns = [
      path("admin/", admin.site.urls),
+     path("api/v1/", include("apiv1.urls")),
  ]
```


### 動作確認

開発サーバーを起動してAPIの動作確認をします。

#### 開発サーバーの起動

```sh
$ ./manage.py runserver
```

#### curlでの動作確認

<details>
<summary>一覧取得</summary>

```sh
$ curl -i -H "Content-Type: application/json" \
    -X GET http://localhost:8000/api/v1/books/

HTTP/1.1 200 OK
Date: Mon, 24 Jul 2023 06:14:32 GMT
Server: WSGIServer/0.2 CPython/3.11.4
Content-Type: application/json
Vary: Accept, Cookie
Allow: GET, POST, HEAD, OPTIONS
X-Frame-Options: DENY
Content-Length: 167
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

[{"id":"0723d0ee-3903-4b07-8c41-190f0b8bec9e","title":"テストテスト","price":100},{"id":"d7cc2fa5-7d79-4d50-8220-5c97e305b1c5","title":"テスト2","price":100}]%
```

</details>

<details>
<summary>詳細取得</summary>

```sh
$ curl -i -H "Content-Type: application/json" \
    -X GET http://localhost:8000/api/v1/books/0723d0ee-3903-4b07-8c41-190f0b8bec9e/
    
HTTP/1.1 200 OK
Date: Mon, 24 Jul 2023 06:15:52 GMT
Server: WSGIServer/0.2 CPython/3.11.4
Content-Type: application/json
Vary: Accept, Cookie
Allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS
X-Frame-Options: DENY
Content-Length: 86
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

{"id":"0723d0ee-3903-4b07-8c41-190f0b8bec9e","title":"テストテスト","price":100}%
```
</details>

<details>
<summary>詳細取得(対象レコードがない場合)</summary>

```sh
$ curl -i -H "Content-Type: application/json" \
    -X GET http://localhost:8000/api/v1/books/hogehoge/
    
HTTP/1.1 404 Not Found
Date: Mon, 24 Jul 2023 06:20:26 GMT
Server: WSGIServer/0.2 CPython/3.11.4
Content-Type: application/json
Vary: Accept, Cookie
Allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS
X-Frame-Options: DENY
Content-Length: 23
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

{"detail":"Not found."}%
```
</details>

<details>
<summary>登録</summary>

```sh
$ curl -i -H "Content-Type: application/json" \
    -X POST http://localhost:8000/api/v1/books/ \
    -d '{"title":"テスト3", "price":300}'
    
HTTP/1.1 201 Created
Date: Mon, 24 Jul 2023 06:16:32 GMT
Server: WSGIServer/0.2 CPython/3.11.4
Content-Type: application/json
Vary: Accept, Cookie
Allow: GET, POST, HEAD, OPTIONS
X-Frame-Options: DENY
Content-Length: 78
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

{"id":"ae1bc53c-5194-4e8d-848d-479216dcf3fa","title":"テスト3","price":300}%
```
</details>


<details>
<summary>登録(バリデーションエラーの場合)</summary>

```sh
$ curl -i -H "Content-Type: application/json" \
    -X POST http://localhost:8000/api/v1/books/ \
    -d '{"title":"テスト3", "price":"hogehoge"}'
    
HTTP/1.1 400 Bad Request
Date: Mon, 24 Jul 2023 06:26:11 GMT
Server: WSGIServer/0.2 CPython/3.11.4
Content-Type: application/json
Vary: Accept, Cookie
Allow: GET, POST, HEAD, OPTIONS
X-Frame-Options: DENY
Content-Length: 42
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

{"price":["A valid integer is required."]}%
```
</details>

<details>
<summary>更新</summary>

```sh
$ curl -i -H "Content-Type: application/json" \
    -X PUT http://localhost:8000/api/v1/books/ae1bc53c-5194-4e8d-848d-479216dcf3fa/ \
    -d '{"title":"テスト3-2", "price":320}'
    
HTTP/1.1 200 OK
Date: Mon, 24 Jul 2023 06:17:34 GMT
Server: WSGIServer/0.2 CPython/3.11.4
Content-Type: application/json
Vary: Accept, Cookie
Allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS
X-Frame-Options: DENY
Content-Length: 80
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

{"id":"ae1bc53c-5194-4e8d-848d-479216dcf3fa","title":"テスト3-2","price":320}%
```
</details>

<details>
<summary>更新(対象レコードがない場合)</summary>

```sh
$ curl -i -H "Content-Type: application/json" \
    -X PUT http://localhost:8000/api/v1/books/hogehoge/ \
    -d '{"title":"テスト3-2", "price":320}'
    
HTTP/1.1 404 Not Found
Date: Mon, 24 Jul 2023 06:23:00 GMT
Server: WSGIServer/0.2 CPython/3.11.4
Content-Type: application/json
Vary: Accept, Cookie
Allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS
X-Frame-Options: DENY
Content-Length: 23
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

{"detail":"Not found."}%
```
</details>

<details>
<summary>一部更新</summary>

```sh
$ curl -i -H "Content-Type: application/json" \
    -X PATCH http://localhost:8000/api/v1/books/ae1bc53c-5194-4e8d-848d-479216dcf3fa/ \
    -d '{"price":350}'
    
HTTP/1.1 200 OK
Date: Mon, 24 Jul 2023 06:18:48 GMT
Server: WSGIServer/0.2 CPython/3.11.4
Content-Type: application/json
Vary: Accept, Cookie
Allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS
X-Frame-Options: DENY
Content-Length: 80
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

{"id":"ae1bc53c-5194-4e8d-848d-479216dcf3fa","title":"テスト3-2","price":350}%
```
</details>

<details>
<summary>削除</summary>

```sh
$ curl -i -H "Content-Type: application/json" \
    -X DELETE http://localhost:8000/api/v1/books/ae1bc53c-5194-4e8d-848d-479216dcf3fa/
    
HTTP/1.1 204 No Content
Date: Mon, 24 Jul 2023 06:19:29 GMT
Server: WSGIServer/0.2 CPython/3.11.4
Vary: Accept, Cookie
Allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS
X-Frame-Options: DENY
Content-Length: 0
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin
```
</details>

<details>
<summary>削除(対象レコードがない場合)</summary>

```sh
$ curl -i -H "Content-Type: application/json" \
    -X DELETE http://localhost:8000/api/v1/books/hogehoge/
    
HTTP/1.1 404 Not Found
Date: Mon, 24 Jul 2023 06:23:19 GMT
Server: WSGIServer/0.2 CPython/3.11.4
Content-Type: application/json
Vary: Accept, Cookie
Allow: GET, PUT, PATCH, DELETE, HEAD, OPTIONS
X-Frame-Options: DENY
Content-Length: 23
X-Content-Type-Options: nosniff
Referrer-Policy: same-origin
Cross-Origin-Opener-Policy: same-origin

{"detail":"Not found."}%
```
</details>