LoginSignup
3
0
お題は不問!Qiita Engineer Festa 2024で記事投稿!
Qiita Engineer Festa20242024年7月17日まで開催中!

GraphQLサーバとRESTサーバをさっと立ちあげて、実際に触ってみる。つづき。戻り電文の型を変更する

Last updated at Posted at 2024-06-24

こんにちは。@masatomix です。

最近、現場でGraphQLとRESTサーバを構築する機会があったので、そのとき得られたナレッジを備忘としてまとめているのですが、その続きです。

さてさて前回のコードを地道にリファクタリングやいじってナレッジを記録していきます。

今回やること

  • スキーマファイルを外出しファイルにします。
  • Unionを使うことで、実行結果によって戻り電文の型を変更してみます。

やってみる

スキーマ情報を外だしします

コード上のGraphQLのスキーマ情報ですが、

src/schema.ts
export const typeDefs = gql`
  type Query {
    users: [User]
    companies: [Company]
    findUserById(id: ID!): User
    findCompanyById(id: ID!): Company
  }
  type Mutation {
    createUser(user: UserInput): User
    updateUser(user: UserInput): SuccessResponse
  }

  type SuccessResponse {
    success: Boolean!
    message: String
    error: String
  }

  type User{
    id: ID!
    firstName: String
    lastName: String
    email: String
    age: Int
    companyCode:String
    company: Company
  }

  type Company{
    code: ID!
    name: String
  }
  
  input UserInput {
    userId: ID!
    firstName: String
    lastName: String
    email: String
    age: Int
    companyCode:String
  }
`;

ってコード上にベタがきしていましたが、
下記の通り別ファイルにしておくと見通しが良さそうです。

src/schema.ts
import { readFileSync } from 'fs';
import { join } from 'path';

export const typeDefs = gql(readFileSync(join(__dirname, 'schema.gql'), 'utf8'));
src/schema.gql (こちらに外だし)
type Query {
  users: [User]
  companies: [Company]
  user(id: ID!): User
  company(id: ID!): Company
}

type Mutation {
  createUser(user: UserInput): User
  updateUser(user: UserInput): Response
}

type SuccessResponse {
  success: Boolean!
}

type FailResponse {
  success: Boolean!
  message: String
  error: String
}

union Response = SuccessResponse | FailResponse

type User {
  id: ID!
  firstName: String
  lastName: String
  email: String
  age: Int
  companyCode: String
  company: Company
}

type Company {
  code: ID!
  name: String
}

input UserInput {
  userId: ID!
  firstName: String
  lastName: String
  email: String
  age: Int
  companyCode: String
}

unionを使って、実行結果によって戻り電文の型を変更する

たとえば処理が成功したときはsuccessプロパティだけだけど、失敗したときはエラーメッセージなど追加情報プロパティを付与したいなどがあります。

具体的には、先日のコードでは、

  SuccessResponse: {
    success: (parent: Response) => {
      return parent.result ?
        parent.result.status >= 200 && parent.result.status < 300 :
        false
    },
    message: (parent: Response) => {
      return parent.result ?
        parent.result.status :
        parent.error.message
    },
    error: (parent: Response) => {
      return parent.result ?
        parent.result.status :
        JSON.stringify(parent.error)
    },
  },

と戻りの型は固定的でしたが、失敗したときだけ message やerror をつけたいな、みたいなケースです。上記のようにスキーマは固定的のままで不要なプロパティは値をnullにする、でも良いのですが、戻り電文の型の仕様自体を変更したい場合は、 GraphQLのunionという機構を使います。

unionを使ったスキーマ定義はこんな感じ。

type SuccessResponse {
  success: Boolean!
}

type FailResponse {
  success: Boolean!
  message: String
  error: String
}

union Response = SuccessResponse | FailResponse

成功(SuccessResponse)と失敗(FailResponse)それぞれ別のプロパティの型を定義し、Responseはそれらのunionと定義しました。状況に応じてどっちか、みたいな意味です。

さてそれぞれのリゾルバです。

  SuccessResponse: {
    success: checkSuccess,
  },

  FailResponse: {
    success: checkSuccess,
    message: (parent: RestResponse) => {
      return parent.result ?
        parent.result.status :
        parent.error.message
    },
    error: (parent: RestResponse) => {
      return parent.result ?
        parent.result.status :
        JSON.stringify(parent.error)
    },
  },

