カスタム リゾルバを使用して Data Connect を拡張する

カスタム リゾルバを作成することで、Cloud SQL に加えて他のデータソースをサポートするように Firebase Data Connect を拡張できます。複数のデータソース(Cloud SQL とカスタム リゾルバによって提供されるデータソース)を 1 つのクエリまたはミューテーションに結合できます。

「データソース」の概念は柔軟です。It includes:

  • Cloud SQL 以外のデータベース(Cloud Firestore、MongoDB など)。
  • Cloud Storage、AWS S3 などのストレージ サービス。
  • Stripe、SendGrid、Salesforce などの API ベースの統合。
  • カスタム ビジネス ロジック。

追加のデータソースをサポートするカスタム リゾルバを作成すると、Data Connect クエリとミューテーションでさまざまな方法でそれらを組み合わせることができ、次のようなメリットが得られます。

  • データソースの統合認証レイヤ。たとえば、Cloud SQL に保存されているデータを使用して、Cloud Storage 内のファイルへのアクセスを承認します。
  • ウェブ、Android、iOS 向けのタイプセーフなクライアント SDK。
  • 複数のソースからデータを返すクエリ。
  • データベースの状態に基づいて関数呼び出しを制限します。

前提条件

独自のカスタム リゾルバを作成するには、次のものが必要です。

  • Firebase CLI v15.9.0 以降
  • Firebase Functions SDK v7.1.0 以降

また、カスタム リゾルバのロジックを実装する方法である Cloud Functions for Firebase を使用した関数の記述にも精通している必要があります。

始める前に

Data Connect を使用するようにプロジェクトがすでに設定されている必要があります。

まだ設定していない場合は、いずれかのクイックスタート ガイドに沿って設定できます。

カスタム リゾルバを作成する

カスタム リゾルバの作成は、大きく分けて 3 つの部分で構成されます。1 つ目はカスタム リゾルバのスキーマの定義、2 つ目は Cloud Functions を使用したリゾルバの実装、3 つ目はクエリとミューテーションでのカスタム リゾルバ フィールドの使用です。このとき、Cloud SQL や他のカスタム リゾルバと組み合わせて使用することもできます。

次のセクションの手順に沿って、その方法を学習します。例として、ユーザーの公開プロフィール情報が Cloud SQL の外部に保存されているとします。これらの例では、正確なデータストアは指定されていませんが、Cloud Storage、MongoDB インスタンスなどを使用できます。

以降のセクションでは、外部プロファイル情報を Data Connect に取り込むことができるカスタム リゾルバのスケルトン実装について説明します。

カスタム リゾルバのスキーマを定義する

  1. Firebase プロジェクト ディレクトリで、次のコマンドを実行します。

    firebase init dataconnect:resolver

    Firebase CLI は、カスタム リゾルバの名前の入力を求め、TypeScript または JavaScript でリゾルバの実装例を生成するかどうかを尋ねます。このガイドに沿って操作する場合は、デフォルトの名前を使用して TypeScript の例を生成します。

    ツールは空の dataconnect/schema_resolver/schema.gql ファイルを作成し、新しいリゾルバ構成を dataconnect.yaml ファイルに追加します。

  2. この schema.gql ファイルを、カスタム リゾルバが提供するクエリとミューテーションを定義する GraphQL スキーマで更新します。たとえば、Cloud SQL 以外のデータストアに保存されているユーザーの公開プロフィールを取得して更新できるカスタム リゾルバのスキーマは次のようになります。

    # dataconnect/schema_resolver/schema.gql
    
    type PublicProfile {
      name: String!
      photoUrl: String!
      bioLine: String!
    }
    
    type Query {
      # This field will be backed by your Cloud Function.
      publicProfile(userId: String!): PublicProfile
    }
    
    type Mutation {
      # This field will be backed by your Cloud Function.
      updatePublicProfile(
        userId: String!, name: String, photoUrl: String, bioLine: String
      ): PublicProfile
    }
    

カスタム リゾルバ ロジックを実装する

