Расширьте возможности подключения к данным с помощью пользовательских обработчиков запросов.

Создавая собственные резолверы, вы можете расширить функциональность Firebase Data Connect , добавив поддержку других источников данных помимо Cloud SQL. Затем вы можете объединить несколько источников данных (Cloud SQL и источники данных, предоставляемые вашими пользовательскими резолверами) в один запрос или мутацию.

Понятие «источник данных» является гибким. Оно включает в себя:

  • Помимо Cloud SQL, можно использовать и другие базы данных, такие как Cloud Firestore, MongoDB и другие.
  • Сервисы хранения данных, такие как Cloud Storage, AWS S3 и другие.
  • Любая интеграция на основе API, например, Stripe, SendGrid, Salesforce и другие.
  • Пользовательская бизнес-логика.

После того как вы напишете пользовательские обработчики для поддержки дополнительных источников данных, ваши запросы и мутации Data Connect смогут комбинировать их различными способами, предоставляя такие преимущества, как:

  • Единый уровень авторизации для ваших источников данных. Например, авторизуйте доступ к файлам в Cloud Storage, используя данные, хранящиеся в Cloud SQL.
  • Типобезопасные клиентские SDK для веб-приложений, Android и iOS.
  • Запросы, возвращающие данные из нескольких источников.
  • Ограничение на вызовы функций в зависимости от состояния вашей базы данных.

Предварительные требования

Для написания собственных пользовательских обработчиков событий вам потребуется следующее:

  • Firebase CLI версии 15.9.0 или выше.
  • Firebase Functions SDK версии 7.1.0 или выше

Кроме того, вам следует ознакомиться с написанием функций с использованием Cloud Functions for Firebase , именно так вы будете реализовывать логику ваших пользовательских резолверов.

Прежде чем начать

У вас уже должен быть настроен проект для использования Data Connect .

Если вы еще этого не сделали, вы можете воспользоваться одним из руководств по быстрой настройке:

Напишите собственные обработчики событий

В общих чертах, написание пользовательского резолвера состоит из трех частей: во-первых, определение схемы для вашего пользовательского резолвера; во-вторых, реализация ваших резолверов с использованием Cloud Functions; и, наконец, использование полей вашего пользовательского резолвера в запросах и мутациях, возможно, в сочетании с Cloud SQL или другими пользовательскими резолверами.

Следуйте инструкциям в следующих разделах, чтобы узнать, как это сделать. В качестве примера предположим, что у вас есть общедоступная информация профилей пользователей, хранящаяся вне Cloud SQL. Точное хранилище данных в этих примерах не указано, но это может быть что-то вроде Cloud Storage, экземпляр MongoDB или что-то еще.

В следующих разделах будет продемонстрирована базовая реализация пользовательского обработчика, который может передавать информацию из внешнего профиля в Data Connect .

Определите схему для вашего пользовательского обработчика запросов.

  1. В каталоге вашего проекта Firebase выполните следующую команду:

    firebase init dataconnect:resolver

    Интерфейс командной строки Firebase запросит у вас имя для вашего пользовательского резолвера и спросит, следует ли генерировать примеры реализаций резолвера на TypeScript или JavaScript. Если вы следуете этому руководству, примите имя по умолчанию и сгенерируйте примеры на TypeScript.

    Затем инструмент создаст пустой файл dataconnect/schema_resolver/schema.gql и добавит вашу новую конфигурацию резолвера в файл dataconnect.yaml .

  2. Обновите файл schema.gql , указав схему GraphQL, определяющую запросы и мутации, которые будет предоставлять ваш пользовательский резолвер. Например, вот схема для пользовательского резолвера, который может получать и обновлять общедоступный профиль пользователя, хранящийся в хранилище данных, отличном от 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
    }
    

Реализуйте собственную логику обработки запросов.

