Rework logic function module (#17588)
core-modules/logic-function/
├── logic-function.module.ts
├── logic-function-executor/
│ ├── logic-function-executor.module.ts
│ ├── commands/
│ │ └── add-packages.command.ts
│ ├── constants/
│ │ └── logic-function-executor.constants.ts
│ ├── factories/
│ │ └── logic-function-module.factory.ts
│ ├── interfaces/
│ │ └── logic-function-executor.interface.ts
│ └── services/
│ └── logic-function-executor.service.ts
├── logic-function-build/
│ ├── logic-function-build.module.ts
│ ├── services/
│ │ └── logic-function-build.service.ts
│ └── utils/
│ └── get-logic-function-base-folder-path.util.ts
├── logic-function-drivers/
│ ├── logic-function-drivers.module.ts
│ ├── constants/
│ │ └── ...
│ ├── drivers/
│ │ ├── disabled.driver.ts
│ │ ├── lambda.driver.ts
│ │ └── local.driver.ts
│ ├── interfaces/
│ │ └── logic-function-executor-driver.interface.ts
│ ├── layers/
│ │ └── ...
│ └── utils/
│ └── ...
├── logic-function-layer/
│ ├── logic-function-layer.module.ts
│ └── services/
│ └── logic-function-layer.service.ts
└── logic-function-trigger/
├── logic-function-trigger.module.ts
├── jobs/
│ └── logic-function-trigger.job.ts
└── triggers/
├── cron/
├── database-event/
└── route/
├── exceptions/
├── services/
│ └── route-trigger.service.ts
└── utils/
This commit is contained in:
-59
@@ -1,59 +0,0 @@
|
||||
import {
|
||||
type ArgumentsHost,
|
||||
Catch,
|
||||
type ExceptionFilter,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import type { Response } from 'express';
|
||||
|
||||
import {
|
||||
RouteTriggerException,
|
||||
RouteTriggerExceptionCode,
|
||||
} from 'src/engine/metadata-modules/route-trigger/exceptions/route-trigger.exception';
|
||||
import type { CustomException } from 'src/utils/custom-exception';
|
||||
import { HttpExceptionHandlerService } from 'src/engine/core-modules/exception-handler/http-exception-handler.service';
|
||||
|
||||
@Catch(RouteTriggerException)
|
||||
export class RouteTriggerRestApiExceptionFilter implements ExceptionFilter {
|
||||
constructor(
|
||||
private readonly httpExceptionHandlerService: HttpExceptionHandlerService,
|
||||
) {}
|
||||
|
||||
catch(exception: RouteTriggerException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
|
||||
switch (exception.code) {
|
||||
case RouteTriggerExceptionCode.WORKSPACE_NOT_FOUND:
|
||||
case RouteTriggerExceptionCode.ROUTE_NOT_FOUND:
|
||||
case RouteTriggerExceptionCode.TRIGGER_NOT_FOUND:
|
||||
case RouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND:
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
404,
|
||||
);
|
||||
case RouteTriggerExceptionCode.FORBIDDEN_EXCEPTION:
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
403,
|
||||
);
|
||||
case RouteTriggerExceptionCode.LOGIC_FUNCTION_EXECUTION_ERROR:
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
500,
|
||||
);
|
||||
case RouteTriggerExceptionCode.ROUTE_ALREADY_EXIST:
|
||||
case RouteTriggerExceptionCode.ROUTE_PATH_ALREADY_EXIST:
|
||||
default: {
|
||||
return this.httpExceptionHandlerService.handleError(
|
||||
exception as CustomException,
|
||||
response,
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export enum RouteTriggerExceptionCode {
|
||||
WORKSPACE_NOT_FOUND = 'WORKSPACE_NOT_FOUND',
|
||||
ROUTE_NOT_FOUND = 'ROUTE_NOT_FOUND',
|
||||
TRIGGER_NOT_FOUND = 'TRIGGER_NOT_FOUND',
|
||||
LOGIC_FUNCTION_NOT_FOUND = 'LOGIC_FUNCTION_NOT_FOUND',
|
||||
ROUTE_ALREADY_EXIST = 'ROUTE_ALREADY_EXIST',
|
||||
ROUTE_PATH_ALREADY_EXIST = 'ROUTE_PATH_ALREADY_EXIST',
|
||||
FORBIDDEN_EXCEPTION = 'FORBIDDEN_EXCEPTION',
|
||||
LOGIC_FUNCTION_EXECUTION_ERROR = 'LOGIC_FUNCTION_EXECUTION_ERROR',
|
||||
}
|
||||
|
||||
const getRouteTriggerExceptionUserFriendlyMessage = (
|
||||
code: RouteTriggerExceptionCode,
|
||||
) => {
|
||||
switch (code) {
|
||||
case RouteTriggerExceptionCode.WORKSPACE_NOT_FOUND:
|
||||
return msg`Workspace not found.`;
|
||||
case RouteTriggerExceptionCode.ROUTE_NOT_FOUND:
|
||||
return msg`Route not found.`;
|
||||
case RouteTriggerExceptionCode.TRIGGER_NOT_FOUND:
|
||||
return msg`Trigger not found.`;
|
||||
case RouteTriggerExceptionCode.LOGIC_FUNCTION_NOT_FOUND:
|
||||
return msg`Logic function not found.`;
|
||||
case RouteTriggerExceptionCode.ROUTE_ALREADY_EXIST:
|
||||
return msg`Route already exists.`;
|
||||
case RouteTriggerExceptionCode.ROUTE_PATH_ALREADY_EXIST:
|
||||
return msg`Route path already exists.`;
|
||||
case RouteTriggerExceptionCode.FORBIDDEN_EXCEPTION:
|
||||
return msg`You do not have permission to perform this action.`;
|
||||
case RouteTriggerExceptionCode.LOGIC_FUNCTION_EXECUTION_ERROR:
|
||||
return msg`Logic function execution failed.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
};
|
||||
|
||||
export class RouteTriggerException extends CustomException<RouteTriggerExceptionCode> {
|
||||
constructor(
|
||||
message: string,
|
||||
code: RouteTriggerExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
|
||||
) {
|
||||
super(message, code, {
|
||||
userFriendlyMessage:
|
||||
userFriendlyMessage ??
|
||||
getRouteTriggerExceptionUserFriendlyMessage(code),
|
||||
});
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -15,8 +15,8 @@ import { HTTPMethod } from 'twenty-shared/types';
|
||||
|
||||
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
|
||||
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
|
||||
import { RouteTriggerRestApiExceptionFilter } from 'src/engine/metadata-modules/route-trigger/exceptions/route-trigger-rest-api-exception-filter';
|
||||
import { RouteTriggerService } from 'src/engine/metadata-modules/route-trigger/route-trigger.service';
|
||||
import { RouteTriggerRestApiExceptionFilter } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/exceptions/route-trigger-rest-api-exception-filter';
|
||||
import { RouteTriggerService } from 'src/engine/core-modules/logic-function/logic-function-trigger/triggers/route/route-trigger.service';
|
||||
|
||||
@Controller('s')
|
||||
@UseGuards(PublicEndpointGuard, NoPermissionGuard)
|
||||
|
||||
-14
@@ -1,22 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { TokenModule } from 'src/engine/core-modules/auth/token/token.module';
|
||||
import { WorkspaceDomainsModule } from 'src/engine/core-modules/domain/workspace-domains/workspace-domains.module';
|
||||
import { RouteTriggerController } from 'src/engine/metadata-modules/route-trigger/route-trigger.controller';
|
||||
import { RouteTriggerService } from 'src/engine/metadata-modules/route-trigger/route-trigger.service';
|
||||
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
|
||||
import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([LogicFunctionEntity]),
|
||||
TokenModule,
|
||||
WorkspaceDomainsModule,
|
||||
LogicFunctionModule,
|
||||
],
|
||||
controllers: [RouteTriggerController],
|
||||
providers: [RouteTriggerService],
|
||||
exports: [],
|
||||
})
|
||||
export class RouteTriggerModule {}
|
||||
|
||||
-168
@@ -1,168 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Request } from 'express';
|
||||
import { match } from 'path-to-regexp';
|
||||
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
|
||||
import { IsNull, Not, Repository } from 'typeorm';
|
||||
import { HTTPMethod } from 'twenty-shared/types';
|
||||
|
||||
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
|
||||
import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspace-domains/services/workspace-domains.service';
|
||||
import {
|
||||
RouteTriggerException,
|
||||
RouteTriggerExceptionCode,
|
||||
} from 'src/engine/metadata-modules/route-trigger/exceptions/route-trigger.exception';
|
||||
import { buildLogicFunctionEvent } from 'src/engine/metadata-modules/route-trigger/utils/build-logic-function-event.util';
|
||||
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
|
||||
import { LogicFunctionService } from 'src/engine/metadata-modules/logic-function/logic-function.service';
|
||||
|
||||
@Injectable()
|
||||
export class RouteTriggerService {
|
||||
constructor(
|
||||
private readonly accessTokenService: AccessTokenService,
|
||||
private readonly logicFunctionService: LogicFunctionService,
|
||||
private readonly workspaceDomainsService: WorkspaceDomainsService,
|
||||
@InjectRepository(LogicFunctionEntity)
|
||||
private readonly logicFunctionRepository: Repository<LogicFunctionEntity>,
|
||||
) {}
|
||||
|
||||
private async getLogicFunctionWithPathParamsOrFail({
|
||||
request,
|
||||
httpMethod,
|
||||
}: {
|
||||
request: Request;
|
||||
httpMethod: HTTPMethod;
|
||||
}): Promise<{
|
||||
logicFunction: LogicFunctionEntity;
|
||||
pathParams: Partial<Record<string, string | string[]>>;
|
||||
}> {
|
||||
const host = `${request.protocol}://${request.get('host')}`;
|
||||
|
||||
const workspace =
|
||||
await this.workspaceDomainsService.getWorkspaceByOriginOrDefaultWorkspace(
|
||||
host,
|
||||
);
|
||||
|
||||
assertIsDefinedOrThrow(
|
||||
workspace,
|
||||
new RouteTriggerException(
|
||||
'Workspace not found',
|
||||
RouteTriggerExceptionCode.WORKSPACE_NOT_FOUND,
|
||||
),
|
||||
);
|
||||
|
||||
const logicFunctionsWithHttpRouteTrigger =
|
||||
await this.logicFunctionRepository.find({
|
||||
where: {
|
||||
workspaceId: workspace.id,
|
||||
httpRouteTriggerSettings: Not(IsNull()),
|
||||
},
|
||||
});
|
||||
|
||||
const requestPath = request.path.replace(/^\/s\//, '/');
|
||||
|
||||
for (const logicFunction of logicFunctionsWithHttpRouteTrigger) {
|
||||
const httpRouteSettings = logicFunction.httpRouteTriggerSettings;
|
||||
|
||||
if (
|
||||
!isDefined(httpRouteSettings) ||
|
||||
httpRouteSettings.httpMethod !== httpMethod
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const routeMatcher = match(httpRouteSettings.path, {
|
||||
decode: decodeURIComponent,
|
||||
});
|
||||
const routeMatched = routeMatcher(requestPath);
|
||||
|
||||
if (routeMatched) {
|
||||
return {
|
||||
logicFunction,
|
||||
pathParams: routeMatched.params,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new RouteTriggerException(
|
||||
'No Route trigger found',
|
||||
RouteTriggerExceptionCode.TRIGGER_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
private async validateWorkspaceFromRequest({
|
||||
request,
|
||||
workspaceId,
|
||||
}: {
|
||||
request: Request;
|
||||
workspaceId: string;
|
||||
}) {
|
||||
const authContext =
|
||||
await this.accessTokenService.validateTokenByRequest(request);
|
||||
|
||||
if (!isDefined(authContext.workspace)) {
|
||||
throw new RouteTriggerException(
|
||||
'Workspace not found',
|
||||
RouteTriggerExceptionCode.WORKSPACE_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if (authContext.workspace.id !== workspaceId) {
|
||||
throw new RouteTriggerException(
|
||||
'You are not authorized',
|
||||
RouteTriggerExceptionCode.FORBIDDEN_EXCEPTION,
|
||||
);
|
||||
}
|
||||
|
||||
return authContext;
|
||||
}
|
||||
|
||||
async handle({
|
||||
request,
|
||||
httpMethod,
|
||||
}: {
|
||||
request: Request;
|
||||
httpMethod: HTTPMethod;
|
||||
}) {
|
||||
const { logicFunction, pathParams } =
|
||||
await this.getLogicFunctionWithPathParamsOrFail({
|
||||
request,
|
||||
httpMethod,
|
||||
});
|
||||
|
||||
const httpRouteSettings = logicFunction.httpRouteTriggerSettings;
|
||||
|
||||
if (httpRouteSettings?.isAuthRequired) {
|
||||
await this.validateWorkspaceFromRequest({
|
||||
request,
|
||||
workspaceId: logicFunction.workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
const event = buildLogicFunctionEvent({
|
||||
request,
|
||||
pathParameters: pathParams,
|
||||
forwardedRequestHeaders: httpRouteSettings?.forwardedRequestHeaders ?? [],
|
||||
});
|
||||
|
||||
const result = await this.logicFunctionService.executeOneLogicFunction({
|
||||
id: logicFunction.id,
|
||||
workspaceId: logicFunction.workspaceId,
|
||||
payload: event,
|
||||
});
|
||||
|
||||
if (!isDefined(result)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
throw new RouteTriggerException(
|
||||
result.error.errorMessage,
|
||||
RouteTriggerExceptionCode.LOGIC_FUNCTION_EXECUTION_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
}
|
||||
-438
@@ -1,438 +0,0 @@
|
||||
import { type Request } from 'express';
|
||||
|
||||
import {
|
||||
buildLogicFunctionEvent,
|
||||
extractBody,
|
||||
filterRequestHeaders,
|
||||
normalizePathParameters,
|
||||
normalizeQueryStringParameters,
|
||||
} from 'src/engine/metadata-modules/route-trigger/utils/build-logic-function-event.util';
|
||||
|
||||
describe('filterRequestHeaders', () => {
|
||||
it('should filter headers based on allowed names', () => {
|
||||
const requestHeaders = {
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer token123',
|
||||
'x-custom-header': 'custom-value',
|
||||
'user-agent': 'test-agent',
|
||||
};
|
||||
const forwardedRequestHeaders = ['content-type', 'authorization'];
|
||||
|
||||
const result = filterRequestHeaders({
|
||||
requestHeaders,
|
||||
forwardedRequestHeaders,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer token123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle case-insensitive header names', () => {
|
||||
const requestHeaders = {
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer token123',
|
||||
};
|
||||
const forwardedRequestHeaders = ['Content-Type', 'AUTHORIZATION'];
|
||||
|
||||
const result = filterRequestHeaders({
|
||||
requestHeaders,
|
||||
forwardedRequestHeaders,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer token123',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty object when no headers match', () => {
|
||||
const requestHeaders = {
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
const forwardedRequestHeaders = ['x-custom-header'];
|
||||
|
||||
const result = filterRequestHeaders({
|
||||
requestHeaders,
|
||||
forwardedRequestHeaders,
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should return empty object when forwardedRequestHeaders is empty', () => {
|
||||
const requestHeaders = {
|
||||
'content-type': 'application/json',
|
||||
};
|
||||
|
||||
const result = filterRequestHeaders({
|
||||
requestHeaders,
|
||||
forwardedRequestHeaders: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should convert array header values to comma-separated string', () => {
|
||||
const requestHeaders = {
|
||||
'x-custom-array-header': ['value1', 'value2', 'value3'],
|
||||
};
|
||||
const forwardedRequestHeaders = ['x-custom-array-header'];
|
||||
|
||||
const result = filterRequestHeaders({
|
||||
requestHeaders,
|
||||
forwardedRequestHeaders,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
'x-custom-array-header': 'value1, value2, value3',
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip undefined header values', () => {
|
||||
const requestHeaders = {
|
||||
'content-type': 'application/json',
|
||||
'x-missing': undefined,
|
||||
};
|
||||
const forwardedRequestHeaders = ['content-type', 'x-missing'];
|
||||
|
||||
const result = filterRequestHeaders({
|
||||
requestHeaders,
|
||||
forwardedRequestHeaders,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
'content-type': 'application/json',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBody', () => {
|
||||
it('should return null for undefined body', () => {
|
||||
const request = { body: undefined } as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for null body', () => {
|
||||
const request = { body: null } as unknown as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should parse string body as JSON', () => {
|
||||
const request = { body: '{"key":"value"}' } as unknown as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toEqual({ key: 'value' });
|
||||
});
|
||||
|
||||
it('should wrap non-JSON string body in raw property', () => {
|
||||
const request = { body: 'plain text body' } as unknown as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toEqual({ raw: 'plain text body' });
|
||||
});
|
||||
|
||||
it('should return object body as-is (parsed JSON)', () => {
|
||||
const request = {
|
||||
body: { key: 'value', nested: { foo: 'bar' } },
|
||||
} as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toEqual({ key: 'value', nested: { foo: 'bar' } });
|
||||
});
|
||||
|
||||
it('should parse Buffer body as JSON', () => {
|
||||
const request = {
|
||||
body: Buffer.from('{"buffered":"json"}'),
|
||||
} as unknown as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toEqual({ buffered: 'json' });
|
||||
});
|
||||
|
||||
it('should wrap non-JSON Buffer body in raw property', () => {
|
||||
const request = {
|
||||
body: Buffer.from('buffer content'),
|
||||
} as unknown as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toEqual({ raw: 'buffer content' });
|
||||
});
|
||||
|
||||
it('should handle empty object body', () => {
|
||||
const request = { body: {} } as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should handle array body', () => {
|
||||
const request = { body: [1, 2, 3] } as unknown as Request;
|
||||
|
||||
const result = extractBody(request);
|
||||
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeQueryStringParameters', () => {
|
||||
it('should handle simple string parameters', () => {
|
||||
const query = { page: '1', limit: '10' };
|
||||
|
||||
const result = normalizeQueryStringParameters(query);
|
||||
|
||||
expect(result).toEqual({ page: '1', limit: '10' });
|
||||
});
|
||||
|
||||
it('should join array parameters with commas', () => {
|
||||
const query = { ids: ['1', '2', '3'] };
|
||||
|
||||
const result = normalizeQueryStringParameters(query);
|
||||
|
||||
expect(result).toEqual({ ids: '1,2,3' });
|
||||
});
|
||||
|
||||
it('should skip undefined parameters', () => {
|
||||
const query = { page: '1', missing: undefined };
|
||||
|
||||
const result = normalizeQueryStringParameters(query);
|
||||
|
||||
expect(result).toEqual({ page: '1' });
|
||||
});
|
||||
|
||||
it('should handle empty query object', () => {
|
||||
const query = {};
|
||||
|
||||
const result = normalizeQueryStringParameters(query);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it('should stringify nested objects', () => {
|
||||
const query = { filter: { name: 'test' } as unknown as string };
|
||||
|
||||
const result = normalizeQueryStringParameters(query);
|
||||
|
||||
expect(result).toEqual({ filter: '{"name":"test"}' });
|
||||
});
|
||||
|
||||
it('should filter non-string values from arrays and join with commas', () => {
|
||||
const query = { ids: ['1', undefined as unknown as string, '2'] };
|
||||
|
||||
const result = normalizeQueryStringParameters(query);
|
||||
|
||||
expect(result).toEqual({ ids: '1,2' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizePathParameters', () => {
|
||||
it('should handle simple string parameters', () => {
|
||||
const pathParams = { id: '123', slug: 'test' };
|
||||
|
||||
const result = normalizePathParameters(pathParams);
|
||||
|
||||
expect(result).toEqual({ id: '123', slug: 'test' });
|
||||
});
|
||||
|
||||
it('should join array parameters with commas', () => {
|
||||
const pathParams = { ids: ['1', '2', '3'] };
|
||||
|
||||
const result = normalizePathParameters(pathParams);
|
||||
|
||||
expect(result).toEqual({ ids: '1,2,3' });
|
||||
});
|
||||
|
||||
it('should skip undefined parameters', () => {
|
||||
const pathParams = { id: '123', missing: undefined };
|
||||
|
||||
const result = normalizePathParameters(pathParams);
|
||||
|
||||
expect(result).toEqual({ id: '123' });
|
||||
});
|
||||
|
||||
it('should handle empty object', () => {
|
||||
const pathParams = {};
|
||||
|
||||
const result = normalizePathParameters(pathParams);
|
||||
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLogicFunctionEvent', () => {
|
||||
const createMockRequest = (overrides: Partial<Request> = {}): Request =>
|
||||
({
|
||||
headers: {},
|
||||
query: {},
|
||||
body: undefined,
|
||||
method: 'GET',
|
||||
path: '/test',
|
||||
...overrides,
|
||||
}) as Request;
|
||||
|
||||
it('should build a complete event from Express request', () => {
|
||||
const request = createMockRequest({
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer token',
|
||||
'user-agent': 'test',
|
||||
},
|
||||
query: { page: '1' },
|
||||
body: { data: 'test' },
|
||||
method: 'POST',
|
||||
path: '/s/users/123',
|
||||
});
|
||||
|
||||
const result = buildLogicFunctionEvent({
|
||||
request,
|
||||
pathParameters: { id: '123' },
|
||||
forwardedRequestHeaders: ['content-type', 'authorization'],
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer token',
|
||||
},
|
||||
queryStringParameters: { page: '1' },
|
||||
pathParameters: { id: '123' },
|
||||
body: { data: 'test' },
|
||||
isBase64Encoded: false,
|
||||
requestContext: {
|
||||
http: {
|
||||
method: 'POST',
|
||||
path: '/s/users/123',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve the request path as-is', () => {
|
||||
const request = createMockRequest({
|
||||
path: '/s/api/users',
|
||||
});
|
||||
|
||||
const result = buildLogicFunctionEvent({
|
||||
request,
|
||||
pathParameters: {},
|
||||
forwardedRequestHeaders: [],
|
||||
});
|
||||
|
||||
expect(result.requestContext.http.path).toBe('/s/api/users');
|
||||
});
|
||||
|
||||
it('should preserve path without prefix', () => {
|
||||
const request = createMockRequest({
|
||||
path: '/api/users',
|
||||
});
|
||||
|
||||
const result = buildLogicFunctionEvent({
|
||||
request,
|
||||
pathParameters: {},
|
||||
forwardedRequestHeaders: [],
|
||||
});
|
||||
|
||||
expect(result.requestContext.http.path).toBe('/api/users');
|
||||
});
|
||||
|
||||
it('should handle GET request with no body', () => {
|
||||
const request = createMockRequest({
|
||||
method: 'GET',
|
||||
query: { search: 'test' },
|
||||
body: undefined,
|
||||
});
|
||||
|
||||
const result = buildLogicFunctionEvent({
|
||||
request,
|
||||
pathParameters: {},
|
||||
forwardedRequestHeaders: [],
|
||||
});
|
||||
|
||||
expect(result.body).toBeNull();
|
||||
expect(result.queryStringParameters).toEqual({ search: 'test' });
|
||||
});
|
||||
|
||||
it('should handle DELETE request with path parameters', () => {
|
||||
const request = createMockRequest({
|
||||
method: 'DELETE',
|
||||
path: '/s/users/456',
|
||||
});
|
||||
|
||||
const result = buildLogicFunctionEvent({
|
||||
request,
|
||||
pathParameters: { userId: '456' },
|
||||
forwardedRequestHeaders: [],
|
||||
});
|
||||
|
||||
expect(result.requestContext.http.method).toBe('DELETE');
|
||||
expect(result.pathParameters).toEqual({ userId: '456' });
|
||||
});
|
||||
|
||||
it('should filter only allowed headers', () => {
|
||||
const request = createMockRequest({
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: 'Bearer secret',
|
||||
'x-api-key': 'key123',
|
||||
cookie: 'session=abc',
|
||||
},
|
||||
});
|
||||
|
||||
const result = buildLogicFunctionEvent({
|
||||
request,
|
||||
pathParameters: {},
|
||||
forwardedRequestHeaders: ['x-api-key'],
|
||||
});
|
||||
|
||||
expect(result.headers).toEqual({
|
||||
'x-api-key': 'key123',
|
||||
});
|
||||
expect(result.headers['authorization']).toBeUndefined();
|
||||
expect(result.headers['cookie']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set isBase64Encoded to false', () => {
|
||||
const request = createMockRequest();
|
||||
|
||||
const result = buildLogicFunctionEvent({
|
||||
request,
|
||||
pathParameters: {},
|
||||
forwardedRequestHeaders: [],
|
||||
});
|
||||
|
||||
expect(result.isBase64Encoded).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle complex path parameters', () => {
|
||||
const request = createMockRequest({
|
||||
path: '/s/organizations/org1/users/user1/posts',
|
||||
});
|
||||
|
||||
const result = buildLogicFunctionEvent({
|
||||
request,
|
||||
pathParameters: {
|
||||
orgId: 'org1',
|
||||
userId: 'user1',
|
||||
},
|
||||
forwardedRequestHeaders: [],
|
||||
});
|
||||
|
||||
expect(result.pathParameters).toEqual({
|
||||
orgId: 'org1',
|
||||
userId: 'user1',
|
||||
});
|
||||
});
|
||||
});
|
||||
-158
@@ -1,158 +0,0 @@
|
||||
import { type Request } from 'express';
|
||||
import { type LogicFunctionEvent } from 'twenty-shared/types';
|
||||
|
||||
/**
|
||||
* Filters HTTP headers from Express request based on allowed header names
|
||||
* Header names are case-insensitive as per HTTP specification
|
||||
*/
|
||||
export const filterRequestHeaders = ({
|
||||
requestHeaders,
|
||||
forwardedRequestHeaders,
|
||||
}: {
|
||||
requestHeaders: Request['headers'];
|
||||
forwardedRequestHeaders: string[];
|
||||
}): Record<string, string | undefined> => {
|
||||
const lowercaseForwardedHeaders = forwardedRequestHeaders.map((h) =>
|
||||
h.toLowerCase(),
|
||||
);
|
||||
|
||||
const filteredHeaders: Record<string, string | undefined> = {};
|
||||
|
||||
for (const headerName of lowercaseForwardedHeaders) {
|
||||
const headerValue = requestHeaders[headerName];
|
||||
|
||||
if (headerValue !== undefined) {
|
||||
// Convert string[] to comma-separated string (as per HTTP spec)
|
||||
filteredHeaders[headerName] = Array.isArray(headerValue)
|
||||
? headerValue.join(', ')
|
||||
: headerValue;
|
||||
}
|
||||
}
|
||||
|
||||
return filteredHeaders;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the body from Express request as an object
|
||||
* Express body-parser middleware parses JSON bodies automatically
|
||||
* Returns null if body is empty/undefined
|
||||
*/
|
||||
export const extractBody = (request: Request): object | null => {
|
||||
if (request.body === undefined || request.body === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If body is already an object (parsed JSON by body-parser), return as-is
|
||||
if (typeof request.body === 'object' && !Buffer.isBuffer(request.body)) {
|
||||
return request.body;
|
||||
}
|
||||
|
||||
// If body is a string, try to parse as JSON
|
||||
if (typeof request.body === 'string') {
|
||||
try {
|
||||
return JSON.parse(request.body);
|
||||
} catch {
|
||||
// If not valid JSON, wrap in an object
|
||||
return { raw: request.body };
|
||||
}
|
||||
}
|
||||
|
||||
// If body is a Buffer, try to parse as JSON
|
||||
if (Buffer.isBuffer(request.body)) {
|
||||
try {
|
||||
return JSON.parse(request.body.toString('utf-8'));
|
||||
} catch {
|
||||
return { raw: request.body.toString('utf-8') };
|
||||
}
|
||||
}
|
||||
|
||||
return { raw: String(request.body) };
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts Express query parameters to a normalized string format
|
||||
* Arrays are joined with commas (e.g., ['1', '2', '3'] → '1,2,3')
|
||||
*/
|
||||
export const normalizeQueryStringParameters = (
|
||||
query: Request['query'],
|
||||
): Record<string, string | undefined> => {
|
||||
const normalized: Record<string, string | undefined> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
// Join array values with commas
|
||||
const stringValues = value.filter(
|
||||
(v): v is string => typeof v === 'string',
|
||||
);
|
||||
|
||||
normalized[key] = stringValues.join(',');
|
||||
} else if (typeof value === 'string') {
|
||||
normalized[key] = value;
|
||||
} else if (typeof value === 'object') {
|
||||
// Handle nested query objects (e.g., ?foo[bar]=baz)
|
||||
// This is uncommon in REST APIs, convert to JSON string as fallback
|
||||
normalized[key] = JSON.stringify(value);
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes path parameters to string format
|
||||
* Arrays are joined with commas (e.g., ['1', '2', '3'] → '1,2,3')
|
||||
*/
|
||||
export const normalizePathParameters = (
|
||||
pathParams: Record<string, string | string[] | undefined>,
|
||||
): Record<string, string | undefined> => {
|
||||
const normalized: Record<string, string | undefined> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(pathParams)) {
|
||||
if (value === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
normalized[key] = value.join(',');
|
||||
} else {
|
||||
normalized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds an AWS HTTP API v2 compatible event from an Express request
|
||||
* @see https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html
|
||||
*/
|
||||
export const buildLogicFunctionEvent = ({
|
||||
request,
|
||||
pathParameters,
|
||||
forwardedRequestHeaders,
|
||||
}: {
|
||||
request: Request;
|
||||
pathParameters: Record<string, string | string[] | undefined>;
|
||||
forwardedRequestHeaders: string[];
|
||||
}): LogicFunctionEvent => {
|
||||
return {
|
||||
headers: filterRequestHeaders({
|
||||
requestHeaders: request.headers,
|
||||
forwardedRequestHeaders,
|
||||
}),
|
||||
queryStringParameters: normalizeQueryStringParameters(request.query),
|
||||
pathParameters: normalizePathParameters(pathParameters),
|
||||
body: extractBody(request),
|
||||
isBase64Encoded: false,
|
||||
requestContext: {
|
||||
http: {
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user