次に、Cloud Functions を使用してリゾルバを実装します。内部的には GraphQL サーバーが作成されますが、Cloud Functions には、その詳細を処理するヘルパー メソッド onGraphRequest が用意されているため、データソースにアクセスするリゾルバ ロジックを記述するだけで済みます。

  1. functions/src/index.ts ファイルを開きます。

    上記の firebase init dataconnect:resolver を実行すると、この Cloud Functions のソースコード ディレクトリが作成され、index.ts のサンプルコードで初期化されました。

  2. 次の定義を追加します。

    import {
      FirebaseContext,
      onGraphRequest,
    } from "firebase-functions/dataconnect/graphql";
    
    const opts = {
      // Points to the schema you defined earlier, relative to the root of your
      // Firebase project.
      schemaFilePath: "dataconnect/schema_resolver/schema.gql",
      resolvers: {
        query: {
          // This resolver function populates the data for the "publicProfile" field
          // defined in your GraphQL schema located at schemaFilePath.
          publicProfile(
            _parent: unknown,
            args: Record<string, unknown>,
            _contextValue: FirebaseContext,
            _info: unknown
          ) {
            const userId = args.userId;
    
            // Here you would use the user ID to retrieve the user profile from your data
            // store. In this example, we just return a hard-coded value.
    
            return {
              name: "Ulysses von Userberg",
              photoUrl: "https://example.com/profiles/12345/photo.jpg",
              bioLine: "Just a guy on a mountain. Ski fanatic.",
            };
          },
        },
        mutation: {
          // This resolver function updates data for the "updatePublicProfile" field
          // defined in your GraphQL schema located at schemaFilePath.
          updatePublicProfile(
            _parent: unknown,
            args: Record<string, unknown>,
            _contextValue: FirebaseContext,
            _info: unknown
          ) {
            const { userId, name, photoUrl, bioLine } = args;
    
            // Here you would update in your datastore the user's profile using the
            // arguments that were passed. In this example, we just return the profile
            // as though the operation had been successful.
    
            return { name, photoUrl, bioLine };
          },
        },
      },
    };
    
    export const resolver = onGraphRequest(opts);
    

これらのスケルトン実装は、リゾルバ関数が取る必要がある一般的な形状を示しています。完全に機能するカスタム リゾルバを作成するには、コメント付きのセクションに、データソースの読み取りと書き込みを行うコードを入力する必要があります。

クエリとミューテーションでカスタム リゾルバを使用する

カスタム リゾルバのスキーマを定義し、それをサポートするロジックを実装したので、Data Connect クエリとミューテーションでカスタム リゾルバを使用できます。これらのオペレーションは、Cloud SQL、カスタム リゾルバ、またはその組み合わせでバックアップされたすべてのデータにアクセスするために使用できるカスタム クライアント SDK を自動的に生成するために使用します。

  1. dataconnect/example/queries.gql に次の定義を追加します。

    query GetPublicProfile($id: String!)
        @auth(level: PUBLIC, insecureReason: "Anyone can see a public profile.") {
      publicProfile(userId: $id) {
        name
        photoUrl
        bioLine
      }
    }
    

    このクエリは、カスタム リゾルバを使用してユーザーの公開プロフィールを取得します。

  2. dataconnect/example/mutations.gql に次の定義を追加します。

    mutation SetPublicProfile(
      $id: String!, $name: String, $photoUrl: String, $bioLine: String
    ) @auth(expr: "vars.id == auth.uid") {
      updatePublicProfile(userId: $id, name: $name, photoUrl: $photoUrl, bioLine: $bioLine) {
        name
        photoUrl
        bioLine
      }
    }
    

    このミューテーションは、カスタム リゾルバを使用して、新しいプロファイル データのセットをデータストアに書き込みます。このスキーマでは、Data Connect@auth ディレクティブを使用して、ユーザーが自分のプロフィールのみを更新できるようにしています。Data Connect を介してデータストアにアクセスしているため、このような Data Connect 機能を利用できます。

上記の例では、カスタム リゾルバを使用してデータストアからデータにアクセスする Data Connect オペレーションを定義しました。ただし、Cloud SQL または単一のカスタム データソースからデータにアクセスするオペレーションに制限はありません。複数のソースのデータを組み合わせる高度なユースケースについては、のセクションをご覧ください。

その前に、次のセクションに進んで、カスタム リゾルバの動作を確認してください。

カスタム リゾルバとオペレーションをデプロイする

Data Connect スキーマに変更を加える場合と同様に、変更を有効にするにはデプロイする必要があります。その前に、Cloud Functions を使用して実装したカスタム リゾルバ ロジックをデプロイします。

firebase deploy --only functions

更新されたスキーマとオペレーションをデプロイできるようになりました。

firebase deploy --only dataconnect

Data Connect スキーマを変更した後は、新しいクライアント SDK も生成する必要があります。

firebase dataconnect:sdk:generate

これらの例は、より高度なユースケースを実装する方法と、一般的な落とし穴を回避する方法を示しています。

Cloud SQL のデータを使用してカスタム リゾルバへのアクセスを承認する

カスタム リゾルバを使用してデータソースを Data Connect に統合するメリットの 1 つは、データソースを結合するオペレーションを記述できることです。

この例では、ソーシャル メディア アプリを構築しているとします。ユーザーがしばらくの間ユーザーと交流していない場合、ユーザーの友だちにナッジメールを送信するミューテーションがカスタム リゾルバとして実装されています。

