Memperluas SQL Connect dengan resolver kustom

Dengan menulis resolver kustom, Anda dapat memperluas Firebase SQL Connect untuk mendukung sumber data lain selain Cloud SQL. Kemudian, Anda dapat menggabungkan beberapa sumber data (Cloud SQL dan sumber data yang disediakan oleh resolver kustom Anda) 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 Anda menulis resolver kustom untuk mendukung sumber data tambahan, kueri dan mutasi SQL Connect dapat menggabungkannya dengan berbagai cara, sehingga memberikan manfaat seperti:

  • Lapisan otorisasi terpadu untuk sumber data Anda. Misalnya, mengotorisasi akses ke file di Cloud Storage menggunakan data yang disimpan di Cloud SQL.
  • SDK klien yang aman untuk web, Android, dan iOS.
  • Kueri yang menampilkan data dari beberapa sumber.
  • Pemanggilan 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 menerapkan logika resolver kustom.

Sebelum memulai

Anda harus sudah menyiapkan project untuk menggunakan SQL Connect.

Anda dapat mengikuti salah satu panduan Memulai untuk menyiapkan jika belum:

Menulis resolver kustom

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

Ikuti langkah-langkah di beberapa bagian berikutnya untuk mempelajari cara melakukannya. Sebagai contoh motivasi, 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 hal lainnya.

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

Menentukan skema untuk resolver kustom

  1. Di direktori project Firebase, jalankan:

    firebase init dataconnect:resolver

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

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

  2. Perbarui file schema.gql ini dengan skema GraphQL yang menentukan kueri dan mutasi yang akan disediakan oleh resolver kustom Anda. Misalnya, berikut adalah skema untuk resolver kustom yang dapat mengambil dan memperbarui profil publik pengguna, yang disimpan di datastore 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 resolver menggunakan Cloud Functions. Di balik layar, Anda akan membuat server GraphQL; namun, Cloud Functions memiliki metode helper, onGraphRequest, yang menangani detail melakukannya, sehingga Anda hanya perlu menulis logika resolver yang mengakses sumber data.

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

    Saat Anda menjalankan firebase init dataconnect:resolver di atas, perintah tersebut akan 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 diambil oleh fungsi resolver. Untuk membuat resolver kustom yang berfungsi penuh, Anda harus mengisi bagian yang dikomentari dengan kode yang membaca dan menulis ke sumber data.

Menggunakan resolver kustom dalam kueri dan mutasi

Setelah menentukan skema resolver kustom dan menerapkan logika yang mendukungnya, Anda dapat menggunakan resolver kustom dalam kueri dan mutasi SQL Connect. Nanti, Anda akan menggunakan operasi ini untuk otomatis membuat SDK klien kustom yang dapat digunakan untuk mengakses semua data, 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, sekali lagi menggunakan resolver kustom Anda. Perhatikan bahwa skema ini menggunakan SQL Connect's @auth direktif untuk memastikan bahwa pengguna dapat hanya memperbarui profil mereka sendiri. Karena Anda mengakses datastore melalui SQL Connect, Anda otomatis dapat memanfaatkan fitur SQL Connect seperti ini.

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

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

Men-deploy resolver dan operasi kustom

Seperti saat membuat perubahan pada skema SQL 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 membuat perubahan pada skema SQL 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 masalah umum.

Mengotorisasi akses ke resolver kustom menggunakan data dari Cloud SQL

Salah satu manfaat mengintegrasikan sumber data ke SQL Connect menggunakan resolver kustom adalah Anda dapat menulis operasi yang menggabungkan data sumber.

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

Untuk menerapkan fitur pemberitahuan, 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 pengiriman email mahal dan berpotensi disalahgunakan, Anda harus memastikan bahwa penerima yang dituju sudah ada dalam daftar teman pengguna sebelum menggunakan resolver kustom sendEmail.

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 mengkueri Cloud SQL untuk memastikan bahwa pengirim ada dalam daftar teman penerima sebelum menggunakan resolver 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, misalkan Anda memiliki kueri yang secara dinamis mentranskripsikan video sesuai permintaan menggunakan AI API. Panggilan API seperti ini bisa mahal, jadi Anda ingin membatasi panggilan di balik beberapa kriteria, seperti pengguna memiliki video, atau pengguna telah membeli kredit premium di aplikasi Anda.

Upaya pertama untuk mencapai hal ini 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 berharap 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)
  }
}

Batasan

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 resolver 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 dengan aman.

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

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

Misalnya, pertimbangkan contoh ini, 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 SDK admin untuk database Anda dan gunakan dalam fungsi untuk menjalankan 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 resolver 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 resolver 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 digabungkan 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 ke dalam Cloud Function, dan tangani validasi serta rollback menggunakan Admin SDK atau koneksi SQL langsung.