1751 extensibility twenty sdk v2 use twenty sdk to define a serverless function trigger (#15347)
This PR adds 2 columns handlerPath and handlerName in serverlessFunction
to locate the entrypoint of a serverless in a codebase
It adds the following decorators in twenty-sdk:
- ServerlessFunction
- DatabaseEventTrigger
- RouteTrigger
- CronTrigger
- ApplicationVariable
It still supports deprecated entity.manifest.jsonc
Overall code needs to be cleaned a little bit, but it should work
properly so you can try to test if the DEVX fits your needs
See updates in hello-world application
```typescript
import axios from 'axios';
import {
DatabaseEventTrigger,
ServerlessFunction,
RouteTrigger,
CronTrigger,
ApplicationVariable,
} from 'twenty-sdk';
@ApplicationVariable({
universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305',
key: 'TWENTY_API_KEY',
description: 'Twenty API Key',
isSecret: true,
})
@DatabaseEventTrigger({
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
eventName: 'person.created',
})
@RouteTrigger({
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: false,
})
@CronTrigger({
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
pattern: '0 0 1 1 *', // Every year 1st of January
})
@ServerlessFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
})
class CreateNewPostCard {
main = async (params: { recipient: string }): Promise<string> => {
const { recipient } = params;
const options = {
method: 'POST',
url: 'http://localhost:3000/rest/postCards',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
data: { name: recipient ?? 'Unknown' },
};
try {
const { data } = await axios.request(options);
console.log(`New post card to "${recipient}" created`);
return data;
} catch (error) {
console.error(error);
throw error;
}
};
}
export const createNewPostCardHandler = new CreateNewPostCard().main;
```
### [edit] V2
After the v1 proposal, I see that using a class method to define the
serverless function handler is pretty confusing. Lets leave
serverlessFunction configuration decorators on the class, but move the
handler like before. Here is the v2 hello-world serverless function:
```typescript
import axios from 'axios';
import {
DatabaseEventTrigger,
ServerlessFunction,
RouteTrigger,
CronTrigger,
ApplicationVariable,
} from 'twenty-sdk';
@ApplicationVariable({
universalIdentifier: 'dedc53eb-9c12-4fe2-ba86-4a2add19d305',
key: 'TWENTY_API_KEY',
description: 'Twenty API Key',
isSecret: true,
})
@DatabaseEventTrigger({
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
eventName: 'person.created',
})
@RouteTrigger({
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: false,
})
@CronTrigger({
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
pattern: '0 0 1 1 *', // Every year 1st of January
})
@ServerlessFunction({
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
})
export class ServerlessFunctionDefinition {}
export const main = async (params: { recipient: string }): Promise<string> => {
const { recipient } = params;
const options = {
method: 'POST',
url: 'http://localhost:3000/rest/postCards',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
data: { name: recipient ?? 'Unknown' },
};
try {
const { data } = await axios.request(options);
console.log(`New post card to "${recipient}" created`);
return data;
} catch (error) {
console.error(error);
throw error;
}
};
```
### [edit] V3
After the v2 proposal, we don't really like decorators on empty classes.
We decided to go with a Vercel approach with a config constant
```typescript
import axios from 'axios';
import { ServerlessFunctionConfig } from 'twenty-sdk';
export const main = async (params: { recipient: string }): Promise<string> => {
const { recipient } = params;
const options = {
method: 'POST',
url: 'http://localhost:3000/rest/postCards',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.TWENTY_API_KEY}`,
},
data: { name: recipient ?? 'Unknown' },
};
try {
const { data } = await axios.request(options);
console.log(`New post card to "${recipient}" created`);
return data;
} catch (error) {
console.error(error);
throw error;
}
};
export const config: ServerlessFunctionConfig = {
universalIdentifier: 'e56d363b-0bdc-4d8a-a393-6f0d1c75bdcf',
routeTriggers: [
{
universalIdentifier: 'c9f84c8d-b26d-40d1-95dd-4f834ae5a2c6',
path: '/post-card/create',
httpMethod: 'GET',
isAuthRequired: false,
}
],
cronTriggers: [
{
universalIdentifier: 'dd802808-0695-49e1-98c9-d5c9e2704ce2',
pattern: '0 0 1 1 *', // Every year 1st of January
}
],
databaseEventTriggers: [
{
universalIdentifier: '203f1df3-4a82-4d06-a001-b8cf22a31156',
eventName: 'person.created',
}
]
}
```
This commit is contained in:
@@ -5,32 +5,19 @@ import inquirer from 'inquirer';
|
||||
import path from 'path';
|
||||
import camelcase from 'lodash.camelcase';
|
||||
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
|
||||
import { HTTPMethod } from '../types/config.types';
|
||||
import { parseJsoncFile, writeJsoncFile } from '../utils/jsonc-parser';
|
||||
import { getSchemaUrls } from '../utils/schema-validator';
|
||||
import { BASE_SCHEMAS_PATH } from '../constants/constants-path';
|
||||
import { getDecoratedClass } from '../utils/get-decorated-class';
|
||||
import { getObjectMetadataDecoratedClass } from '../utils/get-object-metadata-decorated-class';
|
||||
import { getServerlessFunctionBaseFile } from '../utils/get-serverless-function-base-file';
|
||||
|
||||
const ROOT_FOLDER = 'src';
|
||||
|
||||
export enum SyncableEntity {
|
||||
AGENT = 'agent',
|
||||
OBJECT = 'object',
|
||||
SERVERLESS_FUNCTION = 'serverlessFunction',
|
||||
TRIGGER = 'trigger',
|
||||
}
|
||||
|
||||
const getFolderName = (entity: SyncableEntity) => {
|
||||
switch (entity) {
|
||||
case SyncableEntity.AGENT:
|
||||
return 'agents';
|
||||
case SyncableEntity.OBJECT:
|
||||
return 'objects';
|
||||
case SyncableEntity.SERVERLESS_FUNCTION:
|
||||
return 'serverlessFunctions';
|
||||
default:
|
||||
throw new Error(`Unknown entity type: ${entity}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const isSyncableEntity = (value: string): value is SyncableEntity => {
|
||||
return Object.values(SyncableEntity).includes(value as SyncableEntity);
|
||||
};
|
||||
@@ -38,22 +25,12 @@ export const isSyncableEntity = (value: string): value is SyncableEntity => {
|
||||
export class AppAddCommand {
|
||||
async execute(entityType?: SyncableEntity): Promise<void> {
|
||||
try {
|
||||
const appPath = CURRENT_EXECUTION_DIRECTORY;
|
||||
const appPath = path.join(CURRENT_EXECUTION_DIRECTORY, ROOT_FOLDER);
|
||||
|
||||
await fs.ensureDir(appPath);
|
||||
|
||||
const entity = entityType ?? (await this.getEntity());
|
||||
|
||||
const appExists = await fs.pathExists(appPath);
|
||||
|
||||
if (!appExists) {
|
||||
console.error(chalk.red('App does not exist'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (entity === SyncableEntity.TRIGGER) {
|
||||
await this.addTriggerToServerlessFunction(appPath);
|
||||
return;
|
||||
}
|
||||
|
||||
const entityName = await this.getEntityName(entity);
|
||||
|
||||
const entityData = await this.getEntityToCreateData(entity, entityName);
|
||||
@@ -64,7 +41,7 @@ export class AppAddCommand {
|
||||
|
||||
const objectFileName = `${camelcase(entityName)}.ts`;
|
||||
|
||||
const decoratedObject = getDecoratedClass({
|
||||
const decoratedObject = getObjectMetadataDecoratedClass({
|
||||
data: entityData,
|
||||
name: entityName,
|
||||
});
|
||||
@@ -74,18 +51,20 @@ export class AppAddCommand {
|
||||
return;
|
||||
}
|
||||
|
||||
const folderName = getFolderName(entity);
|
||||
if (entity === SyncableEntity.SERVERLESS_FUNCTION) {
|
||||
const objectFileName = `${camelcase(entityName)}.ts`;
|
||||
|
||||
const entitiesDir = path.join(appPath, folderName, entityName);
|
||||
const decoratedServerlessFunction = getServerlessFunctionBaseFile({
|
||||
name: entityName,
|
||||
});
|
||||
|
||||
await fs.ensureDir(entitiesDir);
|
||||
await fs.writeFile(
|
||||
path.join(appPath, objectFileName),
|
||||
decoratedServerlessFunction,
|
||||
);
|
||||
|
||||
await writeJsoncFile(
|
||||
path.join(entitiesDir, `${entity}.manifest.jsonc`),
|
||||
entityData,
|
||||
);
|
||||
|
||||
await this.addEntityInitFiles(entity, entitiesDir);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
chalk.red(`Add new entity failed:`),
|
||||
@@ -123,11 +102,7 @@ export class AppAddCommand {
|
||||
name: 'entity',
|
||||
message: `What entity do you want to create?`,
|
||||
default: '',
|
||||
choices: [
|
||||
SyncableEntity.SERVERLESS_FUNCTION,
|
||||
SyncableEntity.OBJECT,
|
||||
SyncableEntity.TRIGGER,
|
||||
],
|
||||
choices: [SyncableEntity.SERVERLESS_FUNCTION, SyncableEntity.OBJECT],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -212,178 +187,4 @@ export class AppAddCommand {
|
||||
|
||||
return entityToCreateData;
|
||||
}
|
||||
|
||||
private async addTriggerToServerlessFunction(appPath: string) {
|
||||
const serverlessFunctionsDir = path.join(appPath, 'serverlessFunctions');
|
||||
|
||||
if (!(await fs.pathExists(serverlessFunctionsDir))) {
|
||||
console.error(chalk.red('No serverless functions found in this app'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const serverlessFunctions = await fs.readdir(serverlessFunctionsDir);
|
||||
|
||||
if (serverlessFunctions.length === 0) {
|
||||
console.error(chalk.red('No serverless functions found in this app'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { serverlessFunctionName } = await inquirer.prompt<{
|
||||
serverlessFunctionName: string;
|
||||
}>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'serverlessFunctionName',
|
||||
message: 'Select a serverless function to add a trigger to:',
|
||||
choices: serverlessFunctions,
|
||||
},
|
||||
]);
|
||||
|
||||
const { triggerType } = await inquirer.prompt<{ triggerType: string }>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'triggerType',
|
||||
message: 'Select the type of trigger:',
|
||||
choices: ['databaseEvent', 'cron', 'route'],
|
||||
},
|
||||
]);
|
||||
|
||||
let triggerData: any;
|
||||
|
||||
if (triggerType === 'databaseEvent') {
|
||||
triggerData = await this.createDatabaseEventTrigger();
|
||||
} else if (triggerType === 'cron') {
|
||||
triggerData = await this.createCronTrigger();
|
||||
} else if (triggerType === 'route') {
|
||||
triggerData = await this.createRouteTrigger();
|
||||
}
|
||||
|
||||
const manifestPath = path.join(
|
||||
serverlessFunctionsDir,
|
||||
serverlessFunctionName,
|
||||
'serverlessFunction.manifest.jsonc',
|
||||
);
|
||||
|
||||
const manifest = await parseJsoncFile(manifestPath);
|
||||
|
||||
if (!manifest.triggers) {
|
||||
manifest.triggers = [];
|
||||
}
|
||||
|
||||
manifest.triggers.push(triggerData);
|
||||
|
||||
await writeJsoncFile(manifestPath, manifest);
|
||||
|
||||
console.log(
|
||||
chalk.green(`✅ Trigger added successfully to ${serverlessFunctionName}`),
|
||||
);
|
||||
}
|
||||
|
||||
private async createDatabaseEventTrigger() {
|
||||
const uuid = randomUUID();
|
||||
|
||||
const { eventName } = await inquirer.prompt<{ eventName: string }>([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'eventName',
|
||||
message:
|
||||
'Enter the database event name (e.g. company.created, *.updated, person.*):',
|
||||
validate: (input) => {
|
||||
if (input.length === 0) {
|
||||
return 'Event name is required';
|
||||
}
|
||||
if (!/^(?:[a-zA-Z]+|\*)\.(created|updated|deleted|\*)$/.test(input)) {
|
||||
return 'Event name must be in format: (objectName|*).(created|updated|deleted|*)';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
universalIdentifier: uuid,
|
||||
type: 'databaseEvent',
|
||||
eventName,
|
||||
};
|
||||
}
|
||||
|
||||
private async createCronTrigger() {
|
||||
const uuid = randomUUID();
|
||||
|
||||
const { schedule } = await inquirer.prompt<{ schedule: string }>([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'schedule',
|
||||
message: 'Enter the cron schedule (e.g., 0 9 * * * for daily at 9 AM):',
|
||||
validate: (input) => {
|
||||
if (input.length === 0) {
|
||||
return 'Schedule is required';
|
||||
}
|
||||
|
||||
const parts = input.trim().split(/\s+/);
|
||||
|
||||
if (parts.length < 5 || parts.length > 6) {
|
||||
return 'Cron schedule must have 5 or 6 fields (e.g., 0 9 * * *)';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
universalIdentifier: uuid,
|
||||
type: 'cron',
|
||||
schedule,
|
||||
};
|
||||
}
|
||||
|
||||
private async createRouteTrigger() {
|
||||
const uuid = randomUUID();
|
||||
|
||||
const { path } = await inquirer.prompt<{ path: string }>([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'path',
|
||||
message: 'Enter the route path (e.g., /webhook/company):',
|
||||
validate: (input) => {
|
||||
if (input.length === 0) {
|
||||
return 'Path is required';
|
||||
}
|
||||
if (!input.startsWith('/')) {
|
||||
return 'Path must start with /';
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const { httpMethod } = await inquirer.prompt<{ httpMethod: HTTPMethod }>([
|
||||
{
|
||||
type: 'list',
|
||||
name: 'httpMethod',
|
||||
message: 'Select the HTTP method:',
|
||||
choices: Object.values(HTTPMethod),
|
||||
default: HTTPMethod.GET,
|
||||
},
|
||||
]);
|
||||
|
||||
const { isAuthRequired } = await inquirer.prompt<{
|
||||
isAuthRequired: boolean;
|
||||
}>([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'isAuthRequired',
|
||||
message: 'Is authentication required?',
|
||||
default: true,
|
||||
},
|
||||
]);
|
||||
|
||||
return {
|
||||
universalIdentifier: uuid,
|
||||
type: 'route',
|
||||
path,
|
||||
httpMethod,
|
||||
isAuthRequired,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import inquirer from 'inquirer';
|
||||
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
|
||||
import { ApiService } from '../services/api.service';
|
||||
import { ApiResponse } from '../types/config.types';
|
||||
import { loadManifest } from '../utils/app-manifest-loader';
|
||||
import { loadManifest } from '../utils/load-manifest';
|
||||
|
||||
export class AppDeleteCommand {
|
||||
private apiService = new ApiService();
|
||||
@@ -25,9 +25,11 @@ export class AppDeleteCommand {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { packageJson } = await loadManifest(appPath);
|
||||
const { manifest } = await loadManifest(appPath);
|
||||
|
||||
const result = await this.apiService.deleteApplication(packageJson);
|
||||
const result = await this.apiService.deleteApplication(
|
||||
manifest.application.universalIdentifier,
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
console.error(chalk.red('❌ Deletion failed:'), result.error);
|
||||
|
||||
@@ -2,14 +2,17 @@ import chalk from 'chalk';
|
||||
import * as chokidar from 'chokidar';
|
||||
import { ApiService } from '../services/api.service';
|
||||
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
|
||||
import { loadManifest } from '../utils/app-manifest-loader';
|
||||
import { loadManifest } from '../utils/load-manifest';
|
||||
|
||||
export class AppDevCommand {
|
||||
private apiService = new ApiService();
|
||||
|
||||
async execute(options: { debounce: string }): Promise<void> {
|
||||
async execute(options: {
|
||||
appPath?: string;
|
||||
debounce: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const appPath = CURRENT_EXECUTION_DIRECTORY;
|
||||
const appPath = options.appPath ?? CURRENT_EXECUTION_DIRECTORY;
|
||||
|
||||
const debounceMs = parseInt(options.debounce, 10);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import kebabCase from 'lodash.kebabcase';
|
||||
export class AppInitCommand {
|
||||
async execute(directory?: string): Promise<void> {
|
||||
try {
|
||||
const { appName, appDirectory, appDescription } =
|
||||
const { appName, appDisplayName, appDirectory, appDescription } =
|
||||
await this.getAppInfos(directory);
|
||||
|
||||
await this.validateDirectory(appDirectory);
|
||||
@@ -19,6 +19,7 @@ export class AppInitCommand {
|
||||
|
||||
await copyBaseApplicationProject({
|
||||
appName,
|
||||
appDisplayName,
|
||||
appDescription,
|
||||
appDirectory,
|
||||
});
|
||||
@@ -35,19 +36,31 @@ export class AppInitCommand {
|
||||
|
||||
private async getAppInfos(directory?: string): Promise<{
|
||||
appName: string;
|
||||
appDirectory: string;
|
||||
appDisplayName: string;
|
||||
appDescription: string;
|
||||
appDirectory: string;
|
||||
}> {
|
||||
const { name, description } = await inquirer.prompt([
|
||||
const { name, displayName, description } = await inquirer.prompt([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'name',
|
||||
message: 'Application name (eg: My awesome application):',
|
||||
message: 'Application name (eg: my-awesome-app):',
|
||||
validate: (input) => {
|
||||
if (input.length === 0) return 'Application name is required';
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'displayName',
|
||||
message: 'Display name (eg: My awesome app):',
|
||||
default: (answers: any) => {
|
||||
return answers.name
|
||||
.split('-')
|
||||
.map((word: string) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'input',
|
||||
name: 'description',
|
||||
@@ -58,13 +71,15 @@ export class AppInitCommand {
|
||||
|
||||
const appName = name.trim();
|
||||
|
||||
const appDisplayName = displayName.trim();
|
||||
|
||||
const appDescription = description.trim();
|
||||
|
||||
const appDirectory = directory
|
||||
? path.join(process.cwd(), kebabCase(directory))
|
||||
: path.join(process.cwd(), kebabCase(appName));
|
||||
|
||||
return { appName, appDirectory, appDescription };
|
||||
return { appName, appDisplayName, appDirectory, appDescription };
|
||||
}
|
||||
|
||||
private async validateDirectory(appDirectory: string): Promise<void> {
|
||||
|
||||
@@ -2,7 +2,7 @@ import chalk from 'chalk';
|
||||
import { CURRENT_EXECUTION_DIRECTORY } from '../constants/current-execution-directory';
|
||||
import { ApiService } from '../services/api.service';
|
||||
import { ApiResponse } from '../types/config.types';
|
||||
import { loadManifest } from '../utils/app-manifest-loader';
|
||||
import { loadManifest } from '../utils/load-manifest';
|
||||
|
||||
export class AppSyncCommand {
|
||||
private apiService = new ApiService();
|
||||
|
||||
@@ -9,6 +9,7 @@ import { AppDeleteCommand } from './app-delete.command';
|
||||
import { AppDevCommand } from './app-dev.command';
|
||||
import { AppInitCommand } from './app-init.command';
|
||||
import { AppSyncCommand } from './app-sync.command';
|
||||
import { formatPath } from '../utils/format-path';
|
||||
|
||||
export class AppCommand {
|
||||
private devCommand = new AppDevCommand();
|
||||
@@ -22,19 +23,22 @@ export class AppCommand {
|
||||
appCommand.description('Application development commands');
|
||||
|
||||
appCommand
|
||||
.command('dev')
|
||||
.command('dev [appPath]')
|
||||
.description('Watch and sync local application changes')
|
||||
.option('-d, --debounce <ms>', 'Debounce delay in milliseconds', '1000')
|
||||
.action(async (options) => {
|
||||
await this.devCommand.execute(options);
|
||||
.action(async (appPath, options) => {
|
||||
await this.devCommand.execute({
|
||||
...options,
|
||||
appPath: formatPath(appPath),
|
||||
});
|
||||
});
|
||||
|
||||
appCommand
|
||||
.command('sync')
|
||||
.command('sync [appPath]')
|
||||
.description('Sync application to Twenty')
|
||||
.action(async () => {
|
||||
.action(async (appPath?: string) => {
|
||||
try {
|
||||
const result = await this.syncCommand.execute();
|
||||
const result = await this.syncCommand.execute(formatPath(appPath));
|
||||
if (!result.success) {
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -44,11 +48,12 @@ export class AppCommand {
|
||||
});
|
||||
|
||||
appCommand
|
||||
.command('delete')
|
||||
.command('delete [appPath]')
|
||||
.description('Delete application from Twenty')
|
||||
.action(async () => {
|
||||
.action(async (appPath?: string) => {
|
||||
try {
|
||||
const result = await this.deleteCommand.execute({
|
||||
appPath: formatPath(appPath),
|
||||
askForConfirmation: true,
|
||||
});
|
||||
if (!result.success) {
|
||||
|
||||
Reference in New Issue
Block a user