iT邦幫忙

2023 iThome 鐵人賽

DAY 20
0

前面自定義的文章發布功能,仔細看回傳的型態,除了Post以外,還有OperationInfo,這是因為在input_mutation上面設定handle_django_errors=True ,它會將 Django 的ValidationErrorPermissionDeniedObjectDoesNotExist這幾種例外錯誤以OperationInfo型態回傳。

https://ithelp.ithome.com.tw/upload/images/20231005/20161957GyxLzLwsJM.png

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我們註冊的輸入欄位有usernamepassword,要加其他的使用者欄位也可以。

然後到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)

https://ithelp.ithome.com.tw/upload/images/20231005/20161957dCU2K2Fuwx.png

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的部分:

  1. 先拿到使用者模型本身。
  2. data拿到username
  3. 使用username去資料庫查詢對應使用者,得到使用者 ORM 物件,DjangoCreateMutation會處理找不到使用者時的例外錯誤。
  4. 建立驗證表單物件,傳入輸入資料跟使用者 ORM 物件。
  5. 如果驗證失敗就處理驗證錯誤資訊,並回傳OperationInfo
  6. 最後透過驗證表單物件儲存修改資料後的使用者,回傳使用者 ORM 物件。

https://ithelp.ithome.com.tw/upload/images/20231005/20161957BD0NKdPs78.png

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)

		# ... 省略

這邊使用純函式的寫法,其實大致跟上面的編輯使用者功能差不多,差在回傳型態,以及錯誤處理有針對不同類型錯誤進行處理。

https://ithelp.ithome.com.tw/upload/images/20231005/20161957o20GzCch4O.png

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:

參考資料


上一篇
Day 19:Strawberry Django 新增、修改、刪除的變更
下一篇
Day 21:Strawberry Django 檔案上傳
系列文
Django 與 Strawberry GraphQL:探索現代 API 開發之路30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言