บทช่วยสอน Schema
บทช่วยสอน Schemaบทเรียนที่ 22: การจัดการข้อผิดพลาดเมื่อเชื่อมต่อกับบริการ

บทเรียนที่ 22: การจัดการข้อผิดพลาดเมื่อเชื่อมต่อกับบริการ

เราอาจพบข้อผิดพลาดประเภทต่างๆ เมื่อดึงข้อมูลจาก API ภายนอก

ตัวอย่างเช่น ลองพิจารณา query ต่อไปนี้:

{
  externalData: _sendJSONObjectItemHTTPRequest(
    input: {
      url: "https://newapi.getpop.org/wp-json/wp/v2/posts/8888/"
    }
  )
    
  postTitle: _objectProperty(
    object: $__externalData,
    by: { path: "title.rendered"}
  )
}

หากการเชื่อมต่ออินเทอร์เน็ตขาดหาย ฟิลด์ _sendJSONObjectItemHTTPRequest จะทริกเกอร์ข้อผิดพลาด:

{
  "errors": [
    {
      "message": "cURL error 6: Could not resolve host: newapi.getpop.org (see https://curl.haxx.se/libcurl/c/libcurl-errors.html) for https://newapi.getpop.org/wp-json/wp/v2/posts/8888/",
      "locations": [
        {
          "line": 2,
          "column": 17
        }
      ],
      "extensions": {
        "path": [
          "externalData: _sendJSONObjectItemHTTPRequest(input: {url: \"https://newapi.getpop.org/wp-json/wp/v2/posts/8888/\"}) @export(as: \"externalData\")",
          "query { ... }"
        ],
        "type": "QueryRoot",
        "field": "externalData: _sendJSONObjectItemHTTPRequest(input: {url: \"https://newapi.getpop.org/wp-json/wp/v2/posts/8888/\"}) @export(as: \"externalData\")",
        "id": "root",
        "code": "PoP/ComponentModel@e1"
      }
    },
    {
      "message": "Argument 'object' in field '_objectProperty' of type 'QueryRoot' cannot be null",
      "locations": [
        {
          "line": 10,
          "column": 13
        }
      ],
      "extensions": {
        "path": [
          "$__externalData",
          "(object: $__externalData)",
          "postTitle: _objectProperty(object: $__externalData, by: {path: \"title.rendered\"})",
          "query { ... }"
        ],
        "type": "QueryRoot",
        "field": "postTitle: _objectProperty(object: $__externalData, by: {path: \"title.rendered\"})",
        "id": "root",
        "code": "gql@5.4.2.1[b]",
        "specifiedBy": "https://spec.graphql.org/draft/#sec-Required-Arguments"
      }
    }
  ],
  "data": {
    "externalData": null,
    "postTitle": null
  }
}

หากเราเชื่อมต่อสำเร็จ แต่รีซอร์สที่ร้องขอไม่มีอยู่ เราจะได้รับ 404:

{
  "errors": [
    {
      "message": "Client error: `GET https://newapi.getpop.org/wp-json/wp/v2/posts/8888/` resulted in a `404 Not Found` response:\n{\"code\":\"rest_post_invalid_id\",\"message\":\"Invalid post ID.\",\"data\":{\"status\":404}}\n",
      "locations": [
        {
          "line": 2,
          "column": 17
        }
      ],
      "extensions": {
        "path": [
          "externalData: _sendJSONObjectItemHTTPRequest(input: {url: \"https://newapi.getpop.org/wp-json/wp/v2/posts/8888/\"}) @export(as: \"externalData\")",
          "query { ... }"
        ],
        "type": "QueryRoot",
        "field": "externalData: _sendJSONObjectItemHTTPRequest(input: {url: \"https://newapi.getpop.org/wp-json/wp/v2/posts/8888/\"}) @export(as: \"externalData\")",
        "id": "root",
        "code": "PoP/ComponentModel@e1"
      }
    },
    {
      "message": "Argument 'object' in field '_objectProperty' of type 'QueryRoot' cannot be null",
      "locations": [
        {
          "line": 10,
          "column": 13
        }
      ],
      "extensions": {
        "path": [
          "$__externalData",
          "(object: $__externalData)",
          "postTitle: _objectProperty(object: $__externalData, by: {path: \"title.rendered\"})",
          "query { ... }"
        ],
        "type": "QueryRoot",
        "field": "postTitle: _objectProperty(object: $__externalData, by: {path: \"title.rendered\"})",
        "id": "root",
        "code": "gql@5.4.2.1[b]",
        "specifiedBy": "https://spec.graphql.org/draft/#sec-Required-Arguments"
      }
    }
  ],
  "data": {
    "externalData": null,
    "postTitle": null
  }
}

