スキーマチュートリアル
スキーマチュートリアルレッスン22:サービスへの接続時にエラーを処理する

レッスン22:サービスへの接続時にエラーを処理する

外部APIからデータを取得する際、さまざまな種類のエラーが発生することがあります。

たとえば、次のクエリを考えてみましょう:

{
  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 になり、2番目のエラーをトリガーするからです。これは理想的ではありません。エラーが発生したことを認識した上で、残りのGraphQLクエリの実行をスキップすることが望ましいでしょう。

このチュートリアルレッスンでは、それを実現する方法を探ります。

REST APIへの接続時にエラーを処理する

このGraphQLクエリはロジックを2つのオペレーションに分割します:

  • 最初のオペレーションは動的変数 $requestProducedErrors をエクスポートし、フィールド _sendJSONObjectItemHTTPRequest の値が null かどうか(その場合、何らかのエラーが発生した)を示します
  • 2番目のオペレーションは $requestProducedErrorstrue の場合に @skip されます

このようにして、実行するロジックを含む2番目のオペレーションは、最初のオペレーションでデータの取得中にエラーがあった場合にスキップされます:

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 を渡すと、クエリは成功し、レスポンスは次のようになります:

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

存在しないリソースに対して $postId: 8888 を渡すと、次のレスポンスが返されます(レスポンスに postTitle がなく、2番目のエラーメッセージもないことに注目してください):

{
  "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レスポンスからエラーメッセージを表示する

前のクエリはフィールド _sendJSONObjectItemHTTPRequest を使用しており、ステータスコードが 200(またはその他の成功コード)であることを期待しています。

ただし、REST APIが存在しないリソースに対して 404 を返し、JSONレスポンスに説明的なエラーメッセージを提供することがあります。

_sendJSONObjectItemHTTPRequest_sendHTTPRequest に置き換えることで、ウェブサーバーからのこのフィードバックをキャプチャし、GraphQLレスポンスの errors エントリに表示できます。

たとえば、WP REST APIの存在しないリソースからデータを取得すると、レスポンスに data.status エントリと関連データが返されます。

このGraphQLクエリはこのデータをキャプチャし、Response Error Trigger エクステンションが提供するフィールド _fail を使用して、レスポンスのエラーコードとメッセージを含むエラーエントリを明示的に追加します:

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 の下にカスタムエントリを追加する2つの方法を提供します:

  • フィールド _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
  }
}

変数 $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!"
  }
}

変数 $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への接続時にエラーを処理する

GraphQL APIで存在しないリソースをクエリすると、レスポンスのステータスコードは 200 となり、そのリソースの値は null になります(404 を返すRESTとは異なります)。

以下のGraphQLクエリは、_sendGraphQLHTTPRequest の実行時にエラーが発生しなかったことを次の点を確認することで検証します:

  • レスポンスが null でない(例:インターネット接続が切断されていない)
  • レスポンスに errors エントリが含まれていない
  • レスポンスの data.post エントリの下に null でない値が含まれている(つまり、クエリしたリソースが存在する)
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 を渡すと、クエリは成功し、レスポンスは次のようになります:

{
  "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クエリでは、クエリした投稿が存在しない場合、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
      }
    }
  }
}