Memperluas Data Connect dengan resolver kustom

Dengan menulis resolver kustom, Anda dapat memperluas Firebase Data Connect untuk mendukung sumber data lain selain Cloud SQL. Kemudian, Anda dapat menggabungkan beberapa sumber data (Cloud SQL dan sumber data yang disediakan oleh pemecah kustom) ke dalam satu kueri atau mutasi.

Konsep "sumber data" bersifat fleksibel. Ini mencakup:

  • Database selain Cloud SQL, seperti Cloud Firestore, MongoDB, dan lainnya.
  • Layanan penyimpanan seperti Cloud Storage, AWS S3, dan lainnya.
  • Integrasi berbasis API apa pun, seperti Stripe, SendGrid, Salesforce, dan lainnya.
  • Logika bisnis kustom.

Setelah menulis resolver kustom untuk mendukung sumber data tambahan, kueri dan mutasi Data Connect dapat menggabungkannya dalam berbagai cara, sehingga memberikan manfaat seperti:

  • Lapisan otorisasi terpadu untuk sumber data Anda. Misalnya, mengizinkan akses ke file di Cloud Storage menggunakan data yang disimpan di Cloud SQL.
  • SDK klien yang aman jenisnya untuk web, Android, dan iOS.
  • Kueri yang menampilkan data dari beberapa sumber.
  • Panggilan fungsi yang dibatasi berdasarkan status database Anda.

Prasyarat

Untuk menulis resolver kustom Anda sendiri, Anda memerlukan hal berikut:

  • Firebase CLI v15.9.0 atau yang lebih tinggi
  • Firebase Functions SDK v7.1.0 atau yang lebih tinggi

Selain itu, Anda harus memahami cara menulis fungsi menggunakan Cloud Functions for Firebase, yang merupakan cara Anda akan menerapkan logika resolver kustom.

Sebelum memulai

Anda seharusnya sudah menyiapkan project untuk menggunakan Data Connect.

Anda dapat mengikuti salah satu panduan Memulai Cepat untuk melakukan penyiapan jika belum melakukannya:

Menulis resolver kustom

Secara umum, penulisan resolver kustom memiliki tiga bagian: pertama, menentukan skema untuk resolver kustom; kedua, menerapkan resolver menggunakan Cloud Functions; dan terakhir, menggunakan kolom resolver kustom dalam kueri dan mutasi, mungkin bersama dengan Cloud SQL atau resolver kustom lainnya.

Ikuti langkah-langkah di beberapa bagian berikutnya untuk mempelajari cara melakukannya. Sebagai contoh yang memotivasi, misalkan Anda memiliki informasi profil publik untuk pengguna yang disimpan di luar Cloud SQL. Datastore yang tepat tidak ditentukan dalam contoh ini, tetapi bisa berupa Cloud Storage, instance MongoDB, atau apa pun.

Bagian berikut akan menunjukkan penerapan kerangka resolver kustom yang dapat membawa informasi profil eksternal tersebut ke Data Connect.

Menentukan skema untuk resolver kustom Anda

  1. Di direktori project Firebase Anda, jalankan:

    firebase init dataconnect:resolver

    Firebase CLI akan meminta Anda memasukkan nama untuk resolver kustom, dan menanyakan apakah akan membuat contoh penerapan resolver dalam TypeScript atau JavaScript. Jika Anda mengikuti panduan ini, setujui nama default dan buat contoh TypeScript.

    Kemudian, alat ini akan membuat file dataconnect/schema_resolver/schema.gql kosong dan menambahkan konfigurasi resolver baru Anda ke file dataconnect.yaml.

  2. Perbarui file schema.gql ini dengan skema GraphQL yang menentukan kueri dan mutasi yang akan disediakan oleh pemecah kustom Anda. Misalnya, berikut adalah skema untuk pemroses kustom yang dapat mengambil dan memperbarui profil publik pengguna, yang disimpan di penyimpanan data selain 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
    }
    

Menerapkan logika resolver kustom