ในทั้งสองกรณี มีข้อผิดพลาดเพิ่มเติมในการตอบกลับ:

{
  "message": "Argument 'object' in field '_objectProperty' of type 'QueryRoot' cannot be null" 
}

ข้อผิดพลาดนี้เกิดขึ้นเพราะ หลังจากข้อผิดพลาดแรก ตัวแปรไดนามิก $__externalData จะมีค่าเป็น null ซึ่งทริกเกอร์ข้อผิดพลาดที่สอง สิ่งนี้ไม่เหมาะสม เราควรจะรับรู้ว่ามีข้อผิดพลาดเกิดขึ้น และจากนั้นข้ามการดำเนินการส่วนที่เหลือของ GraphQL query

ในบทเรียนนี้ เราจะมาสำรวจวิธีการทำสิ่งนี้

การจัดการข้อผิดพลาดเมื่อเชื่อมต่อกับ REST API

GraphQL query นี้แบ่งตรรกะออกเป็นสองออเปอเรชัน โดยที่:

  • ออเปอเรชันแรกเอ็กซ์พอร์ตตัวแปรไดนามิก $requestProducedErrors ซึ่งระบุว่าค่าของฟิลด์ _sendJSONObjectItemHTTPRequest เป็น null หรือไม่ (ในกรณีดังกล่าวคือมีข้อผิดพลาดเกิดขึ้น)
  • ออเปอเรชันที่สองจะถูก @skip เมื่อ $requestProducedErrors เป็น true

ด้วยวิธีนี้ ออเปอเรชันที่สองซึ่งมีตรรกะที่จะดำเนินการ จะถูกข้ามเมื่อมีข้อผิดพลาดในการดึงข้อมูลในออเปอเรชันแรก:

query ConnectToRESTEndpoint($postId: ID!) {
  endpoint: _sprintf(
    string: "https://newapi.getpop.org/wp-json/wp/v2/posts/%s/?_fields=id,type,title,date"
    values: [$postId]
  ) @remove
  
  externalData: _sendJSONObjectItemHTTPRequest(
    input: {
      url: $__endpoint
    }
  ) @export(as: "externalData")
 
  requestProducedErrors: _isNull(value: $__externalData)
    @export(as: "requestProducedErrors")
    @remove
}
 
query ExecuteOperation
  @depends(on: "ConnectToRESTEndpoint")
  @skip(if: $requestProducedErrors)
{
  # Do something...
  postTitle: _objectProperty(
    object: $externalData,
    by: { path: "title.rendered"}
  )
}

เมื่อส่ง $postId: 1 query จะสำเร็จ และการตอบกลับคือ:

{
  "data": {
    "externalData": {
      "id": 1,
      "date": "2019-08-02T07:53:57",
      "type": "post",
      "title": {
        "rendered": "Hello world!"
      }
    },
    "postTitle": "Hello world!"
  }
}

เมื่อส่ง $postId: 8888 ซึ่งเกี่ยวข้องกับรีซอร์สที่ไม่มีอยู่ เราจะได้รับการตอบกลับนี้ (สังเกตว่าไม่มี postTitle ในการตอบกลับ และไม่มีข้อความข้อผิดพลาดที่สอง):

