GraphQL subscriptions์ GraphQL์์ subscriptions์ ๊ตฌํํ๊ธฐ ์ํด pubsub ์์คํ
๊ณผ GraphQL์ ์ฐ๊ฒฐํ ์ ์๋ Npm ํจํค์ง์ด๋ค.
๋ชจ๋ GraphQL ํด๋ผ์ด์ธํธ ๋ฐ ์๋ฒ์ ํจ๊ป ์ฌ์ฉํ ์ ์๋ค.
npm i graphql-subscriptions
https://www.npmjs.com/package/graphql-subscriptions
๋ค์๊ณผ ๊ฐ์ด PubSub ์ธ์คํด์ค๋ฅผ ์์ฑํ๊ณ Subscription decorator๋ฅผ ์ฌ์ฉํด pubsub.asyncIterator๋ฅผ return ํ ๊ฒฝ์ฐ "error": "Could not connect to websocket endpoint ws://localhost:3000/graphql. Please check if the endpoint url is correct." ์ ๊ฐ์ ์๋ฌ๋ฅผ returnํ๋ค.
์ฐ๋ฆฌ ์๋ฒ๋ http ํ๋กํ ์ฝ์ ์ฌ์ฉํ๊ณ ์๋๋ฐ ws ๋ํ ํ๋กํ ์ฝ์ด๋ฉด realtime์ ์ฒ๋ฆฌํ๋ WebSocket์ ๋งํ๋ค. mutation๊ณผ query๋ http๋ฅผ ํ์๋ก ํ๊ณ subscription์ webSocket์ ํ์๋ก ํ๊ธฐ๋๋ฌธ์ ๋ ๊ณณ์์ ๋ชจ๋ ์๋ฒ๊ฐ ๋์๊ฐ ์ ์์ด์ผํ๋ค.
const pubsub = new PubSub();
@Subscription((returns) => String)
hotPotatos() {
return pubsub.asyncIterator('hotPotatos');
}
subscriptions์ ํ์ฑํํ๋ ค๋ฉด app module์์ ๋ค์๊ณผ ๊ฐ์ด ์ค์ ํด์ผํ๋ค. ์ด๋ ๊ฒ ํ๋ฉด ์๋ฒ์์ ๋๊ฐ์ง ํ๋กํ ์ฝ์ ๋ชจ๋ ์ฌ์ฉํ ์ ์๊ฒ๋๋ค.
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
subscriptions: {
'graphql-ws': true
},
})
๋จผ์ Guard์ AuthUser ๋ฐ์ฝ๋ ์ดํฐ์์ user๋ฅผ ๋ฐ์์ค๋ ๊ณผ์ ์ ๋ํด ์์๋ณด์
๋ก๊ทธ์ธํด์ ๋ฐ์ ํ ํฐ์ http header์ ๋ฃ์ด์ request๋ฅผ ๋ณด๋ด๋ฉด jwtmiddleware์์ ํ ํฐ์ ๋ฐ์ decodingํ ํ user๋ฅผ req์ ๋ฃ์ด์ ๋ฆฌํดํ๋ค.
๊ทธ๋ผ graphql module์์ context์ req๋ฅผ ๋ฃ์ด guard์์ ๋ฐ์ ํ context๋ฅผ ํตํด role์ ์์๋ผ ์ ์๋ค.
ํ์ง๋ง middleware๋ฅผ ์ฌ์ฉํด์๋ request๋ง ๋ฐ์์ฌ ์ ์์ด httpํ๋กํ ์ฝ๋ง ์ฌ์ฉ์ด ๊ฐ๋ฅํ๋ฉฐ connection์ ๋ฐ์์์ผํ๋ wsํ๋กํ ์ฝ์ ์ฌ์ฉ์ด ๋ถ๊ฐ๋ฅํ๋ค. ๊ทธ๋์ app module์์ middleware๋ฅผ ์ ๊ฑฐํด์ค ํ graphql module์ ์๋์ ์ฝ๋๋ฅผ ์ถ๊ฐํด์ค๋ค.
subscriptions ๋ด๋ถ์ ์ฝ๋๋ connection์ ํ ํฐ์ ์ถ๊ฐํด์ฃผ๋ ์ฝ๋์ด๊ณ ์๋์ context ๋ด๋ถ์ ์ฝ๋๋ req์ ํ ํฐ์ ์ถ๊ฐํด์ฃผ๋ ์ฝ๋์ด๋ค.
GraphQLModule.forRoot({
driver: ApolloDriver,
subscriptions: {
'subscriptions-transport-ws': {
onConnect: (connectionParams) => {
console.log('connectionParams', connectionParams);
const authToken = connectionParams['x-jwt'];
if (!authToken) {
throw new Error('Token is not valid');
}
const token = authToken;
return { token };
},
},
},
context: ({ req }) => ({ token: req.headers['x-jwt'] }),
}),
์ดํ Guard์์ ํ ํฐ์ ๋ฐ์ middleware์์ ํ๋ decoding์ Guard์์ ์๋์ ์ฝ๋์ ๊ฐ์ด ์งํํ๋ค.
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly jwtService: JwtService,
private readonly userService: UserService,
) {}
async canActivate(context: ExecutionContext) {
const roles = this.reflector.get<AllowedRoles>(
'roles',
context.getHandler(),
);
if (!roles) {
return true;
}
const gqlContext = GqlExecutionContext.create(context).getContext();
const token = gqlContext.token;
if (token) {
const decoded = this.jwtService.verify(token.toString());
if (typeof decoded === 'object' && decoded.hasOwnProperty('id')) {
const { user } = await this.userService.findById(decoded['id']);
if (user) {
gqlContext['user'] = user;
if (roles.includes('Any')) {
return true;
}
return roles.includes(user.role);
}
}
}
return false;
}
}
PubSub์ App ์ ์ฒด์์ ํ๋์ ์ธ์คํด์ค๋ง ์์ฑ๋์ด์ผ ํ๋ค. ์ด๋ฅผ ์ํด ์ธ์คํด์ค๋ฅผ common Module์ ๋ค์๊ณผ ๊ฐ์ด ์ ์ธํ ํ common Module์ @Global() ์ฒ๋ฆฌ ํด์ค๋ค.
const pubsub = new PubSub();
@Global()
@Module({
providers: [
{
provide: PUB_SUB,
useValue: pubsub,
},
],
exports: [PUB_SUB],
})
export class CommonModule {}
Resolver์์๋ PubSub์ ์ฌ์ฉํ๊ณ ์ ํ ๋๋ ๋ค์๊ณผ ๊ฐ์ด @Inject๋ฅผ ํตํด ์ฌ์ฉํ ์ ์๋ค
@Resolver(of => Order)
export class OrderResolver {
constructor(
@Inject(PUB_SUB) private readonly pubSub: PubSub,
) {}
Subscriptions์ ์ฌ์ฉํ ๋ ํน์ ์ด๋ฒคํธ๋ฅผ ํํฐ๋งํ๋ ค๋ฉด ํํฐ ์์ฑ์ ํํฐ ํจ์๋ก ์ค์ ํ ์ ์๋ค.
//EX
@Subscription(returns => Comment, {
filter: (payload, variables, context) =>
payload.commentAdded.title === variables.title,
})
commentAdded(@Args('title') title: string) {
return pubSub.asyncIterator('commentAdded');
}
@Mutation((returns) => Boolean)
async potatoReady(@Args('potatoId') potatoId: number) {
await this.pubSub.publish('hotPotatos', {
readyPotato: potatoId,
});
return true;
}
@Subscription((returns) => String, {
filter: ({ readyPotato }, { potatoId }) => {
return readyPotato === potatoId;
},
})
@Role(['Any'])
readyPotato(@AuthUser() user: User, @Args('potatoId') potatoId: number) {
return this.pubSub.asyncIterator('hotPotatos');
}
potatoId๊ฐ ๊ฐ์ ๊ฒฝ์ฐ์๋ง ๊ฒฐ๊ณผ๋ฅผ Loadํ๋ค.
https://docs.nestjs.com/graphql/subscriptions#filtering-subscriptions
resolverํจ์๊ฐ ๋ฆฌํดํ๋ ๊ฐ์ pubsub.asyncIterator()๋ฅผ ํตํด ๋ฐ๋ ๊ฐ์ด ๋๋ค. publishํ event payload๋ฅผ ๋ณํํ๋ ค๋ฉด resolve ์์ฑ์ ํจ์๋ก ์ค์ ํ๋ค. ํจ์๋ ์ด๋ฒคํธ payload๋ฅผ ์์ ํ๊ณ ์ ์ ํ ๊ฐ์ ๋ฐํํ๋ค.
@Subscription((returns) => String, {
filter: ({ readyPotato }, { potatoId }) => {
return readyPotato === potatoId;
},
resolve: ({ readyPotato }) =>
`Your potato with the id ${readyPotato} is ready!`,
})
//orders.service.ts / createOrder part
await this.pubSub.publish(NEW_PENDING_ORDER, {
pendingOrders: { order, ownerId: restaurant.ownerId },
});
//orders.resolver.ts
@Subscription((returns) => Order, {
filter: ({ pendingOrders: { ownerId } }, _, { user }) => {
return ownerId === user.id;
},
resolve: ({ pendingOrders: { order } }) => order,
})
@Role(['Owner'])
pendingOrders() {
return this.pubSub.asyncIterator(NEW_PENDING_ORDER);
}
service์ payload์ Order์ด ์๋ {pendingOrders: { order, ownerId: restaurant.ownerId }} ์ด์ ๊ฐ์ Object๋ฅผ ๋ฃ์๊ธฐ ๋๋ฌธ์ Resolve Option์ ํตํด Order๋ฅผ ๋ฐํํด์ค์ผํ๋ค.
Eager relation์ ๋ฐ์ดํฐ๋ฒ ์ด์ค์์ ์ํฐํฐ๋ฅผ ๋ก๋ํ ๋๋ง๋ค ์๋์ผ๋ก relation ํ๋๋ค์ ๋ก๋ํ๋ค. (eager: true๋ฅผ ์ถ๊ฐ)
@ManyToMany(type => Category, category => category.questions, {
eager: true
})
@JoinTable()
categories: Category[];
https://orkhan.gitbook.io/typeorm/docs/eager-and-lazy-relations#eager-relations
Lazy relation์ ํด๋น ํ๋์ ์ ๊ทผํ๋ฉด ๋ก๋๋๋ค. Lazy relation์ ํ์ ์ผ๋ก Promise๋ฅผ ๊ฐ์ ธ์ผ ํ๋ค. Promise์ ๊ฐ์ ์ ์ฅํ๊ณ ๋ก๋ํ ๋๋ Promise๋ฅผ ๋ฐํํ๋ค.
@ManyToMany(type => Question, question => question.categories)
questions: Promise< Question[]>;
@ManyToMany(type => Category, category => category.questions)
@JoinTable()
categories: Promise< Category[]>;
const question = await connection.getRepository(Question).findOne(1);
const categories = await question.categories;
https://orkhan.gitbook.io/typeorm/docs/eager-and-lazy-relations#lazy-relations