◀ 24.

DjangoモデルをJSONで返す|型・公開フィールド・DRF認可

▶

DjangoモデルをJSONで返すときは、Pythonの辞書/配列・JSON文字列・HTTP応答を区別します。変換できることと、どのユーザーのどの項目を返してよいかは別の問題です。

所有者と公開fieldを選びPython値とJSON文字列とHTTP応答を分ける図

元の例:JSON文字列を辞書に入れるとどうなるか

初期版のUserViewはserializeで得た文字列をusersへ入れてJsonResponseへ渡していました。その結果はusersが配列ではなく文字列です。元の「jsonオブジェクトとなる」という説明だけでは、この違いが分かりません。次の表は元v2のコードを保持した資料です。delete_flagだけを権限判定とせず、Userを全fieldで公開する実装としてコピーしません。ここでいうUserは元の独自モデルで、Django標準auth.Userにdelete_flagがあるという意味でもありません。

from django.core import serializers
from django.http import JsonResponse

from django.views.generic import View
from db.models.user import User


class UserView(View):

    def get(self, request, *args, **kwargs):

        users = User.objects.filter(delete_flag=0)
        users =
serializers.serialize('json', users)

        context = {
            'users': users,
        }

        return JsonResponse(context)

文字列・配列・直接HTTPを比べる

次の関数には、認可済みで件数を限定したBook QuerySetだけを渡します。最初はbooksが文字列、次はbooksが配列、最後はトップレベル配列です。json.loadsで二重JSONを解消することも認可にはならず、Userの全データを変換し直して公開しません。

import json

from django.core import serializers
from django.http import HttpResponse, JsonResponse


def compare_representations(scoped_queryset):
    # Call only with an authorized, bounded queryset; never serialize auth.User wholesale.
    serialized = serializers.serialize("json", scoped_queryset, fields=("title", "isbn"))
    return (
        JsonResponse({"books": serialized}),       # books is a JSON string.
        JsonResponse({"books": json.loads(serialized)}),  # books is an array.
        HttpResponse(serialized, content_type="application/json"),  # Top-level array.
    )
この記事の要点
  • 辞書を明示して返す例: JsonResponse({"id": obj.id, "name": obj.name}) で辞書を直接返す
  • QuerySet 一括: django.core.serializers.serialize("json", qs)
  • DRFは選択肢の一つ: ModelSerializer でフィールド宣言的に、@api_view + Response で返却
  • Date / Decimal / UUID は標準 json モジュールで失敗 → DjangoJSONEncoderや明示変換等で出力形式を決める
  • ネスト関係は DRF の depth / nested serializer / SerializerMethodField で表現

4 つのアプローチ

方法用途長所短所
1. JsonResponse 直接小規模 API、単純レコードDjango標準。速度の優劣は未測定フィールド指定が手動
2. django.core.serializersQuerySet 丸ごと標準同梱独特なラップ形式
3. DRF ModelSerializer入力/出力を宣言するAPI宣言的、バリデーション付きパッケージ追加
4. 自作 to_dict()カスタム整形柔軟メンテ負担

方法1: JsonResponse で直接返す

旧例の注意:この旧例には認証/所有者の絞込みや一覧件数上限がありません。getで対象がない場合の404と、published_atがnullの場合も別途扱います。JSON化しても他人の行や関連を返してよいことにはなりません。

from django.http import JsonResponse
from .models import Book

def book_detail(request, pk):
    book = Book.objects.get(pk=pk)
    return JsonResponse({
        'id':           book.id,
        'title':        book.title,
        'isbn':         book.isbn,
        'published_at': book.published_at.isoformat(),   # date → ISO 文字列
        'author':       {
            'id':   book.author.id,
            'name': book.author.name,
        },
    })

# QuerySet をリストで返す
def book_list(request):
    qs = Book.objects.select_related('author').all()
    data = [{
        'id':    b.id,
        'title': b.title,
        'author': b.author.name,
    } for b in qs]
    return JsonResponse({'count': len(data), 'results': data})
    # ↑ safe=False が必要な場合は: JsonResponse(data, safe=False)

