diff --git a/src/modules/connection-test/connection-test.controller.ts b/src/modules/connection-test/connection-test.controller.ts index ac3d03f..288d008 100644 --- a/src/modules/connection-test/connection-test.controller.ts +++ b/src/modules/connection-test/connection-test.controller.ts @@ -22,6 +22,9 @@ import { ConnectionTestListTablesRes, GetTableMetadataRes, GetTableMetadataReq, + RefreshCatalogReq, + RefreshCatalogRes, + RefreshCatalogStatusReq, } from './dto/connection-test'; import { DadosferaLogger } from '@dadosfera/dadosfera-logs'; import { Authenticated } from 'src/decorators/authentication.decorator'; @@ -121,4 +124,35 @@ export class ConnectionTestController { user, ); } + + @Post('refresh-catalog') + @ApiOkResponse({ type: RefreshCatalogRes }) + @HttpCode(HttpStatus.ACCEPTED) + async refreshCatalog( + @User() user: RequestUser, + @Body(new ValidationPipe()) body: RefreshCatalogReq, + ) { + this.logger.info('/connection-test/refresh-catalog', { + user: user.user_id, + customer: user.customer_name, + connection: body.connection_id, + }); + return this.connectionTestService.refreshCatalog(body, user); + } + + @Post('refresh-catalog/status') + @ApiOkResponse({ type: RefreshCatalogRes }) + @HttpCode(HttpStatus.OK) + async refreshCatalogStatus( + @User() user: RequestUser, + @Body(new ValidationPipe()) body: RefreshCatalogStatusReq, + ) { + this.logger.info('/connection-test/refresh-catalog/status', { + user: user.user_id, + customer: user.customer_name, + connection: body.connection_id, + session: body.session_id, + }); + return this.connectionTestService.refreshCatalogStatus(body, user); + } } diff --git a/src/modules/connection-test/connection-test.module.ts b/src/modules/connection-test/connection-test.module.ts index b162429..c184391 100644 --- a/src/modules/connection-test/connection-test.module.ts +++ b/src/modules/connection-test/connection-test.module.ts @@ -6,6 +6,7 @@ import { ClientsModule } from '@nestjs/microservices'; import { ConnectionTestClientConfiguration } from './connection-test-client.config'; import { ConnectionModule } from '../connection/connection.module'; import { ConnectionsApiModule } from '../connections-api/connections-api.module'; +import { PlatformApiModule } from '../platform-api/platform-api.module'; const client = new ConnectionTestClientConfiguration(); @Module({ controllers: [ConnectionTestController], @@ -14,6 +15,7 @@ const client = new ConnectionTestClientConfiguration(); ClientsModule.register([client.providerOptions]), ConnectionModule, ConnectionsApiModule, + PlatformApiModule, ], }) export class ConnectionTestModule {} diff --git a/src/modules/connection-test/connection-test.service.spec.ts b/src/modules/connection-test/connection-test.service.spec.ts index a4f71a5..aed103f 100644 --- a/src/modules/connection-test/connection-test.service.spec.ts +++ b/src/modules/connection-test/connection-test.service.spec.ts @@ -16,6 +16,7 @@ describe('ConnectionTestService catalog cache', () => { const grpcClient = { getService: jest.fn().mockReturnValue({}) }; const connectionsService = {}; const connectionsApiService = { proxy: jest.fn() }; + const platformApiService = { proxy: jest.fn() }; let service: ConnectionTestService; beforeEach(() => { @@ -24,6 +25,7 @@ describe('ConnectionTestService catalog cache', () => { grpcClient as any, connectionsService as any, connectionsApiService as any, + platformApiService as any, ); }); @@ -106,4 +108,98 @@ describe('ConnectionTestService catalog cache', () => { user, ); }); + + it('submits a catalog refresh without holding the request open', async () => { + platformApiService.proxy.mockResolvedValue({ + session_id: 'session-id', + date: '20260731', + }); + + await expect( + service.refreshCatalog( + { connection_id: 'config-id', plugin: 'postgresql' }, + user, + ), + ).resolves.toEqual({ + operation_result: true, + status: 'PENDING', + session_id: 'session-id', + date: '20260731', + }); + + expect(platformApiService.proxy).toHaveBeenCalledWith( + 'POST', + '/connection_test', + user, + { + customer_id: user.customer_name, + plugin: 'postgresql', + task: { + task_type: 'refresh_catalog', + connection: { + provider: 'connection_manager', + config_id: 'config-id', + }, + }, + }, + ); + }); + + it('keeps polling without changing the catalog pointer while pending', async () => { + platformApiService.proxy.mockResolvedValue({ status: 'PENDING' }); + + await expect( + service.refreshCatalogStatus( + { + connection_id: 'config-id', + plugin: 'postgresql', + session_id: 'session-id', + date: '20260731', + }, + user, + ), + ).resolves.toEqual({ + operation_result: false, + status: 'PENDING', + session_id: 'session-id', + date: '20260731', + }); + + expect(connectionsApiService.proxy).not.toHaveBeenCalled(); + }); + + it('publishes the catalog pointer after the refresh finishes', async () => { + platformApiService.proxy.mockResolvedValue({ status: 'DONE' }); + connectionsApiService.proxy.mockResolvedValue({ + last_catalog_refresh_status: 'SUCCESS', + }); + + await expect( + service.refreshCatalogStatus( + { + connection_id: 'config/id', + plugin: 'postgresql', + session_id: 'session-id', + date: '20260731', + }, + user, + ), + ).resolves.toEqual({ + operation_result: true, + status: 'DONE', + session_id: 'session-id', + date: '20260731', + }); + + expect(connectionsApiService.proxy).toHaveBeenCalledWith( + 'PUT', + '/connection_config/config%2Fid/catalog_metadata', + user, + { + last_catalog_refresh_status: 'SUCCESS', + last_catalog_connection_test_date: '20260731', + last_catalog_connection_test_session_id: 'session-id', + }, + ); + }); }); diff --git a/src/modules/connection-test/connection-test.service.ts b/src/modules/connection-test/connection-test.service.ts index af5c10b..fe4930d 100644 --- a/src/modules/connection-test/connection-test.service.ts +++ b/src/modules/connection-test/connection-test.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common'; import { ClientGrpc } from '@nestjs/microservices'; import { ConnectionTest } from '@dadosfera/protospack-v2'; import { lastValueFrom } from 'rxjs'; @@ -13,6 +13,9 @@ import { ConnectionTestPingRes, GetTableMetadataReq, GetTableMetadataRes, + RefreshCatalogReq, + RefreshCatalogRes, + RefreshCatalogStatusReq, } from './dto/connection-test'; import { ConnectionClientService } from '../connection/client.service'; import { @@ -22,6 +25,7 @@ import { import { RequestUser } from 'src/decorators/user.decorator'; import { PackTheMetadata } from 'src/utils/PackTheMetadata'; import { ConnectionsApiService } from '../connections-api/connections-api.service'; +import { PlatformApiService } from '../platform-api/platform-api.service'; @Injectable() export class ConnectionTestService { @@ -30,6 +34,7 @@ export class ConnectionTestService { @Inject('ConnectionTestGrpcClient') private readonly grpcClient: ClientGrpc, private connectionsService: ConnectionClientService, private connectionsApiService: ConnectionsApiService, + private platformApiService: PlatformApiService, ) { this.connectionTestReadClient = grpcClient.getService( @@ -204,4 +209,82 @@ export class ConnectionTestService { ); return { operation_result: true, tables_metadata }; } + + async refreshCatalog( + body: RefreshCatalogReq, + user: RequestUser, + ): Promise { + const task = await this.platformApiService.proxy( + 'POST', + '/connection_test', + user, + { + customer_id: user.customer_name, + plugin: body.plugin, + task: { + task_type: 'refresh_catalog', + connection: { + provider: 'connection_manager', + config_id: body.connection_id, + }, + }, + }, + ); + + if (!task.session_id || !task.date) { + throw new HttpException( + 'Platform API did not return a catalog refresh task identifier', + HttpStatus.BAD_GATEWAY, + ); + } + + return { + operation_result: true, + status: 'PENDING', + session_id: task.session_id, + date: task.date, + }; + } + + async refreshCatalogStatus( + body: RefreshCatalogStatusReq, + user: RequestUser, + ): Promise { + const result = await this.platformApiService.proxy( + 'POST', + '/connection_test/status', + user, + { + session_id: body.session_id, + date: body.date, + }, + ); + + if (result.status === 'DONE') { + await this.connectionsApiService.proxy( + 'PUT', + `/connection_config/${encodeURIComponent( + body.connection_id, + )}/catalog_metadata`, + user, + { + last_catalog_refresh_status: 'SUCCESS', + last_catalog_connection_test_date: body.date, + last_catalog_connection_test_session_id: body.session_id, + }, + ); + } else if (result.status === 'ERROR' || result.status === 'EXPIRED') { + throw new HttpException( + `Catalog refresh finished with status ${result.status}`, + HttpStatus.BAD_GATEWAY, + ); + } + + return { + operation_result: result.status === 'DONE', + status: result.status, + session_id: body.session_id, + date: body.date, + }; + } } diff --git a/src/modules/connection-test/dto/connection-test.ts b/src/modules/connection-test/dto/connection-test.ts index 44120fc..3496d4c 100644 --- a/src/modules/connection-test/dto/connection-test.ts +++ b/src/modules/connection-test/dto/connection-test.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional, OmitType } from '@nestjs/swagger'; -import { IsString, IsOptional } from 'class-validator'; +import { IsIn, IsString, IsOptional } from 'class-validator'; import { DatabaseConnectionPropertiesDto } from 'src/modules/connection/dtos/connection'; import { CreateConnectionDto } from 'src/modules/connection/dtos/connection'; export class ColumnDto { @@ -133,3 +133,37 @@ export class GetTableMetadataRes { @ApiProperty({ type: [TableMetadataDto] }) tables_metadata: TableMetadataDto[]; } + +export class RefreshCatalogReq { + @ApiProperty() + @IsString() + connection_id: string; + + @ApiProperty({ enum: ['oracle', 'mysql', 'postgresql', 'sqlserver'] }) + @IsIn(['oracle', 'mysql', 'postgresql', 'sqlserver']) + plugin: string; +} + +export class RefreshCatalogStatusReq extends RefreshCatalogReq { + @ApiProperty() + @IsString() + session_id: string; + + @ApiProperty() + @IsString() + date: string; +} + +export class RefreshCatalogRes { + @ApiProperty() + operation_result: boolean; + + @ApiProperty() + status: string; + + @ApiProperty() + session_id: string; + + @ApiProperty() + date: string; +} diff --git a/src/modules/connections-api/connections-api.service.ts b/src/modules/connections-api/connections-api.service.ts index 81e5c41..ae2dfd4 100644 --- a/src/modules/connections-api/connections-api.service.ts +++ b/src/modules/connections-api/connections-api.service.ts @@ -27,9 +27,19 @@ export class ConnectionsApiService { method: string, path: string, user: RequestUser, + body?: any, + query?: Record, ): Promise { const baseUrl = CONNECTIONS_API_CONFIG.getUrl(); const url = new URL(`${baseUrl}${path}`); + + if (query) { + Object.entries(query).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + url.searchParams.set(key, String(value)); + } + }); + } const headers: Record = { host: url.hostname, 'content-type': 'application/json', @@ -45,8 +55,9 @@ export class ConnectionsApiService { protocol: url.protocol, hostname: url.hostname, port: url.port ? parseInt(url.port, 10) : undefined, - path: url.pathname, + path: url.pathname + url.search, headers, + body: body ? JSON.stringify(body) : undefined, }; try { @@ -55,6 +66,7 @@ export class ConnectionsApiService { method: method as Method, url: url.href, headers: signedRequest.headers as Record, + data: body, timeout: CONNECTIONS_API_CONFIG.timeout, validateStatus: () => true, });