diff --git a/back/_doc/env-vars.md b/back/_doc/env-vars.md index 4176261b9..b8489c382 100644 --- a/back/_doc/env-vars.md +++ b/back/_doc/env-vars.md @@ -87,3 +87,4 @@ | REQUEST_TIMEOUT | string | | RieBroker_QUEUE | string | | RieBroker_URLS | json | +| USE_HTTP_CLIENT | string | diff --git a/back/apps/csmr-rie/src/app.module.ts b/back/apps/csmr-rie/src/app.module.ts deleted file mode 100644 index 7b7b4a8c2..000000000 --- a/back/apps/csmr-rie/src/app.module.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ConfigModule, ConfigService } from "@fc/config"; -import { LoggerModule } from "@fc/logger"; -import { DynamicModule, Module } from "@nestjs/common"; -import { CsmrHttpProxyModule } from "./csmr-http-proxy.module"; - -@Module({}) -export class AppModule { - static forRoot(configService: ConfigService): DynamicModule { - return { - module: AppModule, - imports: [ - // 1. Load config module first - ConfigModule.forRoot(configService), - // 2. Load logger module next - LoggerModule.forRoot(), - // 3. Load other modules - CsmrHttpProxyModule, - ], - }; - } -} diff --git a/back/apps/csmr-rie/src/config/index.ts b/back/apps/csmr-rie/src/config/index.ts index b2d74de67..c5b50caa0 100644 --- a/back/apps/csmr-rie/src/config/index.ts +++ b/back/apps/csmr-rie/src/config/index.ts @@ -9,4 +9,5 @@ export default { Logger, LoggerLegacy, HttpProxyBroker, + httpClient: process.env.USE_HTTP_CLIENT || "axios", } as CsmrHttpProxyConfig; diff --git a/back/apps/csmr-rie/src/csmr-http-proxy.module.spec.ts b/back/apps/csmr-rie/src/csmr-http-proxy.module.spec.ts index 9e63d2334..5fae96f5d 100644 --- a/back/apps/csmr-rie/src/csmr-http-proxy.module.spec.ts +++ b/back/apps/csmr-rie/src/csmr-http-proxy.module.spec.ts @@ -1,11 +1,41 @@ import { ConfigModule } from "@fc/config"; import { LoggerModule } from "@fc/logger"; import { getConfigMock } from "@mocks/config"; +import { HttpService } from "@nestjs/axios"; import { Test } from "@nestjs/testing"; import { CsmrHttpProxyModule } from "./csmr-http-proxy.module"; +import { FetchHttpService } from "./http/fetch-http.service"; describe("CsmrHttpProxyModule Dependency Validation", () => { - it("should compile successfully with minimal config", async () => { + it.each(["axios", "fetch"] as const)( + "should compile successfully with httpClient=%s", + async (httpClient) => { + const configServiceMock = getConfigMock(); + configServiceMock.get.mockImplementation( + (key: string) => + ({ + Logger: { threshold: "info" }, + HttpProxyBroker: { requestTimeout: 5000 }, + })[key] || {}, + ); + + const moduleFixture = await Test.createTestingModule({ + imports: [ + CsmrHttpProxyModule.register({ + configService: configServiceMock as any, + httpClient, + }), + ConfigModule.forRoot(configServiceMock as any), + LoggerModule.forRoot([]), + ], + }).compile(); + + expect(moduleFixture).toBeDefined(); + expect(moduleFixture.get(CsmrHttpProxyModule)).toBeDefined(); + }, + ); + + it("should wire FetchHttpService when httpClient=fetch", async () => { const configServiceMock = getConfigMock(); configServiceMock.get.mockImplementation( (key: string) => @@ -17,13 +47,15 @@ describe("CsmrHttpProxyModule Dependency Validation", () => { const moduleFixture = await Test.createTestingModule({ imports: [ - CsmrHttpProxyModule, + CsmrHttpProxyModule.register({ + configService: configServiceMock as any, + httpClient: "fetch", + }), ConfigModule.forRoot(configServiceMock as any), LoggerModule.forRoot([]), ], }).compile(); - expect(moduleFixture).toBeDefined(); - expect(moduleFixture.get(CsmrHttpProxyModule)).toBeDefined(); + expect(moduleFixture.get(HttpService)).toBeInstanceOf(FetchHttpService); }); }); diff --git a/back/apps/csmr-rie/src/csmr-http-proxy.module.ts b/back/apps/csmr-rie/src/csmr-http-proxy.module.ts index cde50b770..7cf6657eb 100644 --- a/back/apps/csmr-rie/src/csmr-http-proxy.module.ts +++ b/back/apps/csmr-rie/src/csmr-http-proxy.module.ts @@ -1,30 +1,58 @@ import { AsyncLocalStorageModule } from "@fc/async-local-storage"; import { ConfigModule, ConfigService } from "@fc/config"; +import { LoggerModule } from "@fc/logger"; import { RabbitmqConfig, RabbitmqModule } from "@fc/rabbitmq"; import { HttpModule } from "@nestjs/axios"; -import { Module } from "@nestjs/common"; +import { DynamicModule, Module } from "@nestjs/common"; import { CsmrHttpProxyController, HealthController } from "./controllers"; -import { rawTransform, validateStatus } from "./http"; +import { HttpClient } from "./dto"; +import { FetchHttpModule, rawTransform, validateStatus } from "./http"; import { CsmrHttpProxyService } from "./services"; -@Module({ - imports: [ - HttpModule.registerAsync({ - imports: [ConfigModule, AsyncLocalStorageModule], - useFactory: (configService: ConfigService) => { - const { requestTimeout: timeout } = - configService.get("HttpProxyBroker"); - return { - timeout, - validateStatus, - transformResponse: rawTransform, - }; - }, - inject: [ConfigService], - }), - RabbitmqModule.registerFor("HttpProxy"), - ], - controllers: [CsmrHttpProxyController, HealthController], - providers: [CsmrHttpProxyService], -}) -export class CsmrHttpProxyModule {} +const axiosStrategy = HttpModule.registerAsync({ + imports: [ConfigModule], + useFactory: (svc: ConfigService) => { + const { requestTimeout: timeout } = + svc.get("HttpProxyBroker"); + return { timeout, validateStatus, transformResponse: rawTransform }; + }, + inject: [ConfigService], +}); + +const fetchStrategy = FetchHttpModule.registerAsync({ + imports: [ConfigModule], + useFactory: (svc: ConfigService) => { + const { requestTimeout } = svc.get("HttpProxyBroker"); + return () => ({ signal: AbortSignal.timeout(requestTimeout) }); + }, + inject: [ConfigService], +}); + +const strategies: Record = { + axios: axiosStrategy, + fetch: fetchStrategy, +}; + +@Module({}) +export class CsmrHttpProxyModule { + static register({ + configService, + httpClient = "axios", + }: { + configService: ConfigService; + httpClient?: HttpClient; + }): DynamicModule { + return { + module: CsmrHttpProxyModule, + imports: [ + ConfigModule.forRoot(configService), + LoggerModule.forRoot(), + AsyncLocalStorageModule, + strategies[httpClient], + RabbitmqModule.registerFor("HttpProxy"), + ], + controllers: [CsmrHttpProxyController, HealthController], + providers: [CsmrHttpProxyService], + }; + } +} diff --git a/back/apps/csmr-rie/src/dto/csmr-http-proxy.config.ts b/back/apps/csmr-rie/src/dto/csmr-http-proxy.config.ts index 0c7e27fa4..5d9c8f57a 100644 --- a/back/apps/csmr-rie/src/dto/csmr-http-proxy.config.ts +++ b/back/apps/csmr-rie/src/dto/csmr-http-proxy.config.ts @@ -2,7 +2,9 @@ import { AppRmqConfig } from "@fc/app"; import { LoggerConfig, LoggerLegacyConfig } from "@fc/logger"; import { RabbitmqConfig } from "@fc/rabbitmq"; import { Type } from "class-transformer"; -import { IsObject, ValidateNested } from "class-validator"; +import { IsIn, IsObject, ValidateNested } from "class-validator"; + +export type HttpClient = "axios" | "fetch"; export class CsmrHttpProxyConfig { @IsObject() @@ -24,4 +26,7 @@ export class CsmrHttpProxyConfig { @ValidateNested() @Type(() => RabbitmqConfig) readonly HttpProxyBroker: RabbitmqConfig; + + @IsIn(["axios", "fetch"] satisfies HttpClient[]) + readonly httpClient: HttpClient; } diff --git a/back/apps/csmr-rie/src/http/fetch-http.module.ts b/back/apps/csmr-rie/src/http/fetch-http.module.ts new file mode 100644 index 000000000..eb15006fa --- /dev/null +++ b/back/apps/csmr-rie/src/http/fetch-http.module.ts @@ -0,0 +1,44 @@ +import { HttpService } from "@nestjs/axios"; +import type { ModuleMetadata } from "@nestjs/common"; +import { + DynamicModule, + InjectionToken, + Module, + OptionalFactoryDependency, +} from "@nestjs/common"; +import { FetchHttpService } from "./fetch-http.service"; +import { + FETCH_FN, + FETCH_REQUEST_INIT, + FetchFn, + FetchRequestInitFactory, +} from "./fetch-http.tokens"; + +export { FETCH_FN, FETCH_REQUEST_INIT }; +export type { FetchFn, FetchRequestInitFactory }; + +export interface FetchModuleAsyncOptions { + imports?: ModuleMetadata["imports"]; + useFactory: (...args: unknown[]) => FetchRequestInitFactory; + inject?: (InjectionToken | OptionalFactoryDependency)[]; +} + +@Module({}) +export class FetchHttpModule { + static registerAsync({ + imports = [], + useFactory, + inject = [], + }: FetchModuleAsyncOptions): DynamicModule { + return { + module: FetchHttpModule, + imports, + providers: [ + { provide: FETCH_FN, useValue: globalThis.fetch }, + { provide: FETCH_REQUEST_INIT, useFactory, inject }, + { provide: HttpService, useClass: FetchHttpService }, + ], + exports: [HttpService], + }; + } +} diff --git a/back/apps/csmr-rie/src/http/fetch-http.service.spec.ts b/back/apps/csmr-rie/src/http/fetch-http.service.spec.ts new file mode 100644 index 000000000..397c0f2f8 --- /dev/null +++ b/back/apps/csmr-rie/src/http/fetch-http.service.spec.ts @@ -0,0 +1,133 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { lastValueFrom } from "rxjs"; +import { FetchHttpService } from "./fetch-http.service"; +import { FETCH_FN, FETCH_REQUEST_INIT } from "./fetch-http.tokens"; + +describe("FetchHttpService", () => { + let service: FetchHttpService; + + const fetchMock = jest.fn(); + const getInitMock = jest.fn(); + + const responseMock = { + status: 200, + statusText: "OK", + headers: new Headers({ "content-type": "text/html; charset=UTF-8" }), + text: jest.fn().mockResolvedValue("response body"), + }; + + beforeEach(async () => { + jest.resetAllMocks(); + getInitMock.mockReturnValue({}); + responseMock.text.mockResolvedValue("response body"); + fetchMock.mockResolvedValue(responseMock); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FetchHttpService, + { provide: FETCH_FN, useValue: fetchMock }, + { provide: FETCH_REQUEST_INIT, useValue: getInitMock }, + ], + }).compile(); + + service = module.get(FetchHttpService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("get()", () => { + it("should call fetch with GET method", async () => { + // Given + const url = "http://example.com"; + const config = { headers: { "x-custom": "value" } }; + // When + await lastValueFrom(service.get(url, config)); + // Then + expect(fetchMock).toHaveBeenCalledWith(url, { + method: "GET", + headers: config.headers, + }); + }); + + it("should spread getInit into fetch options", async () => { + // Given + const signal = AbortSignal.timeout(1000); + getInitMock.mockReturnValue({ signal }); + // When + await lastValueFrom(service.get("http://example.com")); + // Then + expect(fetchMock).toHaveBeenCalledWith("http://example.com", { + signal, + method: "GET", + headers: undefined, + }); + }); + + it("should call getInit on each request", async () => { + // When + await lastValueFrom(service.get("http://example.com")); + await lastValueFrom(service.get("http://example.com")); + // Then + expect(getInitMock).toHaveBeenCalledTimes(2); + }); + + it("should map the response to HttpResponse shape", async () => { + // When + const result = await lastValueFrom(service.get("http://example.com")); + // Then + expect(result).toMatchObject({ + status: 200, + statusText: "OK", + headers: { "content-type": "text/html; charset=UTF-8" }, + data: "response body", + config: {}, + request: {}, + }); + }); + }); + + describe("post()", () => { + it("should call fetch with POST method and body", async () => { + // Given + const url = "http://example.com"; + const body = "request body"; + const config = { headers: { "content-type": "application/json" } }; + // When + await lastValueFrom(service.post(url, body, config)); + // Then + expect(fetchMock).toHaveBeenCalledWith(url, { + method: "POST", + body, + headers: config.headers, + }); + }); + + it("should call fetch without body when data is undefined", async () => { + // When + await lastValueFrom(service.post("http://example.com")); + // Then + expect(fetchMock).toHaveBeenCalledWith("http://example.com", { + method: "POST", + body: undefined, + headers: undefined, + }); + }); + + it("should spread getInit into fetch options", async () => { + // Given + const signal = AbortSignal.timeout(1000); + getInitMock.mockReturnValue({ signal }); + // When + await lastValueFrom(service.post("http://example.com", "data")); + // Then + expect(fetchMock).toHaveBeenCalledWith("http://example.com", { + signal, + method: "POST", + body: "data", + headers: undefined, + }); + }); + }); +}); diff --git a/back/apps/csmr-rie/src/http/fetch-http.service.ts b/back/apps/csmr-rie/src/http/fetch-http.service.ts new file mode 100644 index 000000000..030715835 --- /dev/null +++ b/back/apps/csmr-rie/src/http/fetch-http.service.ts @@ -0,0 +1,59 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { from, Observable } from "rxjs"; +import type { FetchFn, FetchRequestInitFactory } from "./fetch-http.tokens"; +import { FETCH_FN, FETCH_REQUEST_INIT } from "./fetch-http.tokens"; + +type HttpOptions = { headers?: Record }; +type HttpResponse = { + status: number; + statusText: string; + headers: Record; + data: string; + config: object; + request: object; +}; + +async function toResponse(res: Response): Promise { + return { + status: res.status, + statusText: res.statusText, + headers: Object.fromEntries(res.headers.entries()), + data: await res.text(), + config: {}, + request: {}, + }; +} + +@Injectable() +export class FetchHttpService { + constructor( + @Inject(FETCH_REQUEST_INIT) + private readonly getInit: FetchRequestInitFactory, + @Inject(FETCH_FN) private readonly fetch: FetchFn, + ) {} + + get(url: string, config?: HttpOptions): Observable { + return from( + this.fetch(url, { + ...this.getInit(), + method: "GET", + headers: config?.headers, + }).then(toResponse), + ); + } + + post( + url: string, + data?: string, + config?: HttpOptions, + ): Observable { + return from( + this.fetch(url, { + ...this.getInit(), + method: "POST", + body: data, + headers: config?.headers, + }).then(toResponse), + ); + } +} diff --git a/back/apps/csmr-rie/src/http/fetch-http.tokens.ts b/back/apps/csmr-rie/src/http/fetch-http.tokens.ts new file mode 100644 index 000000000..f5e787ca1 --- /dev/null +++ b/back/apps/csmr-rie/src/http/fetch-http.tokens.ts @@ -0,0 +1,5 @@ +export type FetchRequestInitFactory = () => RequestInit; +export type FetchFn = typeof fetch; + +export const FETCH_REQUEST_INIT = Symbol("FETCH_REQUEST_INIT"); +export const FETCH_FN = Symbol("FETCH_FN"); diff --git a/back/apps/csmr-rie/src/http/index.ts b/back/apps/csmr-rie/src/http/index.ts index 3f254a559..cc20c748a 100644 --- a/back/apps/csmr-rie/src/http/index.ts +++ b/back/apps/csmr-rie/src/http/index.ts @@ -1 +1,3 @@ +export * from "./fetch-http.module"; +export * from "./fetch-http.service"; export * from "./http-module.config"; diff --git a/back/apps/csmr-rie/src/main.ts b/back/apps/csmr-rie/src/main.ts index c4e4fb76d..86e7ee809 100644 --- a/back/apps/csmr-rie/src/main.ts +++ b/back/apps/csmr-rie/src/main.ts @@ -1,24 +1,25 @@ import { ConfigService } from "@fc/config"; -import { CsmrHttpProxyConfig } from "@fc/csmr-http-proxy"; +import { CsmrHttpProxyConfig, HttpClient } from "@fc/csmr-http-proxy"; import { NestLoggerService } from "@fc/logger"; import { RabbitmqConfig } from "@fc/rabbitmq"; import { NestFactory } from "@nestjs/core"; import { MicroserviceOptions, Transport } from "@nestjs/microservices"; -import { AppModule } from "./app.module"; import configuration from "./config"; +import { CsmrHttpProxyModule } from "./csmr-http-proxy.module"; async function bootstrap() { - const configOptions = { + const configService = new ConfigService({ config: configuration, schema: CsmrHttpProxyConfig, - }; - const configService = new ConfigService(configOptions); + }); const options = configService.get("HttpProxyBroker"); + const httpClient = configService.get("httpClient"); - const appModule = AppModule.forRoot(configService); - - const app = await NestFactory.create(appModule, { bufferLogs: true }); + const app = await NestFactory.create( + CsmrHttpProxyModule.register({ configService, httpClient }), + { bufferLogs: true }, + ); const logger = await app.resolve(NestLoggerService); app.useLogger(logger); diff --git a/docker/compose/fca-low/.env/hybridge-rie.env b/docker/compose/fca-low/.env/hybridge-rie.env index c35af653e..25567675b 100644 --- a/docker/compose/fca-low/.env/hybridge-rie.env +++ b/docker/compose/fca-low/.env/hybridge-rie.env @@ -1,5 +1,6 @@ # -- global APP_NAME=csmr-rie +USE_HTTP_CLIENT=axios APP_ROOT= FQDN=csmr-rie.docker.dev-franceconnect.fr NESTJS_INSTANCE=csmr-rie