{
  "errors": [
    {
      "message": "Client error: `GET https://newapi.getpop.org/wp-json/wp/v2/posts/8888/?_fields=id,type,title,date` resulted in a `404 Not Found` response:\n{\"code\":\"rest_post_invalid_id\",\"message\":\"Invalid post ID.\",\"data\":{\"status\":404}}\n",
      "locations": [
        {
          "line": 6,
          "column": 17
        }
      ],
      "extensions": {
        "path": [
          "externalData: _sendJSONObjectItemHTTPRequest(input: {url: $__endpoint}) @export(as: \"externalData\")",
          "query ConnectToRESTEndpoint($postId: ID!) { ... }"
        ],
        "type": "QueryRoot",
        "field": "externalData: _sendJSONObjectItemHTTPRequest(input: {url: $__endpoint}) @export(as: \"externalData\")",
        "id": "root",
        "code": "PoP/ComponentModel@e1"
      }
    }
  ],
  "data": {
    "externalData": null
  }
}

หากการเชื่อมต่ออินเทอร์เน็ตขาดหาย เราจะได้รับการตอบกลับนี้:

{
  "errors": [
    {
      "message": "cURL error 6: Could not resolve host: newapi.getpop.org (see https://curl.haxx.se/libcurl/c/libcurl-errors.html) for https://newapi.getpop.org/wp-json/wp/v2/posts/8888/?_fields=id,type,title,date",
      "locations": [
        {
          "line": 17,
          "column": 17
        }
      ],
      "extensions": {
        "path": [
          "externalData: _sendHTTPRequest(input: {url: $__endpoint, method: GET}) { ... }",
          "query ConnectToAPI($postId: ID!) @depends(on: \"ExportDefaultDynamicVariables\") { ... }"
        ],
        "type": "QueryRoot",
        "field": "externalData: _sendHTTPRequest(input: {url: $__endpoint, method: GET}) { ... }",
        "id": "root",
        "code": "PoP/ComponentModel@e1"
      }
    }
  ],
  "data": {
    "externalData": null
  }
}

การแสดงข้อความข้อผิดพลาดจากการตอบกลับของ REST API

query ก่อนหน้านี้ใช้ฟิลด์ _sendJSONObjectItemHTTPRequest ซึ่งคาดหวังว่าสเตตัสโค้ดจะเป็น 200 (หรือโค้ดที่สำเร็จอื่นๆ)

อย่างไรก็ตาม เป็นไปได้ที่ REST API จะส่งคืน 404 สำหรับรีซอร์สที่ไม่มีอยู่ และให้ข้อความข้อผิดพลาดเชิงอธิบายในการตอบกลับ JSON

เราสามารถจับฟีดแบ็กนี้จากเว็บเซิร์ฟเวอร์ได้โดยการแทนที่ _sendJSONObjectItemHTTPRequest ด้วย _sendHTTPRequest และแสดงในเอนทรี errors ของการตอบกลับ GraphQL

ตัวอย่างเช่น เมื่อดึงข้อมูลจากรีซอร์สที่ไม่มีอยู่จาก WP REST API จะส่งคืนเอนทรี data.status ในการตอบกลับพร้อมข้อมูลที่เกี่ยวข้อง

GraphQL query นี้จับข้อมูลนี้ และเพิ่มเอนทรีข้อผิดพลาดอย่างชัดเจนพร้อมโค้ดและข้อความข้อผิดพลาดของการตอบกลับ โดยใช้ฟิลด์ _fail (จัดเตรียมโดยเอกซ์เทนชัน Response Error Trigger):

query ExportDefaultDynamicVariables
  @configureWarningsOnExportingDuplicateVariable(enabled: false)
{
  defaultEndpointHasErrors: _echo(value: true)
    @export(as: "endpointHasErrors")
    @remove
}
 
query ConnectToAPI($postId: ID!)
  @depends(on: "ExportDefaultDynamicVariables")
{
  endpoint: _sprintf(
    string: "https://newapi.getpop.org/wp-json/wp/v2/posts/%s/?_fields=id,type,title,date"
    values: [$postId]
  ) @remove
  
  externalData: _sendHTTPRequest(
    input: {
      url: $__endpoint,
      method: GET
    }
  ) {    
    contentType
    statusCode
    body @remove
    bodyJSONObject: _strDecodeJSONObject(string: $__body)
      @export(as: "externalData")
  }
 
  isNullExternalData: _isNull(value: $__externalData)
    @export(as: "isNullExternalData")
    @remove
}
 
