【Flutter】graphql_flutterでの認証処理について

graphql_flutter【Flutter】graphql_flutterのクライアントにTimeoutを設定するにて記載した通り、 Flutterでgraphqlを扱う際には第一候補として使用を検討するライブラリかと思います。

本稿では、graphql_flutterを使用した認証処理について記載します。

TL;DR

  • AuthLinkを使用する
  • JWTのような有効期限つきの認証トークンを使用する場合は、AuthLinkの初期化時の引数であるgetTokenに更新処理を渡しておく

    開発環境

    下記環境を使用しています。

[] Flutter (Channel stable, 1.22.2, on Mac OS X 10.15.6 19G2021, locale ja-JP)
 
[] Android toolchain - develop for Android devices (Android SDK version 30.0.0)
[] Xcode - develop for iOS and macOS (Xcode 11.5)
[] Android Studio (version 4.0)
[] VS Code (version 1.49.1)

graphql_flutter: "3.1.0"

Linkについて

GraphQLの通信をどのように行うか、operationの結果をどのように使用するかなどを記述する仕組みです。 通信先のURLを指定したり、使用するClientを定義したり、認証処理を定義したりできます。 GraphQLのClientでは下記のようにlinkをつなげて定義できます。

static final Link link = authLink.concat(httpLink).concat(websocketLink);

ここで、通信時の処理は結合したlinkの前の方から行われていきます。 実際のLinkの定義を見ると理解が深まります。 重要な部分のみ抜粋すると下記のようになります。

Link {
  Link concat(Link next) => _concat(this, next);
}

Link _concat(
  Link first,
  Link second,
) {
  return Link(request: (
    Operation operation, [
    NextLink forward,
  ]) {
    return first.request(operation, (Operation op) {
      return second.request(op, forward);
    });
  });
}

concat(link)で行っていることは、1番目のlinkで処理した結果を2番目のlinkに引き渡すという処理をまとめた新たなlinkを作成するということです。 LinkについてはApolloのLinkに関するDocumentが参考になります。

AuthLinkについて

AuthLinkは上記のLinkのうち認証の仕組みを導入するために使用されるLinkです。 下記のように定義して使用します。

  static final AuthLink authLink = AuthLink(getToken: () => _token);

実際のコードは下記のようになっています。 onListen内の処理が肝心な部分となっていて、Streamが流れてきた都度 getTokenを実行し、 tokenがnullでない場合にHeaderにデフォルトではAuthorizationキーに対して認証情報を付与しています。 ここで重要なのはcontrollerのonListenのタイミングで都度getTokenが呼び出されると言う点です。 そのためgetTokenに関数を使用し、都度その時の最新のaccessTokenを取得するようにできます。

class AuthLink extends Link {
  AuthLink({@required this.getToken, this.headerKey = 'Authorization'})
      : super(
          request: (Operation operation, [NextLink forward]) {
            StreamController<FetchResult> controller;

            Future<void> onListen() async {
              try {
                final String token = await getToken();
                if (token != null) {
                  operation.setContext(<String, Map<String, String>>{
                    'headers': <String, String>{headerKey: token}
                  });
                }
              } catch (error) {
                controller.addError(error);
              }

              await controller.addStream(forward(operation));
              await controller.close();
            }

            controller = StreamController<FetchResult>(onListen: onListen);

            return controller.stream;
          },
        );

  GetToken getToken;
  String headerKey;
}

AuthLink内にJWTの更新処理を実装

上記までの情報を前提にJWTのような有効期限を有する認証トークンを使用した認証処理を考えてみます。 今回は、RefreshTokenとAccessTokenを使用し、RefreshTokenによるアクセストークンの更新の都度、RefreshTokenも合わせて更新されるようなパターンを考えます。 RefreshToken、AccessTokenの更新をどこでやるかですが、これらに対する関心はAPI Clientに閉じると考えられますので、API Clientクラス内に記述します。

流れのみを記述すると下記のようになります。

final httpLink = HttpLink(uri: '$apiUrl');  //apiのurlを入れる
final authLink = AuthLink(getToken: () async => await _getAccessToken());
final link = authLink.concat(httpLink);
final _graphQLClient = GraphQLClient(link: link, cache: InMemoryCache());

String _accessToken; // ひとまずインスタンスに持たせていますが、SecureStorageに持たせるのもよいと思います。

Future<String> _getAccessToken() async {
  final accessToken = _accessToken;
  if (accessToken == null) {
    return null;
  }
  
  // アクセストークンの有効期限を確認します。
  // APIからExpireDateが返却されていた場合はそちらの値を、
  // そうでない場合はAccessTokenから有効期限を取得します。
  // jaguar_jwtなど使用すると楽ができると思います。
  final accessTokenExpireDate = await _getAccessTokenExpireDate();
  final currentDateTime = DateTime.now();
      
  if (accessTokenExpireDate.isBefore(currentDateTime)) {
    // refreshTokenを用いてaccessTokenとrefreshTokenの更新を実施します。
    // refreshTokenはsecureStorageに格納しておいて、取得すると良いかと思います。
    final refreshToken = await _getRefreshToken();
    if (refreshToken == null) return null;
    
    final newAccessToken = await authorize(refreshToken: refreshToken);
    return 'Bearer $newAccessToken';
  } else {
    return 'Bearer $accessToken';
  }
}

上記において、authorize(refreshToken: refreshToken)ではAPIに用意されたrefreshTokenを用いたaccessTokenの更新処理を行います。 返却値に応じて下記が必要になるかと思います。

  • 返却値のaccessTokenの保持処理
  • 返却値のrefreshTokenの保存処理

まとめ

graphql_flutterでの認証処理に関して記載しました。 基本的にはAuthLinkを使用することで問題なく実装を進めることができるかと思います。

参考

【Flutter】graphql_flutterのクライアントにTimeoutを設定する

graphql_flutterはgrahQLの有名なライブラリーであるApolloにインスパイアされたflutterのgraphql clientライブラリです。 FlutterでgraphQLを使用する場合はgraphql_flutterを使用するかgraphqlを使用することになるかと思います。

ネットワーク通信を行う場合は、通信環境の悪い場合を考慮してTimeout設定を行います。 しかしながらgraphql_flutterにはデフォルトのタイムアウト設定用のAPIが存在しません。 本稿ではgraphql_flutterにおけるTimeout設定に関して記載します。

TL;DR

  • Timeout用のAPIが存在しないのでFutureのタイムアウトを使用する
  • Timeout設定が一箇所に集約されるようにGraphQLClient用のクラスを作成する

開発環境

下記環境を使用しています。

[] Flutter (Channel stable, 1.22.2, on Mac OS X 10.15.6 19G2021, locale ja-JP)
 
[] Android toolchain - develop for Android devices (Android SDK version 30.0.0)
[] Xcode - develop for iOS and macOS (Xcode 11.5)
[] Android Studio (version 4.0)
[] VS Code (version 1.49.1)

graphql_flutter: "3.1.0"

Timeoutの実装

graqhql_flutterにはtimeout用のapiがありません。 しかしながら、graphql_flutterのquerymutateメソッドの返却値はFutureであり、dartのFutureにtimeout設定を行うためのAPIがあります。 そのため、Futureのtimeoutを設定することでgraphql_flutterの各メソッドにtimeoutを設定することができます。

// Futureのtimeoutメソッド
Future<T> timeout(Duration timeLimit, {FutureOr<T> onTimeout()?});

// 実装サンプル
future // Future<T>
  .timeout(const Duration(milliseconds: 3000), onTimeout: () {
      throw TimeoutException('通信に失敗しました。');
  });

timeoutはtimeoutまでの時間とtimeout時の処理を引数としてとります。 上記の実装により、3秒でtimeoutし、timeout時は通信失敗のメッセージを含んだTimeoutExceptionを発生させるようにしています。

上記により、timeout設定を行うことができました。 しかし、このままではクライアントのメソッド呼び出しのたびにtimeout設定を行う必要があるため冗長であり、また記述漏れや記述ミスによる不具合につながります。 以下で、そのような問題に対処する方法を記載します。

実装の改善

GraphQLClientをそのまま使用すると各メソッド処理でtimeoutの記述をする必要があるため、GraphQLClientをラップしたクラスをアプリ用に作成します。 先にコードの全体像を記載します。

import 'dart:async';

import 'package:graphql_flutter/graphql_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sample/core/configs/config.dart';

// hooksを使用してGraphQLClient用のproviderを作成しています。
// graphql_flutterのREADME通りvalue notifierを使用して、GraphQLProviderを使用した場合は別途検討が必要かと思います。
// getitなどを使用してdiしている場合は同様の方法で対応できるかと思われます。
final gqlClientProvider = Provider<AppGraphQLClient>((_) => AppGraphQLClient());

class AppGraphQLClient {
  AppGraphQLClient() {
    _gqlClient = GraphQLClient(link: _baseLink, cache: InMemoryCache());
  }

  GraphQLClient _gqlClient;
  final _baseLink = HttpLink(uri: Config.apiBaseUrl());

  // access tokenが一定時間で変わる場合にaccess tokenを入れ替える用のメソッドです。
  // 方法はまだ検討中です。
  Future<void> setAuth({String token}) {
    final authLink = AuthLink(getToken: () async => 'Bearer $token');
    _gqlClient =
        GraphQLClient(link: authLink.concat(_baseLink), cache: InMemoryCache());
  }

  // 本稿のメインです。
  // GraphQLClientのメソッドに直接アクセスするのではなく、
  // 本クラスのメソッド経由でアクセスすることでtimeoutやエラーハンドリングなどの処理を共通化しています。
  Future<QueryResult> query(QueryOptions queryOptions) async {
    final result = await _gqlClient
        .query(queryOptions)
        .timeout(const Duration(milliseconds: 3000), onTimeout: () {
      throw TimeoutException('通信に失敗しました。');
    });

    if (result.hasException) {
      // TODO: Error Handling
    }

    return result;
  }

  // 同上です。
  Future<QueryResult> mutate(MutationOptions mutationOptions) async {
    final result = await _gqlClient
        .mutate(mutationOptions)
        .timeout(const Duration(milliseconds: 3000), onTimeout: () {
      throw TimeoutException('通信に失敗しました。');
    });

    if (result.hasException) {
      // TODO: error handling
    }

    return result;
  }
}

GraphQLClientをラップしたクラスを作成します。 queryやmutateのメソッドを作成し、各メソッド処理からGraphQLClientのquery, mutateにアクセスします。 その際にtimeoutを設定することで、timeout時のエラーハンドリング を共通化できます。

また、graphQLはRESTとは異なりServer側での意図したエラー(401や403など)は200で返却した上で errors に含まれます。 その処理も可能な限り共通化した方が良いかと思いますので、同様に各メソッドの処理に含めています。 TODOの記載に済ませていますが、具体的にはerrorsのstatus codeからアプリで定義した各エラーにキャストする、 extensionsに含まれた内容からアプリで定義したAPI Server用の型にパースした上でThrowするなどがあげられるかと思います。

まとめ

本稿ではgraphql_flutterでのtimeout設定方法に関して記載しました。 また、timeout処理の共通化と合わせてエラーハンドリング部分の共通化に関しても言及しました。 私はgraphQL, Flutterともに学習期間がまだまだ短く、知らないことも多いためもっと良い方法をご存知の方がいましたらコメントいただけると嬉しいです。 本稿の情報がどなたかのお役に立てたら幸いです。

参考

【Flutter】AWS SNSからFlutterで作成したAndroidアプリにPush通知を送った際に「Unable to handle incoming background message.」が発生した際の解決方法

FlutterでPush通知を実装する場合、firebase_messagingライブラリを使用するかと思います。 私も使用していたのですが、AWS SNSと組み合わせたところ、バックグラウンドでPush通知を受け取ることができませんでした。

AndroidのPush通知に不慣れなこともあり、解決に時間を要したため備忘もかねて記録します。

発生した事象

バックグラウンドの状態でPush通知を送ると、下記のエラーが発生しPush通知を正常に受け取ることができませんでした。 なお、Firebase Cloud Messagingから送信した場合は問題なくPush通知を受け取れています。

I/flutter ( 7845): Unable to handle incoming background message.
I/flutter ( 7845): NoSuchMethodError: The method 'call' was called on null.
I/flutter ( 7845): Receiver: null
I/flutter ( 7845): Tried calling: call(_LinkedHashMap len:1)

本エラーメッセージで検索をかけたところ、Firebase Messagingのライブラリ内でエラーが起きていることがわかりました。 github.com

なおAWS SNSからは下記のペイロードで送っています。 こちらは、 AWS SNSからFirebase Cloud Messaging (FCM)のApplication Platformを作成して、GUI上からメッセージを送ろうとし、 「配信プロトコルごとにカスタムペイロード。」を選択した際にデフォルトで表示される内容です。

f:id:Iganin:20200909143318p:plain

解決方法

送信するペイロードを下記のような形式に変更することで解消しました。

{ 
"GCM": "{ \"notification\": { \"body\": \"body\", \"title\": \"title \" } , \"data\" : {\"key1\" : \"value1\", \"key2\" : \"value2\" } }""
}

dataのpayloadが無視されてしまっていることが原因のようです。 notificationのpayloadに変更し、カスタムのdataはnotification payload内に含ませることでバックグランド状態でもPush通知を受け取れるようになります。

まとめ

AWS SNSで自動生成されたpayloadではうまく動作しないというところがはまりポイントかなと思います。 どなたかのお役に立ちましたら幸いです。

参考

【Server】APIにおけるHTTPメソッドの分類と意味

APIにおいて、URIとともにGETやDELETEといったHTTPメソッドを使用します。 GETやDELETEはそれぞれ意味がすぐにわかりますが、POST、PUTやPATCHおよびそれらの差異となるとたまに思い出すために時間がかかるため、備忘を兼ねメモします。

分類

  • GET
    • リソースの取得
  • POST
    • リソースの新規生成
  • PUT
    • 作成済みリソースの置き換え
  • PATCH
    • 作成済みリソースの修正
  • DELETE
    • 作成済みリソースの削除
  • HEAD

各論

以下で https://sample.com/api/v1/items/ リクエストを例に各論を見ていきます。

GET

リソースを取得します。 https://sample.com/api/v1/items/${item_id}の形でidを用いて一意に限定して取得するか、https://sample.com/api/v1/itemsでリストを取得することが多いかと思います。 GETはあくまでリソースの取得を行い、リソース自体への修正や削除は実施しません。(ただし、既読などの情報取得に応じた状態の変更はこの限りではなさそうです)

POST

リソースの新規生成を行います。 https://sample.com/api/v1/itemsの形式でリクエストを行い、対応するリソースの生成を行います。 RFC7231に記述されているように、リソース生成によって作成されたidを返却する場合が多いように思います。 つまり、 https://sample.com/api/v1/itemsによってitemが生成され 12345というidが採番された場合は 12345を返却します。

POST 要請が成功裡に処理された結果,生成元サーバ上にて一つ以上のリソースが作成された場合、生成元サーバは,次を包含する 201 (Created) 応答を送信するべきである:[ 作成された主たるリソース用の識別子 ]を供する Location ヘッダ,新たなリソース(たち)を指しつつ, 要請の状態°も述べるような,表現。

PUT

指定したURIにおける情報を更新します。 この際に、部分的に更新するのではなく、新しいリクエストに含まれる値で置換します。すなわち、下記リソースに対し、 https://sample.com/api/v1/items/12345 のリクエストで name = "sample" category = "sample"のようにリクエストし、12345で表されるリソースを上書きし200(OK)か204(No Content)を返却します。 また、該当URIのリソースが存在しない場合は新規リソースを生成し、201(Created)を返却します。

{
  "id" : "12345",
  "name": "hoge",
  "category": "fuga"
}

PATCH

指定したURIにおける情報の部分更新を行います。 先ほどのPUTではURIに存在するリソースを新しいリソースで置換していましたが、本メソッドではリソースの一部分を上書きします。 例えば、PUTでのリソースの場合に https://sample.com/api/v1/items/12345 name = "sample"のようにリクエストし、下記のように該当リソースを修正します。

{
  "id" : "12345",
  "name" : "sample",
  "category" : "fuga"
}

DELETE

指定したURIのリソースを削除します。 レスポンスのステータスコードは下記のように定義されています。(RFC7231より)

動作は成功する見込みが高いが、まだ実行済みでない場合 : 202 (Accepted)
動作は実行済みで、更なる情報は給されない場合 : 204 (No Content)
動作は実行済みで、応答メッセージが[ その状態°を述べる表現 ]を内包する場合 : 200 (OK)

HEAD

GETリクエストとほぼ同じですが、ヘッダーのみ返却されます。 また、ヘッダーの内容のうち、ペイロードヘッダーは省略されえます。

まとめ

HTTPメソッドに関してそれぞれの役割について簡単にまとめました。 POSTは実際はもっと多様に扱われますが、一つのリソースに対する扱い方という観点から整理しています。 調べる中でRFCが参考になりましたので、一度ご確認いただけますと幸いです。

参考

2020年5-7月振り返り

5-7月の振り返り

