Si escribes resolutores personalizados, puedes extender Firebase Data Connect para que admita 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 son de 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 consultas y mutaciones de Data Connect 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 archivos en Cloud Storage con datos almacenados en Cloud SQL.
- SDKs cliente con seguridad de tipos para la Web, Android y iOS
- Consultas que devuelven datos de varias fuentes
- Invocaciones de funciones restringidas según el estado de tu base de datos
Requisitos previos
Para escribir tus propios solucionadores personalizados, necesitas lo siguiente:
- Firebase CLI v15.9.0 o posterior
- SDK de Firebase Functions v7.1.0 o posterior
Además, debes saber cómo escribir funciones con Cloud Functions para Firebase, que es la forma en que implementarás la lógica de tus solucionadores personalizados.
Antes de comenzar
Ya deberías tener un proyecto configurado para usar Data Connect.
Si aún no lo hiciste, puedes seguir una de las guías de inicio rápido para configurar tu entorno:
Escribe resolutores personalizados
En términos generales, escribir un solucionador personalizado tiene tres partes: primero, definir un esquema para tu solucionador personalizado; segundo, implementar tus solucionadores con Cloud Functions; y, por último, usar tus campos de solucionador personalizado en consultas y mutaciones, posiblemente en conjunto con Cloud SQL o con otros solucionadores personalizados.
Sigue los pasos de las próximas secciones para aprender a hacerlo. Como ejemplo motivador, supongamos que tienes información de perfil pública de tus usuarios almacenada fuera de Cloud SQL. En estos ejemplos, no se especifica el almacén de datos exacto, 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 esquelética de un solucionador personalizado que puede incorporar esa información de perfil externa a Data Connect.
Define el esquema de tu resolver personalizado
En el directorio de tu proyecto de Firebase, ejecuta el siguiente comando:
firebase init dataconnect:resolverFirebase CLI te pedirá que ingreses un nombre para tu solucionador personalizado y te preguntará si deseas generar implementaciones de ejemplo del solucionador 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 solucionador al archivodataconnect.yaml.Actualiza este archivo
schema.gqlcon un esquema de GraphQL que defina las consultas y las mutaciones que proporcionará tu resolver personalizado. Por ejemplo, a continuación, se muestra un esquema para un solucionador personalizado que puede recuperar y actualizar el perfil público de un usuario, almacenado en un almacén de datos que no es 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 solucionador personalizado
A continuación, implementa tus solucionadores con Cloud Functions. En segundo plano, crearás un servidor de GraphQL. Sin embargo, Cloud Functions tiene un método auxiliar, onGraphRequest, que controla los detalles de esta tarea, por lo que solo deberás escribir la lógica del resolver 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 tener una función de resolución. Para crear un solucionador personalizado que funcione correctamente, 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 resolver personalizado y que implementaste la lógica que lo respalda, puedes usar el resolver personalizado en tus consultas y mutaciones de Data Connect. Más adelante, usarás estas operaciones para generar automáticamente un SDK de cliente personalizado que podrás usar para acceder a todos tus datos, ya sea que estén respaldados por Cloud SQL, tus resolutores personalizados o una combinación de ambos.
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 solucionador 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 resolver personalizado. Ten en cuenta que el esquema usa la directiva
@authde Data Connect para garantizar que los usuarios solo puedan actualizar sus propios perfiles. Como accedes a tu almacén de datos a través de Data Connect, puedes aprovechar automáticamente las funciones de Data Connect, como esta.
En los ejemplos anteriores, definiste operaciones de Data Connect que acceden a los datos de tu almacén de datos con tus resolutores personalizados. Sin embargo, tus operaciones no se limitan al acceso a datos desde Cloud SQL o desde 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 solucionador y tus operaciones personalizados
Al igual que cuando realizas cambios en tus esquemas de Data Connect, debes implementarlos para que surtan efecto. Antes de hacerlo, primero implementa la lógica del solucionador 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 esquemas de Data Connect, también debes generar nuevos SDKs de 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 solucionador personalizado con datos de Cloud SQL
Uno de los beneficios de integrar tus fuentes de datos en Data 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 resolver personalizado que envía un correo electrónico de sugerencia al amigo de un usuario si no interactuó con él en algún tiempo.
Para implementar la función de sugerencias, crea un solucionador 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 se basa en una Cloud Function, 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 y puede ser un vector de abuso, debes asegurarte de que el destinatario previsto ya esté en la lista de amigos del usuario antes de usar tu solucionador 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 garantizar que el remitente esté en la lista de amigos del destinatario antes de usar el resolver 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!")
}
Como nota aparte, este ejemplo también ilustra que una fuente de datos en el contexto de los solucionadores personalizados puede incluir recursos que no sean bases de datos ni sistemas similares. En este ejemplo, la fuente de datos es un servicio de envío de correos electrónicos en la nube.
Cómo garantizar 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 otra fuente de datos. Por ejemplo, supongamos que tienes una búsqueda que transcribe de forma dinámica un video on demand con una API de IA. Una llamada a la API como esta puede ser costosa, por lo que te conviene restringirla con 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 así:
# 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 la consulta; el servidor de 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, ya que el servidor de 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 resolución personalizada se lanzó como versión preliminar pública experimental. Ten en cuenta las siguientes limitaciones actuales:
No hay expresiones CEL en los argumentos del solucionador personalizado
No puedes usar expresiones CEL de forma dinámica en los argumentos de un solucionador 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 trasladar toda tu lógica a un solucionador 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 de 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 administrador para tu base de datos y úsalo en la función para realizar la consulta de 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);
No hay tipos de objetos de entrada en los parámetros del solucionador personalizado
Los solucionadores personalizados no aceptan tipos de entrada complejos de GraphQL. Los parámetros deben ser tipos escalares básicos (String, Int, Date, Any, etc.) 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 solucionadores personalizados no pueden preceder a las operaciones de SQL
En una mutación, colocar un resolver 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 un solucionador personalizado.
No hay transacciones (@transaction)
Los solucionadores personalizados no se pueden incluir dentro de un bloque @transaction con operaciones de SQL estándar. Si la Cloud Function que respalda el solucionador falla después de que se realiza correctamente una inserción de SQL, la base de datos no se revertirá automáticamente.
Para lograr la seguridad transaccional entre SQL y otra fuente de datos, mueve la lógica de operación de SQL dentro de la Cloud Function y controla la validación y las reversiones con el SDK de Admin o las conexiones SQL directas.