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")
generateToken(@Body() body: { id: string }) {
if (!body.id) {
throw new Error("Project ID is required");
}
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 { Token } from "./entities/token.entity";
import { ProjectModule } from "../project/project.module";
@ -6,11 +6,19 @@ import { ApiService } from "./api.service";
import { ApiController } from "./api.controller";
import { Project } from "../project/entities/project.entity";
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({
imports: [ProjectModule, TypeOrmModule.forFeature([Token, Project])],
imports: [
forwardRef(() => RedisModule),
forwardRef(() => QueryModule),
ProjectModule,
TypeOrmModule.forFeature([Token, Project]),
],
controllers: [ApiController],
providers: [ApiService, ApiTokenGuard],
exports: [ApiTokenGuard, TypeOrmModule],
providers: [ApiService, ApiTokenGuard, QueryGuard],
exports: [ApiTokenGuard, ApiService, TypeOrmModule],
})
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 { Token } from "./entities/token.entity";
import { Repository } from "typeorm";
import { Project } from "../project/entities/project.entity";
import { RedisClient } from "src/redis/redis.service";
@Injectable()
export class ApiService {
constructor(
@InjectRepository(Token)
private readonly tokenRepository: Repository<Token>,
@Inject(RedisClient)
private readonly redisClient: RedisClient,
@InjectRepository(Project)
private readonly projectRepository: Repository<Project>
) {}
@ -27,6 +29,25 @@ export class ApiService {
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) {
const token = await this.tokenRepository.findOne({
where: { token: tokenString },
@ -36,6 +57,8 @@ export class ApiService {
throw new Error("Token not found");
}
await this.redisClient.del(`token_${tokenString}`);
token.isActive = false;
return this.tokenRepository.save(token);
}

View File

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

View File

@ -1,34 +1,28 @@
import {
CanActivate,
ExecutionContext,
Inject,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { Token } from "../entities/token.entity";
import { ApiService } from "../api.service";
@Injectable()
export class ApiTokenGuard implements CanActivate {
constructor(
@InjectRepository(Token)
private readonly tokenRepository: Repository<Token>
@Inject(ApiService)
private readonly apiService: ApiService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
return true;
const request = context.switchToHttp().getRequest();
const token = request.params?.token || request.headers?.["x-api-token"];
const token = request.headers?.["x-api-token"];
if (!token) {
throw new UnauthorizedException("API token is required");
}
const tokenEntity = await this.tokenRepository.findOne({
where: { token },
relations: ["project"],
});
const tokenEntity = await this.apiService.getTokenDetails(token);
if (!tokenEntity) {
throw new UnauthorizedException("Invalid API token");
@ -38,6 +32,8 @@ export class ApiTokenGuard implements CanActivate {
throw new UnauthorizedException("API token is inactive");
}
request.apiToken = tokenEntity;
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 { DatabaseNodeService } from "./databaseNode/database.node.service";
import { ApiModule } from "src/api/api.module";
import { RedisModule } from "src/redis/redis.module";
@Module({
imports: [
forwardRef(() => ProjectModule),
forwardRef(() => MigrationModule),
forwardRef(() => ApiModule),
forwardRef(() => RedisModule),
TypeOrmModule.forFeature([Database, DatabaseNode, Project]),
],
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 { Database } from "../entities/database.entity";
import { Repository } from "typeorm";
@ -6,6 +6,7 @@ import { ProjectService } from "src/project/project.service";
import { DatabaseEncryptionService } from "../database.encryption.service";
import { DatabaseNodeService } from "../databaseNode/database.node.service";
import * as mysql from "mysql2/promise";
import { RedisClient } from "src/redis/redis.service";
@Injectable()
export class DatabaseManagerService extends DatabaseEncryptionService {
@ -13,7 +14,9 @@ export class DatabaseManagerService extends DatabaseEncryptionService {
@InjectRepository(Database)
private databaseRepository: Repository<Database>,
private readonly projectService: ProjectService,
private readonly databaseNodeService: DatabaseNodeService
private readonly databaseNodeService: DatabaseNodeService,
@Inject(RedisClient)
private readonly redisClient: RedisClient
) {
super();
}
@ -64,6 +67,14 @@ export class DatabaseManagerService extends DatabaseEncryptionService {
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({
where: { project: { id: project.id } },
relations: ["node"],
@ -73,7 +84,7 @@ export class DatabaseManagerService extends DatabaseEncryptionService {
throw new Error("Database not found");
}
return {
const connectionOptions = {
host: database.node.host,
port: database.node.port,
user: queryUser ? database.q_username : database.c_username,
@ -82,6 +93,14 @@ export class DatabaseManagerService extends DatabaseEncryptionService {
idleTimeout: 150e3,
connectTimeout: 2e3,
};
await this.redisClient.set(
`db_conn_opts_${projectId}_${queryUser}`,
connectionOptions,
300
);
return connectionOptions;
}
async createDatabase(projectId: string): Promise<Database> {

View File

@ -4,9 +4,14 @@ import { Project } from "./entities/project.entity";
import { ProjectService } from "./project.service";
import { ProjectController } from "./project.controller";
import { ApiModule } from "src/api/api.module";
import { RedisModule } from "src/redis/redis.module";
@Module({
imports: [forwardRef(() => ApiModule), TypeOrmModule.forFeature([Project])],
imports: [
forwardRef(() => ApiModule),
forwardRef(() => RedisModule),
TypeOrmModule.forFeature([Project]),
],
controllers: [ProjectController],
providers: [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 { Repository } from "typeorm";
import { Project } from "./entities/project.entity";
import { RedisClient } from "src/redis/redis.service";
@Injectable()
export class ProjectService {
constructor(
@InjectRepository(Project)
private readonly projectRepository: Repository<Project>
private readonly projectRepository: Repository<Project>,
@Inject(RedisClient)
private readonly redisClient: RedisClient
) {}
create(name: string) {
@ -15,17 +18,33 @@ export class ProjectService {
return this.projectRepository.save(project);
}
findById(id: string) {
return this.projectRepository.findOne({ where: { id: id } });
async findById(id: string) {
const cached = await this.redisClient.get(`project_${id}`);
if (cached) {
return cached;
}
updateDatabase(projectId: string, databaseId: string) {
const project = await this.projectRepository.findOne({ where: { id: id } });
if (project) {
await this.redisClient.set(`project_${id}`, project, 300);
}
return project;
}
async updateDatabase(projectId: string, databaseId: string) {
await this.redisClient.del(`project_${projectId}`);
return this.projectRepository.update(projectId, {
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, {
redisNodes: redisNodeId,
});

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@ import { Repository } from "typeorm";
import { Query } from "../entities/query.entity";
import { InjectRepository } from "@nestjs/typeorm";
import { ProjectService } from "src/project/project.service";
import { RedisClient } from "src/redis/redis.service";
@Injectable()
export class QueryHandlerService {
@ -10,7 +11,9 @@ export class QueryHandlerService {
@InjectRepository(Query)
private readonly queryRepository: Repository<Query>,
@Inject(ProjectService)
private readonly projectService: ProjectService
private readonly projectService: ProjectService,
@Inject(RedisClient)
private readonly redisClient: RedisClient
) {}
async createQuery(
@ -34,6 +37,25 @@ export class QueryHandlerService {
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>) {
const query = await this.queryRepository.findOne({ where: { id } });
@ -41,6 +63,8 @@ export class QueryHandlerService {
throw new Error("Query not found");
}
await this.redisClient.del(`query_${id}`);
Object.assign(query, updateData);
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 { QueueModule } from "src/queue/queue.module";
import { FunctionEntity } from "./entities/function.entity";
import { FunctionService } from "src/project/function/function.service";
import { FunctionController } from "src/project/function/function.controller";
import { FunctionService } from "src/query/function/function.service";
import { FunctionController } from "src/query/function/function.controller";
import { RedisManagerModule } from "src/redisManager/redisManager.module";
import { RedisModule } from "src/redis/redis.module";
@Module({
imports: [
@ -20,11 +21,12 @@ import { RedisManagerModule } from "src/redisManager/redisManager.module";
forwardRef(() => DatabaseManagerModule),
forwardRef(() => ApiModule),
forwardRef(() => QueueModule),
forwardRef(() => RedisModule),
forwardRef(() => RedisManagerModule),
TypeOrmModule.forFeature([Query, FunctionEntity]),
],
controllers: [QueryController, CommandController, FunctionController],
providers: [QueryExecuterService, QueryHandlerService, FunctionService],
exports: [QueryExecuterService, TypeOrmModule],
exports: [QueryExecuterService, TypeOrmModule, QueryHandlerService],
})
export class QueryModule {}

View File

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

View File

@ -10,33 +10,33 @@ import * as path from "path";
(async () => {
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(
db.id,
"CREATE TABLE `test` (id INT AUTO_INCREMENT PRIMARY KEY, col1 VARCHAR(255))",
"DROP TABLE `test`"
);
// const migration = await createMigration(
// db.id,
// "CREATE TABLE `test` (id INT AUTO_INCREMENT PRIMARY KEY, col1 VARCHAR(255))",
// "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 query = await createQuery(
project,
{ token: "04c38f93-f2fb-4d2c-a8e2-791effa35239" },
fs.readFileSync(payloadPath, { encoding: "utf-8" })
);

View File

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

View File

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