Ampliar o SQL Connect com resoluções personalizadas

Ao criar resolvedores personalizados, você pode estender Firebase SQL Connect para oferecer suporte a outras fontes de dados além do Cloud SQL. Em seguida, é possível combinar várias fontes de dados (Cloud SQL e as fontes de dados fornecidas pelos resolvedores personalizados) em uma única consulta ou mutação.

O conceito de "fonte de dados" é flexível. Ele inclui:

  • Bancos de dados que não sejam o Cloud SQL, como o Cloud Firestore, o MongoDB e outros.
  • Serviços de armazenamento, como o Cloud Storage, o AWS S3 e outros.
  • Qualquer integração baseada em API, como Stripe, SendGrid, Salesforce e outras.
  • Lógica de negócios personalizada.

Depois de criar resolvedores personalizados para oferecer suporte a outras fontes de dados, suas consultas e mutações SQL Connect poderão combiná-las de várias maneiras, oferecendo benefícios como:

  • Uma camada de autorização unificada para suas fontes de dados. Por exemplo, autorize o acesso a arquivos no Cloud Storage usando dados armazenados no Cloud SQL.
  • SDKs de cliente com segurança de tipo para Web, Android e iOS.
  • Consultas que retornam dados de várias fontes.
  • Invocações de função restritas com base no estado do banco de dados.

Pré-requisitos

Para criar seus próprios resolvedores personalizados, você precisa do seguinte:

  • CLI do Firebase v15.9.0 ou mais recente
  • SDK do Firebase Functions v7.1.0 ou mais recente

Além disso, você precisa estar familiarizado com a criação de funções usando Cloud Functions para Firebase, que é como você vai implementar a lógica dos resolvedores personalizados.

Antes de começar

Você já precisa ter um projeto configurado para usar SQL Connect.

Siga um dos guias de início rápido para configurar, caso ainda não tenha feito isso:

Criar resolvedores personalizados

Em um nível alto, a criação de um resolvedor personalizado tem três partes: primeiro, definir um esquema para o resolvedor personalizado; segundo, implementar os resolvedores usando o Cloud Functions; e, por fim, usar os campos do resolvedor personalizado em consultas e mutações, possivelmente em conjunto com o Cloud SQL ou outros resolvedores personalizados.

Siga as etapas nas próximas seções para aprender a fazer isso. Como um exemplo motivador, suponha que você tenha informações de perfil público dos seus usuários armazenadas fora do Cloud SQL. O armazenamento de dados exato não é especificado nesses exemplos, mas pode ser algo como o Cloud Storage, uma instância do MongoDB ou qualquer outra coisa.

As seções a seguir demonstram uma implementação de esqueleto de um resolvedor personalizado que pode trazer essas informações de perfil externo para SQL Connect.

Definir o esquema do resolvedor personalizado

  1. No diretório do projeto do Firebase, execute:

    firebase init dataconnect:resolver

    A CLI do Firebase vai solicitar um nome para o resolvedor personalizado e perguntar se você quer gerar implementações de resolvedores de exemplo em TypeScript ou JavaScript. Se você estiver seguindo este guia, aceite o nome padrão e gere exemplos do TypeScript.

    A ferramenta vai criar um arquivo dataconnect/schema_resolver/schema.gql vazio e adicionar a nova configuração do resolvedor ao arquivo dataconnect.yaml.

  2. Atualize esse arquivo schema.gql com um esquema GraphQL que defina as consultas e mutações que o resolvedor personalizado vai fornecer. Por exemplo, aqui está um esquema para um resolvedor personalizado que pode recuperar e atualizar o perfil público de um usuário, armazenado em um armazenamento de dados diferente do 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
    }
    

Implementar a lógica do resolvedor personalizado

Em seguida, implemente os resolvedores usando o Cloud Functions. Nos bastidores, você vai criar um servidor GraphQL. No entanto, o Cloud Functions tem um método auxiliar, onGraphRequest, que processa os detalhes dessa ação. Portanto, você só precisa criar a lógica do resolvedor que acessa sua fonte de dados.

  1. Abra o arquivo functions/src/index.ts.

    Quando você executou firebase init dataconnect:resolver acima, o comando criou esse diretório de código-fonte do Cloud Functions e o inicializou com um exemplo de código em index.ts.

  2. Adicione as seguintes definições:

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

Essas implementações de esqueleto mostram o formato geral que uma função de resolvedor precisa assumir. Para criar um resolvedor personalizado totalmente funcional, você precisa preencher as seções comentadas com um código que lê e grava na sua fonte de dados.

Usar resolvedores personalizados em consultas e mutações

Agora que você definiu o esquema do resolvedor personalizado e implementou a lógica que o oferece suporte, é possível usar o resolvedor personalizado nas SQL Connect consultas e mutações. Mais tarde, você vai usar essas operações para gerar automaticamente um SDK de cliente personalizado que pode ser usado para acessar todos os dados, com suporte do Cloud SQL, dos resolvedores personalizados ou de uma combinação.

  1. Em dataconnect/example/queries.gql, adicione a seguinte definição:

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

    Essa consulta recupera o perfil público de um usuário usando o resolvedor personalizado.

  2. Em dataconnect/example/mutations.gql, adicione a seguinte definição:

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

    Essa mutação grava um novo conjunto de dados de perfil no armazenamento de dados, novamente usando o resolvedor personalizado. O esquema usa a diretiva SQL Connect's @auth para garantir que os usuários só possam atualizar os próprios perfis. Como você está acessando o armazenamento de dados pelo SQL Connect, pode aproveitar automaticamente os recursos do SQL Connect, como esse.

