PR V: dbis_core HTTP client adapter with provider-switch #26

Merged
nsatoshi merged 1 commits from devin/1776892235-pr-v-dbiscore-client into main 2026-04-22 21:11:59 +00:00
4 changed files with 528 additions and 0 deletions

View 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();
}

View 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";

View 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;
}

View 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",
);
});
});