Далее реализуйте свои резолверы с помощью Cloud Functions. По сути, вы будете создавать GraphQL-сервер; однако в Cloud Functions есть вспомогательный метод onGraphRequest , который обрабатывает все детали этого процесса, поэтому вам нужно будет написать только логику резолвера, которая обращается к вашему источнику данных.

  1. Откройте файл functions/src/index.ts .

    При выполнении firebase init dataconnect:resolver указанной выше, был создан каталог с исходным кодом Cloud Functions и инициализирован примером кода из index.ts .

  2. Добавьте следующие определения:

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

Эти базовые реализации показывают общую структуру, которую должна иметь функция разрешения зависимостей. Для создания полностью функциональной пользовательской функции разрешения зависимостей вам потребуется заполнить закомментированные разделы кодом, который будет считывать и записывать данные в ваш источник данных.

Используйте пользовательские резолверы в запросах и мутациях.

Теперь, когда вы определили схему своего пользовательского резолвера и реализовали лежащую в его основе логику, вы можете использовать этот пользовательский резолвер в запросах и мутациях Data Connect . Позже вы будете использовать эти операции для автоматической генерации пользовательского клиентского SDK, который можно использовать для доступа ко всем вашим данным, независимо от того, используются ли для этого Cloud SQL, ваши пользовательские резолверы или их комбинация.

  1. В dataconnect/example/queries.gql добавьте следующее определение:

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

    Этот запрос извлекает общедоступный профиль пользователя, используя ваш собственный механизм разрешения имен.

  2. В dataconnect/example/mutations.gql добавьте следующее определение:

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

    Эта мутация записывает новый набор данных профиля в хранилище данных, снова используя ваш пользовательский резолвер. Обратите внимание, что схема использует директиву @auth Data Connect , чтобы гарантировать, что пользователи могут обновлять только свои собственные профили. Поскольку вы получаете доступ к своему хранилищу данных через Data Connect , вы автоматически можете воспользоваться такими функциями Data Connect как эта.

В приведенных выше примерах вы определили операции Data Connect , которые получают доступ к данным из вашего хранилища данных с помощью ваших пользовательских резолверов. Однако ваши операции не ограничены доступом к данным только из Cloud SQL или из одного пользовательского источника данных. См. раздел «Примеры» для получения информации о более сложных вариантах использования, объединяющих данные из нескольких источников.

Прежде чем продолжить, перейдите к следующему разделу, чтобы увидеть ваши пользовательские обработчики событий в действии.

Разверните свой собственный механизм разрешения зависимостей и операции.

Как и при внесении любых изменений в схемы Data Connect , для того чтобы они вступили в силу, необходимо их развернуть. Прежде чем это сделать, сначала разверните пользовательскую логику разрешения имен, которую вы реализовали с помощью Cloud Functions:

firebase deploy --only functions

Теперь вы можете развернуть обновленные схемы и операции:

firebase deploy --only dataconnect

После внесения изменений в схемы Data Connect необходимо также сгенерировать новые клиентские SDK:

firebase dataconnect:sdk:generate

Примеры

Эти примеры демонстрируют, как реализовать некоторые более сложные сценарии использования и как избежать распространенных ошибок.

Авторизация доступа к пользовательскому обработчику запросов с использованием данных из Cloud SQL.

Одним из преимуществ интеграции источников данных в Data Connect с использованием пользовательских обработчиков является возможность написания операций, объединяющих источники данных.

В этом примере предположим, что вы разрабатываете приложение для социальных сетей, и у вас есть реализованная в виде пользовательского обработчика мутация, которая отправляет напоминание по электронной почте другу пользователя, если тот какое-то время не взаимодействовал с пользователем.

Для реализации функции "подталкивания" создайте пользовательский резолвер со схемой, подобной следующей:

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

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

Это определение подкрепляется облачной функцией, например, следующей:

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