ここまではいいですよね。成功の時は success 、失敗の時はその他のプロパティも返しています。で、Responseのリゾルバは、

Response: {// GraphQLでのunion型は、どちらの型を返すかのロジックを定義しないといけない
  __resolveType(obj: { result: any; }, context: any, info: any) {
    return obj.result ? 'SuccessResponse' : 'FailResponse'
  }
},

としました。unionであるResponseについては、どちらの型を返すかの判定ロジックを記述しています。

コード全体をのせると以下の通りです。

import { gql } from 'apollo-server-express';
import {
  AppUserEntityControllerApi,
  AppUserRequestBody,
  CompanyEntityControllerApi,
  EntityModelAppUser,
  EntityModelCompany
} from './generated'
import axios, { AxiosResponse } from 'axios';
import DataLoader from 'dataloader';

import { readFileSync } from 'fs';
import { join } from 'path';

export const typeDefs = gql(readFileSync(join(__dirname, 'schema.gql'), 'utf8'));

type UserInput = {
  userId: string
  firstName?: string
  lastName?: string
  email?: string
  age?: number
  companyCode?: string
}

type RestResponse = {
  result?: AxiosResponse<EntityModelAppUser, any>;
  error?: any;
}

const checkSuccess = (parent: RestResponse): boolean => {
  return parent.result ?
    parent.result.status >= 200 && parent.result.status < 300 :
    false
}

const dataLoader = new DataLoader<string, EntityModelCompany>(async (ids): Promise<EntityModelCompany[]> => {
  console.log('batch start')
  console.log(ids)

  const idsStr = ids.join(',')
  const data = (await axios.get('http://localhost:8080/companies/search/findAllByIdIn',
    {
      params: { ids: idsStr }
    })).data;
  const companies: EntityModelCompany[] = data._embedded?.company
  // 戻り値のデータは、引数のデータの順番通りとはかぎらない

  // 引数のidsの順番にあうように、結果を並べ替える処理
  // companyCodeとCompanyのMap
  type CompanyMap = { [key: string]: EntityModelCompany }
  const companyMap: CompanyMap = companies.reduce((map, company) => {
    map[company.companyCode!] = company
    return map
  }, {} as CompanyMap)

  return ids.map(id => companyMap[id])
})

export const resolvers = {
  Query: {
    users: async () => {
      const api = new AppUserEntityControllerApi()
      const data = (await api.getCollectionResourceAppuserGet1()).data
      console.table(data._embedded?.user)
      return data._embedded?.user
    },

    companies: async () => {
      const api = new CompanyEntityControllerApi()
      const data = (await api.getCollectionResourceCompanyGet1()).data
      console.table(data._embedded?.company)
      return data._embedded?.company
    },

    user: async (parent: {}, args: { id: string }) => {
      const p = new AppUserEntityControllerApi().getItemResourceAppuserGet(args.id)
      const data = (await p).data
      console.log(data)
      return data
    },

    company: async (parent: {}, args: { id: string }) => {
      const p = new CompanyEntityControllerApi().getItemResourceCompanyGet(args.id)
      const data = (await p).data
      console.log(data)
      return data
    }

  },

  User: {
    id: (parent: EntityModelAppUser) => `${parent.userId}`,
    // company: (parent: EntityModelAppUser) => parent._links!.company.href
    company: async (parent: EntityModelAppUser) => {
      // DataLoaderを使う方法。ここではこのコードの登録(?)のみが行われ、
      // 実際は全部のcompanyがそろった時点で、DataLoader.BatchLoadFn 関数がコールされる
      const data = (await dataLoader.load(parent.companyCode!))

      // この方式だと、PK指定でN+1問題が発生しちゃう
      // const api = new CompanyEntityControllerApi()
      // const data = (await api.getItemResourceCompanyGet(parent.companyCode!)).data

      console.log(data)
      return data
    }
  },

  Company: {
    code: (parent: EntityModelCompany) => `${parent.companyCode}`,
    name: (parent: EntityModelCompany) => `${parent.companyName}`,
  },

  Response: {// GraphQLでのunion型は、どちらの型を返すかのロジックを定義しないといけない
    __resolveType(obj: { result: any; }, context: any, info: any) {
      return obj.result ? 'SuccessResponse' : 'FailResponse'
    }
  },

  SuccessResponse: {
    success: checkSuccess,
  },

  FailResponse: {
    success: checkSuccess,
    message: (parent: RestResponse) => {
      return parent.result ?
        parent.result.status :
        parent.error.message
    },
    error: (parent: RestResponse) => {
      return parent.result ?
        parent.result.status :
        JSON.stringify(parent.error)
    },
  },

  Mutation: {
    createUser: async (parent: {}, args: { user: UserInput }) => {
      const api = new AppUserEntityControllerApi()
      return (await api.postCollectionResourceAppuserPost(args.user)).data
    },

    updateUser: async (parent: {}, args: { user: UserInput }): Promise<RestResponse> => {
      try {
        const userPromise = new AppUserEntityControllerApi()
          .getItemResourceAppuserGet(args.user.userId)
        const instance = (await userPromise).data
        console.log(instance)

        const userInput = args.user
        console.log(userInput)

        const body: AppUserRequestBody = {
          userId: userInput.userId,
          firstName: userInput.firstName ?? instance.firstName,
          lastName: userInput.lastName ?? instance.lastName,
          age: userInput.age ?? instance.age,
          companyCode: userInput.companyCode ?? instance.companyCode,
          email: userInput.email ?? instance.email,
        }

        try {
          const updatePromise = new AppUserEntityControllerApi()
            .postCollectionResourceAppuserPost(body)
          const result = await updatePromise
          return { result }
        } catch (error) {
          console.log(error)
          return { error } // 例外のときもがんばって正常終了させる
        }
      } catch (error) {
        console.log(error)
        return { error } // 例外のときもがんばって正常終了させる
      }
    }
  }
}

