跳轉到

Django Ninja

上一個任務中,我們學習了如何使用 Django REST Framework 來建立 API。 現在,讓我們來認識另一個現代化的 API 框架——Django Ninja

開始之前

任務目標

在這個任務中,你將學習:

  • 了解 Django Ninja 的特色
  • 使用 Django Ninja 建立第一個 API
  • 理解 Schema 的用途並實作 CRUD 操作
  • 使用 ModelSchema 簡化程式碼
  • 使用 Router 組織 API 結構
  • 設定 API 的驗證與授權機制
  • 使用內建的 OpenAPI 文件
  • 設定搜尋與過濾功能

什麼是 Django Ninja?

Django Ninja 是一個受 FastAPI 啟發的 Django API 框架。它的核心特色包括:

  • 使用 Python Type Hints 定義 API 參數和回應
  • 基於 Pydantic 的資料驗證
  • 自動產生 OpenAPI 文件,不需要額外套件
  • 高效能,支援非同步
  • 語法簡潔,學習曲線平緩

安裝 Django Ninja

首先,讓我們安裝 Django Ninja:

uv add django-ninja

Django Ninja 不需要加入 INSTALLED_APPS,直接使用即可。

建立第一個 API

Django Ninja 的架構設計是在專案層級建立一個 NinjaAPI 實例,然後讓各個 App 定義自己的 Router。這樣的設計讓程式碼更有組織性。

建立主要 API 實例

首先,在 core 資料夾中建立 ninja.py

core/ninja.py
from ninja import NinjaAPI

api = NinjaAPI()


@api.get("/hello")
def hello(request):
    return {"message": "Hello, Django Ninja!"}

設定 URL

接下來,在專案的主要 URL 設定中引入這個 API:

core/urls.py
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth import views as auth_views
from django.urls import include, path, reverse_lazy
from django.views.generic import RedirectView
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
from rest_framework.authtoken.views import obtain_auth_token

from core import views
from core.ninja import api as ninja_api

auth_urlpatterns = [
    path(
        "login/",
        auth_views.LoginView.as_view(template_name="registration/login.html"),
        name="login",
    ),
    path(
        "logout/",
        auth_views.LogoutView.as_view(),
        name="logout",
    ),
    path("register/", views.register, name="register"),
    path(
        "password-change/",
        auth_views.PasswordChangeView.as_view(
            template_name="registration/password_change.html",
            success_url=reverse_lazy("auth:password_change_done"),
        ),
        name="password_change",
    ),
    path(
        "password-change/done/",
        auth_views.PasswordChangeDoneView.as_view(
            template_name="registration/password_change_done.html"
        ),
        name="password_change_done",
    ),
    path(
        "password-reset/",
        auth_views.PasswordResetView.as_view(
            template_name="registration/password_reset.html",
            success_url=reverse_lazy("auth:password_reset_done"),
        ),
        name="password_reset",
    ),
    path(
        "password-reset/done/",
        auth_views.PasswordResetDoneView.as_view(
            template_name="registration/password_reset_done.html"
        ),
        name="password_reset_done",
    ),
    path(
        "password-reset/<uidb64>/<token>/",
        auth_views.PasswordResetConfirmView.as_view(
            template_name="registration/password_reset_confirm.html",
            success_url=reverse_lazy("auth:password_reset_complete"),
        ),
        name="password_reset_confirm",
    ),
    path(
        "password-reset/complete/",
        auth_views.PasswordResetCompleteView.as_view(
            template_name="registration/password_reset_complete.html"
        ),
        name="password_reset_complete",
    ),
]

urlpatterns = [
    path("", RedirectView.as_view(pattern_name="blog:article_list"), name="root"),
    path("admin/", admin.site.urls),
    path("practices/", include("practices.urls")),
    path("blog/", include("blog.urls")),
    path("auth/", include((auth_urlpatterns, "auth"))),
    path("api-drf/blog/", include("blog.drf_urls")),
    path("api-drf/token", obtain_auth_token, name="api-token"),
    # API 文件
    path("api-drf/schema", SpectacularAPIView.as_view(), name="schema"),
    path(
        "api-drf/docs",
        SpectacularSwaggerView.as_view(url_name="schema"),
        name="swagger-ui",
    ),
    # Django Ninja API
    path("api-ninja/", ninja_api.urls),  # (1)!
]

