PR V: dbis_core HTTP client adapter with provider-switch #26
215
orchestrator/src/services/dbisCore/client.ts
Normal file
215
orchestrator/src/services/dbisCore/client.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* HTTP client adapter for `d-bis/dbis_core`.
|
||||
*
|
||||
* Provider-switched: when `DBIS_CORE_URL` is set the client makes real
|
||||
* HTTP calls to the upstream DBIS Core Banking API; otherwise every
|
||||
* method returns a deterministic mock response so unit tests, local
|
||||
* dev, and CI still work.
|
||||
*
|
||||
* This is intentionally minimal — only the endpoints the orchestrator
|
||||
* actually calls from its settlement / obligation / compliance paths.
|
||||
* Extend the client surface as new orchestrator capabilities need more
|
||||
* of the dbis_core API.
|
||||
*/
|
||||
|
||||
import { logger } from "../../logging/logger";
|
||||
import type {
|
||||
AccountBalance,
|
||||
AriDecisionRequest,
|
||||
AriDecisionResponse,
|
||||
AtomicSettleRequest,
|
||||
AtomicSettleResponse,
|
||||
Pacs008DispatchRequest,
|
||||
Pacs008DispatchResponse,
|
||||
RouteRequest,
|
||||
RouteResponse,
|
||||
SettlementStatus,
|
||||
} from "./types";
|
||||
|
||||
export interface DbisCoreConfig {
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
timeoutMs?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export interface DbisCoreClient {
|
||||
mode: "live" | "mock";
|
||||
getAccountBalance(accountId: string): Promise<AccountBalance>;
|
||||
findSettlementRoute(req: RouteRequest): Promise<RouteResponse>;
|
||||
atomicSettle(req: AtomicSettleRequest): Promise<AtomicSettleResponse>;
|
||||
getSettlementStatus(settlementId: string): Promise<SettlementStatus>;
|
||||
requestAriDecision(req: AriDecisionRequest): Promise<AriDecisionResponse>;
|
||||
dispatchPacs008(req: Pacs008DispatchRequest): Promise<Pacs008DispatchResponse>;
|
||||
}
|
||||
|
||||
function loadConfigFromEnv(): DbisCoreConfig {
|
||||
return {
|
||||
baseUrl: process.env.DBIS_CORE_URL,
|
||||
apiKey: process.env.DBIS_CORE_API_KEY,
|
||||
timeoutMs: process.env.DBIS_CORE_TIMEOUT_MS
|
||||
? parseInt(process.env.DBIS_CORE_TIMEOUT_MS, 10)
|
||||
: 10_000,
|
||||
};
|
||||
}
|
||||
|
||||
class HttpDbisCoreClient implements DbisCoreClient {
|
||||
readonly mode = "live" as const;
|
||||
private readonly baseUrl: string;
|
||||
private readonly apiKey?: string;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
|
||||
constructor(cfg: Required<Pick<DbisCoreConfig, "baseUrl">> & DbisCoreConfig) {
|
||||
this.baseUrl = cfg.baseUrl.replace(/\/+$/, "");
|
||||
this.apiKey = cfg.apiKey;
|
||||
this.timeoutMs = cfg.timeoutMs ?? 10_000;
|
||||
this.fetchImpl = cfg.fetchImpl ?? fetch;
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: "GET" | "POST",
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
Accept: "application/json",
|
||||
};
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json";
|
||||
if (this.apiKey) headers["X-API-Key"] = this.apiKey;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
try {
|
||||
const resp = await this.fetchImpl(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => "");
|
||||
throw new Error(
|
||||
`dbis_core ${method} ${path} failed: HTTP ${resp.status} ${text.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
return (await resp.json()) as T;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
getAccountBalance(accountId: string): Promise<AccountBalance> {
|
||||
return this.request<AccountBalance>(
|
||||
"GET",
|
||||
`/api/accounts/${encodeURIComponent(accountId)}/balance`,
|
||||
);
|
||||
}
|
||||
|
||||
findSettlementRoute(req: RouteRequest): Promise<RouteResponse> {
|
||||
return this.request<RouteResponse>("POST", "/api/isn/route", req);
|
||||
}
|
||||
|
||||
atomicSettle(req: AtomicSettleRequest): Promise<AtomicSettleResponse> {
|
||||
return this.request<AtomicSettleResponse>("POST", "/api/isn/atomic", req);
|
||||
}
|
||||
|
||||
getSettlementStatus(settlementId: string): Promise<SettlementStatus> {
|
||||
return this.request<SettlementStatus>(
|
||||
"GET",
|
||||
`/api/isn/settlements/${encodeURIComponent(settlementId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
requestAriDecision(req: AriDecisionRequest): Promise<AriDecisionResponse> {
|
||||
return this.request<AriDecisionResponse>("POST", "/api/ari/decision", req);
|
||||
}
|
||||
|
||||
dispatchPacs008(req: Pacs008DispatchRequest): Promise<Pacs008DispatchResponse> {
|
||||
return this.request<Pacs008DispatchResponse>(
|
||||
"POST",
|
||||
"/api/v1/gpn/message/pacs008",
|
||||
req,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MockDbisCoreClient implements DbisCoreClient {
|
||||
readonly mode = "mock" as const;
|
||||
|
||||
async getAccountBalance(accountId: string): Promise<AccountBalance> {
|
||||
return {
|
||||
accountId,
|
||||
currency: "USD",
|
||||
available: "1000000.00",
|
||||
held: "0.00",
|
||||
asOf: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async findSettlementRoute(req: RouteRequest): Promise<RouteResponse> {
|
||||
return {
|
||||
routeId: `mock-route-${req.sourceBankId}-${req.destinationBankId}`,
|
||||
hops: [
|
||||
{ bankId: req.sourceBankId, latencyMs: 20, feeBps: 0 },
|
||||
{ bankId: req.destinationBankId, latencyMs: 40, feeBps: 5 },
|
||||
],
|
||||
estimatedLatencyMs: 60,
|
||||
estimatedFeeBps: 5,
|
||||
};
|
||||
}
|
||||
|
||||
async atomicSettle(req: AtomicSettleRequest): Promise<AtomicSettleResponse> {
|
||||
return {
|
||||
settlementId: `mock-stlm-${req.reference}`,
|
||||
status: "settled",
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async getSettlementStatus(settlementId: string): Promise<SettlementStatus> {
|
||||
return {
|
||||
settlementId,
|
||||
status: "settled",
|
||||
legs: [
|
||||
{ legId: `${settlementId}-leg1`, bankId: "mock-src", status: "confirmed" },
|
||||
{ legId: `${settlementId}-leg2`, bankId: "mock-dst", status: "confirmed" },
|
||||
],
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async requestAriDecision(req: AriDecisionRequest): Promise<AriDecisionResponse> {
|
||||
return {
|
||||
txId: req.txId,
|
||||
outcome: "allow",
|
||||
riskScore: 0.1,
|
||||
reasons: ["mock: default allow"],
|
||||
evaluatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async dispatchPacs008(req: Pacs008DispatchRequest): Promise<Pacs008DispatchResponse> {
|
||||
return {
|
||||
messageId: req.messageId,
|
||||
status: "accepted",
|
||||
acknowledgmentRef: `mock-ack-${req.messageId}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory. Call once per process (or per test run) to get a client
|
||||
* wired to whichever backend the env selects.
|
||||
*/
|
||||
export function createDbisCoreClient(
|
||||
cfg: DbisCoreConfig = loadConfigFromEnv(),
|
||||
): DbisCoreClient {
|
||||
if (cfg.baseUrl) {
|
||||
logger.info({ baseUrl: cfg.baseUrl, mode: "live" }, "[DbisCore] HTTP client");
|
||||
return new HttpDbisCoreClient({ ...cfg, baseUrl: cfg.baseUrl });
|
||||
}
|
||||
logger.info({ mode: "mock" }, "[DbisCore] HTTP client (no DBIS_CORE_URL — mock mode)");
|
||||
return new MockDbisCoreClient();
|
||||
}
|
||||
9
orchestrator/src/services/dbisCore/index.ts
Normal file
9
orchestrator/src/services/dbisCore/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Public surface for the dbis_core client adapter.
|
||||
* See ./client.ts for implementation and ./types.ts for the shared
|
||||
* request/response shapes.
|
||||
*/
|
||||
|
||||
export * from "./types";
|
||||
export { createDbisCoreClient } from "./client";
|
||||
export type { DbisCoreClient, DbisCoreConfig } from "./client";
|
||||
106
orchestrator/src/services/dbisCore/types.ts
Normal file
106
orchestrator/src/services/dbisCore/types.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Canonical request/response shapes for the subset of `d-bis/dbis_core`
|
||||
* endpoints the orchestrator actually calls. Kept small and focused —
|
||||
* this is a client adapter, not a mirror of the upstream service.
|
||||
*
|
||||
* Upstream endpoint references (from dbis_core/src/integration/api-
|
||||
* gateway/app.ts mount points):
|
||||
*
|
||||
* GET /api/accounts/:accountId/balance
|
||||
* POST /api/isn/route
|
||||
* POST /api/isn/atomic
|
||||
* POST /api/ari/decision
|
||||
* POST /api/v1/gpn/message/pacs008
|
||||
* GET /api/isn/settlements/:settlementId
|
||||
*/
|
||||
|
||||
export interface AccountBalance {
|
||||
accountId: string;
|
||||
currency: string;
|
||||
available: string;
|
||||
held: string;
|
||||
asOf: string;
|
||||
}
|
||||
|
||||
export interface RouteRequest {
|
||||
sourceBankId: string;
|
||||
destinationBankId: string;
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
}
|
||||
|
||||
export interface SettlementHop {
|
||||
bankId: string;
|
||||
latencyMs: number;
|
||||
feeBps: number;
|
||||
}
|
||||
|
||||
export interface RouteResponse {
|
||||
routeId: string;
|
||||
hops: SettlementHop[];
|
||||
estimatedLatencyMs: number;
|
||||
estimatedFeeBps: number;
|
||||
}
|
||||
|
||||
export interface AtomicSettleRequest {
|
||||
routeId: string;
|
||||
sourceAccountId: string;
|
||||
destinationAccountId: string;
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
reference: string;
|
||||
}
|
||||
|
||||
export interface AtomicSettleResponse {
|
||||
settlementId: string;
|
||||
status: "accepted" | "settled" | "rejected";
|
||||
completedAt?: string;
|
||||
rejectionReason?: string;
|
||||
}
|
||||
|
||||
export interface AriDecisionRequest {
|
||||
txId: string;
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
creator: string;
|
||||
counterparty?: string;
|
||||
metadata?: Record<string, string | number | boolean>;
|
||||
}
|
||||
|
||||
export type AriOutcome = "allow" | "deny" | "review";
|
||||
|
||||
export interface AriDecisionResponse {
|
||||
txId: string;
|
||||
outcome: AriOutcome;
|
||||
riskScore: number;
|
||||
reasons: string[];
|
||||
evaluatedAt: string;
|
||||
}
|
||||
|
||||
export interface Pacs008DispatchRequest {
|
||||
messageId: string;
|
||||
creationDateTime: string;
|
||||
debtor: { name: string; bic: string; account: string };
|
||||
creditor: { name: string; bic: string; account: string };
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
remittanceInfo?: string;
|
||||
}
|
||||
|
||||
export interface Pacs008DispatchResponse {
|
||||
messageId: string;
|
||||
status: "accepted" | "rejected";
|
||||
acknowledgmentRef?: string;
|
||||
rejectionReason?: string;
|
||||
}
|
||||
|
||||
export interface SettlementStatus {
|
||||
settlementId: string;
|
||||
status: "pending" | "routing" | "executing" | "settled" | "failed" | "reversed";
|
||||
legs: {
|
||||
legId: string;
|
||||
bankId: string;
|
||||
status: "pending" | "dispatched" | "confirmed" | "failed";
|
||||
}[];
|
||||
lastUpdated: string;
|
||||
}
|
||||
198
orchestrator/tests/services/dbisCoreClient.test.ts
Normal file
198
orchestrator/tests/services/dbisCoreClient.test.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Unit tests for the dbis_core HTTP client adapter.
|
||||
*
|
||||
* Covers both provider-switch legs:
|
||||
* - `createDbisCoreClient()` with DBIS_CORE_URL unset → mock mode.
|
||||
* - `createDbisCoreClient({ baseUrl, fetchImpl })` → live mode, with
|
||||
* a stub `fetch` so tests never hit the network.
|
||||
*/
|
||||
|
||||
import {
|
||||
createDbisCoreClient,
|
||||
type DbisCoreClient,
|
||||
} from "../../src/services/dbisCore";
|
||||
|
||||
describe("dbisCore.createDbisCoreClient() — mock mode", () => {
|
||||
let client: DbisCoreClient;
|
||||
|
||||
beforeAll(() => {
|
||||
delete process.env.DBIS_CORE_URL;
|
||||
delete process.env.DBIS_CORE_API_KEY;
|
||||
client = createDbisCoreClient();
|
||||
});
|
||||
|
||||
it("reports mock mode", () => {
|
||||
expect(client.mode).toBe("mock");
|
||||
});
|
||||
|
||||
it("returns a balance shaped like upstream", async () => {
|
||||
const b = await client.getAccountBalance("acct-1");
|
||||
expect(b.accountId).toBe("acct-1");
|
||||
expect(typeof b.available).toBe("string");
|
||||
expect(typeof b.held).toBe("string");
|
||||
expect(b.currency).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns a plausible route", async () => {
|
||||
const r = await client.findSettlementRoute({
|
||||
sourceBankId: "src",
|
||||
destinationBankId: "dst",
|
||||
amount: "100",
|
||||
currencyCode: "USD",
|
||||
});
|
||||
expect(r.routeId).toContain("src");
|
||||
expect(r.hops.length).toBeGreaterThan(0);
|
||||
expect(r.estimatedFeeBps).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("settles atomically with a deterministic id", async () => {
|
||||
const s = await client.atomicSettle({
|
||||
routeId: "r1",
|
||||
sourceAccountId: "a",
|
||||
destinationAccountId: "b",
|
||||
amount: "1",
|
||||
currencyCode: "USD",
|
||||
reference: "ref-1",
|
||||
});
|
||||
expect(s.status).toBe("settled");
|
||||
expect(s.settlementId).toContain("ref-1");
|
||||
expect(s.completedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns an allow decision by default from ARI", async () => {
|
||||
const d = await client.requestAriDecision({
|
||||
txId: "tx-1",
|
||||
amount: "1",
|
||||
currencyCode: "USD",
|
||||
creator: "0xdead",
|
||||
});
|
||||
expect(d.outcome).toBe("allow");
|
||||
expect(d.txId).toBe("tx-1");
|
||||
expect(d.riskScore).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("accepts a pacs008 dispatch and echoes the messageId", async () => {
|
||||
const r = await client.dispatchPacs008({
|
||||
messageId: "msg-1",
|
||||
creationDateTime: "2026-01-01T00:00:00Z",
|
||||
debtor: { name: "Acme", bic: "ACMEUS33", account: "1" },
|
||||
creditor: { name: "Widget", bic: "WDGTGB22", account: "2" },
|
||||
amount: "100",
|
||||
currencyCode: "USD",
|
||||
});
|
||||
expect(r.status).toBe("accepted");
|
||||
expect(r.messageId).toBe("msg-1");
|
||||
});
|
||||
|
||||
it("returns a settled status from a synthetic settlementId", async () => {
|
||||
const s = await client.getSettlementStatus("stlm-99");
|
||||
expect(s.status).toBe("settled");
|
||||
expect(s.legs.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dbisCore.createDbisCoreClient() — live mode (stubbed fetch)", () => {
|
||||
function makeFetch(
|
||||
record: (url: string, init: RequestInit) => void,
|
||||
responseBody: unknown,
|
||||
status = 200,
|
||||
): typeof fetch {
|
||||
return (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
record(String(input), init ?? {});
|
||||
return new Response(JSON.stringify(responseBody), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
}
|
||||
|
||||
it("reports live mode when baseUrl is set", () => {
|
||||
const client = createDbisCoreClient({
|
||||
baseUrl: "http://dbis.example.test",
|
||||
fetchImpl: makeFetch(
|
||||
() => undefined,
|
||||
{ accountId: "a", currency: "USD", available: "0", held: "0", asOf: "" },
|
||||
),
|
||||
});
|
||||
expect(client.mode).toBe("live");
|
||||
});
|
||||
|
||||
it("hits GET /api/accounts/:id/balance with the API key header", async () => {
|
||||
const calls: { url: string; headers: Record<string, string>; method?: string }[] = [];
|
||||
const client = createDbisCoreClient({
|
||||
baseUrl: "http://dbis.example.test",
|
||||
apiKey: "k-secret",
|
||||
fetchImpl: makeFetch(
|
||||
(url, init) => {
|
||||
calls.push({
|
||||
url,
|
||||
method: init.method,
|
||||
headers: (init.headers ?? {}) as Record<string, string>,
|
||||
});
|
||||
},
|
||||
{
|
||||
accountId: "a42",
|
||||
currency: "USD",
|
||||
available: "500",
|
||||
held: "10",
|
||||
asOf: "2026-01-01T00:00:00Z",
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
const b = await client.getAccountBalance("a42");
|
||||
expect(b.available).toBe("500");
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].url).toBe("http://dbis.example.test/api/accounts/a42/balance");
|
||||
expect(calls[0].method).toBe("GET");
|
||||
expect(calls[0].headers["X-API-Key"]).toBe("k-secret");
|
||||
});
|
||||
|
||||
it("posts a route request and parses the structured response", async () => {
|
||||
const client = createDbisCoreClient({
|
||||
baseUrl: "http://dbis.example.test/",
|
||||
fetchImpl: makeFetch(
|
||||
() => undefined,
|
||||
{
|
||||
routeId: "R1",
|
||||
hops: [{ bankId: "A", latencyMs: 1, feeBps: 2 }],
|
||||
estimatedLatencyMs: 10,
|
||||
estimatedFeeBps: 2,
|
||||
},
|
||||
),
|
||||
});
|
||||
const r = await client.findSettlementRoute({
|
||||
sourceBankId: "A",
|
||||
destinationBankId: "B",
|
||||
amount: "1",
|
||||
currencyCode: "USD",
|
||||
});
|
||||
expect(r.routeId).toBe("R1");
|
||||
expect(r.estimatedFeeBps).toBe(2);
|
||||
});
|
||||
|
||||
it("throws a descriptive error on non-2xx", async () => {
|
||||
const client = createDbisCoreClient({
|
||||
baseUrl: "http://dbis.example.test",
|
||||
fetchImpl: makeFetch(() => undefined, { error: "denied" }, 403),
|
||||
});
|
||||
await expect(client.getAccountBalance("a1")).rejects.toThrow(/HTTP 403/);
|
||||
});
|
||||
|
||||
it("encodes path parameters safely", async () => {
|
||||
const calls: string[] = [];
|
||||
const client = createDbisCoreClient({
|
||||
baseUrl: "http://dbis.example.test",
|
||||
fetchImpl: makeFetch(
|
||||
(url) => {
|
||||
calls.push(url);
|
||||
},
|
||||
{ settlementId: "x", status: "settled", legs: [], lastUpdated: "" },
|
||||
),
|
||||
});
|
||||
await client.getSettlementStatus("weird/id with space");
|
||||
expect(calls[0]).toBe(
|
||||
"http://dbis.example.test/api/isn/settlements/weird%2Fid%20with%20space",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user