前面自定義的文章發布功能,仔細看回傳的型態,除了Post
以外,還有OperationInfo
,這是因為在input_mutation
上面設定handle_django_errors=True
,它會將 Django 的ValidationError
、PermissionDenied
、ObjectDoesNotExist
這幾種例外錯誤以OperationInfo
型態回傳。
mutation MyMutation($postId: UUID!) {
publishPost(input: {id: $postId}) {
... on Post {
id
title
slug
publishedAt
published
}
... on OperationInfo {
__typename
messages {
code
field
kind
message
}
}
}
}
上面在回傳格式的地方,就可以使用 內嵌片段(Inline Fragments) 來取得回傳資料或是錯誤資訊。
在使用 Strawberry 的時候會發現,在定義輸入型態的時候只能定義欄位的型態,無法對欄位設定驗證,像是字串的長度、文字的格式匹配、數字範圍等 GraphQL 本身無法處理,這個時候就會想到 Django 內建不就有驗證資料的功能Form
,那我們就可以在resolver
的地方接收輸入資料,傳入自定義的Form
來進行資料的驗證。
下面我們透過 Django 建立的使用者資料編輯的Form
來做資料驗證,並且使用OperationInfo
回傳驗證錯誤資訊。
首先我們先在authentication
的應用程式設定相關程式:
# server/app/authentication/graph/types.py
# ... 省略
@strawberry_django.type(USER_MODEL)
class User:
id: strawberry.ID # noqa: A003
username: str
first_name: str
last_name: str
email: str
+ is_superuser: bool = strawberry.field(default=False)
+ is_staff: bool = strawberry.field(default=False)
+ is_active: bool = strawberry.field(default=True)
這邊幫User
幾個欄位,新增使用者查詢,一開始建立新檔案:
$ touch server/app/authentication/graph/queries.py
接著在queries.py
增加使用者查詢功能:
import strawberry
import strawberry_django
from server.app.authentication.graph import types as auth_types
__all__ = ("Query",)
@strawberry.type
class Query:
users: list[auth_types.User] = strawberry_django.field()
再接著新增使用者的變更功能,一樣先新增檔案:
$ touch server/app/authentication/graph/mutations.py
然後我們要先做使用者註冊的功能,所以先新增註冊的輸入型態:
# server/app/authentication/graph/types.py
# ... 省略
+@strawberry_django.input(USER_MODEL)
+class UserRegisterInput:
+ username: str
+ password: str
編輯mutations.py
:
import strawberry
import strawberry_django
import strawberry_django.auth
from server.app.authentication.graph import types as auth_types
__all__ = ("Mutation",)
@strawberry.type
class Mutation:
user_register: auth_types.User = strawberry_django.auth.register(
auth_types.UserRegisterInput,
handle_django_errors=True,
)
strawberry_django.auth
有內建一些查詢與變更功能,有登入、登出、註冊及查詢當前使用者等功能,這邊我們使用使用者註冊功能,register
我們註冊的輸入欄位有username
跟password
,要加其他的使用者欄位也可以。
然後到server/schema.py
設定使用者相關 GraphQL 功能:
# server/schema.py
# ... 省略
+from server.app.authentication.graph import mutations as auth_mutations
+from server.app.authentication.graph import queries as auth_queries
from server.app.blog.graph import mutations as blog_mutations
from server.app.blog.graph import queries as blog_queries
__all__ = ("schema",)
query = strawberry.tools.merge_types(
"Query",
(
blog_queries.Query,
+ auth_queries.Query,
),
)
mutation = strawberry.tools.merge_types(
"Mutation",
(
blog_mutations.Mutation,
+ auth_mutations.Mutation,
),
)
schema = strawberry.Schema(query=query, mutation=mutation)
mutation MyMutation($userRegister: UserRegisterInput!) {
userRegister(data: $userRegister) {
... on User {
id
email
firstName
username
}
... on OperationInfo {
__typename
messages {
code
field
kind
message
}
}
}
}
再接下來是新增使用者資訊編輯的功能,也是一樣先新增 Python 檔案放跟 Django Form 有關的程式:
$ touch server/app/authentication/forms.py
在forms.py
中新增編輯使用者的 Form :
from django import forms
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import UsernameField
__all__ = ("UserEditForm",)
UserModel = get_user_model()
class UserEditForm(forms.ModelForm):
class Meta:
model = UserModel
fields = (
"username",
"first_name",
"last_name",
"email",
"is_superuser",
"is_staff",
"is_active",
)
field_classes = {"username": UsernameField}
後面參考strawberry_django.auth.register
的原始碼 [1] 跟handle_django_errors
的原始碼 [2],來實作編輯使用者資訊的功能與 Form 的驗證失敗處理。
下面編輯server/app/authentication/graph/mutations.py
:
+import typing
import strawberry
import strawberry_django
import strawberry_django.auth
+import strawberry_django.mutations
+from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
+from strawberry.types import Info
+from strawberry.utils.str_converters import to_camel_case
+from strawberry_django.fields.types import OperationInfo, OperationMessage
+from strawberry_django.mutations.fields import DjangoCreateMutation
+from strawberry_django.optimizer import DjangoOptimizerExtension
+from server.app.authentication.forms import UserEditForm
+from server.app.authentication.graph import types as auth_types
__all__ = ("Mutation",)
+if typing.TYPE_CHECKING:
+ from django.contrib.auth.base_user import AbstractBaseUser
+class UserEditMutation(DjangoCreateMutation):
+ def _handle_errors(
+ self,
+ errors: dict[str, list[ValidationError]],
+ ) -> typing.Iterator[OperationMessage]:
+ kind = OperationMessage.Kind.VALIDATION
+ for field, field_errors in errors.items():
+ for err in field_errors:
+ yield OperationMessage(
+ kind=kind,
+ field=to_camel_case(field) if field != NON_FIELD_ERRORS else None,
+ message=err.message % err.params if err.params else err.message,
+ code=getattr(err, "code", None),
+ )
+ def create(
+ self,
+ data: dict[str, typing.Any],
+ *,
+ info: Info,
+ ) -> typing.Union["AbstractBaseUser", "OperationInfo"]:
+ model = typing.cast(type["AbstractBaseUser"], self.django_model)
+ username = data.get("username")
+ obj = model.objects.get(username=username)
+ form = UserEditForm(data, instance=obj)
+ if not form.is_valid():
+ return OperationInfo(
+ messages=list(self._handle_errors(form.errors.as_data())),
+ )
+ with DjangoOptimizerExtension.disabled():
+ return form.save()
+user_edit_mutation = (
+ strawberry_django.mutations.create if typing.TYPE_CHECKING else UserEditMutation
+)
@strawberry.type
class Mutation:
user_register: auth_types.User = strawberry_django.auth.register(
auth_types.UserRegisterInput,
handle_django_errors=True,
)
+ user_edit: auth_types.User = user_edit_mutation(
+ auth_types.UserEditInput,
+ handle_django_errors=True,
+ )
主要邏輯在UserEditMutation.create
的部分:
data
拿到username
。username
去資料庫查詢對應使用者,得到使用者 ORM 物件,DjangoCreateMutation
會處理找不到使用者時的例外錯誤。OperationInfo
。mutation MyMutation($userData: UserEditInput!) {
userEdit(data: $userData) {
... on User {
id
email
firstName
username
lastName
}
... on OperationInfo {
__typename
messages {
code
field
kind
message
}
}
}
}
到這邊就完成一個使用 Django Form 驗證資料的功能。
在 Strawberry 官方文件上有個頁面提供處理錯誤的建議 [3],頁面的最下方有更多處理錯誤的作法的外部連結。
也許在前面的測試 GraphQL 的時候會發現,GraphQL 發生例外錯誤的時候,HTTP 狀態依然回覆 200,並且回傳的資料格式類似下面這樣:
{
"data": null,
"errors": [
{
"message": "Post matching query does not exist.",
"locations": [
{
"line": 2,
"column": 3
}
],
"path": [
"updatePost"
]
}
]
}
基本上這樣的錯誤訊息是面向開發者閱讀的資訊,並非是前端加工能夠給使用者的訊息。
在上面我們使用OperationInfo
這種通用的錯誤訊息格式,它提供了錯誤資訊代碼、欄位、種類及錯誤訊息,已經提供客戶端一定程度的可以顯示出來給使用者知道的錯誤資訊,但是這種方式可以看到所有的錯誤資訊都集中在一起,在一些簡單的應用程式上可能沒什麼太大的問題,如果客戶端需要在不同的錯誤顯示不ㄧ樣的錯誤資訊也許就比較困難了。
假設客戶端想要不同類型的錯誤,顯示不同的格式,那應該怎麼做?
下面我們重新寫一個新增文章的變更功能,首先新增放表單功能的檔案:
$ touch server/app/blog/forms.py
然後編輯forms.py
,增加文章的驗證表單:
from django import forms
from django.contrib.auth import get_user_model
from server.app.blog import models as blog_models
USER_MODEL = get_user_model()
class PostForm(forms.ModelForm):
tags = forms.ModelMultipleChoiceField(
queryset=blog_models.Tag.objects.all(),
to_field_name="name",
)
categories = forms.ModelMultipleChoiceField(
queryset=blog_models.Category.objects.all(),
to_field_name="slug",
)
author = forms.ModelChoiceField(
queryset=USER_MODEL.objects.all(),
to_field_name="username",
)
class Meta:
model = blog_models.Post
fields = (
"title",
"content",
"published",
"published_at",
"slug",
)
文章的驗證表單,在這邊將有關聯的欄位都使用唯一鍵當作資料庫查詢條件。
接著編輯types.py
:
# server/app/blog/graph/types.py
# ... 省略
@strawberry_django.input(blog_models.Post)
class PostInput:
slug: str
title: str
content: str
- author: strawberry.auto
- tags: strawberry.auto
- categories: strawberry.auto
+ author: str = strawberry.field(description="Username of the author")
+ tags: list[str] = strawberry.field(description="List of tag names")
+ categories: list[str] = strawberry.field(description="List of category slugs")
# ... 省略
+@strawberry.interface
+class FormError:
+ field: str
+ message: str
+@strawberry.type
+class ValidationError(FormError):
+ pass
+@strawberry.type
+class InvalidChoiceError(FormError):
+ value: str
+@strawberry.type
+class DuplicateError(FormError):
+ pass
+@strawberry.type
+class CreatePostResult:
+ post: Post | None = strawberry.UNSET
+ errors: list[
+ typing.Annotated[
+ ValidationError | (InvalidChoiceError | DuplicateError),
+ strawberry.union("FormValidationError"),
+ ]
+ ] | None = strawberry.field(default=None)
這邊透過interface
定義表單錯誤的共通欄位,接著依照不同的錯誤類型定義型態與它需要顯示的欄位,最後定義一個建立文章結果的型態,這個型態如果成功新增文章就會包含文章的物件,否則列出所有錯誤。
再接下來,重新實作一個新增文章的功能:
# server/app/blog/graph/mutations.py
# ... 省略
import strawberry
import strawberry_django
+from django.core.exceptions import ValidationError
from django.utils import timezone
+from strawberry.utils.str_converters import to_camel_case
from strawberry_django import mutations
+from server.app.blog import forms as blog_forms
from server.app.blog import models as blog_models
from server.app.blog.graph import types as blog_types
# ... 省略
+def _handle_form_errors(
+ errors: dict[str, list[ValidationError]],
+) -> typing.Iterator[
+ blog_types.ValidationError
+ | blog_types.InvalidChoiceError
+ | blog_types.DuplicateError
+]:
+ for field, field_errors in errors.items():
+ for err in field_errors:
+ code = getattr(err, "code", "invalid")
+ if code == "unique":
+ yield blog_types.DuplicateError(
+ field=to_camel_case(field),
+ message=err.message % err.params if err.params else err.message,
+ )
+ elif code == "invalid_choice":
+ yield blog_types.InvalidChoiceError(
+ field=to_camel_case(field),
+ message=err.message % err.params if err.params else err.message,
+ value=err.params["value"],
+ )
+ else:
+ yield blog_types.ValidationError(
+ field=to_camel_case(field),
+ message=err.message % err.params if err.params else err.message,
+ )
# ... 省略
@strawberry.type
class Mutation:
- create_post: blog_types.Post = mutations.create(blog_types.PostInput)
# ... 省略
+ @strawberry_django.mutation
+ def create_post(self, data: blog_types.PostInput) -> blog_types.CreatePostResult:
+ form = blog_forms.PostForm(vars(data))
+ if not form.is_valid():
+ return blog_types.CreatePostResult(
+ post=None,
+ errors=list(_handle_form_errors(form.errors.as_data())),
+ )
+ post = form.save()
+ return blog_types.CreatePostResult(post=post)
# ... 省略
這邊使用純函式的寫法,其實大致跟上面的編輯使用者功能差不多,差在回傳型態,以及錯誤處理有針對不同類型錯誤進行處理。
mutation MyMutation($postData: PostInput!) {
createPost(data: $postData) {
post {
content
id
title
tags {
name
}
categories {
name
}
author {
username
}
}
errors {
... on ValidationError {
__typename
field
message
}
... on InvalidChoiceError {
__typename
field
message
value
}
... on DuplicateError {
__typename
field
message
}
}
}
}
這次修改內容可以參考 Git commit: