🪢

Flutter から Hasura + Firebaseで作ったGraphQL APIを活用する

2021/12/05に公開

こんにちは @glassmonekeyです。

この記事はFlutter アドベントカレンダー5 日目の記事です。

普段は趣味でFlutterでのアプリ開発に勤しんでいます。

今回はその際の個人開発のバックエンドに Hasura を活用してとても便利だったのでその紹介の記事となります。

この記事ではデータの取得および型生成の方法に関して解説しますが、ウィジェットへの組み込み方法や状態管理の方法に関しては触れません。

Hasura 及び認証で利用した Firebase Authentication についてはHasura + Firebaseで実践GraphQL入門をご参照ください。

なぜFlutterにGraphQLか?

昨今の宣言的 UI を用いた開発では GraphQL を用いた開発に注目を集めています。
特に React ではApolloの代等でエコシステムが大分整ってきた印象です。

宣言的 UI を用いた開発では複雑な UX を担保するために API から取得するデータ構造や状態管理など考慮することが多いと思われます。
GraphQL の一番のメリットは取得するデータ構造をクライアント側が最終的決定するので、
特にオーバーフェッチング(過剰なデータ取得)やアンダーフェッチング(データ不足による複数の API リクエスト)を回避できます。

GraphQL のクエリから型生成ができるので、型安全にデータのやりとりが可能です。
また、取得するオブジェクトに対して ID を定義ことでキャッシュする方法が定義されています。
とはいえ Flutter のクライアントライブラリは Apollo ほど成熟してはいないので、キャッシュ機構がなかったりするので注意が必要です。

今回はArtemisを用いました。
執筆時のバージョンは 7.2.3-beta を用いています。`
クエリからの型生成された構成が Artemis が個人的に一番使いやすかった点があげられます。
キャッシュ機構が存在しないので、必要な場合はferryを使うなどを検討が必要です。

Flutter における GraphQL の詳細に関しては先日開催されたFlutter Kaigi2021@iganin_devさんの FlutterでGraphQLを扱う という発表に詳しく紹介されていたので必見です。
この場を借りてお礼を申し上げます。

https://www.youtube.com/watch?v=1xWjeGloMCw

FlutterでGraphQL(Hasura)を使うための準備

Flutter で GraphQL(Hasura)を利用する準備を進めます。
事前にHasura + Firebaseで実践GraphQL入門を完了してる前提となります。

スキーマーをダウンロードする。

最初に Hasura 側からスキーマをダウンロードします。
GraphQL を用いたデータのやりとりでは、サーバー側が公開しているスキーマ情報をもとにクライアント側がクエリ情報を作成します。
筆者は hasura 製のhasura/graphqurlを使用しました。
HASURA_GRAPHQL_ENDPOINT は GraphQL サーバーのエンドポイント、HASURA_GRAPHQL_ADMIN_SECRET は Hasura の管理トークンを指定します。
Hasura 以外にも利用できるものではあるので、各自 GraphQL サーバーの認証方法などで読み変えてください。

今回は lib/schema.graphql にファイルを置くようにしました。

$ npx gq $(HASURA_GRAPHQL_ENDPOINT)/v1/graphql -H "X-Hasura-Admin-Secret: $(HASURA_GRAPHQL_ADMIN_SECRET)" --introspect > lib/schema.graphql

型生成の設定

build_runnerを用いて型生成するので build.yml に設定を記載します。
Hasura との連携時にハマった点として一部の型が dart と対応してないのでマッピングを用意してあげる必要があります。
なお今回は queries_glob: lib/**.gql と記述しているように型生成用のクエリは gql 拡張子で置く前提となります。
個人的には利用箇所の近いところにクエリファイルを置くことで、管理やロジックとの対応をつけやすくメンテしやすくなるのでおすすめです。
生成ファイルに関しては、スキーマファイル(lib/schema.graphql)が対象にならないように気をつけておく必要があります。

targets:
  $default:
    sources:
      - lib/**
      - lib/$lib$
      - $package$
      - graqhql/schema.graphql
      - test/**.dart
    builders:
      artemis:
        options:
          custom_parser_import: 'package:パッケージ名/path/to/dir/convert.dart'
          schema_mapping:
            - schema: lib/schema.graphql
              queries_glob: lib/**.gql
              output: lib/graphql/gen.dart
          scalar_mapping:
            - graphql_type: bigint
              dart_type: int
            - graphql_type: timestamptz
              dart_type: DateTime
              use_custom_parser: true

拡張された型に関しては、二種類の方法があります。

型エイリアスが必要な場合

bigint の場合は実際のところは int として解釈させれば良いので、プリミティブな型と対応させる場合はその型を記述するのみで良いです。
graphql_type が Hasura 側の型、dart_type に Flutter 側で解釈させたい型を記述します。

 scalar_mapping:
   - graphql_type: bigint
     dart_type: int

カスタム型スキームが必要な場合

型エイリアスとしてだけでなく、マッピングの際にロジックを挟み込みたいものもあります。
例えば UTC タイムスタンプ型(timestamptz)はローカル時刻の Datetime のほうが扱いやすいはずです。
その場合は custom_parser_import でパーサーを記述し、use_custom_parser を true にします。
custom_parser_import に指定するパス名は lib 以下 と対応します。以下の例だと、lib/graphql/convert.dart を指定したことになります。

 custom_parser_import: 'package:パッケージ名/graphql/convert.dart'
 scalar_mapping:
   - graphql_type: timestamptz
     dart_type: DateTime
     use_custom_parser: true

convert.dart の内容は以下になります。
関数名が自動的に型マッピングと対応してくれるようです。
命名規則自体は fromGraphQL{$Hasra 側の型名}ToDart{$Dart 側の型名} になります。

// 2021-10-24T05:30:11.549928+00:00 のようなUTCでくるので変換する
DateTime fromGraphQLtimestamptzToDartDateTime(String date) =>
    DateTime.parse(date).toLocal();

筆者はユースケースが思いつきませんでしたが、Dart の型を Hasura 上に変換する際はソースコードを見る限り逆の命名規則である fromDart{$dart側の型名}ToGraphQL{$Hasura側の型名} にすれば良さそうです。

 jsonKeyAnnotation['fromJson'] =
            'fromGraphQL${graphqlTypeSafeStr.namePrintable}ToDart${dartTypeSafeStr.namePrintable}';
 jsonKeyAnnotation['toJson'] =
            'fromDart${dartTypeSafeStr.namePrintable}ToGraphQL${graphqlTypeSafeStr.namePrintable}';

https://github.com/comigor/artemis/blob/e1a213400a84dc44eb830bd90f26efe12bb2874c/lib/visitor/generator_visitor.dart#L192-L195

query情報の設定

最終的には利用するクエリ情報の .gql などの拡張子のファイルを元に型生成します。
例で述べている Item型 や items スキーマに関してはクエリは適当なサンプルなので説明は割愛します。

クエリを用意する。

スキーマダウンロードするとクエリを用意します。
AndroidStudio の場合だとJS GraphQLを入れておくと型補完が効くのではかどります。

query fetch_items {
    items  {
       id
       name
       created_at
       updated_at
    }
}

型定義からのコード使用例。クエリ名から XXXXQuery 型が生成されています。

  Future<List<Item>> fetchItems() async {
    final response = await gqlClient.execute(FetchItemsQuery());
    if (response.hasErrors) {
      return [];
    }
    return response.data?.items
        .map<Item>((response) => Item.fromResponse(response))
        .toList() ?? [];
  }

もちろん ID 指定なども可能で、以下のように where で絞り込むようなクエリを用意します。

query fetch_item($id: bigint!) {
    items(where: {id: {_eq: $id}}) {
        id
        name
        created_at
        updated_at
    }
}

型定義からのコード使用例その2。クエリ名から XXXXQuery 型に加えてパラメータを管理する XXXArguments 型が生成されます。

    Future<Item?> fetchItem(int id) async {
    final response = await gqlClient.execute(
        FetchTimesDetailQuery(variables: FetchItemArguments(id: id)));
    if (response.hasErrors) {
      return null
    }
    final items = response.data?.items
        .map<Times>((response) => Item.fromResponse(response))
        .toList();
    return items?.first;
  }

型生成に関しては Mutation と Subscription も同様に扱えます。

FlutterのHasuraの認証設定

Hasura + Firebaseで実践GraphQL入門を完了してる前提となります。

基本的には FirebaseAuthentication から JWT トークンをもらってそれをリクエスト時のヘッダーに乗せることができたら OK です。
GraphQL のデータ取得はLink構造を重ねることで実現します。

通常のクエリやサブスクリプション併用するときは終端リンクが HttpLink と WebSocketLink の2種類使うことになるので、 分割しないといけない点で注意が必要です。
その場合は以下のようなクライアント定義にしておくと良いでしょう。

認証ヘッダーの設定方法は Query や Mutation で利用する HttpLink の場合は AuthLink を噛ませてあげるのが楽です。

サブスクリプションで利用する WebSocketLink の場合は SocketClientConfig の initPayload から Authorization ヘッダーで設定可能です。

ArtemisClient.fromLink(
     Link.from([
      DedupeLink(), //重複リクエスト防止
      LoggerLink(),
    ]).split(
      (request) => request.isSubscription, //終端が複数になるのでsplitでわける
      WebSocketLink(
          config: SocketClientConfig(
            autoReconnect: true,
            initialPayload: () async {
              final token = await _getToken();
              return {'headers': {
                  'Authorization': token
              }};
      })),
      AuthLink(getToken: _getToken).concat(HttpLink()),
    )

Firebase からトークンを取得に関しては、いたってシンプルです。

  Future<String?> _getToken() async {
    final token = FirebaseAuth.instance.currentUser?.getIdToken();
    return 'Bearer $token';
  }

また、筆者は以下のような Logger 用 Link を定義して一律ログに落としています。

class LoggerLink extends Link {
  Logger logger;
  LoggerLink({required this.logger});

  
  Stream<Response> request(Request request, [NextLink? forward]) async* {
    yield* forward!(request).map((event) {
      logger.d(event);
      if (event.errors != null) {
        logger.e(event.errors);
      }
      if (event.data != null) {
        logger.i(event.data);
      }
      return event;
    });
  }
}

以上で設定などが終わりです。これでデータが取得できるようになってるはずです。

アプリ側の状態管理について

StreamBuilder などで直接使うなども検討できますが、
Hasura を扱うならビジネスロジックはアプリ側で組み込むほうが扱いやすいと思うので、一層state_notifierなどを使うなどすると良いと個人的には考えています。

Hasura 側でcustom functionを使う方法もありますが、
バックエンドを薄くしているメリットを捨てにいってるようなものなので要検討です。

おわりに

Flutter で HasuraFirebase Authentication を使った GraphQL API との連携の解説をしました。

GraphQL のエコシステムは React に比べるとまだまだ未成熟な印象を受けますが、
型生成やキャッシュなどある程度できることは増えている印象です。(今回触った Artemis はキャッシュがない点は困ったちゃんではありますが。)

宣言的 UI と GraphQL は間違いなく相性がいいのでこれを機会に Hasura と合わせて遊んでみてはどうでしょうか?

とはいえ GraphQL は銀の弾丸というわけではなく、認証周りや N+1 など考えることも多いので適材適所という感じではあります。

そういった問題の大部分がケアされている Hasura はよくできている製品だと思いました。

今回は認証周りを Firebase にしましたが、データベースが Postgress であるSupabaseHasura の組み合わせをいつかチャレンジしてみたいお気持ちです。

FlutterGraphQL のことをたまにつぶやいてたりするので、もしよかったら@glassmonekeyをフォローしていただけると喜びます。

Discussion