Serverless function UI (#6388)
https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=36235-120877 Did not do the file manager part. A Function is defined using one unique file at the moment Feature protected by featureFlag `IS_FUNCTION_SETTINGS_ENABLED` ## Demo https://github.com/user-attachments/assets/0acb8291-47b4-4521-a6fa-a88b9198609b
This commit is contained in:
@ -25,6 +25,7 @@ export enum FeatureFlagKeys {
|
||||
IsMessagingAliasFetchingEnabled = 'IS_MESSAGING_ALIAS_FETCHING_ENABLED',
|
||||
IsGoogleCalendarSyncV2Enabled = 'IS_GOOGLE_CALENDAR_SYNC_V2_ENABLED',
|
||||
IsFreeAccessEnabled = 'IS_FREE_ACCESS_ENABLED',
|
||||
IsFunctionSettingsEnabled = 'IS_FUNCTION_SETTINGS_ENABLED',
|
||||
IsWorkflowEnabled = 'IS_WORKFLOW_ENABLED',
|
||||
}
|
||||
|
||||
|
||||
@ -23,7 +23,11 @@ export const checkFilePath = (filePath: string): string => {
|
||||
throw new BadRequestException(`Folder ${folder} is not allowed`);
|
||||
}
|
||||
|
||||
if (size && !settings.storage.imageCropSizes[folder]?.includes(size)) {
|
||||
if (
|
||||
folder !== kebabCase(FileFolder.ServerlessFunction) &&
|
||||
size &&
|
||||
!settings.storage.imageCropSizes[folder]?.includes(size)
|
||||
) {
|
||||
throw new BadRequestException(`Size ${size} is not allowed`);
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Readable } from 'stream';
|
||||
|
||||
export interface StorageDriver {
|
||||
delete(params: { folderPath: string; filename?: string }): Promise<void>;
|
||||
read(params: { folderPath: string; filename: string }): Promise<Readable>;
|
||||
write(params: {
|
||||
file: Buffer | Uint8Array | string;
|
||||
|
||||
@ -42,6 +42,19 @@ export class LocalDriver implements StorageDriver {
|
||||
await fs.writeFile(filePath, params.file);
|
||||
}
|
||||
|
||||
async delete(params: {
|
||||
folderPath: string;
|
||||
filename?: string;
|
||||
}): Promise<void> {
|
||||
const filePath = join(
|
||||
`${this.options.storagePath}/`,
|
||||
params.folderPath,
|
||||
params.filename || '',
|
||||
);
|
||||
|
||||
await fs.rm(filePath, { recursive: true });
|
||||
}
|
||||
|
||||
async read(params: {
|
||||
folderPath: string;
|
||||
filename: string;
|
||||
|
||||
@ -2,8 +2,11 @@ import { Readable } from 'stream';
|
||||
|
||||
import {
|
||||
CreateBucketCommandInput,
|
||||
DeleteObjectCommand,
|
||||
DeleteObjectsCommand,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommandInput,
|
||||
ListObjectsV2Command,
|
||||
NotFound,
|
||||
PutObjectCommand,
|
||||
S3,
|
||||
@ -53,6 +56,57 @@ export class S3Driver implements StorageDriver {
|
||||
await this.s3Client.send(command);
|
||||
}
|
||||
|
||||
private async emptyS3Directory(folderPath) {
|
||||
const listParams = {
|
||||
Bucket: this.bucketName,
|
||||
Prefix: folderPath,
|
||||
};
|
||||
|
||||
const listObjectsCommand = new ListObjectsV2Command(listParams);
|
||||
const listedObjects = await this.s3Client.send(listObjectsCommand);
|
||||
|
||||
if (listedObjects.Contents?.length === 0) return;
|
||||
|
||||
const deleteParams = {
|
||||
Bucket: this.bucketName,
|
||||
Delete: {
|
||||
Objects: listedObjects.Contents?.map(({ Key }) => {
|
||||
return { Key };
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const deleteObjectCommand = new DeleteObjectsCommand(deleteParams);
|
||||
|
||||
await this.s3Client.send(deleteObjectCommand);
|
||||
|
||||
if (listedObjects.IsTruncated) {
|
||||
await this.emptyS3Directory(folderPath);
|
||||
}
|
||||
}
|
||||
|
||||
async delete(params: {
|
||||
folderPath: string;
|
||||
filename?: string;
|
||||
}): Promise<void> {
|
||||
if (params.filename) {
|
||||
const deleteCommand = new DeleteObjectCommand({
|
||||
Key: `${params.folderPath}/${params.filename}`,
|
||||
Bucket: this.bucketName,
|
||||
});
|
||||
|
||||
await this.s3Client.send(deleteCommand);
|
||||
} else {
|
||||
await this.emptyS3Directory(params.folderPath);
|
||||
const deleteEmptyFolderCommand = new DeleteObjectCommand({
|
||||
Key: `${params.folderPath}`,
|
||||
Bucket: this.bucketName,
|
||||
});
|
||||
|
||||
await this.s3Client.send(deleteEmptyFolderCommand);
|
||||
}
|
||||
}
|
||||
|
||||
async read(params: {
|
||||
folderPath: string;
|
||||
filename: string;
|
||||
|
||||
@ -10,6 +10,10 @@ import { StorageDriver } from './drivers/interfaces/storage-driver.interface';
|
||||
export class FileStorageService implements StorageDriver {
|
||||
constructor(@Inject(STORAGE_DRIVER) private driver: StorageDriver) {}
|
||||
|
||||
delete(params: { folderPath: string; filename?: string }): Promise<void> {
|
||||
return this.driver.delete(params);
|
||||
}
|
||||
|
||||
write(params: {
|
||||
file: string | Buffer | Uint8Array;
|
||||
name: string;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
|
||||
|
||||
export interface ServerlessDriver {
|
||||
delete(serverlessFunction: ServerlessFunctionEntity): Promise<void>;
|
||||
build(serverlessFunction: ServerlessFunctionEntity): Promise<void>;
|
||||
execute(
|
||||
serverlessFunction: ServerlessFunctionEntity,
|
||||
|
||||
@ -5,8 +5,12 @@ import {
|
||||
Lambda,
|
||||
LambdaClientConfig,
|
||||
InvokeCommand,
|
||||
GetFunctionCommand,
|
||||
UpdateFunctionCodeCommand,
|
||||
DeleteFunctionCommand,
|
||||
} from '@aws-sdk/client-lambda';
|
||||
import { CreateFunctionCommandInput } from '@aws-sdk/client-lambda/dist-types/commands/CreateFunctionCommand';
|
||||
import { UpdateFunctionCodeCommandInput } from '@aws-sdk/client-lambda/dist-types/commands/UpdateFunctionCodeCommand';
|
||||
|
||||
import { ServerlessDriver } from 'src/engine/integrations/serverless/drivers/interfaces/serverless-driver.interface';
|
||||
|
||||
@ -42,6 +46,18 @@ export class LambdaDriver
|
||||
this.buildDirectoryManagerService = options.buildDirectoryManagerService;
|
||||
}
|
||||
|
||||
async delete(serverlessFunction: ServerlessFunctionEntity) {
|
||||
try {
|
||||
const deleteFunctionCommand = new DeleteFunctionCommand({
|
||||
FunctionName: serverlessFunction.id,
|
||||
});
|
||||
|
||||
await this.lambdaClient.send(deleteFunctionCommand);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
async build(serverlessFunction: ServerlessFunctionEntity) {
|
||||
const javascriptCode = await this.getCompiledCode(
|
||||
serverlessFunction,
|
||||
@ -59,21 +75,44 @@ export class LambdaDriver
|
||||
|
||||
await createZipFile(sourceTemporaryDir, lambdaZipPath);
|
||||
|
||||
const params: CreateFunctionCommandInput = {
|
||||
Code: {
|
||||
let existingFunction = true;
|
||||
|
||||
try {
|
||||
const getFunctionCommand = new GetFunctionCommand({
|
||||
FunctionName: serverlessFunction.id,
|
||||
});
|
||||
|
||||
await this.lambdaClient.send(getFunctionCommand);
|
||||
} catch {
|
||||
existingFunction = false;
|
||||
}
|
||||
|
||||
if (!existingFunction) {
|
||||
const params: CreateFunctionCommandInput = {
|
||||
Code: {
|
||||
ZipFile: await fs.promises.readFile(lambdaZipPath),
|
||||
},
|
||||
FunctionName: serverlessFunction.id,
|
||||
Handler: lambdaHandler,
|
||||
Role: this.lambdaRole,
|
||||
Runtime: serverlessFunction.runtime,
|
||||
Description: 'Lambda function to run user script',
|
||||
Timeout: 900,
|
||||
};
|
||||
|
||||
const command = new CreateFunctionCommand(params);
|
||||
|
||||
await this.lambdaClient.send(command);
|
||||
} else {
|
||||
const params: UpdateFunctionCodeCommandInput = {
|
||||
ZipFile: await fs.promises.readFile(lambdaZipPath),
|
||||
},
|
||||
FunctionName: serverlessFunction.id,
|
||||
Handler: lambdaHandler,
|
||||
Role: this.lambdaRole,
|
||||
Runtime: 'nodejs18.x',
|
||||
Description: 'Lambda function to run user script',
|
||||
Timeout: 900,
|
||||
};
|
||||
FunctionName: serverlessFunction.id,
|
||||
};
|
||||
|
||||
const command = new CreateFunctionCommand(params);
|
||||
const command = new UpdateFunctionCodeCommand(params);
|
||||
|
||||
await this.lambdaClient.send(command);
|
||||
await this.lambdaClient.send(command);
|
||||
}
|
||||
|
||||
await this.buildDirectoryManagerService.clean();
|
||||
}
|
||||
|
||||
@ -28,6 +28,12 @@ export class LocalDriver
|
||||
this.fileStorageService = options.fileStorageService;
|
||||
}
|
||||
|
||||
async delete(serverlessFunction: ServerlessFunctionEntity) {
|
||||
await this.fileStorageService.delete({
|
||||
folderPath: this.getFolderPath(serverlessFunction),
|
||||
});
|
||||
}
|
||||
|
||||
async build(serverlessFunction: ServerlessFunctionEntity) {
|
||||
const javascriptCode = await this.getCompiledCode(
|
||||
serverlessFunction,
|
||||
@ -57,8 +63,16 @@ export class LocalDriver
|
||||
const modifiedContent = `
|
||||
process.on('message', async (message) => {
|
||||
const { event, context } = message;
|
||||
const result = await handler(event, context);
|
||||
process.send(result);
|
||||
try {
|
||||
const result = await handler(event, context);
|
||||
process.send(result);
|
||||
} catch (error) {
|
||||
process.send({
|
||||
errorType: error.name,
|
||||
errorMessage: error.message,
|
||||
stackTrace: error.stack.split('\\n').filter((line) => line.trim() !== ''),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
${fileContent}
|
||||
@ -67,7 +81,7 @@ export class LocalDriver
|
||||
await fs.writeFile(tmpFilePath, modifiedContent);
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = fork(tmpFilePath);
|
||||
const child = fork(tmpFilePath, { silent: true });
|
||||
|
||||
child.on('message', (message: object) => {
|
||||
resolve(message);
|
||||
@ -75,6 +89,32 @@ export class LocalDriver
|
||||
fs.unlink(tmpFilePath);
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
const stackTrace = data
|
||||
.toString()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim() !== '');
|
||||
const errorTrace = stackTrace.filter((line) =>
|
||||
line.includes('Error: '),
|
||||
)?.[0];
|
||||
|
||||
let errorType = 'Unknown';
|
||||
let errorMessage = '';
|
||||
|
||||
if (errorTrace) {
|
||||
errorType = errorTrace.split(':')[0];
|
||||
errorMessage = errorTrace.split(': ')[1];
|
||||
}
|
||||
|
||||
resolve({
|
||||
errorType,
|
||||
errorMessage,
|
||||
stackTrace: stackTrace,
|
||||
});
|
||||
child.kill();
|
||||
fs.unlink(tmpFilePath);
|
||||
});
|
||||
|
||||
child.on('error', (error) => {
|
||||
reject(error);
|
||||
child.kill();
|
||||
|
||||
@ -9,6 +9,10 @@ import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless
|
||||
export class ServerlessService implements ServerlessDriver {
|
||||
constructor(@Inject(SERVERLESS_DRIVER) private driver: ServerlessDriver) {}
|
||||
|
||||
async delete(serverlessFunction: ServerlessFunctionEntity): Promise<void> {
|
||||
return this.driver.delete(serverlessFunction);
|
||||
}
|
||||
|
||||
async build(serverlessFunction: ServerlessFunctionEntity): Promise<void> {
|
||||
return this.driver.build(serverlessFunction);
|
||||
}
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
@InputType()
|
||||
export class CreateServerlessFunctionFromFileInput {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Field({ nullable: true })
|
||||
description?: string;
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
import { CreateServerlessFunctionFromFileInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function-from-file.input';
|
||||
|
||||
@InputType()
|
||||
export class CreateServerlessFunctionInput extends CreateServerlessFunctionFromFileInput {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
code: string;
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
import { ID, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
|
||||
@InputType()
|
||||
export class DeleteServerlessFunctionInput {
|
||||
@IDField(() => ID, { description: 'The id of the function.' })
|
||||
id!: string;
|
||||
}
|
||||
@ -1,14 +1,18 @@
|
||||
import { ArgsType, Field } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
import { IsNotEmpty, IsObject, IsOptional, IsUUID } from 'class-validator';
|
||||
import graphqlTypeJson from 'graphql-type-json';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ArgsType()
|
||||
export class ExecuteServerlessFunctionInput {
|
||||
@Field({ description: 'Name of the serverless function to execute' })
|
||||
@Field(() => UUIDScalarType, {
|
||||
description: 'Id of the serverless function to execute',
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
name: string;
|
||||
@IsUUID()
|
||||
id: string;
|
||||
|
||||
@Field(() => graphqlTypeJson, {
|
||||
description: 'Payload in JSON format',
|
||||
|
||||
@ -4,7 +4,7 @@ import { IsObject } from 'class-validator';
|
||||
import graphqlTypeJson from 'graphql-type-json';
|
||||
|
||||
@ObjectType('ServerlessFunctionExecutionResult')
|
||||
export class ServerlessFunctionExecutionResultDTO {
|
||||
export class ServerlessFunctionExecutionResultDto {
|
||||
@IsObject()
|
||||
@Field(() => graphqlTypeJson, {
|
||||
description: 'Execution result in JSON format',
|
||||
@ -26,7 +26,7 @@ registerEnumType(ServerlessFunctionSyncStatus, {
|
||||
description: 'SyncStatus of the serverlessFunction',
|
||||
});
|
||||
|
||||
@ObjectType('serverlessFunction')
|
||||
@ObjectType('ServerlessFunction')
|
||||
@Authorize({
|
||||
authorize: (context: any) => ({
|
||||
workspaceId: { eq: context?.req?.user?.workspace?.id },
|
||||
@ -47,11 +47,25 @@ export class ServerlessFunctionDto {
|
||||
@Field()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@Field()
|
||||
description: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
sourceCodeHash: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
sourceCodeFullPath: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
runtime: string;
|
||||
|
||||
@IsEnum(ServerlessFunctionSyncStatus)
|
||||
@IsNotEmpty()
|
||||
@Field(() => ServerlessFunctionSyncStatus)
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@InputType()
|
||||
export class UpdateServerlessFunctionInput {
|
||||
@Field(() => UUIDScalarType, {
|
||||
description: 'Id of the serverless function to execute',
|
||||
})
|
||||
@IsNotEmpty()
|
||||
@IsUUID()
|
||||
id: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@Field({ nullable: true })
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
code: string;
|
||||
}
|
||||
@ -12,6 +12,10 @@ export enum ServerlessFunctionSyncStatus {
|
||||
READY = 'READY',
|
||||
}
|
||||
|
||||
export enum ServerlessFunctionRuntime {
|
||||
NODE18 = 'nodejs18.x',
|
||||
}
|
||||
|
||||
@Entity('serverlessFunction')
|
||||
@Unique('IndexOnNameAndWorkspaceIdUnique', ['name', 'workspaceId'])
|
||||
export class ServerlessFunctionEntity {
|
||||
@ -21,9 +25,18 @@ export class ServerlessFunctionEntity {
|
||||
@Column({ nullable: false })
|
||||
name: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
description: string;
|
||||
|
||||
@Column({ nullable: false })
|
||||
sourceCodeHash: string;
|
||||
|
||||
@Column({ nullable: false })
|
||||
sourceCodeFullPath: string;
|
||||
|
||||
@Column({ nullable: false, default: ServerlessFunctionRuntime.NODE18 })
|
||||
runtime: ServerlessFunctionRuntime;
|
||||
|
||||
@Column({
|
||||
nullable: false,
|
||||
default: ServerlessFunctionSyncStatus.NOT_READY,
|
||||
|
||||
@ -9,6 +9,7 @@ export class ServerlessFunctionException extends CustomException {
|
||||
|
||||
export enum ServerlessFunctionExceptionCode {
|
||||
SERVERLESS_FUNCTION_NOT_FOUND = 'SERVERLESS_FUNCTION_NOT_FOUND',
|
||||
FEATURE_FLAG_INVALID = 'FEATURE_FLAG_INVALID',
|
||||
SERVERLESS_FUNCTION_ALREADY_EXIST = 'SERVERLESS_FUNCTION_ALREADY_EXIST',
|
||||
SERVERLESS_FUNCTION_NOT_READY = 'SERVERLESS_FUNCTION_NOT_READY',
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import {
|
||||
NestjsQueryGraphQLModule,
|
||||
@ -14,6 +15,7 @@ import { ServerlessFunctionResolver } from 'src/engine/metadata-modules/serverle
|
||||
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
|
||||
import { ServerlessFunctionDto } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto';
|
||||
import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-upload.module';
|
||||
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@ -24,6 +26,7 @@ import { FileUploadModule } from 'src/engine/core-modules/file/file-upload/file-
|
||||
[ServerlessFunctionEntity],
|
||||
'metadata',
|
||||
),
|
||||
TypeOrmModule.forFeature([FeatureFlagEntity], 'core'),
|
||||
],
|
||||
services: [ServerlessFunctionService],
|
||||
resolvers: [
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { UseGuards } from '@nestjs/common';
|
||||
import { Args, Mutation, Resolver } from '@nestjs/graphql';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { FileUpload, GraphQLUpload } from 'graphql-upload';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
|
||||
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
|
||||
@ -9,45 +11,136 @@ import { ExecuteServerlessFunctionInput } from 'src/engine/metadata-modules/serv
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { ServerlessFunctionDto } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto';
|
||||
import { ServerlessFunctionExecutionResultDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result-d-t.o';
|
||||
import { ServerlessFunctionExecutionResultDto } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto';
|
||||
import { serverlessFunctionGraphQLApiExceptionHandler } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils';
|
||||
import { CreateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function.input';
|
||||
import { CreateServerlessFunctionFromFileInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function-from-file.input';
|
||||
import { UpdateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/update-serverless-function.input';
|
||||
import { DeleteServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/delete-serverless-function.input';
|
||||
import {
|
||||
FeatureFlagEntity,
|
||||
FeatureFlagKeys,
|
||||
} from 'src/engine/core-modules/feature-flag/feature-flag.entity';
|
||||
import {
|
||||
ServerlessFunctionException,
|
||||
ServerlessFunctionExceptionCode,
|
||||
} from 'src/engine/metadata-modules/serverless-function/serverless-function.exception';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Resolver()
|
||||
export class ServerlessFunctionResolver {
|
||||
constructor(
|
||||
private readonly serverlessFunctionService: ServerlessFunctionService,
|
||||
@InjectRepository(FeatureFlagEntity, 'core')
|
||||
private readonly featureFlagRepository: Repository<FeatureFlagEntity>,
|
||||
) {}
|
||||
|
||||
async checkFeatureFlag(workspaceId: string) {
|
||||
const isFunctionSettingsEnabled =
|
||||
await this.featureFlagRepository.findOneBy({
|
||||
workspaceId,
|
||||
key: FeatureFlagKeys.IsFunctionSettingsEnabled,
|
||||
value: true,
|
||||
});
|
||||
|
||||
if (!isFunctionSettingsEnabled) {
|
||||
throw new ServerlessFunctionException(
|
||||
`IS_FUNCTION_SETTINGS_ENABLED feature flag is not set to true for this workspace`,
|
||||
ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation(() => ServerlessFunctionDto)
|
||||
async createOneServerlessFunction(
|
||||
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||
file: FileUpload,
|
||||
@Args('name', { type: () => String }) name: string,
|
||||
async deleteOneServerlessFunction(
|
||||
@Args('input') input: DeleteServerlessFunctionInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
) {
|
||||
try {
|
||||
return await this.serverlessFunctionService.createOne(
|
||||
name,
|
||||
await this.checkFeatureFlag(workspaceId);
|
||||
|
||||
return await this.serverlessFunctionService.deleteOneServerlessFunction(
|
||||
input.id,
|
||||
workspaceId,
|
||||
file,
|
||||
);
|
||||
} catch (error) {
|
||||
serverlessFunctionGraphQLApiExceptionHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation(() => ServerlessFunctionExecutionResultDTO)
|
||||
@Mutation(() => ServerlessFunctionDto)
|
||||
async updateOneServerlessFunction(
|
||||
@Args('input')
|
||||
input: UpdateServerlessFunctionInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
) {
|
||||
try {
|
||||
await this.checkFeatureFlag(workspaceId);
|
||||
|
||||
return await this.serverlessFunctionService.updateOneServerlessFunction(
|
||||
input,
|
||||
workspaceId,
|
||||
);
|
||||
} catch (error) {
|
||||
serverlessFunctionGraphQLApiExceptionHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation(() => ServerlessFunctionDto)
|
||||
async createOneServerlessFunction(
|
||||
@Args('input')
|
||||
input: CreateServerlessFunctionInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
) {
|
||||
try {
|
||||
await this.checkFeatureFlag(workspaceId);
|
||||
|
||||
return await this.serverlessFunctionService.createOneServerlessFunction(
|
||||
{
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
},
|
||||
input.code,
|
||||
workspaceId,
|
||||
);
|
||||
} catch (error) {
|
||||
serverlessFunctionGraphQLApiExceptionHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation(() => ServerlessFunctionDto)
|
||||
async createOneServerlessFunctionFromFile(
|
||||
@Args({ name: 'file', type: () => GraphQLUpload })
|
||||
file: FileUpload,
|
||||
@Args('input')
|
||||
input: CreateServerlessFunctionFromFileInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
) {
|
||||
try {
|
||||
await this.checkFeatureFlag(workspaceId);
|
||||
|
||||
return await this.serverlessFunctionService.createOneServerlessFunction(
|
||||
input,
|
||||
file,
|
||||
workspaceId,
|
||||
);
|
||||
} catch (error) {
|
||||
serverlessFunctionGraphQLApiExceptionHandler(error);
|
||||
}
|
||||
}
|
||||
|
||||
@Mutation(() => ServerlessFunctionExecutionResultDto)
|
||||
async executeOneServerlessFunction(
|
||||
@Args() executeServerlessFunctionInput: ExecuteServerlessFunctionInput,
|
||||
@AuthWorkspace() { id: workspaceId }: Workspace,
|
||||
) {
|
||||
try {
|
||||
const { name, payload } = executeServerlessFunctionInput;
|
||||
await this.checkFeatureFlag(workspaceId);
|
||||
const { id, payload } = executeServerlessFunctionInput;
|
||||
|
||||
return {
|
||||
result: await this.serverlessFunctionService.executeOne(
|
||||
name,
|
||||
id,
|
||||
workspaceId,
|
||||
payload,
|
||||
),
|
||||
|
||||
@ -5,6 +5,8 @@ import { join } from 'path';
|
||||
|
||||
import { FileUpload } from 'graphql-upload';
|
||||
import { Repository } from 'typeorm';
|
||||
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
|
||||
|
||||
@ -21,24 +23,28 @@ import { readFileContent } from 'src/engine/integrations/file-storage/utils/read
|
||||
import { FileStorageService } from 'src/engine/integrations/file-storage/file-storage.service';
|
||||
import { SOURCE_FILE_NAME } from 'src/engine/integrations/serverless/drivers/constants/source-file-name';
|
||||
import { serverlessFunctionCreateHash } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-create-hash.utils';
|
||||
import { CreateServerlessFunctionFromFileInput } from 'src/engine/metadata-modules/serverless-function/dtos/create-serverless-function-from-file.input';
|
||||
import { UpdateServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/update-serverless-function.input';
|
||||
|
||||
@Injectable()
|
||||
export class ServerlessFunctionService {
|
||||
export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFunctionEntity> {
|
||||
constructor(
|
||||
private readonly fileStorageService: FileStorageService,
|
||||
private readonly serverlessService: ServerlessService,
|
||||
@InjectRepository(ServerlessFunctionEntity, 'metadata')
|
||||
private readonly serverlessFunctionRepository: Repository<ServerlessFunctionEntity>,
|
||||
) {}
|
||||
) {
|
||||
super(serverlessFunctionRepository);
|
||||
}
|
||||
|
||||
async executeOne(
|
||||
name: string,
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
payload: object | undefined = undefined,
|
||||
) {
|
||||
const functionToExecute = await this.serverlessFunctionRepository.findOne({
|
||||
where: {
|
||||
name,
|
||||
id,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
@ -62,14 +68,82 @@ export class ServerlessFunctionService {
|
||||
return this.serverlessService.execute(functionToExecute, payload);
|
||||
}
|
||||
|
||||
async createOne(
|
||||
name: string,
|
||||
async deleteOneServerlessFunction(id: string, workspaceId: string) {
|
||||
const existingServerlessFunction =
|
||||
await this.serverlessFunctionRepository.findOne({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
|
||||
if (!existingServerlessFunction) {
|
||||
throw new ServerlessFunctionException(
|
||||
`Function does not exist`,
|
||||
ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
await super.deleteOne(id);
|
||||
|
||||
await this.serverlessService.delete(existingServerlessFunction);
|
||||
|
||||
return existingServerlessFunction;
|
||||
}
|
||||
|
||||
async updateOneServerlessFunction(
|
||||
serverlessFunctionInput: UpdateServerlessFunctionInput,
|
||||
workspaceId: string,
|
||||
{ createReadStream, mimetype }: FileUpload,
|
||||
) {
|
||||
const existingServerlessFunction =
|
||||
await this.serverlessFunctionRepository.findOne({
|
||||
where: { name, workspaceId },
|
||||
where: { id: serverlessFunctionInput.id, workspaceId },
|
||||
});
|
||||
|
||||
if (!existingServerlessFunction) {
|
||||
throw new ServerlessFunctionException(
|
||||
`Function does not exist`,
|
||||
ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
const codeHasChanged =
|
||||
serverlessFunctionCreateHash(serverlessFunctionInput.code) !==
|
||||
existingServerlessFunction.sourceCodeHash;
|
||||
|
||||
await super.updateOne(existingServerlessFunction.id, {
|
||||
name: serverlessFunctionInput.name,
|
||||
description: serverlessFunctionInput.description,
|
||||
sourceCodeHash: serverlessFunctionCreateHash(
|
||||
serverlessFunctionInput.code,
|
||||
),
|
||||
});
|
||||
|
||||
if (codeHasChanged) {
|
||||
const fileFolder = join(
|
||||
FileFolder.ServerlessFunction,
|
||||
workspaceId,
|
||||
existingServerlessFunction.id,
|
||||
);
|
||||
|
||||
await this.fileStorageService.write({
|
||||
file: serverlessFunctionInput.code,
|
||||
name: SOURCE_FILE_NAME,
|
||||
mimeType: undefined,
|
||||
folder: fileFolder,
|
||||
});
|
||||
|
||||
await this.serverlessService.build(existingServerlessFunction);
|
||||
}
|
||||
|
||||
return await this.findById(existingServerlessFunction.id);
|
||||
}
|
||||
|
||||
async createOneServerlessFunction(
|
||||
serverlessFunctionInput: CreateServerlessFunctionFromFileInput,
|
||||
code: FileUpload | string,
|
||||
workspaceId: string,
|
||||
) {
|
||||
const existingServerlessFunction =
|
||||
await this.serverlessFunctionRepository.findOne({
|
||||
where: { name: serverlessFunctionInput.name, workspaceId },
|
||||
});
|
||||
|
||||
if (existingServerlessFunction) {
|
||||
@ -79,34 +153,44 @@ export class ServerlessFunctionService {
|
||||
);
|
||||
}
|
||||
|
||||
const typescriptCode = await readFileContent(createReadStream());
|
||||
let typescriptCode: string;
|
||||
|
||||
const serverlessFunction = await this.serverlessFunctionRepository.save({
|
||||
name,
|
||||
workspaceId,
|
||||
sourceCodeHash: serverlessFunctionCreateHash(typescriptCode),
|
||||
});
|
||||
if (typeof code === 'string') {
|
||||
typescriptCode = code;
|
||||
} else {
|
||||
typescriptCode = await readFileContent(code.createReadStream());
|
||||
}
|
||||
|
||||
const serverlessFunctionId = v4();
|
||||
|
||||
const fileFolder = join(
|
||||
FileFolder.ServerlessFunction,
|
||||
workspaceId,
|
||||
serverlessFunction.id,
|
||||
serverlessFunctionId,
|
||||
);
|
||||
|
||||
const sourceCodeFullPath = fileFolder + '/' + SOURCE_FILE_NAME;
|
||||
|
||||
const serverlessFunction = await super.createOne({
|
||||
...serverlessFunctionInput,
|
||||
id: serverlessFunctionId,
|
||||
workspaceId,
|
||||
sourceCodeHash: serverlessFunctionCreateHash(typescriptCode),
|
||||
sourceCodeFullPath,
|
||||
});
|
||||
|
||||
await this.fileStorageService.write({
|
||||
file: typescriptCode,
|
||||
name: SOURCE_FILE_NAME,
|
||||
mimeType: mimetype,
|
||||
mimeType: undefined,
|
||||
folder: fileFolder,
|
||||
});
|
||||
|
||||
await this.serverlessService.build(serverlessFunction);
|
||||
await this.serverlessFunctionRepository.update(serverlessFunction.id, {
|
||||
await super.updateOne(serverlessFunctionId, {
|
||||
syncStatus: ServerlessFunctionSyncStatus.READY,
|
||||
});
|
||||
|
||||
return await this.serverlessFunctionRepository.findOneByOrFail({
|
||||
id: serverlessFunction.id,
|
||||
});
|
||||
return await this.findById(serverlessFunctionId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ export const serverlessFunctionGraphQLApiExceptionHandler = (error: any) => {
|
||||
case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_ALREADY_EXIST:
|
||||
throw new ConflictError(error.message);
|
||||
case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_READY:
|
||||
case ServerlessFunctionExceptionCode.FEATURE_FLAG_INVALID:
|
||||
throw new ForbiddenError(error.message);
|
||||
default:
|
||||
throw new InternalServerError(error.message);
|
||||
|
||||
@ -62,6 +62,7 @@ export class AddStandardIdCommand extends CommandRunner {
|
||||
IS_MESSAGING_ALIAS_FETCHING_ENABLED: true,
|
||||
IS_GOOGLE_CALENDAR_SYNC_V2_ENABLED: true,
|
||||
IS_FREE_ACCESS_ENABLED: false,
|
||||
IS_FUNCTION_SETTINGS_ENABLED: false,
|
||||
IS_WORKFLOW_ENABLED: false,
|
||||
},
|
||||
);
|
||||
@ -81,6 +82,7 @@ export class AddStandardIdCommand extends CommandRunner {
|
||||
IS_MESSAGING_ALIAS_FETCHING_ENABLED: true,
|
||||
IS_GOOGLE_CALENDAR_SYNC_V2_ENABLED: true,
|
||||
IS_FREE_ACCESS_ENABLED: false,
|
||||
IS_FUNCTION_SETTINGS_ENABLED: false,
|
||||
IS_WORKFLOW_ENABLED: false,
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user