8311 serverless function functions can be executed with any input (#8380)

- remove ts-morph
- update inputSchema shape

![image](https://github.com/user-attachments/assets/e62f3fdb-5be8-4666-8172-44f73a1981b9)


https://github.com/user-attachments/assets/913cd305-9e7c-48da-b20f-c974a8ac7cea

## TODO
- have inputTypes to match the inputSchema type (string, number,
boolean, etc...), only string for now
- handle required/optional inputs
- handle case when inputSchema changes, fix data reset when switching
function
This commit is contained in:
martmull
2024-11-08 17:15:27 +01:00
committed by GitHub
parent 0381996fb9
commit 354ee86cb9
26 changed files with 534 additions and 296 deletions

View File

@ -1,6 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing';
import { CodeIntrospectionException } from 'src/modules/code-introspection/code-introspection.exception';
import { CodeIntrospectionService } from 'src/modules/code-introspection/code-introspection.service';
describe('CodeIntrospectionService', () => {
@ -19,118 +18,121 @@ describe('CodeIntrospectionService', () => {
});
describe('getFunctionInputSchema', () => {
it('should analyze a function declaration correctly', () => {
it('should analyze a simple function correctly', () => {
const fileContent = `
function testFunction(param1: string, param2: number): void {
console.log(param1, param2);
return;
}
`;
const result = service.getFunctionInputSchema(fileContent);
expect(result).toEqual([
{ name: 'param1', type: 'string' },
{ name: 'param2', type: 'number' },
]);
expect(result).toEqual({
param1: { type: 'string' },
param2: { type: 'number' },
});
});
it('should analyze an arrow function correctly', () => {
it('should analyze a arrow function correctly', () => {
const fileContent = `
const testArrowFunction = (param1: string, param2: number): void => {
console.log(param1, param2);
export const main = async (
param1: string,
param2: number,
): Promise<object> => {
return params;
};
`;
const result = service.getFunctionInputSchema(fileContent);
expect(result).toEqual([
{ name: 'param1', type: 'string' },
{ name: 'param2', type: 'number' },
]);
expect(result).toEqual({
param1: { type: 'string' },
param2: { type: 'number' },
});
});
it('should return an empty array for files without functions', () => {
it('should analyze a complex function correctly', () => {
const fileContent = `
const x = 5;
console.log(x);
`;
const result = service.getFunctionInputSchema(fileContent);
expect(result).toEqual([]);
});
it('should throw an exception for multiple function declarations', () => {
const fileContent = `
function func1(param1: string) {}
function func2(param2: number) {}
`;
expect(() => service.getFunctionInputSchema(fileContent)).toThrow(
CodeIntrospectionException,
);
expect(() => service.getFunctionInputSchema(fileContent)).toThrow(
'Only one function is allowed',
);
});
it('should throw an exception for multiple arrow functions', () => {
const fileContent = `
const func1 = (param1: string) => {};
const func2 = (param2: number) => {};
`;
expect(() => service.getFunctionInputSchema(fileContent)).toThrow(
CodeIntrospectionException,
);
expect(() => service.getFunctionInputSchema(fileContent)).toThrow(
'Only one arrow function is allowed',
);
});
it('should correctly analyze complex types', () => {
const fileContent = `
function complexFunction(param1: string[], param2: { key: number }): Promise<boolean> {
return Promise.resolve(true);
function testFunction(
params: {
param1: string;
param2: number;
param3: boolean;
param4: object;
param5: { subParam1: string };
param6: "my" | "enum";
param7: string[];
}
): void {
return
}
`;
const result = service.getFunctionInputSchema(fileContent);
expect(result).toEqual([
{ name: 'param1', type: 'string[]' },
{ name: 'param2', type: '{ key: number; }' },
]);
expect(result).toEqual({
params: {
type: 'object',
properties: {
param1: { type: 'string' },
param2: { type: 'number' },
param3: { type: 'boolean' },
param4: { type: 'object' },
param5: {
type: 'object',
properties: {
subParam1: { type: 'string' },
},
},
param6: { type: 'string', enum: ['my', 'enum'] },
param7: { type: 'array', items: { type: 'string' } },
},
},
});
});
});
describe('generateFakeDataForFunction', () => {
it('should generate fake data for function', () => {
describe('generateInputData', () => {
it('should generate fake data for simple function', () => {
const fileContent = `
const testArrowFunction = (param1: string, param2: number): void => {
console.log(param1, param2);
};
function testFunction(param1: string, param2: number): void {
return;
}
`;
const inputSchema = service.getFunctionInputSchema(fileContent);
const result = service.generateInputData(inputSchema);
const result = service.generateInputData(fileContent);
expect(typeof result['param1']).toEqual('string');
expect(typeof result['param2']).toEqual('number');
expect(result).toEqual({ param1: 'generated-string-value', param2: 1 });
});
it('should generate fake data for complex function', () => {
const fileContent = `
const testArrowFunction = (param1: string[], param2: { key: number }): void => {
console.log(param1, param2);
};
function testFunction(
params: {
param1: string;
param2: number;
param3: boolean;
param4: object;
param5: { subParam1: string };
param6: "my" | "enum";
param7: string[];
}
): void {
return
}
`;
const result = service.generateInputData(fileContent);
const inputSchema = service.getFunctionInputSchema(fileContent);
const result = service.generateInputData(inputSchema);
expect(Array.isArray(result['param1'])).toBeTruthy();
expect(typeof result['param1'][0]).toEqual('string');
expect(typeof result['param2']).toEqual('object');
expect(typeof result['param2']['key']).toEqual('number');
expect(result).toEqual({
params: {
param1: 'generated-string-value',
param2: 1,
param3: true,
param4: {},
param5: { subParam1: 'generated-string-value' },
param6: 'my',
param7: ['generated-string-value'],
},
});
});
});
});

View File

@ -1,105 +1,157 @@
import { Injectable } from '@nestjs/common';
import {
ArrayTypeNode,
createSourceFile,
LiteralTypeNode,
PropertySignature,
ScriptTarget,
StringLiteral,
SyntaxKind,
TypeNode,
UnionTypeNode,
VariableStatement,
ArrowFunction,
FunctionDeclaration,
ParameterDeclaration,
Project,
SyntaxKind,
} from 'ts-morph';
} from 'typescript';
import { FunctionParameter } from 'src/engine/metadata-modules/serverless-function/dtos/function-parameter.dto';
import { generateFakeValue } from 'src/engine/utils/generate-fake-value';
import { isDefined } from 'src/utils/is-defined';
import {
CodeIntrospectionException,
CodeIntrospectionExceptionCode,
} from 'src/modules/code-introspection/code-introspection.exception';
InputSchema,
InputSchemaProperty,
} from 'src/modules/code-introspection/types/input-schema.type';
@Injectable()
export class CodeIntrospectionService {
private project: Project;
constructor() {
this.project = new Project();
}
public generateInputData(fileContent: string, fileName = 'temp.ts') {
const parameters = this.getFunctionInputSchema(fileContent, fileName);
return this.generateFakeDataFromParams(parameters);
}
public getFunctionInputSchema(
fileContent: string,
fileName = 'temp.ts',
): FunctionParameter[] {
const sourceFile = this.project.createSourceFile(fileName, fileContent, {
overwrite: true,
});
const functionDeclarations = sourceFile.getFunctions();
if (functionDeclarations.length > 0) {
return this.getFunctionParameters(functionDeclarations);
}
const arrowFunctions = sourceFile.getDescendantsOfKind(
SyntaxKind.ArrowFunction,
);
if (arrowFunctions.length > 0) {
return this.getArrowFunctionParameters(arrowFunctions);
}
return [];
}
private getFunctionParameters(
functionDeclarations: FunctionDeclaration[],
): FunctionParameter[] {
if (functionDeclarations.length > 1) {
throw new CodeIntrospectionException(
'Only one function is allowed',
CodeIntrospectionExceptionCode.ONLY_ONE_FUNCTION_ALLOWED,
);
}
const functionDeclaration = functionDeclarations[0];
return functionDeclaration.getParameters().map(this.buildFunctionParameter);
}
private getArrowFunctionParameters(
arrowFunctions: ArrowFunction[],
): FunctionParameter[] {
if (arrowFunctions.length > 1) {
throw new CodeIntrospectionException(
'Only one arrow function is allowed',
CodeIntrospectionExceptionCode.ONLY_ONE_FUNCTION_ALLOWED,
);
}
const arrowFunction = arrowFunctions[0];
return arrowFunction.getParameters().map(this.buildFunctionParameter);
}
private buildFunctionParameter(
parameter: ParameterDeclaration,
): FunctionParameter {
return {
name: parameter.getName(),
type: parameter.getType().getText(),
};
}
private generateFakeDataFromParams(
params: FunctionParameter[],
): Record<string, any> {
return params.reduce((acc, param) => {
acc[param.name] = generateFakeValue(param.type);
public generateInputData(inputSchema: InputSchema) {
return Object.entries(inputSchema).reduce((acc, [key, value]) => {
if (isDefined(value.enum)) {
acc[key] = value.enum?.[0];
} else if (['string', 'number', 'boolean'].includes(value.type)) {
acc[key] = generateFakeValue(value.type);
} else if (value.type === 'object') {
acc[key] = isDefined(value.properties)
? this.generateInputData(value.properties)
: {};
} else if (value.type === 'array' && isDefined(value.items)) {
acc[key] = [generateFakeValue(value.items.type)];
}
return acc;
}, {});
}
public getFunctionInputSchema(fileContent: string): InputSchema {
const sourceFile = createSourceFile(
'temp.ts',
fileContent,
ScriptTarget.ESNext,
true,
);
const schema: InputSchema = {};
sourceFile.forEachChild((node) => {
if (node.kind === SyntaxKind.FunctionDeclaration) {
const funcNode = node as FunctionDeclaration;
const params = funcNode.parameters;
params.forEach((param) => {
const paramName = param.name.getText();
const typeNode = param.type;
if (typeNode) {
schema[paramName] = this.getTypeString(typeNode);
} else {
schema[paramName] = { type: 'unknown' };
}
});
} else if (node.kind === SyntaxKind.VariableStatement) {
const varStatement = node as VariableStatement;
varStatement.declarationList.declarations.forEach((declaration) => {
if (
declaration.initializer &&
declaration.initializer.kind === SyntaxKind.ArrowFunction
) {
const arrowFunction = declaration.initializer as ArrowFunction;
const params = arrowFunction.parameters;
params.forEach((param: any) => {
const paramName = param.name.text;
const typeNode = param.type;
if (typeNode) {
schema[paramName] = this.getTypeString(typeNode);
} else {
schema[paramName] = { type: 'unknown' };
}
});
}
});
}
});
return schema;
}
private getTypeString(typeNode: TypeNode): InputSchemaProperty {
switch (typeNode.kind) {
case SyntaxKind.NumberKeyword:
return { type: 'number' };
case SyntaxKind.StringKeyword:
return { type: 'string' };
case SyntaxKind.BooleanKeyword:
return { type: 'boolean' };
case SyntaxKind.ArrayType:
return {
type: 'array',
items: this.getTypeString((typeNode as ArrayTypeNode).elementType),
};
case SyntaxKind.ObjectKeyword:
return { type: 'object' };
case SyntaxKind.TypeLiteral: {
const properties: InputSchema = {};
(typeNode as any).members.forEach((member: PropertySignature) => {
if (member.name && member.type) {
const memberName = (member.name as any).text;
properties[memberName] = this.getTypeString(member.type);
}
});
return { type: 'object', properties };
}
case SyntaxKind.UnionType: {
const unionNode = typeNode as UnionTypeNode;
const enumValues: string[] = [];
let isEnum = true;
unionNode.types.forEach((subType) => {
if (subType.kind === SyntaxKind.LiteralType) {
const literal = (subType as LiteralTypeNode).literal;
if (literal.kind === SyntaxKind.StringLiteral) {
enumValues.push((literal as StringLiteral).text);
} else {
isEnum = false;
}
} else {
isEnum = false;
}
});
if (isEnum) {
return { type: 'string', enum: enumValues };
}
return { type: 'unknown' };
}
default:
return { type: 'unknown' };
}
}
}

View File

@ -0,0 +1,18 @@
type InputSchemaPropertyType =
| 'string'
| 'number'
| 'boolean'
| 'object'
| 'array'
| 'unknown';
export type InputSchemaProperty = {
type: InputSchemaPropertyType;
enum?: string[];
items?: InputSchemaProperty; // used to describe array type elements
properties?: InputSchema; // used to describe object type elements
};
export type InputSchema = {
[name: string]: InputSchemaProperty;
};

View File

@ -173,18 +173,16 @@ export class WorkflowBuilderWorkspaceService {
return {};
}
const inputSchema =
codeIntrospectionService.getFunctionInputSchema(sourceCode);
const fakeFunctionInput =
codeIntrospectionService.generateInputData(sourceCode);
// handle the case when event parameter is destructured:
// (event: {param1: string; param2: number}) VS ({param1, param2}: {param1: string; param2: number})
const formattedInput = Object.values(fakeFunctionInput)[0];
codeIntrospectionService.generateInputData(inputSchema);
const resultFromFakeInput =
await serverlessFunctionService.executeOneServerlessFunction(
serverlessFunctionId,
workspaceId,
formattedInput,
fakeFunctionInput,
serverlessFunctionVersion,
);

View File

@ -1,5 +1,4 @@
export type OutputSchema = object;
export type InputSchema = object;
type BaseWorkflowStepSettings = {
input: object;