En écrivant des résolveurs personnalisés, vous pouvez étendre Firebase Data Connect pour prendre en charge d'autres sources de données en plus de Cloud SQL. Vous pouvez ensuite combiner plusieurs sources de données (Cloud SQL et les sources de données fournies par vos résolveurs personnalisés) en une seule requête ou mutation.
Le concept de "source de données" est flexible. Il inclut les éléments suivants :
- Bases de données autres que Cloud SQL, telles que Cloud Firestore, MongoDB et d'autres.
- Services de stockage tels que Cloud Storage, AWS S3, etc.
- Toute intégration basée sur une API, comme Stripe, SendGrid, Salesforce, etc.
- Logique métier personnalisée
Une fois que vous avez écrit des résolveurs personnalisés pour prendre en charge vos sources de données supplémentaires, vos requêtes et mutations Data Connect peuvent les combiner de nombreuses façons, ce qui offre des avantages tels que :
- Une couche d'autorisation unifiée pour vos sources de données. Par exemple, autorisez l'accès aux fichiers dans Cloud Storage à l'aide des données stockées dans Cloud SQL.
- SDK client avec sûreté du typage pour le Web, Android et iOS.
- Requêtes renvoyant des données provenant de plusieurs sources.
- Les appels de fonction sont limités en fonction de l'état de votre base de données.
Prérequis
Pour écrire vos propres résolveurs personnalisés, vous avez besoin des éléments suivants :
- CLI Firebase version 15.9.0 ou ultérieure
- SDK Firebase Functions version 7.1.0 ou ultérieure
De plus, vous devez savoir écrire des fonctions à l'aide de Cloud Functions for Firebase, qui vous permettra d'implémenter la logique de vos résolveurs personnalisés.
Avant de commencer
Vous devez déjà avoir configuré un projet pour utiliser Data Connect.
Si vous ne l'avez pas encore fait, vous pouvez suivre l'un des guides de démarrage rapide pour configurer votre environnement :
Écrire des résolveurs personnalisés
De manière générale, l'écriture d'un résolveur personnalisé comporte trois parties : d'abord, la définition d'un schéma pour votre résolveur personnalisé ; ensuite, l'implémentation de vos résolveurs à l'aide de Cloud Functions ; et enfin, l'utilisation de vos champs de résolveur personnalisé dans les requêtes et les mutations, éventuellement en parallèle avec Cloud SQL ou d'autres résolveurs personnalisés.
Suivez les étapes des sections suivantes pour découvrir comment procéder. Prenons un exemple motivant : supposons que vous stockiez les informations de profil public de vos utilisateurs en dehors de Cloud SQL. L'entrepôt de données exact n'est pas spécifié dans ces exemples, mais il peut s'agir de Cloud Storage, d'une instance MongoDB ou de tout autre élément.
Les sections suivantes présentent une implémentation squelette d'un résolveur personnalisé qui peut importer ces informations de profil externes dans Data Connect.
Définir le schéma de votre résolveur personnalisé
Dans le répertoire de votre projet Firebase, exécutez la commande suivante :
firebase init dataconnect:resolverLa CLI Firebase vous demandera de choisir un nom pour votre résolveur personnalisé et si vous souhaitez générer des exemples d'implémentations de résolveur en TypeScript ou en JavaScript. Si vous suivez ce guide, acceptez le nom par défaut et générez des exemples TypeScript.
L'outil crée ensuite un fichier
dataconnect/schema_resolver/schema.gqlvide et ajoute votre nouvelle configuration de résolveur au fichierdataconnect.yaml.Mettez à jour ce fichier
schema.gqlavec un schéma GraphQL qui définit les requêtes et les mutations que votre résolveur personnalisé fournira. Par exemple, voici un schéma pour un résolveur personnalisé qui peut récupérer et mettre à jour le profil public d'un utilisateur, stocké dans un datastore autre que 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 }
Implémenter la logique du résolveur personnalisé
Implémentez ensuite vos résolveurs à l'aide de Cloud Functions. En coulisses, vous allez créer un serveur GraphQL. Toutefois, Cloud Functions dispose d'une méthode d'assistance, onGraphRequest, qui gère les détails de cette opération. Vous n'aurez donc qu'à écrire la logique du résolveur qui accède à votre source de données.
Ouvrez le fichier
functions/src/index.ts.Lorsque vous avez exécuté
firebase init dataconnect:resolverci-dessus, la commande a créé ce répertoire de code source Cloud Functions et l'a initialisé avec un exemple de code dansindex.ts.Ajoutez les définitions suivantes :
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);
Ces implémentations squelettes montrent la forme générale qu'une fonction de résolution doit prendre. Pour créer un résolveur personnalisé entièrement fonctionnel, vous devrez remplir les sections commentées avec du code qui lit et écrit dans votre source de données.
Utiliser des résolveurs personnalisés dans les requêtes et les mutations
Maintenant que vous avez défini le schéma de votre résolveur personnalisé et implémenté la logique qui le sous-tend, vous pouvez utiliser le résolveur personnalisé dans vos requêtes et mutations Data Connect. Vous utiliserez ensuite ces opérations pour générer automatiquement un SDK client personnalisé que vous pourrez utiliser pour accéder à toutes vos données, qu'elles soient sauvegardées par Cloud SQL, vos résolveurs personnalisés ou une combinaison des deux.
Dans
dataconnect/example/queries.gql, ajoutez la définition suivante :query GetPublicProfile($id: String!) @auth(level: PUBLIC, insecureReason: "Anyone can see a public profile.") { publicProfile(userId: $id) { name photoUrl bioLine } }Cette requête récupère le profil public d'un utilisateur à l'aide de votre résolveur personnalisé.
Dans
dataconnect/example/mutations.gql, ajoutez la définition suivante :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 } }Cette mutation écrit un nouvel ensemble de données de profil dans le datastore, en utilisant à nouveau votre résolveur personnalisé. Notez que le schéma utilise la directive
@authde Data Connect pour s'assurer que les utilisateurs ne peuvent modifier que leur propre profil. Étant donné que vous accédez à votre data store via Data Connect, vous pouvez automatiquement profiter des fonctionnalités Data Connect telles que celle-ci.
Dans les exemples ci-dessus, vous avez défini des opérations Data Connect qui accèdent aux données de votre datastore à l'aide de vos résolveurs personnalisés. Toutefois, vous n'êtes pas limité à l'accès aux données de Cloud SQL ou d'une seule source de données personnalisée. Pour découvrir des cas d'utilisation plus avancés qui combinent des données provenant de plusieurs sources, consultez la section Exemples.
Avant cela, passez à la section suivante pour voir vos résolveurs personnalisés en action.
Déployer votre résolveur et vos opérations personnalisés
Comme pour toute modification apportée à vos schémas Data Connect, vous devez les déployer pour qu'ils prennent effet. Avant de le faire, déployez d'abord la logique de résolution personnalisée que vous avez implémentée à l'aide de Cloud Functions :
firebase deploy --only functionsVous pouvez maintenant déployer les schémas et les opérations mis à jour :
firebase deploy --only dataconnectAprès avoir modifié vos schémas Data Connect, vous devez également générer de nouveaux SDK client :
firebase dataconnect:sdk:generateExemples
Ces exemples montrent comment implémenter des cas d'utilisation plus avancés et comment éviter les pièges courants.
Autoriser l'accès à un résolveur personnalisé à l'aide de données provenant de Cloud SQL
L'un des avantages de l'intégration de vos sources de données dans Data Connect à l'aide de résolveurs personnalisés est que vous pouvez écrire des opérations qui combinent des sources de données.
Dans cet exemple, supposons que vous développiez une application de réseau social et que vous ayez implémenté une mutation en tant que résolveur personnalisé, qui envoie un e-mail de rappel à l'ami d'un utilisateur s'il n'a pas interagi avec lui depuis un certain temps.
Pour implémenter la fonctionnalité de suggestion, créez un résolveur personnalisé avec un schéma semblable à celui-ci :
# A GraphQL server must define a root query type per the spec.
type Query {
unused: String
}
type Mutation {
sendEmail(id: String!, content: String): Boolean
}
Cette définition est soutenue par une fonction Cloud, telle que la suivante :
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);
L'envoi d'e-mails étant coûteux pour vous et pouvant être utilisé à des fins abusives, assurez-vous que le destinataire prévu figure déjà dans la liste d'amis de l'utilisateur avant d'utiliser votre résolveur personnalisé sendEmail.
Supposons que les données de votre liste d'amis soient stockées dans 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!
}
Vous pouvez écrire une mutation qui interroge d'abord Cloud SQL pour s'assurer que l'expéditeur figure dans la liste d'amis du destinataire avant d'utiliser le résolveur personnalisé pour envoyer l'e-mail :
# 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!")
}
Cet exemple montre également qu'une source de données dans le contexte des résolveurs personnalisés peut inclure des ressources autres que des bases de données et des systèmes similaires. Dans cet exemple, la source de données est un service d'envoi d'e-mails dans le cloud.
Garantir l'exécution séquentielle à l'aide de mutations
Lorsque vous combinez des sources de données, vous devez souvent vous assurer qu'une requête adressée à une source de données est terminée avant d'en envoyer une autre à une source de données différente. Par exemple, supposons que vous ayez une requête qui transcrit dynamiquement une vidéo à la demande à l'aide d'une API d'IA. Un appel d'API comme celui-ci peut être coûteux. Vous devez donc le limiter en fonction de certains critères, par exemple si l'utilisateur est propriétaire de la vidéo ou s'il a acheté des crédits premium dans votre application.
Voici à quoi pourrait ressembler une première tentative pour y parvenir :
# 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)
}
}
Cette approche ne fonctionnera pas, car l'ordre d'exécution des champs de requête n'est pas garanti. Le serveur GraphQL s'attend à pouvoir résoudre les champs dans n'importe quel ordre, afin de maximiser la simultanéité. En revanche, les champs d'une mutation sont toujours résolus dans l'ordre, car le serveur GraphQL s'attend à ce que certains champs d'une mutation puissent avoir des effets secondaires lors de la résolution.
Même si la première étape de l'opération d'exemple n'a pas d'effets secondaires, vous pouvez définir l'opération comme une mutation afin de profiter du fait que les champs de mutation sont résolus dans l'ordre :
# 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)
}
}
Limites
La fonctionnalité de résolveurs personnalisés est disponible en version Preview publique expérimentale. Notez les limites actuelles suivantes :
Aucune expression CEL dans les arguments du résolveur personnalisé
Vous ne pouvez pas utiliser d'expressions CEL de manière dynamique dans les arguments d'un résolveur personnalisé. Par exemple, les éléments suivants ne sont pas possibles :
mutation UpdateMyProfile($newName: String!) @auth(level: USER) {
updateMongoDocument(
collection: "profiles"
# This isn't supported:
id_expr: "auth.uid"
update: { name: $newName }
)
}
Passez plutôt des variables standards (par exemple, $authUid) et validez-les au niveau de l'opération à l'aide de la directive @auth(expr: ...) évaluée de manière sécurisée.
mutation UpdateMyProfile(
$newName: String!, $authUid: String!
) @auth(expr: "vars.authUid == auth.uid") {
updateMongoDocument(
collection: "profiles"
id: $authUid
update: { name: $newName }
)
}
Une autre solution consiste à déplacer toute votre logique dans un résolveur personnalisé et à effectuer toutes vos opérations de données à partir de Cloud Functions.
Par exemple, le code suivant ne fonctionnera pas pour le moment :
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.
)
}
À la place, déplacez la requête Cloud SQL et l'appel au service de messagerie dans un seul champ de mutation, soutenu par une fonction :
mutation ForwardToEmail($chatMessageId: UUID!) @auth(level: USER_EMAIL_VERIFIED) {
forwardChatToEmail(
chatMessageId: $chatMessageId
)
}
Générez un SDK Admin pour votre base de données et utilisez-le dans la fonction pour exécuter la requête 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);
Aucun type d'objet d'entrée dans les paramètres du résolveur personnalisé
Les résolveurs personnalisés n'acceptent pas les types d'entrée GraphQL complexes. Les paramètres doivent être des types scalaires de base (String, Int, Date, Any, etc.) et des 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
}
Les résolveurs personnalisés ne peuvent pas précéder les opérations SQL
Dans une mutation, placer un résolveur personnalisé avant des opérations SQL standards génère une erreur. Toutes les opérations basées sur SQL doivent apparaître avant toute invocation de résolveur personnalisé.
Aucune transaction (@transaction)
Les résolveurs personnalisés ne peuvent pas être encapsulés dans un bloc @transaction avec des opérations SQL standards. Si la fonction Cloud qui sous-tend le résolveur échoue après l'insertion SQL, la base de données n'effectuera pas automatiquement de rollback.
Pour assurer la sécurité transactionnelle entre SQL et une autre source de données, déplacez la logique d'opération SQL à l'intérieur de la fonction Cloud, et gérez la validation et les rollbacks à l'aide de l'Admin SDK ou de connexions SQL directes.