Поскольку отправка электронных писем обходится вам дорого и является потенциальным каналом злоупотреблений, перед использованием пользовательского обработчика sendEmail убедитесь, что предполагаемый получатель уже находится в списке друзей пользователя.

Предположим, что в вашем приложении данные списка друзей хранятся в 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!
}

Вы можете написать мутацию, которая сначала запрашивает Cloud SQL, чтобы убедиться, что отправитель находится в списке друзей получателя, прежде чем использовать пользовательский резолвер для отправки электронного письма:

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

В качестве дополнительного замечания, этот пример также иллюстрирует, что источник данных в контексте пользовательских резолверов может включать ресурсы, отличные от баз данных и подобных систем. В этом примере источником данных является облачный сервис отправки электронной почты.

Обеспечение последовательного выполнения с помощью мутаций.

При объединении источников данных часто необходимо убедиться, что запрос к одному источнику данных завершен, прежде чем отправлять запрос к другому. Например, предположим, у вас есть запрос, который динамически расшифровывает видео по запросу с помощью API искусственного интеллекта. Такой вызов API может быть ресурсоемким, поэтому вам нужно ограничить его выполнение определенными критериями, например, тем, что пользователь является владельцем видео или что пользователь приобрел какие-либо премиум-кредиты в вашем приложении.

Первая попытка добиться этого может выглядеть примерно так:

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

Этот подход не сработает, поскольку порядок выполнения полей запроса не гарантируется ; GraphQL-сервер ожидает возможности разрешать поля в любом порядке, чтобы максимизировать параллелизм. С другой стороны, поля мутации всегда разрешаются в правильном порядке , поскольку GraphQL-сервер ожидает, что некоторые поля мутации могут иметь побочные эффекты при разрешении.

Несмотря на то, что первый шаг в приведенном примере операции не имеет побочных эффектов, вы можете определить эту операцию как мутацию, чтобы воспользоваться тем фактом, что поля мутации обрабатываются в порядке их следования:

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

Ограничения

Функция пользовательских резолверов выпущена в качестве экспериментальной публичной предварительной версии. Обратите внимание на следующие текущие ограничения:

В пользовательских аргументах резолвера отсутствуют выражения CEL.

Вы не можете динамически использовать выражения CEL в аргументах пользовательского обработчика запросов. Например, следующее невозможно:

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

Вместо этого передавайте стандартные переменные (например, $authUid ) и проверяйте их на уровне операции, используя безопасно вычисляемую директиву @auth(expr: ...) .

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

Ещё один обходной путь — перенести всю логику в пользовательский обработчик запросов и выполнять все операции с данными из Cloud Functions.

Например, рассмотрим этот пример, который в данный момент не работает:

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

Вместо этого перенесите как запрос Cloud SQL, так и вызов почтовой службы в одно поле мутации, поддерживаемое функцией:

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

Сгенерируйте SDK администратора для вашей базы данных и используйте его в функции для выполнения запроса 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);

В параметрах пользовательского резолвера отсутствуют типы входных объектов.

Пользовательские резолверы не принимают сложные типы входных данных GraphQL. Параметры должны быть базовыми скалярными типами ( String , Int , Date , Any и т. д.) и перечислениями 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
}

Пользовательские резолверы не могут предшествовать операциям SQL.

При мутации размещение пользовательского резолвера перед стандартными операциями SQL приводит к ошибке. Все операции на основе SQL должны выполняться перед вызовами любых пользовательских резолверов.

Нет транзакций (@transaction)

Пользовательские обработчики событий нельзя заключать в блок @transaction при выполнении стандартных SQL-операций. Если облачная функция, лежащая в основе обработчика событий, завершится с ошибкой после успешной вставки SQL-запроса, база данных не будет автоматически откатываться.

Для обеспечения транзакционной безопасности между SQL и другим источником данных перенесите логику операций SQL внутрь облачной функции, а проверку и откат обрабатывайте с помощью Admin SDK или прямых SQL-подключений.