if settings.DEBUG:
    from debug_toolbar.toolbar import debug_toolbar_urls

    urlpatterns = [
        *urlpatterns,
        *debug_toolbar_urls(),
        *static(settings.STATIC_URL, document_root=settings.STATIC_ROOT),
        *static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT),
    ]
  1. Django Ninja 的 URL 設定方式跟 DRF 不同,直接使用 api.urls 即可

啟動開發伺服器後,開啟 http://127.0.0.1:8000/api-ninja/hello,你會看到 JSON 回應。

更棒的是,Django Ninja 內建了互動式 API 文件!開啟 http://127.0.0.1:8000/api-ninja/docs 就能看到自動產生的 Swagger UI 介面。

使用 Router 建立文章 API

在實際專案中,我們會讓各個 App 定義自己的 Router,然後在主要的 NinjaAPI 中註冊這些 Router。這樣可以保持程式碼的模組化。

建立 Router

blog 資料夾中建立 ninja.py

檔案命名

這裡我們把檔案命名為 ninja.py,是為了與之前 DRF 的 drf_views.py 區分。在實際專案中,如果你只使用一種 API 框架,可以自由選擇檔案名稱,例如 routers.pyapi.pyendpoints.py 都是常見的選擇。

blog/ninja.py
from ninja import Router

from blog.models import Article

router = Router()


@router.get("/articles")
def list_articles(request):
    articles = Article.objects.all()
    return [
        {
            "id": article.id,
            "title": article.title,
            "content": article.content,
            "is_published": article.is_published,
            "created_at": article.created_at,
        }
        for article in articles
    ]


@router.get("/articles/{article_id}")
def get_article(request, article_id: int):  # (1)!
    article = Article.objects.get(id=article_id)
    return {
        "id": article.id,
        "title": article.title,
        "content": article.content,
        "is_published": article.is_published,
        "created_at": article.created_at,
    }
  1. 路徑參數直接透過 Type Hint 定義類型,Django Ninja 會自動進行類型轉換和驗證

在主 API 中註冊 Router

更新 core/ninja.py,把 blog 的 Router 註冊進來:

core/ninja.py
from ninja import NinjaAPI

from blog.ninja import router as blog_router

api = NinjaAPI()
api.add_router("/blog", blog_router, tags=["文章"])


@api.get("/hello")
def hello(request):
    return {"message": "Hello, Django Ninja!"}

現在 API 的結構變得更清晰了:

  • /api-ninja/blog/articles → 文章列表
  • /api-ninja/blog/articles/{article_id} → 單一文章操作

這裡有幾個重點:

  • 路徑參數{article_id} 會自動對應到函式參數 article_id
  • Type Hintsarticle_id: int 告訴 Django Ninja 這個參數應該是整數
  • 自動驗證:如果傳入的不是有效的整數,會自動回傳 422 錯誤

Schema:資料的序列化與驗證

跟 DRF 的 Serializer 類似,Django Ninja 使用 Schema 來定義資料結構。Schema 基於 Pydantic,提供強大的資料驗證功能。

建立 Schema

建立一個新檔案 blog/schemas.py

blog/schemas.py
from datetime import datetime

from ninja import Schema


class ArticleIn(Schema):
    title: str
    content: str
    is_published: bool = False  # (1)!


class ArticleOut(Schema):
    id: int
    title: str
    content: str
    is_published: bool
    created_by_id: int
    author_id: int | None  # (2)!
    created_at: datetime
    updated_at: datetime
  1. 設定預設值,如果沒有提供就使用 False
  2. 使用 | None 表示這個欄位可以是 None

使用 Schema 改寫 API

blog/ninja.py
from django.shortcuts import get_object_or_404
from ninja import Router

from blog.models import Article
from blog.schemas import ArticleOut

router = Router()


@router.get("/articles", response=list[ArticleOut])  # (1)!
def list_articles(request):
    return Article.objects.all()  # (2)!


@router.get("/articles/{article_id}", response=ArticleOut)
def get_article(request, article_id: int):
    return get_object_or_404(Article, id=article_id)  # (3)!
  1. response=list[ArticleOut] 定義回應的資料結構
  2. 直接回傳 QuerySet,Django Ninja 會自動轉換成 JSON
  3. 使用 get_object_or_404 處理資料不存在的情況

使用 Schema 的好處:

  • 自動序列化:直接回傳 Model instance 或 QuerySet,Schema 會自動轉換
  • 自動驗證:輸入資料會根據 Schema 定義自動驗證
  • 自動文件:Schema 會自動出現在 API 文件中

使用 ModelSchema 簡化

每次都要手動定義欄位很麻煩。Django Ninja 提供了 ModelSchema,可以自動根據 Model 產生 Schema:

blog/schemas.py
from ninja import ModelSchema

from blog.models import Article


class ArticleIn(ModelSchema):
    class Meta:
        model = Article
        fields = ["title", "content", "is_published"]


class ArticleOut(ModelSchema):
    class Meta:
        model = Article
        fields = [
            "id",
            "title",
            "content",
            "is_published",
            "created_by",
            "author",
            "created_at",
            "updated_at",
        ]

ModelSchema 會自動:

  • 根據 Model 的欄位定義產生對應的 Schema 欄位
  • 設定適當的類型和驗證規則
  • 處理 null=True 的欄位

fields 的設定

跟 DRF 一樣,你也可以使用 fields = "__all__" 來包含所有欄位,但這不建議在正式環境中使用。

新增文章

接下來實作新增文章的 API:

blog/ninja.py
from django.shortcuts import get_object_or_404
from ninja import Router

from blog.models import Article
from blog.schemas import ArticleIn, ArticleOut

router = Router()


@router.get("/articles", response=list[ArticleOut])
def list_articles(request):
    return Article.objects.all()


@router.get("/articles/{article_id}", response=ArticleOut)
def get_article(request, article_id: int):
    return get_object_or_404(Article, id=article_id)


@router.post("/articles", response={201: ArticleOut})  # (1)!
def create_article(request, payload: ArticleIn):  # (2)!
    article = Article.objects.create(**payload.dict())
    return 201, article
  1. response={201: ArticleOut} 表示成功時回傳 201 狀態碼
  2. payload: ArticleIn 表示請求的 body 會被解析成 ArticleIn Schema

更新文章

blog/ninja.py
from django.shortcuts import get_object_or_404
from ninja import Router

from blog.models import Article
from blog.schemas import ArticleIn, ArticleOut

router = Router()


@router.get("/articles", response=list[ArticleOut])
def list_articles(request):
    return Article.objects.all()


@router.get("/articles/{article_id}", response=ArticleOut)
def get_article(request, article_id: int):
    return get_object_or_404(Article, id=article_id)


@router.post("/articles", response={201: ArticleOut})
def create_article(request, payload: ArticleIn):
    article = Article.objects.create(**payload.dict())
    return 201, article


@router.put("/articles/{article_id}", response=ArticleOut)
def update_article(request, article_id: int, payload: ArticleIn):
    article = get_object_or_404(Article, id=article_id)
    for attr, value in payload.dict().items():
        setattr(article, attr, value)

    article.save()
    return article

部分更新文章

PUT 方法需要提供所有欄位,如果只想更新部分欄位,可以使用 PATCH 方法。Django Ninja 提供了 PatchDict 來簡化這個需求:

blog/ninja.py
from django.shortcuts import get_object_or_404
from ninja import PatchDict, Router

from blog.models import Article
from blog.schemas import ArticleIn, ArticleOut

router = Router()


@router.get("/articles", response=list[ArticleOut])
def list_articles(request):
    return Article.objects.all()


@router.get("/articles/{article_id}", response=ArticleOut)
def get_article(request, article_id: int):
    return get_object_or_404(Article, id=article_id)


@router.post("/articles", response={201: ArticleOut})
def create_article(request, payload: ArticleIn):
    article = Article.objects.create(**payload.dict())
    return 201, article


@router.put("/articles/{article_id}", response=ArticleOut)
def update_article(request, article_id: int, payload: ArticleIn):
    article = get_object_or_404(Article, id=article_id)
    for attr, value in payload.dict().items():
        setattr(article, attr, value)

    article.save()
    return article


@router.patch("/articles/{article_id}", response=ArticleOut)
def partial_update_article(
    request,
    article_id: int,
    payload: PatchDict[ArticleIn],  # (1)!
):  
    article = get_object_or_404(Article, id=article_id)
    for attr, value in payload.items():
        setattr(article, attr, value)

    article.save()
    return article
  1. PatchDict[ArticleIn] 會自動將 ArticleIn 的所有欄位變成選填,並且只包含有提供的欄位

刪除文章

blog/ninja.py
from django.shortcuts import get_object_or_404
from ninja import PatchDict, Router

from blog.models import Article
from blog.schemas import ArticleIn, ArticleOut

router = Router()


@router.get("/articles", response=list[ArticleOut])
def list_articles(request):
    return Article.objects.all()


@router.get("/articles/{article_id}", response=ArticleOut)
def get_article(request, article_id: int):
    return get_object_or_404(Article, id=article_id)