ナッジ機能を実装するには、次のようなスキーマを使用してカスタム リゾルバを作成します。

# A GraphQL server must define a root query type per the spec.
type Query {
  unused: String
}

type Mutation {
  sendEmail(id: String!, content: String): Boolean
}

この定義は、次のような Cloud Functions によってサポートされています。

import {
  FirebaseContext,
  onGraphRequest,
} from "firebase-functions/dataconnect/graphql";

const opts = {
  schemaFilePath: "dataconnect/schema_resolver/schema.gql",
  resolvers: {
    mutation: {
      sendEmail(
        _parent: unknown,
        args: Record<string, unknown>,
        _contextValue: FirebaseContext,
        _info: unknown
      ) {
        const { id, content } = args;

        // Look up the friend's email address and call the cloud service of your
        // choice to send the friend an email with the given content.

       return true;
      },
    },
  },
};

export const resolver = onGraphRequest(opts);

メールの送信はコストがかかり、不正使用の可能性もあるため、sendEmail カスタム リゾルバを使用する前に、目的の受信者がユーザーの友だちリストに登録されていることを確認する必要があります。

アプリで友だちリストのデータが Cloud SQL に保存されているとします。

type User @table {
  id: String! @default(expr: "auth.uid")
  acceptNudges: Boolean! @default(value: false)
}

type UserFriend @table(key: ["user", "friend"]) {
  user: User!
  friend: User!
}

まず Cloud SQL にクエリを実行して、送信者が受信者の友だちリストに登録されていることを確認してから、カスタム リゾルバを使用してメールを送信するミューテーションを作成できます。

# Send a "nudge" to a friend as a reminder. This will only let the user send a
# nudge if $friendId is in the user's friends list.
mutation SendNudge($friendId: String!) @auth(level: USER_EMAIL_VERIFIED) {
  # Step 1: Query and check
  query @redact {
    userFriend(
      key: {userId_expr: "auth.uid", friendId: $friendId}
    # This checks that $friendId is in the user's friends list.
    ) @check(expr: "this != null", message: "You must be friends to nudge") {
      friend {
        # This checks that the friend is accepting nudges.
        acceptNudges @check(expr: "this == true", message: "Not accepting nudges")
      }
    }
  }
  # Step 2: Act
  sendEmail(id: $friendId, content: "You've been nudged!")
}

なお、この例は、カスタム リゾルバのコンテキストのデータソースに、データベースや類似のシステム以外のリソースを含めることができることも示しています。この例では、データソースはクラウド メール送信サービスです。

ミューテーションを使用して順次実行を保証する

データソースを結合する場合、多くの場合、別のデータソースにリクエストを行う前に、1 つのデータソースに対するリクエストが完了していることを確認する必要があります。たとえば、AI API を使用してオンデマンドで動画を動的に文字起こしするクエリがあるとします。このような API 呼び出しはコストがかかる可能性があるため、ユーザーが動画を所有している、ユーザーがアプリでプレミアム クレジットを購入しているなどの条件を満たした場合にのみ呼び出すようにします。

これを実現するための最初の試みは、次のようになります。

# This won't work as expected.
query BrokenTranscribeVideo($videoId: UUID!) @auth(level: USER_EMAIL_VERIFIED) {
  # Step 1: Check quota using SQL.
  # Verify the user owns the video and has "pro" status or credits.
  checkQuota: query @redact {
    video(id: $videoId)
    {
      user @check(expr: "this.id == auth.uid && this.hasCredits == true", message: "Unauthorized access") {
        id
        hasCredits
      }
    }
  }

  # Step 2: Trigger expensive compute
  # Only triggers if Step 1 succeeds? No! This won't work because query field
  # execution order is not guaranteed.
  triggerTranscription: query {
    # For example, might call Vertex AI or Transcoder API.
    startVideoTranscription(videoId: $videoId)
  }
}

このアプローチは機能しません。クエリ フィールドの実行順序は保証されないためです。GraphQL サーバーは、同時実行を最大化するために、任意の順序でフィールドを解決できることを想定しています。一方、GraphQL サーバーは、ミューテーションのフィールドが解決されるときに副作用が発生する可能性があることを想定しているため、ミューテーションのフィールドは常に順番に解決されます

例のオペレーションの最初のステップには副作用はありませんが、ミューテーション フィールドが順番に解決されるという事実を利用するために、オペレーションをミューテーションとして定義できます。

