Estendere Data Connect con resolver personalizzati

Scrivendo resolver personalizzati, puoi estendere Firebase Data 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 Data Connect possono combinarle in molti modi, offrendo vantaggi quali:

  • Un livello di autorizzazione unificato per le 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 versioni 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 Data 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 fare. Come esempio motivante, supponiamo di avere informazioni del profilo pubblico per i tuoi utenti archiviate al di fuori di Cloud SQL. Il datastore esatto non è specificato in questi esempi, ma potrebbe essere Cloud Storage, un'istanza MongoDB o qualsiasi altro datastore.

Le seguenti sezioni mostreranno un'implementazione scheletrica di un resolver personalizzato che può importare le informazioni del profilo esterno in Data Connect.

Definisci lo schema per il resolver personalizzato

  1. Nella directory del progetto Firebase, esegui:

    firebase init dataconnect:resolver

    L'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.gql vuoto e aggiungerà la nuova configurazione del resolver al file dataconnect.yaml.

  2. Aggiorna questo file schema.gql con 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. Sotto il cofano, creerai un server GraphQL. Tuttavia, Cloud Functions ha un metodo helper, onGraphRequest, che gestisce i dettagli di questa operazione, quindi dovrai solo scrivere la logica del resolver che accede all'origine dati.

  1. Apri il file functions/src/index.ts.

    Quando hai eseguito firebase init dataconnect:resolver sopra, il comando ha creato questa directory del codice sorgente di Cloud Functions e l'ha inizializzata con il codice campione in index.ts.

  2. 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 Data 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.

  1. 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 tuo resolver personalizzato.

  2. 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 @auth di Data Connect per garantire che gli utenti possano aggiornare solo i propri profili. Poiché accedi al tuo datastore tramite Data Connect, puoi usufruire automaticamente di funzionalità di Data Connect come questa.

Negli esempi precedenti, hai definito Data 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 Data Connect, devi eseguirne 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 functions

Ora puoi eseguire il deployment degli schemi e delle operazioni aggiornati:

firebase deploy --only dataconnect

Dopo aver apportato modifiche agli schemi Data Connect, devi anche generare nuovi SDK client:

firebase dataconnect:sdk:generate

Esempi

Questi esempi mostrano come implementare alcuni casi d'uso più avanzati e come evitare errori comuni.

Autorizzazione dell'accesso a un resolver personalizzato utilizzando i dati di Cloud SQL

Uno dei vantaggi dell'integrazione delle origini dati in Data 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 una mutazione implementata come resolver personalizzato che invia un'email di notifica all'amico di un utente se non interagisce con l'utente da un po' di tempo.

Per implementare la funzionalità di suggerimento, 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);

L'invio di email è costoso e può essere un potenziale vettore di abusi, pertanto devi assicurarti che il destinatario previsto sia già presente 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 un qualche tipo 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 prevede 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 Cloud SQL 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 amministratore per il tuo database e utilizzalo nella funzione per eseguire la query 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 inclusi in un blocco @transaction con operazioni SQL standard. Se la funzione Cloud che supporta il resolver non funziona 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.