GraphQLを使用してFirestoreからデータ取得

# 何故
Hasuraのイベントトリガーを使用してBaaSにログを保存、保存したログをHasuraからアクセスしたいという理由です

https://hasura.io/learn/ja/graphql/hasura/custom-business-logic/5-create-event-trigger/

# 本記事について
タイトルにもあるようにBaaSからGraphQLを使用して取得するコードしか書きません
Hasuraのイベントトリガーとの紐付けは余裕があれば別記事に載せます

# Cloud Firestore
* 多くのエンジニアが使用した経験のある有名どころ
* 知らないエンジニアは殆どいないものである

https://firebase.google.com/docs/firestore?hl=ja

# Cloud Firestore + GraphQL

多くのエンジニアの方がFirebaseのプロジェクト作成を記事にしてくださっているので今回はスキップします
もしプロジェクト作成方法や環境構築方法がわからない場合は各自調べてください

## ディレクトリ構成

```zsh:zsh
.
├── emulator-seed
├── functions
│   ├── lib
│   │   ├── graphql
│   │   │   ├── resolvers
│   │   │   └── typeDefs
│   │   └── utils
│   └── src
│       └── graphql
│           ├── resolvers
│           └── typeDefs
├── public
└── src
    ├── assets
    ├── components
    ├── env
    ├── graphql
    └── lib
```

