14066 extensibility add coretriggerroute table (#14241)

This Pr 
- adds a `route` table to the core schema
- adds a controller to trigger route

For now, we need to add a `/s/<workspace_id>` prefix to all routes

We plan to create a custom domain table in order to let the users create
subdomains for their workspace, and so to link their routes to a
subdomain. Thank to that, we will be able to identify workspace_id from
domain name, and the prefix could be `/s`. If we create a native
dedicated subdomain for routes for each workspaces, the prefix could be
completely removed!

Here the follow up ticket to do that ->
https://github.com/twentyhq/twenty/issues/14240
This commit is contained in:
martmull
2025-09-02 11:24:59 +02:00
committed by GitHub
parent bfad5b0477
commit a722d97724
12 changed files with 367 additions and 8 deletions
@@ -10,6 +10,7 @@ import { RoleModule } from 'src/engine/metadata-modules/role/role.module';
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-migration/workspace-migration.module';
import { RouteModule } from 'src/engine/metadata-modules/route/route.module';
@Module({
imports: [
@@ -23,6 +24,7 @@ import { WorkspaceMigrationModule } from 'src/engine/metadata-modules/workspace-
RemoteServerModule,
RoleModule,
PermissionsModule,
RouteModule,
],
providers: [],
exports: [
@@ -0,0 +1,86 @@
import {
Controller,
Delete,
Get,
Param,
Patch,
Post,
Put,
Req,
UseFilters,
UseGuards,
} from '@nestjs/common';
import { Request } from 'express';
import { PublicEndpointGuard } from 'src/engine/guards/public-endpoint.guard';
import { RouteService } from 'src/engine/metadata-modules/route/route.service';
import { HTTPMethod } from 'src/engine/metadata-modules/route/route.entity';
import { RestApiExceptionFilter } from 'src/engine/api/rest/rest-api-exception.filter';
@Controller('s/:workspaceId')
@UseGuards(PublicEndpointGuard)
@UseFilters(RestApiExceptionFilter)
export class RouteController {
constructor(private readonly routeService: RouteService) {}
@Get('*')
async get(
@Param('workspaceId') workspaceId: string,
@Req() request: Request,
) {
return await this.routeService.handle({
workspaceId,
request,
httpMethod: HTTPMethod.GET,
});
}
@Post('*')
async post(
@Param('workspaceId') workspaceId: string,
@Req() request: Request,
) {
return await this.routeService.handle({
workspaceId,
request,
httpMethod: HTTPMethod.POST,
});
}
@Put('*')
async put(
@Param('workspaceId') workspaceId: string,
@Req() request: Request,
) {
return await this.routeService.handle({
workspaceId,
request,
httpMethod: HTTPMethod.PUT,
});
}
@Patch('*')
async patch(
@Param('workspaceId') workspaceId: string,
@Req() request: Request,
) {
return await this.routeService.handle({
workspaceId,
request,
httpMethod: HTTPMethod.PATCH,
});
}
@Delete('*')
async delete(
@Param('workspaceId') workspaceId: string,
@Req() request: Request,
) {
return await this.routeService.handle({
workspaceId,
request,
httpMethod: HTTPMethod.DELETE,
});
}
}
@@ -0,0 +1,65 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
Relation,
UpdateDateColumn,
Unique,
} from 'typeorm';
import { SyncableEntity } from 'src/engine/workspace-manager/workspace-sync/interfaces/syncable-entity.interface';
import { ServerlessFunctionEntity } from 'src/engine/metadata-modules/serverless-function/serverless-function.entity';
export enum HTTPMethod {
GET = 'GET',
POST = 'POST',
PUT = 'PUT',
PATCH = 'PATCH',
DELETE = 'DELETE',
}
@Entity({ name: 'route', schema: 'core' })
@Unique('IDX_ROUTE_PATH_HTTP_METHOD_WORKSPACE_ID_UNIQUE', [
'path',
'httpMethod',
'workspaceId',
])
export class Route extends SyncableEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false })
path: string;
@Column({ nullable: false, default: true })
isAuthRequired: boolean;
@Column({
type: 'enum',
enum: HTTPMethod,
default: HTTPMethod.GET,
nullable: false,
})
httpMethod: HTTPMethod;
@ManyToOne(
() => ServerlessFunctionEntity,
(serverlessFunction) => serverlessFunction.routes,
{ onDelete: 'CASCADE' },
)
@JoinColumn({ name: 'serverlessFunctionId' })
serverlessFunction: Relation<ServerlessFunctionEntity>;
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -0,0 +1,21 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { RouteService } from 'src/engine/metadata-modules/route/route.service';
import { Route } from 'src/engine/metadata-modules/route/route.entity';
import { RouteController } from 'src/engine/metadata-modules/route/route.controller';
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { DomainManagerModule } from 'src/engine/core-modules/domain-manager/domain-manager.module';
import { ServerlessFunctionModule } from 'src/engine/metadata-modules/serverless-function/serverless-function.module';
@Module({
imports: [
TypeOrmModule.forFeature([Route]),
AuthModule,
DomainManagerModule,
ServerlessFunctionModule,
],
controllers: [RouteController],
providers: [RouteService],
})
export class RouteModule {}
@@ -0,0 +1,124 @@
import {
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Request } from 'express';
import { match } from 'path-to-regexp';
import { isDefined } from 'twenty-shared/utils';
import {
HTTPMethod,
Route,
} from 'src/engine/metadata-modules/route/route.entity';
import { AccessTokenService } from 'src/engine/core-modules/auth/token/services/access-token.service';
import { ServerlessFunctionService } from 'src/engine/metadata-modules/serverless-function/serverless-function.service';
@Injectable()
export class RouteService {
constructor(
private readonly accessTokenService: AccessTokenService,
private readonly serverlessFunctionService: ServerlessFunctionService,
@InjectRepository(Route)
private readonly routeRepository: Repository<Route>,
) {}
private async getOneRouteWithPathParamsOrFail({
workspaceId,
request,
httpMethod,
}: {
workspaceId: string;
request: Request;
httpMethod: HTTPMethod;
}): Promise<{
route: Route;
pathParams: Partial<Record<string, string | string[]>>;
}> {
const routes = await this.routeRepository.find({
where: {
httpMethod,
workspaceId,
},
relations: ['serverlessFunction'],
});
const requestPath = request.path.replace(`/s/${workspaceId}/`, '');
for (const route of routes) {
const routeMatcher = match(route.path, { decode: decodeURIComponent });
const routeMatched = routeMatcher(requestPath);
if (routeMatched) {
return {
route,
pathParams: routeMatched.params,
};
}
}
throw new NotFoundException('No Route found');
}
private async validateWorkspaceFromRequest({
request,
workspaceId,
}: {
request: Request;
workspaceId: string;
}) {
const { workspace } =
await this.accessTokenService.validateTokenByRequest(request);
if (!isDefined(workspace)) {
throw new NotFoundException('Workspace not found');
}
if (workspace.id !== workspaceId) {
throw new ForbiddenException('Invalid Workspace');
}
}
async handle({
workspaceId,
request,
httpMethod,
}: {
workspaceId: string;
request: Request;
httpMethod: HTTPMethod;
}) {
const routeWithPathParams = await this.getOneRouteWithPathParamsOrFail({
workspaceId,
request,
httpMethod,
});
if (routeWithPathParams.route.isAuthRequired) {
await this.validateWorkspaceFromRequest({
request,
workspaceId: routeWithPathParams.route.workspaceId,
});
}
const queryParams = request.query;
const bodyParams = request.body;
const executionParams = {
...queryParams,
...bodyParams,
...routeWithPathParams.pathParams,
};
return await this.serverlessFunctionService.executeOneServerlessFunction(
routeWithPathParams.route.serverlessFunction.id,
routeWithPathParams.route.workspaceId,
executionParams,
'draft',
);
}
}
@@ -13,6 +13,7 @@ import {
import { InputSchema } from 'src/modules/workflow/workflow-builder/workflow-schema/types/input-schema.type';
import { CronTrigger } from 'src/engine/metadata-modules/trigger/entities/cron-trigger.entity';
import { DatabaseEventTrigger } from 'src/engine/metadata-modules/trigger/entities/database-event-trigger.entity';
import { Route } from 'src/engine/metadata-modules/route/route.entity';
const DEFAULT_SERVERLESS_TIMEOUT_SECONDS = 300; // 5 minutes
@@ -73,6 +74,11 @@ export class ServerlessFunctionEntity {
)
databaseEventTriggers: DatabaseEventTrigger[];
@OneToMany(() => Route, (route) => route.serverlessFunction, {
cascade: true,
})
routes: Route[];
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@@ -2,7 +2,6 @@ import {
Column,
CreateDateColumn,
Entity,
DeleteDateColumn,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
@@ -44,7 +43,4 @@ export class CronTrigger extends SyncableEntity {
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date;
}
@@ -2,7 +2,6 @@ import {
Column,
CreateDateColumn,
Entity,
DeleteDateColumn,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
@@ -44,7 +43,4 @@ export class DatabaseEventTrigger extends SyncableEntity {
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date;
}