@router.post("/articles", response={201: ArticleOut})
def create_article(request, payload: ArticleIn):
    article = Article.objects.create(**payload.dict())
    return 201, article


@router.put("/articles/{article_id}", response=ArticleOut)
def update_article(request, article_id: int, payload: ArticleIn):
    article = get_object_or_404(Article, id=article_id)
    for attr, value in payload.dict().items():
        setattr(article, attr, value)

    article.save()
    return article


@router.patch("/articles/{article_id}", response=ArticleOut)
def partial_update_article(
    request,
    article_id: int,
    payload: PatchDict[ArticleIn],
):
    article = get_object_or_404(Article, id=article_id)
    for attr, value in payload.items():
        setattr(article, attr, value)

    article.save()
    return article


@router.delete("/articles/{article_id}", response={204: None})
def delete_article(request, article_id: int):
    article = get_object_or_404(Article, id=article_id)
    article.delete()
    return 204, None

API 的驗證與授權

Django Ninja 提供了多種驗證方式,包括 Session 驗證、API Key、Bearer Token 等。

Session 驗證

最簡單的方式是使用 Django 內建的 Session 驗證:

blog/ninja.py
from django.shortcuts import get_object_or_404
from ninja import PatchDict, Router
from ninja.security import django_auth

from blog.models import Article
from blog.schemas import ArticleIn, ArticleOut

router = Router(auth=django_auth)  # (1)!


@router.get("/articles", response=list[ArticleOut])
def list_articles(request):
    return Article.objects.all()


@router.get("/articles/{article_id}", response=ArticleOut)
def get_article(request, article_id: int):
    return get_object_or_404(Article, id=article_id)


@router.post("/articles", response={201: ArticleOut})
def create_article(request, payload: ArticleIn):
    article = Article.objects.create(**payload.dict())
    return 201, article


@router.put("/articles/{article_id}", response=ArticleOut)
def update_article(request, article_id: int, payload: ArticleIn):
    article = get_object_or_404(Article, id=article_id)
    for attr, value in payload.dict().items():
        setattr(article, attr, value)

    article.save()
    return article


@router.patch("/articles/{article_id}", response=ArticleOut)
def partial_update_article(
    request,
    article_id: int,
    payload: PatchDict[ArticleIn],
):
    article = get_object_or_404(Article, id=article_id)
    for attr, value in payload.items():
        setattr(article, attr, value)

    article.save()
    return article


@router.delete("/articles/{article_id}", response={204: None})
def delete_article(request, article_id: int):
    article = get_object_or_404(Article, id=article_id)
    article.delete()
    return 204, None
  1. 在 Router 層級設定驗證,所有端點都會受到保護

現在所有端點都需要登入才能存取。如果想要讓某些端點公開存取,可以在個別端點上設定:

blog/ninja.py
from django.shortcuts import get_object_or_404
from ninja import PatchDict, Router
from ninja.security import django_auth

from blog.models import Article
from blog.schemas import ArticleIn, ArticleOut

router = Router(auth=django_auth)


@router.get("/articles", response=list[ArticleOut], auth=None)  # (1)!
def list_articles(request):
    return Article.objects.all()


@router.get("/articles/{article_id}", response=ArticleOut, auth=None)  # (2)!
def get_article(request, article_id: int):
    return get_object_or_404(Article, id=article_id)


@router.post("/articles", response={201: ArticleOut})
def create_article(request, payload: ArticleIn):
    article = Article.objects.create(**payload.dict())
    return 201, article


@router.put("/articles/{article_id}", response=ArticleOut)
def update_article(request, article_id: int, payload: ArticleIn):
    article = get_object_or_404(Article, id=article_id)
    for attr, value in payload.dict().items():
        setattr(article, attr, value)

    article.save()
    return article


@router.patch("/articles/{article_id}", response=ArticleOut)
def partial_update_article(
    request,
    article_id: int,
    payload: PatchDict[ArticleIn],
):
    article = get_object_or_404(Article, id=article_id)
    for attr, value in payload.items():
        setattr(article, attr, value)

    article.save()
    return article


@router.delete("/articles/{article_id}", response={204: None})
def delete_article(request, article_id: int):
    article = get_object_or_404(Article, id=article_id)
    article.delete()
    return 204, None
  1. auth=None 表示這個端點不需要驗證
  2. auth=None 表示這個端點不需要驗證

Bearer Token 驗證

如果你想要使用 Token 驗證,可以在 core/ninja.py 中自訂一個驗證類別:

core/ninja.py
from ninja import NinjaAPI
from ninja.security import HttpBearer
from rest_framework.authtoken.models import Token

