[1/n]: Migrate deleteOne Rest API to use TwentyORM directly (#9784)

# This PR

- Addressing #3644 
- Migrates the `DELETE /rest/*` endpoint to use TwentyORM
- Factorizes common middleware logic into a common module

---------

Co-authored-by: martmull <martmull@hotmail.fr>
This commit is contained in:
P A C · 先生
2025-01-31 17:12:20 +02:00
committed by GitHub
parent d6788348ba
commit 66296a4787
22 changed files with 548 additions and 119 deletions

View File

@ -7,20 +7,26 @@ import {
Put,
Req,
Res,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { RestApiCoreServiceV2 } from 'src/engine/api/rest/core/rest-api-core-v2.service';
import { RestApiCoreService } from 'src/engine/api/rest/core/rest-api-core.service';
import { cleanGraphQLResponse } from 'src/engine/api/rest/utils/clean-graphql-response.utils';
import { JwtAuthGuard } from 'src/engine/guards/jwt-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
@Controller('rest/*')
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard)
export class RestApiCoreController {
constructor(private readonly restApiCoreService: RestApiCoreService) {}
constructor(
private readonly restApiCoreService: RestApiCoreService,
private readonly restApiCoreServiceV2: RestApiCoreServiceV2,
) {}
@Post('/duplicates')
async handleApiFindDuplicates(@Req() request: Request, @Res() res: Response) {
@ -37,10 +43,13 @@ export class RestApiCoreController {
}
@Delete()
// We should move this exception filter to RestApiCoreController class level
// when all endpoints are migrated to v2
@UseFilters(RestApiExceptionFilter)
async handleApiDelete(@Req() request: Request, @Res() res: Response) {
const result = await this.restApiCoreService.delete(request);
const result = await this.restApiCoreServiceV2.delete(request);
res.status(200).send(cleanGraphQLResponse(result.data.data));
res.status(200).send(result);
}
@Post()

View File

@ -0,0 +1,73 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { Request } from 'express';
import { capitalize } from 'twenty-shared';
import { CoreQueryBuilderFactory } from 'src/engine/api/rest/core/query-builder/core-query-builder.factory';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@Injectable()
export class RestApiCoreServiceV2 {
constructor(
private readonly coreQueryBuilderFactory: CoreQueryBuilderFactory,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
async delete(request: Request) {
const { workspace } = request;
const { object: parsedObject, id: recordId } = parseCorePath(request);
const objectMetadata = await this.coreQueryBuilderFactory.getObjectMetadata(
request,
parsedObject,
);
if (!objectMetadata) {
throw new BadRequestException('Object metadata not found');
}
if (!recordId) {
throw new BadRequestException('Record ID not found');
}
const objectMetadataNameSingular =
objectMetadata.objectMetadataItem.nameSingular;
if (!workspace?.id) {
throw new BadRequestException('Workspace not found');
}
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspace.id,
objectMetadataNameSingular,
);
const recordToDelete = await repository.findOneOrFail({
where: {
id: recordId,
},
});
await repository.delete(recordId);
return this.formatResult('delete', objectMetadataNameSingular, {
id: recordToDelete.id,
});
}
private formatResult<T>(
operation: 'delete' | 'create' | 'update' | 'find',
objectNameSingular: string,
data: T,
) {
const result = {
data: {
[operation + capitalize(objectNameSingular)]: data,
},
};
return result;
}
}

View File

@ -0,0 +1,24 @@
import { ArgumentsHost, Catch, ExceptionFilter } from '@nestjs/common';
import { Response } from 'express';
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
import { CustomException } from 'src/utils/custom-exception';
@Catch()
export class RestApiExceptionFilter implements ExceptionFilter {
constructor(
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
) {}
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
return this.httpExceptionHandlerService.handleError(
exception as CustomException,
response,
400,
);
}
}

View File

@ -4,6 +4,7 @@ import { Module } from '@nestjs/common';
import { RestApiCoreBatchController } from 'src/engine/api/rest/core/controllers/rest-api-core-batch.controller';
import { RestApiCoreController } from 'src/engine/api/rest/core/controllers/rest-api-core.controller';
import { CoreQueryBuilderModule } from 'src/engine/api/rest/core/query-builder/core-query-builder.module';
import { RestApiCoreServiceV2 } from 'src/engine/api/rest/core/rest-api-core-v2.service';
import { RestApiCoreService } from 'src/engine/api/rest/core/rest-api-core.service';
import { EndingBeforeInputFactory } from 'src/engine/api/rest/input-factories/ending-before-input.factory';
import { LimitInputFactory } from 'src/engine/api/rest/input-factories/limit-input.factory';
@ -13,6 +14,7 @@ import { RestApiMetadataController } from 'src/engine/api/rest/metadata/rest-api
import { RestApiMetadataService } from 'src/engine/api/rest/metadata/rest-api-metadata.service';
import { RestApiService } from 'src/engine/api/rest/rest-api.service';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@Module({
@ -22,6 +24,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
WorkspaceCacheStorageModule,
AuthModule,
HttpModule,
TwentyORMModule,
],
controllers: [
RestApiMetadataController,
@ -31,6 +34,7 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
providers: [
RestApiMetadataService,
RestApiCoreService,
RestApiCoreServiceV2,
RestApiService,
StartingAfterInputFactory,
EndingBeforeInputFactory,

View File

@ -14,8 +14,10 @@ export const handleException = (
exceptionHandlerService: ExceptionHandlerService,
user?: ExceptionHandlerUser,
workspace?: ExceptionHandlerWorkspace,
): void => {
): CustomException => {
exceptionHandlerService.captureExceptions([exception], { user, workspace });
return exception;
};
interface RequestAndParams {
@ -45,7 +47,12 @@ export class HttpExceptionHandlerService {
if (params?.userId) user = { ...user, id: params.userId };
handleException(exception, this.exceptionHandlerService, user, workspace);
const statusCode = errorCode || 500;
return response.status(errorCode || 500).send(exception.message);
return response.status(statusCode).send({
statusCode,
error: exception.code || 'Bad Request',
messages: [exception?.message],
});
};
}

View File

@ -0,0 +1 @@
export const INTERNAL_SERVER_ERROR = 'Internal server error';

View File

@ -0,0 +1,19 @@
export const EXCLUDED_MIDDLEWARE_OPERATIONS = [
'GetClientConfig',
'GetWorkspaceFromInviteHash',
'Track',
'CheckUserExists',
'GetLoginTokenFromCredentials',
'GetAuthTokensFromLoginToken',
'GetLoginTokenFromEmailVerificationToken',
'ResendEmailVerificationToken',
'SignUp',
'RenewToken',
'EmailPasswordResetLink',
'ValidatePasswordResetToken',
'UpdatePasswordViaResetToken',
'IntrospectionQuery',
'ExchangeAuthorizationCode',
'GetAuthorizationUrl',
'GetPublicWorkspaceDataBySubdomain',
] as const;

View File

@ -1,114 +1,28 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import { ExtractJwt } from 'passport-jwt';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { handleExceptionAndConvertToGraphQLError } from 'src/engine/utils/global-exception-handler.util';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
class GraphqlTokenValidationProxy {
private accessTokenService: AccessTokenService;
constructor(accessTokenService: AccessTokenService) {
this.accessTokenService = accessTokenService;
}
async validateToken(req: Request) {
try {
return await this.accessTokenService.validateTokenByRequest(req);
} catch (error) {
const authGraphqlApiExceptionFilter = new AuthGraphqlApiExceptionFilter();
throw authGraphqlApiExceptionFilter.catch(error);
}
}
}
import { MiddlewareService } from 'src/engine/middlewares/middleware.service';
@Injectable()
export class GraphQLHydrateRequestFromTokenMiddleware
implements NestMiddleware
{
constructor(
private readonly accessTokenService: AccessTokenService,
private readonly workspaceStorageCacheService: WorkspaceCacheStorageService,
private readonly exceptionHandlerService: ExceptionHandlerService,
) {}
constructor(private readonly middlewareService: MiddlewareService) {}
async use(req: Request, res: Response, next: NextFunction) {
const body = req.body;
const excludedOperations = [
'GetClientConfig',
'GetWorkspaceFromInviteHash',
'Track',
'CheckUserExists',
'GetLoginTokenFromCredentials',
'GetAuthTokensFromLoginToken',
'GetLoginTokenFromEmailVerificationToken',
'ResendEmailVerificationToken',
'SignUp',
'RenewToken',
'EmailPasswordResetLink',
'ValidatePasswordResetToken',
'UpdatePasswordViaResetToken',
'IntrospectionQuery',
'ExchangeAuthorizationCode',
'GetAuthorizationUrl',
'GetPublicWorkspaceDataBySubdomain',
];
if (
!this.isTokenPresent(req) &&
(!body?.operationName || excludedOperations.includes(body.operationName))
) {
if (this.middlewareService.checkUnauthenticatedAccess(req)) {
return next();
}
let data: AuthContext;
try {
const graphqlTokenValidationProxy = new GraphqlTokenValidationProxy(
this.accessTokenService,
);
data = await graphqlTokenValidationProxy.validateToken(req);
const metadataVersion =
await this.workspaceStorageCacheService.getMetadataVersion(
data.workspace.id,
);
req.user = data.user;
req.apiKey = data.apiKey;
req.workspace = data.workspace;
req.workspaceId = data.workspace.id;
req.workspaceMetadataVersion = metadataVersion;
req.workspaceMemberId = data.workspaceMemberId;
await this.middlewareService.authenticateGraphqlRequest(req);
} catch (error) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.write(
JSON.stringify({
errors: [
handleExceptionAndConvertToGraphQLError(
error,
this.exceptionHandlerService,
),
],
}),
);
res.end();
this.middlewareService.writeGraphqlResponseOnExceptionCaught(res, error);
return;
}
next();
}
isTokenPresent(request: Request): boolean {
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
return !!token;
}
}

View File

@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { WorkspaceMetadataCacheModule } from 'src/engine/metadata-modules/workspace-metadata-cache/workspace-metadata-cache.module';
import { MiddlewareService } from 'src/engine/middlewares/middleware.service';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
@Module({
imports: [
DataSourceModule,
WorkspaceCacheStorageModule,
WorkspaceMetadataCacheModule,
TokenModule,
],
providers: [MiddlewareService],
exports: [MiddlewareService],
})
export class MiddlewareModule {}

View File

@ -0,0 +1,167 @@
import { Injectable } from '@nestjs/common';
import { Request, Response } from 'express';
import { ExtractJwt } from 'passport-jwt';
import { AuthExceptionCode } from 'src/engine/core-modules/auth/auth.exception';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { WorkspaceMetadataCacheService } from 'src/engine/metadata-modules/workspace-metadata-cache/services/workspace-metadata-cache.service';
import { INTERNAL_SERVER_ERROR } from 'src/engine/middlewares/constants/default-error-message.constant';
import { EXCLUDED_MIDDLEWARE_OPERATIONS } from 'src/engine/middlewares/constants/excluded-middleware-operations.constant';
import { GraphqlTokenValidationProxy } from 'src/engine/middlewares/utils/graphql-token-validation-utils';
import {
handleException,
handleExceptionAndConvertToGraphQLError,
} from 'src/engine/utils/global-exception-handler.util';
import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service';
import { CustomException } from 'src/utils/custom-exception';
import { isDefined } from 'src/utils/is-defined';
@Injectable()
export class MiddlewareService {
constructor(
private readonly accessTokenService: AccessTokenService,
private readonly workspaceStorageCacheService: WorkspaceCacheStorageService,
private readonly workspaceMetadataCacheService: WorkspaceMetadataCacheService,
private readonly dataSourceService: DataSourceService,
private readonly exceptionHandlerService: ExceptionHandlerService,
) {}
private excludedOperations = EXCLUDED_MIDDLEWARE_OPERATIONS;
public isTokenPresent(request: Request): boolean {
const token = ExtractJwt.fromAuthHeaderAsBearerToken()(request);
return !!token;
}
public checkUnauthenticatedAccess(request: Request): boolean {
const { body } = request;
const isUserUnauthenticated = !this.isTokenPresent(request);
const isExcludedOperation =
!body?.operationName ||
this.excludedOperations.includes(body.operationName);
return isUserUnauthenticated && isExcludedOperation;
}
public writeRestResponseOnExceptionCaught(res: Response, error: any) {
// capture and handle custom exceptions
handleException(error as CustomException, this.exceptionHandlerService);
const statusCode = this.getStatus(error);
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.write(
JSON.stringify({
statusCode,
messages: [error?.message || INTERNAL_SERVER_ERROR],
error: error?.code || ErrorCode.INTERNAL_SERVER_ERROR,
}),
);
res.end();
}
public writeGraphqlResponseOnExceptionCaught(res: Response, error: any) {
const errors = [
handleExceptionAndConvertToGraphQLError(
error as Error,
this.exceptionHandlerService,
),
];
const statusCode = 200;
res.writeHead(statusCode, {
'Content-Type': 'application/json',
});
res.write(
JSON.stringify({
errors,
}),
);
res.end();
}
public async authenticateRestRequest(request: Request) {
const data = await this.accessTokenService.validateTokenByRequest(request);
const metadataVersion =
await this.workspaceStorageCacheService.getMetadataVersion(
data.workspace.id,
);
if (metadataVersion === undefined) {
await this.workspaceMetadataCacheService.recomputeMetadataCache({
workspaceId: data.workspace.id,
});
throw new Error('Metadata cache version not found');
}
const dataSourcesMetadata =
await this.dataSourceService.getDataSourcesMetadataFromWorkspaceId(
data.workspace.id,
);
if (!dataSourcesMetadata || dataSourcesMetadata.length === 0) {
throw new Error('No data sources found');
}
this.bindDataToRequestObject(data, request, metadataVersion);
}
public async authenticateGraphqlRequest(request: Request) {
const graphqlTokenValidationProxy = new GraphqlTokenValidationProxy(
this.accessTokenService,
);
const data = await graphqlTokenValidationProxy.validateToken(request);
const metadataVersion =
await this.workspaceStorageCacheService.getMetadataVersion(
data.workspace.id,
);
this.bindDataToRequestObject(data, request, metadataVersion);
}
private hasErrorStatus(error: unknown): error is { status: number } {
return isDefined((error as { status: number })?.status);
}
private bindDataToRequestObject(
data: AuthContext,
request: Request,
metadataVersion: number | undefined,
) {
request.user = data.user;
request.apiKey = data.apiKey;
request.workspace = data.workspace;
request.workspaceId = data.workspace.id;
request.workspaceMetadataVersion = metadataVersion;
request.workspaceMemberId = data.workspaceMemberId;
}
private getStatus(error: any): number {
if (this.hasErrorStatus(error)) {
return error.status;
}
if (error instanceof CustomException) {
switch (error.code) {
case AuthExceptionCode.UNAUTHENTICATED:
return 401;
default:
return 400;
}
}
return 500;
}
}

View File

@ -0,0 +1,22 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import { MiddlewareService } from 'src/engine/middlewares/middleware.service';
@Injectable()
export class RestCoreMiddleware implements NestMiddleware {
constructor(private readonly middlewareService: MiddlewareService) {}
async use(req: Request, res: Response, next: NextFunction) {
try {
await this.middlewareService.authenticateRestRequest(req);
} catch (error) {
this.middlewareService.writeRestResponseOnExceptionCaught(res, error);
return;
}
next();
}
}

View File

@ -0,0 +1,22 @@
import { Request } from 'express';
import { AuthGraphqlApiExceptionFilter } from 'src/engine/core-modules/auth/filters/auth-graphql-api-exception.filter';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
export class GraphqlTokenValidationProxy {
private accessTokenService: AccessTokenService;
constructor(accessTokenService: AccessTokenService) {
this.accessTokenService = accessTokenService;
}
async validateToken(req: Request) {
try {
return await this.accessTokenService.validateTokenByRequest(req);
} catch (error) {
const authGraphqlApiExceptionFilter = new AuthGraphqlApiExceptionFilter();
throw authGraphqlApiExceptionFilter.catch(error);
}
}
}

View File

@ -72,7 +72,7 @@ export const shouldFilterException = (exception: Error): boolean => {
return false;
};
const handleException = (
export const handleException = (
exception: Error,
exceptionHandlerService: ExceptionHandlerService,
user?: ExceptionHandlerUser,