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:
@@ -159,6 +159,7 @@
|
||||
"passport-google-oauth20": "2.0.0",
|
||||
"passport-jwt": "4.0.1",
|
||||
"passport-microsoft": "2.1.0",
|
||||
"path-to-regexp": "^8.2.0",
|
||||
"pg": "8.12.0",
|
||||
"pg-boss": "9.0.3",
|
||||
"planer": "1.2.0",
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { type MigrationInterface, type QueryRunner } from 'typeorm';
|
||||
|
||||
export class RemoveUselessDeletedAt1756476065711 implements MigrationInterface {
|
||||
name = 'RemoveUselessDeletedAt1756476065711';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."cronTrigger" DROP COLUMN "deletedAt"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."databaseEventTrigger" DROP COLUMN "deletedAt"`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."databaseEventTrigger" ADD "deletedAt" TIMESTAMP WITH TIME ZONE`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."cronTrigger" ADD "deletedAt" TIMESTAMP WITH TIME ZONE`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { type MigrationInterface, type QueryRunner } from 'typeorm';
|
||||
|
||||
export class AddRouteEntity1756803679889 implements MigrationInterface {
|
||||
name = 'AddRouteEntity1756803679889';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TYPE "core"."route_httpmethod_enum" AS ENUM('GET', 'POST', 'PUT', 'PATCH', 'DELETE')`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "core"."route" ("universalIdentifier" uuid, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "path" character varying NOT NULL, "isAuthRequired" boolean NOT NULL DEFAULT true, "httpMethod" "core"."route_httpmethod_enum" NOT NULL DEFAULT 'GET', "workspaceId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "serverlessFunctionId" uuid, CONSTRAINT "IDX_ROUTE_PATH_HTTP_METHOD_WORKSPACE_ID_UNIQUE" UNIQUE ("path", "httpMethod", "workspaceId"), CONSTRAINT "PK_08affcd076e46415e5821acf52d" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_1c39502392dd9cbb186deba158" ON "core"."route" ("workspaceId", "universalIdentifier") `,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."route" ADD CONSTRAINT "FK_c63b1110bbf09051be2f495d0be" FOREIGN KEY ("serverlessFunctionId") REFERENCES "core"."serverlessFunction"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "core"."route" DROP CONSTRAINT "FK_c63b1110bbf09051be2f495d0be"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DROP INDEX "core"."IDX_1c39502392dd9cbb186deba158"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "core"."route"`);
|
||||
await queryRunner.query(`DROP TYPE "core"."route_httpmethod_enum"`);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
+6
@@ -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;
|
||||
|
||||
|
||||
-4
@@ -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;
|
||||
}
|
||||
|
||||
-4
@@ -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;
|
||||
}
|
||||
|
||||
@@ -43930,6 +43930,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"path-to-regexp@npm:^8.2.0":
|
||||
version: 8.2.0
|
||||
resolution: "path-to-regexp@npm:8.2.0"
|
||||
checksum: 10c0/ef7d0a887b603c0a142fad16ccebdcdc42910f0b14830517c724466ad676107476bba2fe9fffd28fd4c141391ccd42ea426f32bb44c2c82ecaefe10c37b90f5a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"path-type@npm:^4.0.0":
|
||||
version: 4.0.0
|
||||
resolution: "path-type@npm:4.0.0"
|
||||
@@ -51212,6 +51219,7 @@ __metadata:
|
||||
passport-google-oauth20: "npm:2.0.0"
|
||||
passport-jwt: "npm:4.0.1"
|
||||
passport-microsoft: "npm:2.1.0"
|
||||
path-to-regexp: "npm:^8.2.0"
|
||||
pg: "npm:8.12.0"
|
||||
pg-boss: "npm:9.0.3"
|
||||
planer: "npm:1.2.0"
|
||||
|
||||
Reference in New Issue
Block a user