safe=False の話

JsonResponse はデフォルトで辞書のみ受け付けます。リストを直接返したい場合は safe=False を付けます:

旧例の注意:safe=Falseはトップレベルに辞書以外のPython値を渡すための指定です。認可や入力の無害化ではありません。安全性の理由だけで現在のすべてのJSON配列が禁止されるわけではなく、応答形式はAPIの契約で決めます。

return JsonResponse([{'a': 1}, {'b': 2}], safe=False)
# safe=True (既定) で list を渡すと TypeError 発生

# JSON Hijacking 対策で禁止されている、ラップ推奨:
return JsonResponse({'data': [{'a': 1}, {'b': 2}]})

方法2: django.core.serializers

旧例の注意:serializeの結果はJSON文字列です。そのままHttpResponseへ渡す場合とJsonResponseの辞書に入れる場合は型が変わります。全件を無認可で返さず、対象/件数/fieldsを先に限定します。model/pk等の構造も公開API用DTOと同じとは限りません。

from django.core import serializers
from django.http import HttpResponse
from .models import Book

def book_list_json(request):
    qs = Book.objects.all()
    data = serializers.serialize('json', qs, fields=('title', 'isbn'))
    return HttpResponse(data, content_type='application/json')

# 出力例
# [
#   {"model": "library.book", "pk": 1,
#    "fields": {"title": "Python入門", "isbn": "9784..."}},
#   ...
# ]

独自のフォーマット(model / pk / fields)でラップされるため、フロント側が普通の JSON を期待していると扱いにくい点に注意。fixture 形式に近い。

方法3: Django REST Framework(認可とfieldを明示)

旧例の注意:追加依存の導入例であり既存の本番環境へそのままインストールする指示ではありません。既存INSTALLED_APPSや設定全体を消さず、対応版・独立した検証環境・配布手順を確認します。

pip install djangorestframework

# settings.py
INSTALLED_APPS += ['rest_framework']

実行検証したモデル・API・URLの対応

新教材は、登録ユーザーが自分のBookと自分のAuthorだけを扱う方針です。管理者の他人操作や共有は実装しません。モデル例のprivate_note/ownerは出力せず、入力にも許可しません。既存アプリのモデルやDBをこの教材へ差し替える指示ではありません。

from django.conf import settings
from django.db import models


class Author(models.Model):
    owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    name = models.CharField(max_length=80)
    private_note = models.TextField(default="")


class Book(models.Model):
    owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    author = models.ForeignKey(Author, on_delete=models.PROTECT, related_name="books")
    title = models.CharField(max_length=120)
    isbn = models.CharField(max_length=13)
    published_at = models.DateField(null=True, blank=True)
    private_note = models.TextField(default="")

api.pyは通常DjangoのGET専用ビューとDRF ViewSetを含むファイル全体です。通常ビューは先頭20件だけでページングAPIではありません。DRFは一意id順の既定20/最大50件とし、author_idの入力先もrequest.userへ限定します。serializerを入力に使うときはrequest contextが必要で、ない場合はAuthorを選べない設定です。is_recentは日付null/未来を偽とし、今日から過去89日までを真と定義しました。業務ごとの定義とは別です。

from io import BytesIO

from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.views.decorators.http import require_GET
from rest_framework import serializers, viewsets
from rest_framework.authentication import SessionAuthentication
from rest_framework.exceptions import APIException, UnsupportedMediaType
from rest_framework.pagination import PageNumberPagination
from rest_framework.parsers import JSONParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import JSONRenderer

from .models import Author, Book


def owned_books(user):
    # A row with a foreign author's owner is also outside this teaching policy.
    return Book.objects.filter(owner=user, author__owner=user).select_related("author").order_by("id")


def public_book(book):
    return {
        "id": book.pk, "title": book.title, "isbn": book.isbn,
        "published_at": book.published_at,
        "author": {"id": book.author_id, "name": book.author.name},
    }


@require_GET
def book_list(request):
    if not request.user.is_authenticated:
        return JsonResponse({"detail": "Login required."}, status=403)
    # This is a bounded first batch, NOT an automatic DRF paginated view.
    return JsonResponse({"results": [public_book(book) for book in owned_books(request.user)[:20]]})


