More Related Content
Similar to 実践 NestJS (20)
実践 NestJS
- 3. 触ってきたもの
言語
C++, Java, VBScript, PHP, C#, Ruby, JavaScript, TypeScript
フロントエンド開発
Flex(Flash), jQuery, AngularJS, Angular, React
アプリ開発
Android(Java), iOS(Objective-C), Capacitor
3
- 4. バックエンド開発
ASP, ASP<span>.</span>NET, Ruby on Rails, Node.js , NestJS
データベース/データストア
SQL Server, MySQL, DynamoDB, MongoDB , Redis
インフラ
オンプレミス, AWS, Render.com , Heroku
ハードウェア
Arduino, 自作ファミコン ROM 開発, 自作CPU開発
※ 強調 されているものが最近の自分のトレンドです
4
- 6. 開発中のプロダクト(弊社)
企業内の SaaS の契約情報、利用状況、シャドー IT、アカウント情
報、コスト情報を一元的に把握・管理することができる SaaS管理ツール
Google スライドやパワポ、PDF 資料を一元管理し、また、その資料
を組み合わせたり加工したりして新しいプレゼンテーションを作成・
公開できる 資料作成支援&公開ツール
https://www.preco.ink
※このスライドはMarpで作成した PDF を Preco で公開しています
6
- 8. 今回の趣旨
NestJS を導入することで Webアプリケーションによくある機能 を容易に開発
することができました
各機能の 実装ポイント を サンプルコードを交えて 紹介したいと思います
また、サンプルコードは紙面の都合上
かなり省略している部分があり、そのままでは動きません 。あくまでもこのよう
な処理を行うイメージと思って見ていただければと思います。実際は
みなさんの環境によって若干コードも異なるので、適宜実装をしてい
ただければと思います
8
- 10. 認証 の実装
認可 の実装
永続化(データベース) の実装
GraphQL API の実装
REST API の実装
バリデーション の実装
キューイング の実装
イベント処理 の実装
タスクスケジューリング の実装
データベースマイグレーション の実装
10
- 13. NestJS を導入する前は...
当初は AWS(Lambda, AppSync, Cognito, DynamoDB,
DocumentDB)を使い サーバーレス構成 で、ピュアな Node.js に DI ラ
イブラリだけ入れて開発
CDK での AWS の管理・デプロイやリリース管理に ツラさ を感じ
る。 開発速度も上がらない
Heroku で運用することにして脱 AWS、その際に何かしらの
フレームワークの必要性 を感じたため、NestJS を導入することにしま
した。
13
- 15. 選定理由
1. 認証・認可といった Web アプリに必須となる機能が
公式ドキュメントで丁寧に示されており NestJS Way が踏襲しやすい(バー
ジョンアップについていくのも容易だろうと感じた)
2. Express/Fastify や Mongoose/TypeORM など、
適材適所で自分の好きな技術を選択的に組み込み やすい思想
3. ドメイン駆動設計といった 自分のやりたいアーキテクチャを反映しやすい と
感じた(元のピュアな構成+α のイメージで開発できると感じた)
4. フロントエンドもバックエンドも TypeScript にしたい
15
- 20. 名前 管理
フロントエン
ドサーバー
Netlify でホスティング
API サーバー Heroku 上で並列で起動し API を提供する
バッチ処理
高負荷な処理は Lambda 上で起動した NestJS を呼
び出して処理
ファイル S3 と CloudFront で管理・配信
CDN
CloudFront で配信。API サーバーで署名付き URL
を生成してその URL からアクセス
データベース MongoDB Atlas でホスティング
キャッシュ Redis Enterprise でホスティング 20
- 29. ├─.circleci CircleCIの設定
├─.github GitHubの設定
├─.vscode vscodeの設定
├─dist ビルド結果出力
│ └─schema.graphql 自動生成されたschema.graphql
├─e2e E2Eテスト関連
│ ├─*** E2Eテストファイル群
│ └─jest.setup.ts Jestセットアップ処理
├─src
│ ├─@types グローバル型定義群
│ ├─common
│ │ ├─controllers 共通コントローラー群
│ │ ├─decorators 共通デコレータ群
│ │ ├─guards 共通ガード群
│ │ ├─exceptions 共通例外群
│ │ ├─interceptors 共通インターセプター群
│ │ ├─models 共通モデル群
│ │ ├─resolvers 共通リゾルバー群
│ │ ├─types 共通型定義群
│ │ ├─utils 共通ユーティリティ関数群
│ │ └─validators 共通バリデーター群
│ ├─entities エンティティ群
│ ├─events イベント群
│ ├─migrations マイグレーション群
│ ├─models モデル群
│ ├─modules モジュール群 - 各種モジュール・サブモジュール
│ │ └─***
│ │ ├─config 設定群
│ │ ├─domain-services ドメインサービス群
│ │ ├─entities モジュール内で利用するエンティティ群
│ │ ├─events イベント群
│ │ ├─mails メール群
│ │ ├─models モデル群
│ │ ├─processors キュー処理群
│ │ ├─queues キュー群
│ │ ├─resolvers リゾルバー群
│ │ ├─services サービス群
│ │ ├─subscribers サブスクライバー群
│ │ ├─tasks タスク群
│ │ ├─usecases ユースケース群
│ │ ├─utils モジュールごとのユーティリティ関数
│ │ └─*.module.ts モジュール定義
│ ├─app.module.ts メインモジュール
│ └─main.ts エントリーファイル
├─.env 環境変数ファイル
├─.env.local ローカル環境変数ファイル
├─.eslintrc.js ESLint設定ファイル
├─.gitignore gitignore
├─.prettierrc Prettier設定ファイル
├─.versionrc standard-version設定ファイル
├─CHANGELOG.md CHANGELOG(standard-versionで生成)
├─coverageconfig.json tscodecover設定ファイル
├─jest.config.js Jest設定ファイル
├─nest-cli.json NestJS用の設定ファイル
├─package-lock.json package.jsonのロックファイル
├─package.json パッケージファイル
├─Procfile Heroku起動設定ファイル
├─README.md README
├─tsconfig.build.json NestJSビルド時のTypeScriptの設定
└─tsconfig.json TypeScriptの設定
29
- 34. Passport 用のストラテジーを作成
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService, configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: configService.get("jwt.accessToken.secret"),
});
}
async validate(payload: AccessTokenPayload): Promise<UserContext> {
const user = await this.authService.validateAccessTokenPayload(payload);
if (!user) {
throw new UnauthorizedException();
}
// ユーザーコンテキスト(セッション情報と同じようなもの)を生成して返す
return UserContext.createBy({
...user,
originUserId: payload.originUserId,
});
}
}
34
- 36. 作成したストラテジーを使ったガードを作成
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {
getRequest(context: ExecutionContext): any {
const request = getRequestByExecutionContext(context);
return request;
}
handleRequest(err: any, user: any, info: any): any {
if (err || !user) {
throw err || new UnauthorizedException();
}
return user;
}
}
36
- 38. ログイン
async signIn(
props: SignInInput,
ctx: SignInContext
): Promise<SignInResponse> {
const user = await findOne(this.userRepository, {
id: props.userId,
});
if (!user) {
throw new UnauthorizedException();
}
if (!user.verifyPassword(props.password)) {
throw new UnauthorizedException();
}
const accessToken = this.generateAccessToken({
userId: user.id,
});
const refreshToken = this.generateRefreshToken({
userId: user.id,
refreshTokenId: refreshTokenEntity.id,
});
return {
token: {
accessToken,
refreshToken,
},
};
38
- 39. アクセストークン、リフレッシュトークンの生成
public generateAccessToken(
payload: AccessTokenPayload,
opts?: {
expiresIn?: string;
}
): string {
return this.jwtService.sign(payload, {
secret: this.configService.get('jwt.accessToken.secret'),
expiresIn:
opts?.expiresIn ?? this.configService.get('jwt.accessToken.expiresIn'),
subject: payload.userId,
});
}
private generateRefreshToken(payload: RefreshTokenPayload): string {
return this.jwtService.sign(payload, {
secret: this.configService.get('jwt.refreshToken.secret'),
expiresIn: this.configService.get('jwt.refreshToken.expiresIn'),
subject: payload.userId,
});
}
39
- 40. リフレッシュ
async refresh(props: RefreshInput, ctx: RefreshContext): Promise<AuthToken> {
const { refreshToken } = props;
const payload = this.verifyRefreshToken(refreshToken);
const user = await findOne(this.userRepository, {
id: payload.userId,
});
if (!user) {
throw new InternalServerErrorException('ユーザーが存在しません');
}
const accessToken = this.generateAccessToken({
userId: payload.userId,
});
return {
accessToken,
};
}
40
- 43. SAML 用のストラテジーを作成
@Injectable()
export class SamlStrategy extends PassportStrategy(MultiSamlStrategy, "saml") {
constructor(
@InjectRepository(Organization)
private readonly organizationRepository: MongoRepository<Organization>,
@InjectRepository(User)
private readonly userRepository: MongoRepository<User>
) {
super(
{
passReqToCallback: true,
43
- 44. getSamlOptions: async (
req: {
params: {
organizationId?: OrganizationId;
};
},
done: SamlOptionsCallback
) => {
// SAML関連の設定情報の取得と設定(省略)
done(null, {
cert: organization.ssoConfig.cert,
audience: organizationId,
});
},
},
44
- 45. async (
req: {
params: {
organizationId?: OrganizationId;
};
},
profile: {
nameID: string;
},
done: VerifiedCallback
) => {
const { organizationId } = req.params;
const { nameID } = profile;
if (!organizationId) {
done(new UnauthorizedException());
}
45
- 46. const user = await findOne(this.userRepository, {
organizationId,
email: nameID,
});
if (!user) {
done(new UnauthorizedException());
return;
}
done(null, {
...user,
});
}
);
}
}
46
- 48. SAML 用のガードをコントローラーに組み込む(SP Initiated)
@Controller("/api/saml")
export class SamlController {
constructor(
private readonly signInByUserUsecase: SignInByUserUsecase,
private readonly getSamlTargetUrlUsecase: GetSamlTargetUrlUsecase,
private readonly configService: ConfigService
) {}
@Post("/:organizationId/signIn")
@UseGuards(SamlAuthGuard)
async signIn(
@SamlUserContext() ctx: UserContext,
@Ip() ip: string
): Promise<SignInResponse> {
return this.signInByUserUsecase.execute(
{
userId: ctx.id,
},
{
...ctx,
ip,
}
);
}
48
- 49. IdP Initiated
@Post("/:organizationId/acs")
@Redirect()
async acs(
@Body() props: { SAMLResponse: string; RelayState: string },
@Param("organizationId") organizationId: OrganizationId
): Promise<{
url: string;
statusCode: number;
}> {
const baseUrl = this.configService.get("auth.baseUrl");
return {
url: `${baseUrl}/o/${organizationId}/saml?SAMLResponse=${encodeURIComponent(
props.SAMLResponse
)}`,
statusCode: 302,
};
}
} 49
- 55. @Entity()
@ObjectType()
export class User extends BaseEntity<User> {
static new(props: NewProps<User>): User {
return new User(props);
}
@Index()
@Column()
@Field(() => ID)
id: UserId;
@Index()
@Column()
@Field(() => [Role])
roles: Role[];
}
55
- 56. 2) ロールによるチェックを行う ガード を作成する
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return false;
}
const user = getUserByExecutionContext(context);
return requiredRoles.some((role) => user?.roles?.includes(role));
}
}
56
- 57. 3) ロールによるガードをコントローラーかリゾルバーに組み込む
@Roles(Role.User)
@Resolver(() => User)
export class UserResolver extends BaseAuthResolver {
constructor(
private readonly getUserUsecase: GetUserUsecase,
private readonly getUsersUsecase: GetUsersUsecase,
) {
super();
}
// こちらのユーザー取得はBaseAuthResolverの @Roles(Role.User) が効いている
@Query(() => User)
async user(
@Args('id', { type: () => ID }) id: UserId,
@CurrentUserContext() ctx: UserContext
): Promise<User> {
return this.getUserUsecase.execute(id, ctx);
} 57
- 58. // こちらのユーザー一覧は @Roles(Role.Admin) が効いている
@Query(() => PaginatedUser)
@Roles(Role.Admin)
async users(
@Args() props: GetUsersArgs,
@CurrentUserContext() ctx: UserContext
): Promise<PaginatedUser> {
return this.getUsersUsecase.execute(props, ctx);
}
}
58
- 61. CaslService を作成する
@Injectable()
export class CaslService {
for(user: User | UserContext) {
const { can, cannot, build } = new AbilityBuilder<
Ability<[Action, Subjects]>
>(Ability as AbilityClass<AppAbility>);
if (user.roles.includes(Role.user)) {
can(Action.Read, User, { organizationId: user.organizationId });
can(Action.Update, User, {
id: user.id,
roles: { $nin: [Role.Admin] },
});
}
if (user.roles.includes(Role.UserAdmin)) {
can(Action.Manage, "all");
}
const ability = build({
detectSubjectType: (item) =>
item.constructor as ExtractSubjectType<Subjects>,
});
return ability;
}
}
61
- 62. can(
ctx: UserContext,
action: Action,
subject:
| Subjects
| Subjects[]
| Paginated<Subjects>
| unknown
): boolean {
const ability = this.for(ctx);
if (Array.isArray(subject)) {
return subject.every((v) => ability.can(action, v));
} else if (
typeof subject === 'object' && 'pagination' in (subject as any)
) {
return (subject as Paginated<any>).data.every((v) => ability.can(action, v));
} else if (typeof subject === 'object') {
return ability.can(action, subject as Subjects);
} else {
return true;
}
}
62
- 63. CaslService を使った認可チェックをユースケース内で行う
// ユーザー取得のユースケース
@Injectable()
export class GetUserUsecase {
constructor(
private readonly caslService: CaslService,
@InjectRepository(User)
private readonly userRepository: MongoRepository<User>
) {}
async execute(id: UserId, ctx: UserContext): Promise<User> {
const user = await findOne(this.userRepository, { id });
if (!user) {
throw new NotFoundException();
}
if (this.caslService.cannot(ctx, Action.Read, user)) {
throw new ForbiddenException();
}
return user;
}
}
63
- 64. // ユーザー一覧のユースケース
@Injectable()
export class GetUsersUsecase {
constructor(
private readonly caslService: CaslService,
@InjectRepository(User)
private readonly userRepository: MongoRepository<User>
) {}
async execute(props: GetUsersArgs, ctx: UserContext): Promise<PaginatedUser> {
const data = await paginate(this.userRepository, props, {
...(props.organizationId
? {
organizationId: props.organizationId,
}
: {}),
});
if (this.caslService.cannot(ctx, Action.Read, data)) {
throw new ForbiddenException();
}
return data;
}
}
64
- 65. (Optional)Interceptor を使って API が返すデータを再度チェックす
る
/**
* CASLによるリソースベースの権限チェック
* ※サービス内でもチェックしているが、念の為最後のレスポンス時点でもチェックする
*/
@Injectable()
export class CaslInterceptor implements NestInterceptor {
constructor(private readonly caslService: CaslService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
tap((data) => {
const user = getUserByExecutionContext(context);
if (!user) {
return;
}
const ctx = UserContext.createBy(user);
if (this.caslService.cannot(ctx, Action.Read, data)) {
throw new ForbiddenException();
}
})
);
65
- 69. エンティティの定義
@Entity()
export class User {
static new(props: NewProps<User>): User {
return new User(props);
}
@Index()
@Column()
id: UserId;
@Index()
@Column()
name: string;
@Index()
@Column()
email: string;
}
69
- 70. エンティティの生成 と リポジトリ を通した 永続化
@Injectable()
export class CreateUserUsecase {
constructor(
private readonly caslService: CaslService,
@InjectRepository(User)
private readonly userRepository: MongoRepository<User>
) {}
async execute(props: CreateUserInput, ctx: UserContext): Promise<User> {
const user = User.new(props);
if (this.caslService.cannot(ctx, Action.Create, user)) {
throw new ForbiddenException();
}
await bulkSave(this.userRepository, user, ctx);
return user;
}
}
70
- 73. エンティティに GraphQL関連のデコレーター を付ける
@ObjectType()
export class User extends BaseEntity<User> {
static new(props: NewProps<User>): User {
return new User(props);
}
@Field(() => ID)
id: UserId;
@Field(() => GraphQLString)
name: string;
@Field(() => GraphQLString)
email: string;
@Field(() => ID)
organizationId: OrganizationId;
}
73
- 75. リゾルバー を実装する
@Resolver(() => User)
export class UserResolver extends BaseAuthResolver {
constructor(
private readonly getUsersUsecase: GetUsersUsecase,
private readonly getUserUsecase: GetUserUsecase,
private readonly updateUserUsecase: UpdateUserUsecase,
private readonly referenceService: ReferenceService
) {
super();
}
@Query(() => PaginatedUser)
async users(
@Args() props: GetUsersArgs,
@CurrentUserContext() ctx: UserContext
): Promise<PaginatedUser> {
return this.getUsersUsecase.execute(props, ctx);
}
@Query(() => User)
async user(
@Args('id', { type: () => ID }) id: UserId,
@CurrentUserContext() ctx: UserContext
): Promise<User> {
return this.getUserUsecase.execute(id, ctx);
} 75
- 76. @Mutation(() => GraphQLBoolean)
async updateUser(
@Args('props') props: UpdateUserInput,
@CurrentUserContext() ctx: UserContext
): Promise<boolean> {
return this.updateUserUsecase.execute(props, ctx);
}
@ResolveField(() => Organization, { nullable: true })
async organization(
@Parent() user: User,
@CurrentUserContext() ctx: UserContext
): Promise<Organization | undefined> {
return this.referenceService.getOrganization(user.organizationId, ctx);
}
}
76
- 79. user.graphql
query user($id: ID!) {
user(id: $id) {
id
name
email
organizationId
organization {
name
}
}
}
mutation updateUser($props: UpdateUserInput!) {
updateUser(props: $props)
}
79
- 81. generated.ts
export function useUserQuery(
baseOptions: Apollo.QueryHookOptions<UserQuery, UserQueryVariables>
) {
const options = { ...defaultOptions, ...baseOptions };
return Apollo.useQuery<UserQuery, UserQueryVariables>(UserDocument, options);
}
export function useUserLazyQuery(
baseOptions?: Apollo.LazyQueryHookOptions<UserQuery, UserQueryVariables>
) {
const options = { ...defaultOptions, ...baseOptions };
return Apollo.useLazyQuery<UserQuery, UserQueryVariables>(
UserDocument,
options
);
}
81
- 83. (Extra)ResolveField は N+1 問題を避けるためにdataloaderを導入
一定期間内の取得処理を束ねてひとつの問い合わせで取得してくれる
仕組み
function createIdDataLoader<T extends { id: string }>(
repository: MongoRepository<T>
) {
return new DataLoader(async (keys: readonly unknown[]) => {
const ids = [...keys] as string[];
const items = await find(repository, {
id: { $in: ids.map((v) => v) },
});
const itemsIdMap = arrayToMap(items, (v) => v.id);
return ids.map((id) => itemsIdMap.get(id));
});
}
83
- 84. reference.service.ts
@Injectable()
export class ReferenceService {
constructor(
private readonly caslService: CaslService,
@InjectRepository(Organization)
private readonly organizationRepository: MongoRepository<Organization>
) {}
private organizationLoader = createIdDataLoader(this.organizationRepository);
async getOrganization(
id: OrganizationId,
ctx: UserContext
): Promise<Organization | undefined> {
const data = await this.organizationLoader.load(id);
if (!data || this.caslService.cannot(ctx, Action.Read, data)) {
return;
}
return data;
}
}
84
- 85. @ResolveField(() => Organization, { nullable: true })
async organization(
@Parent() user: User,
@CurrentUserContext() ctx: UserContext
): Promise<Organization | undefined> {
return this.referenceService.getOrganization(user.organizationId, ctx);
}
85
- 88. @Controller("api/users")
export class UserController extends BaseApiController {
constructor(
private readonly getUserUsecase: GetUserUsecase,
private readonly updateUserUsecase: UpdateUserUsecase
) {
super();
}
@Get("/:userId")
async user(
@Param("userId")
userId: UserId,
@CurrentUserContext() ctx: UserContext
): Promise<User> {
return this.getUserUsecase.execute(userId, ctx);
}
@Put("/:userId")
async updateUser(
@Body()
props: UpdateUserInput,
@CurrentUserContext() ctx: UserContext
): Promise<boolean> {
return this.updateUserUsecase.execute(props, ctx);
}
}
88
- 91. グローバルパイプ に ValidationPipe を追加する
export async function bootstrap() {
const fastifyInstance = fastify.fastify();
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(fastifyInstance)
);
app.useGlobalPipes(new ValidationPipe());
await app.listen(process.env.PORT ?? 4000, "0.0.0.0");
}
91
- 92. 入力系のオブジェクトに バリデーション用のデコレーター を記載する
@InputType()
export class UpdateUserInput {
@Field(() => ID)
id: UserId;
@MaxLength(100)
@Field(() => GraphQLString, { nullable: true })
name?: string;
@MaxLength(100)
@IsEmail()
@Field(() => GraphQLString, { nullable: true })
email?: string;
}
92
- 95. 1) Redis を導入する
各々好きに Redis を導入してください
Docker を使う場合は下記のような docker-compose.yml を用意します
version: "3"
services:
mongo:
image: mongo:5.0
ports:
- "27017:27017"
redis:
image: redis
ports:
- "6379:6379"
95
- 97. scripts/db-start.ts
import { launchLocalRedis } from "../src/common/utils/createRedis";
(async function () {
await launchLocalRedis();
})();
package.json
"scripts": {
"start": "run-p db:start start:dev",
"start:dev": "nest start --watch",
"db:start": "ts-node scripts/db-start",
}
97
- 98. BullModule を導入する
@Module({
imports: [
BullModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
return {
redis: {
host: configService.get<string>("REDIS_HOST"),
port: configService.get<number>("REDIS_PORT"),
username: configService.get<string>("REDIS_USER"),
password: configService.get<string>("REDIS_PASSWORD"),
},
defaultJobOptions: {
removeOnFail: false,
removeOnComplete: false,
},
};
},
}),
],
controllers: [],
providers: [],
})
export class AppModule {}
98
- 99. キュー を追加する
modules/account/queues/index.ts
import { BullModule } from "@nestjs/bull";
export const QueueName = {
SyncCosts: "account::sync-accounts",
} as { [name: string]: string };
export default Object.values(QueueName).map((name) =>
BullModule.registerQueue({
name,
})
);
99
- 101. キューを処理する プロセッサー を追加する
@Processor(QueueName.SyncAccounts)
export class SyncAccountProcessor {
constructor(private readonly syncAccountUsecase: SyncAccountsUsecase) {}
@Process()
async process(job: Job): Promise<boolean> {
const { props, ctx } = parseJobData<SyncAccountsInput>(job.data);
this.syncAccountUsecase.execute(props, ctx);
}
}
101
- 103. キューにデータを投入する
@Injectable()
export class TriggerSyncAccountsUsecase {
constructor(
@InjectQueue(QueueName.SyncAccounts)
private readonly syncAccountsQueue: Queue<QueueProps<SyncAccountsInput>>
) {}
async execute(props: SyncAccountsInput, ctx: UserContext): Promise<JobId> {
const job = await this.syncAccountsQueue.add({ props, ctx });
return job.id;
}
}
103
- 106. イベントクラスを作成する
export class PresentationCreatedEvent extends BaseContextableEvent<PresentationCreatedEvent> {
static EventName = "presentation.created";
static new(
props: NewProps<PresentationCreatedEvent>
): PresentationCreatedEvent {
return new PresentationCreatedEvent(props);
}
/**
* プレゼンテーション
*/
presentation: Presentation;
}
106
- 107. ユースケース内でイベントを発火する
@Injectable()
export class CreatePresentationUsecase {
constructor(
private readonly caslService: CaslService,
private readonly eventEmitter: EventEmitter2,
@InjectRepository(Presentation)
private readonly presentationRepository: MongoRepository<Presentation>
) {}
async execute(
props: CreatePresentationInput,
ctx: UserContext
): Promise<Presentation> {
// 作成処理(省略)
await this.eventEmitter.emitAsync(
PresentationCreatedEvent.EventName,
PresentationCreatedEvent.new({
presentation,
ctx,
})
);
return presentation;
}
}
107
- 108. サブスクライバーでイベントを処理する
/**
* プレゼンテーションが追加されたらチームのプレゼン数を更新する
*/
@Injectable()
export class PresentationCreatedEventSubscriber extends UpdateNumberOfPresentationsSubscriber {
@OnEvent(PresentationCreatedEvent.EventName)
@CatchEventHandleError()
async handle(event: PresentationCreatedEvent): Promise<void> {
const { teamId, ctx } = event;
// プレゼン数を取得
const numberOfPresentations = await count(this.presentationRepository, {
teamId,
});
// 保存
await bulkPatch(
this.teamRepository,
{
id: teamId,
numberOfPresentations,
},
ctx
);
}
}
108
- 109. export function CatchEventHandleError() {
return function (target: any, propertyName: any, descriptor: any) {
const method = descriptor.value;
descriptor.value = async function (...args: any) {
try {
return await method.apply(this, args);
} catch (e) {
console.error(e);
}
};
};
}
109
- 116. TypeOrmModule に マイグレーションの設定 を行う
@Module({
imports: [
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (configService: ConfigService) => {
return {
// 省略
migrations: isProduction
? ["dist/migrations/**/*{.ts,.js}"]
: undefined,
migrationsRun: isProduction,
};
},
}),
],
controllers: [],
providers: [],
})
export class AppModule {}
116
- 120. データベースマイグレーションの実装
Nestjs - file upload with fastify multipartを参考に実装しましょう
(fastify の例になります)
また、GraphQL の API でファイルアップロードを実現するには
Base64 エンコードした状態で送る必要があり、ファイルサイズが増
大し現実的ではないため、REST API を使います
ファイルアップロードの実装ポイントは 2 つです
1. UploadGuard を作成する
2. FileController を作成する
120
- 121. UploadGuard を作成する
@Injectable()
export class UploadGuard implements CanActivate {
public async canActivate(ctx: ExecutionContext): Promise<boolean> {
const req = ctx.switchToHttp().getRequest() as CustomFastifyRequest;
const isMultipart = req.isMultipart();
if (!isMultipart) {
throw new BadRequestException("multipart/form-data expected.");
}
const file = await req.file();
if (!file) {
throw new BadRequestException("file expected");
}
req.incomingFile = file;
return true;
}
}
121
- 122. import { FastifyRequest } from "fastify";
export type CustomFastifyRequest = FastifyRequest & {
incomingFile: Storage.MultipartFile;
};
122
- 123. FileController を作成する
@Controller("api/files")
export class FileController extends BaseAuthController {
constructor(private readonly fileService: FileService) {
super();
}
@Post()
@UseGuards(UploadGuard)
async upload(@File() props: Storage.MultipartFile): Promise<string> {
return await this.fileService.uploadByMultipartFile(props);
}
}
123
- 124. import { createParamDecorator, ExecutionContext } from "@nestjs/common";
import { CustomFastifyRequest } from "src/models/custom-fastify-request";
export const File = createParamDecorator(
(_data: unknown, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest() as CustomFastifyRequest;
const file = req.incomingFile;
return file;
}
);
124
- 125. @Injectable()
export class FileService {
private readonly s3: S3;
constructor(private readonly configService: ConfigService) {
this.s3 = new S3({
region: configService.get('s3.region'),
credentials: {
accessKeyId: configService.get('s3.accessKeyId'),
secretAccessKey: configService.get('s3.secretAccessKey'),
},
});
}
125
- 126. async upload(props: Storage.MultipartFile): Promise<void> {
const { filename, mimetype } = props;
const body = await props.toBuffer();
const ext = path.extname(filename);
const id = generateUuid();
const key = `upload/${id}${ext}`;
await this.s3.putObject({
Bucket: 'example',
Key: key,
Body: body,
ContentType: mimetype,
});
}
}
126
- 128. CDN まわりの実装
アップロードしたファイルを配信する際、API サーバーを経由せずに
Amazon CloudFront といった CDN から配信したくなると思います。
その際気になるのが
CDNで配信したら権限チェックなく誰でも見れるようになってしまうのでは? です
弊社では@ResolveField で URL を有効期限を短くした署名付き URLに
変えることで
APIサーバーで権限チェックしてURLを発行し、そのURLでファイルにアクセスさせる と
いうことができるようになります。
128
- 132. URL を署名して返す
import { CloudFront } from "aws-sdk";
@Injectable()
export class FileService {
private readonly signer: CloudFront.Signer;
constructor(private readonly configService: ConfigService) {
this.signer = new CloudFront.Signer(
this.configService.get("cf.keypairId"),
this.configService.get("cf.privateKey")
);
}
// 署名付きURLを生成して返す
generateSignedUrl(url: string, ctx: { now: Date }): string {
return this.signer.getSignedUrl({
url,
expires: getUnixTime(addMinutes(ctx.now, 120)), // 有効期限は2時間
});
}
}
132
- 133. user.entity.ts
@ObjectType()
export class User extends BaseEntity<User> {
static new(props: NewProps<User>): User {
return new User(props);
}
@Field(() => ID)
id: UserId;
@Field(() => GraphQLString)
name: string;
// 画像フィールドを追加
@Field(() => GraphQLString)
image?: string;
}
133
- 134. user.resolver.ts
@Resolver(() => User)
export class UserResolver extends BaseAuthResolver {
constructor() {
super();
}
@ResolveField(() => GraphqlString, { nullable: true })
async image(
@Parent() parent: User,
@CurrentUserContext() ctx: UserContext
): Promise<string | undefined> {
if (!parent.image) {
return;
}
return this.fileService.generateSignedUrl(parent.image, ctx);
}
}
134
- 138. I18nServce の作成
@Injectable()
export class I18nService {
private readonly i18n: I18n;
constructor() {
// Lambda対応はパス解決のために別途対応が必要ですが割愛
this.i18n = new I18n();
this.i18n.configure({
locales: ['en', 'ja'],
directory: path.join(__dirname, 'locales')
updateFiles: false,
});
}
t(phrase: string, ctx: { locale?: 'ja' | 'en' }): string {
const { locale } = ctx;
return this.i18n.__({
phrase,
locale,
});
}
}
138
- 140. I18nService の利用
@Injectable()
export class GetUserUsecase {
constructor(
private readonly caslService: CaslService,
private readonly i18n: I18nService,
@InjectRepository(User)
private readonly userRepository: MongoRepository<User>
) {}
async execute(id: UserId, ctx: UserContext): Promise<User> {
const user = await findOne(this.userRepository, { id });
if (!user) {
throw new NotFoundException(this.i18n.t("ユーザーが見つかりません", ctx));
}
if (this.caslService.cannot(ctx, Action.Read, user)) {
throw new ForbiddenException(
this.i18n.t("ユーザーを閲覧する権限がありません", ctx)
);
}
return user;
}
}
140
- 143. aws-serverless-fastify/express を導入し起動関数を調整
$ npm i aws-serverless-fastify
export async function bootstrap() {
const fastifyInstance = fastify.fastify();
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(fastifyInstance)
);
await app.listen(process.env.PORT ?? 4000, "0.0.0.0");
// Fastifyのインスタンスも返すようにする
return {
app,
fastifyInstance,
};
}
143
- 144. Lambda のハンドラーを用意する handlers/api/handler.ts
import {
Context,
APIGatewayProxyEvent,
APIGatewayProxyResult,
} from "aws-lambda";
import * as fastify from "fastify";
import { proxy } from "aws-serverless-fastify";
import { bootstrap } from "src/bootstrap";
let fastifyInstance: fastify.FastifyInstance;
export const handle = async (
event: APIGatewayProxyEvent,
context: Context
): Promise<APIGatewayProxyResult> => {
if (!fastifyInstance) {
const res = await bootstrap();
fastifyInstance = res.fastifyInstance;
}
return await proxy(fastifyInstance, event, context);
};
144
- 147. api:
# url: true
memorySize: 512
timeout: 30
image:
name: appimage
command:
- /app/dist/handlers/api/handler.handle
entryPoint:
- "/lambda-entrypoint.sh"
events:
- http:
cors: true
path: "/"
method: any
- http:
cors: true
path: "{proxy+}"
method: any
147
- 151. オフロード処理の実装ポイントは 3 + 1 つです
1. スタンドアロン起動関数を用意する
2. Lambda のハンドラーを用意する
3. (Optional)Serverless Framework でデプロイ
4. InvokeService を用意して Lambda を呼び出す
151
- 152. スタンドアロン起動関数を用意する
export async function bootstrapStandalone(): Promise<{
app: INestApplicationContext;
}> {
const app = await NestFactory.createApplicationContext(AppModule);
return { app };
}
152
- 153. Lambda のハンドラーを用意する handlers/sync-accounts/handler.ts
let app: INestApplicationContext;
export async function handle(event: {
props: SyncAccountsInput;
ctx: UserContext;
}): Promise<boolean> {
if (!app) {
const res = await bootstrapStandalone();
app = res.app;
}
const { props, ctx } = event;
try {
const usecase = app
.select(AccountModule)
.get(SyncAccountsUsecase, { strict: true });
return usecase.execute(props, UserContext.createBy(ctx));
} catch (e) {
console.dir(e);
throw e;
}
}
153
- 155. InvokeService を用意して Lambda を呼び出す
import * as AWS from 'aws-sdk';
@Injectable()
export class InvokeService {
constructor(private readonly configService: ConfigService) {}
async syncAccounts(
props: SyncAccountsInput,
ctx: UserContext,
localHandler: (event: {
props: SyncAccountsInput;
ctx: UserContext;
}) => Promise<boolean>
): Promise<boolean> {
return this.invoke(this.configService.get('invoke.syncAccountsName'), props, ctx, localHandler);
}
155
- 156. private async invoke<P, R>(
functionName: string,
props: P,
ctx: any,
localHandler: (event: { props: P; ctx: any }) => R
): Promise<R> {
const type = this.configService.get('invoke.type');
if (type === 'lambda') {
const lambda = new AWS.Lambda({
region,
credentials: {
accessKeyId: accessKey,
secretAccessKey: secretAccessKey,
},
});
const payload = JSON.stringify({
props,
ctx,
});
156
- 157. const res = await lambda
.invoke({
FunctionName: functionName,
InvocationType: 'RequestResponse',
Payload: payload,
})
.promise();
const data = res.$response.data?.Payload
? JSON.parse(res.$response.data.Payload.toString())
: undefined;
if (data != null && typeof data === 'object' && 'errorType' in data) {
switch (data.errorType) {
case 'NotFoundException':
throw new NotFoundException(data.errorMessage);
case 'ForbiddenException':
throw new ForbiddenException(data.errorMessage);
default:
throw new InternalServerErrorException(data.errorMessage);
}
}
return data;
} else {
return await localHandler({ props, ctx });
}
}
}
157
- 158. @WorkerProcessor(QueueName.SyncAccounts)
export class SyncAccountProcessor {
constructor(
private readonly invokeService: InvokeService,
private readonly syncAccountUsecase: SyncAccountsUsecase
) {}
@Process()
async process(job: Job): Promise<boolean> {
const { props, ctx } = parseJobData<SyncAccountsInput>(job.data);
return await this.invokeService.syncAccounts(props, ctx, ({ props, ctx }) =>
this.syncAccountUsecase.execute(props, ctx)
);
}
}
158
- 162. export async function createTestApp(name: string): Promise<TestApp> {
const dbName = generateTestDbName(name);
const [mongo, redis] = await Promise.all([
createMongodb(dbName),
createRedis(),
]);
const [mongoUrl, redisHost, redisPort] = await Promise.all([
mongo.getUri(),
redis.getHost(),
redis.getPort(),
]);
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [
TestAppModule.forRoot({
mongoUrl,
redisHost,
redisPort,
}),
],
}).compile();
const app = moduleFixture.createNestApplication(new FastifyAdapter());
await app.init();
return {
app,
mongo,
redis,
};
}
162
- 163. export async function destroyTestApp(props: TestApp): Promise<void> {
const { app, mongo, redis } = props;
await app.close();
await Promise.all([mongo.stop(), mongo.cleanup(), redis.stop()]);
}
163
- 165. beforeAll(async () => {
const testApp = await createTestApp("GetUsersUsecase");
const { app } = testApp;
const adminService = app.get(AdminService);
const [org1] = await Promise.all([
adminService.adminCreateOrganization({
workspace: "org1.com",
name: "org1",
}),
]);
const [a] = await Promise.all([
adminService.adminCreateMember({
name: "Aさん",
organizationId: org1.id,
}),
]);
_ = {
testApp,
adminService,
org1,
a,
};
});
165
- 166. beforeEach(async () => {
await _.adminService.userRepository.deleteMany({});
});
afterAll(async () => {
await destroyTestApp(_.testApp);
});
166
- 167. test("組織IDで絞り込む", async () => {
const usecase = _.testApp.app.get(GetUsersUsecase);
await Promise.all([
_.adminService.adminCreateUser({
name: "Bさん",
organizationId: _.org1.id,
}),
_.adminService.adminCreateUser({
name: "Cさん",
organizationId: _.org1.id,
}),
]);
const { data } = await usecase.execute(
{
organizationId: _.org1.id,
},
UserContext.createBy(_.a)
);
expect(data.length).toBe(3);
expect(data.map((v) => v.name)).toEqual(
expect.arrayContaining(["Aさん", "Bさん", "Cさん"])
);
});
});
167
- 170. 入力パラメータークラスに@ApiProperty デコレーターを付ける
@ArgsType()
export class GetAccountsArgs extends PaginationArgs {
@ApiProperty({
required: true,
description: "組織ID",
})
@Field(() => ID)
organizationId: OrganizationId;
@ApiProperty({
required: false,
description: "契約ID",
})
@Field(() => ID, { nullable: true })
contractId?: ContractId;
}
170
- 171. コントローラーに ApiTags, ApiOperation デコレータを付ける
@ApiTags("アカウント管理")
@Controller("api/accounts")
export class AccountController extends BaseApiController {
constructor(private readonly getAccountsUsecase: GetAccountsUsecase) {
super();
}
@ApiOperation({ summary: "アカウント一覧" })
@Get()
async accounts(
@Query() props: GetAccountsArgs,
@CurrentUserContext() ctx: UserContext
): Promise<PaginatedAccount> {
return this.getAccountsUsecase.execute(props, ctx);
}
}
171
- 172. SwaggerModule を組み込む
export async function bootstrap() {
const fastifyInstance = fastify.fastify();
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter(fastifyInstance)
);
app.useGlobalPipes(new ValidationPipe());
// Swagger
const config = new DocumentBuilder()
.setTitle("エンドポイント一覧")
.setDescription("提供しているAPIのエンドポイント一覧です")
.setVersion("1.0")
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup("docs", app, document);
await app.listen(process.env.PORT ?? 4000, "0.0.0.0");
}
172