@mojaloop/central-services-shared 18.35.2 → 18.36.0-snapshot.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mojaloop/central-services-shared",
3
- "version": "18.35.2",
3
+ "version": "18.36.0-snapshot.0",
4
4
  "description": "Shared code for mojaloop central services",
5
5
  "license": "Apache-2.0",
6
6
  "author": "ModusBox",
@@ -48,6 +48,7 @@
48
48
  "test:endpoints": "npx tape 'test/unit/util/endpoints.test.js'",
49
49
  "test:mysql": "npx tape 'test/unit/mysql/**/*.test.js'",
50
50
  "test:participants": "npx tape 'test/unit/util/participants.test.js'",
51
+ "test:request": "npx tape 'test/unit/util/request.test.js'",
51
52
  "test:trans": "npx tape 'test/unit/util/headers/transformer.test.js'",
52
53
  "test:unit": "npx tape 'test/unit/**/*.test.js' | tap-spec",
53
54
  "test:xunit": "npx tape 'test/unit/**/**.test.js' | tap-xunit > ./test/results/xunit.xml",
@@ -71,12 +72,13 @@
71
72
  "@hapi/joi-date": "2.0.1",
72
73
  "@mojaloop/inter-scheme-proxy-cache-lib": "2.9.0",
73
74
  "@opentelemetry/api": "1.9.0",
75
+ "@opentelemetry/semantic-conventions": "1.39.0",
74
76
  "async-exit-hook": "2.0.1",
75
77
  "async-retry": "1.3.3",
76
78
  "axios": "1.13.4",
77
79
  "clone": "2.1.2",
78
80
  "convict": "^6.2.4",
79
- "dotenv": "17.2.3",
81
+ "dotenv": "17.2.4",
80
82
  "env-var": "7.5.0",
81
83
  "event-stream": "4.0.1",
82
84
  "fast-safe-stringify": "2.1.1",
@@ -100,7 +102,7 @@
100
102
  "@mojaloop/central-services-logger": "11.10.3",
101
103
  "@mojaloop/central-services-metrics": "12.8.3",
102
104
  "@mojaloop/event-sdk": "14.8.2",
103
- "@mojaloop/sdk-standard-components": "19.18.4",
105
+ "@mojaloop/sdk-standard-components": "19.18.7",
104
106
  "@opentelemetry/auto-instrumentations-node": "^0.69.0",
105
107
  "@types/hapi__joi": "17.1.15",
106
108
  "ajv": "^8.17.1",
@@ -152,7 +154,8 @@
152
154
  "validator": "13.15.22",
153
155
  "lodash": "4.17.23",
154
156
  "lodash-es": "4.17.23",
155
- "undici": "7.18.2"
157
+ "undici": "7.18.2",
158
+ "fast-xml-parser": "5.3.4"
156
159
  },
