Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions back/_doc/env-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,4 @@
| REQUEST_TIMEOUT | string |
| RieBroker_QUEUE | string |
| RieBroker_URLS | json |
| USE_HTTP_CLIENT | string |
21 changes: 0 additions & 21 deletions back/apps/csmr-rie/src/app.module.ts

This file was deleted.

1 change: 1 addition & 0 deletions back/apps/csmr-rie/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export default {
Logger,
LoggerLegacy,
HttpProxyBroker,
httpClient: process.env.USE_HTTP_CLIENT || "axios",
} as CsmrHttpProxyConfig;
40 changes: 36 additions & 4 deletions back/apps/csmr-rie/src/csmr-http-proxy.module.spec.ts
Original file line number Diff line number Diff line change
@@ -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) =>
Expand All @@ -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);
});
});
74 changes: 51 additions & 23 deletions back/apps/csmr-rie/src/csmr-http-proxy.module.ts
Original file line number Diff line number Diff line change
@@ -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<RabbitmqConfig>("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<RabbitmqConfig>("HttpProxyBroker");
return { timeout, validateStatus, transformResponse: rawTransform };
},
inject: [ConfigService],
});

const fetchStrategy = FetchHttpModule.registerAsync({
imports: [ConfigModule],
useFactory: (svc: ConfigService) => {
const { requestTimeout } = svc.get<RabbitmqConfig>("HttpProxyBroker");
return () => ({ signal: AbortSignal.timeout(requestTimeout) });
},
inject: [ConfigService],
});

const strategies: Record<HttpClient, DynamicModule> = {
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],
};
}
}
7 changes: 6 additions & 1 deletion back/apps/csmr-rie/src/dto/csmr-http-proxy.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -24,4 +26,7 @@ export class CsmrHttpProxyConfig {
@ValidateNested()
@Type(() => RabbitmqConfig)
readonly HttpProxyBroker: RabbitmqConfig;

@IsIn(["axios", "fetch"] satisfies HttpClient[])
readonly httpClient: HttpClient;
}
44 changes: 44 additions & 0 deletions back/apps/csmr-rie/src/http/fetch-http.module.ts
Original file line number Diff line number Diff line change
@@ -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],
};
}
}
133 changes: 133 additions & 0 deletions back/apps/csmr-rie/src/http/fetch-http.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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>(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,
});
});
});
});
Loading
Loading