Nos exemplos acima, você definiu operações SQL Connect que acessam dados do armazenamento de dados usando os resolvedores personalizados. No entanto, suas operações não se limitam ao acesso a dados do Cloud SQL ou de uma única fonte de dados personalizada. Consulte a seção Exemplos para conferir alguns casos de uso mais avançados que combinam dados de várias fontes.

Antes disso, continue para a próxima seção para conferir os resolvedores personalizados em ação.

Implantar o resolvedor personalizado e as operações

Assim como ao fazer mudanças nos seus esquemas SQL Connect, você precisa implantá-los para que entrem em vigor. Antes de fazer isso, implante a lógica do resolvedor personalizado que você implementou usando o Cloud Functions:

firebase deploy --only functions

Agora você pode implantar os esquemas e operações atualizados:

firebase deploy --only dataconnect

Depois de fazer mudanças nos esquemas do SQL Connect, você também precisa gerar novos SDKs de cliente:

firebase dataconnect:sdk:generate

Exemplos

Esses exemplos mostram como implementar alguns casos de uso mais avançados e como evitar armadilhas comuns.

Autorizar o acesso a um resolvedor personalizado usando dados do Cloud SQL

Um dos benefícios de integrar suas fontes de dados ao SQL Connect usando resolvedores personalizados é que você pode criar operações que combinam fontes de dados.

Neste exemplo, suponha que você esteja criando um app de mídia social e tenha uma mutação implementada como um resolvedor personalizado que envia um e-mail de alerta para o amigo de um usuário se ele não tiver interagido com o usuário por algum tempo.

Para implementar o recurso de alerta, crie um resolvedor personalizado com um esquema como o seguinte:

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

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

Essa definição é apoiada por uma função do Cloud, como a seguinte:

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

Como o envio de e-mails é caro e um vetor potencial de abuso, você precisa garantir que o destinatário pretendido já esteja na lista de amigos do usuário antes de usar o resolvedor personalizado sendEmail.

Suponha que, no seu app, os dados da lista de amigos sejam armazenados no 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!
}

Você pode criar uma mutação que primeiro consulta o Cloud SQL para garantir que o remetente esteja na lista de amigos do destinatário antes de usar o resolvedor personalizado para enviar o 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!")
}

Além disso, esse exemplo também ilustra que uma fonte de dados no contexto de resolvedores personalizados pode incluir recursos diferentes de bancos de dados e sistemas semelhantes. Neste exemplo, a fonte de dados é um serviço de envio de e-mails na nuvem.

Garantir a execução sequencial usando mutações

Ao combinar fontes de dados, muitas vezes é necessário garantir que uma solicitação para uma fonte de dados seja concluída antes de fazer uma solicitação para uma fonte de dados diferente. Por exemplo, suponha que você tenha uma consulta que transcreva dinamicamente um vídeo on demand usando uma API de IA. Uma chamada de API como essa pode ser cara. Portanto, você precisa limitar a chamada por alguns critérios, como o usuário ser proprietário do vídeo ou ter comprado algum tipo de crédito premium no seu app.

Uma primeira tentativa de alcançar isso pode ser algo como:

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

Essa abordagem não funciona porque a ordem de execução dos campos de consulta não é garantida. O servidor GraphQL espera poder resolver campos em qualquer ordem para maximizar a simultaneidade. Por outro lado, os campos de uma mutação são sempre resolvidos em ordem, porque o servidor GraphQL espera que alguns campos de uma mutação possam ter efeitos colaterais quando resolvidos.

Embora a primeira etapa da operação de exemplo não tenha efeitos colaterais, você pode definir a operação como uma mutação para aproveitar o fato de que os campos de mutação são resolvidos em ordem:

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

Limitações

O recurso de resolvedores personalizados é lançado como um acesso antecipado público experimental. Observe as seguintes limitações atuais:

Nenhuma expressão CEL em argumentos de resolvedor personalizados

Não é possível usar expressões CEL dinamicamente nos argumentos de um resolvedor personalizado. Por exemplo, o seguinte não é possível:

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

Em vez disso, transmita variáveis padrão (por exemplo, $authUid) e valide-as no nível da operação usando a diretiva @auth(expr: ...) avaliada com segurança.

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

Outra solução alternativa é mover toda a lógica para um resolvedor personalizado e concluir todas as operações de dados no Cloud Functions.

Por exemplo, considere este exemplo, que não funciona no momento:

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

Em vez disso, mova a consulta SQL do Cloud SQL e a chamada para o serviço de e-mail para um campo de mutação, com suporte de uma função:

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

Gere um SDK Admin para seu banco de dados e use-o na função para executar a consulta SQL do 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);

Nenhum tipo de objeto de entrada em parâmetros de resolvedor personalizados

Os resolvedores personalizados não aceitam tipos de entrada GraphQL complexos. Os parâmetros precisam ser tipos escalares básicos (String, Int, Date, Any etc.) e Enums.

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
}

Os resolvedores personalizados não podem preceder operações SQL

Em uma mutação, colocar um resolvedor personalizado antes das operações SQL padrão resulta em um erro. Todas as operações baseadas em SQL precisam aparecer antes de qualquer invocação de resolvedor personalizado.

Nenhuma transação (@transaction)

Os resolvedores personalizados não podem ser encapsulados em um bloco @transaction com operações SQL padrão. Se a função do Cloud que oferece suporte ao resolvedor falhar após uma inserção SQL bem-sucedida, o banco de dados não será revertido automaticamente.

Para alcançar a segurança transacional entre o SQL e outra fonte de dados, mova a lógica de operação SQL para dentro da função do Cloud e processe a validação e as reversões usando o SDK Admin ou conexões SQL diretas.