# By using a mutation, we guarantee the SQL check happens FIRST.
mutation TranscribeVideo($videoId: UUID!) @auth(level: USER_EMAIL_VERIFIED) {
  # Step 1: Check quota using SQL.
  # Verify the user owns the video and has "pro" status or credits.
  checkQuota: query @redact {
    video(id: $videoId)
    {
      user @check(expr: "this.id == auth.uid && this.hasCredits == true", message: "Unauthorized access") {
        id
        hasCredits
      }
    }
  }

  # Step 2: Trigger expensive compute
  # This Cloud Function will ONLY trigger if Step 1 succeeds.
  triggerTranscription: query {
    # For example, might call Vertex AI or Transcoder API.
    startVideoTranscription(videoId: $videoId)
  }
}

制限事項

カスタム リゾルバ機能は、試験運用版のパブリック プレビューとしてリリースされています。現在の制限事項は次のとおりです。

カスタム リゾルバ引数に CEL 式がない

カスタム リゾルバの引数で CEL 式を動的に使用することはできません。たとえば、次のことはできません。

mutation UpdateMyProfile($newName: String!) @auth(level: USER) {
  updateMongoDocument(
    collection: "profiles"
    # This isn't supported:
    id_expr: "auth.uid"
    update: { name: $newName }
  )
}

代わりに、標準変数($authUid など)を渡し、安全に評価された @auth(expr: ...) ディレクティブを使用してオペレーション レベルで検証します。

mutation UpdateMyProfile(
  $newName: String!, $authUid: String!
) @auth(expr: "vars.authUid == auth.uid") {
  updateMongoDocument(
    collection: "profiles"
    id: $authUid
    update: { name: $newName }
  )
}

別の回避策として、すべてのロジックをカスタム リゾルバに移動し、Cloud Functions からすべてのデータ オペレーションを完了する方法もあります。

たとえば、次の例は現在機能しません。

mutation BrokenForwardToEmail($chatMessageId: UUID!) @auth(level: USER_EMAIL_VERIFIED) {
  query {
    chatMessage(id: $chatMessageId) {
      content
    }
  }
  sendEmail(
    title: "Forwarded Chat Message"
    to_expr: "auth.token.email" # Not supported.
    content_expr: "response.query.chatMessage.content" # Not supported.
  )
}

代わりに、Cloud SQL クエリとメールサービスへの呼び出しの両方を、関数によってバックアップされた 1 つのミューテーション フィールドに移動します。

mutation ForwardToEmail($chatMessageId: UUID!) @auth(level: USER_EMAIL_VERIFIED) {
  forwardChatToEmail(
    chatMessageId: $chatMessageId
  )
}

データベースの Admin SDK を生成し、関数で使用して Cloud SQL クエリを実行します。

const opts = {
 schemaFilePath: "dataconnect/schema_resolver/schema.gql",
 resolvers: {
   query: {
     async forwardToEmail(
       _parent: unknown,
       args: Record<string, unknown>,
       _contextValue: FirebaseContext,
       _info: unknown
     ) {
       const chatMessageId = args.chatMessageId as string;

       let decodedToken;
       try {
         decodedToken = await getAuth().verifyIdToken(_contextValue.auth.token ?? "");
       } catch (error) {
         return false;
       }

       const email = decodedToken.email;
       if (!email) {
         return false;
       }

       const response = await getChatMessage({chatMessageId});
       const messageContent = response.data.chatMessage?.content;

       // Here you call the cloud service of your choice to send the email with
       // the message content.

       return true;
     }
   },
 },
};
export const resolver = onGraphRequest(opts);

カスタム リゾルバ パラメータに入力オブジェクト タイプがない

カスタム リゾルバは、複雑な GraphQL 入力型を受け入れません。パラメータは、基本スカラー型(StringIntDateAny など)と Enum である必要があります。

input PublicProfileInput {
  name: String!
  photoUrl: String!
  bioLine: String!
}

type Mutation {
  # Not supported:
  updatePublicProfile(userId: String!, profile: PublicProfileInput): PublicProfile

  # OK:
  updatePublicProfile(userId: String!, name: String, photoUrl: String, bioLine: String): PublicProfile
}

カスタム リゾルバは SQL オペレーションの前に配置できません

ミューテーションで、カスタム リゾルバを標準 SQL オペレーションの前に配置すると、エラーが発生します。SQL ベースのオペレーションはすべて、カスタム リゾルバの呼び出しの前に記述する必要があります。

トランザクションなし(@transaction)

カスタム リゾルバを標準 SQL オペレーションで @transaction ブロック内にラップすることはできません。SQL 挿入が成功した後にリゾルバをサポートする Cloud Functions が失敗した場合、データベースは自動的にロールバックされません。

SQL と別のデータソース間のトランザクションの安全性を確保するには、SQL オペレーション ロジックを Cloud Functions 内に移動し、Admin SDK または直接 SQL 接続を使用して検証とロールバックを処理します。