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

@@ -33,7 +33,7 @@ const documents = {
"\n mutation DeleteOneFieldMetadataItem($idToDelete: UUID!) {\n deleteOneField(input: { id: $idToDelete }) {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isNullable\n createdAt\n updatedAt\n settings\n }\n }\n": types.DeleteOneFieldMetadataItemDocument,
"\n mutation DeleteOneRelationMetadataItem($idToDelete: UUID!) {\n deleteOneRelation(input: { id: $idToDelete }) {\n id\n }\n }\n": types.DeleteOneRelationMetadataItemDocument,
"\n query ObjectMetadataItems(\n $objectFilter: objectFilter\n $fieldFilter: fieldFilter\n ) {\n objects(paging: { first: 1000 }, filter: $objectFilter) {\n edges {\n node {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isRemote\n isActive\n isSystem\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n shortcut\n isLabelSyncedWithName\n indexMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n indexFieldMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n order\n fieldMetadataId\n }\n }\n }\n }\n }\n }\n fields(paging: { first: 1000 }, filter: $fieldFilter) {\n edges {\n node {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isSystem\n isNullable\n isUnique\n createdAt\n updatedAt\n defaultValue\n options\n settings\n relationDefinition {\n relationId\n direction\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\n }\n targetFieldMetadata {\n id\n name\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n": types.ObjectMetadataItemsDocument,
"\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n syncStatus\n latestVersion\n latestVersionInputSchema {\n name\n type\n }\n publishedVersions\n createdAt\n updatedAt\n }\n": types.ServerlessFunctionFieldsFragmentDoc,
"\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n syncStatus\n latestVersion\n latestVersionInputSchema\n publishedVersions\n createdAt\n updatedAt\n }\n": types.ServerlessFunctionFieldsFragmentDoc,
"\n \n mutation CreateOneServerlessFunctionItem(\n $input: CreateServerlessFunctionInput!\n ) {\n createOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.CreateOneServerlessFunctionItemDocument,
"\n \n mutation DeleteOneServerlessFunction($input: ServerlessFunctionIdInput!) {\n deleteOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.DeleteOneServerlessFunctionDocument,
"\n mutation ExecuteOneServerlessFunction(\n $input: ExecuteServerlessFunctionInput!\n ) {\n executeOneServerlessFunction(input: $input) {\n data\n duration\n status\n error\n }\n }\n": types.ExecuteOneServerlessFunctionDocument,
@@ -142,7 +142,7 @@ export function graphql(source: "\n query ObjectMetadataItems(\n $objectFilt
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n syncStatus\n latestVersion\n latestVersionInputSchema {\n name\n type\n }\n publishedVersions\n createdAt\n updatedAt\n }\n"): (typeof documents)["\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n syncStatus\n latestVersion\n latestVersionInputSchema {\n name\n type\n }\n publishedVersions\n createdAt\n updatedAt\n }\n"];
export function graphql(source: "\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n syncStatus\n latestVersion\n latestVersionInputSchema\n publishedVersions\n createdAt\n updatedAt\n }\n"): (typeof documents)["\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n syncStatus\n latestVersion\n latestVersionInputSchema\n publishedVersions\n createdAt\n updatedAt\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

File diff suppressed because one or more lines are too long

View File

@@ -331,12 +331,6 @@ export type FullName = {
lastName: Scalars['String'];
};
export type FunctionParameter = {
__typename?: 'FunctionParameter';
name: Scalars['String'];
type: Scalars['String'];
};
export type GenerateJwt = GenerateJwtOutputWithAuthTokens | GenerateJwtOutputWithSsoauth;
export type GenerateJwtOutputWithAuthTokens = {
@@ -983,7 +977,7 @@ export type ServerlessFunction = {
description?: Maybe<Scalars['String']>;
id: Scalars['UUID'];
latestVersion?: Maybe<Scalars['String']>;
latestVersionInputSchema?: Maybe<Array<FunctionParameter>>;
latestVersionInputSchema?: Maybe<Scalars['JSON']>;
name: Scalars['String'];
publishedVersions: Array<Scalars['String']>;
runtime: Scalars['String'];

View File

@@ -8,10 +8,7 @@ export const SERVERLESS_FUNCTION_FRAGMENT = gql`
runtime
syncStatus
latestVersion
latestVersionInputSchema {
name
type
}
latestVersionInputSchema
publishedVersions
createdAt
updatedAt

View File

@@ -91,7 +91,9 @@ export const Select = <Value extends string | number | null>({
const isDisabled =
disabledFromProps ||
(options.length <= 1 && !isDefined(callToActionButton));
(options.length <= 1 &&
!isDefined(callToActionButton) &&
(!isDefined(emptyOption) || selectedOption !== emptyOption));
const { closeDropdown } = useDropdown(dropdownId);

View File

@@ -1,13 +1,39 @@
import { ReactNode } from 'react';
import styled from '@emotion/styled';
import { useGetManyServerlessFunctions } from '@/settings/serverless-functions/hooks/useGetManyServerlessFunctions';
import { setNestedValue } from '@/workflow/utils/setNestedValue';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import VariableTagInput from '@/workflow/search-variables/components/VariableTagInput';
import { WorkflowCodeStep } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
import { useState } from 'react';
import { IconCode, isDefined } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce';
import { capitalize } from '~/utils/string/capitalize';
import { getDefaultFunctionInputFromInputSchema } from '@/workflow/utils/getDefaultFunctionInputFromInputSchema';
import { FunctionInput } from '@/workflow/types/FunctionInput';
import { mergeDefaultFunctionInputAndFunctionInput } from '@/workflow/utils/mergeDefaultFunctionInputAndFunctionInput';
const StyledContainer = styled.div`
display: inline-flex;
flex-direction: column;
`;
const StyledLabel = styled.div`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-top: ${({ theme }) => theme.spacing(3)};
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
const StyledInputContainer = styled.div`
display: flex;
flex-direction: column;
position: relative;
gap: ${({ theme }) => theme.spacing(4)};
padding-left: ${({ theme }) => theme.spacing(4)};
`;
type WorkflowEditActionFormServerlessFunctionProps =
| {
@@ -24,16 +50,30 @@ export const WorkflowEditActionFormServerlessFunction = (
props: WorkflowEditActionFormServerlessFunctionProps,
) => {
const theme = useTheme();
const { serverlessFunctions } = useGetManyServerlessFunctions();
const defaultFunctionInput =
props.action.settings.input.serverlessFunctionInput;
const getFunctionInput = (serverlessFunctionId: string) => {
if (!serverlessFunctionId) {
return {};
}
const [functionInput, setFunctionInput] =
useState<Record<string, any>>(defaultFunctionInput);
const serverlessFunction = serverlessFunctions.find(
(f) => f.id === serverlessFunctionId,
);
const inputSchema = serverlessFunction?.latestVersionInputSchema;
const defaultFunctionInput =
getDefaultFunctionInputFromInputSchema(inputSchema);
const [serverlessFunctionId, setServerlessFunctionId] = useState<string>(
const existingFunctionInput =
props.action.settings.input.serverlessFunctionInput;
return mergeDefaultFunctionInputAndFunctionInput({
defaultFunctionInput,
functionInput: existingFunctionInput,
});
};
const functionInput = getFunctionInput(
props.action.settings.input.serverlessFunctionId,
);
@@ -48,14 +88,8 @@ export const WorkflowEditActionFormServerlessFunction = (
settings: {
...props.action.settings,
input: {
serverlessFunctionId:
props.action.settings.input.serverlessFunctionId,
serverlessFunctionVersion:
props.action.settings.input.serverlessFunctionVersion,
serverlessFunctionInput: {
...props.action.settings.input.serverlessFunctionInput,
...newFunctionInput,
},
...props.action.settings.input,
serverlessFunctionInput: newFunctionInput,
},
},
});
@@ -63,14 +97,11 @@ export const WorkflowEditActionFormServerlessFunction = (
1_000,
);
const handleInputChange = (key: string, value: any) => {
const newFunctionInput = { ...functionInput, [key]: value };
setFunctionInput(newFunctionInput);
updateFunctionInput(newFunctionInput);
const handleInputChange = (value: any, path: string[]) => {
updateFunctionInput(setNestedValue(functionInput, path, value));
};
const availableFunctions: Array<SelectOption<string>> = [
{ label: 'None', value: '' },
...serverlessFunctions
.filter((serverlessFunction) =>
isDefined(serverlessFunction.latestVersion),
@@ -83,36 +114,58 @@ export const WorkflowEditActionFormServerlessFunction = (
];
const handleFunctionChange = (newServerlessFunctionId: string) => {
setServerlessFunctionId(newServerlessFunctionId);
const serverlessFunction = serverlessFunctions.find(
(f) => f.id === newServerlessFunctionId,
);
const serverlessFunctionVersion =
serverlessFunction?.latestVersion || 'latest';
const defaultFunctionInput = serverlessFunction?.latestVersionInputSchema
? serverlessFunction.latestVersionInputSchema
.map((parameter) => parameter.name)
.reduce((acc, name) => ({ ...acc, [name]: null }), {})
: {};
const newProps = {
...props.action,
settings: {
...props.action.settings,
input: {
serverlessFunctionId: newServerlessFunctionId,
serverlessFunctionVersion:
serverlessFunction?.latestVersion || 'latest',
serverlessFunctionInput: getFunctionInput(newServerlessFunctionId),
},
},
};
if (!props.readonly) {
props.onActionUpdate({
...props.action,
settings: {
...props.action.settings,
input: {
serverlessFunctionId: newServerlessFunctionId,
serverlessFunctionVersion,
serverlessFunctionInput: defaultFunctionInput,
},
},
});
props.onActionUpdate(newProps);
}
};
setFunctionInput(defaultFunctionInput);
const renderFields = (
functionInput: FunctionInput,
path: string[] = [],
): ReactNode | undefined => {
return Object.entries(functionInput).map(([inputKey, inputValue]) => {
const currentPath = [...path, inputKey];
const pathKey = currentPath.join('.');
if (inputValue !== null && typeof inputValue === 'object') {
return (
<StyledContainer key={pathKey}>
<StyledLabel>{inputKey}</StyledLabel>
<StyledInputContainer>
{renderFields(inputValue, currentPath)}
</StyledInputContainer>
</StyledContainer>
);
} else {
return (
<VariableTagInput
key={pathKey}
inputId={`input-${inputKey}`}
label={inputKey}
placeholder="Enter value (use {{variable}} for dynamic content)"
value={`${inputValue || ''}`}
onChange={(value) => handleInputChange(value, currentPath)}
/>
);
}
});
};
return (
@@ -125,21 +178,13 @@ export const WorkflowEditActionFormServerlessFunction = (
dropdownId="select-serverless-function-id"
label="Function"
fullWidth
value={serverlessFunctionId}
value={props.action.settings.input.serverlessFunctionId}
options={availableFunctions}
emptyOption={{ label: 'None', value: '' }}
disabled={props.readonly}
onChange={handleFunctionChange}
/>
{functionInput &&
Object.entries(functionInput).map(([inputKey, inputValue]) => (
<VariableTagInput
inputId={`input-${inputKey}`}
label={capitalize(inputKey)}
placeholder="Enter value (use {{variable}} for dynamic content)"
value={inputValue ?? ''}
onChange={(value) => handleInputChange(inputKey, value)}
/>
))}
{functionInput && renderFields(functionInput)}
</WorkflowEditGenericFormBase>
);
};

View File

@@ -0,0 +1,5 @@
export type FunctionInput =
| {
[name: string]: FunctionInput;
}
| any;

View File

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

View File

@@ -0,0 +1,29 @@
import { getDefaultFunctionInputFromInputSchema } from '@/workflow/utils/getDefaultFunctionInputFromInputSchema';
import { InputSchema } from '@/workflow/types/InputSchema';
describe('getDefaultFunctionInputFromInputSchema', () => {
it('should init function input properly', () => {
const inputSchema = {
params: {
type: 'object',
properties: {
a: {
type: 'string',
},
b: {
type: 'number',
},
},
},
} as InputSchema;
const expectedResult = {
params: {
a: null,
b: null,
},
};
expect(getDefaultFunctionInputFromInputSchema(inputSchema)).toEqual(
expectedResult,
);
});
});

View File

@@ -0,0 +1,21 @@
import { mergeDefaultFunctionInputAndFunctionInput } from '@/workflow/utils/mergeDefaultFunctionInputAndFunctionInput';
describe('mergeDefaultFunctionInputAndFunctionInput', () => {
it('should merge properly', () => {
const defaultFunctionInput = {
params: { a: null, b: null, c: { cc: null } },
};
const functionInput = {
params: { a: 'a', c: 'c' },
};
const expectedResult = {
params: { a: 'a', b: null, c: { cc: null } },
};
expect(
mergeDefaultFunctionInputAndFunctionInput({
defaultFunctionInput,
functionInput,
}),
).toEqual(expectedResult);
});
});

View File

@@ -0,0 +1,11 @@
import { setNestedValue } from '@/workflow/utils/setNestedValue';
describe('setNestedValue', () => {
it('should set nested value properly', () => {
const obj = { a: { b: 'b' } };
const path = ['a', 'b'];
const newValue = 'bb';
const expectedResult = { a: { b: newValue } };
expect(setNestedValue(obj, path, newValue)).toEqual(expectedResult);
});
});

View File

@@ -0,0 +1,22 @@
import { InputSchema } from '@/workflow/types/InputSchema';
import { FunctionInput } from '@/workflow/types/FunctionInput';
import { isDefined } from '~/utils/isDefined';
export const getDefaultFunctionInputFromInputSchema = (
inputSchema: InputSchema | undefined,
): FunctionInput => {
return isDefined(inputSchema)
? Object.entries(inputSchema).reduce((acc, [key, value]) => {
if (['string', 'number', 'boolean'].includes(value.type)) {
acc[key] = null;
} else if (value.type === 'object') {
acc[key] = isDefined(value.properties)
? getDefaultFunctionInputFromInputSchema(value.properties)
: {};
} else if (value.type === 'array' && isDefined(value.items)) {
acc[key] = [];
}
return acc;
}, {} as FunctionInput)
: {};
};

View File

@@ -0,0 +1,32 @@
import { FunctionInput } from '@/workflow/types/FunctionInput';
export const mergeDefaultFunctionInputAndFunctionInput = ({
defaultFunctionInput,
functionInput,
}: {
defaultFunctionInput: FunctionInput;
functionInput: FunctionInput;
}): FunctionInput => {
const result: FunctionInput = {};
for (const key of Object.keys(defaultFunctionInput)) {
if (!(key in functionInput)) {
result[key] = defaultFunctionInput[key];
} else {
if (
defaultFunctionInput[key] !== null &&
typeof defaultFunctionInput[key] === 'object'
) {
result[key] = mergeDefaultFunctionInputAndFunctionInput({
defaultFunctionInput: defaultFunctionInput[key],
functionInput:
typeof functionInput[key] === 'object' ? functionInput[key] : {},
});
} else {
result[key] = functionInput[key];
}
}
}
return result;
};

View File

@@ -0,0 +1,10 @@
export const setNestedValue = (obj: any, path: string[], value: any) => {
const newObj = { ...obj };
path.reduce((o, key, index) => {
if (index === path.length - 1) {
o[key] = value;
}
return o[key] || {};
}, newObj);
return newObj;
};

View File

@@ -1,7 +1,10 @@
export const handler = async (
event: object,
context: object,
): Promise<object> => {
export const main = async (params: {
a: string;
b: number;
}): Promise<object> => {
const { a, b } = params;
// Your code here
return {};
};

View File

@@ -227,7 +227,7 @@ export class LambdaDriver implements ServerlessDriver {
ZipFile: await fs.readFile(lambdaZipPath),
},
FunctionName: serverlessFunction.id,
Handler: 'src/index.handler',
Handler: 'src/index.main',
Layers: [layerArn],
Environment: {
Variables: envVariables,

View File

@@ -94,9 +94,9 @@ export class LocalDriver implements ServerlessDriver {
process.env = ${JSON.stringify(envVariables)}
process.on('message', async (message) => {
const { event, context } = message;
const { params } = message;
try {
const result = await index_1.handler(event, context);
const result = await index_1.main(params);
process.send(result);
} catch (error) {
process.send({
@@ -245,7 +245,7 @@ export class LocalDriver implements ServerlessDriver {
}
});
child.send({ event: payload });
child.send({ params: payload });
});
} catch (error) {
return {

View File

@@ -1,14 +0,0 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsString } from 'class-validator';
@ObjectType()
export class FunctionParameter {
@IsString()
@Field(() => String)
name: string;
@IsString()
@Field(() => String)
type: string;
}

View File

@@ -18,10 +18,11 @@ import {
IsString,
IsUUID,
} from 'class-validator';
import GraphQLJSON from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { FunctionParameter } from 'src/engine/metadata-modules/serverless-function/dtos/function-parameter.dto';
import { ServerlessFunctionSyncStatus } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import { InputSchema } from 'src/modules/code-introspection/types/input-schema.type';
registerEnumType(ServerlessFunctionSyncStatus, {
name: 'ServerlessFunctionSyncStatus',
@@ -65,9 +66,8 @@ export class ServerlessFunctionDTO {
@Field(() => [String], { nullable: false })
publishedVersions: string[];
@IsArray()
@Field(() => [FunctionParameter], { nullable: true })
latestVersionInputSchema: FunctionParameter[] | null;
@Field(() => GraphQLJSON, { nullable: true })
latestVersionInputSchema: InputSchema;
@IsEnum(ServerlessFunctionSyncStatus)
@IsNotEmpty()

View File

@@ -29,7 +29,7 @@ export class ServerlessFunctionPublicationListener {
serverlessFunctionVersion: string;
}>,
): Promise<void> {
payload.events.forEach(async (event) => {
for (const event of payload.events) {
const sourceCode =
await this.serverlessFunctionService.getServerlessFunctionSourceCode(
payload.workspaceId,
@@ -48,12 +48,12 @@ export class ServerlessFunctionPublicationListener {
}
const latestVersionInputSchema =
await this.codeIntrospectionService.getFunctionInputSchema(indexCode);
this.codeIntrospectionService.getFunctionInputSchema(indexCode);
await this.serverlessFunctionRepository.update(
{ id: event.serverlessFunctionId },
{ latestVersionInputSchema },
);
});
}
}
}

View File

@@ -6,7 +6,7 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { FunctionParameter } from 'src/engine/metadata-modules/serverless-function/dtos/function-parameter.dto';
import { InputSchema } from 'src/modules/code-introspection/types/input-schema.type';
export enum ServerlessFunctionSyncStatus {
NOT_READY = 'NOT_READY',
@@ -35,7 +35,7 @@ export class ServerlessFunctionEntity {
publishedVersions: string[];
@Column({ nullable: true, type: 'jsonb' })
latestVersionInputSchema: FunctionParameter[];
latestVersionInputSchema: InputSchema;
@Column({ nullable: false, default: ServerlessFunctionRuntime.NODE18 })
runtime: ServerlessFunctionRuntime;

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;