diff --git a/package-lock.json b/package-lock.json index 15c5624fa..83770cff1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3155,7 +3155,6 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/correlation-id/-/correlation-id-5.2.0.tgz", "integrity": "sha512-qTsYujgBvWIx05qF9HV4+KoezGTelgqJiFnyEfRsEqjpQUZdWnraOGHD+IMep7lPFg6MiI55fvpC4qruKdY5Dw==", - "dev": true, "engines": { "node": ">=14.17.0" } @@ -9412,6 +9411,7 @@ "@nmshd/core-types": "*", "@nmshd/crypto": "2.0.6", "axios": "^1.7.7", + "correlation-id": "^5.2.0", "fast-json-patch": "^3.1.1", "form-data": "^4.0.0", "https-proxy-agent": "^7.0.5", diff --git a/packages/runtime/src/useCases/common/UseCase.ts b/packages/runtime/src/useCases/common/UseCase.ts index bcbb66b78..bac6e67ad 100644 --- a/packages/runtime/src/useCases/common/UseCase.ts +++ b/packages/runtime/src/useCases/common/UseCase.ts @@ -2,6 +2,7 @@ import { ParsingError, ServalError, ValidationError } from "@js-soft/ts-serval"; import { ApplicationError, Result } from "@js-soft/ts-utils"; import { CoreError } from "@nmshd/core-types"; import { RequestError } from "@nmshd/transport"; +import correlator from "correlation-id"; import stringifySafe from "json-stringify-safe"; import { PlatformErrorCodes } from "./PlatformErrorCodes"; import { RuntimeErrors } from "./RuntimeErrors"; @@ -12,19 +13,25 @@ export abstract class UseCase { public constructor(private readonly requestValidator?: IValidator) {} public async execute(request: IRequest): Promise> { - if (this.requestValidator) { - const validationResult = await this.requestValidator.validate(request); + const callback = async (): Promise> => { + if (this.requestValidator) { + const validationResult = await this.requestValidator.validate(request); - if (validationResult.isInvalid()) { - return this.validationFailed(validationResult); + if (validationResult.isInvalid()) { + return this.validationFailed(validationResult); + } } - } - try { - return await this.executeInternal(request); - } catch (e) { - return this.failingResultFromUnknownError(e); - } + try { + return await this.executeInternal(request); + } catch (e) { + return this.failingResultFromUnknownError(e); + } + }; + + const correlationId = correlator.getId(); + if (correlationId) return await correlator.withId(correlationId, callback); + return await correlator.withId(callback); } private failingResultFromUnknownError(error: unknown): Result { diff --git a/packages/runtime/test/lib/RequestInterceptor.ts b/packages/runtime/test/lib/RequestInterceptor.ts new file mode 100644 index 000000000..87bcdeec5 --- /dev/null +++ b/packages/runtime/test/lib/RequestInterceptor.ts @@ -0,0 +1,73 @@ +import { RESTClient } from "@nmshd/transport"; +import { AxiosRequestConfig, AxiosResponse, Method } from "axios"; + +export class RequestInterceptor { + protected _measuringRequests = true; + public get measuringRequests(): boolean { + return this._measuringRequests; + } + + protected _requests: AxiosRequestConfig[] = []; + public get requests(): AxiosRequestConfig[] { + return this._requests; + } + + protected _responses: AxiosResponse[] = []; + public get responses(): AxiosResponse[] { + return this._responses; + } + + protected _client: RESTClient; + public get controller(): RESTClient { + return this._client; + } + + public constructor(client: RESTClient) { + this._client = client; + this._measuringRequests = true; + this.injectToClient(client); + } + + private injectToClient(client: RESTClient) { + const that = this; + + const axiosInstance = client["axiosInstance"]; + axiosInstance.interceptors.request.use((req) => { + if (!that._measuringRequests) return req; + that._requests.push(req); + return req; + }); + axiosInstance.interceptors.response.use((res) => { + if (!that._measuringRequests) return res; + that._responses.push(res); + return res; + }); + } + + public start(): this { + this._measuringRequests = true; + this.reset(); + return this; + } + + private reset() { + this._requests = []; + this._responses = []; + } + + public stop(): Communication { + this._measuringRequests = false; + return new Communication(this.requests, this.responses); + } +} + +class Communication { + public constructor( + public readonly requests: AxiosRequestConfig[], + public readonly responses: AxiosResponse[] + ) {} + + public getRequests(filter: { method: Method; urlSubstring: string }) { + return this.requests.filter((r) => r.url!.toLowerCase().includes(filter.urlSubstring.toLowerCase()) && r.method?.toLowerCase() === filter.method.toLowerCase()); + } +} diff --git a/packages/runtime/test/misc/CorrelationId.test.ts b/packages/runtime/test/misc/CorrelationId.test.ts new file mode 100644 index 000000000..d3e9b95ea --- /dev/null +++ b/packages/runtime/test/misc/CorrelationId.test.ts @@ -0,0 +1,44 @@ +import { AccountController } from "@nmshd/transport"; +import correlator from "correlation-id"; +import { Container } from "typescript-ioc"; +import { RuntimeServiceProvider, TestRuntimeServices } from "../lib"; +import { RequestInterceptor } from "../lib/RequestInterceptor"; + +const uuidRegex = new RegExp("[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"); + +describe("CorrelationId", function () { + let runtime: TestRuntimeServices; + let runtimeServiceProvider: RuntimeServiceProvider; + let interceptor: RequestInterceptor; + + beforeAll(async function () { + runtimeServiceProvider = new RuntimeServiceProvider(); + runtime = (await runtimeServiceProvider.launch(1))[0]; + + const accountController = Container.get(AccountController); + interceptor = new RequestInterceptor((accountController as any).synchronization.client); + }); + + afterAll(async function () { + await runtimeServiceProvider.stop(); + }); + + test("should send correlation id to the backbone when given", async function () { + interceptor.start(); + await correlator.withId("test-correlation-id", async () => { + await runtime.transport.account.syncEverything(); + }); + + const requests = interceptor.stop().requests; + expect(requests.at(-1)!.headers!["x-correlation-id"]).toBe("test-correlation-id"); + }); + + test("should send a generated correlation id to the backbone", async function () { + interceptor.start(); + + await runtime.transport.account.syncEverything(); + + const requests = interceptor.stop().requests; + expect(requests.at(-1)!.headers!["x-correlation-id"]).toMatch(uuidRegex); + }); +}); diff --git a/packages/transport/package.json b/packages/transport/package.json index 4f6d43901..ee2002241 100644 --- a/packages/transport/package.json +++ b/packages/transport/package.json @@ -73,6 +73,7 @@ "@nmshd/core-types": "*", "@nmshd/crypto": "2.0.6", "axios": "^1.7.7", + "correlation-id": "^5.2.0", "fast-json-patch": "^3.1.1", "form-data": "^4.0.0", "https-proxy-agent": "^7.0.5", diff --git a/packages/transport/src/core/backbone/RESTClient.ts b/packages/transport/src/core/backbone/RESTClient.ts index 17ecfd39e..8c591aef2 100644 --- a/packages/transport/src/core/backbone/RESTClient.ts +++ b/packages/transport/src/core/backbone/RESTClient.ts @@ -1,6 +1,7 @@ import { ILogger } from "@js-soft/logging-abstractions"; import { CoreBuffer } from "@nmshd/crypto"; import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; +import correlator from "correlation-id"; import formDataLib from "form-data"; import { AgentOptions } from "http"; import { AgentOptions as HTTPSAgentOptions } from "https"; @@ -114,6 +115,12 @@ export class RESTClient { this._logger = TransportLoggerFactory.getLogger(RESTClient); this.axiosInstance = axios.create(resultingRequestConfig); + this.axiosInstance.interceptors.request.use((config) => { + const correlationId = correlator.getId(); + config.headers["x-correlation-id"] = correlationId; + return config; + }); + if (this.config.debug) { this.addAxiosLoggingInterceptors(this.axiosInstance); } diff --git a/packages/transport/test/core/backbone/Authentication.test.ts b/packages/transport/test/core/backbone/Authentication.test.ts index d3e020e33..25cc79677 100644 --- a/packages/transport/test/core/backbone/Authentication.test.ts +++ b/packages/transport/test/core/backbone/Authentication.test.ts @@ -58,6 +58,7 @@ describe("AuthenticationTest", function () { testAccount = accounts[0]; interceptor = new RequestInterceptor((testAccount.authenticator as any).authClient); }); + afterAll(async function () { await testAccount.close(); await connection.close(); diff --git a/packages/transport/test/core/backbone/CorrelationId.test.ts b/packages/transport/test/core/backbone/CorrelationId.test.ts new file mode 100644 index 000000000..d1dfff7bd --- /dev/null +++ b/packages/transport/test/core/backbone/CorrelationId.test.ts @@ -0,0 +1,35 @@ +import { IDatabaseConnection } from "@js-soft/docdb-access-abstractions"; +import correlator from "correlation-id"; +import { AccountController } from "../../../src"; +import { RequestInterceptor } from "../../testHelpers/RequestInterceptor"; +import { TestUtil } from "../../testHelpers/TestUtil"; + +describe("CorrelationId", function () { + let connection: IDatabaseConnection; + let testAccount: AccountController; + let interceptor: RequestInterceptor; + + beforeAll(async function () { + connection = await TestUtil.createDatabaseConnection(); + const transport = TestUtil.createTransport(connection); + await transport.init(); + const accounts = await TestUtil.provideAccounts(transport, 1); + testAccount = accounts[0]; + interceptor = new RequestInterceptor((testAccount as any).synchronization.client); + }); + + afterAll(async function () { + await testAccount.close(); + await connection.close(); + }); + + test("should send correlation id to the backbone when given", async function () { + interceptor.start(); + await correlator.withId("test-correlation-id", async () => { + await testAccount.syncEverything(); + }); + + const requests = interceptor.stop().requests; + expect(requests.at(-1)!.headers!["x-correlation-id"]).toBe("test-correlation-id"); + }); +});