@require_GET
def book_detail(request, pk):
    if not request.user.is_authenticated:
        return JsonResponse({"detail": "Login required."}, status=403)
    return JsonResponse(public_book(get_object_or_404(owned_books(request.user), pk=pk)))


class AuthorSerializer(serializers.ModelSerializer):
    class Meta:
        model = Author
        fields = ("id", "name")


class BookSerializer(serializers.ModelSerializer):
    author = AuthorSerializer(read_only=True)
    author_id = serializers.PrimaryKeyRelatedField(
        queryset=Author.objects.none(), source="author", write_only=True,
    )
    is_recent = serializers.SerializerMethodField()

    class Meta:
        model = Book
        fields = ("id", "title", "isbn", "published_at", "author", "author_id", "is_recent")
        read_only_fields = ("id",)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        request = self.context.get("request")
        if request is not None and request.user.is_authenticated:
            self.fields["author_id"].queryset = Author.objects.filter(owner=request.user)

    def to_internal_value(self, data):
        allowed = {"title", "isbn", "published_at", "author_id"}
        if not isinstance(data, dict) or set(data) - allowed:
            raise serializers.ValidationError({"detail": "Expected only writable book fields."})
        for field in ("title", "isbn"):
            if field in data and type(data[field]) is not str:
                raise serializers.ValidationError({field: "Expected a string."})
        if "published_at" in data and data["published_at"] is not None and type(data["published_at"]) is not str:
            raise serializers.ValidationError({"published_at": "Expected an ISO date or null."})
        if "author_id" in data and (type(data["author_id"]) is not int or not 1 <= data["author_id"] <= 2147483647):
            raise serializers.ValidationError({"author_id": "Expected a positive integer."})
        return super().to_internal_value(data)

    def get_is_recent(self, obj):
        return obj.published_at is not None and 0 <= (timezone.localdate() - obj.published_at).days < 90


class BookPagination(PageNumberPagination):
    page_size = 20
    page_size_query_param = "page_size"
    max_page_size = 50


class BodyTooLarge(APIException):
    status_code = 413
    default_detail = "JSON body too large."


class BoundedJSONParser(JSONParser):
    def parse(self, stream, media_type=None, parser_context=None):
        encoding = (parser_context or {}).get("encoding") or "utf-8"
        if encoding.lower().replace("-", "") != "utf8":
            raise UnsupportedMediaType(media_type)
        raw = stream.read(4097)
        if len(raw) > 4096:
            raise BodyTooLarge()
        return super().parse(BytesIO(raw), media_type, parser_context)


class BookViewSet(viewsets.ModelViewSet):
    authentication_classes = (SessionAuthentication,)
    permission_classes = (IsAuthenticated,)
    parser_classes = (BoundedJSONParser,)
    renderer_classes = (JSONRenderer,)
    serializer_class = BookSerializer
    pagination_class = BookPagination

    def get_queryset(self):
        return owned_books(self.request.user)

    def perform_create(self, serializer):
        serializer.save(owner=self.request.user)

通常GETは/plain/books/、DRFは/api/books/へ分けます。既存auth/session/CSRF middlewareと認証したsessionが前提で、JSONだけを受け付けます。新parserで読むJSON本文は4096byteまで/UTF-8のみです。未使用のDELETE本文等まで解析する全体のrequest容量制限や連打対策ではなく、Web層/アプリの制限や監査も別に設計します。ISBNは長さ等の文字列検査で、ISBNのチェックディジットまで実装していません。

from django.urls import include, path
from rest_framework.routers import SimpleRouter

from .api import BookViewSet, book_detail, book_list

router = SimpleRouter()
router.register("books", BookViewSet, basename="book")
urlpatterns = [
    path("plain/books/", book_list),
    path("plain/books/<int:pk>/", book_detail),
    path("api/", include(router.urls)),
]

ModelSerializer

