Initial transfer API rail: client, router, docs fetcher, tests

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
defiQUG
2026-02-12 11:52:26 -08:00
commit 851a37f224
14 changed files with 5456 additions and 0 deletions

14
.env.example Normal file
View File

@@ -0,0 +1,14 @@
# Transfer API Rail — external ISO 20022 API
# Copy to .env and set values. Do not commit .env.
# Base URL of the external API (e.g. IPv4 address)
TRANSFER_RAIL_BASE_URL=http://187.43.157.150
# API key (e.g. IPv6 address or token provided by the provider)
TRANSFER_RAIL_API_KEY=
# Optional: path to API docs on the external host (default: /openapi.json)
TRANSFER_RAIL_DOCS_PATH=/openapi.json
# Optional: header name for API key (default: X-API-Key)
TRANSFER_RAIL_API_KEY_HEADER=X-API-Key

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
dist/
.env
*.log
.DS_Store
coverage/
*.tsbuildinfo

115
README.md Normal file
View File

@@ -0,0 +1,115 @@
# api-transfer-joesph
ISO 20022 transfer API rail for single-point cash transfer credit messages. Connects to an external API (base URL + API key) and exposes a client and optional HTTP endpoint for core banking applications.
## Configuration
Environment variables (see [.env.example](.env.example)):
| Variable | Description |
|----------|-------------|
| `TRANSFER_RAIL_BASE_URL` | External API base URL (e.g. `http://187.43.157.150`) |
| `TRANSFER_RAIL_API_KEY` | API key (e.g. IPv6 or token from provider) |
| `TRANSFER_RAIL_DOCS_PATH` | Optional; path to API docs (default: `/openapi.json`) |
| `TRANSFER_RAIL_API_KEY_HEADER` | Optional; header name for API key (default: `X-API-Key`) |
| `TRANSFER_RAIL_PORT` | Optional; port for standalone server (default: `4001`) |
Do not commit `.env`. Use a secret manager in production.
## Usage
### Option A — As a library (in-process)
In the core banking app, add this repo as a submodule (e.g. `transfer-rail/`) and depend on it:
```json
"dependencies": {
"api-transfer-joesph": "file:./transfer-rail"
}
```
Then:
```ts
import { createTransferRailClient, getTransferRailConfig } from 'api-transfer-joesph';
const config = getTransferRailConfig();
const client = createTransferRailClient(config);
const result = await client.sendCreditTransfer({
messageType: 'pacs.008',
sender: 'SENDERBIC',
receiver: 'RECEIVERBIC',
document: { amount: '100', currency: 'USD' },
});
```
### Option B — As an HTTP endpoint (sidecar)
Mount the router in your Express app:
```ts
import { createTransferRailRouter } from 'api-transfer-joesph/router';
app.use('/api/transfer-rail', createTransferRailRouter());
```
Or run the standalone server:
```bash
npm run start
# Listens on TRANSFER_RAIL_PORT or 4001
```
Endpoints:
- `GET /api/transfer-rail/health` — health check (and optional external API reachability).
- `POST /api/transfer-rail/iso20022/send` — body: `{ messageType, sender, receiver, document }` (aligned with asle bank API). Returns `{ success, messageId }`.
### Fetching API docs from the external host
```ts
import { fetchApiDocs, getTransferRailConfig } from 'api-transfer-joesph';
const config = getTransferRailConfig();
const docs = await fetchApiDocs(config);
if (docs.ok && typeof docs.body === 'object') {
console.log('OpenAPI spec:', docs.body);
}
```
## Adding this repo as a submodule (core banking app)
From the root of the core banking application repo:
```bash
git submodule add <repo-url> transfer-rail
git add .gitmodules transfer-rail
git commit -m "Add api-transfer-joesph as transfer-rail submodule"
```
Clone the parent repo with submodules:
```bash
git clone --recurse-submodules <parent-repo-url>
# or after clone:
git submodule update --init --recursive
```
Update the submodule to latest:
```bash
git submodule update --remote transfer-rail
```
## Build and test
```bash
npm install
npm run build
npm test
```
## License
ISC

10
jest.config.js Normal file
View File

