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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Теперь, когда вы определили схему своего пользовательского резолвера и реализовали лежащую в его основе логику, вы можете использовать этот пользовательский резолвер в своих запросах и мутациях SQL 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 SQL Connect , чтобы гарантировать, что пользователи могут обновлять только свои собственные профили. Поскольку вы получаете доступ к своему хранилищу данных через SQL Connect , вы автоматически можете воспользоваться такими функциями SQL Connect , как эта.

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

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

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

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

firebase deploy --only functions

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

firebase deploy --only dataconnect

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

firebase dataconnect:sdk:generate

Примеры

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

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

Одним из преимуществ интеграции источников данных в SQL 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-подключений.