旧例の注意:この最初のserializer自体はfieldsを明示していますが、author_idのquerysetが全Authorを許可し、関数ビューにも認可がありません。request contextも渡していません。日付nullで失敗し、未来の日もis_recentが真になる式です。直後の新教材では入力関連も所有者へ限定します。

# serializers.py
from rest_framework import serializers
from .models import Book, Author

class AuthorSerializer(serializers.ModelSerializer):
    class Meta:
        model  = Author
        fields = ['id', 'name', 'birth_date']

class BookSerializer(serializers.ModelSerializer):
    author = AuthorSerializer(read_only=True)        # ネスト表示
    author_id = serializers.PrimaryKeyRelatedField(
        queryset=Author.objects.all(), source='author', write_only=True
    )
    # 計算項目
    is_recent = serializers.SerializerMethodField()

    class Meta:
        model  = Book
        fields = ['id', 'title', 'isbn', 'published_at',
                  'author', 'author_id', 'is_recent']
        read_only_fields = ['id']

    def get_is_recent(self, obj):
        from datetime import date
        return (date.today() - obj.published_at).days < 90


# views.py
from rest_framework.decorators import api_view
from rest_framework.response import Response
from .serializers import BookSerializer

@api_view(['GET'])
def book_list(request):
    qs = Book.objects.select_related('author').all()
    ser = BookSerializer(qs, many=True)
    return Response(ser.data)

@api_view(['POST'])
def book_create(request):
    ser = BookSerializer(data=request.data)
    ser.is_valid(raise_exception=True)
    ser.save()
    return Response(ser.data, status=201)

ViewSet + Router でフル CRUD

旧例の注意:旧ViewSetは認可指定がなく、全体設定が既定ならAllowAnyとなります。Serializerのfieldを絞るだけでは匿名CRUDや他人の行を防げません。所有者でのget_querysetと作成時ownerも必要です。

from rest_framework import viewsets
from rest_framework.routers import DefaultRouter

class BookViewSet(viewsets.ModelViewSet):
    queryset = Book.objects.select_related('author')
    serializer_class = BookSerializer

router = DefaultRouter()
router.register(r'books', BookViewSet)
urlpatterns = router.urls
# → /books/ (GET, POST), /books/<id>/ (GET, PUT, PATCH, DELETE) 自動生成

Date / UUID / Decimal の扱い

Python 標準の json.dumps() は date / datetime / Decimal / UUID をそのままシリアライズできません:

旧例の注意:日付は固定入力の例とdate.todayの実行結果を混同しません。DjangoJSONEncoderの出力形式とDRF各fieldの表現は別に確認します。JsonResponseは既定で同encoderを使いますが、モデル/QuerySetを直接変換する機能ではありません。

import json
from datetime import date
from decimal import Decimal
from uuid import uuid4

data = {'d': date.today(), 'p': Decimal('123.45'), 'u': uuid4()}

# ❌ TypeError: Object of type date is not JSON serializable
json.dumps(data)

# ✅ Django 標準のエンコーダを使う
from django.core.serializers.json import DjangoJSONEncoder
json.dumps(data, cls=DjangoJSONEncoder)
# {"d": "2025-01-15", "p": "123.45", "u": "..."}

# JsonResponse は内部で DjangoJSONEncoder を使うので OK
from django.http import JsonResponse
JsonResponse(data)   # 動く

各型の出力フォーマット

Python 型JSON 出力
date"2025-01-15"
datetime"2025-01-15T10:00:00+09:00"
time"10:00:00"
timedelta"P3DT12H00M00S"(3日12時間を与えた場合)
Decimal"123.45"(文字列)
UUID"550e8400-e29b-41d4-a716-446655440000"
Promise (lazy)str() 評価後の文字列

ネスト関係の扱い

旧例の注意:後半のfields=__all__/depth=1は最初の明示field例とは別のクラスです。同じファイルへ貼るとBookSerializerという名前を上書きします。depthの関連表示はread-onlyでも他のfieldを含む保存全体が禁止になるわけではなく、関連の公開fieldや所有者の制限を自動で保証しません。