@@ -0,0 +1,10 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.ts'],
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.test.ts'],
coverageDirectory: 'coverage',
moduleFileExtensions: ['ts', 'js', 'json'],
};

4852
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "api-transfer-joesph",
"version": "1.0.0",
"description": "ISO 20022 transfer API rail — single-point cash transfer credit messages (external API client + optional HTTP endpoint)",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"prepare": "npm run build",
"test": "jest",
"test:watch": "jest --watch",
"start": "node dist/server.js"
},
"exports": {
".": {
"import": "./dist/index.js",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"./client": {
"import": "./dist/client.js",
"require": "./dist/client.js",
"types": "./dist/client.d.ts"
},
"./router": {
"import": "./dist/router.js",
"require": "./dist/router.js",
"types": "./dist/router.d.ts"
}
},
"keywords": ["iso20022", "transfer", "rail", "pacs.008", "banking"],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"express": "^4.22.1"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/jest": "^29.5.14",
"@types/node": "^22.10.1",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
"typescript": "^5.6.3"
},
"engines": {
"node": ">=18"
}
}

116
src/client.test.ts Normal file
View File

@@ -0,0 +1,116 @@
import { createTransferRailClient } from './client';
const baseUrl = 'http://187.43.157.150';
const apiKey = '2804:388:c339:5954:48da:4664:dca1:d00c';
const config = { baseUrl, apiKey, apiKeyHeader: 'X-API-Key' as const };
describe('createTransferRailClient', () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe('sendCreditTransfer', () => {
it('sends ISO 20022 message and returns messageId on 200', async () => {
globalThis.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ 'content-type': 'application/json' }),
json: () => Promise.resolve({ messageId: 'msg-123' }),
text: () => Promise.resolve(''),
});
const client = createTransferRailClient(config);
const result = await client.sendCreditTransfer({
messageType: 'pacs.008',
sender: 'SENDERBIC',
receiver: 'RECEIVERBIC',
document: { amount: '100', currency: 'USD' },
});
expect(result.ok).toBe(true);
expect(result.messageId).toBe('msg-123');
expect(result.statusCode).toBe(200);
expect(fetch).toHaveBeenCalledWith(
`${baseUrl}/api/iso20022/send`,
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({ 'X-API-Key': apiKey }),
body: expect.any(String),
})
);
});
it('returns ok: false and error on non-2xx', async () => {
globalThis.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 502,
headers: new Headers({ 'content-type': 'application/json' }),
json: () => Promise.resolve({ error: 'Bad gateway' }),
text: () => Promise.resolve(''),
});
const client = createTransferRailClient(config);
const result = await client.sendCreditTransfer({
messageType: 'pacs.008',
sender: 'A',
receiver: 'B',
document: {},
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
expect(result.statusCode).toBe(502);
});
it('returns ok: false on network error', async () => {
globalThis.fetch = jest.fn().mockRejectedValue(new Error('ECONNREFUSED'));
const client = createTransferRailClient(config);
const result = await client.sendCreditTransfer({
messageType: 'pacs.008',
sender: 'A',
receiver: 'B',
document: {},
});
expect(result.ok).toBe(false);
expect(result.error).toBeDefined();
});
});
describe('health', () => {
it('returns ok and externalReachable when /health returns 200', async () => {
globalThis.fetch = jest.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
json: () => Promise.resolve({}),
text: () => Promise.resolve(''),
});
const client = createTransferRailClient(config);
const result = await client.health();
expect(result.ok).toBe(true);
expect(result.externalReachable).toBe(true);
});
it('returns ok: false when /health fails', async () => {
globalThis.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 503,
headers: new Headers(),
json: () => Promise.resolve({}),
text: () => Promise.resolve(''),
});
const client = createTransferRailClient(config);
const result = await client.health();
expect(result.ok).toBe(false);
expect(result.externalReachable).toBe(true);
});
});
});

88
src/client.ts Normal file
View File

