何故
Hasuraのイベントトリガーを使用してBaaSにログを保存、保存したログをHasuraからアクセスしたいという理由です
本記事について
タイトルにもあるようにBaaSからGraphQLを使用して取得するコードしか書きません
Hasuraのイベントトリガーとの紐付けは余裕があれば別記事に載せます
Cloud Firestore
- 多くのエンジニアが使用した経験のある有名どころ
- 知らないエンジニアは殆どいないものである
Cloud Firestore + GraphQL
多くのエンジニアの方がFirebaseのプロジェクト作成を記事にしてくださっているので今回はスキップします
もしプロジェクト作成方法や環境構築方法がわからない場合は各自調べてください
ディレクトリ構成
.
├── 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も構成に入っています
ライブラリインストール
フロントエンド
フロントエンド側にて下記ライブラリをインストールします
npm install @apollo/client @firebase/app @firebase/auth graphql
Cloud Functions for Firebase
npm install @apollo/server express graphql
npm install --save-dev @types/express
フロントエンド実装
環境変数周り
envファイルの型になり、Firebase関連の環境変数になります
/// <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ファイルで設定した環境変数を呼んであげます
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も使用するための設定が含まれています
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
に記述していきます
今回は簡単な実装のためキャッシュの設定は行っていません
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つのファイルで管理しています
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へのユーザー登録処理
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
に大部分の処理をまとめて実装しています
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から取得してきたユーザーデータを表示するコンポーネント
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
は使用していません
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>
);
};
追加したユーザーデータを表示するコンポーネント
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へアクセスする作りになっています
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;
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周り
スキーマを定義していきます
簡単めなクエリやミューテーションにしています
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を元にサーバーを設定していきます
import { ApolloServer } from "@apollo/server";
import resolvers from "./resolvers";
import typeDefs from "./typeDefs";
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: true,
});
export default server;
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(初期設定のまま)にアクセスすれば下記添付画像のような形で表示されます
表示されたらユーザー登録を行ってみてください
上手く実装出来ていれば登録が出来ます
リロードしても登録したユーザーはしっかり表示されます
Function側
http://localhost:5001/{プロジェクト名}/{ロケーション}/graphql
にCloud Functions for Firebase
で立てたGraphQLサーバーにアクセス出来ます
左メニューから自作したQueryやMutationから実行したいものを選んであげれば実際に登録されているデータに問い合わせが出来ます
まとめ
学生のころからFirestore + GraphQL
はやってみたかった実装なので楽しい時間でした
前述した通り、余裕があればHasura + Firestore + GraphQL
の実装・記事にしていきます