「Nest.jsをバックで使っている時にFirebase認証をどうやるの?」とふと思ったので自分なりに学んで実装した記事になります
構成
.
├── Makefile
├── backend
│ ├── README.md
│ ├── nest-cli.json
│ ├── package-lock.json
│ ├── package.json
│ ├── src
│ │ ├── app.controller.spec.ts
│ │ ├── app.controller.ts
│ │ ├── app.module.ts
│ │ ├── app.service.ts
│ │ ├── auth
│ │ │ ├── auth.guard.spec.ts
│ │ │ └── auth.guard.ts
│ │ ├── firebase-admin
│ │ │ ├── firebase-admin.module.ts
│ │ │ ├── firebase-admin.service.spec.ts
│ │ │ └── firebase-admin.service.ts
│ │ ├── main.ts
│ │ ├── schema.gql
│ │ └── user
│ │ ├── dto
│ │ ├── entities
│ │ ├── user.module.ts
│ │ ├── user.resolver.spec.ts
│ │ ├── user.resolver.ts
│ │ ├── user.service.spec.ts
│ │ └── user.service.ts
│ ├── test
│ │ ├── app.e2e-spec.ts
│ │ └── jest-e2e.json
│ ├── tsconfig.build.json
│ └── tsconfig.json
├── docker
│ ├── backend
│ │ └── Dockerfile
│ └── frontend
│ └── Dockerfile
├── docker-compose.yml
└── frontend
├── README.md
├── codegen.ts
├── eslint.config.js
├── index.html
├── package-lock.json
├── package.json
├── public
│ └── vite.svg
├── setupTests.ts
├── src
│ ├── App.css
│ ├── App.tsx
│ ├── assets
│ │ └── react.svg
│ ├── auth
│ │ ├── __tests__
│ │ ├── firebase.ts
│ │ ├── firebaseInit.ts
│ │ └── useFirebase.ts
│ ├── configs
│ │ └── apolloClient.ts
│ ├── gql
│ │ ├── user.gen.ts
│ │ └── user.gql
│ ├── index.css
│ ├── main.tsx
│ ├── mocks
│ │ ├── handlers.ts
│ │ └── server.ts
│ ├── page
│ │ ├── __tests__
│ │ ├── create.tsx
│ │ ├── login.tsx
│ │ ├── userWithLogin.tsx
│ │ └── userWithoutLogin.tsx
│ ├── providers
│ │ └── apolloProvider.tsx
│ ├── router
│ │ └── router.tsx
│ ├── types
│ │ └── graphql.gen.ts
│ └── vite-env.d.ts
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
└── vite.config.ts
バックエンド側
タイトルにもある通りNestjsになります
フロントエンド側
サクッと構築したいのでReactになります
FireabaseAuthでログイン機能を実装・バックエンドとやり取りをする形になります
docker
dockerは本題とは違うため簡単な形で構成しています
環境構築
docker
mkdir nestFirebase
cd nestFirebase
初めにdocker-compose.yml
を作成していきます
version: '3.8'
services:
frontend:
build:
context: ./frontend
dockerfile: ../docker/frontend/Dockerfile
ports:
- ${FRONTEND_PORT}:${FRONTEND_PORT}
environment:
VITE_BACKEND_URI: ${BACKEND_URI}
VITE_FIREBASE_API_KEY: ${FIREBASE_API_KEY}
VITE_FIREBASE_AUTH_DOMAIN: ${FIREBASE_AUTH_DOMAIN}
VITE_FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID}
VITE_FIREBASE_STORAGE_BUCKET: ${FIREBASE_STORAGE_BUCKET}
VITE_FIREBASE_MESSAGING_SENDER_ID: ${FIREBASE_MESSAGING_SENDER_ID}
VITE_FIREBASE_APP_ID: ${FIREBASE_APP_ID}
volumes:
- ./frontend:/app
depends_on:
- backend
backend:
build:
context: ./backend
dockerfile: ../docker/backend/Dockerfile
ports:
- ${BACKEND_PORT}:${BACKEND_PORT}
depends_on:
- postgres
volumes:
- ./backend:/app
environment:
POSTGRES_HOST: ${POSTGRES_HOST}
POSTGRES_PORT: ${POSTGRES_PORT}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
PORT: ${BACKEND_PORT}
SCHEMA: ${POSTGRES_SCHEMA}
FIREBASE_PROJECT_ID: ${FIREBASE_PROJECT_ID}
FIREBASE_CLIENT_EMAIL: ${FIREBASE_CLIENT_EMAIL}
FIREBASE_PRIVATE_KEY: ${FIREBASE_PRIVATE_KEY}
networks:
- backend-network
postgres:
image: postgres:13
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- backend-network
volumes:
postgres_data:
networks:
backend-network:
driver: bridge
.envを整えていきます
PostgresSQL・Portは個人開発用設定のため載せています
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=mydb
POSTGRES_SCHEMA=public
BACKEND_PORT=4000
FRONTEND_PORT=3000
BACKEND_URI=http://localhost:4000/graphql
FIREBASE_API_KEY=
FIREBASE_AUTH_DOMAIN=
FIREBASE_PROJECT_ID=
FIREBASE_STORAGE_BUCKET=
FIREBASE_MESSAGING_SENDER_ID=
FIREBASE_APP_ID=
FIREBASE_CLIENT_EMAIL=
FIREBASE_PRIVATE_KEY=
- FIREBASE_API_KEY=
- FIREBASE_AUTH_DOMAIN=
- FIREBASE_PROJECT_ID=
- FIREBASE_STORAGE_BUCKET=
- FIREBASE_MESSAGING_SENDER_ID=
- FIREBASE_APP_ID=
上記6つはFirebaseのアプリに表示されているConfigを記載してください
- FIREBASE_CLIENT_EMAIL=
- FIREBASE_PRIVATE_KEY=
上記2つはプロジェクトの設定 > サービスアカウント
移動するとFirebase Admin SDKの欄があるので「新しい秘密鍵を生成」をクリックしてダウンロードします
ダウンロード出来たら開いて
-
client_email
はFIREBASE_CLIENT_EMAIL
に記載 -
private_key
はFIREBASE_PRIVATE_KEY
に記載
してください
backendとfrontendのDockerfile
を整えていきます
今回はバックエンド用・フロントエンド用の二種類になります
バックエンド用Dockerfile
mkdir docker
cd docker
mkdir backend
cd backend
touch Dockerfile
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
EXPOSE 4000
CMD ["npm", "run", "start:dev"]
フロントエンド用Dockerfile
mkdir docker
cd docker
mkdir frontend
cd frontend
touch Dockerfile
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
EXPOSE 3000
CMD ["npm", "run", "dev"]
バックエンド構築
npx nest new backend
今回はWhich package manager would you ❤️ to use?
をnpm
で選択していますが、ご自身の使いやすいものを選択してください
npm以外を選択した場合は今後のnpmで書かれている箇所を読み替えてください
⚡ We will scaffold your app in a few seconds..
? Which package manager would you ❤️ to use? npm
CREATE backend/.eslintrc.js (663 bytes)
CREATE backend/.prettierrc (51 bytes)
CREATE backend/README.md (5205 bytes)
CREATE backend/nest-cli.json (171 bytes)
CREATE backend/package.json (1943 bytes)
CREATE backend/tsconfig.build.json (97 bytes)
CREATE backend/tsconfig.json (546 bytes)
CREATE backend/src/app.controller.ts (274 bytes)
CREATE backend/src/app.module.ts (249 bytes)
CREATE backend/src/app.service.ts (142 bytes)
CREATE backend/src/main.ts (228 bytes)
CREATE backend/src/app.controller.spec.ts (617 bytes)
CREATE backend/test/jest-e2e.json (183 bytes)
CREATE backend/test/app.e2e-spec.ts (630 bytes)
✔ Installation in progress... ☕
🚀 Successfully created project backend
👉 Get started with the following commands:
$ cd backend
$ npm run start
Thanks for installing Nest 🙏
Please consider donating to our open collective
to help us maintain this package.
🍷 Donate: https://opencollective.com/nest
フロントエンド構築
npm create vite@latest frontend -- --template react-swc-ts
portをDockerfileに合わせるためvite.config.ts
を変更していきます
import react from "@vitejs/plugin-react-swc";
import { defineConfig } from "vite";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
+ server: {
+ host: "0.0.0.0",
+ port: 3000,
+ },
});
実行
ここまで来たらコンテナが問題なく作成・起動するか確認をしてみます
docker compose up -d --build
下記にアクセスして問題なく表示されたら一先ず完了になります
バックエンド
フロントエンド
バックエンド実装
インストール
バックエンド側で必要なライブラリをインストールしていきます
npm install @apollo/server @nestjs/apollo @nestjs/graphql graphql
npm install --save @nestjs/config firebase-admin typeorm class-validator class-transformer @nestjs/typeorm @nestjs/config
CORSの設定
バックエンドだけであれば問題ないのですが、今回はフロントエンドも実装するのでCORS設定が必要です
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
+ app.enableCors({
+ origin: 'http://localhost:3000',
+ methods: 'GET, POST, PUT, DELETE',
+ allowedHeaders: 'Content-Type, Authorization',
+ });
await app.listen(process.env.PORT);
}
bootstrap();
app.module.tsを整える
必要な設定を記載しています
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GraphQLModule } from '@nestjs/graphql';
import { TypeOrmModule } from '@nestjs/typeorm';
import { join } from 'path';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { FirebaseAdminModule } from './firebase-admin/firebase-admin.module';
import { FirebaseAdminService } from './firebase-admin/firebase-admin.service';
import { UserModule } from './user/user.module';
const configService = new ConfigService();
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
playground: true,
installSubscriptionHandlers: true,
debug: true,
sortSchema: true,
}),
TypeOrmModule.forRoot({
type: 'postgres',
host: configService.get('POSTGRES_HOST'),
port: configService.get('POSTGRES_PORT'),
username: configService.get('POSTGRES_USER'),
password: configService.get('POSTGRES_PASSWORD'),
database: configService.get('POSTGRES_DB'),
schema: configService.get('POSTGRES_SCHEMA'),
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: true,
}),
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
認証機能を整える
今回一番大事な箇所になります
serviceとguardを使用して認証機能を実装します
1. service作成
serviceを作成していきます
docker compose exec backend npx nest g s firebaseAdmin
問題なければbackend/src/firebase-admin/firebase-admin.service.ts
が作成されているのでコードを書いていきます
admin.auth().verifyIdToken(token)
でフロントエンドからjwtが渡ってきて検証する形になります
カスタムクレームを設定していればより厳密な検証が行えます
import {
Injectable,
OnModuleInit,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as admin from 'firebase-admin';
import { ServiceAccount } from 'firebase-admin';
@Injectable()
export class FirebaseAdminService implements OnModuleInit {
onModuleInit() {
const configService = new ConfigService();
if (admin.apps.length === 0) {
admin.initializeApp({
credential: admin.credential.cert({
projectId: configService.get('FIREBASE_PROJECT_ID'),
clientEmail: configService.get('FIREBASE_CLIENT_EMAIL'),
privateKey: configService
.get('FIREBASE_PRIVATE_KEY')
.replace(/\\n/g, '\n'),
} as ServiceAccount),
});
}
}
async verifyToken(token: string) {
try {
return await admin.auth().verifyIdToken(token);
} catch (error) {
throw new UnauthorizedException('Invalid token', error);
}
}
}
テスト
import { UnauthorizedException } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import * as admin from 'firebase-admin';
import { FirebaseAdminService } from './firebase-admin.service';
describe('FirebaseAdminService', () => {
let service: FirebaseAdminService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [FirebaseAdminService],
}).compile();
service = module.get<FirebaseAdminService>(FirebaseAdminService);
jest.spyOn(admin, 'auth').mockReturnValue({
verifyIdToken: jest.fn(),
} as any);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('verifyToken', () => {
it('有効なトークを検証する', async () => {
const mockToken = 'valid_token';
const mockDecodedToken = { uid: 'user123' };
(admin.auth().verifyIdToken as jest.Mock).mockResolvedValueOnce(
mockDecodedToken,
);
const result = await service.verifyToken(mockToken);
expect(result).toEqual(mockDecodedToken);
});
it('無効なトークンでUnauthorizedExceptionを投げる', async () => {
const mockToken = 'invalid_token';
(admin.auth().verifyIdToken as jest.Mock).mockRejectedValueOnce(
new Error('Invalid token'),
);
await expect(service.verifyToken(mockToken)).rejects.toThrow(
UnauthorizedException,
);
});
});
});
2. module作成
moduleを作成していきます
docker compose exec backend npx nest g mo firebaseAdmin
問題なければbackend/src/firebase-admin/firebase-admin.module.ts
が作成されるのでコードを書いていきます
前述したserviceを呼んであげるだけになります
import { Global, Module } from '@nestjs/common';
import { FirebaseAdminService } from './firebase-admin.service';
@Global()
@Module({
providers: [FirebaseAdminService],
exports: [FirebaseAdminService],
})
export class FirebaseAdminModule {}
3. guard作成
guardを作成していきます
docker compose exec backend npx nest g gu auth
問題なければbackend/src/auth/auth.guard.ts
が作成されるのでコードを書いていきます
headerの中身にあるauthorization
からjwtを取得、serviceにて実装したverifyToken
を使って認証処理を行っています
req.uuid = decodedToken.uid;
req.email = decodedToken.email;
と記載しておけば処理内でuuidとemailを取得することが出来ます
uuidとemailの取得をバックエンドのみで簡潔することが出来ます
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { FirebaseAdminService } from '../firebase-admin/firebase-admin.service';
export type AuthContext = {
req: Record<'uuid' | 'email', string>;
};
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly firebaseAdminService: FirebaseAdminService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const ctx = GqlExecutionContext.create(context);
const req = ctx.getContext().req;
const authHeader = req.headers['authorization'];
const token = authHeader?.split(' ')[1];
if (!token) {
throw new UnauthorizedException('Token not found');
}
try {
const decodedToken = await this.firebaseAdminService.verifyToken(token);
req.uuid = decodedToken.uid;
req.email = decodedToken.email;
if (!req.uuid || !req.email) {
throw new UnauthorizedException('Invalid token payload');
}
return true;
} catch (error) {
console.error('Token verification failed:', error);
throw new UnauthorizedException('Invalid token');
}
}
}
テスト
import { ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
import { FirebaseAdminService } from '../firebase-admin/firebase-admin.service';
import { AuthGuard } from './auth.guard';
describe('AuthGuard', () => {
let authGuard: AuthGuard;
let mockFirebaseAdminService: Partial<FirebaseAdminService>;
beforeEach(() => {
mockFirebaseAdminService = {
verifyToken: jest.fn(),
};
authGuard = new AuthGuard(mockFirebaseAdminService as FirebaseAdminService);
jest.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
jest.restoreAllMocks();
});
it('定義されていること', () => {
expect(authGuard).toBeDefined();
});
it('トークンが有効であればアクセスを許可すること', async () => {
const mockToken = 'valid-token';
const mockDecodedToken = { uid: '123', email: 'test@example.com' };
(mockFirebaseAdminService.verifyToken as jest.Mock).mockResolvedValue(
mockDecodedToken,
);
const mockReq = {
headers: {
authorization: `Bearer ${mockToken}`,
},
uuid: '',
email: '',
};
const mockContext = {
getContext: () => ({ req: mockReq }),
getType: () => 'graphql',
} as unknown as ExecutionContext;
jest
.spyOn(GqlExecutionContext, 'create')
.mockReturnValue(mockContext as any);
const result = await authGuard.canActivate(mockContext);
expect(result).toBe(true);
expect(mockReq.uuid).toBe(mockDecodedToken.uid);
expect(mockReq.email).toBe(mockDecodedToken.email);
});
it('トークンが無効であればUnauthorizedExceptionをスローすること', async () => {
const mockToken = 'invalid-token';
(mockFirebaseAdminService.verifyToken as jest.Mock).mockRejectedValue(
new Error('Invalid token'),
);
const mockReq = {
headers: {
authorization: `Bearer ${mockToken}`,
},
};
const mockContext = {
getContext: () => ({ req: mockReq }),
getType: () => 'graphql',
} as unknown as ExecutionContext;
jest
.spyOn(GqlExecutionContext, 'create')
.mockReturnValue(mockContext as any);
await expect(authGuard.canActivate(mockContext)).rejects.toThrow(
UnauthorizedException,
);
});
it('トークンが提供されていなければUnauthorizedExceptionをスローすること', async () => {
const mockReq = {
headers: {},
};
const mockContext = {
getContext: () => ({ req: mockReq }),
getType: () => 'graphql',
} as unknown as ExecutionContext;
jest
.spyOn(GqlExecutionContext, 'create')
.mockReturnValue(mockContext as any);
await expect(authGuard.canActivate(mockContext)).rejects.toThrow(
UnauthorizedException,
);
});
it('トークンのペイロードにuidまたはemailが含まれていない場合、UnauthorizedExceptionをスローすること', async () => {
const mockToken = 'valid-token';
const mockDecodedToken = { uid: '', email: '' };
(mockFirebaseAdminService.verifyToken as jest.Mock).mockResolvedValue(
mockDecodedToken,
);
const mockReq = {
headers: {
authorization: `Bearer ${mockToken}`,
},
};
const mockContext = {
getContext: () => ({ req: mockReq }),
getType: () => 'graphql',
} as unknown as ExecutionContext;
jest
.spyOn(GqlExecutionContext, 'create')
.mockReturnValue(mockContext as any);
await expect(authGuard.canActivate(mockContext)).rejects.toThrow(
UnauthorizedException,
);
});
});
Userテーブル
今回は簡単に実装するためにUserテーブルのみ作成していきます
1. entity作成
まずはテーブル構造に当たるentityを作成していきます
mkdir -p backend/src/user/entities
touch backend/src/user/entities/user.entity.ts
作成出来たらコードを書いていきます
uuid
・name
・email
の3つだけにしています
import { Field, ObjectType } from '@nestjs/graphql';
import { Column, Entity, PrimaryColumn } from 'typeorm';
@ObjectType()
@Entity()
export class User {
@Field(() => String)
@PrimaryColumn({ nullable: false, comment: 'FIrebase Auth UUID' })
uuid: string;
@Field()
@Column({ nullable: false, comment: 'ユーザー名' })
name: string;
@Field()
@Column({ nullable: false, comment: 'メールアドレス' })
email: string;
}
2. DTO作成
DAO・DTOとは少し違ってRequest Payloadの型を定義したものだと考えてください
初めにdtoを入れておくディレクトリを作成します
mkdir backend/src/user/dto
2-1. 新規作成で使用するdto作成
初めにユーザーデータを新規作成する際に使用するdtoを作成していきます
- create-user-only-name.input
- フロントエンドから送られているnameの型を定義します
- create-user.input.ts
- resolverからserviceを呼ぶ際に渡す型を定義します
touch backend/src/user/dto/create-user-only-name.input.ts
touch backend/src/user/dto/create-user.input.ts
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@InputType()
export class CreateUserOnlyNameInput {
@Field()
@IsNotEmpty()
@IsString()
name: string;
}
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@InputType()
export class CreateUserInput {
@Field()
@IsNotEmpty()
@IsString()
uuid: string;
@Field()
@IsNotEmpty()
@IsString()
name: string;
@Field()
@IsNotEmpty()
@IsString()
email: string;
}
2-2. データ呼び出しに使用するdto作成
データ呼び出し時はFirebaseAuthのuuidを元にデータを絞り込んで検索出来るように型を定義しています
touch backend/src/user/dto/find-one-by-uuid.input.ts
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@InputType()
export class FindOneByUuidInput {
@Field()
@IsNotEmpty()
@IsString()
uuid: string;
}
3. service作成
docker compose exec backend npx nest g s user
問題なければbackend/src/user/user.service.ts
が作成されるのでコードを書いていきます
dto作成時にあった、新規作成・データ呼び出し以外に全件呼び出しをする処理も実装していきます
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateUserInput } from './dto/create-user.input';
import { FindOneByUuidInput } from './dto/find-one-by-uuid.input';
import { User } from './entities/user.entity';
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
) {}
findAll(): Promise<User[]> {
return this.userRepository.find();
}
findOneByUUID(findOneByUuidInput: FindOneByUuidInput): Promise<User> {
return this.userRepository.findOneBy(findOneByUuidInput);
}
create(createUserInput: CreateUserInput): Promise<User> {
const user = this.userRepository.create(createUserInput);
return this.userRepository.save(user);
}
}
テスト
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from './entities/user.entity';
import { UserService } from './user.service';
describe('UserService', () => {
let service: UserService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
UserService,
{
provide: getRepositoryToken(User),
useClass: Repository,
},
],
}).compile();
service = module.get<UserService>(UserService);
});
it('定義されている', () => {
expect(service).toBeDefined();
});
it('findAllでユーザーの配列が返される', async () => {
const result = [new User()];
jest.spyOn(service, 'findAll').mockResolvedValue(result);
expect(await service.findAll()).toBe(result);
});
it('findOneByUUIDでユーザーが返される', async () => {
const result = new User();
jest.spyOn(service, 'findOneByUUID').mockResolvedValue(result);
expect(await service.findOneByUUID({ uuid: 'some-uuid' })).toBe(result);
});
it('createで新規ユーザーが返される', async () => {
const result = new User();
jest.spyOn(service, 'create').mockResolvedValue(result);
expect(
await service.create({
uuid: 'some-uuid',
email: 'john.doe@example.com',
name: 'John Doe',
}),
).toBe(result);
});
});
4. resolver作成
docker compose exec backend npx nest g r user
問題なければbackend/src/user/user.resolver.ts
が作成されるのでコードを書いていきます
dtoの他に前述したAuthGuardを呼び出してあげます
@UseGuards(AuthGuard)
と呼び出してあげると認証処理が走り、失敗したらデータ取得出来ずエラーになります
@Context() context: AuthContext
と呼ぶと処理内でuuidとemailの取得が出来ます
import { UseGuards } from '@nestjs/common';
import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql';
import { AuthContext, AuthGuard } from '../auth/auth.guard';
import { CreateUserOnlyNameInput } from './dto/create-user-only-name.input';
import { CreateUserInput } from './dto/create-user.input';
import { User } from './entities/user.entity';
import { UserService } from './user.service';
@Resolver(() => User)
export class UserResolver {
constructor(private readonly userService: UserService) {}
@Query(() => [User], { name: 'users' })
findAll() {
return this.userService.findAll();
}
@Query(() => User, { name: 'user' })
@UseGuards(AuthGuard)
findOne(@Context() context: AuthContext) {
const { uuid } = context.req;
return this.userService.findOneByUUID({ uuid });
}
@Mutation(() => User)
@UseGuards(AuthGuard)
createUser(
@Args('createUserOnlyNameInput')
createUserOnlyNameInput: CreateUserOnlyNameInput,
@Context() context: AuthContext,
) {
const { uuid, email } = context.req;
const userWithUUID: CreateUserInput = {
...createUserOnlyNameInput,
uuid,
email,
};
return this.userService.create(userWithUUID);
}
}
テスト
import { Test, TestingModule } from '@nestjs/testing';
import { DecodedIdToken } from 'firebase-admin/lib/auth/token-verifier';
import { AuthGuard } from '../auth/auth.guard';
import { CreateUserOnlyNameInput } from './dto/create-user-only-name.input';
import { User } from './entities/user.entity';
import { UserResolver } from './user.resolver';
import { UserService } from './user.service';
describe('UserResolver', () => {
let resolver: UserResolver;
let userService: UserService;
beforeEach(async () => {
const mockAuthGuard = {
canActivate: jest.fn(() => true),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
UserResolver,
{
provide: UserService,
useValue: {
findAll: jest.fn(),
findOneByUUID: jest.fn(),
create: jest.fn(),
},
},
],
})
.overrideGuard(AuthGuard)
.useValue(mockAuthGuard)
.compile();
resolver = module.get<UserResolver>(UserResolver);
userService = module.get<UserService>(UserService);
});
it('定義されているべき', () => {
expect(resolver).toBeDefined();
});
describe('findAll', () => {
it('ユーザーの配列を返すべき', async () => {
const result = [new User()];
jest.spyOn(userService, 'findAll').mockResolvedValue(result);
expect(await resolver.findAll()).toBe(result);
});
});
describe('findOne', () => {
it('単一のユーザーを返すべき', async () => {
const user = new User();
const decodedIdToken: DecodedIdToken = {
uid: 'test-uid',
email: 'test@example.com',
} as DecodedIdToken;
jest.spyOn(userService, 'findOneByUUID').mockResolvedValue(user);
expect(
await resolver.findOne({
req: { uuid: decodedIdToken.uid, email: decodedIdToken.email },
}),
).toBe(user);
});
});
describe('createUser', () => {
it('ユーザーを作成して返すべき', async () => {
const user = new User();
const decodedIdToken: DecodedIdToken = {
uid: 'test-uid',
email: 'test@example.com',
} as DecodedIdToken;
const createUserOnlyNameInput: CreateUserOnlyNameInput = {
name: 'Test User',
};
jest.spyOn(userService, 'create').mockResolvedValue(user);
expect(
await resolver.createUser(createUserOnlyNameInput, {
req: { uuid: decodedIdToken.uid, email: decodedIdToken.email },
}),
).toBe(user);
});
});
});
5. module作成
moduleを作成していきます
docker compose exec backend npx nest g mo user
問題なければbackend/src/user/user.module.ts
が作成されるのでコードを書いていきます
今まで作成してきたUser関連のファイルを呼んであげます
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { UserResolver } from './user.resolver';
import { UserService } from './user.service';
@Module({
imports: [TypeOrmModule.forFeature([User])],
providers: [UserService, UserResolver],
})
export class UserModule {}
app.module.tsを整える
ここまで来たら最後にapp.module.ts
を整えます
module類は自動で追加されています
ただ、serviceは追加されていないので追加してあげます
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { GraphQLModule } from '@nestjs/graphql';
import { TypeOrmModule } from '@nestjs/typeorm';
import { join } from 'path';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { FirebaseAdminModule } from './firebase-admin/firebase-admin.module';
import { FirebaseAdminService } from './firebase-admin/firebase-admin.service';
import { UserModule } from './user/user.module';
const configService = new ConfigService();
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
playground: true,
installSubscriptionHandlers: true,
debug: true,
sortSchema: true,
}),
TypeOrmModule.forRoot({
type: 'postgres',
host: configService.get('POSTGRES_HOST'),
port: configService.get('POSTGRES_PORT'),
username: configService.get('POSTGRES_USER'),
password: configService.get('POSTGRES_PASSWORD'),
database: configService.get('POSTGRES_DB'),
schema: configService.get('POSTGRES_SCHEMA'),
entities: [__dirname + '/**/*.entity{.ts,.js}'],
synchronize: true,
}),
UserModule,
FirebaseAdminModule,
],
controllers: [AppController],
providers: [
AppService,
+ FirebaseAdminService,
],
})
export class AppModule {}
問題なければnestのログでエラーなく実行されているはずです
docker compose logs -f backend
backend-1 | [Nest] 207 - 11/27/2024, 11:49:37 PM LOG [NestFactory] Starting Nest application...
backend-1 | [Nest] 207 - 11/27/2024, 11:49:37 PM LOG [InstanceLoader] TypeOrmModule dependencies initialized +45ms
backend-1 | [Nest] 207 - 11/27/2024, 11:49:37 PM LOG [InstanceLoader] FirebaseAdminModule dependencies initialized +0ms
backend-1 | [Nest] 207 - 11/27/2024, 11:49:37 PM LOG [InstanceLoader] AppModule dependencies initialized +0ms
backend-1 | [Nest] 207 - 11/27/2024, 11:49:37 PM LOG [InstanceLoader] GraphQLSchemaBuilderModule dependencies initialized +0ms
backend-1 | [Nest] 207 - 11/27/2024, 11:49:37 PM LOG [InstanceLoader] GraphQLModule dependencies initialized +1ms
backend-1 | [Nest] 207 - 11/27/2024, 11:49:37 PM LOG [InstanceLoader] TypeOrmCoreModule dependencies initialized +50ms
backend-1 | [Nest] 207 - 11/27/2024, 11:49:37 PM LOG [InstanceLoader] TypeOrmModule dependencies initialized +0ms
backend-1 | [Nest] 207 - 11/27/2024, 11:49:37 PM LOG [InstanceLoader] UserModule dependencies initialized +1ms
backend-1 | [Nest] 207 - 11/27/2024, 11:49:37 PM LOG [RoutesResolver] AppController {/}: +3ms
backend-1 | [Nest] 207 - 11/27/2024, 11:49:37 PM LOG [RouterExplorer] Mapped {/, GET} route +1ms
backend-1 | [Nest] 207 - 11/27/2024, 11:49:37 PM LOG [GraphQLModule] Mapped {/graphql, POST} route +57ms
backend-1 | [Nest] 207 - 11/27/2024, 11:49:37 PM LOG [NestApplication] Nest application successfully started +28ms
下記にアクセスして添付画像のplaygroundが表示されていればバックエンド側の実装完了になります
フロントエンド実装
インストール
npm install --save @apollo/client firebase graphql graphql-request react-router-dom @types/react-dom
npm install --save-dev @graphql-codegen/cli @graphql-codegen/near-operation-file-preset @graphql-codegen/typescript @graphql-codegen/typescript-graphql-request @graphql-codegen/typescript-msw @graphql-codegen/typescript-operations @graphql-codegen/typescript-react-apollo @graphql-codegen/typescript-resolvers @testing-library/dom @testing-library/jest-dom @testing-library/react jsdom msw vite vitest
codegenの設定ファイル作成
自分自身がよく使っている設定ではありますが記載していきます
touch frontend/codegen.ts
import type { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
overwrite: true,
schema: "../backend/src/schema.gql",
documents: "src/**/*.gql",
hooks: {
afterAllFileWrite: ["prettier --write"],
},
generates: {
"src/types/graphql.gen.ts": {
plugins: [
"@graphql-codegen/typescript",
"@graphql-codegen/typescript-resolvers",
],
config: {
enumsAsTypes: true,
namingConvention: "keep",
avoidOptionals: true,
scalars: {
BigInt: " string",
ISO8601Date: "string",
ISO8601DateTime: "string",
},
},
},
"src/": {
preset: "near-operation-file-preset",
presetConfig: {
extension: ".gen.ts",
baseTypesPath: "types/graphql.gen.ts",
},
plugins: [
"@graphql-codegen/typescript-operations",
"@graphql-codegen/typescript-react-apollo",
"@graphql-codegen/typescript-msw",
],
config: {
gqlImport: "@apollo/client#gql",
constEnums: true,
reactApolloVersion: 3,
withComponent: false,
withHOC: false,
withHooks: true,
enumsAsTypes: true,
namingConvention: "keep",
avoidOptionals: true,
},
},
},
};
export default config;
コードが書けたらpackage.jsonのscriptsにcodegenの実行コマンドを追加していきます
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview",
+ "codegen": "graphql-codegen --config codegen.ts",
"test": "vitest"
},
...
}
テスト周りの設定
オプション的な立ち位置のテストになるので説明はないです
import "@testing-library/jest-dom";
import { afterAll, afterEach, beforeAll, vi } from "vitest";
import * as UserGen from "./src/gql/user.gen";
import { mockAuthUser } from "./src/mocks/handlers";
import { server } from "./src/mocks/server";
export const mockIdToken = "mockIdToken";
export const mockUid = "mockUid";
beforeAll(() => server.listen({ onUnhandledRequest: "warn" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
vi.mock(
"firebase/auth",
async (importOrigin: () => Promise<typeof import("firebase/auth")>) => {
const actual = await importOrigin();
return {
...actual,
getAuth: () => ({
onAuthStateChanged: vi.fn((callback) => {
callback({
uid: mockUid,
displayName: mockAuthUser.name,
getIdToken: vi.fn().mockResolvedValue(mockIdToken),
});
}),
currentUser: null,
}),
createUserWithEmailAndPassword: async (email: string) => {
const userCredential = {
user: {
uid: mockUid,
email,
getIdToken: vi.fn().mockResolvedValue(mockIdToken),
},
};
return userCredential;
},
signInWithEmailAndPassword: async (email: string) => {
const userCredential = {
user: {
uid: mockUid,
email,
getIdToken: vi.fn().mockResolvedValue(mockIdToken),
},
};
return userCredential;
},
signOut: vi.fn().mockResolvedValue(undefined),
};
}
);
vi.mock("./src/gql/user.gen", async (importOrigin) => {
const actual = (await importOrigin()) as typeof UserGen;
return {
...actual,
useGetUserLazyQuery: actual.useGetUserLazyQuery,
useGetUsersLazyQuery: actual.useGetUsersLazyQuery,
useCreateUserMutation: actual.useCreateUserMutation,
};
});
beforeAll(() => {
global.localStorage = {
getItem: vi.fn(),
setItem: vi.fn(),
clear: vi.fn(),
length: 0,
key: vi.fn().mockReturnValue(null),
removeItem: vi.fn(),
};
});
テストの設定追加
import react from "@vitejs/plugin-react-swc";
import { defineConfig } from "vite";
import { configDefaults } from "vitest/config";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
host: "0.0.0.0",
port: 3000,
},
+ test: {
+ globals: true,
+ environment: "jsdom",
+ setupFiles: "./setupTests.ts",
+ include: ["**/__tests__/**/*.test.(tsx|ts)"],
+ exclude: [
+ ...configDefaults.exclude,
+ "**/assets/**",
+ "**/coverage/**",
+ "**/dist/**",
+ "src/app/types/**",
+ "**/*.config.ts",
+ ],
+ coverage: {
+ exclude: [
+ ...(configDefaults.coverage.exclude ?? []),
+ "**/assets/**",
+ "**/coverage/**",
+ "**/dist/**",
+ "src/app/types/**",
+ "**/*.config.ts",
+ ],
+ },
+ },
});
モック作成
import { HttpResponse } from "msw";
import {
mockCreateUserMutation,
mockGetUserQuery,
mockGetUsersQuery,
} from "../gql/user.gen";
export const mockUser = {
name: "mock",
email: "mock@example.com",
};
const mockUserPassword = "password1234";
export const mockAuthUser = {
...mockUser,
password: mockUserPassword,
};
export const mockUsers = [
{ name: "John Doe", email: "john@example.com" },
{ name: "Jane Doe", email: "jane@example.com" },
];
export const handlers = [
mockGetUserQuery(() => {
return HttpResponse.json({
data: {
user: mockUser,
},
});
}),
mockGetUsersQuery(() => {
return HttpResponse.json({
data: {
users: mockUsers,
},
});
}),
mockCreateUserMutation(({ variables }) => {
const name = variables?.name as string;
return HttpResponse.json({
data: {
createUser: {
name,
email: mockUser.email,
},
},
});
}),
];
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
gqlファイル作成
今回は
- CreateUser
- ユーザーデータ作成を行うMutation
- GetUser
- uuidを元にユーザーデータを取得するQuery
- GetUsers
- 全ユーザーデータを取得するQuery
をの3つを記載します
mkdir -p frontend/gql
touch frontend/gql/user.gql
query GetUser {
user {
name
email
}
}
query GetUsers {
users {
name
email
}
}
mutation CreateUser($name: String!) {
createUser(createUserOnlyNameInput: { name: $name }) {
name
email
}
}
作成出来たらターミナルにてcodegenを実行してファイルを生成してください
npm run codegen
Firebase設定
Firebase周りの設定を書いていきます
多くの記事で見るようなコードになるため読みやすいかと思います
各ファイルの役割としては
- firebaseInit.ts
- Firebaseの初期化を行う
- firebase.ts
- バックエンド側に送るJWTを取得するための処理を持つ
- useFirebase.ts
- 新規アカウント作成・ログイン・ログアウトの処理を持ったカスタムフック
- localStorageにJWTを入れていますが、実際に運用するプロジェクトではやらないようにしてください
- チェック用に記載しています
mkdir frontend/src/auth
touch frontend/src/auth/firebaseInit.ts
touch frontend/src/auth/firebase.ts
touch frontend/src/auth/useFirebase.ts
import { initializeApp } from "firebase/app";
import { getAuth } from "firebase/auth";
const firebaseConfig = {
apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
appId: import.meta.env.VITE_FIREBASE_APP_ID,
};
const app = initializeApp(firebaseConfig);
export const auth = getAuth(app);
import { auth } from "./firebaseInit";
export const getIdToken = async (): Promise<string | null> => {
return new Promise((resolve) => {
auth.onAuthStateChanged(async (user) => {
if (user) {
const token = await user.getIdToken();
resolve(token);
} else {
resolve(null);
}
});
});
};
import { FirebaseError } from "firebase/app";
import {
createUserWithEmailAndPassword,
signInWithEmailAndPassword,
signOut,
} from "firebase/auth";
import { useCallback } from "react";
import { auth } from "./firebaseInit";
type FirebaseAuthArg = Record<"email" | "password", string>;
type FirebaseLogin = (arg: FirebaseAuthArg) => Promise<void>;
type FirebaseCreate = (arg: FirebaseAuthArg) => Promise<void>;
type UseFirebaseReturn = {
firebaseLogin: FirebaseLogin;
firebaseCreate: FirebaseCreate;
firebaseLogout: () => Promise<void>;
};
type UseFirebase = () => UseFirebaseReturn;
export const useFirebase: UseFirebase = () => {
const firebaseLogin = useCallback<FirebaseLogin>(
async ({ email, password }) => {
try {
const userCredential = await signInWithEmailAndPassword(
auth,
email,
password
);
// NOTE: Playgroundで実行用に
const token = await userCredential.user.getIdToken();
localStorage.setItem("jwtToken", token);
} catch (error) {
const message = handleFirebaseError(error);
console.error("Error:", message);
}
},
[]
);
const firebaseCreate = useCallback<FirebaseCreate>(
async ({ email, password }) => {
try {
const userCredential = await createUserWithEmailAndPassword(
auth,
email,
password
);
const token = await userCredential.user.getIdToken();
localStorage.setItem("jwtToken", token);
} catch (error) {
const message = handleFirebaseError(error);
console.error("Error:", message);
}
},
[]
);
const firebaseLogout = useCallback(async () => {
try {
await signOut(auth);
} catch (error) {
const message = handleFirebaseError(error);
console.error("Error:", message, error);
}
localStorage.removeItem("jwtToken");
}, []);
return {
firebaseLogin,
firebaseCreate,
firebaseLogout,
};
};
const handleFirebaseError = (error: unknown) => {
if (error instanceof FirebaseError) {
switch (error.code) {
case "auth/user-not-found":
return "ユーザーが見つかりません";
case "auth/wrong-password":
return "パスワードが間違っています";
case "auth/email-already-in-use":
return "このメールアドレスはすでに登録されています";
case "auth/network-request-failed":
return "ネットワークエラーが発生しました";
default:
return "エラー発生しました";
}
}
return "不明なエラーが発生しました";
};
テスト
import { renderHook } from "@testing-library/react";
import { FirebaseError } from "firebase/app";
import { createUserWithEmailAndPassword } from "firebase/auth";
import { act } from "react";
import { describe, expect, it, vi } from "vitest";
import { mockIdToken } from "../../../setupTests";
import { mockAuthUser } from "../../mocks/handlers";
import { useFirebase } from "../useFirebase";
describe("useFirebase", () => {
it("初期状態", () => {
const { result } = renderHook(() => useFirebase());
expect(result.current.firebaseCreate).toBeDefined();
expect(result.current.firebaseLogin).toBeDefined();
expect(result.current.firebaseLogout).toBeDefined();
});
it("ユーザー登録に成功する", async () => {
const { result } = renderHook(() => useFirebase());
await act(async () => {
await result.current.firebaseCreate({
email: mockAuthUser.email,
password: mockAuthUser.password,
});
});
expect(localStorage.setItem).toHaveBeenCalledWith("jwtToken", mockIdToken);
});
it("ログインに成功する", async () => {
const { result } = renderHook(() => useFirebase());
await act(async () => {
await result.current.firebaseLogin({
email: mockAuthUser.email,
password: mockAuthUser.password,
});
});
expect(localStorage.setItem).toHaveBeenCalledWith("jwtToken", mockIdToken);
});
it("ログアウトに成功する", async () => {
const { result } = renderHook(() => useFirebase());
await act(async () => {
await result.current.firebaseLogout();
});
expect(localStorage.removeItem).toHaveBeenCalledWith("jwtToken");
});
it("ユーザー登録に失敗する", async () => {
const networkError = new FirebaseError(
"auth/network-request-failed",
"Network error occurred"
);
const mockCreateUserWithEmailAndPassword = vi
.fn()
.mockRejectedValue(networkError) as typeof createUserWithEmailAndPassword;
const originalCreateUserWithEmailAndPassword =
createUserWithEmailAndPassword;
(createUserWithEmailAndPassword as typeof mockCreateUserWithEmailAndPassword) =
mockCreateUserWithEmailAndPassword;
const { result } = renderHook(() => useFirebase());
await act(async () => {
try {
await result.current.firebaseCreate({
email: mockAuthUser.email,
password: mockAuthUser.password,
});
} catch (error) {
expect(error).toEqual(networkError);
}
});
expect(mockCreateUserWithEmailAndPassword).toHaveBeenCalledTimes(1);
expect(mockCreateUserWithEmailAndPassword).toHaveBeenCalledWith(
expect.anything(),
mockAuthUser.email,
mockAuthUser.password
);
// 元の実装を復元
(createUserWithEmailAndPassword as typeof mockCreateUserWithEmailAndPassword) =
originalCreateUserWithEmailAndPassword;
});
});
apolloClient設定
よくあるapolloClientのこーどになりますが、今回大事なのはgetIdToken()
が記載されている箇所になります
バックエンドへ送る際のheaderにgetIdToken()
で取得したJWTをセットして送る形にしています
ログインしていない場合は空文字を送るようにしています
mkdir frontend/src/configs
mkdir frontend/src/providers
touch frontend/src/configs/apolloClient.ts
touch frontend/src/providers/apolloProvider.ts
import { ApolloClient, InMemoryCache, createHttpLink } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import { getIdToken } from "../auth/firebase";
const httpLink = createHttpLink({
uri: import.meta.env.VITE_BACKEND_URI,
});
const authLink = setContext(async (_, { headers }) => {
const token = await getIdToken();
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : "",
},
};
});
const client = new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache(),
});
export default client;
import { ApolloProvider as Provider } from "@apollo/client";
import { FC, ReactNode } from "react";
import client from "../configs/apolloClient";
interface ApolloProviderProps {
children: ReactNode;
}
export const ApolloProvider: FC<ApolloProviderProps> = ({ children }) => {
return <Provider client={client}>{children}</Provider>;
};
apolloProvider.tsx
まで書けたらmain.tsx
で呼んであげます
呼ぶことで全ページにて使用できるようにしてあげます
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import { ApolloProvider } from "./providers/apolloProvider.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ApolloProvider>
<App />
</ApolloProvider>
</StrictMode>
);
ページ作成
作成するページは
- create.tsx
- 新規アカウント作成ページ
- login.tsx
- ログインページ
- userWithLogin.tsx
- ログインしたアカウントのみデータ取得出来るページ
- userWithoutLogin.tsx
- ログイン状況関係なくデータ取得出来るページ
になります
mkdir frontend/src/page
touch frontend/src/page/create.tsx
touch frontend/src/page/login.tsx
touch frontend/src/page/userWithLogin.tsx
touch frontend/src/page/userWithoutLogin.tsx
import { ChangeEvent, FC, useCallback, useState } from "react";
import { useFirebase } from "../auth/useFirebase";
import { useCreateUserMutation } from "../gql/user.gen";
export const Create: FC = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [user, setUser] = useState<{ name: string; email: string } | null>(
null
);
const { firebaseCreate } = useFirebase();
const [createUser] = useCreateUserMutation();
const createHandler = useCallback(async () => {
try {
await firebaseCreate({ email, password });
const newUser = await createUser({ variables: { name } });
console.dir(newUser, { depth: null });
if (newUser.data) {
const { name, email } = newUser.data.createUser;
setUser({ name, email });
}
} catch (error) {
console.error("Create failed:", error);
}
}, [firebaseCreate, email, password, createUser, name]);
const setterEmail = useCallback(
(e: ChangeEvent<HTMLInputElement>) => setEmail(e.target.value),
[]
);
const setterPassword = useCallback(
(e: ChangeEvent<HTMLInputElement>) => setPassword(e.target.value),
[]
);
const setterName = useCallback(
(e: ChangeEvent<HTMLInputElement>) => setName(e.target.value),
[]
);
return (
<div>
<h1>Create</h1>
<div>
<label htmlFor="email">Email</label>
<br />
<input id="email" type="text" onChange={setterEmail} />
</div>
<div>
<label htmlFor="password">Password</label>
<br />
<input id="password" type="password" onChange={setterPassword} />
</div>
<div>
<label htmlFor="name">Name</label>
<br />
<input id="name" type="text" onChange={setterName} />
</div>
<br />
<button onClick={createHandler}>新規作成</button>
{user && (
<div>
<p>
Name: <span>{user.name}</span>
</p>
<p>
Email: <span>{user.email}</span>
</p>
</div>
)}
</div>
);
};
import { FC, useCallback, useState } from "react";
import { useFirebase } from "../auth/useFirebase";
export const Login: FC = () => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const { firebaseLogin, firebaseLogout } = useFirebase();
const loginHandler = useCallback(
async () =>
await firebaseLogin({
email,
password,
}),
[firebaseLogin, email, password]
);
const setterEmail = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => setEmail(e.target.value),
[]
);
const setterPassword = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => setPassword(e.target.value),
[]
);
return (
<div>
<h1>Login</h1>
<div>
<label htmlFor="email">Email</label>
<br />
<input id="email" type="text" onChange={setterEmail} />
</div>
<div>
<label htmlFor="password">Password</label>
<br />
<input id="password" type="password" onChange={setterPassword} />
</div>
<br />
<button onClick={loginHandler}>ログイン</button>
<button onClick={firebaseLogout}>ログアウト</button>
</div>
);
};
import { FC, useEffect } from "react";
import { useGetUserLazyQuery } from "../gql/user.gen";
export const UserWithLogin: FC = () => {
const [getUser, { loading, data, error }] = useGetUserLazyQuery({
fetchPolicy: "cache-and-network",
});
useEffect(() => {
getUser();
}, [getUser]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h1>UserWithLogin</h1>
{data ? (
<div>
<h1>{data.user.name}</h1>
<p>{data.user.email}</p>
</div>
) : (
<p>Not Data</p>
)}
</div>
);
};
import { FC, useEffect } from "react";
import { useGetUsersLazyQuery } from "../gql/user.gen";
export const UserWithoutLogin: FC = () => {
const [getUsers, { data, loading, error }] = useGetUsersLazyQuery({
fetchPolicy: "network-only",
});
useEffect(() => {
getUsers();
}, [getUsers]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
<h1>UserWithoutLogin</h1>
{data?.users.map((user, index) => (
<div key={index}>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
))}
</div>
);
};
テスト
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
import { useFirebase } from "../../auth/useFirebase";
import { mockAuthUser } from "../../mocks/handlers";
import { ApolloProvider } from "../../providers/apolloProvider";
import { Create } from "../create";
vi.mock("../../auth/useFirebase.ts", () => ({
useFirebase: vi.fn(),
}));
const mockFirebaseCreate = vi.fn();
beforeEach(() => {
(useFirebase as Mock).mockReturnValue({
firebaseCreate: mockFirebaseCreate,
});
vi.clearAllMocks();
});
describe("create", () => {
it("新規作成ボタンをクリックでユーザー作成処理が成功する", async () => {
render(
<ApolloProvider>
<Create />
</ApolloProvider>
);
fireEvent.change(screen.getByLabelText("Email"), {
target: { value: mockAuthUser.email },
});
fireEvent.change(screen.getByLabelText("Password"), {
target: { value: mockAuthUser.password },
});
fireEvent.change(screen.getByLabelText("Name"), {
target: { value: mockAuthUser.name },
});
fireEvent.click(screen.getByText("新規作成"));
await waitFor(() => {
expect(mockFirebaseCreate).toHaveBeenCalledWith({
email: mockAuthUser.email,
password: mockAuthUser.password,
});
});
expect(await screen.findByText(mockAuthUser.name)).toBeInTheDocument();
expect(await screen.findByText(mockAuthUser.email)).toBeInTheDocument();
});
});
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, Mock, vi } from "vitest";
import { useFirebase } from "../../auth/useFirebase";
import { mockAuthUser } from "../../mocks/handlers";
import { Login } from "../login";
vi.mock("../../auth/useFirebase.ts", () => ({
useFirebase: vi.fn(),
}));
const mockFirebaseLogin = vi.fn();
const mockFirebaseLogout = vi.fn();
beforeEach(() => {
(useFirebase as Mock).mockReturnValue({
firebaseLogin: mockFirebaseLogin,
firebaseLogout: mockFirebaseLogout,
});
vi.clearAllMocks();
});
describe("login", () => {
it("ログインボタンをクリックでログイン処理が成功する", async () => {
render(<Login />);
fireEvent.change(screen.getByLabelText("Email"), {
target: { value: mockAuthUser.email },
});
fireEvent.change(screen.getByLabelText("Password"), {
target: { value: mockAuthUser.password },
});
fireEvent.click(screen.getByText("ログイン"));
await waitFor(() => {
expect(mockFirebaseLogin).toHaveBeenCalledWith({
email: mockAuthUser.email,
password: mockAuthUser.password,
});
});
});
it("ログアウトボタンをクリックしてログアウト処理が成功する", async () => {
render(<Login />);
fireEvent.click(screen.getByText("ログアウト"));
await waitFor(() => {
expect(mockFirebaseLogout).toHaveBeenCalled();
});
});
});
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { mockUser } from "../../mocks/handlers";
import { ApolloProvider } from "../../providers/apolloProvider";
import { UserWithLogin } from "../userWithLogin";
describe("テスト", () => {
it("ローディングが表示されること", () => {
render(
<ApolloProvider>
<UserWithLogin />
</ApolloProvider>
);
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
it("ユーザーデータが表示されること", async () => {
render(
<ApolloProvider>
<UserWithLogin />
</ApolloProvider>
);
expect(await screen.findByText(mockUser.name)).toBeInTheDocument();
expect(await screen.findByText(mockUser.email)).toBeInTheDocument();
});
});
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { mockUsers } from "../../mocks/handlers";
import { ApolloProvider } from "../../providers/apolloProvider";
import { UserWithoutLogin } from "../userWithoutLogin";
describe("UserWithoutLogin", () => {
it("ローディングが表示される", () => {
render(
<ApolloProvider>
<UserWithoutLogin />
</ApolloProvider>
);
expect(screen.getByText("Loading...")).toBeInTheDocument();
});
it("ユーザーデータが表示される", async () => {
render(
<ApolloProvider>
<UserWithoutLogin />
</ApolloProvider>
);
mockUsers.forEach(async (user) => {
expect(await screen.findByText(user.name)).toBeInTheDocument();
expect(await screen.findByText(user.email)).toBeInTheDocument();
});
});
});
ルーティング設定
ページの作成完了致しましたので最後にルーティング設定をしていきます
実運用ならルーティングに権限処理を実装すべきですが今回はオミットしています
mkdir frontend/src/router
touch frontend/src/router/router.tsx
import { BrowserRouter, Link, Route, Routes } from "react-router-dom";
import { Create } from "../page/create";
import { Login } from "../page/login";
import { UserWithLogin } from "../page/userWithLogin";
import { UserWithoutLogin } from "../page/userWithoutLogin";
export const Router = () => {
return (
<BrowserRouter>
<Link to="/">Login</Link> | <Link to="/create">Create</Link> |{" "}
<Link to="/userWithoutLogin">userWithoutLogin</Link> |{" "}
<Link to="/userWithLogin">userWithLogin</Link> |{" "}
<Routes>
<Route path="/" Component={Login} />
<Route path="/userWithoutLogin" Component={UserWithoutLogin} />
<Route path="/userWithLogin" Component={UserWithLogin} />
<Route path="/create" Component={Create} />
</Routes>
</BrowserRouter>
);
};
router.tsx
が書けたらApp.tsx
を更新していきます
import "./App.css";
import { Router } from "./router/router";
function App() {
return <Router />;
}
export default App;
動作チェック
ここまで実装できたらフロント側問題なく表示されているか確認してみます
- ログインページ
- 新規アカウント作成ページ
- ログイン状態関係なくデータ取得できるページ
- ログイン状況関係なくデータ取得出来るページ(現状ログインしていないため
Token not found
と表示されている)
今後
今回はuuid
とemail
のみアカウント情報から取得していますが
カスタムクレームを使用すればより厳格な認証が行えると思うのでそちらも試せていければと思っています