from blog.ninja import router as blog_router


class AuthBearer(HttpBearer):
    def authenticate(self, request, token):
        try:
            token_obj = Token.objects.get(key=token)
        except Token.DoesNotExist:
            return None

        return token_obj.user


api = NinjaAPI(auth=AuthBearer())  # (1)!
api.add_router("/blog", blog_router, tags=["文章"])


@api.get("/hello")
def hello(request):
    return {"message": "Hello, Django Ninja!"}
  1. 在 API 層級設定全域驗證

複用 DRF 的 Token

這裡我們複用了 DRF 的 Token 模型(rest_framework.authtoken.models.Token),這樣就可以用同一個 Token 存取 DRF 和 Django Ninja 的 API。

在實務中通常不會安裝 DRF 可以使用其他第三方套件或自己實作。

接著為了方便測試讓我們把 Router 的驗證拿掉,因為 Router 的(範圍小的)會覆蓋範圍大的 auth 設定。

blog/ninja.py
from django.shortcuts import get_object_or_404
from ninja import PatchDict, Router

from blog.models import Article
from blog.schemas import ArticleIn, ArticleOut

router = Router()


@router.get("/articles", response=list[ArticleOut], auth=None)
def list_articles(request):
    return Article.objects.all()


@router.get("/articles/{article_id}", response=ArticleOut, auth=None)
def get_article(request, article_id: int):
    return get_object_or_404(Article, id=article_id)


@router.post("/articles", response={201: ArticleOut})
def create_article(request, payload: ArticleIn):
    article = Article.objects.create(**payload.dict())
    return 201, article


@router.put("/articles/{article_id}", response=ArticleOut)
def update_article(request, article_id: int, payload: ArticleIn):
    article = get_object_or_404(Article, id=article_id)
    for attr, value in payload.dict().items():
        setattr(article, attr, value)

    article.save()
    return article


@router.patch("/articles/{article_id}", response=ArticleOut)
def partial_update_article(
    request,
    article_id: int,
    payload: PatchDict[ArticleIn],
):
    article = get_object_or_404(Article, id=article_id)
    for attr, value in payload.items():
        setattr(article, attr, value)

    article.save()
    return article


@router.delete("/articles/{article_id}", response={204: None})
def delete_article(request, article_id: int):
    article = get_object_or_404(Article, id=article_id)
    article.delete()
    return 204, None

使用方式跟 DRF 一樣,在請求標頭中加入 Authorization: Bearer <token>

curl -X 'POST' \
  'http://127.0.0.1:8000/api-ninja/blog/articles' \
  -H "Authorization: Bearer 9944b09199c62bcf9418ad846dd0e4bbdfc6ee4b" \
  -H 'accept: application/json' \
  -H 'Content-Type: application/json' \
  -d '{
  "title": "Hello",
  "content": "World",
  "is_published": false
}'

提示

上方的指令「驗證成功」的話會產生錯誤是正常的,因為我們並未給 Article 提供建立者的資訊。只要確認沒有出現下方的為驗證訊息就好了

{"detail": "Unauthorized"}

取得登入使用者

在驗證通過後,你可以透過 request.auth 取得登入的使用者:

blog/ninja.py
from django.shortcuts import get_object_or_404
from ninja import PatchDict, Router

from blog.models import Article
from blog.schemas import ArticleIn, ArticleOut

router = Router()


@router.get("/articles", response=list[ArticleOut], auth=None)
def list_articles(request):
    return Article.objects.all()


@router.get("/articles/{article_id}", response=ArticleOut, auth=None)
def get_article(request, article_id: int):
    return get_object_or_404(Article, id=article_id)


@router.post("/articles", response={201: ArticleOut})
def create_article(request, payload: ArticleIn):
    article = Article.objects.create(
        **payload.dict(),
        created_by=request.auth,  # (1)!
    )
    return 201, article


@router.put("/articles/{article_id}", response=ArticleOut)
def update_article(request, article_id: int, payload: ArticleIn):
    article = get_object_or_404(Article, id=article_id)
    for attr, value in payload.dict().items():
        setattr(article, attr, value)

    article.save()
    return article


@router.patch("/articles/{article_id}", response=ArticleOut)
def partial_update_article(
    request,
    article_id: int,
    payload: PatchDict[ArticleIn],
):
    article = get_object_or_404(Article, id=article_id)
    for attr, value in payload.items():
        setattr(article, attr, value)

    article.save()
    return article


