0
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?

Nest.jsにFirebase認証を使ったデータ取得をする

Posted at

「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

zsh
mkdir nestFirebase
cd nestFirebase

初めにdocker-compose.ymlを作成していきます

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は個人開発用設定のため載せています

.env
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を記載してください

CleanShot 2024-11-22 at 07.24.55.png

  • FIREBASE_CLIENT_EMAIL=
  • FIREBASE_PRIVATE_KEY=

上記2つはプロジェクトの設定 > サービスアカウント移動するとFirebase Admin SDKの欄があるので「新しい秘密鍵を生成」をクリックしてダウンロードします
ダウンロード出来たら開いて

  • client_emailFIREBASE_CLIENT_EMAILに記載
  • private_keyFIREBASE_PRIVATE_KEYに記載
    してください

CleanShot 2024-11-22 at 07.40.53.png

backendとfrontendのDockerfileを整えていきます
今回はバックエンド用・フロントエンド用の二種類になります

バックエンド用Dockerfile

zsh
mkdir docker
cd docker
mkdir backend
cd backend
touch Dockerfile
docker/backend/Dockerfile
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
EXPOSE 4000
CMD ["npm", "run", "start:dev"]

フロントエンド用Dockerfile

zsh
mkdir docker
cd docker
mkdir frontend
cd frontend
touch Dockerfile
docker/frontend/Dockefile
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
EXPOSE 3000
CMD ["npm", "run", "dev"]

バックエンド構築

zsh
npx nest new backend

今回はWhich package manager would you ❤️ to use?npmで選択していますが、ご自身の使いやすいものを選択してください
npm以外を選択した場合は今後のnpmで書かれている箇所を読み替えてください

zsh
⚡  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

フロントエンド構築

zsh
npm create vite@latest frontend -- --template react-swc-ts

portをDockerfileに合わせるためvite.config.tsを変更していきます

frontend/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,
+  },
});

実行

ここまで来たらコンテナが問題なく作成・起動するか確認をしてみます

zsh
docker compose up -d --build

下記にアクセスして問題なく表示されたら一先ず完了になります

バックエンド

フロントエンド

バックエンド実装

インストール

バックエンド側で必要なライブラリをインストールしていきます

zsh
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設定が必要です

backend/src/main.ts
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を整える

必要な設定を記載しています

backend/src/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を作成していきます

zsh
docker compose exec backend npx nest g s firebaseAdmin

問題なければbackend/src/firebase-admin/firebase-admin.service.tsが作成されているのでコードを書いていきます
admin.auth().verifyIdToken(token)でフロントエンドからjwtが渡ってきて検証する形になります
カスタムクレームを設定していればより厳密な検証が行えます

backend/src/firebase-admin/firebase-admin.service.ts
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);
    }
  }
}
テスト
backend/src/firebase-admin/firebase-admin.service.spec.ts
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を作成していきます

zsh
docker compose exec backend npx nest g mo firebaseAdmin

問題なければbackend/src/firebase-admin/firebase-admin.module.tsが作成されるのでコードを書いていきます
前述したserviceを呼んであげるだけになります

backend/src/firebase-admin/firebase-admin.module.ts
import { Global, Module } from '@nestjs/common';
import { FirebaseAdminService } from './firebase-admin.service';

@Global()
@Module({
  providers: [FirebaseAdminService],
  exports: [FirebaseAdminService],
})
export class FirebaseAdminModule {}

3. guard作成

guardを作成していきます

zsh
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の取得をバックエンドのみで簡潔することが出来ます

backend/src/auth/auth.guard.ts
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');
    }
  }
}
テスト
backend/src/auth/auth.guard.spec.ts
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を作成していきます

zsh
mkdir -p backend/src/user/entities
touch backend/src/user/entities/user.entity.ts

作成出来たらコードを書いていきます
uuidnameemailの3つだけにしています

backend/src/user/entities/user.entity.ts
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を入れておくディレクトリを作成します

zsh
mkdir backend/src/user/dto

2-1. 新規作成で使用するdto作成

初めにユーザーデータを新規作成する際に使用するdtoを作成していきます

  • create-user-only-name.input
    • フロントエンドから送られているnameの型を定義します
  • create-user.input.ts
    • resolverからserviceを呼ぶ際に渡す型を定義します
zsh
touch backend/src/user/dto/create-user-only-name.input.ts
touch backend/src/user/dto/create-user.input.ts
create-user-only-name.input.ts
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';

@InputType()
export class CreateUserOnlyNameInput {
  @Field()
  @IsNotEmpty()
  @IsString()
  name: string;
}
create-user.input.ts
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を元にデータを絞り込んで検索出来るように型を定義しています

zsh
touch backend/src/user/dto/find-one-by-uuid.input.ts
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作成

zsh
docker compose exec backend npx nest g s user

問題なければbackend/src/user/user.service.tsが作成されるのでコードを書いていきます
dto作成時にあった、新規作成・データ呼び出し以外に全件呼び出しをする処理も実装していきます

backend/user/user.service.ts
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);
  }
}
テスト
backend/src/user/auth.service.spec.ts
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作成

zsh
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);
  }
}
テスト
backend/src/user/auth.service.spec.ts
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を作成していきます

zsh
docker compose exec backend npx nest g mo user

問題なければbackend/src/user/user.module.tsが作成されるのでコードを書いていきます
今まで作成してきたUser関連のファイルを呼んであげます

backend/src/user/user.module.ts
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は追加されていないので追加してあげます

backend/src/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,
    }),
    UserModule,
    FirebaseAdminModule,
  ],
  controllers: [AppController],
  providers: [
    AppService,
+    FirebaseAdminService,
  ],
})
export class AppModule {}

問題なければnestのログでエラーなく実行されているはずです

zsh
docker compose logs -f backend
docker compose log -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が表示されていればバックエンド側の実装完了になります

CleanShot 2024-11-30 at 00.37.55@2x.png

フロントエンド実装

インストール

zsh
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の設定ファイル作成

自分自身がよく使っている設定ではありますが記載していきます

zsh
touch frontend/codegen.ts
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の実行コマンドを追加していきます

frontend/package.json
{
  "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"
  },
  ...
}
テスト周りの設定

オプション的な立ち位置のテストになるので説明はないです

frontend/setupTests.ts
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(),
  };
});

テストの設定追加

frontend/vite.config.ts
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",
+      ],
+    },
+  },
});

モック作成

frontend/src/mocks/handlers.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,
        },
      },
    });
  }),
];
frontend/src/mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";

export const server = setupServer(...handlers);

gqlファイル作成

今回は

  • CreateUser
    • ユーザーデータ作成を行うMutation
  • GetUser
    • uuidを元にユーザーデータを取得するQuery
  • GetUsers
    • 全ユーザーデータを取得するQuery

をの3つを記載します

zsh
mkdir -p frontend/gql
touch frontend/gql/user.gql
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を実行してファイルを生成してください

zsh
npm run codegen

Firebase設定

Firebase周りの設定を書いていきます
多くの記事で見るようなコードになるため読みやすいかと思います

各ファイルの役割としては

  • firebaseInit.ts
    • Firebaseの初期化を行う
  • firebase.ts
    • バックエンド側に送るJWTを取得するための処理を持つ
  • useFirebase.ts
    • 新規アカウント作成・ログイン・ログアウトの処理を持ったカスタムフック
    • localStorageにJWTを入れていますが、実際に運用するプロジェクトではやらないようにしてください
      • チェック用に記載しています
zsh
mkdir frontend/src/auth
touch frontend/src/auth/firebaseInit.ts
touch frontend/src/auth/firebase.ts
touch frontend/src/auth/useFirebase.ts
frontend/src/auth/firebaseInit.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);
frontend/src/auth/firebase.ts
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);
      }
    });
  });
};
frontend/src/auth/useFirebase.ts
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 "不明なエラーが発生しました";
};
テスト
frontend/src/auth/__tests__/useFirebase.test.ts
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をセットして送る形にしています
ログインしていない場合は空文字を送るようにしています

zsh
mkdir frontend/src/configs
mkdir frontend/src/providers
touch frontend/src/configs/apolloClient.ts
touch frontend/src/providers/apolloProvider.ts
frontend/src/configs/apolloClient.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;
frontend/src/providers/apolloProvider.tsx
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で呼んであげます
呼ぶことで全ページにて使用できるようにしてあげます

frontend/src/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
    • ログイン状況関係なくデータ取得出来るページ

になります

zsh
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
frontend/src/page/create.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>
  );
};
frontend/src/page/login.tsx
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>
  );
};
frontend/src/page/userWithLogin.tsx
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>
  );
};
frontend/src/page/userWithoutLogin.tsx
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>
  );
};
テスト
frontend/src/page/__tests__/create.test.tsx
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();
  });
});
frontend/src/page/__tests__/login.test.tsx
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();
    });
  });
});
frontend/src/page/__tests__/userWithLogin.test.tsx
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();
  });
});
frontend/src/page/__tests__/userWithoutLogin.test.tsx
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
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を更新していきます

frontend/src/App.tsx
import "./App.css";
import { Router } from "./router/router";

function App() {
  return <Router />;
}

export default App;

動作チェック

ここまで実装できたらフロント側問題なく表示されているか確認してみます

  • ログインページ
    CleanShot 2024-11-30 at 07.43.37@2x.png
  • 新規アカウント作成ページ
    CleanShot 2024-11-30 at 07.44.35@2x.png
  • ログイン状態関係なくデータ取得できるページ
    CleanShot 2024-11-30 at 10.11.47@2x.png
  • ログイン状況関係なくデータ取得出来るページ(現状ログインしていないためToken not foundと表示されている)
    CleanShot 2024-11-30 at 07.47.05@2x.png

上手く動くところを見ると楽しいですね
CleanShot 2024-11-30 at 10.38.47.gif

今後

今回はuuidemailのみアカウント情報から取得していますが
カスタムクレームを使用すればより厳格な認証が行えると思うのでそちらも試せていければと思っています

0
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
0
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?