diff --git a/packages/twenty-server/src/app.module.ts b/packages/twenty-server/src/app.module.ts index cb00efef2..a447c1e5f 100644 --- a/packages/twenty-server/src/app.module.ts +++ b/packages/twenty-server/src/app.module.ts @@ -1,19 +1,12 @@ import { Module } from '@nestjs/common'; import { GraphQLModule } from '@nestjs/graphql'; import { ConfigModule } from '@nestjs/config'; -import { APP_FILTER, ContextIdFactory, ModuleRef } from '@nestjs/core'; +import { APP_FILTER } from '@nestjs/core'; -import { GraphQLError, GraphQLSchema } from 'graphql'; import { YogaDriver, YogaDriverConfig } from '@graphql-yoga/nestjs'; -import GraphQLJSON from 'graphql-type-json'; -import { TokenExpiredError, JsonWebTokenError } from 'jsonwebtoken'; -import { WorkspaceFactory } from 'src/workspace/workspace.factory'; -import { TypeOrmExceptionFilter } from 'src/filters/typeorm-exception.filter'; -import { HttpExceptionFilter } from 'src/filters/http-exception.filter'; +import { GraphQLConfigService } from 'src/graphql-config.service'; import { GlobalExceptionFilter } from 'src/filters/global-exception.filter'; -import { TokenService } from 'src/core/auth/services/token.service'; -import { Workspace } from 'src/core/workspace/workspace.entity'; import { AppService } from './app.service'; @@ -27,61 +20,10 @@ import { WorkspaceModule } from './workspace/workspace.module'; ConfigModule.forRoot({ isGlobal: true, }), - GraphQLModule.forRoot({ - context: ({ req }) => ({ req }), + GraphQLModule.forRootAsync({ driver: YogaDriver, - autoSchemaFile: true, - include: [CoreModule], - conditionalSchema: async (request) => { - try { - // Get TokenService from AppModule - const tokenService = AppModule.moduleRef.get(TokenService, { - strict: false, - }); - - let workspace: Workspace; - - try { - workspace = await tokenService.validateToken(request.req); - } catch (err) { - return new GraphQLSchema({}); - } - - const contextId = ContextIdFactory.create(); - - AppModule.moduleRef.registerRequestByContextId(request, contextId); - - // Get the SchemaGenerationService from the AppModule - const workspaceFactory = await AppModule.moduleRef.resolve( - WorkspaceFactory, - contextId, - { - strict: false, - }, - ); - - return await workspaceFactory.createGraphQLSchema(workspace.id); - } catch (error) { - if (error instanceof JsonWebTokenError) { - //mockedUserJWT - throw new GraphQLError('Unauthenticated', { - extensions: { - code: 'UNAUTHENTICATED', - }, - }); - } - if (error instanceof TokenExpiredError) { - throw new GraphQLError('Unauthenticated', { - extensions: { - code: 'UNAUTHENTICATED', - }, - }); - } - throw error; - } - }, - resolvers: { JSON: GraphQLJSON }, - plugins: [], + imports: [CoreModule], + useClass: GraphQLConfigService, }), HealthModule, IntegrationsModule, @@ -90,27 +32,10 @@ import { WorkspaceModule } from './workspace/workspace.module'; ], providers: [ AppService, - // Exceptions filters must be ordered from the least specific to the most specific - // If TypeOrmExceptionFilter handle something, HttpExceptionFilter will not handle it - // GlobalExceptionFilter will handle the rest of the exceptions { provide: APP_FILTER, - useClass: GlobalExceptionFilter, - }, - { - provide: APP_FILTER, - useClass: HttpExceptionFilter, - }, - { - provide: APP_FILTER, - useClass: TypeOrmExceptionFilter, + useValue: GlobalExceptionFilter, }, ], }) -export class AppModule { - static moduleRef: ModuleRef; - - constructor(private moduleRef: ModuleRef) { - AppModule.moduleRef = this.moduleRef; - } -} +export class AppModule {} diff --git a/packages/twenty-server/src/filters/global-exception.filter.ts b/packages/twenty-server/src/filters/global-exception.filter.ts index 1879c9aee..6e248bc46 100644 --- a/packages/twenty-server/src/filters/global-exception.filter.ts +++ b/packages/twenty-server/src/filters/global-exception.filter.ts @@ -1,6 +1,7 @@ import { Catch, Injectable } from '@nestjs/common'; import { GqlExceptionFilter } from '@nestjs/graphql'; +import { globalExceptionHandler } from 'src/filters/utils/global-exception-handler.util'; import { ExceptionHandlerService } from 'src/integrations/exception-handler/exception-handler.service'; @Catch() @@ -11,8 +12,6 @@ export class GlobalExceptionFilter implements GqlExceptionFilter { ) {} catch(exception: unknown) { - this.exceptionHandlerService.captureException(exception); - - return exception; + return globalExceptionHandler(exception, this.exceptionHandlerService); } } diff --git a/packages/twenty-server/src/filters/http-exception.filter.ts b/packages/twenty-server/src/filters/http-exception.filter.ts deleted file mode 100644 index 4a33e7dda..000000000 --- a/packages/twenty-server/src/filters/http-exception.filter.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { ArgumentsHost, Catch, HttpException } from '@nestjs/common'; -import { GqlContextType, GqlExceptionFilter } from '@nestjs/graphql'; - -import { - AuthenticationError, - BaseGraphQLError, - ForbiddenError, -} from 'src/filters/utils/graphql-errors.util'; - -const graphQLPredefinedExceptions = { - 401: AuthenticationError, - 403: ForbiddenError, -}; - -@Catch(HttpException) -export class HttpExceptionFilter - implements GqlExceptionFilter -{ - catch(exception: HttpException, host: ArgumentsHost) { - if (host.getType() !== 'graphql') { - return null; - } - - let error: BaseGraphQLError; - - if (exception.getStatus() in graphQLPredefinedExceptions) { - error = new graphQLPredefinedExceptions[exception.getStatus()]( - exception.message, - ); - } else { - error = new BaseGraphQLError( - exception.message, - exception.getStatus().toString(), - ); - } - - error.stack = exception.stack; - error.extensions['response'] = exception.getResponse(); - - return error; - } -} diff --git a/packages/twenty-server/src/filters/typeorm-exception.filter.ts b/packages/twenty-server/src/filters/typeorm-exception.filter.ts deleted file mode 100644 index 06f4c5bf7..000000000 --- a/packages/twenty-server/src/filters/typeorm-exception.filter.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ArgumentsHost, Catch } from '@nestjs/common'; -import { GqlContextType, GqlExceptionFilter } from '@nestjs/graphql'; - -import { TypeORMError } from 'typeorm'; - -import { BaseGraphQLError } from 'src/filters/utils/graphql-errors.util'; - -@Catch(TypeORMError) -export class TypeOrmExceptionFilter - implements GqlExceptionFilter -{ - catch(exception: TypeORMError, host: ArgumentsHost) { - if (host.getType() !== 'graphql') { - return null; - } - - const error = new BaseGraphQLError(exception.name, 'INTERNAL_SERVER_ERROR'); - - error.stack = exception.stack; - error.extensions['response'] = exception.message; - - return error; - } -} diff --git a/packages/twenty-server/src/filters/utils/global-exception-handler.util.ts b/packages/twenty-server/src/filters/utils/global-exception-handler.util.ts new file mode 100644 index 000000000..d7ec0f367 --- /dev/null +++ b/packages/twenty-server/src/filters/utils/global-exception-handler.util.ts @@ -0,0 +1,75 @@ +import { HttpException } from '@nestjs/common'; + +import { TypeORMError } from 'typeorm'; + +import { + AuthenticationError, + BaseGraphQLError, + ForbiddenError, +} from 'src/filters/utils/graphql-errors.util'; +import { ExceptionHandlerService } from 'src/integrations/exception-handler/exception-handler.service'; + +const graphQLPredefinedExceptions = { + 401: AuthenticationError, + 403: ForbiddenError, +}; + +export const globalExceptionHandler = ( + exception: unknown, + exceptionHandlerService: ExceptionHandlerService, +) => { + if (exception instanceof HttpException) { + return httpExceptionHandler(exception, exceptionHandlerService); + } + + if (exception instanceof TypeORMError) { + return typeOrmExceptionHandler(exception, exceptionHandlerService); + } + + exceptionHandlerService.captureException(exception); + + return exception; +}; + +export const httpExceptionHandler = ( + exception: HttpException, + exceptionHandlerService: ExceptionHandlerService, +) => { + const status = exception.getStatus(); + let error: BaseGraphQLError; + + // Capture all 5xx errors and send them to exception handler + if (status >= 500) { + exceptionHandlerService.captureException(exception); + } + + if (status in graphQLPredefinedExceptions) { + error = new graphQLPredefinedExceptions[exception.getStatus()]( + exception.message, + ); + } else { + error = new BaseGraphQLError( + exception.message, + exception.getStatus().toString(), + ); + } + + error.stack = exception.stack; + error.extensions['response'] = exception.getResponse(); + + return error; +}; + +export const typeOrmExceptionHandler = ( + exception: TypeORMError, + exceptionHandlerService: ExceptionHandlerService, +) => { + exceptionHandlerService.captureException(exception); + + const error = new BaseGraphQLError(exception.name, 'INTERNAL_SERVER_ERROR'); + + error.stack = exception.stack; + error.extensions['response'] = exception.message; + + return error; +}; diff --git a/packages/twenty-server/src/graphql-config.service.ts b/packages/twenty-server/src/graphql-config.service.ts new file mode 100644 index 000000000..e122ce01c --- /dev/null +++ b/packages/twenty-server/src/graphql-config.service.ts @@ -0,0 +1,95 @@ +import { Injectable } from '@nestjs/common'; +import { ContextIdFactory, ModuleRef } from '@nestjs/core'; +import { GqlOptionsFactory } from '@nestjs/graphql'; + +import { + YogaDriverConfig, + YogaDriverServerContext, +} from '@graphql-yoga/nestjs'; +import { GraphQLSchema, GraphQLError } from 'graphql'; +import GraphQLJSON from 'graphql-type-json'; +import { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken'; +import { GraphQLSchemaWithContext, YogaInitialContext } from 'graphql-yoga'; + +import { TokenService } from 'src/core/auth/services/token.service'; +import { CoreModule } from 'src/core/core.module'; +import { Workspace } from 'src/core/workspace/workspace.entity'; +import { WorkspaceFactory } from 'src/workspace/workspace.factory'; +import { ExceptionHandlerService } from 'src/integrations/exception-handler/exception-handler.service'; +import { globalExceptionHandler } from 'src/filters/utils/global-exception-handler.util'; + +@Injectable() +export class GraphQLConfigService + implements GqlOptionsFactory> +{ + constructor( + private readonly tokenService: TokenService, + private readonly exceptionHandlerService: ExceptionHandlerService, + private readonly moduleRef: ModuleRef, + ) {} + + createGqlOptions(): YogaDriverConfig { + return { + context: ({ req }) => ({ req }), + autoSchemaFile: true, + include: [CoreModule], + conditionalSchema: async (context) => { + try { + let workspace: Workspace; + + // If token is not valid, it will return an empty schema + try { + workspace = await this.tokenService.validateToken(context.req); + } catch (err) { + return new GraphQLSchema({}); + } + + return await this.createSchema(context, workspace); + } catch (error) { + if (error instanceof JsonWebTokenError) { + //mockedUserJWT + throw new GraphQLError('Unauthenticated', { + extensions: { + code: 'UNAUTHENTICATED', + }, + }); + } + + if (error instanceof TokenExpiredError) { + throw new GraphQLError('Unauthenticated', { + extensions: { + code: 'UNAUTHENTICATED', + }, + }); + } + + throw globalExceptionHandler(error, this.exceptionHandlerService); + } + }, + resolvers: { JSON: GraphQLJSON }, + plugins: [], + }; + } + + async createSchema( + context: YogaDriverServerContext<'express'> & YogaInitialContext, + workspace: Workspace, + ): Promise>> { + // Create a new contextId for each request + const contextId = ContextIdFactory.create(); + + // Register the request in the contextId + this.moduleRef.registerRequestByContextId(context.req, contextId); + + // Resolve the WorkspaceFactory for the contextId + const workspaceFactory = await this.moduleRef.resolve( + WorkspaceFactory, + contextId, + { + strict: false, + }, + ); + + return await workspaceFactory.createGraphQLSchema(workspace.id); + } +}