簡易的な構成になっていますが、ローカル環境で開発しやすいように[Firebase emulator](https://firebase.google.com/docs/emulator-suite?hl=ja)を使用しています

GraphQLサーバーを立てるために[Cloud Functions for Firebase](https://firebase.google.com/docs/functions?hl=ja)も構成に入っています

## ライブラリインストール
### フロントエンド
フロントエンド側にて下記ライブラリをインストールします
```zsh:zsh
npm install @apollo/client @firebase/app @firebase/auth graphql
```

### Cloud Functions for Firebase
```zsh:zsh
npm install @apollo/server express graphql
```
```zsh:zsh
npm install --save-dev @types/express 
```

## フロントエンド実装

### 環境変数周り

envファイルの型になり、Firebase関連の環境変数になります

```ts:vite-env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly REACT_PUBLIC_API_KEY: string;
  readonly REACT_PUBLIC_AUTH_DOMAIN: string;
  readonly REACT_PUBLIC_PROJECT_ID: string;
  readonly REACT_PUBLIC_STORAGE_BUCKET: string;
  readonly REACT_PUBLIC_MESSAGING_SENDER_ID: string;
  readonly REACT_PUBLIC_APP_ID: string;
  readonly REACT_PUBLIC_MEASUREMENT_ID: string;
  readonly REACT_PUBLIC_USE_FIREBASE_EMULATOR: "run" | undefined; // エミュレーター起動用
  readonly REACT_FIRESTORE_GRAPHQL_EMULATOR_ENDPOINT: string; // エミュレーターのGraphQLサーバーのエンドポイント
  readonly REACT_FIRESTORE_GRAPHQL_ENDPOINT: string; // 実際のFirebaseにデプロイされているGraphQLサーバーのエンドポイント
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}
```

envファイルで設定した環境変数を呼んであげます

```ts:src/env/dotEnv.ts
const DEVELOPMENT = "development";

type FirebaseConfigEnvType = {
  apiKey: string;
  authDomain: string;
  projectId: string;
  storageBucket: string;
  messagingSenderId: string;
  appId: string;
  measurementId: string;
};

export interface DotEnvInterface {
  getFirebaseConfig: () => FirebaseConfigEnvType;
  isFirebaseEmulator: () => boolean;
  getFirestoreGraphQLEndpoint: () => string;
}

class DotEnv implements DotEnvInterface {
  getFirebaseConfig = () => {
    return {
      apiKey: import.meta.env.VITE_PUBLIC_API_KEY,
      authDomain: import.meta.env.VITE_PUBLIC_AUTH_DOMAIN,
      projectId: import.meta.env.VITE_PUBLIC_PROJECT_ID,
      storageBucket: import.meta.env.VITE_PUBLIC_STORAGE_BUCKET,
      messagingSenderId: import.meta.env.VITE_PUBLIC_MESSAGING_SENDER_ID,
      appId: import.meta.env.VITE_PUBLIC_APP_ID,
      measurementId: import.meta.env.VITE_PUBLIC_MEASUREMENT_ID,
    };
  };

  isFirebaseEmulator = () => {
    return Boolean(import.meta.env.VITE_PUBLIC_USE_FIREBASE_EMULATOR);
  };

  getFirestoreGraphQLEndpoint = () => {
    const mode = import.meta.env.MODE;
    console.log(import.meta.env.VITE_PUBLIC_MEASUREMENT_ID);
    console.table(import.meta.env);

    if (mode === DEVELOPMENT)
      return import.meta.env.VITE_FIRESTORE_GRAPHQL_EMULATOR_ENDPOINT;

    return import.meta.env.VITE_FIRESTORE_GRAPHQL_ENDPOINT;
  };
}

export const env = new DotEnv();
```

Firebaseの初期化を行うファイルになります
Emulatorも使用するための設定が含まれています

```ts:src/lib/FirebaseInitialize.ts
import { initializeApp } from "firebase/app";
import { connectFirestoreEmulator, getFirestore } from "firebase/firestore";
import { connectFunctionsEmulator, getFunctions } from "firebase/functions";
import { env } from "../env/dotEnv";

const FIREBASE_EMULATOR_FIRESTORE_PORT = 8000;
const FIREBASE_EMULATOR_FUNCTIONS_PORT = 5001;

const isEmulator = () => {
  const useEmulator = env.isFirebaseEmulator();
  return !!(useEmulator && useEmulator);
};

const app = initializeApp(env.getFirebaseConfig());

const firebaseFunctions = getFunctions(app, "asia-northeast1");
const firebaseFirestore = getFirestore(app);

if (isEmulator()) {
  connectFirestoreEmulator(
    firebaseFirestore,
    "localhost",
    FIREBASE_EMULATOR_FIRESTORE_PORT
  );
  connectFunctionsEmulator(
    firebaseFunctions,
    "localhost",
    FIREBASE_EMULATOR_FUNCTIONS_PORT
  );
}

export { firebaseFirestore, firebaseFunctions };
```

### Apollo周り

ApolloClientの設定を`main.tsx`に記述していきます
今回は簡単な実装のためキャッシュの設定は行っていません

```tsx:main.tsx
import { ApolloClient, ApolloProvider, InMemoryCache } from "@apollo/client";
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { env } from "./env/dotEnv.ts";
import "./index.css";

const client = new ApolloClient({
  uri: env.getFirestoreGraphQLEndpoint(),
  cache: new InMemoryCache(),
});

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <ApolloProvider client={client}>
      <App />
    </ApolloProvider>
  </React.StrictMode>
);
```

### GraphQL周り

Firestoreに登録されているユーザーデータを取得する処理
クエリは別ファイルで管理した方が良いですが、今回は1つのファイルで管理しています

```tsx:src/graphql/useUserQuery.tsx
import { ApolloError, gql, useQuery } from "@apollo/client";

export const GET_USERS = gql`
  query GetUser {
    getUsers {
      id
      name
      email
    }
  }
`;

export type User = {
  id: string;
  name: string;
  email: string;
};

type GetUsersData = {
  getUsers: User[];
};

type UseUserQueryReturn = {
  loading: boolean;
  error: ApolloError | undefined;
  data: GetUsersData | undefined;
};

type UseUserQuery = () => UseUserQueryReturn;

export const useUserQuery: UseUserQuery = () => {
  const { loading, error, data } = useQuery<GetUsersData>(GET_USERS);

  return {
    loading,
    error,
    data,
  };
};
```

Firestoreへのユーザー登録処理

```tsx:src/graphql/useAddUserMutation.tsx
import {
  ApolloCache,
  ApolloError,
  DefaultContext,
  FetchResult,
  gql,
  MutationFunctionOptions,
  useMutation,
} from "@apollo/client";
import { useState } from "react";

export const ADD_USER = gql`
  mutation AddUser($name: String!, $email: String!) {
    addUser(name: $name, email: $email) {
      id
      name
      email
    }
  }
`;

export type User = {
  id: string;
  name: string;
  email: string;
};

export type AddUserData = {
  addUser: User;
};

export interface AddUserVars {
  name: string;
  email: string;
}

type UseAddUserMutationReturn = {
  addUser: (
    options?:
      | MutationFunctionOptions<
          AddUserData,
          AddUserVars,
          DefaultContext,
          ApolloCache<unknown>
        >
      | undefined
  ) => Promise<FetchResult<AddUserData>>;
  loading: boolean;
  error: ApolloError | undefined;
  data: AddUserData | null | undefined;
  name: string;
  setName: React.Dispatch<React.SetStateAction<string>>;
  email: string;
  setEmail: React.Dispatch<React.SetStateAction<string>>;
};

type UseAddUserMutation = () => UseAddUserMutationReturn;

export const useAddUserMutation: UseAddUserMutation = () => {
  const [name, setName] = useState<string>("");
  const [email, setEmail] = useState<string>("");
  const [addUser, { data, loading, error }] = useMutation<
    AddUserData,
    AddUserVars
  >(ADD_USER);

  return {
    addUser,
    data,
    loading,
    error,
    name,
    setName,
    email,
    setEmail,
  };
};
```

## jsx周り
今回は簡単に実装しているため`App.tsx`に大部分の処理をまとめて実装しています

```tsx:App.tsx
import { MutationFunctionOptions } from "@apollo/client";
import { FormEvent } from "react";
import { AddUserCard } from "./components/AddUserCard";
import { AddUserForm } from "./components/AddUserForm";
import { UserCard } from "./components/UserCard";
import {
  AddUserData,
  AddUserVars,
  useAddUserMutation,
} from "./graphql/useAddUserMutation";
import { useUserQuery } from "./graphql/useUserQuery";

const App: React.FC = () => {
  const { loading, error, data } = useUserQuery();
  const {
    addUser,
    data: mutationData,
    loading: mutationLoading,
    error: mutationError,
    name,
    setName,
    email,
    setEmail,
  } = useAddUserMutation();

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  if (mutationLoading) return <p>Loading</p>;
  if (error) return <p>Error: {mutationError?.message}</p>;

  const { getUsers } = data!;

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    try {
      await addUser({
        variables: { name, email },
      } as MutationFunctionOptions<AddUserData, AddUserVars>);
      setName("");
      setEmail("");
    } catch (e) {
      console.error(e);
    }
  };

  return (
    <div>
      <h1>Firestore + GraphQL</h1>
      {getUsers.map((user) => (
        <UserCard user={user} />
      ))}

      <AddUserForm
        name={name}
        email={email}
        onChangeName={(e) => setName(e.target.value)}
        onChangeEmail={(e) => setEmail(e.target.value)}
        handleSubmit={handleSubmit}
      />

      <AddUserCard addUser={mutationData} />
    </div>
  );
};

export default App;
```

Firestoreから取得してきたユーザーデータを表示するコンポーネント

```tsx:src/components/UserCard.tsx
import { FC } from "react";
import { User } from "../graphql/useUserQuery";

type UserCardProps = {
  user: User;
};

export const UserCard: FC<UserCardProps> = ({ user }) => {
  return (
    <div>
      <h2>ユーザー詳細</h2>
      <p>ID: {user.id}</p>
      <p>名前: {user.name}</p>
      <p>メールアドレス: {user.email}</p>
    </div>
  );
};
```

ユーザー登録するフォームコンポーネント
`react-hook-form`は使用していません

```tsx:src/components/AddUserForm.tsx
import { ChangeEventHandler, FC, FormEvent } from "react";

type AddUserFormProps = {
  name: string;
  email: string;
  handleSubmit: (e: FormEvent) => Promise<void>;
  onChangeName: ChangeEventHandler<HTMLInputElement>;
  onChangeEmail: ChangeEventHandler<HTMLInputElement>;
};

export const AddUserForm: FC<AddUserFormProps> = ({
  name,
  email,
  handleSubmit,
  onChangeName,
  onChangeEmail,
}) => {
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={name}
        onChange={onChangeName}
        placeholder="名前"
      />
      <input
        type="email"
        value={email}
        onChange={onChangeEmail}
        placeholder="メールアドレス"
      />
      <button type="submit">ユーザー追加</button>
    </form>
  );
};
```

追加したユーザーデータを表示するコンポーネント

```tsx:src/components/AddUserCard.tsx
import { FC } from "react";
import { AddUserData } from "../graphql/useAddUserMutation";

type AddUserCardType = {
  addUser: AddUserData | null | undefined;
};

export const AddUserCard: FC<AddUserCardType> = ({ addUser }) => {
  if (!addUser) return <></>;

  return (
    <div>
      <h2>追加ユーザー</h2>
      <p>ID: {addUser.addUser.id}</p>
      <p>名前: {addUser.addUser.name}</p>
      <p>メールアドレス: {addUser.addUser.email}</p>
    </div>
  );
};
```

これにてフロントエンド側の実装は完了になります
GraphQL周りの処理以外は基本的なReactになっています

## Cloud Functions for Firebase

### resolver周り

FunctionからFirestoreへアクセスする作りになっています

```ts:src/graphql/resolvers/user.ts
import { firestore } from "firebase-admin";

/**
 * user resolver
 *
 * @class UsersResolver
 */
class UsersResolver {
  private db: firestore.Firestore;

  /**
   * Creates an instance of UsersResolver.
   * @memberof UsersResolver
   */
  constructor() {
    this.db = firestore();
  }

  /**
   * Get Users
   *
   * @readonly
   * @memberof UsersResolver
   */
  async getUsers() {
    const userDoc = this.db.collection("users");
    const snapshot = await userDoc.get();
    const users: unknown[] = [];
    snapshot.forEach((doc) => {
      users.push({
        id: doc.id,
        ...doc.data(),
      });
    });
    return users;
  }

  /**
   * Get User
   *
   * @param {string} id
   * @memberof UsersResolver
   */
  async getUser(id: string) {
    const userRef = this.db.collection("users").doc(id);
    const doc = await userRef.get();
    if (!doc.exists) {
      throw new Error("User not found");
    }
    return { id: doc.id, ...doc.data() };
  }

  /**
   * Add User
   *
   * @param {string} name
   * @param {string} email
   * @return {*}
   * @memberof UsersResolver
   */
  async addUser(name: string, email: string) {
    const userRef = this.db.collection("users").doc();
    const user = { name, email };
    await userRef.set(user);
    return { id: userRef.id, ...user };
  }

  /**
   * Update User
   *
   * @param {string} id
   * @param {string} [name]
   * @param {string} [email]
   * @return {*}
   * @memberof UsersResolver
   */
  async updateUser(id: string, name?: string, email?: string) {
    const userRef = this.db.collection("users").doc(id);
    const doc = await userRef.get();
    if (!doc.exists) {
      throw new Error("User not found");
    }
    const updatedData: { name?: string; email?: string } = {};
    if (name) updatedData.name = name;
    if (email) updatedData.email = email;
    await userRef.update(updatedData);
    return { id: doc.id, ...updatedData };
  }

  /**
   * Delete User
   *
   * @param {string} id
   * @return {*}
   * @memberof UsersResolver
   */
  async deleteUser(id: string) {
    const userRef = this.db.collection("users").doc(id);
    const doc = await userRef.get();
    if (!doc.exists) {
      throw new Error("User not found");
    }
    await userRef.delete();
    return true;
  }
}

export default UsersResolver;
```

```ts:src/graphql/resolvers/index.ts
import UsersResolver from "./users";

const usersResolver = new UsersResolver();

const resolvers = {
  Query: {
    getUser: async (_: unknown, { id }: { id: string }) => {
      return usersResolver.getUser(id);
    },

    getUsers: async () => {
      return usersResolver.getUsers();
    },
  },
  Mutation: {
    addUser: async (
      _: unknown,
      { name, email }: { name: string; email: string }
    ) => {
      return usersResolver.addUser(name, email);
    },
    updateUser: async (
      _: unknown,
      { id, name, email }: { id: string; name?: string; email?: string }
    ) => {
      return usersResolver.updateUser(id, name, email);
    },
    deleteUser: async (_: unknown, { id }: { id: string }) => {
      return usersResolver.deleteUser(id);
    },
  },
};

export default resolvers;
```

### typeDefs周り

スキーマを定義していきます
簡単めなクエリやミューテーションにしています

```ts:src/graphql/typeDefs/index.ts
const typeDefs = `#graphql
  type Query {
    getUser(id: String!): User!
    getUsers: [User]!
  }

  type Mutation {
    addUser(name: String!, email: String!): User
    updateUser(id: String!, name: String, email: String): User
    deleteUser(id: String!): Boolean
  }

  type User {
    id: String
    name: String
    email: String
  }
`;

export default typeDefs;
```

### GraphQLサーバー周り

上記で設定したresolverやtypeDefsを元にサーバーを設定していきます

```ts:src/graphql/index.ts
import { ApolloServer } from "@apollo/server";
import resolvers from "./resolvers";
import typeDefs from "./typeDefs";

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: true,
});

export default server;
```

```ts:src/index.ts
import { initializeApp } from "firebase-admin/app";
initializeApp();

import express from "express";
import { onRequest } from "firebase-functions/v2/https";
import { expressMiddleware } from "@apollo/server/express4";
import server from "./graphql";

const app = express();

server.start().then(() => {
  app.use("/", expressMiddleware(server));
});

exports.graphql = onRequest(app);
```

これにてFunction側も構築が完了致しました

## 動作確認

:::note warn
今回はEmulatorを使用して動作確認を行います
:::

### フロントエンド側

[http://localhost:5173](http://localhost:5173)(初期設定のまま)にアクセスすれば下記添付画像のような形で表示されます

![CleanShot 2024-10-30 at 08.11.43@2x.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311195/3209746f-4d57-87fd-6636-f0a02d1267c1.png)

表示されたらユーザー登録を行ってみてください
上手く実装出来ていれば登録が出来ます
リロードしても登録したユーザーはしっかり表示されます

![CleanShot 2024-10-30 at 08.13.01.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311195/1bc34385-526a-50ba-1c21-2bd0ef565258.gif)

### Function側

`http://localhost:5001/{プロジェクト名}/{ロケーション}/graphql`に`Cloud Functions for Firebase`で立てたGraphQLサーバーにアクセス出来ます

左メニューから自作したQueryやMutationから実行したいものを選んであげれば実際に登録されているデータに問い合わせが出来ます

![CleanShot 2024-10-30 at 08.27.23.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/311195/fca6a1bc-e716-5654-22ae-ad0f6d52d7a1.gif)

# まとめ
学生のころから`Firestore + GraphQL`はやってみたかった実装なので楽しい時間でした
前述した通り、余裕があれば`Hasura + Firestore + GraphQL`の実装・記事にしていきます