2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

何故

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

本記事について

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

Cloud Firestore

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

Cloud Firestore + GraphQL

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

ディレクトリ構成

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

簡易的な構成になっていますが、ローカル環境で開発しやすいようにFirebase emulatorを使用しています

GraphQLサーバーを立てるためにCloud Functions for Firebaseも構成に入っています

ライブラリインストール

フロントエンド

フロントエンド側にて下記ライブラリをインストールします

zsh
npm install @apollo/client @firebase/app @firebase/auth graphql

Cloud Functions for Firebase

zsh
npm install @apollo/server express graphql
zsh
npm install --save-dev @types/express 

フロントエンド実装

環境変数周り

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

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ファイルで設定した環境変数を呼んであげます

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も使用するための設定が含まれています

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に記述していきます
今回は簡単な実装のためキャッシュの設定は行っていません

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つのファイルで管理しています

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へのユーザー登録処理

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に大部分の処理をまとめて実装しています

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から取得してきたユーザーデータを表示するコンポーネント

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は使用していません

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

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

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へアクセスする作りになっています

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;
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周り

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

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を元にサーバーを設定していきます

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;
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側も構築が完了致しました

動作確認

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

フロントエンド側

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

CleanShot 2024-10-30 at 08.11.43@2x.png

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

CleanShot 2024-10-30 at 08.13.01.gif

Function側

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

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

CleanShot 2024-10-30 at 08.27.23.gif

まとめ

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

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?