Si escribes resolutores personalizados, puedes extender Firebase SQL Connect para admitir otras fuentes de datos además de Cloud SQL. Luego, puedes combinar varias fuentes de datos (Cloud SQL y las fuentes de datos que proporcionan tus resolutores personalizados) en una sola consulta o mutación.
El concepto de "fuente de datos" es flexible. Incluye lo siguiente:
- Bases de datos que no sean Cloud SQL, como Cloud Firestore, MongoDB y otras.
- Servicios de almacenamiento, como Cloud Storage, AWS S3 y otros
- Cualquier integración basada en la API, como Stripe, SendGrid, Salesforce y otras
- Lógica empresarial personalizada
Una vez que hayas escrito resolutores personalizados para admitir tus fuentes de datos adicionales, tus SQL Connect consultas y mutaciones podrán combinarlos de muchas maneras, lo que proporcionará beneficios como los siguientes:
- Una capa de autorización unificada para tus fuentes de datos (por ejemplo, autoriza el acceso a los archivos en Cloud Storage con los datos almacenados en Cloud SQL)
- SDKs cliente con seguridad de tipos para la Web, Android y iOS
- Consultas que muestran datos de varias fuentes
- Invocaciones de funciones restringidas según el estado de tu base de datos
Requisitos previos
Para escribir tus propios resolutores personalizados, necesitas lo siguiente:
- Firebase CLI v15.9.0 o posterior
- SDK de Firebase Functions v7.1.0 o posterior
Además, debes estar familiarizado con la escritura de funciones con Cloud Functions para Firebase, que es la forma en que implementarás la lógica de tus resolutores personalizados.
Antes de comenzar
Ya deberías tener un proyecto configurado para usar SQL Connect.
Puedes seguir una de las guías de inicio rápido para configurarlo si aún no lo hiciste:
Escribe resolutores personalizados
En un nivel superior, la escritura de un resolutor personalizado tiene tres partes: primero, definir un esquema para tu resolutor personalizado; segundo, implementar tus resolutores con Cloud Functions; y, por último, usar los campos de tu resolutor personalizado en consultas y mutaciones, posiblemente en conjunto con Cloud SQL o con otros resolutores personalizados.
Sigue los pasos de las siguientes secciones para aprender a hacerlo. Como ejemplo motivador, supongamos que tienes información de perfil público para tus usuarios almacenada fuera de Cloud SQL. No se especifica el almacén de datos exacto en estos ejemplos, pero podría ser algo como Cloud Storage, una instancia de MongoDB o cualquier otra cosa.
En las siguientes secciones, se mostrará una implementación de esqueleto de un resolutor personalizado que puede incorporar esa información de perfil externa a SQL Connect.
Define el esquema para tu resolutor personalizado
En el directorio de tu proyecto de Firebase, ejecuta lo siguiente:
firebase init dataconnect:resolverFirebase CLI te pedirá un nombre para tu resolutor personalizado y te preguntará si deseas generar implementaciones de resolutores de ejemplo en TypeScript o JavaScript. Si sigues esta guía, acepta el nombre predeterminado y genera ejemplos de TypeScript.
Luego, la herramienta creará un archivo
dataconnect/schema_resolver/schema.gqlvacío y agregará la nueva configuración del agente de resolución al archivodataconnect.yaml.Actualiza este archivo
schema.gqlcon un esquema de GraphQL que defina las consultas y mutaciones que proporcionará tu resolutor personalizado. Por ejemplo, este es un esquema para un resolutor personalizado que puede recuperar y actualizar el perfil público de un usuario, almacenado en un almacén de datos que no sea 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 lógica del resolutor personalizado
A continuación, implementa tus resolutores con Cloud Functions. En segundo plano, crearás un servidor GraphQL. Sin embargo, Cloud Functions tiene un método auxiliar, onGraphRequest, que controla los detalles para hacerlo, por lo que solo deberás escribir la lógica del resolutor que accede a tu fuente de datos.
Abre el archivo
functions/src/index.ts.Cuando ejecutaste
firebase init dataconnect:resolveranteriormente, el comando creó este directorio de código fuente de Cloud Functions y lo inicializó con código de muestra enindex.ts.Agrega las siguientes definiciones:
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);
Estas implementaciones de esqueleto muestran la forma general que debe adoptar una función de resolutor. Para crear un resolutor personalizado que funcione por completo, deberás completar las secciones comentadas con código que lea y escriba en tu fuente de datos.
Usa resolutores personalizados en consultas y mutaciones
Ahora que definiste el esquema de tu resolutor personalizado y que implementaste la lógica que lo respalda, puedes usar el resolutor personalizado en tus SQL Connect consultas y mutaciones. Más adelante, usarás estas operaciones para generar automáticamente un SDK cliente personalizado que puedes usar para acceder a todos tus datos, ya sea que estén respaldados por Cloud SQL, tus resolutores personalizados o una combinación.
En
dataconnect/example/queries.gql, agrega la siguiente definición:query GetPublicProfile($id: String!) @auth(level: PUBLIC, insecureReason: "Anyone can see a public profile.") { publicProfile(userId: $id) { name photoUrl bioLine } }Esta consulta recupera el perfil público de un usuario con tu resolutor personalizado.
En
dataconnect/example/mutations.gql, agrega la siguiente definición: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 } }Esta mutación escribe un nuevo conjunto de datos de perfil en el almacén de datos, nuevamente con tu resolutor personalizado. Ten en cuenta que el esquema usa la directiva SQL Connect's
@authpara garantizar que los usuarios solo puedan actualizar sus propios perfiles. Como accedes a tu almacén de datos a través de SQL Connect, puedes aprovechar automáticamente las funciones de SQL Connect, como esta.
En los ejemplos anteriores, definiste operaciones de SQL Connect que acceden a los datos de tu almacén de datos con tus resolutores personalizados. Sin embargo, tus operaciones no se limitan a acceder a los datos de Cloud SQL o de una sola fuente de datos personalizada. Consulta la sección Ejemplos para ver algunos casos de uso más avanzados que combinan datos de varias fuentes.
Antes de eso, continúa con la siguiente sección para ver tus resolutores personalizados en acción.
Implementa tu resolutor personalizado y tus operaciones
Al igual que cuando realizas cambios en tus esquemas SQL Connect, debes implementarlos para que surtan efecto. Antes de hacerlo, primero implementa la lógica del resolutor personalizado que implementaste con Cloud Functions:
firebase deploy --only functionsAhora puedes implementar los esquemas y las operaciones actualizados:
firebase deploy --only dataconnectDespués de realizar cambios en tus SQL Connect esquemas, también debes generar nuevos SDKs cliente:
firebase dataconnect:sdk:generateEjemplos
En estos ejemplos, se muestra cómo implementar algunos casos de uso más avanzados y cómo evitar errores comunes.
Autoriza el acceso a un resolutor personalizado con datos de Cloud SQL
Uno de los beneficios de integrar tus fuentes de datos en SQL Connect con resolutores personalizados es que puedes escribir operaciones que combinen fuentes de datos.
En este ejemplo, supongamos que estás compilando una app de redes sociales y tienes una mutación implementada como un resolutor personalizado que envía un correo electrónico de aviso al amigo de un usuario si no interactuó con él en algún tiempo.
Para implementar la función de sugerencia, crea un resolutor personalizado con un esquema como el siguiente:
# A GraphQL server must define a root query type per the spec.
type Query {
unused: String
}
type Mutation {
sendEmail(id: String!, content: String): Boolean
}
Esta definición está respaldada por una función de Cloud Functions, como la siguiente:
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);
Dado que enviar correos electrónicos es costoso para ti y un vector potencial de abuso, debes asegurarte de que el destinatario deseado ya esté en la lista de amigos del usuario antes de usar tu resolutor personalizado sendEmail.
Supongamos que, en tu app, los datos de la lista de amigos se almacenan en 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!
}
Puedes escribir una mutación que primero consulte Cloud SQL para asegurarte de que el remitente esté en la lista de amigos del destinatario antes de usar el resolutor personalizado para enviar el correo electrónico:
# 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!")
}
Además, este ejemplo también ilustra que una fuente de datos en el contexto de los resolutores personalizados puede incluir recursos que no sean bases de datos y sistemas similares. En este ejemplo, la fuente de datos es un servicio de envío de correo electrónico en la nube.
Garantiza la ejecución secuencial con mutaciones
Cuando combines fuentes de datos, a menudo deberás asegurarte de que se complete una solicitud a una fuente de datos antes de realizar una solicitud a una fuente de datos diferente. Por ejemplo, supongamos que tienes una consulta que transcribe de forma dinámica un video a pedido con una API de IA. Una llamada a la API como esta puede ser costosa, por lo que debes restringir la llamada detrás de algunos criterios, como que el usuario sea propietario del video o que haya comprado algún tipo de créditos premium en tu app.
Un primer intento para lograr esto podría verse de la siguiente manera:
# 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)
}
}
Este enfoque no funcionará porque no se garantiza el orden de ejecución de los campos de consulta; el servidor GraphQL espera poder resolver los campos en cualquier orden para maximizar la simultaneidad. Por otro lado, los campos de una mutación siempre se resuelven en orden, porque el servidor GraphQL espera que algunos campos de una mutación puedan tener efectos secundarios cuando se resuelven.
Aunque el primer paso de la operación de ejemplo no tiene efectos secundarios, puedes definir la operación como una mutación para aprovechar el hecho de que los campos de mutación se resuelven en orden:
# 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)
}
}
Limitaciones
La función de resolutores personalizados se lanza como una versión preliminar pública experimental. Ten en cuenta las siguientes limitaciones actuales:
No hay expresiones CEL en los argumentos del resolutor personalizado
No puedes usar expresiones CEL de forma dinámica en los argumentos de un resolutor personalizado. Por ejemplo, no es posible lo siguiente:
mutation UpdateMyProfile($newName: String!) @auth(level: USER) {
updateMongoDocument(
collection: "profiles"
# This isn't supported:
id_expr: "auth.uid"
update: { name: $newName }
)
}
En su lugar, pasa variables estándar (por ejemplo, $authUid) y valídalas a nivel de la operación con la directiva @auth(expr: ...) evaluada de forma segura.
mutation UpdateMyProfile(
$newName: String!, $authUid: String!
) @auth(expr: "vars.authUid == auth.uid") {
updateMongoDocument(
collection: "profiles"
id: $authUid
update: { name: $newName }
)
}
Otra solución alternativa es mover toda tu lógica a un resolutor personalizado y completar todas tus operaciones de datos desde Cloud Functions.
Por ejemplo, considera este ejemplo, que no funcionará actualmente:
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.
)
}
En su lugar, mueve la consulta Cloud SQL y la llamada al servicio de correo electrónico a un campo de mutación, respaldado por una función:
mutation ForwardToEmail($chatMessageId: UUID!) @auth(level: USER_EMAIL_VERIFIED) {
forwardChatToEmail(
chatMessageId: $chatMessageId
)
}
Genera un SDK de Admin para tu base de datos y úsalo en la función para realizar la Cloud SQL consulta:
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);
No hay tipos de objetos de entrada en los parámetros del resolutor personalizado
Los resolutores personalizados no aceptan tipos de entrada complejos de GraphQL. Los parámetros deben ser tipos escalares básicos (String, Int, Date, Any, etcétera) y 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
}
Los resolutores personalizados no pueden preceder a las operaciones de SQL
En una mutación, colocar un resolutor personalizado antes de las operaciones de SQL estándar genera un error. Todas las operaciones basadas en SQL deben aparecer antes de cualquier invocación de resolutor personalizado.
No hay transacciones (@transaction)
Los resolutores personalizados no se pueden incluir dentro de un bloque @transaction con operaciones estándar de SQL. Si falla la función de Cloud Functions que respalda el resolutor después de que se realiza correctamente una inserción de SQL, la base de datos no revertirá automáticamente.
Para lograr la seguridad transaccional entre SQL y otra fuente de datos, mueve la lógica de la operación de SQL dentro de la función de Cloud Functions y controla la validación y las reversiones con el SDK de Admin o las conexiones directas de SQL.