157
160
  "peerDependencies": {
158
161
  "@mojaloop/central-services-error-handling": "13.x.x",
package/src/config.js CHANGED
@@ -9,6 +9,13 @@ const config = convict({
9
9
  env: 'SHARED_CACHE_LOG_LEVEL'
10
10
  },
11
11
 
12
+ httpLogLevel: {
13
+ doc: 'Log level for HTTP wrapper.',
14
+ format: logLevelValues,
15
+ default: logLevelsMap.warn,
16
+ env: 'LOG_LEVEL_HTTP'
17
+ },
18
+
12
19
  defaultTtlSec: {
13
20
  doc: 'Default cache TTL.',
14
21
  format: Number,
package/src/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Utils as HapiUtil, Server } from '@hapi/hapi'
2
2
  import { ILogger } from '@mojaloop/central-services-logger/src/contextLogger'
3
3
  import { Knex } from 'knex';
4
+ import { AxiosRequestConfig, AxiosResponse, ResponseType as AxiosResponseType } from 'axios'
4
5
  import IORedis from 'ioredis';
5
6
 
6
7
  declare namespace CentralServicesShared {
@@ -656,9 +657,27 @@ declare namespace CentralServicesShared {
656
657
  accept: string
657
658
  }
658
659
 
659
- type RequestParams = { url: string, headers: HapiUtil.Dictionary<string>, source: string, destination: string, hubNameRegex: RegExp, method?: RestMethodsEnum, payload?: any, responseType?: string, span?: any, jwsSigner?: any, protocolVersions?: ProtocolVersionsType }
660
- interface Request {
661
- sendRequest(params: RequestParams): Promise<any>
660
+ type RequestParams = {
661
+ url: string,
662
+ headers: HapiUtil.Dictionary<string>,
663
+ source: string,
664
+ destination?: string,
665
+ hubNameRegex: RegExp,
666
+ method?: RestMethodsEnum,
667
+ payload?: any,
668
+ params?: AxiosRequestConfig['params'],
669
+ responseType?: AxiosResponseType,
670
+ span?: any,
671
+ jwsSigner?: any,
672
+ protocolVersions?: ProtocolVersionsType,
673
+ apiType?: ApiTypeValues,
674
+ axiosRequestOptionsOverride?: Partial<AxiosRequestConfig>,
675
+ logger?: ILogger,
676
+ peerService?: string
677
+ }
678
+ export interface Request {
679
+ sendRequest(params: RequestParams): Promise<AxiosResponse>
680
+ sendBaseRequest(params?: AxiosRequestConfig & { logger?: ILogger, peerService?: string }): Promise<AxiosResponse>
662
681
  }
663
682
 
664
683
  interface Kafka {
@@ -788,7 +807,7 @@ declare namespace CentralServicesShared {
788
807
  RedisCache: RedisCache;
789
808
  }
790
809
 
791
- type RedisInstanceConfig =
810
+ type RedisInstanceConfig =
792
811
  | {
793
812
  type: 'redis';
794
813
  host: string;
@@ -0,0 +1,53 @@
1
+ /*****
2
+ License
3
+ --------------
4
+ Copyright © 2020-2025 Mojaloop Foundation
5
+ The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at
6
+
7
+ http://www.apache.org/licenses/LICENSE-2.0
8
+
9
+ Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
10
+
11
+ Contributors
12
+ --------------
13
+ This is the official list of the Mojaloop project contributors for this file.
14
+ Names of the original copyright holders (individuals or organizations)
15
+ should be listed with a '*' in the first column. People who have
16
+ contributed from an organization can be listed under the organization
17
+ that actually holds the copyright for their contributions (see the
18
+ Mojaloop Foundation for an example). Those individuals should have
19
+ their names indented and be marked with a '-'. Email address can be added
20
+ optionally within square brackets <email>.
21
+
22
+ * Mojaloop Foundation
23
+ * Eugen Klymniuk <eugen.klymniuk@infitx.com>
24
+
25
+ --------------
26
+ ******/
27
+ /* istanbul ignore file */
28
+
29
+ const otel = require('@opentelemetry/semantic-conventions')
30
+
31
+ const ATTR_SERVICE_PEER_NAME = 'service.peer.name' // using string literal because ATTR_SERVICE_PEER_NAME is only available in @opentelemetry/semantic-conventions/incubating as of now
32
+
33
+ const outgoingRequestDto = ({
34
+ method, url, durationSec, statusCode, errorType, peerService
35
+ }) => ({
36
+ attributes: {
37
+ [otel.ATTR_HTTP_REQUEST_METHOD]: method,
38
+ [otel.ATTR_URL_FULL]: url,
39
+ [otel.METRIC_HTTP_CLIENT_REQUEST_DURATION]: durationSec, // 'duration.ms' is a custom attribute
40
+ ...(statusCode && { [otel.ATTR_HTTP_RESPONSE_STATUS_CODE]: statusCode }),
41
+ ...(errorType && { [otel.ATTR_ERROR_TYPE]: errorType }),
42
+ ...(peerService && { [ATTR_SERVICE_PEER_NAME]: peerService })
43
+ // peerService - logical service name, must be explicitly provided by caller (not derived from URL hostname)
44
+ // think if we should extract it for internal http://... calls from url hostname
45
+ }
46
+ })
47
+
48
+ const incomingRequestDto = () => ({ attributes: {} }) // todo: add impl.
49
+
50
+ module.exports = {
51
+ outgoingRequestDto,
52
+ incomingRequestDto
53
+ }
@@ -30,29 +30,31 @@
30
30
  'use strict'
31
31
 
32
32
  const http = require('node:http')
33
- const request = require('axios')
33
+ const axios = require('axios')
34
34
  const stringify = require('fast-safe-stringify')
35
35
  const EventSdk = require('@mojaloop/event-sdk')
36
36
  const ErrorHandler = require('@mojaloop/central-services-error-handling')
37
37
  const Metrics = require('@mojaloop/central-services-metrics')
38
- const Headers = require('./headers/transformer')
39
- const enums = require('../enums')
40
- const { logger } = require('../logger')
38
+
39
+ const { logger: globalLogger } = require('../logger')
41
40
  const { API_TYPES } = require('../constants')
42
41
  const config = require('../config')
42
+ const enums = require('../enums')
43
+ const Headers = require('./headers/transformer')
44
+ const { outgoingRequestDto } = require('./otelDto')
43
45
 
44
46
  const MISSING_FUNCTION_PARAMETERS = 'Missing parameters for function'
45
47
 
46
48
  // Delete the default headers that the `axios` module inserts as they can brake our conventions.
47
49
  // By default it would insert `"Accept":"application/json, text/plain, */*"`.
48
- delete request.defaults.headers.common.Accept
50
+ delete axios.defaults.headers.common.Accept
49
51
 
50
52
  const keepAlive = (process.env.HTTP_AGENT_KEEP_ALIVE ?? 'true') === 'true'
51
- logger.verbose('http keepAlive:', { keepAlive })
53
+ globalLogger.verbose('http keepAlive:', { keepAlive })
52
54
 
53
55
  // Enable keepalive for http
54
- request.defaults.httpAgent = new http.Agent({ keepAlive })
55
- request.defaults.httpAgent.toJSON = () => ({})
56
+ axios.defaults.httpAgent = new http.Agent({ keepAlive })
57
+ axios.defaults.httpAgent.toJSON = () => ({})
56
58
 
57
59
  /**
58
60
  * @function sendRequest
@@ -77,11 +79,12 @@ request.defaults.httpAgent.toJSON = () => ({})
77
79
  * @param {SendRequestProtocolVersions | undefined} protocolVersions the config for Protocol versions to be used
78
80
  * @param {'fspiop' | 'iso20022'} apiType the API type of the request being sent
79
81
  * @param {object} axiosRequestOptionsOverride axios request options to override https://axios-http.com/docs/req_config
82
+ * @param {ILogger} [logger] ContextLogger instance with specific context
83
+ * @param {string} [peerService] Logical service name to call (for OTel)
80
84
  * @param {regex} hubNameRegex hubName Regex
81
85
  *
82
86
  *@return {Promise<any>} The response for the request being sent or error object with response included
83
87
  */
84
-
85
88
  const sendRequest = async ({
86
89
  url,
87
90
  headers,
@@ -96,6 +99,8 @@ const sendRequest = async ({
96
99
  protocolVersions = undefined,
97
100
  apiType = API_TYPES.fspiop,
98
101
  axiosRequestOptionsOverride = {},
102
+ logger = createHttpLogger(),
103
+ peerService = '',
99
104
  hubNameRegex
100
105
  }) => {
101
106
  const histTimerEnd = Metrics.getHistogram(
@@ -113,6 +118,9 @@ const sendRequest = async ({
113
118
  // think, if we can just avoid checking "destination"
114
119
  throw ErrorHandler.Factory.createInternalServerFSPIOPError(MISSING_FUNCTION_PARAMETERS)
115
120
  }
121
+
122
+ const log = logger.child({ component: 'httpRequest' })
123
+
116
124
  try {
117
125
  const transformedHeaders = Headers.transformHeaders(headers, {
118
126
  httpMethod: method,
@@ -126,9 +134,10 @@ const sendRequest = async ({
126
134
  url,
127
135
  method,
128
136
  headers: transformedHeaders,
129
- data: payload, // todo: think, if it's better to transform to ISO format here (based on apiType)
137
+ data: payload,
130
138
  params,
131
139
  responseType,
140
+ peerService,
132
141
  timeout: config.get('httpRequestTimeoutMs'),
133
142
  ...axiosRequestOptionsOverride
134
143
  }
@@ -149,14 +158,18 @@ const sendRequest = async ({
149
158
  }
150
159
  span.audit({ ...rest, payload }, EventSdk.AuditEventAction.egress)
151
160
  }
152
- logger.debug('sendRequest::requestOptions:', { requestOptions })
153
- const response = await request(requestOptions)
161
+
162
+ const response = await sendBaseRequest({
163
+ ...requestOptions,
164
+ logger: log,
165
+ peerService
166
+ })
154
167
 
155
168
  !!sendRequestSpan && await sendRequestSpan.finish()
156
169
  histTimerEnd({ success: true, source, destination, method })
157
170
  return response
158
171
  } catch (error) {
159
- logger.error('error in request.sendRequest:', {
172
+ log.error('error in request.sendRequest:', {
160
173
  code: error.code,
161
174
  message: error.message,
162
175
  stack: error.stack,
@@ -247,6 +260,60 @@ const sendRequest = async ({
247
260
  }
248
261
  }
249
262
 
263
+ // todo: think better name
264
+ // it's for http calls without params validation and transformHeaders
265
+ const sendBaseRequest = async ({
266
+ logger = createHttpLogger(),
267
+ peerService = '',
268
+ ...reqOptions
269
+ } = {}) => {
270
+ const log = logger.child({ component: 'sendBaseRequest' })
271
+ const { method, url } = reqOptions
272
+ const methodUrl = `${method?.toUpperCase()} ${url}`
273
+ const startTime = Date.now()
274
+ let statusCode
275
+ let errorType
276
+
277
+ try {
278
+ log.debug(`[-->] options for ${methodUrl}: `, { reqOptions })
279
+
280
+ const response = await axios(reqOptions)
281
+
282
+ statusCode = response?.status
283
+ log.verbose(`[<--] details of ${methodUrl}: `, {
284
+ data: response?.data,
285
+ headers: response?.headers, // todo: extract only needed headers
286
+ statusCode,
287
+ reqOptions
288
+ })
289
+
290
+ return response
291
+ } catch (error) {
292
+ statusCode = error.response?.status
293
+ errorType = error.code
294
+ throw error // todo: think, if we need to rethrow our custom error here
295
+ } finally {
296
+ const severity = typeof statusCode === 'number'
297
+ ? (statusCode >= 200 && statusCode < 300 ? 'info' : 'warn')
298
+ : 'error'
299
+ log[severity](`[<--] ${methodUrl} [${statusCode || errorType}]: `, outgoingRequestDto({
300
+ method,
301
+ url,
302
+ statusCode,
303
+ durationSec: (Date.now() - startTime) / 1000,
304
+ errorType,
305
+ peerService
306
+ }))
307
+ }
308
+ }
309
+
310
+ const createHttpLogger = () => {
311
+ const logger = globalLogger.child()
312
+ logger.setLevel(config.get('httpLogLevel'))
313
+ return logger
314
+ }
315
+
250
316
  module.exports = {
251
- sendRequest
317
+ sendRequest,
318
+ sendBaseRequest
252
319
  }