@@ -0,0 +1,88 @@
import type { TransferRailConfig } from './config';
export interface ISO20022Message {
messageType: string;
sender: string;
receiver: string;
document: Record<string, unknown>;
}
export interface SendCreditTransferResult {
messageId: string;
ok: boolean;
statusCode?: number;
raw?: unknown;
error?: string;
}
export interface TransferRailClient {
sendCreditTransfer(message: ISO20022Message): Promise<SendCreditTransferResult>;
health(): Promise<{ ok: boolean; externalReachable?: boolean; error?: string }>;
}
function defaultPostPath(baseUrl: string): string {
return `${baseUrl.replace(/\/$/, '')}/api/iso20022/send`;
}
/**
* Creates a transfer rail client that uses base URL + API key for all requests.
* Sends ISO 20022 single-point cash transfer credit messages (e.g. pacs.008).
*/
export function createTransferRailClient(config: TransferRailConfig): TransferRailClient {
const baseUrl = config.baseUrl.replace(/\/$/, '');
const apiKeyHeader = config.apiKeyHeader || 'X-API-Key';
async function sendCreditTransfer(message: ISO20022Message): Promise<SendCreditTransferResult> {
const url = defaultPostPath(baseUrl);
const headers: Record<string, string> = {
[apiKeyHeader]: config.apiKey,
'Content-Type': 'application/json',
};
try {
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(message),
});
const contentType = res.headers.get('content-type') || '';
let raw: unknown;
if (contentType.includes('application/json')) {
raw = await res.json();
} else {
raw = await res.text();
}
if (res.ok) {
const data = raw as { messageId?: string; id?: string };
const messageId = data.messageId ?? data.id ?? `ISO20022-${Date.now()}`;
return { messageId, ok: true, statusCode: res.status, raw };
}
const error = typeof raw === 'object' && raw !== null && 'error' in raw
? String((raw as { error: unknown }).error)
: `HTTP ${res.status}`;
return { messageId: '', ok: false, statusCode: res.status, raw, error };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
return { messageId: '', ok: false, error };
}
}
async function health(): Promise<{ ok: boolean; externalReachable?: boolean; error?: string }> {
const url = `${baseUrl}/health`;
try {
const res = await fetch(url, {
method: 'GET',
headers: { [apiKeyHeader]: config.apiKey },
});
return { ok: res.ok, externalReachable: true };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
return { ok: false, externalReachable: false, error };
}
}
return { sendCreditTransfer, health };
}

25
src/config.ts Normal file
View File

@@ -0,0 +1,25 @@
export interface TransferRailConfig {
baseUrl: string;
apiKey: string;
docsPath?: string;
apiKeyHeader?: string;
}
const DEFAULT_DOCS_PATH = '/openapi.json';
const DEFAULT_API_KEY_HEADER = 'X-API-Key';
export function getTransferRailConfig(): TransferRailConfig {
const baseUrl = process.env.TRANSFER_RAIL_BASE_URL || '';
const apiKey = process.env.TRANSFER_RAIL_API_KEY || '';
return {
baseUrl: baseUrl.replace(/\/$/, ''),
apiKey,
docsPath: process.env.TRANSFER_RAIL_DOCS_PATH || DEFAULT_DOCS_PATH,
apiKeyHeader: process.env.TRANSFER_RAIL_API_KEY_HEADER || DEFAULT_API_KEY_HEADER,
};
}
export function isTransferRailConfigured(config?: TransferRailConfig): boolean {
const c = config ?? getTransferRailConfig();
return Boolean(c.baseUrl && c.apiKey);
}

60
src/docs.ts Normal file
View File

