graphql yoga에서는 pubsub(graphql-subscriptions) 을 이용하여 subscription을 사용해보았습니다.
darrengwon.tistory.com/931?category=900016
여기서는 Nest에서 Subscription을 사용하는 방법을 알아보려고 합니다.
역시나 개발 시에는 graphql-subscriptions
실제 배포시에는 graphql-redis-subscriptions (저는 Redis가 좋습니다...)
docs.nestjs.com/graphql/subscriptions
Enable subscriptions
To enable subscriptions, set the installSubscriptionHandlers property to true.
GraphQLModule.forRoot({ installSubscriptionHandlers: true, })
Context function param: connection
Next에는 없지만 중요하니 apollo 관련 문서를 읽어봅시다.
www.apollographql.com/docs/apollo-server/data/subscriptions/#context-with-subscriptions
The function to create a context for subscriptions includes connection, while the function for Queries and Mutations contains the arguments for the integration, in express's case req and res. This means that the context creation function needs to check the input. This is especially important, since the auth tokens are handled differently depending on the transport:
그러니까, context function의 인자로 subscription은 connection, query와 mutation은 req, res를 가지고 있습니다.
이를 구분하는 것은 중요한데, auth token이 어떤 프로토콜을 사용하는지에 따라 다르게 다뤄져야 하기 때문입니다.
connection이 있으면 ws 프로토콜, req가 있으면 http 프로토콜.
매 요청마다 들어오는 req와는 달리 connection은 ws 연결되는 시점 1번만 출력된다.
const server = new ApolloServer({
schema,
context: async ({ req, connection }) => {
if (connection) {
// check connection for metadata
return connection.context;
} else {
// check from req
const token = req.headers.authorization || "";
return { token };
}
},
});
connection contains various metadata, found here.
문서의 connection에는 아래와 같은 meta 데이터가 있습니다. 적절히 사용하시면 됩니다.
[
'query',
'variables',
'operationName',
'context',
'formatResponse',
'formatError',
'callback',
'schema'
]
Code first 방식에서의 Subscription 기본
schema first 방식은 여기서는 다루지 않겠습니다.
graphql-subscriptions의 pubsub을 이용하여 아래와 같이 Subscription resolver를 작성할 수 있습니다.
Nest에서도 경고하고 있지만, production에서는 적합하지 않습니다.
PubSub is a class that exposes a simple publish and subscribe API. Read more about it here. Note that the Apollo docs warn that the default implementation is not suitable for production (read more here). Production apps should use a PubSub implementation backed by an external store (read more here).
예를 들면 Redis, Memcache와 같은. 외부 strore를 사용하라는 것이죠. Redis는 제 블로그에서도 정리해두었으니 참고!
const pubSub = new PubSub();
@Resolver(of => Author)
export class AuthorResolver {
// ...
@Subscription(returns => Comment)
commentAdded() {
return pubSub.asyncIterator('commentAdded');
}
}
* pub/sub model에 대해서는 아래 Redis와 곁들여 쓴 글을 보시면 쉽게 이해하실 수 있습니다.
이제 기본적인 pub/sub 모델을 Subscription을 이용하여 만들어봅시다.
1. 특정 Subscription에서 들을 수 있는 topic을 지정합니다. pubsub.asyncIterator('topic')
2. subscription을 해줬으니 publish를 어디선가 해줘야겠죠. pubsub.publish('topic', {subName: any}) 꼴로 보내줍니다.
3. 이제, subscription을 시작하고, publish를 해주면 publish의 payload 부분을 받아볼 수 있습니다.
* 현재 어떤 유저라도 Subscription을 할 수 있습니다. 이를 방지하기 위해선 별도의 조치를 취해야 합니다.
import { PubSub } from 'graphql-subscriptions';
const pubsub = new PubSub();
@Resolver(() => Order)
export class OrderResolver {
... 중략
@Mutation(() => Boolean)
anythingReady() {
pubsub.publish('anything', { anythingSubscription: 'publishing by anythingReady' });
return true;
}
@Subscription(() => String)
anythingSubscription() {
return pubsub.asyncIterator('anything');
}
}
global pubsub
pubsub을 특정 resolver에서만 두는 것은 좋지 않습니다. 모든 resolver에서 사용할 수 있도록 최상위 경로에 두는 것이 좋습니다.
따라서 Global 모듈을 하나 만든 후, provider로 제공해준 다음에 appModule에 추가해주었습니다.
export에 주의합시다.
@Global()
@Module({
providers: [{ provide: PUB_SUB, useValue: new PubSub() }],
exports: [PUB_SUB],
})
export class CommonModule {}
@Inject해서 사용하면 됩니다.
@Resolver(() => Order)
export class OrderResolver {
constructor(private readonly orderService: OrderService, @Inject(PUB_SUB) private readonly pubSub: PubSub) {}
@Mutation(() => Boolean)
anythingReady() {
this.pubSub.publish('anything', { anythingSubscription: 'publishing by anythingReady' });
return true;
}
@Subscription(() => String)
@Role(['Any'])
anythingSubscription(@AuthUser() user: User) {
console.log(user);
return this.pubSub.asyncIterator('anything');
}
}
Authentication Over WebSocket
graphql-yoga에서는 withFilter를 사용하여 특정 유저만 Subscription을 할 수 있도록 한 바 있습니다.
darrengwon.tistory.com/932?category=900016
이번에는 Next에서 Subscription을 특정 유저만 사용할 수 있도록 보호해봅시다.
일반적으로, http에 jwt 토큰을 주고, decode하는 작업에서는 아래와 같은 절차를 걸쳐왔습니다.
[http 메서드만 사용할 경우 토큰을 이용해 authentication하기 ]
(1) http 메서드에서 header에 토큰을 보낸다.
(2) jwtMiddleware를 appmodule에 달아 req에 user를 달아준다
(3) GraphQLModule의 req에서 user를 추출하여 context에 담아준다.
(4) 이 grapqhl의 context에서 return하는 값은 @Context() context를 통해 user를 resolver에서 사용할 수 있다.
혹은, customDecorator에서 context를 통해 추출하여 한 번 거른 다음 resolver에서 사용할 수도 있다.
이 context는 guard에서 canActivate 함수의 인자인 ExecutionContext에서도 사용할 수도 있다.
그러나 ws(Subscription)을 사용한다면 jwtMiddleware를 사용할 수 없습니다
AppModule에서 미들웨어를 지워주고 대신 Guard를 활용해보자.
1. 우선, 미들웨어 없이 곧바로 넘어온 request(http) 혹은 connection(ws)를 받은 GraphQLModule에서 context를 통해 헤더에 붙어온 토큰을 받아 다른 곳에서 context로 활용할 수 있도록 return해줍니다.
context: ({ req, connection }) => {
const TOKEN_KEY = 'x-jwt';
// http
if (req) {
return { token: req.headers[TOKEN_KEY] }; // 헤더에 있는 토큰만 쓸거니 헤더 중 토큰만 솎아서 보내자
}
// ws. connection은 ws 연결시 딱 한 번만 반환됨
if (connection) {
return { token: connection.context[TOKEN_KEY] }; // connection.context 에 토큰이 들어 있다
}
},
2. 해당 context에서 찾은 token을 이용하여 유저를 찾고, 이를 context에 담아서 다른 곳에서도 사용할 수 있도록 조치합니다.
@Injectable()
export class AuthGaurd implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly jwtService: JwtService,
private readonly userService: UsersService,
) {}
async canActivate(context: ExecutionContext) {
// gql-context
const gqlContext = GqlExecutionContext.create(context).getContext(); // apollo-server context를 받기 위한 과정
const token = gqlContext.token;
if (token) {
const decoded = this.jwtService.verify(token);
if (typeof decoded === 'object' || decoded.hasOwnProperty('id')) {
// 유저 식별 성공 하면 req 객체에 담아 context를 통해 resolver 전역에서 사용 가능하게끔
const { user } = await this.userService.findById(decoded['id']);
if (!user) {
return false; // 찾는 user 없으면 guard가 막기
}
// 다른 곳에서도 식별된 user를 사용할 수 있도록 context에 달아주기
gqlContext['user'] = user;
return true
} else {
return false;
}
} else {
return false;
}
}
}
이제 해당 context를 이용하여 적절하게 원하는 대로 사용하면 됩니다.
@Context() context를 통해 resolver에서 쓰거나
customDecorator에서 context를 통해 user에 어떤 조작을 가하거나...
customDecorator의 경우 아래와 같이 사용할 수 있습니다.
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { GqlExecutionContext } from '@nestjs/graphql';
export const AuthUser = createParamDecorator((data: unknown, context: ExecutionContext) => {
const gqlContext = GqlExecutionContext.create(context).getContext(); // apollo-server context를 받기 위한 과정
const user = gqlContext['user']; // Guard에서 gqlContext에 user를 넣어 줬음.
return user;
});
Subscription option : (1) filtering
docs.nestjs.com/graphql/subscriptions#filtering-subscriptions
Subsription한 topic 중에서도 특정 조건에 만족할 때만 반응을 보이고 싶다면, filter를 이용하면 됩니다.
Subscription의 정의 부분을 확인해보면, 두번째 인자로 Option이 있음을 확인할 수 있고,
활용도가 높아 보이는 filter와 resolve를 확인할 수 있습니다. 여기서는 filter를 알아보겠습니다.
export interface SubscriptionOptions extends BaseTypeOptions {
name?: string;
description?: string;
deprecationReason?: string;
filter?: (payload: any, variables: any, context: any) => boolean | Promise<boolean>;
resolve?: (payload: any, args: any, context: any, info: any) => any | Promise<any>;
}
export declare function Subscription(typeFunc: ReturnTypeFunc, options?: SubscriptionOptions): MethodDecorator;
간단히 filter의 인자인 payload, variables, context를 출력하여 각 특성에 알아보겠습니다.
It takes two arguments: payload containing the event payload (as sent by the event publisher), and variables taking any arguments passed in during the subscription request.
It returns a boolean determining whether this event should be published to client listeners.
@Subscription(() => String, {
filter: (payload, variables, context) => {
console.log(payload, variables, context);
return true;
},
})
@Role(['Any'])
anythingSubscription(@Args('anyId') anyId: number) {
console.log(anyId);
return this.pubSub.asyncIterator('anything');
}
출력 결과는 아래와 같습니다.
// publish한 곳에서 담아 보낸 payload
payload => { anythingSubscription: 'publishing by anythingReady 99' }
// 해당 Subscription에 직접 입력해준 인자
variables => { anyId: 1 }
// context. Guard에서 context에 담은 내용들이 보인다.
{
token: 'some token',
req: undefined,
user: User {
id: 4,
email: 'client@gmail.com',
password: '$2b$10$yxGdva/3eQAt2FHNSBucRecz/gDlbf04S/0VP29GwszF.dkX5t2jG',
role: 'Client',
verified: false
}
}
publish, variables, context를 목적에 맞게 사용하여 특정 조건에서만 filter가 통과하게 만들 수 있습니다.
@Subscription(() => String, {
filter: ({ readyId }, { anyId }) => {
return readyId === anyId;
},
})
@Role(['Any'])
anythingSubscription(@Args('anyId') anyId: number) {
console.log(anyId);
return this.pubSub.asyncIterator('anything');
}
Subscription option : (2) resolve
docs.nestjs.com/graphql/subscriptions#mutating-subscription-payloads
To mutate the published event payload, set the resolve property to a function. The function receives the event payload (as sent by the event publisher) and returns the appropriate value.
쉽게 말해서 Subscription이 출력하는 값을 변경하는 겁니다.
resolve?: (payload: any, args: any, context: any, info: any) => any | Promise<any>;
아래와 같이 resolve를 지정하면
@Subscription(() => String, {
resolve: ({ readyId }) => `you recieved ${readyId}`,
})
@Role(['Any'])
anythingSubscription(@Args('anyId') anyId: number) {
console.log(anyId);
return this.pubSub.asyncIterator('anything');
}
아래와 같이 나옵니다.
'Node, Nest, Deno > 🦁 Nest - Series' 카테고리의 다른 글
Nest + gql + typeORM(@nestjs/typeorm) (8) computed field (dynamic field) (0) | 2020.12.22 |
---|---|
Nest + Jest + supertest e2e test (2) : grapqhl query (0) | 2020.12.13 |
Nest + Jest + supertest e2e test (1) : setting (0) | 2020.12.13 |
Nest + Jest unit test (5) spy function, mockImplementation (0) | 2020.12.12 |
Nest + Jest unit test (4) 외부 패키지 mocking (0) | 2020.12.12 |