Scrivendo resolver personalizzati, puoi estendere Firebase SQL Connect per supportare altre origini dati oltre a Cloud SQL. Puoi quindi combinare più origini dati (Cloud SQL e le origini dati fornite dai resolver personalizzati) in una singola query o mutazione.
Il concetto di "origine dati" è flessibile. Comprende:
- Database diversi da Cloud SQL, come Cloud Firestore, MongoDB e altri.
- Servizi di archiviazione come Cloud Storage, AWS S3 e altri.
- Qualsiasi integrazione basata su API, come Stripe, SendGrid, Salesforce e altre.
- Logica di business personalizzata.
Una volta scritti i resolver personalizzati per supportare le origini dati aggiuntive, le query e le mutazioni SQL Connect possono combinarle in molti modi, offrendo vantaggi quali:
- Un livello di autorizzazione unificato per le tue origini dati. Ad esempio, autorizza l'accesso ai file in Cloud Storage utilizzando i dati archiviati in Cloud SQL.
- SDK client type-safe per web, Android e iOS.
- Query che restituiscono dati da più origini.
- Invocazioni di funzioni limitate in base allo stato del database.
Prerequisiti
Per scrivere i tuoi resolver personalizzati, devi disporre di quanto segue:
- Interfaccia a riga di comando di Firebase v15.9.0 o successive
- SDK Firebase Functions versione 7.1.0 o successive
Inoltre, devi avere familiarità con la scrittura di funzioni utilizzando Cloud Functions for Firebase, che è il modo in cui implementerai la logica dei tuoi resolver personalizzati.
Prima di iniziare
Dovresti già avere un progetto configurato per utilizzare SQL Connect.
Se non l'hai ancora fatto, puoi seguire una delle guide rapide per la configurazione:
Scrivere resolver personalizzati
A livello generale, la scrittura di un resolver personalizzato è composta da tre parti: innanzitutto, la definizione di uno schema per il resolver personalizzato; in secondo luogo, l'implementazione dei resolver utilizzando Cloud Functions; infine, l'utilizzo dei campi del resolver personalizzato in query e mutazioni, possibilmente in combinazione con Cloud SQL o altri resolver personalizzati.
Segui i passaggi nelle sezioni successive per scoprire come farlo. Come esempio motivante, supponiamo di avere informazioni del profilo pubblico per i tuoi utenti archiviate al di fuori di Cloud SQL. In questi esempi non viene specificato l'esatto datastore, ma potrebbe trattarsi di Cloud Storage, di un'istanza MongoDB o di qualsiasi altro elemento.
Le seguenti sezioni mostreranno un'implementazione di base di un resolver personalizzato che può importare le informazioni del profilo esterno in SQL Connect.
Definisci lo schema per il resolver personalizzato
Nella directory del progetto Firebase, esegui:
firebase init dataconnect:resolverL'interfaccia a riga di comando di Firebase ti chiederà un nome per il resolver personalizzato e se generare implementazioni di esempio del resolver in TypeScript o JavaScript. Se segui questa guida, accetta il nome predefinito e genera esempi TypeScript.
Lo strumento creerà quindi un file
dataconnect/schema_resolver/schema.gqlvuoto e aggiungerà la nuova configurazione del resolver al filedataconnect.yaml.Aggiorna questo file
schema.gqlcon uno schema GraphQL che definisca le query e le mutazioni che il resolver personalizzato fornirà. Ad esempio, ecco uno schema per un resolver personalizzato che può recuperare e aggiornare il profilo pubblico di un utente, memorizzato in un datastore diverso da 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 }
Implementa la logica del resolver personalizzato
Successivamente, implementa i resolver utilizzando Cloud Functions. Dietro le quinte, creerai un server GraphQL. Tuttavia, Cloud Functions dispone di un metodo helper, onGraphRequest, che gestisce i dettagli di questa operazione, quindi dovrai solo scrivere la logica del resolver che accede all'origine dati.
Apri il file
functions/src/index.ts.Quando hai eseguito
firebase init dataconnect:resolversopra, il comando ha creato questa directory del codice sorgente di Cloud Functions e l'ha inizializzata con il codice campione inindex.ts.Aggiungi le seguenti definizioni:
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);
Queste implementazioni scheletriche mostrano la forma generale che deve assumere una funzione di risoluzione. Per creare un resolver personalizzato completamente funzionante, devi compilare le sezioni commentate con il codice che legge e scrive nell'origine dati.
Utilizzare resolver personalizzati in query e mutazioni
Ora che hai definito lo schema del resolver personalizzato e implementato la logica che lo supporta, puoi utilizzare il resolver personalizzato nelle query e nelle mutazioni SQL Connect. In seguito, utilizzerai queste operazioni per generare automaticamente un SDK client personalizzato che puoi utilizzare per accedere a tutti i tuoi dati, supportati da Cloud SQL, dai tuoi resolver personalizzati o da una combinazione.
In
dataconnect/example/queries.gql, aggiungi la seguente definizione:query GetPublicProfile($id: String!) @auth(level: PUBLIC, insecureReason: "Anyone can see a public profile.") { publicProfile(userId: $id) { name photoUrl bioLine } }Questa query recupera il profilo pubblico di un utente utilizzando il resolver personalizzato.
In
dataconnect/example/mutations.gql, aggiungi la seguente definizione: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 } }Questa mutazione scrive un nuovo insieme di dati del profilo nel datastore, utilizzando di nuovo il resolver personalizzato. Tieni presente che lo schema utilizza la direttiva SQL Connect di
@authper garantire che gli utenti possano aggiornare solo i propri profili. Poiché accedi al tuo datastore tramite SQL Connect, puoi usufruire automaticamente di funzionalità di SQL Connect come questa.
Negli esempi precedenti, hai definito SQL Connect operazioni che accedono ai dati dal tuo datastore utilizzando i resolver personalizzati. Tuttavia, le operazioni non sono limitate all'accesso ai dati da Cloud SQL o da una singola origine dati personalizzata. Consulta la sezione Esempi per alcuni casi d'uso più avanzati che combinano dati provenienti da più origini.
Prima di ciò, continua con la sezione successiva per vedere i tuoi resolver personalizzati in azione.
Esegui il deployment del resolver e delle operazioni personalizzati
Come per qualsiasi modifica agli schemi SQL Connect, devi eseguire il deployment affinché abbiano effetto. Prima di farlo, esegui il deployment della logica del resolver personalizzato che hai implementato utilizzando Cloud Functions:
firebase deploy --only functionsOra puoi eseguire il deployment degli schemi e delle operazioni aggiornati:
firebase deploy --only dataconnectDopo aver apportato modifiche agli schemi SQL Connect, devi anche generare nuovi SDK client:
firebase dataconnect:sdk:generateEsempi
Questi esempi mostrano come implementare alcuni casi d'uso più avanzati e come evitare le insidie più comuni.
Autorizzazione dell'accesso a un resolver personalizzato utilizzando i dati di Cloud SQL
Uno dei vantaggi dell'integrazione delle origini dati in SQL Connect utilizzando resolver personalizzati è che puoi scrivere operazioni che combinano le origini dati.
In questo esempio, supponiamo che tu stia creando un'app di social media e che tu abbia implementato una mutazione come resolver personalizzato che invia un'email di sollecito all'amico di un utente se non interagisce con l'utente da un po' di tempo.
Per implementare la funzionalità di sollecito, crea un resolver personalizzato con uno schema come il seguente:
# A GraphQL server must define a root query type per the spec.
type Query {
unused: String
}
type Mutation {
sendEmail(id: String!, content: String): Boolean
}
Questa definizione è supportata da una funzione Cloud, ad esempio la seguente:
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);
Poiché l'invio di email è costoso e un potenziale vettore di abusi,
devi assicurarti che il destinatario previsto sia già nell'elenco amici dell'utente
prima di utilizzare il tuo resolver personalizzato sendEmail.
Supponiamo che nella tua app i dati dell'elenco amici siano archiviati in 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!
}
Puoi scrivere una mutazione che esegue prima una query su Cloud SQL per assicurarsi che il mittente si trovi nell'elenco degli amici del destinatario prima di utilizzare il resolver personalizzato per inviare l'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!")
}
Come nota a margine, questo esempio illustra anche che un'origine dati nel contesto dei resolver personalizzati può includere risorse diverse dai database e sistemi simili. In questo esempio, l'origine dati è un servizio di invio di email cloud.
Garantire l'esecuzione sequenziale utilizzando le mutazioni
Quando combini le origini dati, spesso devi assicurarti che una richiesta a un'origine dati venga completata prima di effettuare una richiesta a un'origine dati diversa. Ad esempio, supponiamo di avere una query che trascrive dinamicamente un video on demand utilizzando un'API AI. Una chiamata API come questa può essere costosa, quindi devi limitarla in base a determinati criteri, ad esempio che l'utente sia il proprietario del video o che abbia acquistato una sorta di crediti premium nella tua app.
Un primo tentativo per raggiungere questo obiettivo potrebbe avere il seguente aspetto:
# 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)
}
}
Questo approccio non funzionerà perché l'ordine di esecuzione dei campi della query non è garantito; il server GraphQL si aspetta di poter risolvere i campi in qualsiasi ordine, per massimizzare la concorrenza. D'altra parte, i campi di una mutazione vengono sempre risolti in ordine, perché il server GraphQL prevede che alcuni campi di una mutazione potrebbero avere effetti collaterali quando vengono risolti.
Anche se il primo passaggio dell'operazione di esempio non ha effetti collaterali, puoi definire l'operazione come mutazione per sfruttare il fatto che i campi di mutazione vengono risolti in ordine:
# 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)
}
}
Limitazioni
La funzionalità di resolver personalizzati viene rilasciata come anteprima pubblica sperimentale. Tieni presenti le seguenti limitazioni attuali:
Nessuna espressione CEL negli argomenti del resolver personalizzato
Non puoi utilizzare le espressioni CEL in modo dinamico negli argomenti di un resolver personalizzato. Ad esempio, non è possibile quanto segue:
mutation UpdateMyProfile($newName: String!) @auth(level: USER) {
updateMongoDocument(
collection: "profiles"
# This isn't supported:
id_expr: "auth.uid"
update: { name: $newName }
)
}
Passa invece le variabili standard (ad esempio $authUid) e convalidale a livello di operazione utilizzando la direttiva @auth(expr: ...) valutata in modo sicuro.
mutation UpdateMyProfile(
$newName: String!, $authUid: String!
) @auth(expr: "vars.authUid == auth.uid") {
updateMongoDocument(
collection: "profiles"
id: $authUid
update: { name: $newName }
)
}
Un'altra soluzione alternativa consiste nello spostare tutta la logica in un resolver personalizzato e completare tutte le operazioni sui dati da Cloud Functions.
Ad esempio, considera questo esempio, che al momento non funziona:
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.
)
}
Sposta invece sia la query SQL di Cloud sia la chiamata al servizio email in un unico campo di mutazione, supportato da una funzione:
mutation ForwardToEmail($chatMessageId: UUID!) @auth(level: USER_EMAIL_VERIFIED) {
forwardChatToEmail(
chatMessageId: $chatMessageId
)
}
Genera un SDK Admin per il tuo database e utilizzalo nella funzione per eseguire la query SQL di 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);
Nessun tipo di oggetto di input nei parametri del resolver personalizzato
I resolver personalizzati non accettano tipi di input GraphQL complessi. I parametri devono essere
tipi scalari di base (String, Int, Date, Any e così via) e 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
}
I resolver personalizzati non possono precedere le operazioni SQL
In una mutazione, il posizionamento di un resolver personalizzato prima delle operazioni SQL standard genera un errore. Tutte le operazioni basate su SQL devono essere visualizzate prima di qualsiasi chiamata di resolver personalizzato.
Nessuna transazione (@transaction)
I resolver personalizzati non possono essere racchiusi in un blocco @transaction con operazioni SQL standard. Se la funzione Cloud che supporta il resolver non va a buon fine dopo l'inserimento di SQL, il database non verrà eseguito automaticamente il rollback.
Per ottenere la sicurezza transazionale tra SQL e un'altra origine dati, sposta la logica dell'operazione SQL all'interno della funzione Cloud e gestisci la convalida e i rollback utilizzando l'SDK Admin o le connessioni SQL dirette.