@@ -0,0 +1,60 @@
import type { TransferRailConfig } from './config';
export interface DocsFetcherResult {
ok: boolean;
contentType?: string;
body?: string | object;
statusCode?: number;
error?: string;
}
/**
* Fetches API documentation from the external host using the same API key.
* Tries common paths (openapi.json, swagger.json, /docs) if docsPath fails.
*/
export async function fetchApiDocs(config: TransferRailConfig): Promise<DocsFetcherResult> {
const headers: Record<string, string> = {
[config.apiKeyHeader || 'X-API-Key']: config.apiKey,
Accept: 'application/json, text/plain, */*',
};
const pathsToTry = [
config.docsPath || '/openapi.json',
'/openapi.json',
'/swagger.json',
'/api-docs',
'/docs',
].filter((p, i, a) => a.indexOf(p) === i);
for (const path of pathsToTry) {
const url = `${config.baseUrl.replace(/\/$/, '')}${path.startsWith('/') ? path : '/' + path}`;
try {
const res = await fetch(url, { headers, method: 'GET' });
const contentType = res.headers.get('content-type') || '';
let body: string | object;
if (contentType.includes('application/json')) {
body = (await res.json()) as object;
} else {
body = await res.text();
}
if (res.ok) {
return { ok: true, contentType, body, statusCode: res.status };
}
return {
ok: false,
statusCode: res.status,
contentType,
body,
error: `HTTP ${res.status}`,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (path === pathsToTry[pathsToTry.length - 1]) {
return { ok: false, error: message };
}
continue;
}
}
return { ok: false, error: 'Could not fetch docs from any path' };
}

8
src/index.ts Normal file
View File

@@ -0,0 +1,8 @@
export { getTransferRailConfig, isTransferRailConfigured } from './config';
export type { TransferRailConfig } from './config';
export { fetchApiDocs } from './docs';
export type { DocsFetcherResult } from './docs';
export { createTransferRailClient } from './client';
export type { TransferRailClient, ISO20022Message, SendCreditTransferResult } from './client';
export { createTransferRailRouter } from './router';
export { startTransferRailServer } from './server';

74
src/router.ts Normal file
View File

@@ -0,0 +1,74 @@
import { Router, Request, Response } from 'express';
import { getTransferRailConfig, isTransferRailConfigured } from './config';
import { createTransferRailClient } from './client';
/**
* Express router for the transfer rail HTTP API (Option B).
* Mount at e.g. app.use('/api/transfer-rail', railRouter).
* GET /health, POST /iso20022/send — body aligned with asle bank API.
*/
export function createTransferRailRouter(): Router {
const router = Router();
const config = getTransferRailConfig();
if (!isTransferRailConfigured(config)) {
router.use((_req: Request, res: Response) => {
res.status(503).json({
error: 'Transfer rail not configured',
hint: 'Set TRANSFER_RAIL_BASE_URL and TRANSFER_RAIL_API_KEY',
});
});
return router;
}
const client = createTransferRailClient(config);
router.get('/health', async (_req: Request, res: Response) => {
try {
const health = await client.health();
res.status(health.ok ? 200 : 503).json({
status: health.ok ? 'ok' : 'degraded',
externalReachable: health.externalReachable,
error: health.error,
timestamp: new Date().toISOString(),
});
} catch (err) {
res.status(503).json({
status: 'error',
error: err instanceof Error ? err.message : String(err),
timestamp: new Date().toISOString(),
});
}
});
router.post('/iso20022/send', async (req: Request, res: Response) => {
try {
const { messageType, sender, receiver, document } = req.body;
if (!messageType || !sender || !receiver || document === undefined) {
return res.status(400).json({
error: 'messageType, sender, receiver, and document are required',
});
}
const result = await client.sendCreditTransfer({
messageType,
sender,
receiver,
document: typeof document === 'object' ? document : {},
});
if (!result.ok) {
return res.status(result.statusCode && result.statusCode >= 400 ? result.statusCode : 502).json({
error: result.error || 'Failed to send ISO 20022 message',
success: false,
});
}
res.json({ success: true, messageId: result.messageId });
} catch (err) {
res.status(500).json({
error: err instanceof Error ? err.message : String(err),
success: false,
});
}
});
return router;
}

20
src/server.ts Normal file
View File

@@ -0,0 +1,20 @@
import express from 'express';
import { createTransferRailRouter } from './router';
/**
* Standalone server for the transfer rail (Option B — run as separate process).
* Use startTransferRailServer() from the core app or run: node dist/server.js
*/
export function startTransferRailServer(port?: number): ReturnType<express.Application['listen']> {
const app = express();
app.use(express.json());
app.use('/', createTransferRailRouter());
const p = port ?? (Number(process.env.TRANSFER_RAIL_PORT) || 4001);
return app.listen(p, () => {
console.log(`Transfer rail server listening on port ${p}`);
});
}
if (require.main === module) {
startTransferRailServer();
}

18
tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}