5-7月の振り返りです。直近私生活で色々あり、ブログの更新が滞っしまいました。。。 5-7月は新しい会社に移って以降、下記のようなことをしていました。

  • iOSアプリ開発
  • DB論理設計
  • API Server実装
  • Pythonの実装修正
  • サービスの全体設計
  • 全体のスケジュール作成・チケット作成
  • セキュリティ

経験が薄いことや初めてのチャレンジが多く、とても大変でしたが自身の知見やスキルの向上が実感できた有意義な3ヶ月だったと感じています。

話は変わりまして、この振り返りですが目標の数を削減し、「読書」とブログ投稿のみ残そうかと思います。 日々の中でやるべきこと、やりたいことが増え、目標の達成に避ける時間が減ってきたことが主な理由です。 以下、目標の対象を限定した上での振り返りです。

プロダクト作成の基礎力向上

読書

「達人に学ぶDB設計 徹底指南書」を読み終えました。RDBに関するバッドプラクティスや論理的な方針、 実際に運用を前提とした上でのグレーノウハウの紹介など実務に応用できる内容が多かった印象です。 正規化周りの話は情報処理試験で知ってはいましたが、詳しい解説の中で微妙に理解が曖昧だったところもあり、 勉強になりました。

「Web API The good parts」を読みはじめました。Web APIを作成する上での実践的なプラクティスを学べたらと思います。

アウトプット

ブログ投稿

  • 年次目標 50(月次目標 約4)
  • 実績:月次(3ヶ月合計) 4記事 / 累計 14記事

本記事と合わせて4記事でした。 3ヶ月の合計なので1記事/月ですね。。。 もう少し書けるよう時間の使い方を考えます。

その他

活動

  • Coursera の Machine-Learningコースを完了しました。予想通り業務の合間に行うのは大変でしたが、なんとか終わることができほっとしています。
  • Flutterでのアプリ開発を本格的にはじめました。

読書

読書開始

読書中

読書完了

読書中止

【GitLab】GitLab内のProjectを複製する方法

はじめに

GitLab内で既存Projectを複製し新規Projectを作成する方法を記載します。

環境設定

以下の環境を使用しています。

  • GitLab

内容

複製元のProjectの操作

  • export projectを押下する f:id:Iganin:20200726145713p:plain

  • Project export started. ~~ のバナーが表示される f:id:Iganin:20200726145726p:plain

  • exportに成功するとdownload exportが選択できるようになる f:id:Iganin:20200726145814p:plain

複製先のProjectの操作

  • new projectを押下 f:id:Iganin:20200726150040p:plain

  • create project画面でimport projectを選択 f:id:Iganin:20200726150059p:plain

  • 作成するproject名称入力

  • exportした gzファイルを GitLab project exportの「ファイルを選択」から選択
  • import projectを押下 f:id:Iganin:20200726150212p:plain

  • projectのimport成功のバナー表示 f:id:Iganin:20200726150235p:plain

まとめ

projectのimport・exportはなかなか行う機会がないので、一度流れをおっておくといざ対応するタイミングで混乱しない気がします。

【iOS】PlaygroundでSwiftUIのViewを描画する

はじめに

SwiftUIで簡単なView構成を試したいときにわざわざProjectを作るのもめんどくさいなという時がありました。 Playgroundを使用してViewの画面を作成し、表示や動作を確認する方法がわかりましたのでメモがてら記載します。

環境設定

以下の環境を使用しています。

内容

Playground Supportをimportし PlaygroundPageを使用することでPlayground上で画面を描画することができます。 PlaygroundPage.current.setLiveView(ContentView())としてるのが設定箇所です。 UIHostingViewControllerを使用して、PlaygroundPage.current.liveView = UIHostingController(rootView: ContentView())とすることでも設定可能です。

コード例を下記に記載します。

import Combine
import SwiftUI
import PlaygroundSupport

struct ContentView: View {
    var body: some View {
        NavigationView {
            List {
                Text("Sample1")
                Text("Sample2")
                Text("Sample3")
            }
        }.navigationBarTitle("Sample Page")
    }
}

// ここで画面描画のための設定を行なっている
PlaygroundPage.current.setLiveView(ContentView())

画面表示は下記となります。コードを実行することで動作確認等も行うことができます。 f:id:Iganin:20200518065527p:plain

まとめ

Playgroundは主に簡単なロジックの挙動確認やSwiftの仕様確認に使用していましたが、簡単な画面の作成に使用するのも良さそうです。

参考