query ValidateAPIResponse
  @depends(on: "ConnectToAPI")
  @skip(if: $isNullExternalData)
{
  endpointHasErrors: _propertyIsSetInJSONObject(
    object: $externalData
    by: {
      path: "data.status"
    }
  )
    @export(as: "endpointHasErrors")
    @remove
}
 
query FailIfExternalAPIHasErrors($postId: ID!)
  @depends(on: "ValidateAPIResponse")
  @include(if: $endpointHasErrors)
  @skip(if: $isNullExternalData)
{
  code: _objectProperty(
    object: $externalData,
    by: {
      key: "code"
    }
  ) @remove
  message: _objectProperty(
    object: $externalData,
    by: {
      key: "message"
    }
  ) @remove
  errorMessage: _sprintf(
    string: "[%s] %s",
    values: [$__code, $__message]
  ) @remove
  data: _objectProperty(
    object: $externalData,
    by: {
      key: "data"
    }
  ) @remove
  _fail(
    message: $__errorMessage
    data: {
      postId: $postId,
      endpointData: $__data
    }
  ) @remove
}
 
query ExecuteSomeOperation
  @depends(on: "FailIfExternalAPIHasErrors")
  @skip(if: $endpointHasErrors)
{
  # Do something...
  postTitle: _objectProperty(
    object: $externalData,
    by: { path: "title.rendered"}
  )
}

เอกซ์เทนชัน Response Error Trigger มีสองวิธีในการเพิ่มเอนทรีแบบกำหนดเองภายใต้ errors:

  • ผ่านฟิลด์ _fail
  • ผ่านไดเรกทีฟ @fail

ในขณะที่ฟิลด์ _fail เพิ่มข้อผิดพลาดเสมอ ไดเรกทีฟ @fail จะเพิ่มเฉพาะเมื่อเงื่อนไขภายใต้อาร์กิวเมนต์ condition เป็นจริงเท่านั้น ค่าเริ่มต้นคือ IS_NULL ซึ่งหมายความว่าจะถูกทริกเกอร์เมื่อฟิลด์ที่มันถูกนำไปใช้มีค่าเป็น null:

query GetPost($id: ID!) {
  post(by:{id: $id})
    @fail(
      message: "There is no post with the provided ID"
      data: {
        id: $id
      }
    )
  {
    id
    title
  }
}

เมื่อดำเนินการ query ด้วยตัวแปร $postId: 1 คำขอจะสำเร็จ และเราจะได้รับ:

{
  "data": {
    "externalData": {
      "contentType": "application/json; charset=UTF-8",
      "statusCode": 200,
      "bodyJSONObject": {
        "id": 1,
        "date": "2019-08-02T07:53:57",
        "type": "post",
        "title": {
          "rendered": "Hello world!"
        }
      }
    },
    "postTitle": "Hello world!"
  }
}

เมื่อดำเนินการ query ด้วยตัวแปร $postId: 8888 รีซอร์สไม่มีอยู่ และเราจะได้รับ:

{
  "errors": [
    {
      "message": "[rest_post_invalid_id] Invalid post ID.",
      "locations": [
        {
          "line": 76,
          "column": 3
        }
      ],
      "extensions": {
        "path": [
          "_fail(message: $__errorMessage, data: {postId: $postId, endpointData: $__data}) @remove",
          "query FailIfExternalAPIHasErrors($postId: ID!) @depends(on: \"ValidateAPIResponse\") @include(if: $endpointHasErrors) @skip(if: $isNullExternalData) { ... }"
        ],
        "type": "QueryRoot",
        "field": "_fail(message: $__errorMessage, data: {postId: $postId, endpointData: $__data}) @remove",
        "id": "root",
        "failureData": {
          "postId": 8888,
          "endpointData": {
            "status": 404
          }
        },
        "code": "PoPSchema/FailFieldAndDirective@e1"
      }
    }
  ],
  "data": {
    "externalData": {
      "contentType": "application/json; charset=UTF-8",
      "statusCode": 404,
      "bodyJSONObject": {
        "code": "rest_post_invalid_id",
        "message": "Invalid post ID.",
        "data": {
          "status": 404
        }
      }
    }
  }
}

