Ao escrever resoluções personalizadas, é possível estender o Firebase Data Connect para oferecer suporte a outras fontes de dados além do Cloud SQL. Em seguida, você pode combinar várias fontes de dados (Cloud SQL e as fontes fornecidas pelos seus resolutores personalizados) em uma única consulta ou mutação.
O conceito de "fonte de dados" é flexível. Ele inclui:
- Bancos de dados que não são do Cloud SQL, como Cloud Firestore, MongoDB e outros.
- Serviços de armazenamento, como Cloud Storage, AWS S3 e outros.
- Qualquer integração baseada em API, como Stripe, SendGrid, Salesforce e outras.
- Lógica de negócios personalizada.
Depois de escrever resoluções personalizadas para oferecer suporte às suas fontes de dados adicionais, as consultas e mutações Data Connect podem 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ção de função restrita com base no estado do banco de dados.
Pré-requisitos
Para gravar seus próprios resolutores 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 saber como escrever funções usando o Cloud Functions para Firebase, que é como você vai implementar a lógica dos seus resolutores personalizados.
Antes de começar
Você já precisa ter um projeto configurado para usar o Data Connect.
Siga um dos guias de início rápido para fazer a configuração, caso ainda não tenha feito isso:
Escrever resoluções personalizadas
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 saber como fazer isso. Por exemplo, suponha que você tenha informações de perfil público dos usuários armazenadas fora do Cloud SQL. O repositório 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 vão demonstrar uma implementação de esqueleto de um resolvedor personalizado que pode trazer essas informações de perfil externo para Data Connect.
Definir o esquema do seu resolvedor personalizado
No diretório do projeto do Firebase, execute:
firebase init dataconnect:resolverA CLI do Firebase vai pedir um nome para seu resolvedor personalizado e perguntar se você quer gerar exemplos de implementações de resolvedor em TypeScript ou JavaScript. Se você estiver seguindo este guia, aceite o nome padrão e gere exemplos em TypeScript.
Em seguida, a ferramenta vai criar um arquivo
dataconnect/schema_resolver/schema.gqlvazio e adicionar a nova configuração de resolver ao arquivodataconnect.yaml.Atualize esse arquivo
schema.gqlcom um esquema GraphQL que defina as consultas e mutações que seu resolvedor personalizado vai fornecer. Por exemplo, este é um esquema para um resolvedor personalizado que pode recuperar e atualizar o perfil público de um usuário, armazenado em um datastore 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 seus resolutores usando o Cloud Functions. Por baixo dos panos, você vai criar um servidor GraphQL. No entanto, o Cloud Functions tem um método auxiliar, onGraphRequest, que processa os detalhes dessa ação. Assim, você só precisa escrever a lógica do resolvedor que acessa sua fonte de dados.
Abra o arquivo
functions/src/index.ts.Quando você executou
firebase init dataconnect:resolveracima, o comando criou esse diretório de código-fonte do Cloud Functions e o inicializou com um exemplo de código emindex.ts.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 resolução precisa ter. Para criar um resolvedor personalizado totalmente funcional, preencha as seções comentadas com o código que lê e grava na sua fonte de dados.
Usar resoluções personalizadas em consultas e mutações
Agora que você definiu o esquema do seu resolvedor personalizado e implementou a lógica que o sustenta, é possível usar o resolvedor personalizado nas consultas e mutações do Data Connect. Mais tarde, você vai usar essas operações para gerar automaticamente um SDK de cliente personalizado que pode ser usado para acessar todos os seus dados, seja com suporte do Cloud SQL, seus resolvedores personalizados ou uma combinação.
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 seu resolvedor personalizado.
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 datastore, usando novamente seu resolver personalizado. O esquema usa a diretiva
@authdo Data Connect para garantir que os usuários só possam atualizar os próprios perfis. Como você está acessando seu armazenamento de dados pelo Data Connect, pode aproveitar automaticamente os recursos do Data Connect, como este.
Nos exemplos acima, você definiu operações Data Connect que acessam dados do seu repositório de dados usando seus resolutores personalizados. No entanto, você não está limitado a acessar dados do Cloud SQL ou de uma única fonte de dados personalizada. Consulte a seção Exemplos para ver alguns casos de uso mais avançados que combinam dados de várias fontes.
Antes disso, continue na próxima seção para ver seus resolutores personalizados em ação.
Implante seu resolvedor e operações personalizadas
Assim como ao fazer mudanças nos seus esquemas Data Connect, é necessário implantá-los para que entrem em vigor. Antes disso, implante a lógica do resolvedor personalizado que você implementou usando o Cloud Functions:
firebase deploy --only functionsAgora é possível implantar os esquemas e operações atualizados:
firebase deploy --only dataconnectDepois de fazer mudanças nos seus esquemas Data Connect, você também precisa gerar novos SDKs do cliente:
firebase dataconnect:sdk:generateExemplos
Esses exemplos mostram como implementar alguns casos de uso mais avançados e como evitar erros comuns.
Autorizar o acesso a um resolvedor personalizado usando dados do Cloud SQL
Um dos benefícios de integrar suas fontes de dados ao Data Connect usando resoluções personalizadas é que você pode escrever 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 interagir com o usuário há algum tempo.
Para implementar o recurso de sugestão, crie um resolvedor personalizado com um esquema como este:
# 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 esta:
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 para você e um possível vetor de abuso,
verifique se o destinatário pretendido já está na lista de amigos do usuário
antes de usar seu sendEmail resolvedor personalizado.
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 escrever 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 resolvers 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 a uma fonte seja concluída antes de fazer uma solicitação a outra. 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. Por isso, é recomendável restringir a chamada com base em 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 fazer isso pode ser parecida com esta:
# 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 resoluções personalizadas foi lançado como um pré-lançamento 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 de forma dinâmica nos argumentos de um resolvedor personalizado. Por exemplo, não é possível fazer o seguinte:
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 do Cloud Functions.
Por exemplo, considere este exemplo, que não vai funcionar 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 de administrador para seu banco de dados e use-o na função para realizar 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 nos parâmetros do resolvedor personalizado
Os resolvedores personalizados não aceitam tipos de entrada complexos do GraphQL. 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
}
Resolvers 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)
Não é possível encapsular resolvedores personalizados em um bloco @transaction com operações SQL padrão. Se a função do Cloud que oferece suporte ao resolvedor falhar depois que uma inserção
SQL for concluída, 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 do SQL para dentro da função do Cloud e processe a validação e os rollbacks usando o SDK Admin ou conexões SQL diretas.