From 634e97d3c5fbaa46319c23242874848b395d3610 Mon Sep 17 00:00:00 2001 From: Mohamed Mehany <7327188+mohamed-mehany@users.noreply.github.com> Date: Thu, 3 Jul 2025 16:12:36 +0400 Subject: [PATCH 1/7] Adds EA for LibreCapital --- .changeset/shiny-points-melt.md | 5 + .pnp.cjs | 126 ++++++++++++++++ packages/sources/nav-libre/CHANGELOG.md | 0 packages/sources/nav-libre/README.md | 3 + packages/sources/nav-libre/package.json | 44 ++++++ .../sources/nav-libre/src/config/index.ts | 26 ++++ .../sources/nav-libre/src/endpoint/index.ts | 1 + .../sources/nav-libre/src/endpoint/nav.ts | 53 +++++++ packages/sources/nav-libre/src/index.ts | 20 +++ .../nav-libre/src/transport/authentication.ts | 27 ++++ .../sources/nav-libre/src/transport/nav.ts | 116 ++++++++++++++ packages/sources/nav-libre/test-payload.json | 7 + .../__snapshots__/adapter.test.ts.snap | 17 +++ .../test/integration/adapter.test.ts | 50 +++++++ .../nav-libre/test/integration/fixtures.ts | 46 ++++++ packages/sources/nav-libre/tsconfig.json | 9 ++ packages/sources/nav-libre/tsconfig.test.json | 7 + packages/tsconfig.json | 3 + packages/tsconfig.test.json | 3 + yarn.lock | 141 +++++++++++++++++- 20 files changed, 702 insertions(+), 2 deletions(-) create mode 100644 .changeset/shiny-points-melt.md create mode 100644 packages/sources/nav-libre/CHANGELOG.md create mode 100644 packages/sources/nav-libre/README.md create mode 100644 packages/sources/nav-libre/package.json create mode 100644 packages/sources/nav-libre/src/config/index.ts create mode 100644 packages/sources/nav-libre/src/endpoint/index.ts create mode 100644 packages/sources/nav-libre/src/endpoint/nav.ts create mode 100644 packages/sources/nav-libre/src/index.ts create mode 100644 packages/sources/nav-libre/src/transport/authentication.ts create mode 100644 packages/sources/nav-libre/src/transport/nav.ts create mode 100644 packages/sources/nav-libre/test-payload.json create mode 100644 packages/sources/nav-libre/test/integration/__snapshots__/adapter.test.ts.snap create mode 100644 packages/sources/nav-libre/test/integration/adapter.test.ts create mode 100644 packages/sources/nav-libre/test/integration/fixtures.ts create mode 100644 packages/sources/nav-libre/tsconfig.json create mode 100755 packages/sources/nav-libre/tsconfig.test.json diff --git a/.changeset/shiny-points-melt.md b/.changeset/shiny-points-melt.md new file mode 100644 index 0000000000..f1ddd2124b --- /dev/null +++ b/.changeset/shiny-points-melt.md @@ -0,0 +1,5 @@ +--- +'@chainlink/nav-libre-adapter': major +--- + +Adds NAV adapter for LibreCapital diff --git a/.pnp.cjs b/.pnp.cjs index 2b2ecb4034..a445c31d11 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -710,6 +710,10 @@ const RAW_RUNTIME_STATE = "name": "@chainlink/nav-generic-adapter",\ "reference": "workspace:packages/sources/nav-generic"\ },\ + {\ + "name": "@chainlink/nav-libre-adapter",\ + "reference": "workspace:packages/sources/nav-libre"\ + },\ {\ "name": "@chainlink/ncfx-adapter",\ "reference": "workspace:packages/sources/ncfx"\ @@ -1126,6 +1130,7 @@ const RAW_RUNTIME_STATE = ["@chainlink/mycryptoapi-adapter", ["workspace:packages/sources/mycryptoapi"]],\ ["@chainlink/nav-consulting-adapter", ["workspace:packages/sources/nav-consulting"]],\ ["@chainlink/nav-generic-adapter", ["workspace:packages/sources/nav-generic"]],\ + ["@chainlink/nav-libre-adapter", ["workspace:packages/sources/nav-libre"]],\ ["@chainlink/ncfx-adapter", ["workspace:packages/sources/ncfx"]],\ ["@chainlink/nexus-kiln-adapter", ["workspace:packages/composites/nexus-kiln"]],\ ["@chainlink/nft-blue-chip-adapter", ["workspace:packages/sources/nft-blue-chip"]],\ @@ -8040,6 +8045,25 @@ const RAW_RUNTIME_STATE = "linkType": "SOFT"\ }]\ ]],\ + ["@chainlink/nav-libre-adapter", [\ + ["workspace:packages/sources/nav-libre", {\ + "packageLocation": "./packages/sources/nav-libre/",\ + "packageDependencies": [\ + ["@chainlink/nav-libre-adapter", "workspace:packages/sources/nav-libre"],\ + ["@chainlink/external-adapter-framework", "npm:2.6.0"],\ + ["@types/crypto-js", "npm:4.2.2"],\ + ["@types/jest", "npm:27.5.2"],\ + ["@types/node", "npm:16.18.119"],\ + ["crypto-js", "npm:4.2.0"],\ + ["dayjs", "npm:1.11.13"],\ + ["nock", "npm:13.5.5"],\ + ["tslib", "npm:2.4.1"],\ + ["typescript", "patch:typescript@npm%3A5.6.3#optional!builtin::version=5.6.3&hash=8c6c40"],\ + ["uuid", "npm:11.1.0"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ ["@chainlink/ncfx-adapter", [\ ["workspace:packages/sources/ncfx", {\ "packageLocation": "./packages/sources/ncfx/",\ @@ -16441,6 +16465,15 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["@types/jest", [\ + ["npm:27.5.2", {\ + "packageLocation": "./.yarn/cache/@types-jest-npm-27.5.2-f8ba570ba6-8608696fbd.zip/node_modules/@types/jest/",\ + "packageDependencies": [\ + ["@types/jest", "npm:27.5.2"],\ + ["jest-matcher-utils", "npm:27.5.1"],\ + ["pretty-format", "npm:27.5.1"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:29.5.14", {\ "packageLocation": "./.yarn/cache/@types-jest-npm-29.5.14-506446c38e-59ec7a9c46.zip/node_modules/@types/jest/",\ "packageDependencies": [\ @@ -16623,6 +16656,13 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "HARD"\ }],\ + ["npm:16.18.119", {\ + "packageLocation": "./.yarn/cache/@types-node-npm-16.18.119-d3f1632e08-ada2921602.zip/node_modules/@types/node/",\ + "packageDependencies": [\ + ["@types/node", "npm:16.18.119"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:22.14.1", {\ "packageLocation": "./.yarn/cache/@types-node-npm-22.14.1-ff7e0a29d7-561b1ad98e.zip/node_modules/@types/node/",\ "packageDependencies": [\ @@ -21164,6 +21204,15 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["dayjs", [\ + ["npm:1.11.13", {\ + "packageLocation": "./.yarn/cache/dayjs-npm-1.11.13-d478bb9479-7374d63ab1.zip/node_modules/dayjs/",\ + "packageDependencies": [\ + ["dayjs", "npm:1.11.13"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["debug", [\ ["npm:2.6.9", {\ "packageLocation": "./.yarn/cache/debug-npm-2.6.9-7d4cb597dc-e07005f2b4.zip/node_modules/debug/",\ @@ -21691,6 +21740,13 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["diff-sequences", [\ + ["npm:27.5.1", {\ + "packageLocation": "./.yarn/cache/diff-sequences-npm-27.5.1-29338362fa-34d852a13e.zip/node_modules/diff-sequences/",\ + "packageDependencies": [\ + ["diff-sequences", "npm:27.5.1"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:29.6.3", {\ "packageLocation": "./.yarn/cache/diff-sequences-npm-29.6.3-18ab2c9949-179daf9d2f.zip/node_modules/diff-sequences/",\ "packageDependencies": [\ @@ -27975,6 +28031,17 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["jest-diff", [\ + ["npm:27.5.1", {\ + "packageLocation": "./.yarn/cache/jest-diff-npm-27.5.1-818e549196-af454f30f3.zip/node_modules/jest-diff/",\ + "packageDependencies": [\ + ["jest-diff", "npm:27.5.1"],\ + ["chalk", "npm:4.1.2"],\ + ["diff-sequences", "npm:27.5.1"],\ + ["jest-get-type", "npm:27.5.1"],\ + ["pretty-format", "npm:27.5.1"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:29.7.0", {\ "packageLocation": "./.yarn/cache/jest-diff-npm-29.7.0-0149e01930-6f3a7eb9cd.zip/node_modules/jest-diff/",\ "packageDependencies": [\ @@ -28027,6 +28094,13 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["jest-get-type", [\ + ["npm:27.5.1", {\ + "packageLocation": "./.yarn/cache/jest-get-type-npm-27.5.1-980fbf7a43-63064ab701.zip/node_modules/jest-get-type/",\ + "packageDependencies": [\ + ["jest-get-type", "npm:27.5.1"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:29.6.3", {\ "packageLocation": "./.yarn/cache/jest-get-type-npm-29.6.3-500477292e-88ac9102d4.zip/node_modules/jest-get-type/",\ "packageDependencies": [\ @@ -28068,6 +28142,17 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["jest-matcher-utils", [\ + ["npm:27.5.1", {\ + "packageLocation": "./.yarn/cache/jest-matcher-utils-npm-27.5.1-0c47b071fb-037f99878a.zip/node_modules/jest-matcher-utils/",\ + "packageDependencies": [\ + ["jest-matcher-utils", "npm:27.5.1"],\ + ["chalk", "npm:4.1.2"],\ + ["jest-diff", "npm:27.5.1"],\ + ["jest-get-type", "npm:27.5.1"],\ + ["pretty-format", "npm:27.5.1"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:29.7.0", {\ "packageLocation": "./.yarn/cache/jest-matcher-utils-npm-29.7.0-dfc74b630e-981904a494.zip/node_modules/jest-matcher-utils/",\ "packageDependencies": [\ @@ -31670,6 +31755,16 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["nock", [\ + ["npm:13.5.5", {\ + "packageLocation": "./.yarn/cache/nock-npm-13.5.5-ccb57f0a2f-c19d7bf965.zip/node_modules/nock/",\ + "packageDependencies": [\ + ["nock", "npm:13.5.5"],\ + ["debug", "virtual:e376c6d25689d1413f13b759a5649fe969efab30320e886cab81ece2b6daf8c4c74f642faff7228a9a286b4b82bc7bac5773e45f1085910307cd111b19a8cd17#npm:4.3.7"],\ + ["json-stringify-safe", "npm:5.0.1"],\ + ["propagate", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:13.5.6", {\ "packageLocation": "./.yarn/cache/nock-npm-13.5.6-6fbafbb636-a57c265b75.zip/node_modules/nock/",\ "packageDependencies": [\ @@ -33576,6 +33671,16 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["pretty-format", [\ + ["npm:27.5.1", {\ + "packageLocation": "./.yarn/cache/pretty-format-npm-27.5.1-cd7d49696f-248990cbef.zip/node_modules/pretty-format/",\ + "packageDependencies": [\ + ["pretty-format", "npm:27.5.1"],\ + ["ansi-regex", "npm:5.0.1"],\ + ["ansi-styles", "npm:5.2.0"],\ + ["react-is", "npm:17.0.2"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:29.7.0", {\ "packageLocation": "./.yarn/cache/pretty-format-npm-29.7.0-7d330b2ea2-dea96bc83c.zip/node_modules/pretty-format/",\ "packageDependencies": [\ @@ -34116,6 +34221,13 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["react-is", [\ + ["npm:17.0.2", {\ + "packageLocation": "./.yarn/cache/react-is-npm-17.0.2-091bbb8db6-73b36281e5.zip/node_modules/react-is/",\ + "packageDependencies": [\ + ["react-is", "npm:17.0.2"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:18.3.1", {\ "packageLocation": "./.yarn/cache/react-is-npm-18.3.1-370a81e1e9-d5f60c87d2.zip/node_modules/react-is/",\ "packageDependencies": [\ @@ -37819,6 +37931,13 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["typescript", [\ + ["patch:typescript@npm%3A5.6.3#optional!builtin::version=5.6.3&hash=8c6c40", {\ + "packageLocation": "./.yarn/cache/typescript-patch-8964a48ba3-00504c01ee.zip/node_modules/typescript/",\ + "packageDependencies": [\ + ["typescript", "patch:typescript@npm%3A5.6.3#optional!builtin::version=5.6.3&hash=8c6c40"]\ + ],\ + "linkType": "HARD"\ + }],\ ["patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5", {\ "packageLocation": "./.yarn/cache/typescript-patch-f64146f279-b9b1e73dab.zip/node_modules/typescript/",\ "packageDependencies": [\ @@ -38358,6 +38477,13 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["uuid", [\ + ["npm:11.1.0", {\ + "packageLocation": "./.yarn/cache/uuid-npm-11.1.0-61d0d08928-d2da43b49b.zip/node_modules/uuid/",\ + "packageDependencies": [\ + ["uuid", "npm:11.1.0"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:2.0.1", {\ "packageLocation": "./.yarn/unplugged/uuid-npm-2.0.1-a78a0c30dd/node_modules/uuid/",\ "packageDependencies": [\ diff --git a/packages/sources/nav-libre/CHANGELOG.md b/packages/sources/nav-libre/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/sources/nav-libre/README.md b/packages/sources/nav-libre/README.md new file mode 100644 index 0000000000..263497465a --- /dev/null +++ b/packages/sources/nav-libre/README.md @@ -0,0 +1,3 @@ +# Chainlink External Adapter for nav-libre + +This README will be generated automatically when code is merged to `main`. If you would like to generate a preview of the README, please run `yarn generate:readme nav-libre`. diff --git a/packages/sources/nav-libre/package.json b/packages/sources/nav-libre/package.json new file mode 100644 index 0000000000..a560cee382 --- /dev/null +++ b/packages/sources/nav-libre/package.json @@ -0,0 +1,44 @@ +{ + "name": "@chainlink/nav-libre-adapter", + "version": "0.0.0", + "description": "Chainlink nav-libre adapter.", + "keywords": [ + "Chainlink", + "LINK", + "blockchain", + "oracle", + "nav-libre" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "repository": { + "url": "https://github.com/smartcontractkit/external-adapters-js", + "type": "git" + }, + "license": "MIT", + "scripts": { + "clean": "rm -rf dist && rm -f tsconfig.tsbuildinfo", + "prepack": "yarn build", + "build": "tsc -b", + "server": "node -e 'require(\"./index.js\").server()'", + "server:dist": "node -e 'require(\"./dist/index.js\").server()'", + "start": "yarn server:dist" + }, + "devDependencies": { + "@types/crypto-js": "^4", + "@types/jest": "27.5.2", + "@types/node": "16.18.119", + "nock": "13.5.5", + "typescript": "5.6.3" + }, + "dependencies": { + "@chainlink/external-adapter-framework": "2.6.0", + "crypto-js": "^4.2.0", + "dayjs": "^1.11.13", + "tslib": "2.4.1", + "uuid": "^11.1.0" + } +} diff --git a/packages/sources/nav-libre/src/config/index.ts b/packages/sources/nav-libre/src/config/index.ts new file mode 100644 index 0000000000..032450d7e2 --- /dev/null +++ b/packages/sources/nav-libre/src/config/index.ts @@ -0,0 +1,26 @@ +import { AdapterConfig } from '@chainlink/external-adapter-framework/config' + +export const config = new AdapterConfig({ + API_KEY: { + description: 'An API key for Data Provider', + type: 'string', + required: true, + sensitive: true, + }, + SECRET_KEY: { + description: 'A key for Data Provider used in hashing the API key', + type: 'string', + required: true, + sensitive: true, + }, + API_ENDPOINT: { + description: 'An API endpoint for Data Provider', + type: 'string', + default: 'https://api.navfundservices.com/', + }, + MAX_RETRIES: { + description: 'Maximum attempts of sending a request', + type: 'number', + default: 3, + }, +}) diff --git a/packages/sources/nav-libre/src/endpoint/index.ts b/packages/sources/nav-libre/src/endpoint/index.ts new file mode 100644 index 0000000000..0b91aa2c62 --- /dev/null +++ b/packages/sources/nav-libre/src/endpoint/index.ts @@ -0,0 +1 @@ +export { endpoint as nav } from './nav' diff --git a/packages/sources/nav-libre/src/endpoint/nav.ts b/packages/sources/nav-libre/src/endpoint/nav.ts new file mode 100644 index 0000000000..e9ce5e481b --- /dev/null +++ b/packages/sources/nav-libre/src/endpoint/nav.ts @@ -0,0 +1,53 @@ +import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter' +import { InputParameters } from '@chainlink/external-adapter-framework/validation' +import dayjs from 'dayjs' +import { config } from '../config' +import { httpTransport } from '../transport/nav' + +export const inputParameters = new InputParameters( + { + globalFundID: { + required: true, + type: 'number', + description: 'The global fund ID for the Libre fund', + }, + fromDate: { + required: false, + type: 'string', + description: 'Start date in MM-DD-YYYY format (defaults to 7 days ago)', + default: dayjs().subtract(7, 'day').format('MM-DD-YYYY'), + }, + toDate: { + required: false, + type: 'string', + description: 'End date in MM-DD-YYYY format (defaults to today)', + default: dayjs().format('MM-DD-YYYY'), + }, + }, + [ + { + globalFundID: 139767, + fromDate: '12-30-2024', + toDate: '01-15-2025', + }, + ], +) +export type BaseEndpointTypes = { + Parameters: typeof inputParameters.definition + Response: { + Result: number + Data: { + navPerShare: number + navDate: string + globalFundID: number + } + } + Settings: typeof config.settings +} + +export const endpoint = new AdapterEndpoint({ + name: 'nav', + aliases: [], + transport: httpTransport, + inputParameters, +}) diff --git a/packages/sources/nav-libre/src/index.ts b/packages/sources/nav-libre/src/index.ts new file mode 100644 index 0000000000..238c96637b --- /dev/null +++ b/packages/sources/nav-libre/src/index.ts @@ -0,0 +1,20 @@ +import { expose, ServerInstance } from '@chainlink/external-adapter-framework' +import { Adapter } from '@chainlink/external-adapter-framework/adapter' +import { config } from './config' +import { nav } from './endpoint' + +export const adapter = new Adapter({ + defaultEndpoint: nav.name, + name: 'NAV_LIBRE', + config, + endpoints: [nav], + rateLimiting: { + tiers: { + default: { + rateLimit1m: 1, + }, + }, + }, +}) + +export const server = (): Promise => expose(adapter) diff --git a/packages/sources/nav-libre/src/transport/authentication.ts b/packages/sources/nav-libre/src/transport/authentication.ts new file mode 100644 index 0000000000..761a644079 --- /dev/null +++ b/packages/sources/nav-libre/src/transport/authentication.ts @@ -0,0 +1,27 @@ +import CryptoJS from 'crypto-js' +import { v4 as uuidv4 } from 'uuid' + +/** + * Generate the necessary headers for calling the NAV API with a 5-minute-valid signature. + */ +export const getNavRequestHeaders = ( + method: string, + path: string, + body: string, + apiKey: string, + secret: string, +) => { + const utcNow = new Date().toUTCString() + const nonce = uuidv4() + const contentHash = CryptoJS.SHA256(body).toString(CryptoJS.enc.Base64) + const stringToSign = [apiKey, path, method, utcNow, nonce, contentHash].join(';') + + // Compute the HMAC-SHA256 signature, Base64-encoded + const signature = CryptoJS.HmacSHA256(stringToSign, secret).toString(CryptoJS.enc.Base64) + + return { + 'x-date': utcNow, + 'x-content-sha256': contentHash, + 'x-hmac256-signature': `${apiKey};${nonce};${signature}`, + } +} diff --git a/packages/sources/nav-libre/src/transport/nav.ts b/packages/sources/nav-libre/src/transport/nav.ts new file mode 100644 index 0000000000..6a2677ca4d --- /dev/null +++ b/packages/sources/nav-libre/src/transport/nav.ts @@ -0,0 +1,116 @@ +import { HttpTransport } from '@chainlink/external-adapter-framework/transports' +import dayjs from 'dayjs' +import { BaseEndpointTypes } from '../endpoint/nav' +import { getNavRequestHeaders } from './authentication' + +export interface ResponseSchema { + Data: { + 'Trading Level Net ROR': { + DTD: number + MTD: number + QTD: number + YTD: number + ITD: number + } + 'Net ROR': { + DTD: number + MTD: number + QTD: number + YTD: number + ITD: number + } + 'NAV Per Share': number + 'Next NAV Price': number + 'Accounting Date': string + 'Ending Balance': number + }[] +} + +export type HttpTransportTypes = BaseEndpointTypes & { + Provider: { + RequestBody: never + ResponseBody: ResponseSchema + } +} + +export const httpTransport = new HttpTransport({ + prepareRequests: (params, config) => { + return params.map((param) => { + // Set defaults for fromDate and toDate if not provided + const now = dayjs() + const fromDate = param.fromDate || now.subtract(7, 'day').format('MM-DD-YYYY') + const toDate = param.toDate || now.format('MM-DD-YYYY') + + // Validate date format MM-DD-YYYY + const dateRegex = /^(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])-\d{4}$/ + if (fromDate && !dateRegex.test(fromDate)) { + throw new Error('fromDate must be in MM-DD-YYYY format') + } + if (toDate && !dateRegex.test(toDate)) { + throw new Error('toDate must be in MM-DD-YYYY format') + } + + const method = 'GET' + const path = + '/navapigateway/api/v1/FundAccountingData/GetOfficialNAVAndPerformanceReturnsForFund' + const query = `globalFundID=${param.globalFundID}&fromDate=${fromDate}&toDate=${toDate}` + // Body is empy for GET + const body = '' + + const headers = getNavRequestHeaders( + method, + path + '?' + query, + body, + config.API_KEY, + config.SECRET_KEY, + ) + return { + params: [param], + request: { + baseURL: config.API_ENDPOINT, + url: path, + headers, + params: { + globalFundID: param.globalFundID, + fromDate, + toDate, + }, + }, + } + }) + }, + parseResponse: (params, response) => { + if (!response.data || !Array.isArray(response.data.Data) || response.data.Data.length === 0) { + return params.map((param) => ({ + params: param, + response: { + errorMessage: `No NAV data returned for fund ${param.globalFundID}`, + statusCode: 502, + }, + })) + } + + // Find the latest NAV entry by Accounting Date + const latest = response.data.Data.reduce((a, b) => { + return new Date(a['Accounting Date']) > new Date(b['Accounting Date']) ? a : b + }) + + const timestamps = { + providerIndicatedTimeUnixMs: new Date(latest['Accounting Date']).getTime(), + } + + return params.map((param) => ({ + params: param, + response: { + result: latest['NAV Per Share'], + data: { + navPerShare: latest['NAV Per Share'], + navDate: latest['Accounting Date'], + globalFundID: param.globalFundID, + }, + timestamps, + statusCode: 200, + }, + })) + }, +}) diff --git a/packages/sources/nav-libre/test-payload.json b/packages/sources/nav-libre/test-payload.json new file mode 100644 index 0000000000..abf2b9f651 --- /dev/null +++ b/packages/sources/nav-libre/test-payload.json @@ -0,0 +1,7 @@ +{ + "requests": [{ + "globalFundID": 1234, + "fromDate": "01-01-2024", + "toDate": "01-30-2024" + }] +} diff --git a/packages/sources/nav-libre/test/integration/__snapshots__/adapter.test.ts.snap b/packages/sources/nav-libre/test/integration/__snapshots__/adapter.test.ts.snap new file mode 100644 index 0000000000..9d8cb03b20 --- /dev/null +++ b/packages/sources/nav-libre/test/integration/__snapshots__/adapter.test.ts.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`execute nav endpoint should return success 1`] = ` +{ + "data": { + "globalFundID": 1234, + "navDate": "01-01-2024", + "navPerShare": 123.45, + }, + "result": 123.45, + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 978347471111, + "providerDataRequestedUnixMs": 978347471111, + }, +} +`; diff --git a/packages/sources/nav-libre/test/integration/adapter.test.ts b/packages/sources/nav-libre/test/integration/adapter.test.ts new file mode 100644 index 0000000000..470e67e441 --- /dev/null +++ b/packages/sources/nav-libre/test/integration/adapter.test.ts @@ -0,0 +1,50 @@ +import { + TestAdapter, + setEnvVariables, +} from '@chainlink/external-adapter-framework/util/testing-utils' +import * as nock from 'nock' +import { mockResponseSuccess } from './fixtures' + +describe('execute', () => { + let spy: jest.SpyInstance + let testAdapter: TestAdapter + let oldEnv: NodeJS.ProcessEnv + + beforeAll(async () => { + oldEnv = JSON.parse(JSON.stringify(process.env)) + process.env.API_KEY = process.env.API_KEY ?? 'fake-api-key' + process.env.SECRET_KEY = 'SOME_SECRET_KEY' + const mockDate = new Date('2001-01-01T11:11:11.111Z') + spy = jest.spyOn(Date, 'now').mockReturnValue(mockDate.getTime()) + + const adapter = (await import('./../../src')).adapter + adapter.rateLimiting = undefined + testAdapter = await TestAdapter.startWithMockedCache(adapter, { + testAdapter: {} as TestAdapter, + }) + }) + + afterAll(async () => { + setEnvVariables(oldEnv) + await testAdapter.api.close() + nock.restore() + nock.cleanAll() + spy.mockRestore() + }) + + describe('nav endpoint', () => { + it('should return success', async () => { + const data = { + globalFundID: 1234, + fromDate: '01-01-2024', + toDate: '01-01-2024', + endpoint: 'nav', + transport: 'rest', + } + mockResponseSuccess() + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }) + }) +}) diff --git a/packages/sources/nav-libre/test/integration/fixtures.ts b/packages/sources/nav-libre/test/integration/fixtures.ts new file mode 100644 index 0000000000..e2c86c3db1 --- /dev/null +++ b/packages/sources/nav-libre/test/integration/fixtures.ts @@ -0,0 +1,46 @@ +import nock from 'nock' + +export const mockResponseSuccess = (): nock.Scope => + nock('https://api.navfundservices.com', { + encodedQueryParams: true, + }) + .get('/navapigateway/api/v1/FundAccountingData/GetOfficialNAVAndPerformanceReturnsForFund') + .query(true) + .reply( + 200, + { + Data: [ + { + 'Trading Level Net ROR': { + DTD: 0.1, + MTD: 0.2, + QTD: 0.3, + YTD: 0.4, + ITD: 0.5, + }, + 'Net ROR': { + DTD: 0.6, + MTD: 0.7, + QTD: 0.8, + YTD: 0.9, + ITD: 1.0, + }, + 'NAV Per Share': 123.45, + 'Next NAV Price': 124.56, + 'Accounting Date': '01-01-2024', + 'Ending Balance': 1000000, + }, + ], + }, + [ + 'Content-Type', + 'application/json', + 'Connection', + 'close', + 'Vary', + 'Accept-Encoding', + 'Vary', + 'Origin', + ], + ) + .persist() diff --git a/packages/sources/nav-libre/tsconfig.json b/packages/sources/nav-libre/tsconfig.json new file mode 100644 index 0000000000..f59363fd76 --- /dev/null +++ b/packages/sources/nav-libre/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*", "src/**/*.json"], + "exclude": ["dist", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/packages/sources/nav-libre/tsconfig.test.json b/packages/sources/nav-libre/tsconfig.test.json new file mode 100755 index 0000000000..e3de28cb5c --- /dev/null +++ b/packages/sources/nav-libre/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*", "**/test", "src/**/*.json"], + "compilerOptions": { + "noEmit": true + } +} diff --git a/packages/tsconfig.json b/packages/tsconfig.json index fe2f777f19..a39110c618 100644 --- a/packages/tsconfig.json +++ b/packages/tsconfig.json @@ -512,6 +512,9 @@ { "path": "./sources/nav-generic" }, + { + "path": "./sources/nav-libre" + }, { "path": "./sources/ncfx" }, diff --git a/packages/tsconfig.test.json b/packages/tsconfig.test.json index 120334750f..e74eab5f5b 100644 --- a/packages/tsconfig.test.json +++ b/packages/tsconfig.test.json @@ -512,6 +512,9 @@ { "path": "./sources/nav-generic/tsconfig.test.json" }, + { + "path": "./sources/nav-libre/tsconfig.test.json" + }, { "path": "./sources/ncfx/tsconfig.test.json" }, diff --git a/yarn.lock b/yarn.lock index 94b15ca736..71237f6085 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4995,6 +4995,23 @@ __metadata: languageName: unknown linkType: soft +"@chainlink/nav-libre-adapter@workspace:packages/sources/nav-libre": + version: 0.0.0-use.local + resolution: "@chainlink/nav-libre-adapter@workspace:packages/sources/nav-libre" + dependencies: + "@chainlink/external-adapter-framework": "npm:2.6.0" + "@types/crypto-js": "npm:^4" + "@types/jest": "npm:27.5.2" + "@types/node": "npm:16.18.119" + crypto-js: "npm:^4.2.0" + dayjs: "npm:^1.11.13" + nock: "npm:13.5.5" + tslib: "npm:2.4.1" + typescript: "npm:5.6.3" + uuid: "npm:^11.1.0" + languageName: unknown + linkType: soft + "@chainlink/ncfx-adapter@workspace:*, @chainlink/ncfx-adapter@workspace:packages/sources/ncfx": version: 0.0.0-use.local resolution: "@chainlink/ncfx-adapter@workspace:packages/sources/ncfx" @@ -12492,7 +12509,7 @@ __metadata: languageName: node linkType: hard -"@types/crypto-js@npm:4.2.2": +"@types/crypto-js@npm:4.2.2, @types/crypto-js@npm:^4": version: 4.2.2 resolution: "@types/crypto-js@npm:4.2.2" checksum: 10/a40fc5a9219fd33f54ba3e094c5e5ab2904d3106681a76f1029bb038976591e9c8959099963bf4474fde21c2d8d00c1f896445206a3a58f85588f9cb1bd96a9a @@ -12638,6 +12655,16 @@ __metadata: languageName: node linkType: hard +"@types/jest@npm:27.5.2": + version: 27.5.2 + resolution: "@types/jest@npm:27.5.2" + dependencies: + jest-matcher-utils: "npm:^27.0.0" + pretty-format: "npm:^27.0.0" + checksum: 10/8608696fbdea81bc9a600d1c5aeb290063357eaa55c0174e7db15087c4f483113b35f8b4c4ae364d2632cfed15a4dd674786254826b946c896de5612c8cb1a26 + languageName: node + linkType: hard + "@types/jest@npm:29.5.14, @types/jest@npm:^29.5.14": version: 29.5.14 resolution: "@types/jest@npm:29.5.14" @@ -12801,6 +12828,13 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:16.18.119": + version: 16.18.119 + resolution: "@types/node@npm:16.18.119" + checksum: 10/ada2921602064448d3584c3d726024e61bb2d837d98fd7f6e057f3d6e945a072ee54415d357be5d6c97a77d5ae1554a7e916bf04a0bf2ba7cbc0b3bad81b7412 + languageName: node + linkType: hard + "@types/node@npm:22.14.1": version: 22.14.1 resolution: "@types/node@npm:22.14.1" @@ -16407,7 +16441,7 @@ __metadata: languageName: node linkType: hard -"crypto-js@npm:4.2.0": +"crypto-js@npm:4.2.0, crypto-js@npm:^4.2.0": version: 4.2.0 resolution: "crypto-js@npm:4.2.0" checksum: 10/c7bcc56a6e01c3c397e95aa4a74e4241321f04677f9a618a8f48a63b5781617248afb9adb0629824792e7ec20ca0d4241a49b6b2938ae6f973ec4efc5c53c924 @@ -16615,6 +16649,13 @@ __metadata: languageName: node linkType: hard +"dayjs@npm:^1.11.13": + version: 1.11.13 + resolution: "dayjs@npm:1.11.13" + checksum: 10/7374d63ab179b8d909a95e74790def25c8986e329ae989840bacb8b1888be116d20e1c4eee75a69ea0dfbae13172efc50ef85619d304ee7ca3c01d5878b704f5 + languageName: node + linkType: hard + "debug@npm:*, debug@npm:4, debug@npm:^4.0.1, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.2.0, debug@npm:^4.3.0, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:~4.3.1, debug@npm:~4.3.2": version: 4.3.7 resolution: "debug@npm:4.3.7" @@ -17020,6 +17061,13 @@ __metadata: languageName: node linkType: hard +"diff-sequences@npm:^27.5.1": + version: 27.5.1 + resolution: "diff-sequences@npm:27.5.1" + checksum: 10/34d852a13eb82735c39944a050613f952038614ce324256e1c3544948fa090f1ca7f329a4f1f57c31fe7ac982c17068d8915b633e300f040b97708c81ceb26cd + languageName: node + linkType: hard + "diff-sequences@npm:^29.6.3": version: 29.6.3 resolution: "diff-sequences@npm:29.6.3" @@ -22733,6 +22781,18 @@ __metadata: languageName: node linkType: hard +"jest-diff@npm:^27.5.1": + version: 27.5.1 + resolution: "jest-diff@npm:27.5.1" + dependencies: + chalk: "npm:^4.0.0" + diff-sequences: "npm:^27.5.1" + jest-get-type: "npm:^27.5.1" + pretty-format: "npm:^27.5.1" + checksum: 10/af454f30f33af625832bdb02614e188a41e33ce79086b43f95dbcc515274dd36bf8443b8d0299e22c2416e7591da4321e6bc7f2b0aef56471d1133c6b6833221 + languageName: node + linkType: hard + "jest-diff@npm:^29.7.0": version: 29.7.0 resolution: "jest-diff@npm:29.7.0" @@ -22781,6 +22841,13 @@ __metadata: languageName: node linkType: hard +"jest-get-type@npm:^27.5.1": + version: 27.5.1 + resolution: "jest-get-type@npm:27.5.1" + checksum: 10/63064ab70195c21007d897c1157bf88ff94a790824a10f8c890392e7d17eda9c3900513cb291ca1c8d5722cad79169764e9a1279f7c8a9c4cd6e9109ff04bbc0 + languageName: node + linkType: hard + "jest-get-type@npm:^29.6.3": version: 29.6.3 resolution: "jest-get-type@npm:29.6.3" @@ -22821,6 +22888,18 @@ __metadata: languageName: node linkType: hard +"jest-matcher-utils@npm:^27.0.0": + version: 27.5.1 + resolution: "jest-matcher-utils@npm:27.5.1" + dependencies: + chalk: "npm:^4.0.0" + jest-diff: "npm:^27.5.1" + jest-get-type: "npm:^27.5.1" + pretty-format: "npm:^27.5.1" + checksum: 10/037f99878a0515581d7728ed3aed03707810f4da5a1c7ffb9d68a2c6c3180851a6ec40b559af37fbe891dde3ba12552b19e47b8188a27b6c5a53376be6907f32 + languageName: node + linkType: hard + "jest-matcher-utils@npm:^29.7.0": version: 29.7.0 resolution: "jest-matcher-utils@npm:29.7.0" @@ -26049,6 +26128,17 @@ __metadata: languageName: node linkType: hard +"nock@npm:13.5.5": + version: 13.5.5 + resolution: "nock@npm:13.5.5" + dependencies: + debug: "npm:^4.1.0" + json-stringify-safe: "npm:^5.0.1" + propagate: "npm:^2.0.0" + checksum: 10/c19d7bf9654db056357a22b00127bb5606c1bbdff188a5b6c469825e580e31cd0cb0701bce8dd8b4876dbbd36a145fdb681fd69fd59308d6db4923ce8ab2439e + languageName: node + linkType: hard + "nock@npm:13.5.6, nock@npm:^13.2.4, nock@npm:^13.3.0": version: 13.5.6 resolution: "nock@npm:13.5.6" @@ -27804,6 +27894,17 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:^27.0.0, pretty-format@npm:^27.5.1": + version: 27.5.1 + resolution: "pretty-format@npm:27.5.1" + dependencies: + ansi-regex: "npm:^5.0.1" + ansi-styles: "npm:^5.0.0" + react-is: "npm:^17.0.1" + checksum: 10/248990cbef9e96fb36a3e1ae6b903c551ca4ddd733f8d0912b9cc5141d3d0b3f9f8dfb4d799fb1c6723382c9c2083ffbfa4ad43ff9a0e7535d32d41fd5f01da6 + languageName: node + linkType: hard + "pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": version: 29.7.0 resolution: "pretty-format@npm:29.7.0" @@ -28274,6 +28375,13 @@ __metadata: languageName: node linkType: hard +"react-is@npm:^17.0.1": + version: 17.0.2 + resolution: "react-is@npm:17.0.2" + checksum: 10/73b36281e58eeb27c9cc6031301b6ae19ecdc9f18ae2d518bdb39b0ac564e65c5779405d623f1df9abf378a13858b79442480244bd579968afc1faf9a2ce5e05 + languageName: node + linkType: hard + "react-is@npm:^18.0.0": version: 18.3.1 resolution: "react-is@npm:18.3.1" @@ -31636,6 +31744,16 @@ __metadata: languageName: node linkType: hard +"typescript@npm:5.6.3": + version: 5.6.3 + resolution: "typescript@npm:5.6.3" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10/c328e418e124b500908781d9f7b9b93cf08b66bf5936d94332b463822eea2f4e62973bfb3b8a745fdc038785cb66cf59d1092bac3ec2ac6a3e5854687f7833f1 + languageName: node + linkType: hard + "typescript@npm:5.8.3": version: 5.8.3 resolution: "typescript@npm:5.8.3" @@ -31646,6 +31764,16 @@ __metadata: languageName: node linkType: hard +"typescript@patch:typescript@npm%3A5.6.3#optional!builtin": + version: 5.6.3 + resolution: "typescript@patch:typescript@npm%3A5.6.3#optional!builtin::version=5.6.3&hash=8c6c40" + bin: + tsc: bin/tsc + tsserver: bin/tsserver + checksum: 10/00504c01ee42d470c23495426af07512e25e6546bce7e24572e72a9ca2e6b2e9bea63de4286c3cfea644874da1467dcfca23f4f98f7caf20f8b03c0213bb6837 + languageName: node + linkType: hard + "typescript@patch:typescript@npm%3A5.8.3#optional!builtin": version: 5.8.3 resolution: "typescript@patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5" @@ -32120,6 +32248,15 @@ __metadata: languageName: node linkType: hard +"uuid@npm:^11.1.0": + version: 11.1.0 + resolution: "uuid@npm:11.1.0" + bin: + uuid: dist/esm/bin/uuid + checksum: 10/d2da43b49b154d154574891ced66d0c83fc70caaad87e043400cf644423b067542d6f3eb641b7c819224a7cd3b4c2f21906acbedd6ec9c6a05887aa9115a9cf5 + languageName: node + linkType: hard + "uuid@npm:^3.3.2": version: 3.4.0 resolution: "uuid@npm:3.4.0" From 06a3c93a72737c8c4595288a1bd807e3bd9600e5 Mon Sep 17 00:00:00 2001 From: Mohamed Mehany <7327188+mohamed-mehany@users.noreply.github.com> Date: Mon, 7 Jul 2025 18:35:08 +0400 Subject: [PATCH 2/7] Adds date validation --- .pnp.cjs | 29 ++++++++++++ packages/sources/nav-libre/package.json | 2 + .../nav-libre/src/transport/authentication.ts | 2 +- .../sources/nav-libre/src/transport/fund.ts | 47 +++++++++++++++++++ .../sources/nav-libre/src/transport/nav.ts | 43 ++++++++++++++++- yarn.lock | 41 +++++++++++++--- 6 files changed, 154 insertions(+), 10 deletions(-) create mode 100644 packages/sources/nav-libre/src/transport/fund.ts diff --git a/.pnp.cjs b/.pnp.cjs index a445c31d11..8be24c1624 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -8051,9 +8051,11 @@ const RAW_RUNTIME_STATE = "packageDependencies": [\ ["@chainlink/nav-libre-adapter", "workspace:packages/sources/nav-libre"],\ ["@chainlink/external-adapter-framework", "npm:2.6.0"],\ + ["@types/async-retry", "npm:1.4.9"],\ ["@types/crypto-js", "npm:4.2.2"],\ ["@types/jest", "npm:27.5.2"],\ ["@types/node", "npm:16.18.119"],\ + ["async-retry", "npm:1.3.3"],\ ["crypto-js", "npm:4.2.0"],\ ["dayjs", "npm:1.11.13"],\ ["nock", "npm:13.5.5"],\ @@ -16126,6 +16128,16 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["@types/async-retry", [\ + ["npm:1.4.9", {\ + "packageLocation": "./.yarn/cache/@types-async-retry-npm-1.4.9-0d12d53d91-9cbfe8fb9a.zip/node_modules/@types/async-retry/",\ + "packageDependencies": [\ + ["@types/async-retry", "npm:1.4.9"],\ + ["@types/retry", "npm:0.12.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@types/babel__core", [\ ["npm:7.20.5", {\ "packageLocation": "./.yarn/cache/@types-babel__core-npm-7.20.5-4d95f75eab-c32838d280.zip/node_modules/@types/babel__core/",\ @@ -16869,6 +16881,13 @@ const RAW_RUNTIME_STATE = ["@types/retry", "npm:0.12.0"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:0.12.5", {\ + "packageLocation": "./.yarn/cache/@types-retry-npm-0.12.5-f1986a76a6-3fb6bf9183.zip/node_modules/@types/retry/",\ + "packageDependencies": [\ + ["@types/retry", "npm:0.12.5"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["@types/rimraf", [\ @@ -18369,6 +18388,16 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["async-retry", [\ + ["npm:1.3.3", {\ + "packageLocation": "./.yarn/cache/async-retry-npm-1.3.3-bc90c5cee0-38a7152ff7.zip/node_modules/async-retry/",\ + "packageDependencies": [\ + ["async-retry", "npm:1.3.3"],\ + ["retry", "npm:0.13.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["asynckit", [\ ["npm:0.4.0", {\ "packageLocation": "./.yarn/cache/asynckit-npm-0.4.0-c718858525-3ce727cbc7.zip/node_modules/asynckit/",\ diff --git a/packages/sources/nav-libre/package.json b/packages/sources/nav-libre/package.json index a560cee382..ab19bb0d35 100644 --- a/packages/sources/nav-libre/package.json +++ b/packages/sources/nav-libre/package.json @@ -28,6 +28,7 @@ "start": "yarn server:dist" }, "devDependencies": { + "@types/async-retry": "^1", "@types/crypto-js": "^4", "@types/jest": "27.5.2", "@types/node": "16.18.119", @@ -36,6 +37,7 @@ }, "dependencies": { "@chainlink/external-adapter-framework": "2.6.0", + "async-retry": "^1.3.3", "crypto-js": "^4.2.0", "dayjs": "^1.11.13", "tslib": "2.4.1", diff --git a/packages/sources/nav-libre/src/transport/authentication.ts b/packages/sources/nav-libre/src/transport/authentication.ts index 761a644079..d4d5df0219 100644 --- a/packages/sources/nav-libre/src/transport/authentication.ts +++ b/packages/sources/nav-libre/src/transport/authentication.ts @@ -4,7 +4,7 @@ import { v4 as uuidv4 } from 'uuid' /** * Generate the necessary headers for calling the NAV API with a 5-minute-valid signature. */ -export const getNavRequestHeaders = ( +export const getRequestHeaders = ( method: string, path: string, body: string, diff --git a/packages/sources/nav-libre/src/transport/fund.ts b/packages/sources/nav-libre/src/transport/fund.ts new file mode 100644 index 0000000000..e92eaa84d3 --- /dev/null +++ b/packages/sources/nav-libre/src/transport/fund.ts @@ -0,0 +1,47 @@ +import { Requester } from '@chainlink/external-adapter-framework/util/requester' +import { AdapterError } from '@chainlink/external-adapter-framework/validation/error' +import { getRequestHeaders } from './authentication' +interface Response { + FundName: string + GlobalFundID: number + FundEndDate: string + FundDailyAccountingStartDate: string + FundDailyAccountingLastAvailableDate: string + FundOfficialAccountingLastAvailableDate: string + PortfolioLastAvailableDate: string +} + +export const getFund = async ( + globalFundID: number, + url: string, + apiKey: string, + secret: string, + requester: Requester, +) => { + const method = 'GET' + const query = `globalFundID=${globalFundID}` + const path = '/navapigateway/api/v1/FundAccountingData/GetAccountingDataDates' + const requestConfig = { + baseURL: url, + url: path, + method: method, + headers: getRequestHeaders(method, path + '?' + query, '', apiKey, secret), + } + + const sourceResponse = await requester.request( + JSON.stringify(requestConfig), + requestConfig, + ) + + if (sourceResponse.response.data.length == 0) { + throw new AdapterError({ + statusCode: 400, + message: `No fund found`, + }) + } + + const response = sourceResponse.response.data[0] + console.log(response) + + return [response.GlobalFundID.toString(), response.FundDailyAccountingLastAvailableDate + 'Z'] +} diff --git a/packages/sources/nav-libre/src/transport/nav.ts b/packages/sources/nav-libre/src/transport/nav.ts index 6a2677ca4d..3b7620fc7f 100644 --- a/packages/sources/nav-libre/src/transport/nav.ts +++ b/packages/sources/nav-libre/src/transport/nav.ts @@ -1,7 +1,7 @@ import { HttpTransport } from '@chainlink/external-adapter-framework/transports' import dayjs from 'dayjs' import { BaseEndpointTypes } from '../endpoint/nav' -import { getNavRequestHeaders } from './authentication' +import { getRequestHeaders } from './authentication' export interface ResponseSchema { Data: { @@ -50,6 +50,15 @@ export const httpTransport = new HttpTransport({ throw new Error('toDate must be in MM-DD-YYYY format') } + const response = await getFund( + getFund, + param.globalFundID, + config.API_ENDPOINT, + config.API_KEY, + config.SECRET_KEY, + this.requestor, + ) + const method = 'GET' const path = '/navapigateway/api/v1/FundAccountingData/GetOfficialNAVAndPerformanceReturnsForFund' @@ -57,7 +66,7 @@ export const httpTransport = new HttpTransport({ // Body is empy for GET const body = '' - const headers = getNavRequestHeaders( + const headers = getRequestHeaders( method, path + '?' + query, body, @@ -114,3 +123,33 @@ export const httpTransport = new HttpTransport({ })) }, }) + +// async function callFunction( +// func: (...args: T) => Promise, +// maxRetry: number, +// ...args: T +// ): Promise { +// return retry( +// async (bail, attempt) => { +// try { +// return await func(...args) +// } catch (err) { +// if (attempt >= maxRetry) { +// // give up and bubble the error +// bail(err) +// return // unreachable, but satisfies TS +// } +// // otherwise throw to trigger another retry +// throw err +// } +// }, +// { +// retries: maxRetry, +// minTimeout: 10000, +// maxTimeout: 10000, +// onRetry: (err, attempt) => { +// logger.info(`${maxRetry - attempt} retries remaining, sleeping for 10000ms...`) +// }, +// }, +// ) +// } diff --git a/yarn.lock b/yarn.lock index 71237f6085..3a2d95f639 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5000,9 +5000,11 @@ __metadata: resolution: "@chainlink/nav-libre-adapter@workspace:packages/sources/nav-libre" dependencies: "@chainlink/external-adapter-framework": "npm:2.6.0" + "@types/async-retry": "npm:^1" "@types/crypto-js": "npm:^4" "@types/jest": "npm:27.5.2" "@types/node": "npm:16.18.119" + async-retry: "npm:^1.3.3" crypto-js: "npm:^4.2.0" dayjs: "npm:^1.11.13" nock: "npm:13.5.5" @@ -12359,6 +12361,15 @@ __metadata: languageName: node linkType: hard +"@types/async-retry@npm:^1": + version: 1.4.9 + resolution: "@types/async-retry@npm:1.4.9" + dependencies: + "@types/retry": "npm:*" + checksum: 10/9cbfe8fb9a6c3559c7084b359edb7b6bae30e16e023eb959e58362b799fc5a9450961e77d994b86526c8de45815eed02c081a104712fb6f2f55ef4cba7263601 + languageName: node + linkType: hard + "@types/babel__core@npm:7.20.5, @types/babel__core@npm:^7.1.14": version: 7.20.5 resolution: "@types/babel__core@npm:7.20.5" @@ -13004,6 +13015,13 @@ __metadata: languageName: node linkType: hard +"@types/retry@npm:*": + version: 0.12.5 + resolution: "@types/retry@npm:0.12.5" + checksum: 10/3fb6bf91835ca0eb2987567d6977585235a7567f8aeb38b34a8bb7bbee57ac050ed6f04b9998cda29701b8c893f5dfe315869bc54ac17e536c9235637fe351a2 + languageName: node + linkType: hard + "@types/retry@npm:0.12.0": version: 0.12.0 resolution: "@types/retry@npm:0.12.0" @@ -14206,6 +14224,15 @@ __metadata: languageName: node linkType: hard +"async-retry@npm:^1.3.3": + version: 1.3.3 + resolution: "async-retry@npm:1.3.3" + dependencies: + retry: "npm:0.13.1" + checksum: 10/38a7152ff7265a9321ea214b9c69e8224ab1febbdec98efbbde6e562f17ff68405569b796b1c5271f354aef8783665d29953f051f68c1fc45306e61aec82fdc4 + languageName: node + linkType: hard + "async@npm:^3.2.2, async@npm:^3.2.3, async@npm:^3.2.4": version: 3.2.6 resolution: "async@npm:3.2.6" @@ -29177,6 +29204,13 @@ __metadata: languageName: node linkType: hard +"retry@npm:0.13.1, retry@npm:^0.13.1": + version: 0.13.1 + resolution: "retry@npm:0.13.1" + checksum: 10/6125ec2e06d6e47e9201539c887defba4e47f63471db304c59e4b82fc63c8e89ca06a77e9d34939a9a42a76f00774b2f46c0d4a4cbb3e287268bd018ed69426d + languageName: node + linkType: hard + "retry@npm:^0.12.0": version: 0.12.0 resolution: "retry@npm:0.12.0" @@ -29184,13 +29218,6 @@ __metadata: languageName: node linkType: hard -"retry@npm:^0.13.1": - version: 0.13.1 - resolution: "retry@npm:0.13.1" - checksum: 10/6125ec2e06d6e47e9201539c887defba4e47f63471db304c59e4b82fc63c8e89ca06a77e9d34939a9a42a76f00774b2f46c0d4a4cbb3e287268bd018ed69426d - languageName: node - linkType: hard - "reusify@npm:^1.0.4": version: 1.0.4 resolution: "reusify@npm:1.0.4" From 721370c7d9173e6c776d8004a4af007879d7c1d0 Mon Sep 17 00:00:00 2001 From: Mohamed Mehany <7327188+mohamed-mehany@users.noreply.github.com> Date: Thu, 10 Jul 2025 03:42:05 +0400 Subject: [PATCH 3/7] Refactors to use SubscribeTransport for multi HTTP requests --- .pnp.cjs | 134 +--------- .../uuid-npm-11.1.0-61d0d08928-d2da43b49b.zip | Bin 0 -> 80844 bytes packages/sources/nav-libre/package.json | 12 +- .../sources/nav-libre/src/config/index.ts | 8 +- .../sources/nav-libre/src/endpoint/nav.ts | 22 +- packages/sources/nav-libre/src/index.ts | 7 - .../nav-libre/src/transport/date-utils.ts | 32 +++ .../nav-libre/src/transport/fund-dates.ts | 39 +++ .../sources/nav-libre/src/transport/fund.ts | 59 +++-- .../sources/nav-libre/src/transport/nav.ts | 238 ++++++++---------- packages/sources/nav-libre/test-payload.json | 2 - .../__snapshots__/adapter.test.ts.snap | 5 +- .../test/integration/adapter.test.ts | 5 +- .../nav-libre/test/integration/fixtures.ts | 21 ++ yarn.lock | 164 +----------- 15 files changed, 267 insertions(+), 481 deletions(-) create mode 100644 .yarn/cache/uuid-npm-11.1.0-61d0d08928-d2da43b49b.zip create mode 100644 packages/sources/nav-libre/src/transport/date-utils.ts create mode 100644 packages/sources/nav-libre/src/transport/fund-dates.ts diff --git a/.pnp.cjs b/.pnp.cjs index 8be24c1624..b44e32a464 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -8051,16 +8051,14 @@ const RAW_RUNTIME_STATE = "packageDependencies": [\ ["@chainlink/nav-libre-adapter", "workspace:packages/sources/nav-libre"],\ ["@chainlink/external-adapter-framework", "npm:2.6.0"],\ - ["@types/async-retry", "npm:1.4.9"],\ ["@types/crypto-js", "npm:4.2.2"],\ - ["@types/jest", "npm:27.5.2"],\ - ["@types/node", "npm:16.18.119"],\ - ["async-retry", "npm:1.3.3"],\ + ["@types/jest", "npm:29.5.14"],\ + ["@types/node", "npm:22.14.1"],\ ["crypto-js", "npm:4.2.0"],\ - ["dayjs", "npm:1.11.13"],\ - ["nock", "npm:13.5.5"],\ + ["date-fns", "npm:4.1.0"],\ + ["nock", "npm:13.5.6"],\ ["tslib", "npm:2.4.1"],\ - ["typescript", "patch:typescript@npm%3A5.6.3#optional!builtin::version=5.6.3&hash=8c6c40"],\ + ["typescript", "patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5"],\ ["uuid", "npm:11.1.0"]\ ],\ "linkType": "SOFT"\ @@ -16128,16 +16126,6 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ - ["@types/async-retry", [\ - ["npm:1.4.9", {\ - "packageLocation": "./.yarn/cache/@types-async-retry-npm-1.4.9-0d12d53d91-9cbfe8fb9a.zip/node_modules/@types/async-retry/",\ - "packageDependencies": [\ - ["@types/async-retry", "npm:1.4.9"],\ - ["@types/retry", "npm:0.12.5"]\ - ],\ - "linkType": "HARD"\ - }]\ - ]],\ ["@types/babel__core", [\ ["npm:7.20.5", {\ "packageLocation": "./.yarn/cache/@types-babel__core-npm-7.20.5-4d95f75eab-c32838d280.zip/node_modules/@types/babel__core/",\ @@ -16477,15 +16465,6 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["@types/jest", [\ - ["npm:27.5.2", {\ - "packageLocation": "./.yarn/cache/@types-jest-npm-27.5.2-f8ba570ba6-8608696fbd.zip/node_modules/@types/jest/",\ - "packageDependencies": [\ - ["@types/jest", "npm:27.5.2"],\ - ["jest-matcher-utils", "npm:27.5.1"],\ - ["pretty-format", "npm:27.5.1"]\ - ],\ - "linkType": "HARD"\ - }],\ ["npm:29.5.14", {\ "packageLocation": "./.yarn/cache/@types-jest-npm-29.5.14-506446c38e-59ec7a9c46.zip/node_modules/@types/jest/",\ "packageDependencies": [\ @@ -16668,13 +16647,6 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "HARD"\ }],\ - ["npm:16.18.119", {\ - "packageLocation": "./.yarn/cache/@types-node-npm-16.18.119-d3f1632e08-ada2921602.zip/node_modules/@types/node/",\ - "packageDependencies": [\ - ["@types/node", "npm:16.18.119"]\ - ],\ - "linkType": "HARD"\ - }],\ ["npm:22.14.1", {\ "packageLocation": "./.yarn/cache/@types-node-npm-22.14.1-ff7e0a29d7-561b1ad98e.zip/node_modules/@types/node/",\ "packageDependencies": [\ @@ -16881,13 +16853,6 @@ const RAW_RUNTIME_STATE = ["@types/retry", "npm:0.12.0"]\ ],\ "linkType": "HARD"\ - }],\ - ["npm:0.12.5", {\ - "packageLocation": "./.yarn/cache/@types-retry-npm-0.12.5-f1986a76a6-3fb6bf9183.zip/node_modules/@types/retry/",\ - "packageDependencies": [\ - ["@types/retry", "npm:0.12.5"]\ - ],\ - "linkType": "HARD"\ }]\ ]],\ ["@types/rimraf", [\ @@ -18388,16 +18353,6 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ - ["async-retry", [\ - ["npm:1.3.3", {\ - "packageLocation": "./.yarn/cache/async-retry-npm-1.3.3-bc90c5cee0-38a7152ff7.zip/node_modules/async-retry/",\ - "packageDependencies": [\ - ["async-retry", "npm:1.3.3"],\ - ["retry", "npm:0.13.1"]\ - ],\ - "linkType": "HARD"\ - }]\ - ]],\ ["asynckit", [\ ["npm:0.4.0", {\ "packageLocation": "./.yarn/cache/asynckit-npm-0.4.0-c718858525-3ce727cbc7.zip/node_modules/asynckit/",\ @@ -21233,15 +21188,6 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ - ["dayjs", [\ - ["npm:1.11.13", {\ - "packageLocation": "./.yarn/cache/dayjs-npm-1.11.13-d478bb9479-7374d63ab1.zip/node_modules/dayjs/",\ - "packageDependencies": [\ - ["dayjs", "npm:1.11.13"]\ - ],\ - "linkType": "HARD"\ - }]\ - ]],\ ["debug", [\ ["npm:2.6.9", {\ "packageLocation": "./.yarn/cache/debug-npm-2.6.9-7d4cb597dc-e07005f2b4.zip/node_modules/debug/",\ @@ -21769,13 +21715,6 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["diff-sequences", [\ - ["npm:27.5.1", {\ - "packageLocation": "./.yarn/cache/diff-sequences-npm-27.5.1-29338362fa-34d852a13e.zip/node_modules/diff-sequences/",\ - "packageDependencies": [\ - ["diff-sequences", "npm:27.5.1"]\ - ],\ - "linkType": "HARD"\ - }],\ ["npm:29.6.3", {\ "packageLocation": "./.yarn/cache/diff-sequences-npm-29.6.3-18ab2c9949-179daf9d2f.zip/node_modules/diff-sequences/",\ "packageDependencies": [\ @@ -28060,17 +27999,6 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["jest-diff", [\ - ["npm:27.5.1", {\ - "packageLocation": "./.yarn/cache/jest-diff-npm-27.5.1-818e549196-af454f30f3.zip/node_modules/jest-diff/",\ - "packageDependencies": [\ - ["jest-diff", "npm:27.5.1"],\ - ["chalk", "npm:4.1.2"],\ - ["diff-sequences", "npm:27.5.1"],\ - ["jest-get-type", "npm:27.5.1"],\ - ["pretty-format", "npm:27.5.1"]\ - ],\ - "linkType": "HARD"\ - }],\ ["npm:29.7.0", {\ "packageLocation": "./.yarn/cache/jest-diff-npm-29.7.0-0149e01930-6f3a7eb9cd.zip/node_modules/jest-diff/",\ "packageDependencies": [\ @@ -28123,13 +28051,6 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["jest-get-type", [\ - ["npm:27.5.1", {\ - "packageLocation": "./.yarn/cache/jest-get-type-npm-27.5.1-980fbf7a43-63064ab701.zip/node_modules/jest-get-type/",\ - "packageDependencies": [\ - ["jest-get-type", "npm:27.5.1"]\ - ],\ - "linkType": "HARD"\ - }],\ ["npm:29.6.3", {\ "packageLocation": "./.yarn/cache/jest-get-type-npm-29.6.3-500477292e-88ac9102d4.zip/node_modules/jest-get-type/",\ "packageDependencies": [\ @@ -28171,17 +28092,6 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["jest-matcher-utils", [\ - ["npm:27.5.1", {\ - "packageLocation": "./.yarn/cache/jest-matcher-utils-npm-27.5.1-0c47b071fb-037f99878a.zip/node_modules/jest-matcher-utils/",\ - "packageDependencies": [\ - ["jest-matcher-utils", "npm:27.5.1"],\ - ["chalk", "npm:4.1.2"],\ - ["jest-diff", "npm:27.5.1"],\ - ["jest-get-type", "npm:27.5.1"],\ - ["pretty-format", "npm:27.5.1"]\ - ],\ - "linkType": "HARD"\ - }],\ ["npm:29.7.0", {\ "packageLocation": "./.yarn/cache/jest-matcher-utils-npm-29.7.0-dfc74b630e-981904a494.zip/node_modules/jest-matcher-utils/",\ "packageDependencies": [\ @@ -31784,16 +31694,6 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["nock", [\ - ["npm:13.5.5", {\ - "packageLocation": "./.yarn/cache/nock-npm-13.5.5-ccb57f0a2f-c19d7bf965.zip/node_modules/nock/",\ - "packageDependencies": [\ - ["nock", "npm:13.5.5"],\ - ["debug", "virtual:e376c6d25689d1413f13b759a5649fe969efab30320e886cab81ece2b6daf8c4c74f642faff7228a9a286b4b82bc7bac5773e45f1085910307cd111b19a8cd17#npm:4.3.7"],\ - ["json-stringify-safe", "npm:5.0.1"],\ - ["propagate", "npm:2.0.1"]\ - ],\ - "linkType": "HARD"\ - }],\ ["npm:13.5.6", {\ "packageLocation": "./.yarn/cache/nock-npm-13.5.6-6fbafbb636-a57c265b75.zip/node_modules/nock/",\ "packageDependencies": [\ @@ -33700,16 +33600,6 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["pretty-format", [\ - ["npm:27.5.1", {\ - "packageLocation": "./.yarn/cache/pretty-format-npm-27.5.1-cd7d49696f-248990cbef.zip/node_modules/pretty-format/",\ - "packageDependencies": [\ - ["pretty-format", "npm:27.5.1"],\ - ["ansi-regex", "npm:5.0.1"],\ - ["ansi-styles", "npm:5.2.0"],\ - ["react-is", "npm:17.0.2"]\ - ],\ - "linkType": "HARD"\ - }],\ ["npm:29.7.0", {\ "packageLocation": "./.yarn/cache/pretty-format-npm-29.7.0-7d330b2ea2-dea96bc83c.zip/node_modules/pretty-format/",\ "packageDependencies": [\ @@ -34250,13 +34140,6 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["react-is", [\ - ["npm:17.0.2", {\ - "packageLocation": "./.yarn/cache/react-is-npm-17.0.2-091bbb8db6-73b36281e5.zip/node_modules/react-is/",\ - "packageDependencies": [\ - ["react-is", "npm:17.0.2"]\ - ],\ - "linkType": "HARD"\ - }],\ ["npm:18.3.1", {\ "packageLocation": "./.yarn/cache/react-is-npm-18.3.1-370a81e1e9-d5f60c87d2.zip/node_modules/react-is/",\ "packageDependencies": [\ @@ -37960,13 +37843,6 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["typescript", [\ - ["patch:typescript@npm%3A5.6.3#optional!builtin::version=5.6.3&hash=8c6c40", {\ - "packageLocation": "./.yarn/cache/typescript-patch-8964a48ba3-00504c01ee.zip/node_modules/typescript/",\ - "packageDependencies": [\ - ["typescript", "patch:typescript@npm%3A5.6.3#optional!builtin::version=5.6.3&hash=8c6c40"]\ - ],\ - "linkType": "HARD"\ - }],\ ["patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5", {\ "packageLocation": "./.yarn/cache/typescript-patch-f64146f279-b9b1e73dab.zip/node_modules/typescript/",\ "packageDependencies": [\ diff --git a/.yarn/cache/uuid-npm-11.1.0-61d0d08928-d2da43b49b.zip b/.yarn/cache/uuid-npm-11.1.0-61d0d08928-d2da43b49b.zip new file mode 100644 index 0000000000000000000000000000000000000000..6acc3b04295e20299b43883d2160820bd68e4fd9 GIT binary patch literal 80844 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 => expose(adapter) diff --git a/packages/sources/nav-libre/src/transport/date-utils.ts b/packages/sources/nav-libre/src/transport/date-utils.ts new file mode 100644 index 0000000000..dc62865ced --- /dev/null +++ b/packages/sources/nav-libre/src/transport/date-utils.ts @@ -0,0 +1,32 @@ +import { differenceInBusinessDays, format, isValid, parse, subBusinessDays } from 'date-fns' + +// Date format used by the NavLibre API +export const DATE_FORMAT = 'MM-dd-yyyy' +export const MAX_BUSINESS_DAYS = 7 + +/** + * Parse a string in MM-DD-YYYY format. + * Throws if the string is missing or malformed. + */ +export function parseDateString(dateStr: string): Date { + const parsed = parse(dateStr, DATE_FORMAT, new Date()) + if (!isValid(parsed)) { + throw new Error(`date must be in ${DATE_FORMAT} format: got "${dateStr}"`) + } + return parsed +} + +/** Ensure the window is <= N business days. Returns the (possibly adjusted) from-date. */ +export function clampToBusinessWindow( + from: Date, + to: Date, + maxBusinessDays = MAX_BUSINESS_DAYS, +): Date { + const span = differenceInBusinessDays(to, from) + return span > maxBusinessDays ? subBusinessDays(to, maxBusinessDays) : from +} + +/** Convenience formatter so every outbound string is consistent. */ +export function toDateString(d: Date): string { + return format(d, DATE_FORMAT) +} diff --git a/packages/sources/nav-libre/src/transport/fund-dates.ts b/packages/sources/nav-libre/src/transport/fund-dates.ts new file mode 100644 index 0000000000..25a0a2002c --- /dev/null +++ b/packages/sources/nav-libre/src/transport/fund-dates.ts @@ -0,0 +1,39 @@ +import { Requester } from '@chainlink/external-adapter-framework/util/requester' +import { AdapterError } from '@chainlink/external-adapter-framework/validation/error' +import { getRequestHeaders } from './authentication' + +interface FundDatesResponse { + LogID: number + FromDate: string + ToDate: string +} + +export const getFundDates = async ( + globalFundID: number, + baseUrl: string, + apiKey: string, + secret: string, + requester: Requester, +) => { + const method = 'GET' + const url = `/navapigateway/api/v1/ClientMasterData/GetAccountingDataDates?globalFundID=${globalFundID}` + const requestConfig = { + baseURL: baseUrl, + url: url, + method: method, + headers: getRequestHeaders(method, url, '', apiKey, secret), + } + + const sourceResponse = await requester.request( + JSON.stringify(requestConfig), + requestConfig, + ) + if (!sourceResponse.response.data) { + throw new AdapterError({ + statusCode: 400, + message: `No fund found`, + }) + } + + return sourceResponse.response.data +} diff --git a/packages/sources/nav-libre/src/transport/fund.ts b/packages/sources/nav-libre/src/transport/fund.ts index e92eaa84d3..a51e164582 100644 --- a/packages/sources/nav-libre/src/transport/fund.ts +++ b/packages/sources/nav-libre/src/transport/fund.ts @@ -1,47 +1,66 @@ import { Requester } from '@chainlink/external-adapter-framework/util/requester' import { AdapterError } from '@chainlink/external-adapter-framework/validation/error' import { getRequestHeaders } from './authentication' -interface Response { - FundName: string - GlobalFundID: number - FundEndDate: string - FundDailyAccountingStartDate: string - FundDailyAccountingLastAvailableDate: string - FundOfficialAccountingLastAvailableDate: string - PortfolioLastAvailableDate: string + +interface FundResponse { + Data: { + 'Trading Level Net ROR': { + DTD: number + MTD: number + QTD: number + YTD: number + ITD: number + } + 'Net ROR': { + DTD: number + MTD: number + QTD: number + YTD: number + ITD: number + } + 'NAV Per Share': number + 'Next NAV Price': number + 'Accounting Date': string + 'Ending Balance': number + }[] } export const getFund = async ( globalFundID: number, - url: string, + fromDate: string, + toDate: string, + baseURL: string, apiKey: string, secret: string, requester: Requester, ) => { const method = 'GET' - const query = `globalFundID=${globalFundID}` - const path = '/navapigateway/api/v1/FundAccountingData/GetAccountingDataDates' + const url = `/navapigateway/api/v1/FundAccountingData/GetOfficialNAVAndPerformanceReturnsForFund?globalFundID=${globalFundID}&fromDate=${fromDate}&toDate=${toDate}` + // Body is empy for GET + const body = '' + const requestConfig = { - baseURL: url, - url: path, + baseURL: baseURL, + url: url, method: method, - headers: getRequestHeaders(method, path + '?' + query, '', apiKey, secret), + headers: getRequestHeaders(method, url, body, apiKey, secret), } - const sourceResponse = await requester.request( + const response = await requester.request( JSON.stringify(requestConfig), requestConfig, ) - if (sourceResponse.response.data.length == 0) { + if ( + !response.response.data || + !Array.isArray(response.response.data.Data) || + response.response.data.Data.length === 0 + ) { throw new AdapterError({ statusCode: 400, message: `No fund found`, }) } - const response = sourceResponse.response.data[0] - console.log(response) - - return [response.GlobalFundID.toString(), response.FundDailyAccountingLastAvailableDate + 'Z'] + return response.response.data.Data } diff --git a/packages/sources/nav-libre/src/transport/nav.ts b/packages/sources/nav-libre/src/transport/nav.ts index 3b7620fc7f..84156a41c3 100644 --- a/packages/sources/nav-libre/src/transport/nav.ts +++ b/packages/sources/nav-libre/src/transport/nav.ts @@ -1,155 +1,113 @@ -import { HttpTransport } from '@chainlink/external-adapter-framework/transports' -import dayjs from 'dayjs' -import { BaseEndpointTypes } from '../endpoint/nav' -import { getRequestHeaders } from './authentication' +import { BaseEndpointTypes, inputParameters } from '../endpoint/nav' +import { getFund } from './fund' +import { getFundDates } from './fund-dates' -export interface ResponseSchema { - Data: { - 'Trading Level Net ROR': { - DTD: number - MTD: number - QTD: number - YTD: number - ITD: number - } - 'Net ROR': { - DTD: number - MTD: number - QTD: number - YTD: number - ITD: number - } - 'NAV Per Share': number - 'Next NAV Price': number - 'Accounting Date': string - 'Ending Balance': number - }[] -} - -export type HttpTransportTypes = BaseEndpointTypes & { - Provider: { - RequestBody: never - ResponseBody: ResponseSchema - } -} +import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' +import { TransportDependencies } from '@chainlink/external-adapter-framework/transports' +import { SubscriptionTransport } from '@chainlink/external-adapter-framework/transports/abstract/subscription' +import { AdapterResponse, makeLogger, sleep } from '@chainlink/external-adapter-framework/util' +import { Requester } from '@chainlink/external-adapter-framework/util/requester' +import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' +import { clampToBusinessWindow, parseDateString, toDateString } from './date-utils' +const logger = makeLogger('NavLibreTransport') -export const httpTransport = new HttpTransport({ - prepareRequests: (params, config) => { - return params.map((param) => { - // Set defaults for fromDate and toDate if not provided - const now = dayjs() - const fromDate = param.fromDate || now.subtract(7, 'day').format('MM-DD-YYYY') - const toDate = param.toDate || now.format('MM-DD-YYYY') +type RequestParams = typeof inputParameters.validated - // Validate date format MM-DD-YYYY - const dateRegex = /^(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])-\d{4}$/ - if (fromDate && !dateRegex.test(fromDate)) { - throw new Error('fromDate must be in MM-DD-YYYY format') - } - if (toDate && !dateRegex.test(toDate)) { - throw new Error('toDate must be in MM-DD-YYYY format') - } - - const response = await getFund( - getFund, - param.globalFundID, - config.API_ENDPOINT, - config.API_KEY, - config.SECRET_KEY, - this.requestor, - ) +export class NavLibreTransport extends SubscriptionTransport { + config!: BaseEndpointTypes['Settings'] + endpointName!: string + name!: string + requester!: Requester - const method = 'GET' - const path = - '/navapigateway/api/v1/FundAccountingData/GetOfficialNAVAndPerformanceReturnsForFund' - const query = `globalFundID=${param.globalFundID}&fromDate=${fromDate}&toDate=${toDate}` - // Body is empy for GET - const body = '' + async initialize( + dependencies: TransportDependencies, + adapterSettings: BaseEndpointTypes['Settings'], + endpointName: string, + transportName: string, + ): Promise { + await super.initialize(dependencies, adapterSettings, endpointName, transportName) + this.endpointName = endpointName + this.requester = dependencies.requester + this.config = adapterSettings + } + async backgroundHandler(context: EndpointContext, entries: RequestParams[]) { + await Promise.all(entries.map(async (param) => this.handleRequest(context, param))) + await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS) + } - const headers = getRequestHeaders( - method, - path + '?' + query, - body, - config.API_KEY, - config.SECRET_KEY, - ) - return { - params: [param], - request: { - baseURL: config.API_ENDPOINT, - url: path, - headers, - params: { - globalFundID: param.globalFundID, - fromDate, - toDate, - }, + async handleRequest(_context: EndpointContext, param: RequestParams) { + let response: AdapterResponse + try { + response = await this._handleRequest(param) + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : 'Unknown error occurred' + logger.error(e, errorMessage) + response = { + statusCode: (e as AdapterInputError)?.statusCode || 502, + errorMessage, + timestamps: { + providerDataRequestedUnixMs: 0, + providerDataReceivedUnixMs: 0, + providerIndicatedTimeUnixMs: undefined, }, } - }) - }, - parseResponse: (params, response) => { - if (!response.data || !Array.isArray(response.data.Data) || response.data.Data.length === 0) { - return params.map((param) => ({ - params: param, - response: { - errorMessage: `No NAV data returned for fund ${param.globalFundID}`, - statusCode: 502, - }, - })) } + await this.responseCache.write(this.name, [{ params: param, response }]) + } + + async _handleRequest( + param: RequestParams, + ): Promise> { + const providerDataRequestedUnixMs = Date.now() + logger.debug(`Handling request for globalFundID: ${param.globalFundID}`) + const { FromDate, ToDate } = await getFundDates( + param.globalFundID, + this.config.API_ENDPOINT, + this.config.API_KEY, + this.config.SECRET_KEY, + this.requester, + ) + let from = parseDateString(FromDate) + const to = parseDateString(ToDate) + from = clampToBusinessWindow(from, to) + + logger.debug(`Fetching NAV for globalFundID: ${param.globalFundID} from ${from} to ${to}`) + const fund = await getFund( + param.globalFundID, + toDateString(from), + toDateString(to), + this.config.API_ENDPOINT, + this.config.API_KEY, + this.config.SECRET_KEY, + this.requester, + ) // Find the latest NAV entry by Accounting Date - const latest = response.data.Data.reduce((a, b) => { + const latest = fund.reduce((a, b) => { return new Date(a['Accounting Date']) > new Date(b['Accounting Date']) ? a : b }) - - const timestamps = { - providerIndicatedTimeUnixMs: new Date(latest['Accounting Date']).getTime(), + const [month, day, year] = latest['Accounting Date'].split('-').map(Number) + // Assumes UTC + const providerIndicatedTimeUnixMs = Date.UTC(year, month - 1, day) // month is 0-based + return { + statusCode: 200, + result: latest['NAV Per Share'], + data: { + globalFundID: param.globalFundID, + navPerShare: latest['NAV Per Share'], + navDate: latest['Accounting Date'], + }, + timestamps: { + providerDataRequestedUnixMs, + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs, + }, } + } - return params.map((param) => ({ - params: param, - response: { - result: latest['NAV Per Share'], - data: { - navPerShare: latest['NAV Per Share'], - navDate: latest['Accounting Date'], - globalFundID: param.globalFundID, - }, - timestamps, - statusCode: 200, - }, - })) - }, -}) + getSubscriptionTtlFromConfig(adapterSettings: BaseEndpointTypes['Settings']): number { + return adapterSettings.WARMUP_SUBSCRIPTION_TTL + } +} -// async function callFunction( -// func: (...args: T) => Promise, -// maxRetry: number, -// ...args: T -// ): Promise { -// return retry( -// async (bail, attempt) => { -// try { -// return await func(...args) -// } catch (err) { -// if (attempt >= maxRetry) { -// // give up and bubble the error -// bail(err) -// return // unreachable, but satisfies TS -// } -// // otherwise throw to trigger another retry -// throw err -// } -// }, -// { -// retries: maxRetry, -// minTimeout: 10000, -// maxTimeout: 10000, -// onRetry: (err, attempt) => { -// logger.info(`${maxRetry - attempt} retries remaining, sleeping for 10000ms...`) -// }, -// }, -// ) -// } +export const navLibreTransport = new NavLibreTransport() diff --git a/packages/sources/nav-libre/test-payload.json b/packages/sources/nav-libre/test-payload.json index abf2b9f651..6c48d1af28 100644 --- a/packages/sources/nav-libre/test-payload.json +++ b/packages/sources/nav-libre/test-payload.json @@ -1,7 +1,5 @@ { "requests": [{ "globalFundID": 1234, - "fromDate": "01-01-2024", - "toDate": "01-30-2024" }] } diff --git a/packages/sources/nav-libre/test/integration/__snapshots__/adapter.test.ts.snap b/packages/sources/nav-libre/test/integration/__snapshots__/adapter.test.ts.snap index 9d8cb03b20..584d8a0c1b 100644 --- a/packages/sources/nav-libre/test/integration/__snapshots__/adapter.test.ts.snap +++ b/packages/sources/nav-libre/test/integration/__snapshots__/adapter.test.ts.snap @@ -10,8 +10,9 @@ exports[`execute nav endpoint should return success 1`] = ` "result": 123.45, "statusCode": 200, "timestamps": { - "providerDataReceivedUnixMs": 978347471111, - "providerDataRequestedUnixMs": 978347471111, + "providerDataReceivedUnixMs": 978307200000, + "providerDataRequestedUnixMs": 978307200000, + "providerIndicatedTimeUnixMs": 1704067200000, }, } `; diff --git a/packages/sources/nav-libre/test/integration/adapter.test.ts b/packages/sources/nav-libre/test/integration/adapter.test.ts index 470e67e441..597858ecc7 100644 --- a/packages/sources/nav-libre/test/integration/adapter.test.ts +++ b/packages/sources/nav-libre/test/integration/adapter.test.ts @@ -14,7 +14,8 @@ describe('execute', () => { oldEnv = JSON.parse(JSON.stringify(process.env)) process.env.API_KEY = process.env.API_KEY ?? 'fake-api-key' process.env.SECRET_KEY = 'SOME_SECRET_KEY' - const mockDate = new Date('2001-01-01T11:11:11.111Z') + process.env.BACKGROUND_EXECUTE_MS = process.env.BACKGROUND_EXECUTE_MS ?? '0' + const mockDate = new Date('2001-01-01T00:00:00.000Z') spy = jest.spyOn(Date, 'now').mockReturnValue(mockDate.getTime()) const adapter = (await import('./../../src')).adapter @@ -36,8 +37,6 @@ describe('execute', () => { it('should return success', async () => { const data = { globalFundID: 1234, - fromDate: '01-01-2024', - toDate: '01-01-2024', endpoint: 'nav', transport: 'rest', } diff --git a/packages/sources/nav-libre/test/integration/fixtures.ts b/packages/sources/nav-libre/test/integration/fixtures.ts index e2c86c3db1..e06ba15f6e 100644 --- a/packages/sources/nav-libre/test/integration/fixtures.ts +++ b/packages/sources/nav-libre/test/integration/fixtures.ts @@ -44,3 +44,24 @@ export const mockResponseSuccess = (): nock.Scope => ], ) .persist() + .get('/navapigateway/api/v1/ClientMasterData/GetAccountingDataDates') + .query(true) + .reply( + 200, + { + LogID: 123456, + FromDate: '01-01-2024', + ToDate: '01-31-2024', + }, + [ + 'Content-Type', + 'application/json', + 'Connection', + 'close', + 'Vary', + 'Accept-Encoding', + 'Vary', + 'Origin', + ], + ) + .persist() diff --git a/yarn.lock b/yarn.lock index 3a2d95f639..28f7bc25f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5000,16 +5000,14 @@ __metadata: resolution: "@chainlink/nav-libre-adapter@workspace:packages/sources/nav-libre" dependencies: "@chainlink/external-adapter-framework": "npm:2.6.0" - "@types/async-retry": "npm:^1" "@types/crypto-js": "npm:^4" - "@types/jest": "npm:27.5.2" - "@types/node": "npm:16.18.119" - async-retry: "npm:^1.3.3" + "@types/jest": "npm:^29.5.14" + "@types/node": "npm:22.14.1" crypto-js: "npm:^4.2.0" - dayjs: "npm:^1.11.13" - nock: "npm:13.5.5" + date-fns: "npm:^4.1.0" + nock: "npm:13.5.6" tslib: "npm:2.4.1" - typescript: "npm:5.6.3" + typescript: "npm:5.8.3" uuid: "npm:^11.1.0" languageName: unknown linkType: soft @@ -12361,15 +12359,6 @@ __metadata: languageName: node linkType: hard -"@types/async-retry@npm:^1": - version: 1.4.9 - resolution: "@types/async-retry@npm:1.4.9" - dependencies: - "@types/retry": "npm:*" - checksum: 10/9cbfe8fb9a6c3559c7084b359edb7b6bae30e16e023eb959e58362b799fc5a9450961e77d994b86526c8de45815eed02c081a104712fb6f2f55ef4cba7263601 - languageName: node - linkType: hard - "@types/babel__core@npm:7.20.5, @types/babel__core@npm:^7.1.14": version: 7.20.5 resolution: "@types/babel__core@npm:7.20.5" @@ -12666,16 +12655,6 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:27.5.2": - version: 27.5.2 - resolution: "@types/jest@npm:27.5.2" - dependencies: - jest-matcher-utils: "npm:^27.0.0" - pretty-format: "npm:^27.0.0" - checksum: 10/8608696fbdea81bc9a600d1c5aeb290063357eaa55c0174e7db15087c4f483113b35f8b4c4ae364d2632cfed15a4dd674786254826b946c896de5612c8cb1a26 - languageName: node - linkType: hard - "@types/jest@npm:29.5.14, @types/jest@npm:^29.5.14": version: 29.5.14 resolution: "@types/jest@npm:29.5.14" @@ -12839,13 +12818,6 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:16.18.119": - version: 16.18.119 - resolution: "@types/node@npm:16.18.119" - checksum: 10/ada2921602064448d3584c3d726024e61bb2d837d98fd7f6e057f3d6e945a072ee54415d357be5d6c97a77d5ae1554a7e916bf04a0bf2ba7cbc0b3bad81b7412 - languageName: node - linkType: hard - "@types/node@npm:22.14.1": version: 22.14.1 resolution: "@types/node@npm:22.14.1" @@ -13015,13 +12987,6 @@ __metadata: languageName: node linkType: hard -"@types/retry@npm:*": - version: 0.12.5 - resolution: "@types/retry@npm:0.12.5" - checksum: 10/3fb6bf91835ca0eb2987567d6977585235a7567f8aeb38b34a8bb7bbee57ac050ed6f04b9998cda29701b8c893f5dfe315869bc54ac17e536c9235637fe351a2 - languageName: node - linkType: hard - "@types/retry@npm:0.12.0": version: 0.12.0 resolution: "@types/retry@npm:0.12.0" @@ -14224,15 +14189,6 @@ __metadata: languageName: node linkType: hard -"async-retry@npm:^1.3.3": - version: 1.3.3 - resolution: "async-retry@npm:1.3.3" - dependencies: - retry: "npm:0.13.1" - checksum: 10/38a7152ff7265a9321ea214b9c69e8224ab1febbdec98efbbde6e562f17ff68405569b796b1c5271f354aef8783665d29953f051f68c1fc45306e61aec82fdc4 - languageName: node - linkType: hard - "async@npm:^3.2.2, async@npm:^3.2.3, async@npm:^3.2.4": version: 3.2.6 resolution: "async@npm:3.2.6" @@ -16662,7 +16618,7 @@ __metadata: languageName: node linkType: hard -"date-fns@npm:4.1.0": +"date-fns@npm:4.1.0, date-fns@npm:^4.1.0": version: 4.1.0 resolution: "date-fns@npm:4.1.0" checksum: 10/d5f6e9de5bbc52310f786099e18609289ed5e30af60a71e0646784c8185ddd1d0eebcf7c96b7faaaefc4a8366f3a3a4244d099b6d0866ee2bec80d1361e64342 @@ -16676,13 +16632,6 @@ __metadata: languageName: node linkType: hard -"dayjs@npm:^1.11.13": - version: 1.11.13 - resolution: "dayjs@npm:1.11.13" - checksum: 10/7374d63ab179b8d909a95e74790def25c8986e329ae989840bacb8b1888be116d20e1c4eee75a69ea0dfbae13172efc50ef85619d304ee7ca3c01d5878b704f5 - languageName: node - linkType: hard - "debug@npm:*, debug@npm:4, debug@npm:^4.0.1, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.2.0, debug@npm:^4.3.0, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4, debug@npm:^4.3.5, debug@npm:~4.3.1, debug@npm:~4.3.2": version: 4.3.7 resolution: "debug@npm:4.3.7" @@ -17088,13 +17037,6 @@ __metadata: languageName: node linkType: hard -"diff-sequences@npm:^27.5.1": - version: 27.5.1 - resolution: "diff-sequences@npm:27.5.1" - checksum: 10/34d852a13eb82735c39944a050613f952038614ce324256e1c3544948fa090f1ca7f329a4f1f57c31fe7ac982c17068d8915b633e300f040b97708c81ceb26cd - languageName: node - linkType: hard - "diff-sequences@npm:^29.6.3": version: 29.6.3 resolution: "diff-sequences@npm:29.6.3" @@ -22808,18 +22750,6 @@ __metadata: languageName: node linkType: hard -"jest-diff@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-diff@npm:27.5.1" - dependencies: - chalk: "npm:^4.0.0" - diff-sequences: "npm:^27.5.1" - jest-get-type: "npm:^27.5.1" - pretty-format: "npm:^27.5.1" - checksum: 10/af454f30f33af625832bdb02614e188a41e33ce79086b43f95dbcc515274dd36bf8443b8d0299e22c2416e7591da4321e6bc7f2b0aef56471d1133c6b6833221 - languageName: node - linkType: hard - "jest-diff@npm:^29.7.0": version: 29.7.0 resolution: "jest-diff@npm:29.7.0" @@ -22868,13 +22798,6 @@ __metadata: languageName: node linkType: hard -"jest-get-type@npm:^27.5.1": - version: 27.5.1 - resolution: "jest-get-type@npm:27.5.1" - checksum: 10/63064ab70195c21007d897c1157bf88ff94a790824a10f8c890392e7d17eda9c3900513cb291ca1c8d5722cad79169764e9a1279f7c8a9c4cd6e9109ff04bbc0 - languageName: node - linkType: hard - "jest-get-type@npm:^29.6.3": version: 29.6.3 resolution: "jest-get-type@npm:29.6.3" @@ -22915,18 +22838,6 @@ __metadata: languageName: node linkType: hard -"jest-matcher-utils@npm:^27.0.0": - version: 27.5.1 - resolution: "jest-matcher-utils@npm:27.5.1" - dependencies: - chalk: "npm:^4.0.0" - jest-diff: "npm:^27.5.1" - jest-get-type: "npm:^27.5.1" - pretty-format: "npm:^27.5.1" - checksum: 10/037f99878a0515581d7728ed3aed03707810f4da5a1c7ffb9d68a2c6c3180851a6ec40b559af37fbe891dde3ba12552b19e47b8188a27b6c5a53376be6907f32 - languageName: node - linkType: hard - "jest-matcher-utils@npm:^29.7.0": version: 29.7.0 resolution: "jest-matcher-utils@npm:29.7.0" @@ -26155,17 +26066,6 @@ __metadata: languageName: node linkType: hard -"nock@npm:13.5.5": - version: 13.5.5 - resolution: "nock@npm:13.5.5" - dependencies: - debug: "npm:^4.1.0" - json-stringify-safe: "npm:^5.0.1" - propagate: "npm:^2.0.0" - checksum: 10/c19d7bf9654db056357a22b00127bb5606c1bbdff188a5b6c469825e580e31cd0cb0701bce8dd8b4876dbbd36a145fdb681fd69fd59308d6db4923ce8ab2439e - languageName: node - linkType: hard - "nock@npm:13.5.6, nock@npm:^13.2.4, nock@npm:^13.3.0": version: 13.5.6 resolution: "nock@npm:13.5.6" @@ -27921,17 +27821,6 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:^27.0.0, pretty-format@npm:^27.5.1": - version: 27.5.1 - resolution: "pretty-format@npm:27.5.1" - dependencies: - ansi-regex: "npm:^5.0.1" - ansi-styles: "npm:^5.0.0" - react-is: "npm:^17.0.1" - checksum: 10/248990cbef9e96fb36a3e1ae6b903c551ca4ddd733f8d0912b9cc5141d3d0b3f9f8dfb4d799fb1c6723382c9c2083ffbfa4ad43ff9a0e7535d32d41fd5f01da6 - languageName: node - linkType: hard - "pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": version: 29.7.0 resolution: "pretty-format@npm:29.7.0" @@ -28402,13 +28291,6 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^17.0.1": - version: 17.0.2 - resolution: "react-is@npm:17.0.2" - checksum: 10/73b36281e58eeb27c9cc6031301b6ae19ecdc9f18ae2d518bdb39b0ac564e65c5779405d623f1df9abf378a13858b79442480244bd579968afc1faf9a2ce5e05 - languageName: node - linkType: hard - "react-is@npm:^18.0.0": version: 18.3.1 resolution: "react-is@npm:18.3.1" @@ -29204,13 +29086,6 @@ __metadata: languageName: node linkType: hard -"retry@npm:0.13.1, retry@npm:^0.13.1": - version: 0.13.1 - resolution: "retry@npm:0.13.1" - checksum: 10/6125ec2e06d6e47e9201539c887defba4e47f63471db304c59e4b82fc63c8e89ca06a77e9d34939a9a42a76f00774b2f46c0d4a4cbb3e287268bd018ed69426d - languageName: node - linkType: hard - "retry@npm:^0.12.0": version: 0.12.0 resolution: "retry@npm:0.12.0" @@ -29218,6 +29093,13 @@ __metadata: languageName: node linkType: hard +"retry@npm:^0.13.1": + version: 0.13.1 + resolution: "retry@npm:0.13.1" + checksum: 10/6125ec2e06d6e47e9201539c887defba4e47f63471db304c59e4b82fc63c8e89ca06a77e9d34939a9a42a76f00774b2f46c0d4a4cbb3e287268bd018ed69426d + languageName: node + linkType: hard + "reusify@npm:^1.0.4": version: 1.0.4 resolution: "reusify@npm:1.0.4" @@ -31771,16 +31653,6 @@ __metadata: languageName: node linkType: hard -"typescript@npm:5.6.3": - version: 5.6.3 - resolution: "typescript@npm:5.6.3" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10/c328e418e124b500908781d9f7b9b93cf08b66bf5936d94332b463822eea2f4e62973bfb3b8a745fdc038785cb66cf59d1092bac3ec2ac6a3e5854687f7833f1 - languageName: node - linkType: hard - "typescript@npm:5.8.3": version: 5.8.3 resolution: "typescript@npm:5.8.3" @@ -31791,16 +31663,6 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A5.6.3#optional!builtin": - version: 5.6.3 - resolution: "typescript@patch:typescript@npm%3A5.6.3#optional!builtin::version=5.6.3&hash=8c6c40" - bin: - tsc: bin/tsc - tsserver: bin/tsserver - checksum: 10/00504c01ee42d470c23495426af07512e25e6546bce7e24572e72a9ca2e6b2e9bea63de4286c3cfea644874da1467dcfca23f4f98f7caf20f8b03c0213bb6837 - languageName: node - linkType: hard - "typescript@patch:typescript@npm%3A5.8.3#optional!builtin": version: 5.8.3 resolution: "typescript@patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5" From a7284d8948602adb7abcd57a129474438749b2b6 Mon Sep 17 00:00:00 2001 From: Mohamed Mehany <7327188+mohamed-mehany@users.noreply.github.com> Date: Fri, 11 Jul 2025 05:20:55 +0400 Subject: [PATCH 4/7] Adds unit tests, addreses feedback --- .../nav-libre/src/transport/date-utils.ts | 6 +- .../nav-libre/src/transport/fund-dates.ts | 4 +- .../sources/nav-libre/src/transport/fund.ts | 2 +- .../sources/nav-libre/src/transport/nav.ts | 23 ++++---- .../nav-libre/test/unit/date-utils.test.ts | 59 +++++++++++++++++++ .../nav-libre/test/unit/fund-dates.test.ts | 35 +++++++++++ .../sources/nav-libre/test/unit/fund.test.ts | 52 ++++++++++++++++ 7 files changed, 165 insertions(+), 16 deletions(-) create mode 100644 packages/sources/nav-libre/test/unit/date-utils.test.ts create mode 100644 packages/sources/nav-libre/test/unit/fund-dates.test.ts create mode 100644 packages/sources/nav-libre/test/unit/fund.test.ts diff --git a/packages/sources/nav-libre/src/transport/date-utils.ts b/packages/sources/nav-libre/src/transport/date-utils.ts index dc62865ced..2aab9fa731 100644 --- a/packages/sources/nav-libre/src/transport/date-utils.ts +++ b/packages/sources/nav-libre/src/transport/date-utils.ts @@ -13,11 +13,11 @@ export function parseDateString(dateStr: string): Date { if (!isValid(parsed)) { throw new Error(`date must be in ${DATE_FORMAT} format: got "${dateStr}"`) } - return parsed + return new Date(Date.UTC(parsed.getFullYear(), parsed.getMonth(), parsed.getDate())) } -/** Ensure the window is <= N business days. Returns the (possibly adjusted) from-date. */ -export function clampToBusinessWindow( +/** Returns a start date that is at most `maxBusinessDays` before `endDate`. */ +export function clampStartByBusinessDays( from: Date, to: Date, maxBusinessDays = MAX_BUSINESS_DAYS, diff --git a/packages/sources/nav-libre/src/transport/fund-dates.ts b/packages/sources/nav-libre/src/transport/fund-dates.ts index 25a0a2002c..d0e3af9ac5 100644 --- a/packages/sources/nav-libre/src/transport/fund-dates.ts +++ b/packages/sources/nav-libre/src/transport/fund-dates.ts @@ -2,7 +2,7 @@ import { Requester } from '@chainlink/external-adapter-framework/util/requester' import { AdapterError } from '@chainlink/external-adapter-framework/validation/error' import { getRequestHeaders } from './authentication' -interface FundDatesResponse { +export interface FundDatesResponse { LogID: number FromDate: string ToDate: string @@ -14,7 +14,7 @@ export const getFundDates = async ( apiKey: string, secret: string, requester: Requester, -) => { +): Promise => { const method = 'GET' const url = `/navapigateway/api/v1/ClientMasterData/GetAccountingDataDates?globalFundID=${globalFundID}` const requestConfig = { diff --git a/packages/sources/nav-libre/src/transport/fund.ts b/packages/sources/nav-libre/src/transport/fund.ts index a51e164582..044777ed13 100644 --- a/packages/sources/nav-libre/src/transport/fund.ts +++ b/packages/sources/nav-libre/src/transport/fund.ts @@ -33,7 +33,7 @@ export const getFund = async ( apiKey: string, secret: string, requester: Requester, -) => { +): Promise => { const method = 'GET' const url = `/navapigateway/api/v1/FundAccountingData/GetOfficialNAVAndPerformanceReturnsForFund?globalFundID=${globalFundID}&fromDate=${fromDate}&toDate=${toDate}` // Body is empy for GET diff --git a/packages/sources/nav-libre/src/transport/nav.ts b/packages/sources/nav-libre/src/transport/nav.ts index 84156a41c3..cffc4059e8 100644 --- a/packages/sources/nav-libre/src/transport/nav.ts +++ b/packages/sources/nav-libre/src/transport/nav.ts @@ -8,7 +8,7 @@ import { SubscriptionTransport } from '@chainlink/external-adapter-framework/tra import { AdapterResponse, makeLogger, sleep } from '@chainlink/external-adapter-framework/util' import { Requester } from '@chainlink/external-adapter-framework/util/requester' import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' -import { clampToBusinessWindow, parseDateString, toDateString } from './date-utils' +import { clampStartByBusinessDays, parseDateString, toDateString } from './date-utils' const logger = makeLogger('NavLibreTransport') type RequestParams = typeof inputParameters.validated @@ -69,7 +69,7 @@ export class NavLibreTransport extends SubscriptionTransport ) let from = parseDateString(FromDate) const to = parseDateString(ToDate) - from = clampToBusinessWindow(from, to) + from = clampStartByBusinessDays(from, to) logger.debug(`Fetching NAV for globalFundID: ${param.globalFundID} from ${from} to ${to}`) const fund = await getFund( @@ -82,20 +82,23 @@ export class NavLibreTransport extends SubscriptionTransport this.requester, ) + const ACCOUNTING_DATE_KEY = 'Accounting Date' + const NAV_PER_SHARE_KEY = 'NAV Per Share' // Find the latest NAV entry by Accounting Date - const latest = fund.reduce((a, b) => { - return new Date(a['Accounting Date']) > new Date(b['Accounting Date']) ? a : b - }) - const [month, day, year] = latest['Accounting Date'].split('-').map(Number) + const latest = fund.reduce((latestRow, row) => + parseDateString(row[ACCOUNTING_DATE_KEY]) > parseDateString(latestRow[ACCOUNTING_DATE_KEY]) + ? row + : latestRow, + ) // Assumes UTC - const providerIndicatedTimeUnixMs = Date.UTC(year, month - 1, day) // month is 0-based + const providerIndicatedTimeUnixMs = parseDateString(latest[ACCOUNTING_DATE_KEY]).getTime() return { statusCode: 200, - result: latest['NAV Per Share'], + result: latest[NAV_PER_SHARE_KEY], data: { globalFundID: param.globalFundID, - navPerShare: latest['NAV Per Share'], - navDate: latest['Accounting Date'], + navPerShare: latest[NAV_PER_SHARE_KEY], + navDate: latest[ACCOUNTING_DATE_KEY], }, timestamps: { providerDataRequestedUnixMs, diff --git a/packages/sources/nav-libre/test/unit/date-utils.test.ts b/packages/sources/nav-libre/test/unit/date-utils.test.ts new file mode 100644 index 0000000000..6c3afd84ab --- /dev/null +++ b/packages/sources/nav-libre/test/unit/date-utils.test.ts @@ -0,0 +1,59 @@ +import { differenceInBusinessDays, parse } from 'date-fns' +import { + clampStartByBusinessDays, + DATE_FORMAT, + MAX_BUSINESS_DAYS, + parseDateString, + toDateString, +} from '../../src/transport/date-utils' + +describe('date-utils', () => { + // Force UTC so tests behave the same everywhere + beforeAll(() => { + process.env.TZ = 'UTC' + }) + + describe('parseDateString', () => { + it('parses a valid MM-dd-yyyy string', () => { + const input = '07-11-2025' + const parsed = parseDateString(input) + expect(parsed).toBeInstanceOf(Date) + expect(parsed.getUTCFullYear()).toBe(2025) + expect(parsed.getUTCMonth()).toBe(6) // July is 6 (zero‑based) + expect(parsed.getUTCDate()).toBe(11) + }) + + it('throws for malformed input', () => { + const badInput = '2025-07-11' + expect(() => parseDateString(badInput)).toThrow( + `date must be in ${DATE_FORMAT} format: got "${badInput}"`, + ) + }) + }) + + describe('clampToBusinessWindow', () => { + const to = parse('07-15-2025', DATE_FORMAT, new Date()) + + it('returns the original from date when within the limit', () => { + const from = parse('07-07-2025', DATE_FORMAT, new Date()) // 6 business days before + const result = clampStartByBusinessDays(from, to, MAX_BUSINESS_DAYS) + expect(result).toEqual(from) + }) + + it('clamps when span exceeds the limit', () => { + const from = parse('07-01-2025', DATE_FORMAT, new Date()) // >7 business days + const result = clampStartByBusinessDays(from, to, MAX_BUSINESS_DAYS) + // Should be exactly MAX_BUSINESS_DAYS business days before `to` + const span = differenceInBusinessDays(to, result) + expect(span).toBe(MAX_BUSINESS_DAYS) + }) + }) + + describe('toDateString', () => { + it('formats a Date back to MM-dd-yyyy', () => { + const date = new Date(Date.UTC(2025, 6, 11)) + const formatted = toDateString(date) + expect(formatted).toBe('07-11-2025') + }) + }) +}) diff --git a/packages/sources/nav-libre/test/unit/fund-dates.test.ts b/packages/sources/nav-libre/test/unit/fund-dates.test.ts new file mode 100644 index 0000000000..f25268eac4 --- /dev/null +++ b/packages/sources/nav-libre/test/unit/fund-dates.test.ts @@ -0,0 +1,35 @@ +import { Requester } from '@chainlink/external-adapter-framework/util/requester' +import { FundDatesResponse, getFundDates } from '../../src/transport/fund-dates' + +describe('getFundDates', () => { + const mockRequester = { + request: jest.fn(), + } as unknown as Requester + + const mockResponse: FundDatesResponse = { + LogID: 1, + FromDate: '01-01-2023', + ToDate: '01-31-2023', + } + + beforeEach(() => { + jest.clearAllMocks() + }) + + it('returns fund dates on success', async () => { + mockRequester.request = jest.fn().mockResolvedValue({ + response: { data: mockResponse }, + }) + const result = await getFundDates(123, 'http://base', 'apiKey', 'secret', mockRequester) + expect(result).toEqual(mockResponse) + }) + + it('throws if no data returned', async () => { + mockRequester.request = jest.fn().mockResolvedValue({ + response: { data: undefined }, + }) + await expect( + getFundDates(123, 'http://base', 'apiKey', 'secret', mockRequester), + ).rejects.toThrow() + }) +}) diff --git a/packages/sources/nav-libre/test/unit/fund.test.ts b/packages/sources/nav-libre/test/unit/fund.test.ts new file mode 100644 index 0000000000..6a4060ee77 --- /dev/null +++ b/packages/sources/nav-libre/test/unit/fund.test.ts @@ -0,0 +1,52 @@ +import { Requester } from '@chainlink/external-adapter-framework/util/requester' +import * as authModule from '../../src/transport/authentication' +import { getFund } from '../../src/transport/fund' + +describe('getFund', () => { + const mockRequester = { + request: jest.fn(), + } as unknown as Requester + + const mockResponse = [ + { + 'Accounting Date': '01-01-2023', + 'NAV Per Share': 123.45, + }, + ] + + let getRequestHeadersStub: jest.SpyInstance + beforeEach(() => { + jest.clearAllMocks() + getRequestHeadersStub = jest.spyOn(authModule, 'getRequestHeaders').mockReturnValue({}) + }) + + afterEach(() => { + getRequestHeadersStub.mockRestore() + }) + it('returns fund data on success', async () => { + mockRequester.request = jest.fn().mockResolvedValue({ + response: { + data: { Data: mockResponse }, + }, + }) + const result = await getFund( + 123, + '01-01-2000', + '01-05-2000', + 'http://base', + 'apiKey', + 'secret', + mockRequester, + ) + expect(result).toEqual(mockResponse) + }) + + it('throws if no data returned', async () => { + mockRequester.request = jest.fn().mockResolvedValue({ + response: { data: undefined }, + }) + await expect( + getFund(123, '01-01-2000', '01-05-2000', 'http://base', 'apiKey', 'secret', mockRequester), + ).rejects.toThrow() + }) +}) From 11aeead30396783bdb346e4ae3068e11133f8feb Mon Sep 17 00:00:00 2001 From: Mohamed Mehany <7327188+mohamed-mehany@users.noreply.github.com> Date: Fri, 11 Jul 2025 05:27:24 +0400 Subject: [PATCH 5/7] Increases cache max age --- .../sources/nav-libre/src/config/index.ts | 64 +++++++++++-------- .../nav-libre/test/unit/date-utils.test.ts | 2 +- .../sources/nav-libre/test/unit/fund.test.ts | 6 +- 3 files changed, 42 insertions(+), 30 deletions(-) diff --git a/packages/sources/nav-libre/src/config/index.ts b/packages/sources/nav-libre/src/config/index.ts index f130e17c0d..3c6023b8c3 100644 --- a/packages/sources/nav-libre/src/config/index.ts +++ b/packages/sources/nav-libre/src/config/index.ts @@ -1,32 +1,40 @@ import { AdapterConfig } from '@chainlink/external-adapter-framework/config' -export const config = new AdapterConfig({ - API_KEY: { - description: 'An API key for Data Provider', - type: 'string', - required: true, - sensitive: true, +export const config = new AdapterConfig( + { + API_KEY: { + description: 'An API key for Data Provider', + type: 'string', + required: true, + sensitive: true, + }, + SECRET_KEY: { + description: 'A key for Data Provider used in hashing the API key', + type: 'string', + required: true, + sensitive: true, + }, + API_ENDPOINT: { + description: 'An API endpoint for Data Provider', + type: 'string', + default: 'https://api.navfundservices.com', + }, + MAX_RETRIES: { + description: 'Maximum attempts of sending a request', + type: 'number', + default: 3, + }, + BACKGROUND_EXECUTE_MS: { + description: + 'The amount of time the background execute should sleep before performing the next request', + type: 'number', + default: 120_000, // one call per two minute + }, }, - SECRET_KEY: { - description: 'A key for Data Provider used in hashing the API key', - type: 'string', - required: true, - sensitive: true, + { + envDefaultOverrides: { + CACHE_MAX_AGE: 900_000, // 15 minute cache + RETRY: 0, // Disables retry on Framework + }, }, - API_ENDPOINT: { - description: 'An API endpoint for Data Provider', - type: 'string', - default: 'https://api.navfundservices.com', - }, - MAX_RETRIES: { - description: 'Maximum attempts of sending a request', - type: 'number', - default: 3, - }, - BACKGROUND_EXECUTE_MS: { - description: - 'The amount of time the background execute should sleep before performing the next request', - type: 'number', - default: 120_000, // one call per two minute - }, -}) +) diff --git a/packages/sources/nav-libre/test/unit/date-utils.test.ts b/packages/sources/nav-libre/test/unit/date-utils.test.ts index 6c3afd84ab..e6fd0eb3f2 100644 --- a/packages/sources/nav-libre/test/unit/date-utils.test.ts +++ b/packages/sources/nav-libre/test/unit/date-utils.test.ts @@ -31,7 +31,7 @@ describe('date-utils', () => { }) }) - describe('clampToBusinessWindow', () => { + describe('clampStartByBusinessDays', () => { const to = parse('07-15-2025', DATE_FORMAT, new Date()) it('returns the original from date when within the limit', () => { diff --git a/packages/sources/nav-libre/test/unit/fund.test.ts b/packages/sources/nav-libre/test/unit/fund.test.ts index 6a4060ee77..ab41114723 100644 --- a/packages/sources/nav-libre/test/unit/fund.test.ts +++ b/packages/sources/nav-libre/test/unit/fund.test.ts @@ -17,7 +17,11 @@ describe('getFund', () => { let getRequestHeadersStub: jest.SpyInstance beforeEach(() => { jest.clearAllMocks() - getRequestHeadersStub = jest.spyOn(authModule, 'getRequestHeaders').mockReturnValue({}) + getRequestHeadersStub = jest.spyOn(authModule, 'getRequestHeaders').mockReturnValue({ + 'x-date': 'dummy', + 'x-content-sha256': 'dummy', + 'x-hmac256-signature': 'dummy', + }) }) afterEach(() => { From 54ee1541722941306835ed8d44b3b3d927902092 Mon Sep 17 00:00:00 2001 From: Mohamed Mehany <7327188+mohamed-mehany@users.noreply.github.com> Date: Tue, 15 Jul 2025 05:06:38 +0400 Subject: [PATCH 6/7] Adds unit tests for transport nav and improvements --- .../nav-libre/src/transport/date-utils.ts | 14 +- .../nav-libre/src/transport/fund-dates.ts | 22 ++- .../sources/nav-libre/src/transport/fund.ts | 26 +-- .../sources/nav-libre/src/transport/nav.ts | 45 +++--- .../nav-libre/test/unit/fund-dates.test.ts | 17 +- .../sources/nav-libre/test/unit/fund.test.ts | 28 ++-- .../sources/nav-libre/test/unit/nav.test.ts | 149 ++++++++++++++++++ 7 files changed, 253 insertions(+), 48 deletions(-) create mode 100644 packages/sources/nav-libre/test/unit/nav.test.ts diff --git a/packages/sources/nav-libre/src/transport/date-utils.ts b/packages/sources/nav-libre/src/transport/date-utils.ts index 2aab9fa731..74c1b046f7 100644 --- a/packages/sources/nav-libre/src/transport/date-utils.ts +++ b/packages/sources/nav-libre/src/transport/date-utils.ts @@ -16,7 +16,19 @@ export function parseDateString(dateStr: string): Date { return new Date(Date.UTC(parsed.getFullYear(), parsed.getMonth(), parsed.getDate())) } -/** Returns a start date that is at most `maxBusinessDays` before `endDate`. */ +/** + * Guarantee the (from -> to) span is <= `maxBusinessDays`. + * + * If the gap is larger, shift `from` forward so it sits exactly + * `maxBusinessDays` business days before `to` and returns the new `from`. + * + * Returns the original `from` if the gap is smaller or equal. + * + * Example: 7-day limit + * from = 2025-06-25 (Wed) + * to = 2025-07-10 (Thu) + * span = 11 business days -> newFrom = 2025-07-01 + */ export function clampStartByBusinessDays( from: Date, to: Date, diff --git a/packages/sources/nav-libre/src/transport/fund-dates.ts b/packages/sources/nav-libre/src/transport/fund-dates.ts index d0e3af9ac5..85f090ad4a 100644 --- a/packages/sources/nav-libre/src/transport/fund-dates.ts +++ b/packages/sources/nav-libre/src/transport/fund-dates.ts @@ -8,17 +8,23 @@ export interface FundDatesResponse { ToDate: string } -export const getFundDates = async ( - globalFundID: number, - baseUrl: string, - apiKey: string, - secret: string, - requester: Requester, -): Promise => { +export const getFundDates = async ({ + globalFundID, + baseURL, + apiKey, + secret, + requester, +}: { + globalFundID: number + baseURL: string + apiKey: string + secret: string + requester: Requester +}): Promise => { const method = 'GET' const url = `/navapigateway/api/v1/ClientMasterData/GetAccountingDataDates?globalFundID=${globalFundID}` const requestConfig = { - baseURL: baseUrl, + baseURL: baseURL, url: url, method: method, headers: getRequestHeaders(method, url, '', apiKey, secret), diff --git a/packages/sources/nav-libre/src/transport/fund.ts b/packages/sources/nav-libre/src/transport/fund.ts index 044777ed13..f64eb6a11e 100644 --- a/packages/sources/nav-libre/src/transport/fund.ts +++ b/packages/sources/nav-libre/src/transport/fund.ts @@ -25,15 +25,23 @@ interface FundResponse { }[] } -export const getFund = async ( - globalFundID: number, - fromDate: string, - toDate: string, - baseURL: string, - apiKey: string, - secret: string, - requester: Requester, -): Promise => { +export const getFund = async ({ + globalFundID, + fromDate, + toDate, + baseURL, + apiKey, + secret, + requester, +}: { + globalFundID: number + fromDate: string + toDate: string + baseURL: string + apiKey: string + secret: string + requester: Requester +}): Promise => { const method = 'GET' const url = `/navapigateway/api/v1/FundAccountingData/GetOfficialNAVAndPerformanceReturnsForFund?globalFundID=${globalFundID}&fromDate=${fromDate}&toDate=${toDate}` // Body is empy for GET diff --git a/packages/sources/nav-libre/src/transport/nav.ts b/packages/sources/nav-libre/src/transport/nav.ts index cffc4059e8..24a7bd2260 100644 --- a/packages/sources/nav-libre/src/transport/nav.ts +++ b/packages/sources/nav-libre/src/transport/nav.ts @@ -60,27 +60,36 @@ export class NavLibreTransport extends SubscriptionTransport ): Promise> { const providerDataRequestedUnixMs = Date.now() logger.debug(`Handling request for globalFundID: ${param.globalFundID}`) - const { FromDate, ToDate } = await getFundDates( - param.globalFundID, - this.config.API_ENDPOINT, - this.config.API_KEY, - this.config.SECRET_KEY, - this.requester, + const { FromDate: earliestPossibleFromStr, ToDate: latestPossibleToStr } = await getFundDates({ + globalFundID: param.globalFundID, + baseURL: this.config.API_ENDPOINT, + apiKey: this.config.API_KEY, + secret: this.config.SECRET_KEY, + requester: this.requester, + }) + + const earliestPossibleFrom = parseDateString(earliestPossibleFromStr) + const latestPossibleTo = parseDateString(latestPossibleToStr) + + // Clamp to trailing-7-business-days window + const preferredFrom = clampStartByBusinessDays( + earliestPossibleFrom, + latestPossibleTo, + 7, // 7 business days ) - let from = parseDateString(FromDate) - const to = parseDateString(ToDate) - from = clampStartByBusinessDays(from, to) - logger.debug(`Fetching NAV for globalFundID: ${param.globalFundID} from ${from} to ${to}`) - const fund = await getFund( - param.globalFundID, - toDateString(from), - toDateString(to), - this.config.API_ENDPOINT, - this.config.API_KEY, - this.config.SECRET_KEY, - this.requester, + logger.debug( + `Fetching NAV for globalFundID: ${param.globalFundID} from ${preferredFrom} to ${latestPossibleTo}`, ) + const fund = await getFund({ + globalFundID: param.globalFundID, + fromDate: toDateString(preferredFrom), + toDate: toDateString(latestPossibleTo), + baseURL: this.config.API_ENDPOINT, + apiKey: this.config.API_KEY, + secret: this.config.SECRET_KEY, + requester: this.requester, + }) const ACCOUNTING_DATE_KEY = 'Accounting Date' const NAV_PER_SHARE_KEY = 'NAV Per Share' diff --git a/packages/sources/nav-libre/test/unit/fund-dates.test.ts b/packages/sources/nav-libre/test/unit/fund-dates.test.ts index f25268eac4..2de5cc05b9 100644 --- a/packages/sources/nav-libre/test/unit/fund-dates.test.ts +++ b/packages/sources/nav-libre/test/unit/fund-dates.test.ts @@ -20,7 +20,14 @@ describe('getFundDates', () => { mockRequester.request = jest.fn().mockResolvedValue({ response: { data: mockResponse }, }) - const result = await getFundDates(123, 'http://base', 'apiKey', 'secret', mockRequester) + const result = await getFundDates({ + globalFundID: 123, + baseURL: 'http://base', + apiKey: 'apiKey', + secret: 'secret', + requester: mockRequester, + }) + expect(result).toEqual(mockResponse) }) @@ -29,7 +36,13 @@ describe('getFundDates', () => { response: { data: undefined }, }) await expect( - getFundDates(123, 'http://base', 'apiKey', 'secret', mockRequester), + getFundDates({ + globalFundID: 123, + baseURL: 'http://base', + apiKey: 'apiKey', + secret: 'secret', + requester: mockRequester, + }), ).rejects.toThrow() }) }) diff --git a/packages/sources/nav-libre/test/unit/fund.test.ts b/packages/sources/nav-libre/test/unit/fund.test.ts index ab41114723..a4ac3f5dce 100644 --- a/packages/sources/nav-libre/test/unit/fund.test.ts +++ b/packages/sources/nav-libre/test/unit/fund.test.ts @@ -33,15 +33,15 @@ describe('getFund', () => { data: { Data: mockResponse }, }, }) - const result = await getFund( - 123, - '01-01-2000', - '01-05-2000', - 'http://base', - 'apiKey', - 'secret', - mockRequester, - ) + const result = await getFund({ + globalFundID: 123, + fromDate: '01-01-2000', + toDate: '01-05-2000', + baseURL: 'http://base', + apiKey: 'apiKey', + secret: 'secret', + requester: mockRequester, + }) expect(result).toEqual(mockResponse) }) @@ -50,7 +50,15 @@ describe('getFund', () => { response: { data: undefined }, }) await expect( - getFund(123, '01-01-2000', '01-05-2000', 'http://base', 'apiKey', 'secret', mockRequester), + getFund({ + globalFundID: 123, + fromDate: '01-01-2000', + toDate: '01-05-2000', + baseURL: 'http://base', + apiKey: 'apiKey', + secret: 'secret', + requester: mockRequester, + }), ).rejects.toThrow() }) }) diff --git a/packages/sources/nav-libre/test/unit/nav.test.ts b/packages/sources/nav-libre/test/unit/nav.test.ts new file mode 100644 index 0000000000..e40cb3ea82 --- /dev/null +++ b/packages/sources/nav-libre/test/unit/nav.test.ts @@ -0,0 +1,149 @@ +import { TransportDependencies } from '@chainlink/external-adapter-framework/transports' +import { LoggerFactoryProvider } from '@chainlink/external-adapter-framework/util' +import { makeStub } from '@chainlink/external-adapter-framework/util/testing-utils' +import { AdapterError } from '@chainlink/external-adapter-framework/validation/error' + +import { BaseEndpointTypes, inputParameters as navInputParams } from '../../src/endpoint/nav' +import { NavLibreTransport } from '../../src/transport/nav' + +LoggerFactoryProvider.set() + +const FUND_ID = 123 +const transportName = 'nav_transport' +const endpointName = 'nav' + +let transport: NavLibreTransport + +// adapter settings stub +const adapterSettings = makeStub('adapterSettings', { + API_ENDPOINT: 'https://api.navfund.com', + API_KEY: 'apiKey', + SECRET_KEY: 'secret', + BACKGROUND_EXECUTE_MS: 0, + MAX_RETRIES: 3, + WARMUP_SUBSCRIPTION_TTL: 10_000, +} as unknown as BaseEndpointTypes['Settings']) + +// requester stub that we'll control per‑test +const requester = makeStub('requester', { request: jest.fn() }) +const responseCache = makeStub('responseCache', { write: jest.fn() }) +const dependencies = makeStub('dependencies', { + requester, + responseCache, + subscriptionSetFactory: { buildSet: jest.fn() }, +} as unknown as TransportDependencies) + +beforeEach(async () => { + transport = new NavLibreTransport() as unknown as InstanceType + await transport.initialize(dependencies, adapterSettings, endpointName, transportName) + jest.resetAllMocks() +}) + +// helper to pull the cached response written in handleRequest +const getCachedResponse = () => (responseCache.write.mock.calls[0][1] as any)[0].response + +const FUND_DATES_RES = makeStub('fundDatesRes', { + response: { + data: { LogID: 1, FromDate: '06-01-2025', ToDate: '07-01-2025' }, + }, +}) + +const FUND_ROWS = [ + { 'NAV Per Share': 50, 'Accounting Date': '06-10-2025' }, + { 'NAV Per Share': 150, 'Accounting Date': '06-25-2025' }, +] + +const FUND_RES = makeStub('fundRes', { + response: { + data: { Data: FUND_ROWS }, + }, +}) + +describe('NavLibreTransport – handleRequest', () => { + it('returns latest NAV and writes to cache', async () => { + requester.request.mockResolvedValueOnce(FUND_DATES_RES) + requester.request.mockResolvedValueOnce(FUND_RES) + + const param = makeStub('param', { globalFundID: FUND_ID } as typeof navInputParams.validated) + + await transport.handleRequest({ adapterSettings } as any, param) + + expect(responseCache.write).toHaveBeenCalledTimes(1) + + const cached = getCachedResponse() + expect(cached).toEqual({ + statusCode: 200, + result: 150, + data: { + globalFundID: FUND_ID, + navPerShare: 150, + navDate: '06-25-2025', + }, + timestamps: expect.objectContaining({ + providerDataRequestedUnixMs: expect.any(Number), + providerDataReceivedUnixMs: expect.any(Number), + providerIndicatedTimeUnixMs: expect.any(Number), + }), + }) + + expect(requester.request).toHaveBeenCalledTimes(2) + const fundDatesCall = requester.request.mock.calls[0] + expect(fundDatesCall[1]).toMatchObject({ + url: expect.stringContaining('/GetAccountingDataDates'), + }) + + const fundCall = requester.request.mock.calls[1] + expect(fundCall[1]).toMatchObject({ + url: expect.stringContaining('/GetOfficialNAVAndPerformanceReturnsForFund'), + }) + }) + + it('maps downstream AdapterError to 502 response', async () => { + requester.request.mockResolvedValueOnce(FUND_DATES_RES) // first OK + requester.request.mockRejectedValueOnce(new AdapterError({ message: 'boom' })) + + const param = makeStub('param', { globalFundID: FUND_ID } as typeof navInputParams.validated) + + await transport.handleRequest({ adapterSettings } as any, param) + + expect(responseCache.write).toHaveBeenCalledTimes(1) + const cached = getCachedResponse() + expect(cached.statusCode).toBe(500) + expect(cached.errorMessage).toContain('boom') + }) + + it('uses provider earliestFrom when span <= 7 business days', async () => { + const shortSpanDates = makeStub('dates', { + response: { data: { LogID: 1, FromDate: '06-28-2025', ToDate: '07-01-2025' } }, + }) + requester.request.mockResolvedValueOnce(shortSpanDates) + + const fundRows = [ + { 'NAV Per Share': 42, 'Accounting Date': '06-30-2025' }, + { 'NAV Per Share': 43, 'Accounting Date': '07-01-2025' }, + ] + const fundRes = makeStub('fundRes', { response: { data: { Data: fundRows } } }) + requester.request.mockResolvedValueOnce(fundRes) + + const param = makeStub('param', { globalFundID: FUND_ID } as typeof navInputParams.validated) + + await transport.handleRequest({ adapterSettings } as any, param) + + const fundCallCfg = requester.request.mock.calls[1][1] + expect(fundCallCfg.url).toContain('fromDate=06-28-2025') + }) + + it('caches 400 when Fund rows are empty', async () => { + requester.request.mockResolvedValueOnce(FUND_DATES_RES) + requester.request.mockResolvedValueOnce( + makeStub('emptyFund', { response: { data: { Data: [] } } }), + ) + const param = makeStub('param', { globalFundID: FUND_ID } as typeof navInputParams.validated) + + await transport.handleRequest({ adapterSettings } as any, param) + + const cached = getCachedResponse() + expect(cached.statusCode).toBe(400) + expect(cached.errorMessage).toMatch(/No fund found/i) + }) +}) From ea3696953961fba5416c83bea1f372e44129e7ac Mon Sep 17 00:00:00 2001 From: Mohamed Mehany <7327188+mohamed-mehany@users.noreply.github.com> Date: Tue, 15 Jul 2025 14:35:55 +0400 Subject: [PATCH 7/7] Addresses comments --- .../sources/nav-libre/src/config/index.ts | 5 -- .../nav-libre/src/transport/authentication.ts | 20 +++++--- .../nav-libre/src/transport/fund-dates.ts | 8 +++- .../sources/nav-libre/src/transport/fund.ts | 10 ++-- .../sources/nav-libre/test/unit/fund.test.ts | 10 ---- .../sources/nav-libre/test/unit/nav.test.ts | 46 +++++++++++-------- 6 files changed, 54 insertions(+), 45 deletions(-) diff --git a/packages/sources/nav-libre/src/config/index.ts b/packages/sources/nav-libre/src/config/index.ts index 3c6023b8c3..c96cd9bdcd 100644 --- a/packages/sources/nav-libre/src/config/index.ts +++ b/packages/sources/nav-libre/src/config/index.ts @@ -19,11 +19,6 @@ export const config = new AdapterConfig( type: 'string', default: 'https://api.navfundservices.com', }, - MAX_RETRIES: { - description: 'Maximum attempts of sending a request', - type: 'number', - default: 3, - }, BACKGROUND_EXECUTE_MS: { description: 'The amount of time the background execute should sleep before performing the next request', diff --git a/packages/sources/nav-libre/src/transport/authentication.ts b/packages/sources/nav-libre/src/transport/authentication.ts index d4d5df0219..ee9c1f5f93 100644 --- a/packages/sources/nav-libre/src/transport/authentication.ts +++ b/packages/sources/nav-libre/src/transport/authentication.ts @@ -4,13 +4,19 @@ import { v4 as uuidv4 } from 'uuid' /** * Generate the necessary headers for calling the NAV API with a 5-minute-valid signature. */ -export const getRequestHeaders = ( - method: string, - path: string, - body: string, - apiKey: string, - secret: string, -) => { +export const getRequestHeaders = ({ + method, + path, + body, + apiKey, + secret, +}: { + method: string + path: string + body: string + apiKey: string + secret: string +}) => { const utcNow = new Date().toUTCString() const nonce = uuidv4() const contentHash = CryptoJS.SHA256(body).toString(CryptoJS.enc.Base64) diff --git a/packages/sources/nav-libre/src/transport/fund-dates.ts b/packages/sources/nav-libre/src/transport/fund-dates.ts index 85f090ad4a..fdb1b12963 100644 --- a/packages/sources/nav-libre/src/transport/fund-dates.ts +++ b/packages/sources/nav-libre/src/transport/fund-dates.ts @@ -27,7 +27,13 @@ export const getFundDates = async ({ baseURL: baseURL, url: url, method: method, - headers: getRequestHeaders(method, url, '', apiKey, secret), + headers: getRequestHeaders({ + method: method, + path: url, + body: '', + apiKey: apiKey, + secret: secret, + }), } const sourceResponse = await requester.request( diff --git a/packages/sources/nav-libre/src/transport/fund.ts b/packages/sources/nav-libre/src/transport/fund.ts index f64eb6a11e..00e98e65b5 100644 --- a/packages/sources/nav-libre/src/transport/fund.ts +++ b/packages/sources/nav-libre/src/transport/fund.ts @@ -44,14 +44,18 @@ export const getFund = async ({ }): Promise => { const method = 'GET' const url = `/navapigateway/api/v1/FundAccountingData/GetOfficialNAVAndPerformanceReturnsForFund?globalFundID=${globalFundID}&fromDate=${fromDate}&toDate=${toDate}` - // Body is empy for GET - const body = '' const requestConfig = { baseURL: baseURL, url: url, method: method, - headers: getRequestHeaders(method, url, body, apiKey, secret), + headers: getRequestHeaders({ + method: method, + path: url, + body: '', + apiKey: apiKey, + secret: secret, + }), } const response = await requester.request( diff --git a/packages/sources/nav-libre/test/unit/fund.test.ts b/packages/sources/nav-libre/test/unit/fund.test.ts index a4ac3f5dce..b47741f394 100644 --- a/packages/sources/nav-libre/test/unit/fund.test.ts +++ b/packages/sources/nav-libre/test/unit/fund.test.ts @@ -1,5 +1,4 @@ import { Requester } from '@chainlink/external-adapter-framework/util/requester' -import * as authModule from '../../src/transport/authentication' import { getFund } from '../../src/transport/fund' describe('getFund', () => { @@ -14,19 +13,10 @@ describe('getFund', () => { }, ] - let getRequestHeadersStub: jest.SpyInstance beforeEach(() => { jest.clearAllMocks() - getRequestHeadersStub = jest.spyOn(authModule, 'getRequestHeaders').mockReturnValue({ - 'x-date': 'dummy', - 'x-content-sha256': 'dummy', - 'x-hmac256-signature': 'dummy', - }) }) - afterEach(() => { - getRequestHeadersStub.mockRestore() - }) it('returns fund data on success', async () => { mockRequester.request = jest.fn().mockResolvedValue({ response: { diff --git a/packages/sources/nav-libre/test/unit/nav.test.ts b/packages/sources/nav-libre/test/unit/nav.test.ts index e40cb3ea82..0176647a86 100644 --- a/packages/sources/nav-libre/test/unit/nav.test.ts +++ b/packages/sources/nav-libre/test/unit/nav.test.ts @@ -20,7 +20,6 @@ const adapterSettings = makeStub('adapterSettings', { API_KEY: 'apiKey', SECRET_KEY: 'secret', BACKGROUND_EXECUTE_MS: 0, - MAX_RETRIES: 3, WARMUP_SUBSCRIPTION_TTL: 10_000, } as unknown as BaseEndpointTypes['Settings']) @@ -33,12 +32,6 @@ const dependencies = makeStub('dependencies', { subscriptionSetFactory: { buildSet: jest.fn() }, } as unknown as TransportDependencies) -beforeEach(async () => { - transport = new NavLibreTransport() as unknown as InstanceType - await transport.initialize(dependencies, adapterSettings, endpointName, transportName) - jest.resetAllMocks() -}) - // helper to pull the cached response written in handleRequest const getCachedResponse = () => (responseCache.write.mock.calls[0][1] as any)[0].response @@ -60,6 +53,12 @@ const FUND_RES = makeStub('fundRes', { }) describe('NavLibreTransport – handleRequest', () => { + beforeEach(async () => { + transport = new NavLibreTransport() as unknown as InstanceType + await transport.initialize(dependencies, adapterSettings, endpointName, transportName) + jest.resetAllMocks() + }) + it('returns latest NAV and writes to cache', async () => { requester.request.mockResolvedValueOnce(FUND_DATES_RES) requester.request.mockResolvedValueOnce(FUND_RES) @@ -85,17 +84,21 @@ describe('NavLibreTransport – handleRequest', () => { providerIndicatedTimeUnixMs: expect.any(Number), }), }) + expect(requester.request).toHaveBeenNthCalledWith( + 1, + expect.any(String), + expect.objectContaining({ + url: expect.stringContaining('/GetAccountingDataDates'), + }), + ) - expect(requester.request).toHaveBeenCalledTimes(2) - const fundDatesCall = requester.request.mock.calls[0] - expect(fundDatesCall[1]).toMatchObject({ - url: expect.stringContaining('/GetAccountingDataDates'), - }) - - const fundCall = requester.request.mock.calls[1] - expect(fundCall[1]).toMatchObject({ - url: expect.stringContaining('/GetOfficialNAVAndPerformanceReturnsForFund'), - }) + expect(requester.request).toHaveBeenNthCalledWith( + 2, + expect.any(String), + expect.objectContaining({ + url: expect.stringContaining('/GetOfficialNAVAndPerformanceReturnsForFund'), + }), + ) }) it('maps downstream AdapterError to 502 response', async () => { @@ -129,8 +132,13 @@ describe('NavLibreTransport – handleRequest', () => { await transport.handleRequest({ adapterSettings } as any, param) - const fundCallCfg = requester.request.mock.calls[1][1] - expect(fundCallCfg.url).toContain('fromDate=06-28-2025') + expect(requester.request).toHaveBeenNthCalledWith( + 2, + expect.any(String), + expect.objectContaining({ + url: expect.stringContaining('fromDate=06-28-2025'), + }), + ) }) it('caches 400 when Fund rows are empty', async () => {