DataLoader とか見 慣れないコードはまた次回。

とりあえず投げてみましょう。

union.jpg

投げるときはこのように、

mutation Mutation($input1: UserInput) {
  updateUser(user: $input1) {
    ... on SuccessResponse {
      success
    }
    ... on FailResponse {
      error
      message
      success
    }
  }
}

成功した場合と失敗した場合を並べて記述することができます。戻り値は、

成功した場合

{
  "data": {
    "updateUser": {
      "success": true
    }
  }
}

失敗した場合

{
  "data": {
    "updateUser": {
      "error": "{\"message\":\"Request failed with status code 409\",\"name\":\"AxiosError\",\"stack\":\"AxiosError: d...割愛...{"kino1@example.com\\\"}\",\"url\":\"http://localhost:8080/users\"},\"code\":\"ERR_BAD_REQUEST\",\"status\":409}",
      "message": "Request failed with status code 409",
      "success": false
    }
  }
}

というように、プロパティのあるなしに違いがあるのがわかりますね!

ちなみにcurlでも投げておきましょう。

$ cat data.txt 
{
    "query":
    "
mutation Mutation($input1: UserInput) {
  updateUser(user: $input1) {
    ... on SuccessResponse {
      success
    }
    ... on FailResponse {
      error
      message
      success
    }
  }
}
",
    "variables":{
  "input1": {
    "companyCode": "com003",
    "userId": "u001"
  }
}
$ cat data.txt | curl --data @-  \
  --request POST \
  --header 'content-type: application/json' \
  --url http://localhost:3000/graphql | jq

  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   329  100    41  100   288   1491  10474 --:--:-- --:--:-- --:--:-- 12185
{
  "data": {
    "updateUser": {
      "success": true
    }
  }
}
$ 

できました。

まとめ

実際は、成功・失敗をこのように使い分けるか、それともシンプルに失敗は例外をスローしちゃうかはプロジェクトによるかとおもいます1。バックエンドのAPI(用のプロキシライブラリ)が例外をスローしちゃってるのに、わざわざtry/catchして正常終了に詰め替えているのも微妙かもしれません。

ひきつづき、こんどはバックエンドが例外ならGraphQLサーバも例外を投げてしまうケースや、上記で説明をペンディングしたDataLoaderなども記事にしていきます!

おつかれさまでした。

関連リンク

  1. 成功失敗をunion型で判定する際、さらに失敗を種類によって型を分けて、何エラーが発生したかを呼び元でわかるようにするとか、?あんまないかなこのパタンは。

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0