mirror of
https://github.com/lingble/twenty.git
synced 2025-10-29 20:02:29 +00:00
8311 serverless function functions can be executed with any input (#8380)
- remove ts-morph - update inputSchema shape  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:
@@ -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
@@ -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'];
|
||||
|
||||
@@ -8,10 +8,7 @@ export const SERVERLESS_FUNCTION_FRAGMENT = gql`
|
||||
runtime
|
||||
syncStatus
|
||||
latestVersion
|
||||
latestVersionInputSchema {
|
||||
name
|
||||
type
|
||||
}
|
||||
latestVersionInputSchema
|
||||
publishedVersions
|
||||
createdAt
|
||||
updatedAt
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export type FunctionInput =
|
||||
| {
|
||||
[name: string]: FunctionInput;
|
||||
}
|
||||
| any;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
: {};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 {};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export type OutputSchema = object;
|
||||
export type InputSchema = object;
|
||||
|
||||
type BaseWorkflowStepSettings = {
|
||||
input: object;
|
||||
|
||||
Reference in New Issue
Block a user