Selanjutnya, terapkan pemecah masalah menggunakan Cloud Functions. Di balik layar, Anda akan membuat server GraphQL. Namun, Cloud Functions memiliki metode helper, onGraphRequest, yang menangani detailnya, sehingga Anda hanya perlu menulis logika resolver yang mengakses sumber data Anda.

  1. Buka file functions/src/index.ts.

    Saat Anda menjalankan firebase init dataconnect:resolver di atas, perintah tersebut membuat direktori kode sumber Cloud Functions ini dan menginisialisasinya dengan kode contoh di index.ts.

  2. Tambahkan definisi berikut:

    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);
    

Implementasi kerangka ini menunjukkan bentuk umum yang harus dimiliki fungsi resolver. Untuk membuat resolver kustom yang berfungsi penuh, Anda harus mengisi bagian yang diberi komentar dengan kode yang membaca dan menulis ke sumber data Anda.

Menggunakan pemecah kustom dalam kueri dan mutasi

Setelah menentukan skema pemroses kustom dan menerapkan logika yang mendukungnya, Anda dapat menggunakan pemroses kustom dalam kueri dan mutasi Data Connect. Selanjutnya, Anda akan menggunakan operasi ini untuk otomatis membuat SDK klien kustom yang dapat Anda gunakan untuk mengakses semua data Anda, baik yang didukung oleh Cloud SQL, resolver kustom, atau kombinasi.

  1. Di dataconnect/example/queries.gql, tambahkan definisi berikut:

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

    Kueri ini mengambil profil publik pengguna, menggunakan resolver kustom Anda.

  2. Di dataconnect/example/mutations.gql, tambahkan definisi berikut:

    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
      }
    }
    

    Mutasi ini menulis kumpulan data profil baru ke datastore, lagi-lagi menggunakan resolver kustom Anda. Perhatikan bahwa skema menggunakan direktif Data Connect's @auth untuk memastikan bahwa pengguna hanya dapat memperbarui profil mereka sendiri. Karena Anda mengakses datastore melalui Data Connect, Anda otomatis dapat memanfaatkan fitur Data Connect seperti ini.

Dalam contoh di atas, Anda telah menentukan operasi Data Connect yang mengakses data dari datastore menggunakan resolver kustom. Namun, Anda tidak dibatasi dalam operasi untuk mengakses data dari Cloud SQL atau dari satu sumber data kustom. Lihat bagian Contoh untuk beberapa kasus penggunaan yang lebih canggih yang menggabungkan data dari beberapa sumber.

Sebelum itu, lanjutkan ke bagian berikutnya untuk melihat cara kerja resolver kustom Anda.

Men-deploy resolver dan operasi kustom Anda

Seperti saat membuat perubahan pada skema Data Connect, Anda harus men-deploy-nya agar perubahan tersebut diterapkan. Sebelum melakukannya, deploy terlebih dahulu logika resolver kustom yang Anda terapkan menggunakan Cloud Functions:

firebase deploy --only functions

Sekarang Anda dapat men-deploy skema dan operasi yang diperbarui:

firebase deploy --only dataconnect

Setelah melakukan perubahan pada skema Data Connect, Anda juga harus membuat SDK klien baru:

firebase dataconnect:sdk:generate

Contoh

Contoh ini menunjukkan cara menerapkan beberapa kasus penggunaan yang lebih canggih, dan cara menghindari kesalahan umum.

Memberikan otorisasi akses ke resolver kustom menggunakan data dari Cloud SQL

Salah satu manfaat mengintegrasikan sumber data ke dalam Data Connect menggunakan pemroses kustom adalah Anda dapat menulis operasi yang menggabungkan sumber data.

Dalam contoh ini, misalkan Anda sedang membangun aplikasi media sosial, dan Anda memiliki mutasi yang diterapkan sebagai resolver kustom, yang mengirimkan email pemberitahuan kepada teman pengguna jika mereka tidak berinteraksi dengan pengguna dalam beberapa waktu.

Untuk menerapkan fitur saran, buat resolver kustom dengan skema seperti berikut:

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

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

Definisi ini didukung oleh Cloud Function, seperti berikut:

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);

Karena mengirim email memerlukan biaya dan berpotensi disalahgunakan, Anda harus memastikan bahwa penerima yang dituju sudah ada dalam daftar teman pengguna sebelum menggunakan sendEmailresolver kustom Anda.