@router.delete("/articles/{article_id}", response={204: None})
def delete_article(request, article_id: int):
    article = get_object_or_404(Article, id=article_id)
    article.delete()
    return 204, None
  1. request.auth 就是驗證類別 authenticate 方法回傳的值

權限驗證

驗證(Authentication)確認使用者身份後,我們還需要授權(Authorization)來檢查使用者是否有權限執行某個操作。

Django Ninja 可以透過 ninja.errors.HttpError 來回傳權限錯誤,並結合 Django 內建的 has_perm 方法檢查使用者權限:

blog/ninja.py
from django.shortcuts import get_object_or_404
from ninja import PatchDict, Router
from ninja.errors import HttpError

from blog.models import Article
from blog.schemas import ArticleIn, ArticleOut

router = Router()


@router.get("/articles", response=list[ArticleOut], auth=None)
def list_articles(request):
    return Article.objects.all()


@router.get("/articles/{article_id}", response=ArticleOut, auth=None)
def get_article(request, article_id: int):
    return get_object_or_404(Article, id=article_id)


@router.post("/articles", response={201: ArticleOut})
def create_article(request, payload: ArticleIn):
    if not request.auth.has_perm("blog.add_article"):  # (1)!
        raise HttpError(403, "你沒有權限新增文章")

    article = Article.objects.create(
        **payload.dict(),
        created_by=request.auth,
    )
    return 201, article


@router.put("/articles/{article_id}", response=ArticleOut)
def update_article(request, article_id: int, payload: ArticleIn):
    if not request.auth.has_perm("blog.change_article"):  # (2)!
        raise HttpError(403, "你沒有權限編輯文章")

    article = get_object_or_404(Article, id=article_id)
    for attr, value in payload.dict().items():
        setattr(article, attr, value)

    article.save()
    return article


@router.patch("/articles/{article_id}", response=ArticleOut)
def partial_update_article(
    request,
    article_id: int,
    payload: PatchDict[ArticleIn],
):
    if not request.auth.has_perm("blog.change_article"):
        raise HttpError(403, "你沒有權限編輯文章")

    article = get_object_or_404(Article, id=article_id)
    for attr, value in payload.items():
        setattr(article, attr, value)

    article.save()
    return article


@router.delete("/articles/{article_id}", response={204: None})
def delete_article(request, article_id: int):
    if not request.auth.has_perm("blog.delete_article"):  # (3)!
        raise HttpError(403, "你沒有權限刪除文章")

    article = get_object_or_404(Article, id=article_id)
    article.delete()
    return 204, None
  1. 檢查使用者是否有 blog.add_article 權限
  2. 檢查使用者是否有 blog.change_article 權限
  3. 檢查使用者是否有 blog.delete_article 權限

當權限驗證失敗時,API 會回傳 403 狀態碼和錯誤訊息:

{"detail": "你沒有權限編輯文章"}

使用內建的 OpenAPI 文件

Django Ninja 最大的優點之一就是內建 OpenAPI 文件,不需要安裝額外套件。

設定文件資訊

你可以在建立 NinjaAPI 時設定文件的基本資訊:

core/ninja.py
from ninja import NinjaAPI
from ninja.security import HttpBearer
from rest_framework.authtoken.models import Token

from blog.ninja import router as blog_router


class AuthBearer(HttpBearer):
    def authenticate(self, request, token):
        try:
            token_obj = Token.objects.get(key=token)
        except Token.DoesNotExist:
            return None

        return token_obj.user


api = NinjaAPI(
    title="Blog API",
    version="1.0.0",
    description="Django 大冒險的部落格 API (Django Ninja 版本)",
    auth=AuthBearer(),
)
api.add_router("/blog", blog_router, tags=["文章"])


@api.get("/hello")
def hello(request):
    return {"message": "Hello, Django Ninja!"}

開啟 http://127.0.0.1:8000/api-ninja/docs 就能看到更新後的文件。

自訂 OpenAPI Schema

如果需要自訂 OpenAPI Schema,可以使用 openapi_extra 參數:

blog/ninja.py
from django.shortcuts import get_object_or_404
from ninja import PatchDict, Router
from ninja.errors import HttpError

from blog.models import Article
from blog.schemas import ArticleIn, ArticleOut

router = Router()


@router.get("/articles", response=list[ArticleOut], auth=None)
def list_articles(request):
    return Article.objects.all()


@router.get(
    "/articles/{article_id}",
    response=ArticleOut,
    auth=None,
    openapi_extra={
        "responses": {
            404: {
                "description": "文章不存在",
            }
        }
    },
)
def get_article(request, article_id: int):
    return get_object_or_404(Article, id=article_id)


