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:
martmull
2024-07-29 13:03:09 +02:00
committed by GitHub
parent 936279f895
commit 00fea17920
100 changed files with 2283 additions and 121 deletions

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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',

View File

@ -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',

View File

@ -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)

View File

@ -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;
}

View File

@ -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,

View File

@ -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',
}

View File

@ -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: [

View File

@ -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,
),

View File

@ -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);
}
}

View File

@ -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);