การจัดการข้อผิดพลาดเมื่อเชื่อมต่อกับ GraphQL API

เมื่อ query รีซอร์สที่ไม่มีอยู่ใน GraphQL API การตอบกลับจะมีสเตตัสโค้ด 200 และค่า null สำหรับรีซอร์สนั้น (ซึ่งแตกต่างจาก REST ที่ส่งคืน 404 แทน)

GraphQL ด้านล่างตรวจสอบว่าไม่มีข้อผิดพลาดเกิดขึ้นเมื่อดำเนินการ _sendGraphQLHTTPRequest โดยการตรวจสอบว่า:

  • การตอบกลับไม่เป็น null (เช่น การเชื่อมต่ออินเทอร์เน็ตไม่ขาดหาย)
  • การตอบกลับไม่มีเอนทรี errors
  • การตอบกลับมีค่าที่ไม่เป็น null ภายใต้เอนทรี data.post (กล่าวคือ รีซอร์สที่ query มีอยู่)
query InitializeDynamicVariables
  @configureWarningsOnExportingDuplicateVariable(enabled: false)
{
  defaultResponseHasErrors: _echo(value: false)
    @export(as: "responseHasErrors")
    @remove
  defaultPostIsMissing: _echo(value: false)
    @export(as: "postIsMissing")
    @remove
}
 
query ConnectToGraphQLAPI($postId: ID!)
  @depends(on: "InitializeDynamicVariables")
{
  externalData: _sendGraphQLHTTPRequest(
    input: {
      endpoint: "https://newapi.getpop.org/api/graphql/",
      query: """
        query GetPostData($postId: ID!) {
          post(by: { id : $postId }) {
            date
            title
          }
        }
      """,
      variables: [
        {
          name: "postId",
          value: $postId
        }
      ]
    }
  ) @export(as: "externalData")
 
  requestProducedErrors: _isNull(value: $__externalData)
    @export(as: "requestProducedErrors")
    @remove
}
 
query ValidateResponse
  @depends(on: "ConnectToGraphQLAPI")
  @skip(if: $requestProducedErrors)
{
  responseHasErrors: _propertyIsSetInJSONObject(
    object: $externalData
    by: {
      key: "errors"
    }
  )
    @export(as: "responseHasErrors")
    @remove
 
  postExists: _propertyIsSetInJSONObject(
    object: $externalData
    by: {
      path: "data.post"
    }
  )
    @remove
    
  postIsMissing: _not(value: $__postExists)
    @export(as: "postIsMissing")
    @remove
}
 
query FailIfResponseHasErrors
  @depends(on: "ValidateResponse")
  @skip(if: $requestProducedErrors)
  @skip(if: $postIsMissing)
  @include(if: $responseHasErrors)
{
  errors: _objectProperty(
    object: $externalData,
    by: {
      key: "errors"
    }
  ) @remove
 
  _fail(
    message: "Executing the GraphQL query produced error(s)"
    data: {
      errors: $__errors
    }
  ) @remove
}
 
query ExecuteOperation
  @depends(on: "FailIfResponseHasErrors")
  @skip(if: $requestProducedErrors)
  @skip(if: $responseHasErrors)
  @skip(if: $postIsMissing)
{
  # Do something...
  postTitle: _objectProperty(
    object: $externalData,
    by: { path: "data.post.title" }
  )
}

เมื่อส่ง $postId: 1 query จะสำเร็จ และการตอบกลับคือ:

{
  "data": {
    "externalData": {
      "data": {
        "post": {
          "date": "2019-08-02T07:53:57+00:00",
          "title": "Hello world!"
        }
      }
    },
    "postTitle": "Hello world!"
  }
}

