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

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

「データソース」の概念は柔軟です。これには次のものが含まれます。

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

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

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

前提条件

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

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

また、 Cloud Functions for Firebase を使用して関数を作成する方法を理解しておく必要があります。これは、カスタム リゾルバのロジックを実装する方法です。

始める前に

SQL Connect を使用するようにプロジェクトを設定しておく必要があります。

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

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

大まかに言うと、カスタム リゾルバの作成には 3 つの部分があります。まず、カスタム リゾルバのスキーマを定義します。次に、Cloud Functions を使用してリゾルバを実装します。最後に、クエリとミューテーションでカスタム リゾルバ フィールドを使用します。Cloud SQL や他のカスタム リゾルバと組み合わせて使用することもできます。

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

次のセクションでは、外部プロファイル情報を に取り込むことができるカスタム リゾルバのスケルトン実装について説明します。SQL 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);
    

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

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

カスタム リゾルバのスキーマを定義し、それをサポートするロジックを 実装したので、 SQL 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
      }
    }
    

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

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

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

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

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

firebase deploy --only functions

これで、更新されたスキーマとオペレーションをデプロイできます。

firebase deploy --only dataconnect

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

firebase dataconnect:sdk:generate

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

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

カスタム リゾルバを使用してデータソースを SQL 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!")
}

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

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

データソースを組み合わせる場合は、あるデータソースへのリクエストが完了してから別のデータソースにリクエストを行う必要があることがよくあります。たとえば、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 接続を使用して検証とロールバックを処理します。