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 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 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 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 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 \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, "\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. * 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. * 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']; lastName: Scalars['String'];
}; };
export type FunctionParameter = {
__typename?: 'FunctionParameter';
name: Scalars['String'];
type: Scalars['String'];
};
export type GenerateJwt = GenerateJwtOutputWithAuthTokens | GenerateJwtOutputWithSsoauth; export type GenerateJwt = GenerateJwtOutputWithAuthTokens | GenerateJwtOutputWithSsoauth;
export type GenerateJwtOutputWithAuthTokens = { export type GenerateJwtOutputWithAuthTokens = {
@@ -983,7 +977,7 @@ export type ServerlessFunction = {
description?: Maybe<Scalars['String']>; description?: Maybe<Scalars['String']>;
id: Scalars['UUID']; id: Scalars['UUID'];
latestVersion?: Maybe<Scalars['String']>; latestVersion?: Maybe<Scalars['String']>;
latestVersionInputSchema?: Maybe<Array<FunctionParameter>>; latestVersionInputSchema?: Maybe<Scalars['JSON']>;
name: Scalars['String']; name: Scalars['String'];
publishedVersions: Array<Scalars['String']>; publishedVersions: Array<Scalars['String']>;
runtime: Scalars['String']; runtime: Scalars['String'];

View File

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

View File

@@ -91,7 +91,9 @@ export const Select = <Value extends string | number | null>({
const isDisabled = const isDisabled =
disabledFromProps || disabledFromProps ||
(options.length <= 1 && !isDefined(callToActionButton)); (options.length <= 1 &&
!isDefined(callToActionButton) &&
(!isDefined(emptyOption) || selectedOption !== emptyOption));
const { closeDropdown } = useDropdown(dropdownId); 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 { useGetManyServerlessFunctions } from '@/settings/serverless-functions/hooks/useGetManyServerlessFunctions';
import { setNestedValue } from '@/workflow/utils/setNestedValue';
import { Select, SelectOption } from '@/ui/input/components/Select'; import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase'; import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase';
import VariableTagInput from '@/workflow/search-variables/components/VariableTagInput'; import VariableTagInput from '@/workflow/search-variables/components/VariableTagInput';
import { WorkflowCodeStep } from '@/workflow/types/Workflow'; import { WorkflowCodeStep } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import { useState } from 'react';
import { IconCode, isDefined } from 'twenty-ui'; import { IconCode, isDefined } from 'twenty-ui';
import { useDebouncedCallback } from 'use-debounce'; 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 = type WorkflowEditActionFormServerlessFunctionProps =
| { | {
@@ -24,16 +50,30 @@ export const WorkflowEditActionFormServerlessFunction = (
props: WorkflowEditActionFormServerlessFunctionProps, props: WorkflowEditActionFormServerlessFunctionProps,
) => { ) => {
const theme = useTheme(); const theme = useTheme();
const { serverlessFunctions } = useGetManyServerlessFunctions(); const { serverlessFunctions } = useGetManyServerlessFunctions();
const defaultFunctionInput = const getFunctionInput = (serverlessFunctionId: string) => {
props.action.settings.input.serverlessFunctionInput; if (!serverlessFunctionId) {
return {};
}
const [functionInput, setFunctionInput] = const serverlessFunction = serverlessFunctions.find(
useState<Record<string, any>>(defaultFunctionInput); (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, props.action.settings.input.serverlessFunctionId,
); );
@@ -48,14 +88,8 @@ export const WorkflowEditActionFormServerlessFunction = (
settings: { settings: {
...props.action.settings, ...props.action.settings,
input: { input: {
serverlessFunctionId: ...props.action.settings.input,
props.action.settings.input.serverlessFunctionId, serverlessFunctionInput: newFunctionInput,
serverlessFunctionVersion:
props.action.settings.input.serverlessFunctionVersion,
serverlessFunctionInput: {
...props.action.settings.input.serverlessFunctionInput,
...newFunctionInput,
},
}, },
}, },
}); });
@@ -63,14 +97,11 @@ export const WorkflowEditActionFormServerlessFunction = (
1_000, 1_000,
); );
const handleInputChange = (key: string, value: any) => { const handleInputChange = (value: any, path: string[]) => {
const newFunctionInput = { ...functionInput, [key]: value }; updateFunctionInput(setNestedValue(functionInput, path, value));
setFunctionInput(newFunctionInput);
updateFunctionInput(newFunctionInput);
}; };
const availableFunctions: Array<SelectOption<string>> = [ const availableFunctions: Array<SelectOption<string>> = [
{ label: 'None', value: '' },
...serverlessFunctions ...serverlessFunctions
.filter((serverlessFunction) => .filter((serverlessFunction) =>
isDefined(serverlessFunction.latestVersion), isDefined(serverlessFunction.latestVersion),
@@ -83,36 +114,58 @@ export const WorkflowEditActionFormServerlessFunction = (
]; ];
const handleFunctionChange = (newServerlessFunctionId: string) => { const handleFunctionChange = (newServerlessFunctionId: string) => {
setServerlessFunctionId(newServerlessFunctionId);
const serverlessFunction = serverlessFunctions.find( const serverlessFunction = serverlessFunctions.find(
(f) => f.id === newServerlessFunctionId, (f) => f.id === newServerlessFunctionId,
); );
const serverlessFunctionVersion = const newProps = {
serverlessFunction?.latestVersion || 'latest'; ...props.action,
settings: {
const defaultFunctionInput = serverlessFunction?.latestVersionInputSchema ...props.action.settings,
? serverlessFunction.latestVersionInputSchema input: {
.map((parameter) => parameter.name) serverlessFunctionId: newServerlessFunctionId,
.reduce((acc, name) => ({ ...acc, [name]: null }), {}) serverlessFunctionVersion:
: {}; serverlessFunction?.latestVersion || 'latest',
serverlessFunctionInput: getFunctionInput(newServerlessFunctionId),
},
},
};
if (!props.readonly) { if (!props.readonly) {
props.onActionUpdate({ props.onActionUpdate(newProps);
...props.action,
settings: {
...props.action.settings,
input: {
serverlessFunctionId: newServerlessFunctionId,
serverlessFunctionVersion,
serverlessFunctionInput: defaultFunctionInput,
},
},
});
} }
};
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 ( return (
@@ -125,21 +178,13 @@ export const WorkflowEditActionFormServerlessFunction = (
dropdownId="select-serverless-function-id" dropdownId="select-serverless-function-id"
label="Function" label="Function"
fullWidth fullWidth
value={serverlessFunctionId} value={props.action.settings.input.serverlessFunctionId}
options={availableFunctions} options={availableFunctions}
emptyOption={{ label: 'None', value: '' }}
disabled={props.readonly} disabled={props.readonly}
onChange={handleFunctionChange} onChange={handleFunctionChange}
/> />
{functionInput && {functionInput && renderFields(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)}
/>
))}
</WorkflowEditGenericFormBase> </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 ( export const main = async (params: {
event: object, a: string;
context: object, b: number;
): Promise<object> => { }): Promise<object> => {
const { a, b } = params;
// Your code here // Your code here
return {}; return {};
}; };

View File

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

View File

@@ -94,9 +94,9 @@ export class LocalDriver implements ServerlessDriver {
process.env = ${JSON.stringify(envVariables)} process.env = ${JSON.stringify(envVariables)}
process.on('message', async (message) => { process.on('message', async (message) => {
const { event, context } = message; const { params } = message;
try { try {
const result = await index_1.handler(event, context); const result = await index_1.main(params);
process.send(result); process.send(result);
} catch (error) { } catch (error) {
process.send({ process.send({
@@ -245,7 +245,7 @@ export class LocalDriver implements ServerlessDriver {
} }
}); });
child.send({ event: payload }); child.send({ params: payload });
}); });
} catch (error) { } catch (error) {
return { 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, IsString,
IsUUID, IsUUID,
} from 'class-validator'; } from 'class-validator';
import GraphQLJSON from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; 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 { ServerlessFunctionSyncStatus } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import { InputSchema } from 'src/modules/code-introspection/types/input-schema.type';
registerEnumType(ServerlessFunctionSyncStatus, { registerEnumType(ServerlessFunctionSyncStatus, {
name: 'ServerlessFunctionSyncStatus', name: 'ServerlessFunctionSyncStatus',
@@ -65,9 +66,8 @@ export class ServerlessFunctionDTO {
@Field(() => [String], { nullable: false }) @Field(() => [String], { nullable: false })
publishedVersions: string[]; publishedVersions: string[];
@IsArray() @Field(() => GraphQLJSON, { nullable: true })
@Field(() => [FunctionParameter], { nullable: true }) latestVersionInputSchema: InputSchema;
latestVersionInputSchema: FunctionParameter[] | null;
@IsEnum(ServerlessFunctionSyncStatus) @IsEnum(ServerlessFunctionSyncStatus)
@IsNotEmpty() @IsNotEmpty()

View File

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

View File

@@ -6,7 +6,7 @@ import {
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } 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 { export enum ServerlessFunctionSyncStatus {
NOT_READY = 'NOT_READY', NOT_READY = 'NOT_READY',
@@ -35,7 +35,7 @@ export class ServerlessFunctionEntity {
publishedVersions: string[]; publishedVersions: string[];
@Column({ nullable: true, type: 'jsonb' }) @Column({ nullable: true, type: 'jsonb' })
latestVersionInputSchema: FunctionParameter[]; latestVersionInputSchema: InputSchema;
@Column({ nullable: false, default: ServerlessFunctionRuntime.NODE18 }) @Column({ nullable: false, default: ServerlessFunctionRuntime.NODE18 })
runtime: ServerlessFunctionRuntime; runtime: ServerlessFunctionRuntime;

View File

@@ -1,6 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing'; 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'; import { CodeIntrospectionService } from 'src/modules/code-introspection/code-introspection.service';
describe('CodeIntrospectionService', () => { describe('CodeIntrospectionService', () => {
@@ -19,118 +18,121 @@ describe('CodeIntrospectionService', () => {
}); });
describe('getFunctionInputSchema', () => { describe('getFunctionInputSchema', () => {
it('should analyze a function declaration correctly', () => { it('should analyze a simple function correctly', () => {
const fileContent = ` const fileContent = `
function testFunction(param1: string, param2: number): void { function testFunction(param1: string, param2: number): void {
console.log(param1, param2); return;
} }
`; `;
const result = service.getFunctionInputSchema(fileContent); const result = service.getFunctionInputSchema(fileContent);
expect(result).toEqual([ expect(result).toEqual({
{ name: 'param1', type: 'string' }, param1: { type: 'string' },
{ name: 'param2', type: 'number' }, param2: { type: 'number' },
]); });
}); });
it('should analyze an arrow function correctly', () => { it('should analyze a arrow function correctly', () => {
const fileContent = ` const fileContent = `
const testArrowFunction = (param1: string, param2: number): void => { export const main = async (
console.log(param1, param2); param1: string,
param2: number,
): Promise<object> => {
return params;
}; };
`; `;
const result = service.getFunctionInputSchema(fileContent); const result = service.getFunctionInputSchema(fileContent);
expect(result).toEqual([ expect(result).toEqual({
{ name: 'param1', type: 'string' }, param1: { type: 'string' },
{ name: 'param2', type: 'number' }, param2: { type: 'number' },
]); });
}); });
it('should return an empty array for files without functions', () => { it('should analyze a complex function correctly', () => {
const fileContent = ` const fileContent = `
const x = 5; function testFunction(
console.log(x); params: {
`; param1: string;
param2: number;
const result = service.getFunctionInputSchema(fileContent); param3: boolean;
param4: object;
expect(result).toEqual([]); param5: { subParam1: string };
}); param6: "my" | "enum";
param7: string[];
it('should throw an exception for multiple function declarations', () => { }
const fileContent = ` ): void {
function func1(param1: string) {} return
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);
} }
`; `;
const result = service.getFunctionInputSchema(fileContent); const result = service.getFunctionInputSchema(fileContent);
expect(result).toEqual([ expect(result).toEqual({
{ name: 'param1', type: 'string[]' }, params: {
{ name: 'param2', type: '{ key: number; }' }, 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', () => { describe('generateInputData', () => {
it('should generate fake data for function', () => { it('should generate fake data for simple function', () => {
const fileContent = ` const fileContent = `
const testArrowFunction = (param1: string, param2: number): void => { function testFunction(param1: string, param2: number): void {
console.log(param1, param2); return;
}; }
`; `;
const inputSchema = service.getFunctionInputSchema(fileContent);
const result = service.generateInputData(inputSchema);
const result = service.generateInputData(fileContent); expect(result).toEqual({ param1: 'generated-string-value', param2: 1 });
expect(typeof result['param1']).toEqual('string');
expect(typeof result['param2']).toEqual('number');
}); });
it('should generate fake data for complex function', () => { it('should generate fake data for complex function', () => {
const fileContent = ` const fileContent = `
const testArrowFunction = (param1: string[], param2: { key: number }): void => { function testFunction(
console.log(param1, param2); 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(result).toEqual({
expect(typeof result['param1'][0]).toEqual('string'); params: {
expect(typeof result['param2']).toEqual('object'); param1: 'generated-string-value',
expect(typeof result['param2']['key']).toEqual('number'); 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 { Injectable } from '@nestjs/common';
import { import {
ArrayTypeNode,
createSourceFile,
LiteralTypeNode,
PropertySignature,
ScriptTarget,
StringLiteral,
SyntaxKind,
TypeNode,
UnionTypeNode,
VariableStatement,
ArrowFunction, ArrowFunction,
FunctionDeclaration, FunctionDeclaration,
ParameterDeclaration, } from 'typescript';
Project,
SyntaxKind,
} from 'ts-morph';
import { FunctionParameter } from 'src/engine/metadata-modules/serverless-function/dtos/function-parameter.dto';
import { generateFakeValue } from 'src/engine/utils/generate-fake-value'; import { generateFakeValue } from 'src/engine/utils/generate-fake-value';
import { isDefined } from 'src/utils/is-defined';
import { import {
CodeIntrospectionException, InputSchema,
CodeIntrospectionExceptionCode, InputSchemaProperty,
} from 'src/modules/code-introspection/code-introspection.exception'; } from 'src/modules/code-introspection/types/input-schema.type';
@Injectable() @Injectable()
export class CodeIntrospectionService { export class CodeIntrospectionService {
private project: Project; public generateInputData(inputSchema: InputSchema) {
return Object.entries(inputSchema).reduce((acc, [key, value]) => {
constructor() { if (isDefined(value.enum)) {
this.project = new Project(); acc[key] = value.enum?.[0];
} } else if (['string', 'number', 'boolean'].includes(value.type)) {
acc[key] = generateFakeValue(value.type);
public generateInputData(fileContent: string, fileName = 'temp.ts') { } else if (value.type === 'object') {
const parameters = this.getFunctionInputSchema(fileContent, fileName); acc[key] = isDefined(value.properties)
? this.generateInputData(value.properties)
return this.generateFakeDataFromParams(parameters); : {};
} } else if (value.type === 'array' && isDefined(value.items)) {
acc[key] = [generateFakeValue(value.items.type)];
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);
return acc; 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 {}; return {};
} }
const inputSchema =
codeIntrospectionService.getFunctionInputSchema(sourceCode);
const fakeFunctionInput = const fakeFunctionInput =
codeIntrospectionService.generateInputData(sourceCode); codeIntrospectionService.generateInputData(inputSchema);
// 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];
const resultFromFakeInput = const resultFromFakeInput =
await serverlessFunctionService.executeOneServerlessFunction( await serverlessFunctionService.executeOneServerlessFunction(
serverlessFunctionId, serverlessFunctionId,
workspaceId, workspaceId,
formattedInput, fakeFunctionInput,
serverlessFunctionVersion, serverlessFunctionVersion,
); );

View File

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