เมื่อส่ง $postId: 8888 ซึ่งเกี่ยวข้องกับรีซอร์สที่ไม่มีอยู่ เราจะได้รับการตอบกลับนี้ (สังเกตว่าไม่มี postTitle ในการตอบกลับ และไม่มีข้อความข้อผิดพลาดด้วย):

{
  "data": {
    "externalData": {
      "data": {
        "post": null
      }
    }
  }
}

หากการเชื่อมต่ออินเทอร์เน็ตขาดหาย เราจะได้รับการตอบกลับนี้:

{
  "errors": [
    {
      "message": "cURL error 6: Could not resolve host: newapi.getpop.org (see https://curl.haxx.se/libcurl/c/libcurl-errors.html) for https://newapi.getpop.org/api/graphql/",
      "locations": [
        {
          "line": 15,
          "column": 17
        }
      ],
      "extensions": {
        "path": [
          "externalData: _sendGraphQLHTTPRequest(input: {endpoint: \"https://newapi.getpop.org/api/graphql/\", query: \"\n        query GetPostData($postId: ID!) {\n          post(by: { id : $postId }) {\n            date\n            title\n          }\n        }\n      \", variables: [{name: \"postId\", value: $postId}]}) @export(as: \"externalData\")",
          "query ConnectToGraphQLAPI($postId: ID!) @depends(on: \"InitializeDynamicVariables\") { ... }"
        ],
        "type": "QueryRoot",
        "field": "externalData: _sendGraphQLHTTPRequest(input: {endpoint: \"https://newapi.getpop.org/api/graphql/\", query: \"\n        query GetPostData($postId: ID!) {\n          post(by: { id : $postId }) {\n            date\n            title\n          }\n        }\n      \", variables: [{name: \"postId\", value: $postId}]}) @export(as: \"externalData\")",
        "id": "root",
        "code": "PoP/ComponentModel@e1"
      }
    }
  ],
  "data": {
    "externalData": null
  }
}

การสร้างข้อผิดพลาดหากรีซอร์สที่ร้องขอไม่มีอยู่

ใน GraphQL query ด้านบน หากโพสต์ที่ query ไม่มีอยู่ มันจะส่งคืนเพียง null และไม่มีเอนทรีข้อผิดพลาดภายใต้ errors

หากเราต้องการบังคับเพิ่มข้อผิดพลาดในสถานการณ์นั้น เราสามารถเพิ่มออเปอเรชันต่อไปนี้ ซึ่งใช้ฟิลด์ _fail เพื่อทริกเกอร์ข้อผิดพลาด:

query FailIfPostNotExists($postId: ID!)
  @skip(if: $requestProducedErrors)
  @include(if: $postIsMissing)
  @depends(on: "ValidateResponse")
{
  errorMessage: _sprintf(
    string: "There is no post with ID '%s'",
    values: [$postId]
  ) @remove
  _fail(
    message: $__errorMessage
    data: {
      id: $postId
    }
  ) @remove
}
 
query ExecuteOperation
  @depends(on: [
    "FailIfResponseHasErrors",
    "FailIfPostNotExists"
  ])
  # ...
{
  # ...
}

ตอนนี้ เมื่อส่ง $postId: 8888 ซึ่งเกี่ยวข้องกับรีซอร์สที่ไม่มีอยู่ เราจะได้รับการตอบกลับนี้:

{
  "errors": [
    {
      "message": "There is no post with ID '8888'",
      "locations": [
        {
          "line": 96,
          "column": 3
        }
      ],
      "extensions": {
        "path": [
          "_fail(message: $__errorMessage, data: {id: $postId}) @remove",
          "query FailIfPostNotExists($postId: ID!) @skip(if: $requestProducedErrors) @include(if: $postIsMissing) @depends(on: \"ValidateResponse\") { ... }"
        ],
        "type": "QueryRoot",
        "field": "_fail(message: $__errorMessage, data: {id: $postId}) @remove",
        "id": "root",
        "failureData": {
          "id": 8888
        },
        "code": "PoPSchema/FailFieldAndDirective@e1"
      }
    }
  ],
  "data": {
    "externalData": {
      "data": {
        "post": null
      }
    }
  }
}