@router.post("/articles", response={201: ArticleOut})
def create_article(request, payload: ArticleIn):
    if not request.auth.has_perm("blog.add_article"):
        raise HttpError(403, "你沒有權限新增文章")

    article = Article.objects.create(
        **payload.dict(),
        created_by=request.auth,
    )
    return 201, article


@router.put("/articles/{article_id}", response=ArticleOut)
def update_article(request, article_id: int, payload: ArticleIn):
    if not request.auth.has_perm("blog.change_article"):
        raise HttpError(403, "你沒有權限編輯文章")

    article = get_object_or_404(Article, id=article_id)
    for attr, value in payload.dict().items():
        setattr(article, attr, value)

    article.save()
    return article


@router.patch("/articles/{article_id}", response=ArticleOut)
def partial_update_article(
    request,
    article_id: int,
    payload: PatchDict[ArticleIn],
):
    if not request.auth.has_perm("blog.change_article"):
        raise HttpError(403, "你沒有權限編輯文章")

    article = get_object_or_404(Article, id=article_id)
    for attr, value in payload.items():
        setattr(article, attr, value)

    article.save()
    return article


@router.delete("/articles/{article_id}", response={204: None})
def delete_article(request, article_id: int):
    if not request.auth.has_perm("blog.delete_article"):
        raise HttpError(403, "你沒有權限刪除文章")

    article = get_object_or_404(Article, id=article_id)
    article.delete()
    return 204, None

設定搜尋與過濾

Django Ninja 提供了 FilterSchema 來處理過濾邏輯。

基本過濾

blog/schemas.py
from ninja import FilterSchema, ModelSchema

from blog.models import Article


class ArticleIn(ModelSchema):
    class Meta:
        model = Article
        fields = ["title", "content", "is_published"]


class ArticleOut(ModelSchema):
    class Meta:
        model = Article
        fields = [
            "id",
            "title",
            "content",
            "is_published",
            "created_by",
            "author",
            "created_at",
            "updated_at",
        ]


class ArticleFilterSchema(FilterSchema):
    is_published: bool | None = None
    title__icontains: str | None = None  # (1)!
  1. 使用 Django ORM 的 lookup 語法,__icontains 表示不區分大小寫的包含搜尋

在 API 中使用:

blog/ninja.py
from django.shortcuts import get_object_or_404
from ninja import PatchDict, Query, Router
from ninja.errors import HttpError

from blog.models import Article
from blog.schemas import ArticleFilterSchema, ArticleIn, ArticleOut

router = Router()


@router.get("/articles", response=list[ArticleOut], auth=None)
def list_articles(request, filters: Query[ArticleFilterSchema]):  # (1)!
    return filters.filter(Article.objects.all())  # (2)!


@router.get(
    "/articles/{article_id}",
    response=ArticleOut,
    auth=None,
    openapi_extra={
        "responses": {
            404: {
                "description": "文章不存在",
            }
        }
    },
)
def get_article(request, article_id: int):
    return get_object_or_404(Article, id=article_id)


@router.post("/articles", response={201: ArticleOut})
def create_article(request, payload: ArticleIn):
    if not request.auth.has_perm("blog.add_article"):
        raise HttpError(403, "你沒有權限新增文章")

    article = Article.objects.create(
        **payload.dict(),
        created_by=request.auth,
    )
    return 201, article


@router.put("/articles/{article_id}", response=ArticleOut)
def update_article(request, article_id: int, payload: ArticleIn):
    if not request.auth.has_perm("blog.change_article"):
        raise HttpError(403, "你沒有權限編輯文章")

    article = get_object_or_404(Article, id=article_id)
    for attr, value in payload.dict().items():
        setattr(article, attr, value)

    article.save()
    return article


@router.patch("/articles/{article_id}", response=ArticleOut)
def partial_update_article(
    request,
    article_id: int,
    payload: PatchDict[ArticleIn],
):
    if not request.auth.has_perm("blog.change_article"):
        raise HttpError(403, "你沒有權限編輯文章")

    article = get_object_or_404(Article, id=article_id)
    for attr, value in payload.items():
        setattr(article, attr, value)

    article.save()
    return article


@router.delete("/articles/{article_id}", response={204: None})
def delete_article(request, article_id: int):
    if not request.auth.has_perm("blog.delete_article"):
        raise HttpError(403, "你沒有權限刪除文章")

    article = get_object_or_404(Article, id=article_id)
    article.delete()
    return 204, None
  1. Query[ArticleFilterSchema] 表示這些參數來自查詢字串
  2. FilterSchema 提供 filter() 方法來過濾 QuerySet

