feat: enhance API and query handling with Redis caching; add QueryGuard for query validation; refactor services to utilize RedisClient for improved performance

This commit is contained in:
Boris D
2025-10-10 10:51:52 +03:00
parent 45db65cec8
commit ca134414b0
20 changed files with 228 additions and 64 deletions

View File

@ -10,6 +10,10 @@ export class ApiController {
@Post("token/generate") @Post("token/generate")
generateToken(@Body() body: { id: string }) { generateToken(@Body() body: { id: string }) {
if (!body.id) {
throw new Error("Project ID is required");
}
return this.apiService.generateToken(body.id); return this.apiService.generateToken(body.id);
} }

View File

@ -1,4 +1,4 @@
import { Module } from "@nestjs/common"; import { forwardRef, Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm"; import { TypeOrmModule } from "@nestjs/typeorm";
import { Token } from "./entities/token.entity"; import { Token } from "./entities/token.entity";
import { ProjectModule } from "../project/project.module"; import { ProjectModule } from "../project/project.module";
@ -6,11 +6,19 @@ import { ApiService } from "./api.service";
import { ApiController } from "./api.controller"; import { ApiController } from "./api.controller";
import { Project } from "../project/entities/project.entity"; import { Project } from "../project/entities/project.entity";
import { ApiTokenGuard } from "./guards/api-token.guard"; import { ApiTokenGuard } from "./guards/api-token.guard";
import { RedisModule } from "src/redis/redis.module";
import { QueryGuard } from "./guards/query.guard";
import { QueryModule } from "src/query/query.module";
@Module({ @Module({
imports: [ProjectModule, TypeOrmModule.forFeature([Token, Project])], imports: [
forwardRef(() => RedisModule),
forwardRef(() => QueryModule),
ProjectModule,
TypeOrmModule.forFeature([Token, Project]),
],
controllers: [ApiController], controllers: [ApiController],
providers: [ApiService, ApiTokenGuard], providers: [ApiService, ApiTokenGuard, QueryGuard],
exports: [ApiTokenGuard, TypeOrmModule], exports: [ApiTokenGuard, ApiService, TypeOrmModule],
}) })
export class ApiModule {} export class ApiModule {}

View File

@ -1,15 +1,17 @@
import { Injectable } from "@nestjs/common"; import { Inject, Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm"; import { InjectRepository } from "@nestjs/typeorm";
import { Token } from "./entities/token.entity"; import { Token } from "./entities/token.entity";
import { Repository } from "typeorm"; import { Repository } from "typeorm";
import { Project } from "../project/entities/project.entity"; import { Project } from "../project/entities/project.entity";
import { RedisClient } from "src/redis/redis.service";
@Injectable() @Injectable()
export class ApiService { export class ApiService {
constructor( constructor(
@InjectRepository(Token) @InjectRepository(Token)
private readonly tokenRepository: Repository<Token>, private readonly tokenRepository: Repository<Token>,
@Inject(RedisClient)
private readonly redisClient: RedisClient,
@InjectRepository(Project) @InjectRepository(Project)
private readonly projectRepository: Repository<Project> private readonly projectRepository: Repository<Project>
) {} ) {}
@ -27,6 +29,25 @@ export class ApiService {
return this.tokenRepository.save(token); return this.tokenRepository.save(token);
} }
async getTokenDetails(tokenString: string) {
const cached = await this.redisClient.get(`token_${tokenString}`);
if (cached) {
return cached;
}
const token = await this.tokenRepository.findOne({
where: { token: tokenString },
relations: ["project"],
});
if (token) {
await this.redisClient.set(`token_${token.token}`, token, 300);
}
return token;
}
async revokeToken(tokenString: string) { async revokeToken(tokenString: string) {
const token = await this.tokenRepository.findOne({ const token = await this.tokenRepository.findOne({
where: { token: tokenString }, where: { token: tokenString },
@ -36,6 +57,8 @@ export class ApiService {
throw new Error("Token not found"); throw new Error("Token not found");
} }
await this.redisClient.del(`token_${tokenString}`);
token.isActive = false; token.isActive = false;
return this.tokenRepository.save(token); return this.tokenRepository.save(token);
} }

View File

@ -6,7 +6,7 @@ export class Token {
@PrimaryGeneratedColumn("uuid") @PrimaryGeneratedColumn("uuid")
token: string; token: string;
@Column({ type: "tinyint", default: 0 }) @Column({ type: "tinyint", default: 1 })
isActive: boolean; isActive: boolean;
@ManyToOne(() => Project, (project) => project.apiTokens) @ManyToOne(() => Project, (project) => project.apiTokens)

View File

@ -1,34 +1,28 @@
import { import {
CanActivate, CanActivate,
ExecutionContext, ExecutionContext,
Inject,
Injectable, Injectable,
UnauthorizedException, UnauthorizedException,
} from "@nestjs/common"; } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm"; import { ApiService } from "../api.service";
import { Repository } from "typeorm";
import { Token } from "../entities/token.entity";
@Injectable() @Injectable()
export class ApiTokenGuard implements CanActivate { export class ApiTokenGuard implements CanActivate {
constructor( constructor(
@InjectRepository(Token) @Inject(ApiService)
private readonly tokenRepository: Repository<Token> private readonly apiService: ApiService
) {} ) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
return true;
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const token = request.params?.token || request.headers?.["x-api-token"]; const token = request.headers?.["x-api-token"];
if (!token) { if (!token) {
throw new UnauthorizedException("API token is required"); throw new UnauthorizedException("API token is required");
} }
const tokenEntity = await this.tokenRepository.findOne({ const tokenEntity = await this.apiService.getTokenDetails(token);
where: { token },
relations: ["project"],
});
if (!tokenEntity) { if (!tokenEntity) {
throw new UnauthorizedException("Invalid API token"); throw new UnauthorizedException("Invalid API token");
@ -38,6 +32,8 @@ export class ApiTokenGuard implements CanActivate {
throw new UnauthorizedException("API token is inactive"); throw new UnauthorizedException("API token is inactive");
} }
request.apiToken = tokenEntity;
return true; return true;
} }
} }

View File

@ -0,0 +1,49 @@
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { QueryHandlerService } from "src/query/handler/query.handler.service";
@Injectable()
export class QueryGuard implements CanActivate {
constructor(
@Inject(QueryHandlerService)
private readonly queryHandlerService: QueryHandlerService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const apiToken = request.apiToken;
if (!apiToken || !apiToken.project) {
throw new UnauthorizedException("Project not found for the API token");
}
const queryId = request.params?.id;
if (!queryId) {
throw new UnauthorizedException("Query ID is required");
}
const query = await this.queryHandlerService.getQueryById(queryId);
if (!query) {
throw new UnauthorizedException("Query not found");
}
if (!query.isActive) {
throw new UnauthorizedException("Query is inactive");
}
if (query.project.id !== apiToken.project.id) {
throw new UnauthorizedException("You do not have access to this query");
}
request.query = query;
return true;
}
}

View File

@ -9,12 +9,14 @@ import { DatabaseManagerController } from "./database/database.manager.controlle
import { DatabaseManagerService } from "./database/database.manager.service"; import { DatabaseManagerService } from "./database/database.manager.service";
import { DatabaseNodeService } from "./databaseNode/database.node.service"; import { DatabaseNodeService } from "./databaseNode/database.node.service";
import { ApiModule } from "src/api/api.module"; import { ApiModule } from "src/api/api.module";
import { RedisModule } from "src/redis/redis.module";
@Module({ @Module({
imports: [ imports: [
forwardRef(() => ProjectModule), forwardRef(() => ProjectModule),
forwardRef(() => MigrationModule), forwardRef(() => MigrationModule),
forwardRef(() => ApiModule), forwardRef(() => ApiModule),
forwardRef(() => RedisModule),
TypeOrmModule.forFeature([Database, DatabaseNode, Project]), TypeOrmModule.forFeature([Database, DatabaseNode, Project]),
], ],
controllers: [DatabaseManagerController], controllers: [DatabaseManagerController],

View File

@ -1,4 +1,4 @@
import { Injectable } from "@nestjs/common"; import { Inject, Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm"; import { InjectRepository } from "@nestjs/typeorm";
import { Database } from "../entities/database.entity"; import { Database } from "../entities/database.entity";
import { Repository } from "typeorm"; import { Repository } from "typeorm";
@ -6,6 +6,7 @@ import { ProjectService } from "src/project/project.service";
import { DatabaseEncryptionService } from "../database.encryption.service"; import { DatabaseEncryptionService } from "../database.encryption.service";
import { DatabaseNodeService } from "../databaseNode/database.node.service"; import { DatabaseNodeService } from "../databaseNode/database.node.service";
import * as mysql from "mysql2/promise"; import * as mysql from "mysql2/promise";
import { RedisClient } from "src/redis/redis.service";
@Injectable() @Injectable()
export class DatabaseManagerService extends DatabaseEncryptionService { export class DatabaseManagerService extends DatabaseEncryptionService {
@ -13,7 +14,9 @@ export class DatabaseManagerService extends DatabaseEncryptionService {
@InjectRepository(Database) @InjectRepository(Database)
private databaseRepository: Repository<Database>, private databaseRepository: Repository<Database>,
private readonly projectService: ProjectService, private readonly projectService: ProjectService,
private readonly databaseNodeService: DatabaseNodeService private readonly databaseNodeService: DatabaseNodeService,
@Inject(RedisClient)
private readonly redisClient: RedisClient
) { ) {
super(); super();
} }
@ -64,6 +67,14 @@ export class DatabaseManagerService extends DatabaseEncryptionService {
throw new Error("Project not found"); throw new Error("Project not found");
} }
const cached = await this.redisClient.get(
`db_conn_opts_${projectId}_${queryUser}`
);
if (cached) {
return cached;
}
const database = await this.databaseRepository.findOne({ const database = await this.databaseRepository.findOne({
where: { project: { id: project.id } }, where: { project: { id: project.id } },
relations: ["node"], relations: ["node"],
@ -73,7 +84,7 @@ export class DatabaseManagerService extends DatabaseEncryptionService {
throw new Error("Database not found"); throw new Error("Database not found");
} }
return { const connectionOptions = {
host: database.node.host, host: database.node.host,
port: database.node.port, port: database.node.port,
user: queryUser ? database.q_username : database.c_username, user: queryUser ? database.q_username : database.c_username,
@ -82,6 +93,14 @@ export class DatabaseManagerService extends DatabaseEncryptionService {
idleTimeout: 150e3, idleTimeout: 150e3,
connectTimeout: 2e3, connectTimeout: 2e3,
}; };
await this.redisClient.set(
`db_conn_opts_${projectId}_${queryUser}`,
connectionOptions,
300
);
return connectionOptions;
} }
async createDatabase(projectId: string): Promise<Database> { async createDatabase(projectId: string): Promise<Database> {

View File

@ -4,9 +4,14 @@ import { Project } from "./entities/project.entity";
import { ProjectService } from "./project.service"; import { ProjectService } from "./project.service";
import { ProjectController } from "./project.controller"; import { ProjectController } from "./project.controller";
import { ApiModule } from "src/api/api.module"; import { ApiModule } from "src/api/api.module";
import { RedisModule } from "src/redis/redis.module";
@Module({ @Module({
imports: [forwardRef(() => ApiModule), TypeOrmModule.forFeature([Project])], imports: [
forwardRef(() => ApiModule),
forwardRef(() => RedisModule),
TypeOrmModule.forFeature([Project]),
],
controllers: [ProjectController], controllers: [ProjectController],
providers: [ProjectService], providers: [ProjectService],
exports: [ProjectService], exports: [ProjectService],

View File

@ -1,13 +1,16 @@
import { Injectable } from "@nestjs/common"; import { Inject, Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm"; import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm"; import { Repository } from "typeorm";
import { Project } from "./entities/project.entity"; import { Project } from "./entities/project.entity";
import { RedisClient } from "src/redis/redis.service";
@Injectable() @Injectable()
export class ProjectService { export class ProjectService {
constructor( constructor(
@InjectRepository(Project) @InjectRepository(Project)
private readonly projectRepository: Repository<Project> private readonly projectRepository: Repository<Project>,
@Inject(RedisClient)
private readonly redisClient: RedisClient
) {} ) {}
create(name: string) { create(name: string) {
@ -15,17 +18,33 @@ export class ProjectService {
return this.projectRepository.save(project); return this.projectRepository.save(project);
} }
findById(id: string) { async findById(id: string) {
return this.projectRepository.findOne({ where: { id: id } }); const cached = await this.redisClient.get(`project_${id}`);
if (cached) {
return cached;
}
const project = await this.projectRepository.findOne({ where: { id: id } });
if (project) {
await this.redisClient.set(`project_${id}`, project, 300);
}
return project;
} }
updateDatabase(projectId: string, databaseId: string) { async updateDatabase(projectId: string, databaseId: string) {
await this.redisClient.del(`project_${projectId}`);
return this.projectRepository.update(projectId, { return this.projectRepository.update(projectId, {
database: { id: databaseId }, database: { id: databaseId },
}); });
} }
updateRedisNode(projectId: string, redisNodeId: { id: string }[]) { async updateRedisNode(projectId: string, redisNodeId: { id: string }[]) {
await this.redisClient.del(`project_${projectId}`);
return this.projectRepository.update(projectId, { return this.projectRepository.update(projectId, {
redisNodes: redisNodeId, redisNodes: redisNodeId,
}); });

View File

@ -11,6 +11,7 @@ import { Response } from "express";
import { QueryHandlerService } from "../handler/query.handler.service"; import { QueryHandlerService } from "../handler/query.handler.service";
import { ApiTokenGuard } from "src/api/guards/api-token.guard"; import { ApiTokenGuard } from "src/api/guards/api-token.guard";
import { QueryExecuterService } from "../executer/query.executer.service"; import { QueryExecuterService } from "../executer/query.executer.service";
import { QueryGuard } from "src/api/guards/query.guard";
@UseGuards(ApiTokenGuard) @UseGuards(ApiTokenGuard)
export abstract class BaseQueryController { export abstract class BaseQueryController {
@ -31,22 +32,24 @@ export abstract class BaseQueryController {
} }
@Post("update/:id") @Post("update/:id")
@UseGuards(QueryGuard)
async updateQuery( async updateQuery(
@Body() updateData: Partial<{ source: string }>, @Body() updateData: Partial<{ source: string }>,
@Inject("id") id: string @Param("id") id: string
) { ) {
return this.queryHandlerService.updateQuery(id, updateData); return this.queryHandlerService.updateQuery(id, updateData);
} }
@Post("/run/:token") @Post("/run/:id")
@UseGuards(QueryGuard)
async runQuery( async runQuery(
@Param("token") token: string, @Param("id") id: string,
@Body() query: Record<string, any>, @Body() query: Record<string, any>,
@Headers() headers: Record<string, any>, @Headers() headers: Record<string, any>,
@Res() res: Response @Res() res: Response
) { ) {
const queryResult = await this.queryExecuterService.runQueryQueued( const queryResult = await this.queryExecuterService.runQueryQueued(
token, id,
query, query,
headers headers
); );

View File

@ -14,7 +14,7 @@ import { DatabaseManagerService } from "src/databaseManager/database/database.ma
import { InjectQueue } from "@nestjs/bullmq"; import { InjectQueue } from "@nestjs/bullmq";
import { QUEUE_NAMES } from "src/queue/constants"; import { QUEUE_NAMES } from "src/queue/constants";
import { Queue, QueueEvents } from "bullmq"; import { Queue, QueueEvents } from "bullmq";
import { FunctionService } from "src/project/function/function.service"; import { FunctionService } from "src/query/function/function.service";
@Injectable() @Injectable()
export class QueryExecuterService { export class QueryExecuterService {

View File

@ -2,7 +2,7 @@ import { Inject, Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm"; import { InjectRepository } from "@nestjs/typeorm";
import { FunctionEntity } from "src/query/entities/function.entity"; import { FunctionEntity } from "src/query/entities/function.entity";
import { In, Repository } from "typeorm"; import { In, Repository } from "typeorm";
import { ProjectService } from "../project.service"; import { ProjectService } from "../../project/project.service";
@Injectable() @Injectable()
export class FunctionService { export class FunctionService {

View File

@ -3,6 +3,7 @@ import { Repository } from "typeorm";
import { Query } from "../entities/query.entity"; import { Query } from "../entities/query.entity";
import { InjectRepository } from "@nestjs/typeorm"; import { InjectRepository } from "@nestjs/typeorm";
import { ProjectService } from "src/project/project.service"; import { ProjectService } from "src/project/project.service";
import { RedisClient } from "src/redis/redis.service";
@Injectable() @Injectable()
export class QueryHandlerService { export class QueryHandlerService {
@ -10,7 +11,9 @@ export class QueryHandlerService {
@InjectRepository(Query) @InjectRepository(Query)
private readonly queryRepository: Repository<Query>, private readonly queryRepository: Repository<Query>,
@Inject(ProjectService) @Inject(ProjectService)
private readonly projectService: ProjectService private readonly projectService: ProjectService,
@Inject(RedisClient)
private readonly redisClient: RedisClient
) {} ) {}
async createQuery( async createQuery(
@ -34,6 +37,25 @@ export class QueryHandlerService {
return query; return query;
} }
async getQueryById(id: string) {
const cached = await this.redisClient.get(`query_${id}`);
if (cached) {
return cached;
}
const query = await this.queryRepository.findOne({
where: { id },
relations: ["project"],
});
if (query) {
await this.redisClient.set(`query_${id}`, query, 300);
}
return query;
}
async updateQuery(id: string, updateData: Partial<Query>) { async updateQuery(id: string, updateData: Partial<Query>) {
const query = await this.queryRepository.findOne({ where: { id } }); const query = await this.queryRepository.findOne({ where: { id } });
@ -41,6 +63,8 @@ export class QueryHandlerService {
throw new Error("Query not found"); throw new Error("Query not found");
} }
await this.redisClient.del(`query_${id}`);
Object.assign(query, updateData); Object.assign(query, updateData);
return this.queryRepository.save(query); return this.queryRepository.save(query);
} }

View File

@ -10,9 +10,10 @@ import { CommandController } from "./command/command.controller";
import { ApiModule } from "src/api/api.module"; import { ApiModule } from "src/api/api.module";
import { QueueModule } from "src/queue/queue.module"; import { QueueModule } from "src/queue/queue.module";
import { FunctionEntity } from "./entities/function.entity"; import { FunctionEntity } from "./entities/function.entity";
import { FunctionService } from "src/project/function/function.service"; import { FunctionService } from "src/query/function/function.service";
import { FunctionController } from "src/project/function/function.controller"; import { FunctionController } from "src/query/function/function.controller";
import { RedisManagerModule } from "src/redisManager/redisManager.module"; import { RedisManagerModule } from "src/redisManager/redisManager.module";
import { RedisModule } from "src/redis/redis.module";
@Module({ @Module({
imports: [ imports: [
@ -20,11 +21,12 @@ import { RedisManagerModule } from "src/redisManager/redisManager.module";
forwardRef(() => DatabaseManagerModule), forwardRef(() => DatabaseManagerModule),
forwardRef(() => ApiModule), forwardRef(() => ApiModule),
forwardRef(() => QueueModule), forwardRef(() => QueueModule),
forwardRef(() => RedisModule),
forwardRef(() => RedisManagerModule), forwardRef(() => RedisManagerModule),
TypeOrmModule.forFeature([Query, FunctionEntity]), TypeOrmModule.forFeature([Query, FunctionEntity]),
], ],
controllers: [QueryController, CommandController, FunctionController], controllers: [QueryController, CommandController, FunctionController],
providers: [QueryExecuterService, QueryHandlerService, FunctionService], providers: [QueryExecuterService, QueryHandlerService, FunctionService],
exports: [QueryExecuterService, TypeOrmModule], exports: [QueryExecuterService, TypeOrmModule, QueryHandlerService],
}) })
export class QueryModule {} export class QueryModule {}

View File

@ -12,7 +12,7 @@ export class RedisClient {
async set( async set(
key: string, key: string,
value: string, value: any,
expireInSeconds?: number expireInSeconds?: number
): Promise<"OK" | null> { ): Promise<"OK" | null> {
if (!this.redis) { if (!this.redis) {
@ -20,18 +20,23 @@ export class RedisClient {
} }
if (expireInSeconds) { if (expireInSeconds) {
return await this.redis.set(key, value, "EX", expireInSeconds); return await this.redis.set(
key,
JSON.stringify(value),
"EX",
expireInSeconds
);
} }
return await this.redis.set(key, value); return await this.redis.set(key, JSON.stringify(value));
} }
async get(key: string): Promise<string | null> { async get(key: string): Promise<any | null> {
if (!this.redis) { if (!this.redis) {
return null; return null;
} }
return await this.redis.get(key); return JSON.parse(await this.redis.get(key));
} }
async del(key: string): Promise<number | null> { async del(key: string): Promise<number | null> {

View File

@ -10,33 +10,33 @@ import * as path from "path";
(async () => { (async () => {
try { try {
const node = await createDatabaseNode("localhost", 3306, "root", "root"); // const node = await createDatabaseNode("localhost", 3306, "root", "root");
console.log("Database node created:", node); // console.log("Database node created:", node);
const project = await createProject("Test Project"); // const project = await createProject("Test Project");
console.log("Project created:", project); // console.log("Project created:", project);
const db = await createDatabase(project.id, node.id); // const db = await createDatabase(project.id, node.id);
console.log("Database created:", db); // console.log("Database created:", db);
const migration = await createMigration( // const migration = await createMigration(
db.id, // db.id,
"CREATE TABLE `test` (id INT AUTO_INCREMENT PRIMARY KEY, col1 VARCHAR(255))", // "CREATE TABLE `test` (id INT AUTO_INCREMENT PRIMARY KEY, col1 VARCHAR(255))",
"DROP TABLE `test`" // "DROP TABLE `test`"
); // );
console.log("Migration created:", migration); // console.log("Migration created:", migration);
const migrationResult = await databaseMigrationUp(db.id); // const migrationResult = await databaseMigrationUp(db.id);
console.log("Migrations applied:", migrationResult); // console.log("Migrations applied:", migrationResult);
const payloadPath = path.join(__dirname, "case1-payload.js"); const payloadPath = path.join(__dirname, "case1-payload.js");
const query = await createQuery( const query = await createQuery(
project, { token: "04c38f93-f2fb-4d2c-a8e2-791effa35239" },
fs.readFileSync(payloadPath, { encoding: "utf-8" }) fs.readFileSync(payloadPath, { encoding: "utf-8" })
); );

View File

@ -3,10 +3,14 @@ import { config } from "../config";
export default async (project: { token: string }, source: string) => { export default async (project: { token: string }, source: string) => {
try { try {
const response = await axios.post(`${config.url}/query/create`, { const response = await axios.post(
source, `${config.url}/query/create`,
projectToken: project.token, {
}); source,
projectToken: project.token,
},
{ headers: { "x-api-token": "efbeccd6-dde1-47dc-b3aa-4fbd773d5429" } }
);
return response.data; return response.data;
} catch (error) { } catch (error) {
console.error("Error creating query:", error); console.error("Error creating query:", error);

View File

@ -5,7 +5,8 @@ export default async (token: string, queryData: Record<string, any>) => {
try { try {
const response = await axios.post( const response = await axios.post(
`${config.url}/query/run/${token}`, `${config.url}/query/run/${token}`,
queryData queryData,
{ headers: { "x-api-token": "efbeccd6-dde1-47dc-b3aa-4fbd773d5429" } }
); );
return response; return response;