Misalkan di aplikasi Anda, data daftar teman disimpan di 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!
}

Anda dapat menulis mutasi yang pertama-tama melakukan kueri Cloud SQL untuk memastikan bahwa pengirim ada dalam daftar teman penerima sebelum menggunakan pemecah kustom untuk mengirim email:

# 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!")
}

Selain itu, contoh ini juga mengilustrasikan bahwa sumber data dalam konteks resolver kustom dapat mencakup resource selain database dan sistem serupa. Dalam contoh ini, sumber datanya adalah layanan pengiriman email cloud.

Memastikan eksekusi berurutan menggunakan mutasi

Saat menggabungkan sumber data, Anda sering kali perlu memastikan bahwa permintaan ke satu sumber data selesai sebelum membuat permintaan ke sumber data lain. Misalnya, Anda memiliki kueri yang secara dinamis mentranskripsikan video on demand menggunakan AI API. Panggilan API seperti ini bisa mahal, jadi Anda harus membatasi panggilan di balik beberapa kriteria, seperti pengguna memiliki video, atau pengguna telah membeli beberapa jenis kredit premium di aplikasi Anda.

Upaya pertama untuk mencapainya mungkin terlihat seperti ini:

# 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)
  }
}

Pendekatan ini tidak akan berfungsi karena urutan eksekusi kolom kueri tidak dijamin; server GraphQL berharap dapat menyelesaikan kolom dalam urutan apa pun, untuk memaksimalkan konkurensi. Di sisi lain, kolom mutasi selalu diselesaikan secara berurutan, karena server GraphQL mengharapkan beberapa kolom mutasi mungkin memiliki efek samping saat diselesaikan.

Meskipun langkah pertama operasi contoh tidak memiliki efek samping, Anda dapat menentukan operasi sebagai mutasi untuk memanfaatkan fakta bahwa kolom mutasi diselesaikan secara berurutan:

# 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)
  }
}

Keterbatasan

Fitur resolver kustom dirilis sebagai pratinjau publik eksperimental. Perhatikan batasan saat ini berikut:

Tidak ada ekspresi CEL dalam argumen resolver kustom

Anda tidak dapat menggunakan ekspresi CEL secara dinamis dalam argumen ke pemroses kustom. Misalnya, hal berikut tidak mungkin dilakukan:

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

Sebagai gantinya, teruskan variabel standar (misalnya, $authUid) dan validasi di tingkat operasi menggunakan direktif @auth(expr: ...) yang dievaluasi secara aman.

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

Solusi lainnya adalah memindahkan semua logika Anda ke resolver kustom dan menyelesaikan semua operasi data dari Cloud Functions.

Misalnya, perhatikan contoh berikut, yang saat ini tidak akan berfungsi:

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.
  )
}

Sebagai gantinya, pindahkan kueri Cloud SQL dan panggilan ke layanan email ke dalam satu kolom mutasi, yang didukung oleh fungsi:

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

Buat Admin SDK untuk database Anda dan gunakan di fungsi untuk melakukan kueri 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);

Tidak ada jenis objek input dalam parameter pemecah kustom

Resolver kustom tidak menerima jenis input GraphQL yang kompleks. Parameter harus berupa jenis skalar dasar (String, Int, Date, Any, dll.) dan 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
}

Resolver kustom tidak dapat mendahului operasi SQL

Dalam mutasi, menempatkan pemecah kustom sebelum operasi SQL standar akan menghasilkan error. Semua operasi berbasis SQL harus muncul sebelum pemanggilan resolver kustom.

Tidak ada transaksi (@transaction)

Resolver kustom tidak dapat di-wrap di dalam blok @transaction dengan operasi SQL standar. Jika Cloud Function yang mendukung resolver gagal setelah penyisipan SQL berhasil, database tidak akan otomatis di-roll back.

Untuk mencapai keamanan transaksional antara SQL dan sumber data lain, pindahkan logika operasi SQL di dalam Cloud Function, dan tangani validasi serta rollback menggunakan Admin SDK atau koneksi SQL langsung.