只顯示已發布的文章:

curl "http://127.0.0.1:8000/api-ninja/blog/articles?is_published=true"

搜尋標題包含 "django" 的文章:

curl "http://127.0.0.1:8000/api-ninja/blog/articles?title__icontains=django"

組合使用:

curl "http://127.0.0.1:8000/api-ninja/blog/articles?is_published=true&title__icontains=django"

進階搜尋

如果你想要一個搜尋欄位同時搜尋多個欄位,可以使用 Field

blog/schemas.py
from typing import Annotated

from ninja import FilterLookup, FilterSchema, ModelSchema

from blog.models import Article


class ArticleIn(ModelSchema):
    class Meta:
        model = Article
        fields = ["title", "content", "is_published"]


class ArticleOut(ModelSchema):
    class Meta:
        model = Article
        fields = [
            "id",
            "title",
            "content",
            "is_published",
            "created_by",
            "author",
            "created_at",
            "updated_at",
        ]


class ArticleFilterSchema(FilterSchema):
    is_published: bool | None = None
    title__icontains: str | None = None
    search: Annotated[str | None, FilterLookup(["title__icontains", "content__icontains"])] = None  # (1)!
  1. 透過 FilterLookup 指定要搜尋的欄位,會用 OR 條件組合

現在 search 參數會同時搜尋 titlecontent

curl "http://127.0.0.1:8000/api-ninja/blog/articles?search=python"

排序

Django Ninja 沒有內建的排序功能,但你可以輕鬆自己實作:

blog/ninja.py
from typing import Literal

from django.shortcuts import get_object_or_404
from ninja import PatchDict, Query, Router
from ninja.errors import HttpError

from blog.models import Article
from blog.schemas import ArticleFilterSchema, ArticleIn, ArticleOut

router = Router()


@router.get("/articles", response=list[ArticleOut], auth=None)
def list_articles(
    request,
    filters: Query[ArticleFilterSchema],
    ordering: Literal["created_at", "-created_at", "title", "-title"] | None = None,  # (1)!
):
    articles = filters.filter(Article.objects.all())
    if ordering:
        articles = articles.order_by(ordering)

    return articles


@router.get(
    "/articles/{article_id}",
    response=ArticleOut,
    auth=None,
    openapi_extra={
        "responses": {
            404: {
                "description": "文章不存在",
            }
        }
    },
)
def get_article(request, article_id: int):
    return get_object_or_404(Article, id=article_id)


@router.post("/articles", response={201: ArticleOut})
def create_article(request, payload: ArticleIn):
    if not request.auth.has_perm("blog.add_article"):
        raise HttpError(403, "你沒有權限新增文章")

    article = Article.objects.create(
        **payload.dict(),
        created_by=request.auth,
    )
    return 201, article


@router.put("/articles/{article_id}", response=ArticleOut)
def update_article(request, article_id: int, payload: ArticleIn):
    if not request.auth.has_perm("blog.change_article"):
        raise HttpError(403, "你沒有權限編輯文章")

    article = get_object_or_404(Article, id=article_id)
    for attr, value in payload.dict().items():
        setattr(article, attr, value)

    article.save()
    return article


@router.patch("/articles/{article_id}", response=ArticleOut)
def partial_update_article(
    request,
    article_id: int,
    payload: PatchDict[ArticleIn],
):
    if not request.auth.has_perm("blog.change_article"):
        raise HttpError(403, "你沒有權限編輯文章")

    article = get_object_or_404(Article, id=article_id)
    for attr, value in payload.items():
        setattr(article, attr, value)

    article.save()
    return article


@router.delete("/articles/{article_id}", response={204: None})
def delete_article(request, article_id: int):
    if not request.auth.has_perm("blog.delete_article"):
        raise HttpError(403, "你沒有權限刪除文章")

    article = get_object_or_404(Article, id=article_id)
    article.delete()
    return 204, None
  1. 使用 Literal 限制可用的排序選項

任務結束

完成!

恭喜你完成了這個任務!現在你已經學會:

  • 了解 Django Ninja 的特色
  • 使用 Django Ninja 建立第一個 API
  • 理解 Schema 的用途並實作 CRUD 操作
  • 使用 ModelSchema 簡化程式碼
  • 使用 Router 組織 API 結構
  • 設定 API 的驗證與授權機制
  • 使用內建的 OpenAPI 文件
  • 設定搜尋與過濾功能