# Author → Book[]
class AuthorWithBooksSerializer(serializers.ModelSerializer):
    books = BookSerializer(many=True, read_only=True)     # related_name='books' で逆参照

    class Meta:
        model  = Author
        fields = ['id', 'name', 'books']

# 出力
# {
#   "id": 1, "name": "村上春樹",
#   "books": [
#     {"id": 11, "title": "...", "isbn": "..."},
#     {"id": 12, "title": "...", "isbn": "..."}
#   ]
# }

# depth で簡易ネスト(書き込みは不可)
class BookSerializer(serializers.ModelSerializer):
    class Meta:
        model = Book
        fields = '__all__'
        depth = 1    # ForeignKey が自動ネスト

SerializerMethodField で計算項目

旧例の注意:この計算例も日付null/未来と業務の時刻帯を考慮していません。build_absolute_uriのHost/HTTPS/proxy設定とAPIルートは別に確認します。method fieldは認可を代行せず、追加SQLが出る場合は実際に測ります。

class BookSerializer(serializers.ModelSerializer):
    days_since_published = serializers.SerializerMethodField()
    author_name = serializers.CharField(source='author.name', read_only=True)
    full_url = serializers.SerializerMethodField()

    class Meta:
        model  = Book
        fields = ['id', 'title', 'author_name',
                  'days_since_published', 'full_url']

    def get_days_since_published(self, obj):
        from datetime import date
        return (date.today() - obj.published_at).days

    def get_full_url(self, obj):
        request = self.context.get('request')
        return request.build_absolute_uri(f'/books/{obj.id}/') if request else None

パフォーマンス: N+1 を避ける

旧例の注意:これは概念比較の旧断片です。Prefetchのimportやis_publishedフィールド等のモデル前提が省略されています。クエリ数/公開範囲/結果の同一性を比較し、select_relatedだけで常に最速・一定メモリとは断定しません。

# ❌ N+1: author を毎回 SELECT
qs = Book.objects.all()
BookSerializer(qs, many=True).data
# SELECT * FROM book;
# SELECT * FROM author WHERE id=1;   ← N 回
# SELECT * FROM author WHERE id=2;
# ...

# ✅ select_related (1:1, ForeignKey)
qs = Book.objects.select_related('author')

# ✅ prefetch_related (1:N, M:N)
qs = Author.objects.prefetch_related('books')

# ✅ さらに細かく
qs = Author.objects.prefetch_related(
    Prefetch('books', queryset=Book.objects.filter(is_published=True))
)

ページネーション

旧例の注意:この全体設定だけで普通の関数ビューやapi_viewが自動ページングされるわけではありません。新例はViewSetにpagination_classを明示します。旧CursorPaginationのimport/接続も省略されており、nullable/更新される日付と同点順を検討せず高速・重複なしとは保証しません。

# settings.py
REST_FRAMEWORK = {
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 20,
}

# 出力
# {
#   "count": 145,
#   "next": "http://localhost:8000/books/?page=2",
#   "previous": null,
#   "results": [ ... ]
# }

# Cursor 方式(大量データ向け)
class BookCursor(CursorPagination):
    ordering = '-published_at'
    page_size = 50

カスタム to_dict() アプローチ

旧例の注意:このBook定義にはauthorがないためwith_author=Trueはこのままでは動きません。モデル側へto_dictを足すだけで認可・公開範囲・上限は設定されず、QuerySet全件やUserの内部フィールドを丸ごと返す解決策ではありません。

# models.py に直接生やす
class Book(models.Model):
    title = models.CharField(max_length=200)
    isbn  = models.CharField(max_length=13)

    def to_dict(self, *, with_author=False):
        d = {
            'id':    self.id,
            'title': self.title,
            'isbn':  self.isbn,
        }
        if with_author:
            d['author'] = self.author.to_dict()
        return d

# views.py
def book_list(request):
    qs = Book.objects.select_related('author')
    return JsonResponse({'results': [b.to_dict(with_author=True) for b in qs]})

FAQ

Q: JsonResponse と HttpResponse の違いは?
A: JsonResponse は内部で json.dumps(cls=DjangoJSONEncoder) を呼び、Content-Type: application/json を自動付与します。素の HttpResponse だと両方手動。

