6654 serverless functions add a deploy button disable deploy when autosave (#6715)

- improvements on serverless function behavior (autosave performances,
deploy on execution only)
- add versioning to serverless functions
- add a publish endpoint to create a new version of a serverless
function
  - add deploy and reset to lastVersion button in the settings section:
<img width="736" alt="image"
src="https://github.com/user-attachments/assets/2001f8d2-07a4-4f79-84dd-ec74b6f301d3">
This commit is contained in:
martmull
2024-08-23 12:06:03 +02:00
committed by GitHub
parent 7ca091faa5
commit 6f9aa1e870
42 changed files with 850 additions and 269 deletions

View File

@ -33,13 +33,15 @@ 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 }\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 }\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 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 createdAt\n updatedAt\n fromRelationMetadata {\n id\n relationType\n toObjectMetadata {\n id\n dataSourceId\n nameSingular\n namePlural\n isSystem\n isRemote\n }\n toFieldMetadataId\n }\n toRelationMetadata {\n id\n relationType\n fromObjectMetadata {\n id\n dataSourceId\n nameSingular\n namePlural\n isSystem\n isRemote\n }\n fromFieldMetadataId\n }\n defaultValue\n options\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 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 createdAt\n updatedAt\n fromRelationMetadata {\n id\n relationType\n toObjectMetadata {\n id\n dataSourceId\n nameSingular\n namePlural\n isSystem\n isRemote\n }\n toFieldMetadataId\n }\n toRelationMetadata {\n id\n relationType\n fromObjectMetadata {\n id\n dataSourceId\n nameSingular\n namePlural\n isSystem\n isRemote\n }\n fromFieldMetadataId\n }\n defaultValue\n options\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 sourceCodeHash\n sourceCodeFullPath\n runtime\n syncStatus\n createdAt\n updatedAt\n }\n": types.ServerlessFunctionFieldsFragmentDoc, "\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n sourceCodeHash\n runtime\n syncStatus\n latestVersion\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: DeleteServerlessFunctionInput!) {\n deleteOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.DeleteOneServerlessFunctionDocument, "\n \n mutation DeleteOneServerlessFunction($input: DeleteServerlessFunctionInput!) {\n deleteOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.DeleteOneServerlessFunctionDocument,
"\n mutation ExecuteOneServerlessFunction($id: UUID!, $payload: JSON!) {\n executeOneServerlessFunction(id: $id, payload: $payload) {\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,
"\n \n mutation PublishOneServerlessFunction(\n $input: PublishServerlessFunctionInput!\n ) {\n publishServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.PublishOneServerlessFunctionDocument,
"\n \n mutation UpdateOneServerlessFunction($input: UpdateServerlessFunctionInput!) {\n updateOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.UpdateOneServerlessFunctionDocument, "\n \n mutation UpdateOneServerlessFunction($input: UpdateServerlessFunctionInput!) {\n updateOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.UpdateOneServerlessFunctionDocument,
"\n \n query GetManyServerlessFunctions {\n serverlessFunctions(paging: { first: 100 }) {\n edges {\n node {\n ...ServerlessFunctionFields\n }\n }\n }\n }\n": types.GetManyServerlessFunctionsDocument, "\n \n query GetManyServerlessFunctions {\n serverlessFunctions(paging: { first: 100 }) {\n edges {\n node {\n ...ServerlessFunctionFields\n }\n }\n }\n }\n": types.GetManyServerlessFunctionsDocument,
"\n \n query GetOneServerlessFunction($id: UUID!) {\n serverlessFunction(id: $id) {\n ...ServerlessFunctionFields\n }\n }\n": types.GetOneServerlessFunctionDocument, "\n \n query GetOneServerlessFunction($id: UUID!) {\n serverlessFunction(id: $id) {\n ...ServerlessFunctionFields\n }\n }\n": types.GetOneServerlessFunctionDocument,
"\n query FindOneServerlessFunctionSourceCode(\n $input: GetServerlessFunctionSourceCodeInput!\n ) {\n getServerlessFunctionSourceCode(input: $input)\n }\n": types.FindOneServerlessFunctionSourceCodeDocument,
}; };
/** /**
@ -139,7 +141,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 sourceCodeHash\n sourceCodeFullPath\n runtime\n syncStatus\n createdAt\n updatedAt\n }\n"): (typeof documents)["\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n sourceCodeHash\n sourceCodeFullPath\n runtime\n syncStatus\n createdAt\n updatedAt\n }\n"]; export function graphql(source: "\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n sourceCodeHash\n runtime\n syncStatus\n latestVersion\n createdAt\n updatedAt\n }\n"): (typeof documents)["\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n sourceCodeHash\n runtime\n syncStatus\n latestVersion\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.
*/ */
@ -151,7 +153,11 @@ export function graphql(source: "\n \n mutation DeleteOneServerlessFunction($i
/** /**
* 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 mutation ExecuteOneServerlessFunction($id: UUID!, $payload: JSON!) {\n executeOneServerlessFunction(id: $id, payload: $payload) {\n data\n duration\n status\n error\n }\n }\n"): (typeof documents)["\n mutation ExecuteOneServerlessFunction($id: UUID!, $payload: JSON!) {\n executeOneServerlessFunction(id: $id, payload: $payload) {\n data\n duration\n status\n error\n }\n }\n"]; export function graphql(source: "\n mutation ExecuteOneServerlessFunction(\n $input: ExecuteServerlessFunctionInput!\n ) {\n executeOneServerlessFunction(input: $input) {\n data\n duration\n status\n error\n }\n }\n"): (typeof documents)["\n mutation ExecuteOneServerlessFunction(\n $input: ExecuteServerlessFunctionInput!\n ) {\n executeOneServerlessFunction(input: $input) {\n data\n duration\n status\n error\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n \n mutation PublishOneServerlessFunction(\n $input: PublishServerlessFunctionInput!\n ) {\n publishServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n"): (typeof documents)["\n \n mutation PublishOneServerlessFunction(\n $input: PublishServerlessFunctionInput!\n ) {\n publishServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\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.
*/ */
@ -164,6 +170,10 @@ export function graphql(source: "\n \n query GetManyServerlessFunctions {\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.
*/ */
export function graphql(source: "\n \n query GetOneServerlessFunction($id: UUID!) {\n serverlessFunction(id: $id) {\n ...ServerlessFunctionFields\n }\n }\n"): (typeof documents)["\n \n query GetOneServerlessFunction($id: UUID!) {\n serverlessFunction(id: $id) {\n ...ServerlessFunctionFields\n }\n }\n"]; export function graphql(source: "\n \n query GetOneServerlessFunction($id: UUID!) {\n serverlessFunction(id: $id) {\n ...ServerlessFunctionFields\n }\n }\n"): (typeof documents)["\n \n query GetOneServerlessFunction($id: UUID!) {\n serverlessFunction(id: $id) {\n ...ServerlessFunctionFields\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query FindOneServerlessFunctionSourceCode(\n $input: GetServerlessFunctionSourceCodeInput!\n ) {\n getServerlessFunctionSourceCode(input: $input)\n }\n"): (typeof documents)["\n query FindOneServerlessFunctionSourceCode(\n $input: GetServerlessFunctionSourceCodeInput!\n ) {\n getServerlessFunctionSourceCode(input: $input)\n }\n"];
export function graphql(source: string) { export function graphql(source: string) {
return (documents as any)[source] ?? {}; return (documents as any)[source] ?? {};

File diff suppressed because one or more lines are too long

View File

@ -58,7 +58,9 @@ export const useRestoreManyRecords = ({
// TODO: fix optimistic effect // TODO: fix optimistic effect
const findOneQueryName = `FindOne${capitalize(objectNameSingular)}`; const findOneQueryName = `FindOne${capitalize(objectNameSingular)}`;
const findManyQueryName = `FindMany${capitalize(objectMetadataItem.namePlural)}`; const findManyQueryName = `FindMany${capitalize(
objectMetadataItem.namePlural,
)}`;
const restoredRecordsResponse = await apolloClient.mutate({ const restoredRecordsResponse = await apolloClient.mutate({
mutation: restoreManyRecordsMutation, mutation: restoreManyRecordsMutation,

View File

@ -1,4 +1,4 @@
import { H2Title, IconPlayerPlay } from 'twenty-ui'; import { H2Title, IconPlayerPlay, IconGitCommit, IconRestore } from 'twenty-ui';
import { CodeEditor } from '@/ui/input/code-editor/components/CodeEditor'; import { CodeEditor } from '@/ui/input/code-editor/components/CodeEditor';
import { Section } from '@/ui/layout/section/components/Section'; import { Section } from '@/ui/layout/section/components/Section';
import { ServerlessFunctionFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState'; import { ServerlessFunctionFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
@ -14,13 +14,23 @@ const StyledTabList = styled(TabList)`
export const SettingsServerlessFunctionCodeEditorTab = ({ export const SettingsServerlessFunctionCodeEditorTab = ({
formValues, formValues,
handleExecute, handleExecute,
handlePublish,
handleReset,
resetDisabled,
publishDisabled,
onChange, onChange,
setIsCodeValid,
}: { }: {
formValues: ServerlessFunctionFormValues; formValues: ServerlessFunctionFormValues;
handleExecute: () => void; handleExecute: () => void;
handlePublish: () => void;
handleReset: () => void;
resetDisabled: boolean;
publishDisabled: boolean;
onChange: (key: string) => (value: string) => void; onChange: (key: string) => (value: string) => void;
setIsCodeValid: (isCodeValid: boolean) => void;
}) => { }) => {
const HeaderButton = ( const TestButton = (
<Button <Button
title="Test" title="Test"
variant="primary" variant="primary"
@ -30,6 +40,26 @@ export const SettingsServerlessFunctionCodeEditorTab = ({
onClick={handleExecute} onClick={handleExecute}
/> />
); );
const PublishButton = (
<Button
title="Publish"
variant="secondary"
size="small"
Icon={IconGitCommit}
onClick={handlePublish}
disabled={publishDisabled}
/>
);
const ResetButton = (
<Button
title="Reset"
variant="secondary"
size="small"
Icon={IconRestore}
onClick={handleReset}
disabled={resetDisabled}
/>
);
const TAB_LIST_COMPONENT_ID = 'serverless-function-editor'; const TAB_LIST_COMPONENT_ID = 'serverless-function-editor';
@ -41,7 +71,10 @@ export const SettingsServerlessFunctionCodeEditorTab = ({
); );
const Header = ( const Header = (
<CoreEditorHeader leftNodes={[HeaderTabList]} rightNodes={[HeaderButton]} /> <CoreEditorHeader
leftNodes={[HeaderTabList]}
rightNodes={[ResetButton, PublishButton, TestButton]}
/>
); );
return ( return (
@ -53,6 +86,7 @@ export const SettingsServerlessFunctionCodeEditorTab = ({
<CodeEditor <CodeEditor
value={formValues.code} value={formValues.code}
onChange={onChange('code')} onChange={onChange('code')}
setIsCodeValid={setIsCodeValid}
header={Header} header={Header}
/> />
</Section> </Section>

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { useEffect } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useRecoilValue, useSetRecoilState } from 'recoil';
import { import {
DEFAULT_OUTPUT_VALUE, DEFAULT_OUTPUT_VALUE,
@ -13,11 +13,16 @@ export const SettingsServerlessFunctionTestTabEffect = () => {
const setSettingsServerlessFunctionCodeEditorOutputParams = useSetRecoilState( const setSettingsServerlessFunctionCodeEditorOutputParams = useSetRecoilState(
settingsServerlessFunctionCodeEditorOutputParamsState, settingsServerlessFunctionCodeEditorOutputParamsState,
); );
if (settingsServerlessFunctionOutput.data !== DEFAULT_OUTPUT_VALUE) { useEffect(() => {
setSettingsServerlessFunctionCodeEditorOutputParams({ if (settingsServerlessFunctionOutput.data !== DEFAULT_OUTPUT_VALUE) {
language: 'json', setSettingsServerlessFunctionCodeEditorOutputParams({
height: 300, language: 'json',
}); height: 300,
} });
}
}, [
settingsServerlessFunctionOutput.data,
setSettingsServerlessFunctionCodeEditorOutputParams,
]);
return <></>; return <></>;
}; };

View File

@ -6,9 +6,9 @@ export const SERVERLESS_FUNCTION_FRAGMENT = gql`
name name
description description
sourceCodeHash sourceCodeHash
sourceCodeFullPath
runtime runtime
syncStatus syncStatus
latestVersion
createdAt createdAt
updatedAt updatedAt
} }

View File

@ -1,8 +1,10 @@
import { gql } from '@apollo/client'; import { gql } from '@apollo/client';
export const EXECUTE_ONE_SERVERLESS_FUNCTION = gql` export const EXECUTE_ONE_SERVERLESS_FUNCTION = gql`
mutation ExecuteOneServerlessFunction($id: UUID!, $payload: JSON!) { mutation ExecuteOneServerlessFunction(
executeOneServerlessFunction(id: $id, payload: $payload) { $input: ExecuteServerlessFunctionInput!
) {
executeOneServerlessFunction(input: $input) {
data data
duration duration
status status

View File

@ -0,0 +1,13 @@
import { gql } from '@apollo/client';
import { SERVERLESS_FUNCTION_FRAGMENT } from '@/settings/serverless-functions/graphql/fragments/serverlessFunctionFragment';
export const PUBLISH_ONE_SERVERLESS_FUNCTION = gql`
${SERVERLESS_FUNCTION_FRAGMENT}
mutation PublishOneServerlessFunction(
$input: PublishServerlessFunctionInput!
) {
publishServerlessFunction(input: $input) {
...ServerlessFunctionFields
}
}
`;

View File

@ -0,0 +1,9 @@
import { gql } from '@apollo/client';
export const FIND_ONE_SERVERLESS_FUNCTION_SOURCE_CODE = gql`
query FindOneServerlessFunctionSourceCode(
$input: GetServerlessFunctionSourceCodeInput!
) {
getServerlessFunctionSourceCode(input: $input)
}
`;

View File

@ -9,6 +9,13 @@ jest.mock(
}), }),
); );
jest.mock(
'@/settings/serverless-functions/hooks/useGetOneServerlessFunctionSourceCode',
() => ({
useGetOneServerlessFunctionSourceCode: jest.fn(),
}),
);
describe('useServerlessFunctionUpdateFormState', () => { describe('useServerlessFunctionUpdateFormState', () => {
test('should return a form', () => { test('should return a form', () => {
const serverlessFunctionId = 'serverlessFunctionId'; const serverlessFunctionId = 'serverlessFunctionId';
@ -20,6 +27,14 @@ describe('useServerlessFunctionUpdateFormState', () => {
serverlessFunction: { sourceCodeFullPath: undefined }, serverlessFunction: { sourceCodeFullPath: undefined },
}, },
); );
const useGetOneServerlessFunctionSourceCodeMock = jest.requireMock(
'@/settings/serverless-functions/hooks/useGetOneServerlessFunctionSourceCode',
);
useGetOneServerlessFunctionSourceCodeMock.useGetOneServerlessFunctionSourceCode.mockReturnValue(
{
code: 'export const handler = () => {}',
},
);
const { result } = renderHook( const { result } = renderHook(
() => useServerlessFunctionUpdateFormState(serverlessFunctionId), () => useServerlessFunctionUpdateFormState(serverlessFunctionId),
{ {

View File

@ -1,13 +1,13 @@
import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient'; import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient';
import { ApolloClient, useMutation } from '@apollo/client'; import { ApolloClient, useMutation } from '@apollo/client';
import { getOperationName } from '@apollo/client/utilities'; import { getOperationName } from '@apollo/client/utilities';
import { FIND_MANY_SERVERLESS_FUNCTIONS } from '@/settings/serverless-functions/graphql/queries/findManyServerlessFunctions';
import { DELETE_ONE_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/mutations/deleteOneServerlessFunction'; import { DELETE_ONE_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/mutations/deleteOneServerlessFunction';
import { import {
DeleteServerlessFunctionInput, DeleteServerlessFunctionInput,
DeleteOneServerlessFunctionMutation, DeleteOneServerlessFunctionMutation,
DeleteOneServerlessFunctionMutationVariables, DeleteOneServerlessFunctionMutationVariables,
} from '~/generated-metadata/graphql'; } from '~/generated-metadata/graphql';
import { FIND_ONE_SERVERLESS_FUNCTION_SOURCE_CODE } from '@/settings/serverless-functions/graphql/queries/findOneServerlessFunctionSourceCode';
export const useDeleteOneServerlessFunction = () => { export const useDeleteOneServerlessFunction = () => {
const apolloMetadataClient = useApolloMetadataClient(); const apolloMetadataClient = useApolloMetadataClient();
@ -26,7 +26,9 @@ export const useDeleteOneServerlessFunction = () => {
input, input,
}, },
awaitRefetchQueries: true, awaitRefetchQueries: true,
refetchQueries: [getOperationName(FIND_MANY_SERVERLESS_FUNCTIONS) ?? ''], refetchQueries: [
getOperationName(FIND_ONE_SERVERLESS_FUNCTION_SOURCE_CODE) ?? '',
],
}); });
}; };

View File

@ -2,6 +2,7 @@ import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetada
import { ApolloClient, useMutation } from '@apollo/client'; import { ApolloClient, useMutation } from '@apollo/client';
import { EXECUTE_ONE_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/mutations/executeOneServerlessFunction'; import { EXECUTE_ONE_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/mutations/executeOneServerlessFunction';
import { import {
ExecuteServerlessFunctionInput,
ExecuteOneServerlessFunctionMutation, ExecuteOneServerlessFunctionMutation,
ExecuteOneServerlessFunctionMutationVariables, ExecuteOneServerlessFunctionMutationVariables,
} from '~/generated-metadata/graphql'; } from '~/generated-metadata/graphql';
@ -16,13 +17,11 @@ export const useExecuteOneServerlessFunction = () => {
}); });
const executeOneServerlessFunction = async ( const executeOneServerlessFunction = async (
id: string, input: ExecuteServerlessFunctionInput,
payload: object = {},
) => { ) => {
return await mutate({ return await mutate({
variables: { variables: {
id, input,
payload,
}, },
}); });
}; };

View File

@ -0,0 +1,30 @@
import { useQuery } from '@apollo/client';
import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient';
import { FIND_ONE_SERVERLESS_FUNCTION_SOURCE_CODE } from '@/settings/serverless-functions/graphql/queries/findOneServerlessFunctionSourceCode';
import {
FindOneServerlessFunctionSourceCodeQuery,
FindOneServerlessFunctionSourceCodeQueryVariables,
} from '~/generated-metadata/graphql';
export const useGetOneServerlessFunctionSourceCode = ({
id,
version,
onCompleted,
}: {
id: string;
version: string;
onCompleted?: (data: FindOneServerlessFunctionSourceCodeQuery) => void;
}) => {
const apolloMetadataClient = useApolloMetadataClient();
const { data, loading } = useQuery<
FindOneServerlessFunctionSourceCodeQuery,
FindOneServerlessFunctionSourceCodeQueryVariables
>(FIND_ONE_SERVERLESS_FUNCTION_SOURCE_CODE, {
client: apolloMetadataClient ?? undefined,
variables: {
input: { id, version },
},
onCompleted,
});
return { code: data?.getServerlessFunctionSourceCode, loading };
};

View File

@ -0,0 +1,36 @@
import { ApolloClient, useMutation } from '@apollo/client';
import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient';
import { PUBLISH_ONE_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/mutations/publishOneServerlessFunction';
import {
PublishServerlessFunctionInput,
PublishOneServerlessFunctionMutation,
PublishOneServerlessFunctionMutationVariables,
} from '~/generated-metadata/graphql';
import { getOperationName } from '@apollo/client/utilities';
import { FIND_ONE_SERVERLESS_FUNCTION_SOURCE_CODE } from '@/settings/serverless-functions/graphql/queries/findOneServerlessFunctionSourceCode';
export const usePublishOneServerlessFunction = () => {
const apolloMetadataClient = useApolloMetadataClient();
const [mutate] = useMutation<
PublishOneServerlessFunctionMutation,
PublishOneServerlessFunctionMutationVariables
>(PUBLISH_ONE_SERVERLESS_FUNCTION, {
client: apolloMetadataClient ?? ({} as ApolloClient<any>),
});
const publishOneServerlessFunction = async (
input: PublishServerlessFunctionInput,
) => {
return await mutate({
variables: {
input,
},
awaitRefetchQueries: true,
refetchQueries: [
getOperationName(FIND_ONE_SERVERLESS_FUNCTION_SOURCE_CODE) ?? '',
],
});
};
return { publishOneServerlessFunction };
};

View File

@ -1,7 +1,7 @@
import { Dispatch, SetStateAction, useEffect, useState } from 'react'; import { Dispatch, SetStateAction, useState } from 'react';
import { getFileAbsoluteURI } from '~/utils/file/getFileAbsoluteURI';
import { isDefined } from '~/utils/isDefined';
import { useGetOneServerlessFunction } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunction'; import { useGetOneServerlessFunction } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunction';
import { useGetOneServerlessFunctionSourceCode } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunctionSourceCode';
import { FindOneServerlessFunctionSourceCodeQuery } from '~/generated-metadata/graphql';
export type ServerlessFunctionNewFormValues = { export type ServerlessFunctionNewFormValues = {
name: string; name: string;
@ -28,30 +28,21 @@ export const useServerlessFunctionUpdateFormState = (
const { serverlessFunction } = const { serverlessFunction } =
useGetOneServerlessFunction(serverlessFunctionId); useGetOneServerlessFunction(serverlessFunctionId);
useEffect(() => { useGetOneServerlessFunctionSourceCode({
const getFileContent = async () => { id: serverlessFunctionId,
const resp = await fetch( version: 'draft',
getFileAbsoluteURI(serverlessFunction?.sourceCodeFullPath), onCompleted: (data: FindOneServerlessFunctionSourceCodeQuery) => {
); const newState = {
if (resp.status !== 200) { code: data?.getServerlessFunctionSourceCode || '',
throw new Error('Network response was not ok'); name: serverlessFunction?.name || '',
} else { description: serverlessFunction?.description || '',
const result = await resp.text(); };
const newState = { setFormValues((prevState) => ({
code: result, ...prevState,
name: serverlessFunction?.name || '', ...newState,
description: serverlessFunction?.description || '', }));
}; },
setFormValues((prevState) => ({ });
...prevState,
...newState,
}));
}
};
if (isDefined(serverlessFunction?.sourceCodeFullPath)) {
getFileContent();
}
}, [serverlessFunction, setFormValues]);
return [formValues, setFormValues]; return [formValues, setFormValues];
}; };

View File

@ -1,5 +1,5 @@
import Editor, { Monaco, EditorProps } from '@monaco-editor/react'; import Editor, { Monaco, EditorProps } from '@monaco-editor/react';
import { editor } from 'monaco-editor'; import { editor, MarkerSeverity } from 'monaco-editor';
import { codeEditorTheme } from '@/ui/input/code-editor/theme/CodeEditorTheme'; import { codeEditorTheme } from '@/ui/input/code-editor/theme/CodeEditorTheme';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
@ -24,17 +24,20 @@ const StyledEditor = styled(Editor)`
type CodeEditorProps = Omit<EditorProps, 'onChange'> & { type CodeEditorProps = Omit<EditorProps, 'onChange'> & {
header: React.ReactNode; header: React.ReactNode;
onChange?: (value: string) => void; onChange?: (value: string) => void;
setIsCodeValid?: (isCodeValid: boolean) => void;
}; };
export const CodeEditor = ({ export const CodeEditor = ({
value = DEFAULT_CODE, value = DEFAULT_CODE,
onChange, onChange,
setIsCodeValid,
language = 'typescript', language = 'typescript',
height = 500, height = 450,
options = undefined, options = undefined,
header, header,
}: CodeEditorProps) => { }: CodeEditorProps) => {
const theme = useTheme(); const theme = useTheme();
const handleEditorDidMount = ( const handleEditorDidMount = (
editor: editor.IStandaloneCodeEditor, editor: editor.IStandaloneCodeEditor,
monaco: Monaco, monaco: Monaco,
@ -42,6 +45,17 @@ export const CodeEditor = ({
monaco.editor.defineTheme('codeEditorTheme', codeEditorTheme(theme)); monaco.editor.defineTheme('codeEditorTheme', codeEditorTheme(theme));
monaco.editor.setTheme('codeEditorTheme'); monaco.editor.setTheme('codeEditorTheme');
}; };
const handleEditorValidation = (markers: editor.IMarker[]) => {
for (const marker of markers) {
if (marker.severity === MarkerSeverity.Error) {
setIsCodeValid?.(false);
return;
}
}
setIsCodeValid?.(true);
};
useEffect(() => { useEffect(() => {
const style = document.createElement('style'); const style = document.createElement('style');
style.innerHTML = ` style.innerHTML = `
@ -63,6 +77,7 @@ export const CodeEditor = ({
value={value} value={value}
onMount={handleEditorDidMount} onMount={handleEditorDidMount}
onChange={(value?: string) => value && onChange?.(value)} onChange={(value?: string) => value && onChange?.(value)}
onValidate={handleEditorValidation}
options={{ options={{
...options, ...options,
overviewRulerLanes: 0, overviewRulerLanes: 0,

View File

@ -2,6 +2,7 @@ import { settingsServerlessFunctionCodeEditorOutputParamsState } from '@/setting
import { settingsServerlessFunctionInputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionInputState'; import { settingsServerlessFunctionInputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionInputState';
import { settingsServerlessFunctionOutputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState'; import { settingsServerlessFunctionOutputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState';
import { useResetRecoilState } from 'recoil'; import { useResetRecoilState } from 'recoil';
import { useEffect } from 'react';
export const ResetServerlessFunctionStatesEffect = () => { export const ResetServerlessFunctionStatesEffect = () => {
const resetSettingsServerlessFunctionInput = useResetRecoilState( const resetSettingsServerlessFunctionInput = useResetRecoilState(
@ -13,8 +14,14 @@ export const ResetServerlessFunctionStatesEffect = () => {
const resetSettingsServerlessFunctionCodeEditorOutputParamsState = const resetSettingsServerlessFunctionCodeEditorOutputParamsState =
useResetRecoilState(settingsServerlessFunctionCodeEditorOutputParamsState); useResetRecoilState(settingsServerlessFunctionCodeEditorOutputParamsState);
resetSettingsServerlessFunctionInput(); useEffect(() => {
resetSettingsServerlessFunctionOutput(); resetSettingsServerlessFunctionInput();
resetSettingsServerlessFunctionCodeEditorOutputParamsState(); resetSettingsServerlessFunctionOutput();
resetSettingsServerlessFunctionCodeEditorOutputParamsState();
}, [
resetSettingsServerlessFunctionInput,
resetSettingsServerlessFunctionOutput,
resetSettingsServerlessFunctionCodeEditorOutputParamsState,
]);
return <></>; return <></>;
}; };

View File

@ -6,6 +6,7 @@ import { SettingsServerlessFunctionTestTabEffect } from '@/settings/serverless-f
import { useExecuteOneServerlessFunction } from '@/settings/serverless-functions/hooks/useExecuteOneServerlessFunction'; import { useExecuteOneServerlessFunction } from '@/settings/serverless-functions/hooks/useExecuteOneServerlessFunction';
import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState'; import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { useUpdateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useUpdateOneServerlessFunction'; import { useUpdateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useUpdateOneServerlessFunction';
import { usePublishOneServerlessFunction } from '@/settings/serverless-functions/hooks/usePublishOneServerlessFunction';
import { settingsServerlessFunctionInputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionInputState'; import { settingsServerlessFunctionInputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionInputState';
import { settingsServerlessFunctionOutputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState'; import { settingsServerlessFunctionOutputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionOutputState';
import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar';
@ -18,7 +19,10 @@ import { Breadcrumb } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useRecoilValue, useSetRecoilState } from 'recoil';
import { IconCode, IconFunction, IconSettings, IconTestPipe } from 'twenty-ui'; import { IconCode, IconFunction, IconSettings, IconTestPipe } from 'twenty-ui';
import { isDefined } from '~/utils/isDefined';
import { useDebouncedCallback } from 'use-debounce'; import { useDebouncedCallback } from 'use-debounce';
import { useGetOneServerlessFunctionSourceCode } from '@/settings/serverless-functions/hooks/useGetOneServerlessFunctionSourceCode';
import { useState } from 'react';
const TAB_LIST_COMPONENT_ID = 'serverless-function-detail'; const TAB_LIST_COMPONENT_ID = 'serverless-function-detail';
@ -29,10 +33,16 @@ export const SettingsServerlessFunctionDetail = () => {
TAB_LIST_COMPONENT_ID, TAB_LIST_COMPONENT_ID,
); );
const activeTabId = useRecoilValue(activeTabIdState); const activeTabId = useRecoilValue(activeTabIdState);
const [isCodeValid, setIsCodeValid] = useState(true);
const { executeOneServerlessFunction } = useExecuteOneServerlessFunction(); const { executeOneServerlessFunction } = useExecuteOneServerlessFunction();
const { updateOneServerlessFunction } = useUpdateOneServerlessFunction(); const { updateOneServerlessFunction } = useUpdateOneServerlessFunction();
const { publishOneServerlessFunction } = usePublishOneServerlessFunction();
const [formValues, setFormValues] = const [formValues, setFormValues] =
useServerlessFunctionUpdateFormState(serverlessFunctionId); useServerlessFunctionUpdateFormState(serverlessFunctionId);
const { code: latestVersionCode } = useGetOneServerlessFunctionSourceCode({
id: serverlessFunctionId,
version: 'latest',
});
const setSettingsServerlessFunctionOutput = useSetRecoilState( const setSettingsServerlessFunctionOutput = useSetRecoilState(
settingsServerlessFunctionOutputState, settingsServerlessFunctionOutputState,
); );
@ -70,13 +80,56 @@ export const SettingsServerlessFunctionDetail = () => {
}; };
}; };
const handleExecute = async () => { const resetDisabled =
await handleSave(); !isDefined(latestVersionCode) || latestVersionCode === formValues.code;
const publishDisabled = !isCodeValid || latestVersionCode === formValues.code;
const handleReset = async () => {
try { try {
const result = await executeOneServerlessFunction( const newState = {
serverlessFunctionId, code: latestVersionCode || '',
JSON.parse(settingsServerlessFunctionInput), };
setFormValues((prevState) => ({
...prevState,
...newState,
}));
await handleSave();
} catch (err) {
enqueueSnackBar(
(err as Error)?.message || 'An error occurred while reset function',
{
variant: SnackBarVariant.Error,
},
); );
}
};
const handlePublish = async () => {
try {
await publishOneServerlessFunction({
id: serverlessFunctionId,
});
enqueueSnackBar(`New function version has been published`, {
variant: SnackBarVariant.Success,
});
} catch (err) {
enqueueSnackBar(
(err as Error)?.message ||
'An error occurred while publishing new version',
{
variant: SnackBarVariant.Error,
},
);
}
};
const handleExecute = async () => {
try {
const result = await executeOneServerlessFunction({
id: serverlessFunctionId,
payload: JSON.parse(settingsServerlessFunctionInput),
version: 'draft',
});
setSettingsServerlessFunctionOutput({ setSettingsServerlessFunctionOutput({
data: result?.data?.executeOneServerlessFunction?.data data: result?.data?.executeOneServerlessFunction?.data
? JSON.stringify( ? JSON.stringify(
@ -119,7 +172,12 @@ export const SettingsServerlessFunctionDetail = () => {
<SettingsServerlessFunctionCodeEditorTab <SettingsServerlessFunctionCodeEditorTab
formValues={formValues} formValues={formValues}
handleExecute={handleExecute} handleExecute={handleExecute}
handlePublish={handlePublish}
handleReset={handleReset}
resetDisabled={resetDisabled}
publishDisabled={publishDisabled}
onChange={onChange} onChange={onChange}
setIsCodeValid={setIsCodeValid}
/> />
); );
case 'test': case 'test':

View File

@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UpdateServerlessFunction1724171584314
implements MigrationInterface
{
name = 'UpdateServerlessFunction1724171584314';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."serverlessFunction" DROP COLUMN "sourceCodeFullPath"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."serverlessFunction" ADD "latestVersion" character varying`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "metadata"."serverlessFunction" DROP COLUMN "latestVersion"`,
);
await queryRunner.query(
`ALTER TABLE "metadata"."serverlessFunction" ADD "sourceCodeFullPath" character varying NOT NULL`,
);
}
}

View File

@ -13,4 +13,8 @@ export interface StorageDriver {
from: { folderPath: string; filename: string }; from: { folderPath: string; filename: string };
to: { folderPath: string; filename: string }; to: { folderPath: string; filename: string };
}): Promise<void>; }): Promise<void>;
copy(params: {
from: { folderPath: string; filename?: string };
to: { folderPath: string; filename?: string };
}): Promise<void>;
} }

View File

@ -70,6 +70,13 @@ export class LocalDriver implements StorageDriver {
params.filename, params.filename,
); );
if (!existsSync(filePath)) {
throw new FileStorageException(
'File not found',
FileStorageExceptionCode.FILE_NOT_FOUND,
);
}
try { try {
return createReadStream(filePath); return createReadStream(filePath);
} catch (error) { } catch (error) {
@ -115,4 +122,34 @@ export class LocalDriver implements StorageDriver {
throw error; throw error;
} }
} }
async copy(params: {
from: { folderPath: string; filename?: string };
to: { folderPath: string; filename?: string };
}): Promise<void> {
const fromPath = join(
`${this.options.storagePath}/`,
params.from.folderPath,
params.from.filename || '',
);
const toPath = join(
`${this.options.storagePath}/`,
params.to.folderPath,
params.to.filename || '',
);
try {
await fs.cp(fromPath, toPath, { recursive: true });
} catch (error) {
if (error.code === 'ENOENT') {
throw new FileStorageException(
'File not found',
FileStorageExceptionCode.FILE_NOT_FOUND,
);
}
throw error;
}
}
} }

View File

@ -187,6 +187,42 @@ export class S3Driver implements StorageDriver {
} }
} }
async copy(params: {
from: { folderPath: string; filename?: string };
to: { folderPath: string; filename?: string };
}): Promise<void> {
const fromKey = `${params.from.folderPath}/${params.from.filename || ''}`;
const toKey = `${params.to.folderPath}/${params.to.filename || ''}`;
try {
// Check if the source file exists
await this.s3Client.send(
new HeadObjectCommand({
Bucket: this.bucketName,
Key: fromKey,
}),
);
// Copy the object to the new location
await this.s3Client.send(
new CopyObjectCommand({
CopySource: `${this.bucketName}/${fromKey}`,
Bucket: this.bucketName,
Key: toKey,
}),
);
} catch (error) {
if (error.name === 'NotFound') {
throw new FileStorageException(
'File not found',
FileStorageExceptionCode.FILE_NOT_FOUND,
);
}
// For other errors, throw the original error
throw error;
}
}
async checkBucketExists(args: HeadBucketCommandInput) { async checkBucketExists(args: HeadBucketCommandInput) {
try { try {
await this.s3Client.headBucket(args); await this.s3Client.headBucket(args);

View File

@ -33,4 +33,11 @@ export class FileStorageService implements StorageDriver {
}): Promise<void> { }): Promise<void> {
return this.driver.move(params); return this.driver.move(params);
} }
copy(params: {
from: { folderPath: string; filename?: string };
to: { folderPath: string; filename?: string };
}): Promise<void> {
return this.driver.copy(params);
}
} }

View File

@ -1,27 +1,19 @@
import { join } from 'path';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { FileStorageService } from 'src/engine/integrations/file-storage/file-storage.service'; import { FileStorageService } from 'src/engine/integrations/file-storage/file-storage.service';
import { readFileContent } from 'src/engine/integrations/file-storage/utils/read-file-content'; import { readFileContent } from 'src/engine/integrations/file-storage/utils/read-file-content';
import { SOURCE_FILE_NAME } from 'src/engine/integrations/serverless/drivers/constants/source-file-name'; import { SOURCE_FILE_NAME } from 'src/engine/integrations/serverless/drivers/constants/source-file-name';
import { compileTypescript } from 'src/engine/integrations/serverless/drivers/utils/compile-typescript'; import { compileTypescript } from 'src/engine/integrations/serverless/drivers/utils/compile-typescript';
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity'; import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import { getServerlessFolder } from 'src/engine/integrations/serverless/utils/serverless-get-folder.utils';
export class BaseServerlessDriver { export class BaseServerlessDriver {
getFolderPath(serverlessFunction: ServerlessFunctionEntity) {
return join(
'workspace-' + serverlessFunction.workspaceId,
FileFolder.ServerlessFunction,
serverlessFunction.id,
);
}
async getCompiledCode( async getCompiledCode(
serverlessFunction: ServerlessFunctionEntity, serverlessFunction: ServerlessFunctionEntity,
fileStorageService: FileStorageService, fileStorageService: FileStorageService,
) { ) {
const folderPath = this.getFolderPath(serverlessFunction); const folderPath = getServerlessFolder({
serverlessFunction,
version: 'draft',
});
const fileStream = await fileStorageService.read({ const fileStream = await fileStorageService.read({
folderPath, folderPath,
filename: SOURCE_FILE_NAME, filename: SOURCE_FILE_NAME,

View File

@ -16,9 +16,14 @@ export type ServerlessExecuteResult = {
export interface ServerlessDriver { export interface ServerlessDriver {
delete(serverlessFunction: ServerlessFunctionEntity): Promise<void>; delete(serverlessFunction: ServerlessFunctionEntity): Promise<void>;
build(serverlessFunction: ServerlessFunctionEntity): Promise<void>; build(
serverlessFunction: ServerlessFunctionEntity,
version: string,
): Promise<void>;
publish(serverlessFunction: ServerlessFunctionEntity): Promise<string>;
execute( execute(
serverlessFunction: ServerlessFunctionEntity, serverlessFunction: ServerlessFunctionEntity,
payload: object | undefined, payload: object | undefined,
version: string,
): Promise<ServerlessExecuteResult>; ): Promise<ServerlessExecuteResult>;
} }

View File

@ -9,6 +9,9 @@ import {
UpdateFunctionCodeCommand, UpdateFunctionCodeCommand,
DeleteFunctionCommand, DeleteFunctionCommand,
ResourceNotFoundException, ResourceNotFoundException,
waitUntilFunctionUpdatedV2,
PublishVersionCommandInput,
PublishVersionCommand,
} from '@aws-sdk/client-lambda'; } from '@aws-sdk/client-lambda';
import { CreateFunctionCommandInput } from '@aws-sdk/client-lambda/dist-types/commands/CreateFunctionCommand'; import { CreateFunctionCommandInput } from '@aws-sdk/client-lambda/dist-types/commands/CreateFunctionCommand';
import { UpdateFunctionCodeCommandInput } from '@aws-sdk/client-lambda/dist-types/commands/UpdateFunctionCodeCommand'; import { UpdateFunctionCodeCommandInput } from '@aws-sdk/client-lambda/dist-types/commands/UpdateFunctionCodeCommand';
@ -24,6 +27,10 @@ import { FileStorageService } from 'src/engine/integrations/file-storage/file-st
import { BaseServerlessDriver } from 'src/engine/integrations/serverless/drivers/base-serverless.driver'; import { BaseServerlessDriver } from 'src/engine/integrations/serverless/drivers/base-serverless.driver';
import { BuildDirectoryManagerService } from 'src/engine/integrations/serverless/drivers/services/build-directory-manager.service'; import { BuildDirectoryManagerService } from 'src/engine/integrations/serverless/drivers/services/build-directory-manager.service';
import { ServerlessFunctionExecutionStatus } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto'; import { ServerlessFunctionExecutionStatus } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto';
import {
ServerlessFunctionException,
ServerlessFunctionExceptionCode,
} from 'src/engine/metadata-modules/serverless-function/serverless-function.exception';
export interface LambdaDriverOptions extends LambdaClientConfig { export interface LambdaDriverOptions extends LambdaClientConfig {
fileStorageService: FileStorageService; fileStorageService: FileStorageService;
@ -51,12 +58,10 @@ export class LambdaDriver
this.buildDirectoryManagerService = options.buildDirectoryManagerService; this.buildDirectoryManagerService = options.buildDirectoryManagerService;
} }
private async checkFunctionExists( private async checkFunctionExists(functionName: string): Promise<boolean> {
serverlessFunctionId: string,
): Promise<boolean> {
try { try {
const getFunctionCommand = new GetFunctionCommand({ const getFunctionCommand = new GetFunctionCommand({
FunctionName: serverlessFunctionId, FunctionName: functionName,
}); });
await this.lambdaClient.send(getFunctionCommand); await this.lambdaClient.send(getFunctionCommand);
@ -132,42 +137,85 @@ export class LambdaDriver
await this.lambdaClient.send(command); await this.lambdaClient.send(command);
} }
const waitParams = { FunctionName: serverlessFunction.id };
await waitUntilFunctionUpdatedV2(
{ client: this.lambdaClient, maxWaitTime: 5 },
waitParams,
);
await this.buildDirectoryManagerService.clean(); await this.buildDirectoryManagerService.clean();
} }
async publish(serverlessFunction: ServerlessFunctionEntity) {
await this.build(serverlessFunction);
const params: PublishVersionCommandInput = {
FunctionName: serverlessFunction.id,
};
const command = new PublishVersionCommand(params);
const result = await this.lambdaClient.send(command);
const newVersion = result.Version;
if (!newVersion) {
throw new Error('New published version is undefined');
}
return newVersion;
}
async execute( async execute(
functionToExecute: ServerlessFunctionEntity, functionToExecute: ServerlessFunctionEntity,
payload: object | undefined = undefined, payload: object | undefined = undefined,
version: string,
): Promise<ServerlessExecuteResult> { ): Promise<ServerlessExecuteResult> {
const computedVersion =
version === 'latest' ? functionToExecute.latestVersion : version;
const functionName =
computedVersion === 'draft'
? functionToExecute.id
: `${functionToExecute.id}:${computedVersion}`;
const startTime = Date.now(); const startTime = Date.now();
const params = { const params = {
FunctionName: functionToExecute.id, FunctionName: functionName,
Payload: JSON.stringify(payload), Payload: JSON.stringify(payload),
}; };
const command = new InvokeCommand(params); const command = new InvokeCommand(params);
const result = await this.lambdaClient.send(command); try {
const result = await this.lambdaClient.send(command);
const parsedResult = result.Payload const parsedResult = result.Payload
? JSON.parse(result.Payload.transformToString()) ? JSON.parse(result.Payload.transformToString())
: {}; : {};
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
if (result.FunctionError) {
return {
data: null,
duration,
status: ServerlessFunctionExecutionStatus.ERROR,
error: parsedResult,
};
}
if (result.FunctionError) {
return { return {
data: null, data: parsedResult,
duration, duration,
status: ServerlessFunctionExecutionStatus.ERROR, status: ServerlessFunctionExecutionStatus.SUCCESS,
error: parsedResult,
}; };
} catch (error) {
if (error instanceof ResourceNotFoundException) {
throw new ServerlessFunctionException(
`Function Version '${version}' does not exist`,
ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND,
);
}
throw error;
} }
return {
data: parsedResult,
duration,
status: ServerlessFunctionExecutionStatus.SUCCESS,
};
} }
} }

View File

@ -10,6 +10,7 @@ import {
ServerlessExecuteError, ServerlessExecuteError,
ServerlessExecuteResult, ServerlessExecuteResult,
} from 'src/engine/integrations/serverless/drivers/interfaces/serverless-driver.interface'; } from 'src/engine/integrations/serverless/drivers/interfaces/serverless-driver.interface';
import { FileStorageExceptionCode } from 'src/engine/integrations/file-storage/interfaces/file-storage-exception';
import { FileStorageService } from 'src/engine/integrations/file-storage/file-storage.service'; import { FileStorageService } from 'src/engine/integrations/file-storage/file-storage.service';
import { readFileContent } from 'src/engine/integrations/file-storage/utils/read-file-content'; import { readFileContent } from 'src/engine/integrations/file-storage/utils/read-file-content';
@ -17,6 +18,11 @@ import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless
import { BUILD_FILE_NAME } from 'src/engine/integrations/serverless/drivers/constants/build-file-name'; import { BUILD_FILE_NAME } from 'src/engine/integrations/serverless/drivers/constants/build-file-name';
import { BaseServerlessDriver } from 'src/engine/integrations/serverless/drivers/base-serverless.driver'; import { BaseServerlessDriver } from 'src/engine/integrations/serverless/drivers/base-serverless.driver';
import { ServerlessFunctionExecutionStatus } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto'; import { ServerlessFunctionExecutionStatus } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function-execution-result.dto';
import {
ServerlessFunctionException,
ServerlessFunctionExceptionCode,
} from 'src/engine/metadata-modules/serverless-function/serverless-function.exception';
import { getServerlessFolder } from 'src/engine/integrations/serverless/utils/serverless-get-folder.utils';
export interface LocalDriverOptions { export interface LocalDriverOptions {
fileStorageService: FileStorageService; fileStorageService: FileStorageService;
@ -33,11 +39,7 @@ export class LocalDriver
this.fileStorageService = options.fileStorageService; this.fileStorageService = options.fileStorageService;
} }
async delete(serverlessFunction: ServerlessFunctionEntity) { async delete() {}
await this.fileStorageService.delete({
folderPath: this.getFolderPath(serverlessFunction),
});
}
async build(serverlessFunction: ServerlessFunctionEntity) { async build(serverlessFunction: ServerlessFunctionEntity) {
const javascriptCode = await this.getCompiledCode( const javascriptCode = await this.getCompiledCode(
@ -49,20 +51,48 @@ export class LocalDriver
file: javascriptCode, file: javascriptCode,
name: BUILD_FILE_NAME, name: BUILD_FILE_NAME,
mimeType: undefined, mimeType: undefined,
folder: this.getFolderPath(serverlessFunction), folder: getServerlessFolder({
serverlessFunction,
version: 'draft',
}),
}); });
} }
async publish(serverlessFunction: ServerlessFunctionEntity) {
await this.build(serverlessFunction);
return serverlessFunction.latestVersion
? `${parseInt(serverlessFunction.latestVersion, 10) + 1}`
: '1';
}
async execute( async execute(
serverlessFunction: ServerlessFunctionEntity, serverlessFunction: ServerlessFunctionEntity,
payload: object | undefined = undefined, payload: object | undefined = undefined,
version: string,
): Promise<ServerlessExecuteResult> { ): Promise<ServerlessExecuteResult> {
const startTime = Date.now(); const startTime = Date.now();
const fileStream = await this.fileStorageService.read({ let fileContent = '';
folderPath: this.getFolderPath(serverlessFunction),
filename: BUILD_FILE_NAME, try {
}); const fileStream = await this.fileStorageService.read({
const fileContent = await readFileContent(fileStream); folderPath: getServerlessFolder({
serverlessFunction,
version,
}),
filename: BUILD_FILE_NAME,
});
fileContent = await readFileContent(fileStream);
} catch (error) {
if (error.code === FileStorageExceptionCode.FILE_NOT_FOUND) {
throw new ServerlessFunctionException(
`Function Version '${version}' does not exist`,
ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND,
);
}
throw error;
}
const tmpFilePath = join(tmpdir(), `${v4()}.js`); const tmpFilePath = join(tmpdir(), `${v4()}.js`);

View File

@ -16,14 +16,22 @@ export class ServerlessService implements ServerlessDriver {
return this.driver.delete(serverlessFunction); return this.driver.delete(serverlessFunction);
} }
async build(serverlessFunction: ServerlessFunctionEntity): Promise<void> { async build(
return this.driver.build(serverlessFunction); serverlessFunction: ServerlessFunctionEntity,
version: string,
): Promise<void> {
return this.driver.build(serverlessFunction, version);
}
async publish(serverlessFunction: ServerlessFunctionEntity): Promise<string> {
return this.driver.publish(serverlessFunction);
} }
async execute( async execute(
serverlessFunction: ServerlessFunctionEntity, serverlessFunction: ServerlessFunctionEntity,
payload: object | undefined = undefined, payload: object | undefined = undefined,
version: string,
): Promise<ServerlessExecuteResult> { ): Promise<ServerlessExecuteResult> {
return this.driver.execute(serverlessFunction, payload); return this.driver.execute(serverlessFunction, payload, version);
} }
} }

View File

@ -0,0 +1,23 @@
import { join } from 'path';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
export const getServerlessFolder = ({
serverlessFunction,
version,
}: {
serverlessFunction: ServerlessFunctionEntity;
version?: string;
}) => {
const computedVersion =
version === 'latest' ? serverlessFunction.latestVersion : version;
return join(
'workspace-' + serverlessFunction.workspaceId,
FileFolder.ServerlessFunction,
serverlessFunction.id,
computedVersion || '',
);
};

View File

@ -1,11 +1,11 @@
import { ArgsType, Field } from '@nestjs/graphql'; import { ArgsType, Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsObject, IsOptional, IsUUID } from 'class-validator'; import { IsNotEmpty, IsObject, IsOptional, IsUUID } from 'class-validator';
import graphqlTypeJson from 'graphql-type-json'; import graphqlTypeJson 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';
@ArgsType() @InputType()
export class ExecuteServerlessFunctionInput { export class ExecuteServerlessFunctionInput {
@Field(() => UUIDScalarType, { @Field(() => UUIDScalarType, {
description: 'Id of the serverless function to execute', description: 'Id of the serverless function to execute',
@ -21,4 +21,11 @@ export class ExecuteServerlessFunctionInput {
@IsObject() @IsObject()
@IsOptional() @IsOptional()
payload?: JSON; payload?: JSON;
@Field(() => String, {
nullable: false,
description: 'Version of the serverless function to execute',
defaultValue: 'latest',
})
version: string;
} }

View File

@ -0,0 +1,16 @@
import { Field, ID, InputType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
@InputType()
export class GetServerlessFunctionSourceCodeInput {
@IDField(() => ID, { description: 'The id of the function.' })
id!: string;
@Field(() => String, {
nullable: false,
description: 'The version of the function',
defaultValue: 'draft',
})
version: string;
}

View File

@ -0,0 +1,9 @@
import { ID, InputType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
@InputType()
export class PublishServerlessFunctionInput {
@IDField(() => ID, { description: 'The id of the function.' })
id!: string;
}

View File

@ -14,6 +14,7 @@ import {
IsDateString, IsDateString,
IsEnum, IsEnum,
IsNotEmpty, IsNotEmpty,
IsNumber,
IsString, IsString,
IsUUID, IsUUID,
} from 'class-validator'; } from 'class-validator';
@ -59,12 +60,11 @@ export class ServerlessFunctionDTO {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@Field() @Field()
sourceCodeFullPath: string; runtime: string;
@IsString() @IsString()
@IsNotEmpty() @Field({ nullable: true })
@Field() latestVersion: string;
runtime: string;
@IsEnum(ServerlessFunctionSyncStatus) @IsEnum(ServerlessFunctionSyncStatus)
@IsNotEmpty() @IsNotEmpty()

View File

@ -28,11 +28,11 @@ export class ServerlessFunctionEntity {
@Column({ nullable: true }) @Column({ nullable: true })
description: string; description: string;
@Column({ nullable: false }) @Column({ nullable: true })
sourceCodeHash: string; latestVersion: string;
@Column({ nullable: false }) @Column({ nullable: false })
sourceCodeFullPath: string; sourceCodeHash: string;
@Column({ nullable: false, default: ServerlessFunctionRuntime.NODE18 }) @Column({ nullable: false, default: ServerlessFunctionRuntime.NODE18 })
runtime: ServerlessFunctionRuntime; runtime: ServerlessFunctionRuntime;

View File

@ -9,6 +9,7 @@ export class ServerlessFunctionException extends CustomException {
export enum ServerlessFunctionExceptionCode { export enum ServerlessFunctionExceptionCode {
SERVERLESS_FUNCTION_NOT_FOUND = 'SERVERLESS_FUNCTION_NOT_FOUND', SERVERLESS_FUNCTION_NOT_FOUND = 'SERVERLESS_FUNCTION_NOT_FOUND',
SERVERLESS_FUNCTION_VERSION_NOT_FOUND = 'SERVERLESS_FUNCTION_VERSION_NOT_FOUND',
FEATURE_FLAG_INVALID = 'FEATURE_FLAG_INVALID', FEATURE_FLAG_INVALID = 'FEATURE_FLAG_INVALID',
SERVERLESS_FUNCTION_ALREADY_EXIST = 'SERVERLESS_FUNCTION_ALREADY_EXIST', SERVERLESS_FUNCTION_ALREADY_EXIST = 'SERVERLESS_FUNCTION_ALREADY_EXIST',
SERVERLESS_FUNCTION_NOT_READY = 'SERVERLESS_FUNCTION_NOT_READY', SERVERLESS_FUNCTION_NOT_READY = 'SERVERLESS_FUNCTION_NOT_READY',

View File

@ -1,57 +0,0 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { FileService } from 'src/engine/core-modules/file/services/file.service';
@Injectable()
export class ServerlessFunctionInterceptor implements NestInterceptor {
constructor(private readonly fileService: FileService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map(async (data) => {
if (data.edges && Array.isArray(data.edges)) {
return {
...data,
edges: Promise.all(
data.edges.map((item) => ({
...item,
node: this.processItem(item.node),
})),
),
};
} else {
return this.processItem(data);
}
}),
);
}
private async processItem(item: any): Promise<any> {
if (item && item.sourceCodeFullPath) {
const workspaceId = item.workspace?.id || item.workspaceId;
if (!workspaceId) {
return item;
}
const signedPayload = await this.fileService.encodeFileToken({
serverlessFunctionId: item.id,
workspace_id: workspaceId,
});
return {
...item,
sourceCodeFullPath: `${item.sourceCodeFullPath}?token=${signedPayload}`,
};
}
return item;
}
}

View File

@ -15,7 +15,6 @@ import { JwtAuthGuard } from 'src/engine/guards/jwt.auth.guard';
import { ServerlessModule } from 'src/engine/integrations/serverless/serverless.module'; import { ServerlessModule } from 'src/engine/integrations/serverless/serverless.module';
import { ServerlessFunctionDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto'; import { ServerlessFunctionDTO } from 'src/engine/metadata-modules/serverless-function/dtos/serverless-function.dto';
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity'; import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
import { ServerlessFunctionInterceptor } from 'src/engine/metadata-modules/serverless-function/serverless-function.interceptor';
import { ServerlessFunctionResolver } from 'src/engine/metadata-modules/serverless-function/serverless-function.resolver'; import { ServerlessFunctionResolver } from 'src/engine/metadata-modules/serverless-function/serverless-function.resolver';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
@ -45,7 +44,6 @@ import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverles
update: { disabled: true }, update: { disabled: true },
delete: { disabled: true }, delete: { disabled: true },
guards: [JwtAuthGuard], guards: [JwtAuthGuard],
interceptors: [ServerlessFunctionInterceptor],
}, },
], ],
}), }),

View File

@ -1,5 +1,5 @@
import { UseGuards, UseInterceptors } from '@nestjs/common'; import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Resolver } from '@nestjs/graphql'; import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { FileUpload, GraphQLUpload } from 'graphql-upload'; import { FileUpload, GraphQLUpload } from 'graphql-upload';
@ -21,9 +21,10 @@ import {
ServerlessFunctionException, ServerlessFunctionException,
ServerlessFunctionExceptionCode, ServerlessFunctionExceptionCode,
} from 'src/engine/metadata-modules/serverless-function/serverless-function.exception'; } from 'src/engine/metadata-modules/serverless-function/serverless-function.exception';
import { ServerlessFunctionInterceptor } from 'src/engine/metadata-modules/serverless-function/serverless-function.interceptor';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service'; import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
import { serverlessFunctionGraphQLApiExceptionHandler } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils'; import { serverlessFunctionGraphQLApiExceptionHandler } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-graphql-api-exception-handler.utils';
import { GetServerlessFunctionSourceCodeInput } from 'src/engine/metadata-modules/serverless-function/dtos/get-serverless-function-source-code.input';
import { PublishServerlessFunctionInput } from 'src/engine/metadata-modules/serverless-function/dtos/publish-serverless-function.input';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Resolver() @Resolver()
@ -50,6 +51,24 @@ export class ServerlessFunctionResolver {
} }
} }
@Query(() => String)
async getServerlessFunctionSourceCode(
@Args('input') input: GetServerlessFunctionSourceCodeInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
try {
await this.checkFeatureFlag(workspaceId);
return await this.serverlessFunctionService.getServerlessFunctionSourceCode(
workspaceId,
input.id,
input.version,
);
} catch (error) {
serverlessFunctionGraphQLApiExceptionHandler(error);
}
}
@Mutation(() => ServerlessFunctionDTO) @Mutation(() => ServerlessFunctionDTO)
async deleteOneServerlessFunction( async deleteOneServerlessFunction(
@Args('input') input: DeleteServerlessFunctionInput, @Args('input') input: DeleteServerlessFunctionInput,
@ -67,7 +86,6 @@ export class ServerlessFunctionResolver {
} }
} }
@UseInterceptors(ServerlessFunctionInterceptor)
@Mutation(() => ServerlessFunctionDTO) @Mutation(() => ServerlessFunctionDTO)
async updateOneServerlessFunction( async updateOneServerlessFunction(
@Args('input') @Args('input')
@ -86,7 +104,6 @@ export class ServerlessFunctionResolver {
} }
} }
@UseInterceptors(ServerlessFunctionInterceptor)
@Mutation(() => ServerlessFunctionDTO) @Mutation(() => ServerlessFunctionDTO)
async createOneServerlessFunction( async createOneServerlessFunction(
@Args('input') @Args('input')
@ -109,7 +126,6 @@ export class ServerlessFunctionResolver {
} }
} }
@UseInterceptors(ServerlessFunctionInterceptor)
@Mutation(() => ServerlessFunctionDTO) @Mutation(() => ServerlessFunctionDTO)
async createOneServerlessFunctionFromFile( async createOneServerlessFunctionFromFile(
@Args({ name: 'file', type: () => GraphQLUpload }) @Args({ name: 'file', type: () => GraphQLUpload })
@ -133,17 +149,36 @@ export class ServerlessFunctionResolver {
@Mutation(() => ServerlessFunctionExecutionResultDTO) @Mutation(() => ServerlessFunctionExecutionResultDTO)
async executeOneServerlessFunction( async executeOneServerlessFunction(
@Args() executeServerlessFunctionInput: ExecuteServerlessFunctionInput, @Args('input') input: ExecuteServerlessFunctionInput,
@AuthWorkspace() { id: workspaceId }: Workspace, @AuthWorkspace() { id: workspaceId }: Workspace,
) { ) {
try { try {
await this.checkFeatureFlag(workspaceId); await this.checkFeatureFlag(workspaceId);
const { id, payload } = executeServerlessFunctionInput; const { id, payload, version } = input;
return await this.serverlessFunctionService.executeOne( return await this.serverlessFunctionService.executeOneServerlessFunction(
id, id,
workspaceId, workspaceId,
payload, payload,
version,
);
} catch (error) {
serverlessFunctionGraphQLApiExceptionHandler(error);
}
}
@Mutation(() => ServerlessFunctionDTO)
async publishServerlessFunction(
@Args('input') input: PublishServerlessFunctionInput,
@AuthWorkspace() { id: workspaceId }: Workspace,
) {
try {
await this.checkFeatureFlag(workspaceId);
const { id } = input;
return await this.serverlessFunctionService.publishOneServerlessFunction(
id,
workspaceId,
); );
} catch (error) { } catch (error) {
serverlessFunctionGraphQLApiExceptionHandler(error); serverlessFunctionGraphQLApiExceptionHandler(error);

View File

@ -1,15 +1,12 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { join } from 'path';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { FileUpload } from 'graphql-upload'; import { FileUpload } from 'graphql-upload';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import { FileFolder } from 'src/engine/core-modules/file/interfaces/file-folder.interface';
import { ServerlessExecuteResult } from 'src/engine/integrations/serverless/drivers/interfaces/serverless-driver.interface'; import { ServerlessExecuteResult } from 'src/engine/integrations/serverless/drivers/interfaces/serverless-driver.interface';
import { FileStorageExceptionCode } from 'src/engine/integrations/file-storage/interfaces/file-storage-exception';
import { FileStorageService } from 'src/engine/integrations/file-storage/file-storage.service'; import { FileStorageService } from 'src/engine/integrations/file-storage/file-storage.service';
import { readFileContent } from 'src/engine/integrations/file-storage/utils/read-file-content'; import { readFileContent } from 'src/engine/integrations/file-storage/utils/read-file-content';
@ -26,6 +23,8 @@ import {
ServerlessFunctionExceptionCode, ServerlessFunctionExceptionCode,
} from 'src/engine/metadata-modules/serverless-function/serverless-function.exception'; } from 'src/engine/metadata-modules/serverless-function/serverless-function.exception';
import { serverlessFunctionCreateHash } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-create-hash.utils'; import { serverlessFunctionCreateHash } from 'src/engine/metadata-modules/serverless-function/utils/serverless-function-create-hash.utils';
import { isDefined } from 'src/utils/is-defined';
import { getServerlessFolder } from 'src/engine/integrations/serverless/utils/serverless-get-folder.utils';
@Injectable() @Injectable()
export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFunctionEntity> { export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFunctionEntity> {
@ -38,10 +37,54 @@ export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFun
super(serverlessFunctionRepository); super(serverlessFunctionRepository);
} }
async executeOne( async getServerlessFunctionSourceCode(
workspaceId: string,
id: string,
version: string,
) {
try {
const serverlessFunction =
await this.serverlessFunctionRepository.findOne({
where: {
id,
workspaceId,
},
});
if (!serverlessFunction) {
throw new ServerlessFunctionException(
`Function does not exist`,
ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND,
);
}
const folderPath = getServerlessFolder({
serverlessFunction,
version,
});
const fileStream = await this.fileStorageService.read({
folderPath,
filename: SOURCE_FILE_NAME,
});
return await readFileContent(fileStream);
} catch (error) {
if (error.code === FileStorageExceptionCode.FILE_NOT_FOUND) {
throw new ServerlessFunctionException(
`Function Version '${version}' does not exist`,
ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND,
);
}
throw error;
}
}
async executeOneServerlessFunction(
id: string, id: string,
workspaceId: string, workspaceId: string,
payload: object | undefined = undefined, payload: object | undefined = undefined,
version = 'latest',
): Promise<ServerlessExecuteResult> { ): Promise<ServerlessExecuteResult> {
const functionToExecute = await this.serverlessFunctionRepository.findOne({ const functionToExecute = await this.serverlessFunctionRepository.findOne({
where: { where: {
@ -60,13 +103,73 @@ export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFun
if ( if (
functionToExecute.syncStatus === ServerlessFunctionSyncStatus.NOT_READY functionToExecute.syncStatus === ServerlessFunctionSyncStatus.NOT_READY
) { ) {
await this.serverlessService.build(functionToExecute, version);
await super.updateOne(functionToExecute.id, {
syncStatus: ServerlessFunctionSyncStatus.READY,
});
}
return this.serverlessService.execute(functionToExecute, payload, version);
}
async publishOneServerlessFunction(id: string, workspaceId: string) {
const existingServerlessFunction =
await this.serverlessFunctionRepository.findOne({
where: { id, workspaceId },
});
if (!existingServerlessFunction) {
throw new ServerlessFunctionException( throw new ServerlessFunctionException(
`Function is not ready to be executed`, `Function does not exist`,
ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND, ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND,
); );
} }
return this.serverlessService.execute(functionToExecute, payload); if (isDefined(existingServerlessFunction.latestVersion)) {
const latestCode = await this.getServerlessFunctionSourceCode(
workspaceId,
id,
'latest',
);
const draftCode = await this.getServerlessFunctionSourceCode(
workspaceId,
id,
'draft',
);
if (
serverlessFunctionCreateHash(latestCode) ===
serverlessFunctionCreateHash(draftCode)
) {
throw new Error(
'Cannot publish a new version when code has not changed',
);
}
}
const newVersion = await this.serverlessService.publish(
existingServerlessFunction,
);
const draftFolderPath = getServerlessFolder({
serverlessFunction: existingServerlessFunction,
version: 'draft',
});
const newFolderPath = getServerlessFolder({
serverlessFunction: existingServerlessFunction,
version: newVersion,
});
await this.fileStorageService.copy({
from: { folderPath: draftFolderPath },
to: { folderPath: newFolderPath },
});
await super.updateOne(existingServerlessFunction.id, {
latestVersion: newVersion,
});
return await this.findById(existingServerlessFunction.id);
} }
async deleteOneServerlessFunction(id: string, workspaceId: string) { async deleteOneServerlessFunction(id: string, workspaceId: string) {
@ -86,6 +189,12 @@ export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFun
await this.serverlessService.delete(existingServerlessFunction); await this.serverlessService.delete(existingServerlessFunction);
await this.fileStorageService.delete({
folderPath: getServerlessFolder({
serverlessFunction: existingServerlessFunction,
}),
});
return existingServerlessFunction; return existingServerlessFunction;
} }
@ -105,34 +214,23 @@ export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFun
); );
} }
const codeHasChanged =
serverlessFunctionCreateHash(serverlessFunctionInput.code) !==
existingServerlessFunction.sourceCodeHash;
await super.updateOne(existingServerlessFunction.id, { await super.updateOne(existingServerlessFunction.id, {
name: serverlessFunctionInput.name, name: serverlessFunctionInput.name,
description: serverlessFunctionInput.description, description: serverlessFunctionInput.description,
sourceCodeHash: serverlessFunctionCreateHash( syncStatus: ServerlessFunctionSyncStatus.NOT_READY,
serverlessFunctionInput.code,
),
}); });
if (codeHasChanged) { const fileFolder = getServerlessFolder({
const fileFolder = join( serverlessFunction: existingServerlessFunction,
'workspace-' + workspaceId, version: 'draft',
FileFolder.ServerlessFunction, });
existingServerlessFunction.id,
);
await this.fileStorageService.write({ await this.fileStorageService.write({
file: serverlessFunctionInput.code, file: serverlessFunctionInput.code,
name: SOURCE_FILE_NAME, name: SOURCE_FILE_NAME,
mimeType: undefined, mimeType: undefined,
folder: fileFolder, folder: fileFolder,
}); });
await this.serverlessService.build(existingServerlessFunction);
}
return await this.findById(existingServerlessFunction.id); return await this.findById(existingServerlessFunction.id);
} }
@ -162,41 +260,24 @@ export class ServerlessFunctionService extends TypeOrmQueryService<ServerlessFun
typescriptCode = await readFileContent(code.createReadStream()); typescriptCode = await readFileContent(code.createReadStream());
} }
const serverlessFunctionId = v4(); const createdServerlessFunction = await super.createOne({
const fileFolderWithoutWorkspace = join(
FileFolder.ServerlessFunction,
serverlessFunctionId,
);
const fileFolder = join(
'workspace-' + workspaceId,
fileFolderWithoutWorkspace,
);
const sourceCodeFullPath =
fileFolderWithoutWorkspace + '/' + SOURCE_FILE_NAME;
const serverlessFunction = await super.createOne({
...serverlessFunctionInput, ...serverlessFunctionInput,
id: serverlessFunctionId,
workspaceId, workspaceId,
sourceCodeHash: serverlessFunctionCreateHash(typescriptCode), sourceCodeHash: serverlessFunctionCreateHash(typescriptCode),
sourceCodeFullPath, });
const draftFileFolder = getServerlessFolder({
serverlessFunction: createdServerlessFunction,
version: 'draft',
}); });
await this.fileStorageService.write({ await this.fileStorageService.write({
file: typescriptCode, file: typescriptCode,
name: SOURCE_FILE_NAME, name: SOURCE_FILE_NAME,
mimeType: undefined, mimeType: undefined,
folder: fileFolder, folder: draftFileFolder,
}); });
await this.serverlessService.build(serverlessFunction); return await this.findById(createdServerlessFunction.id);
await super.updateOne(serverlessFunctionId, {
syncStatus: ServerlessFunctionSyncStatus.READY,
});
return await this.findById(serverlessFunctionId);
} }
} }

View File

@ -13,6 +13,7 @@ export const serverlessFunctionGraphQLApiExceptionHandler = (error: any) => {
if (error instanceof ServerlessFunctionException) { if (error instanceof ServerlessFunctionException) {
switch (error.code) { switch (error.code) {
case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND: case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_NOT_FOUND:
case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_VERSION_NOT_FOUND:
throw new NotFoundError(error.message); throw new NotFoundError(error.message);
case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_ALREADY_EXIST: case ServerlessFunctionExceptionCode.SERVERLESS_FUNCTION_ALREADY_EXIST:
throw new ConflictError(error.message); throw new ConflictError(error.message);

View File

@ -33,11 +33,12 @@ export class CodeActionExecutor implements WorkflowStepExecutor {
); );
} }
const result = await this.serverlessFunctionService.executeOne( const result =
step.settings.serverlessFunctionId, await this.serverlessFunctionService.executeOneServerlessFunction(
workspaceId, step.settings.serverlessFunctionId,
payload, workspaceId,
); payload,
);
return { data: result.data, ...(result.error && { error: result.error }) }; return { data: result.data, ...(result.error && { error: result.error }) };
} }

View File

@ -90,6 +90,7 @@ export {
IconFocusCentered, IconFocusCentered,
IconForbid, IconForbid,
IconFunction, IconFunction,
IconGitCommit,
IconGripVertical, IconGripVertical,
IconH1, IconH1,
IconH2, IconH2,