Q: 日付のタイムゾーンが UTC で返ってしまう
A: settings.py の TIME_ZONE と USE_TZ=True を確認。表示用に Asia/Tokyo へ変換するには django.utils.timezone.localtime()。

Q: DRF を入れずに JWT 認証だけ追加したい
A: ライブラリ名だけで安全性や速度の優劣は断定できません。必要な認証方式と検証・失効・保管等を別に設計します。本稿はsession認証の隔離教材で、JWTや実ログインAPIの実装は検証していません。

値の変換と公開境界の確認

model_to_dictも公開DTOではありません。教材ではprivate_noteを含む辞書となり、既定のJsonResponseへモデルやQuerySetをそのまま渡すとTypeErrorになりました。safe=Falseはリスト等のトップレベル型を許す指定で、ユーザーの閲覧権限を解除するものではありません。

DjangoJSONEncoderではdateは日付文字列、Decimal/UUIDは文字列、3日12時間のtimedeltaはP3DT12H00M00Sでした。UTCのdatetimeはZ形式で小数秒はミリ秒までとなる例を確認し、時刻帯付きtimeはValueErrorになる条件も検査しました。JSONにはdate/Decimal型がそのまま保存されるわけではなく、受信側と型・精度・null・時刻帯の契約を合わせます。

5冊を同じ応答内容で比較した隔離検体では、著者へのN+1が6 SELECT、select_relatedで1 SELECTでした。これはそのモデル/serializer/データでの本数であり、常に6→1、最速、同じメモリ使用量という保証ではありません。prefetchで取得した全関連をそのまま公開せず、関連の範囲と出力fieldも確認します。

検証範囲と関連資料

2026年10月26日確認。Python3.12.10/Django5.2.18/DRF3.18.3/SQLite3.49.1の新しい専用メモリDBで14試験を確認しました。実sessionとCSRF検査を有効にし、匿名/所有者/別人CRUD、他人author_id・入力owner等の拒否、拒否時のBook不変、公開field・不整合な関連、ページ上限、JSONの型/日付/null/未来日とN+1の同じ結果を比較しています。

本番データ/設定/依存/schemaは変更せず、本番POSTやmigrationは実行していません。実ブラウザ/TLS/CORS/Token/JWT・MySQL/PostgreSQL・並列更新・性能・全業務認可・全serializerの組合せは未検証です。ページ順を固定しても同時更新中のsnapshotは保証しません。

送信側はAjax POSTとCSRF・認可の教材、ビューの役割はViewの子ページ案内を参照してください。公式資料:Djangoのserialize/encoder、JsonResponse、DRFの入力関連queryset、認可、ページング。

子ページ

子ページはありません

同階層のページ
  1. 環境構築とプロジェクト/アプリの作成
  2. MVC(MVT)のそれぞれの使い方と説明
  3. データベースへの接続と操作
  4. Django Administration
  5. git管理
  6. エラー一覧
  7. バージョンの確認方法
  8. ログ出力方法
  9. SQLのログ出力方法
  10. ログのローテート設定
  11. settings.pyの定数にアクセスする方法
  12. 本番環境へのインストールとアプリのデプロイ(apache編)
  13. 本番環境へのインストールとアプリのデプロイ(nginx編)
  14. djangoアプリの本番の開始URLを変更する
  15. 静的(static)ファイルの置き場所と読み込み(画像、css、js )
  16. CSRFトークンをAjaxで使用する方法
  17. ajaxの使用例(POST編)
  18. ファイルのアップロードとファイルの名前
  19. クイックスタート/チュートリアル
  20. ログイン機能
  21. テンプレート側のログイン判定
  22. ビュー側のログイン判定
  23. 管理者ユーザーの作成/判定と管理画面
  24. モデルのjson化とレスポンス
  25. runserverでポートを指定する方法
  26. cronによるバッチ実行
  27. テンプレートで利用する共通のcontextを定義する方法
  28. プログラムが本番サーバーで反映されない場合の対処法
  29. APIの作成
  30. cron用コマンド・ファイルの作成

最近更新/作成されたページ