Refactor API contracts to Zod, zod-to-openapi, and Scalar.

Replace hand-written OpenAPI and Swagger UI with schema-driven docs at /api-docs and Zod validation on request bodies.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Mauricio Barragan
2026-06-11 00:04:12 -06:00
parent b5ff33d716
commit f4def0c214
13 changed files with 788 additions and 812 deletions
File diff suppressed because it is too large Load Diff
+32 -29
View File
@@ -1,6 +1,14 @@
import { Router, type Request, type Response } from "express";
import type { ApplicationService } from "../../composition/application-service.js";
import type { SseHub } from "../sse/sse.js";
import {
ConfigPatchSchema,
DemoControlBodySchema,
MaxTradeControlBodySchema,
RecordControlBodySchema,
ThresholdControlBodySchema,
} from "./schemas/requests.js";
import { parseBody } from "./validate.js";
export function createRouter(app: ApplicationService, sse: SseHub): Router {
const router = Router();
@@ -22,7 +30,10 @@ export function createRouter(app: ApplicationService, sse: SseHub): Router {
});
router.patch("/config", (req: Request, res: Response) => {
const error = app.patchConfig(req.body ?? {});
const patch = parseBody(ConfigPatchSchema, req, res);
if (patch === null) return;
const error = app.patchConfig(patch);
if (error) {
res.status(400).json({ success: false, error });
return;
@@ -46,51 +57,43 @@ export function createRouter(app: ApplicationService, sse: SseHub): Router {
});
router.post("/control/demo", (req: Request, res: Response) => {
const enabled = req.body?.enabled;
if (typeof enabled !== "boolean") {
res.status(400).json({ success: false, error: "enabled must be a boolean" });
return;
}
app.setDemoMode(enabled);
res.json({ success: true, data: { demoMode: enabled } });
const body = parseBody(DemoControlBodySchema, req, res);
if (body === null) return;
app.setDemoMode(body.enabled);
res.json({ success: true, data: { demoMode: body.enabled } });
});
router.post("/control/record", (req: Request, res: Response) => {
const enabled = req.body?.enabled;
if (typeof enabled !== "boolean") {
res.status(400).json({ success: false, error: "enabled must be a boolean" });
return;
}
app.setRecordFeed(enabled);
res.json({ success: true, data: { recordFeed: enabled } });
const body = parseBody(RecordControlBodySchema, req, res);
if (body === null) return;
app.setRecordFeed(body.enabled);
res.json({ success: true, data: { recordFeed: body.enabled } });
});
router.post("/control/threshold", (req: Request, res: Response) => {
const pct = req.body?.pct;
if (typeof pct !== "number" || !Number.isFinite(pct)) {
res.status(400).json({ success: false, error: "pct must be a finite number" });
return;
}
const error = app.setThreshold(pct);
const body = parseBody(ThresholdControlBodySchema, req, res);
if (body === null) return;
const error = app.setThreshold(body.pct);
if (error) {
res.status(400).json({ success: false, error });
return;
}
res.json({ success: true, data: { minNetProfitPct: pct } });
res.json({ success: true, data: { minNetProfitPct: body.pct } });
});
router.post("/control/max-trade", (req: Request, res: Response) => {
const btc = req.body?.btc;
if (typeof btc !== "number" || !Number.isFinite(btc)) {
res.status(400).json({ success: false, error: "btc must be a finite number" });
return;
}
const error = app.setMaxTradeBtc(btc);
const body = parseBody(MaxTradeControlBodySchema, req, res);
if (body === null) return;
const error = app.setMaxTradeBtc(body.btc);
if (error) {
res.status(400).json({ success: false, error });
return;
}
res.json({ success: true, data: { maxTradeBtc: btc } });
res.json({ success: true, data: { maxTradeBtc: body.btc } });
});
return router;
+59
View File
@@ -0,0 +1,59 @@
import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
import { z } from "zod";
extendZodWithOpenApi(z);
export { z };
export const ExchangeIdSchema = z
.enum(["kraken", "bybit", "okx", "binance"])
.openapi("ExchangeId");
export const FeedStatusSchema = z
.enum(["connecting", "live", "stale", "down"])
.openapi("FeedStatus");
export const CircuitStateSchema = z
.enum(["running", "paused", "tripped"])
.openapi("CircuitState");
export const OpportunityStatusSchema = z
.enum([
"executed",
"executed_partial",
"rejected_fees",
"rejected_liquidity",
"rejected_risk",
"rejected_flicker",
"rejected_stale",
"pending_confirm",
])
.openapi("OpportunityStatus");
export const ErrorEnvelopeSchema = z
.object({
success: z.literal(false),
error: z.string(),
})
.strict()
.openapi("ErrorEnvelope");
export const SuccessEnvelopeNoDataSchema = z
.object({
success: z.literal(true),
})
.strict()
.openapi("SuccessEnvelopeNoData");
export function successEnvelope<T extends z.ZodType>(
dataSchema: T,
name: string,
) {
return z
.object({
success: z.literal(true),
data: dataSchema,
})
.strict()
.openapi(name);
}
+161
View File
@@ -0,0 +1,161 @@
import {
CircuitStateSchema,
ExchangeIdSchema,
FeedStatusSchema,
OpportunityStatusSchema,
z,
} from "./common.js";
export const BestQuoteSchema = z
.object({
exchange: ExchangeIdSchema,
bid: z.number().nullable(),
bidQty: z.number().nullable(),
ask: z.number().nullable(),
askQty: z.number().nullable(),
recvTs: z.number().int().nullable(),
status: FeedStatusSchema,
ageMs: z.number().int().nullable(),
})
.strict()
.openapi("BestQuote");
export const WalletSchema = z
.object({
exchange: ExchangeIdSchema,
usdt: z.number(),
btc: z.number(),
})
.strict()
.openapi("Wallet");
export const OpportunitySchema = z
.object({
id: z.string(),
ts: z.number().int(),
buyExchange: ExchangeIdSchema,
sellExchange: ExchangeIdSchema,
topBuyAsk: z.number(),
topSellBid: z.number(),
volumeBtc: z.number(),
buyVwap: z.number(),
sellVwap: z.number(),
grossSpread: z.number(),
grossSpreadPct: z.number(),
feeBuy: z.number(),
feeSell: z.number(),
netProfit: z.number(),
netProfitPct: z.number(),
status: OpportunityStatusSchema,
reason: z.string(),
demo: z.boolean(),
})
.strict()
.openapi("Opportunity");
export const TradeSchema = z
.object({
id: z.string(),
ts: z.number().int(),
buyExchange: ExchangeIdSchema,
sellExchange: ExchangeIdSchema,
volumeBtc: z.number(),
requestedBtc: z.number(),
buyVwap: z.number(),
sellVwap: z.number(),
execBuyVwap: z.number(),
execSellVwap: z.number(),
feeBuy: z.number(),
feeSell: z.number(),
netProfit: z.number(),
netProfitPct: z.number(),
partial: z.boolean(),
demo: z.boolean(),
})
.strict()
.openapi("Trade");
export const RebalanceEventSchema = z
.object({
id: z.string(),
ts: z.number().int(),
fromExchange: ExchangeIdSchema,
toExchange: ExchangeIdSchema,
asset: z.enum(["BTC", "USDT"]),
amount: z.number(),
withdrawalFee: z.number(),
reason: z.string(),
})
.strict()
.openapi("RebalanceEvent");
export const PnlPointSchema = z
.object({
ts: z.number().int(),
pnl: z.number(),
})
.strict()
.openapi("PnlPoint");
export const EngineStatsSchema = z
.object({
uptimeMs: z.number().int(),
ticksProcessed: z.number().int(),
opportunitiesDetected: z.number().int(),
tradesExecuted: z.number().int(),
tradesRejected: z.number().int(),
realizedPnl: z.number(),
consecutiveLosses: z.number().int(),
circuit: CircuitStateSchema,
demoMode: z.boolean(),
avgTickMs: z.number(),
})
.strict()
.openapi("EngineStats");
const ActiveExchangesSchema = z.record(ExchangeIdSchema, z.boolean());
export const PublicConfigSchema = z
.object({
minNetProfitPct: z.number(),
maxTradeBtc: z.number(),
staleMs: z.number().int(),
flickerConfirmMs: z.number().int(),
latencyMs: z.number().int(),
activeExchanges: ActiveExchangesSchema,
defaults: z
.object({
minNetProfitPct: z.number(),
maxTradeBtc: z.number(),
flickerConfirmMs: z.number().int(),
activeExchanges: ActiveExchangesSchema,
})
.strict(),
takerFees: z.record(ExchangeIdSchema, z.number()),
withdrawalFeesBtc: z.record(ExchangeIdSchema, z.number()),
})
.strict()
.openapi("PublicConfig");
export const StateSnapshotSchema = z
.object({
ts: z.number().int(),
quotes: z.array(BestQuoteSchema),
wallets: z.array(WalletSchema),
stats: EngineStatsSchema,
recentOpportunities: z.array(OpportunitySchema),
recentTrades: z.array(TradeSchema),
rebalances: z.array(RebalanceEventSchema),
pnlSeries: z.array(PnlPointSchema),
config: PublicConfigSchema,
})
.strict()
.openapi("StateSnapshot");
export const HealthDataSchema = z
.object({
status: z.literal("ok"),
ts: z.number().int(),
})
.strict()
.openapi("HealthData");
+53
View File
@@ -0,0 +1,53 @@
import { ExchangeIdSchema, z } from "./common.js";
const MIN_PROFIT_PCT = 0.0001;
const MAX_PROFIT_PCT = 0.01;
const MIN_TRADE_BTC = 0.01;
const MAX_TRADE_BTC = 1.0;
const MAX_FLICKER_MS = 500;
export const BooleanControlBodySchema = z
.object({
enabled: z.boolean(),
})
.strict()
.openapi("BooleanControlBody");
export const DemoControlBodySchema =
BooleanControlBodySchema.openapi("DemoControlBody");
export const RecordControlBodySchema =
BooleanControlBodySchema.openapi("RecordControlBody");
export const ThresholdControlBodySchema = z
.object({
pct: z.number().finite().min(MIN_PROFIT_PCT).max(MAX_PROFIT_PCT),
})
.strict()
.openapi("ThresholdControlBody");
export const MaxTradeControlBodySchema = z
.object({
btc: z.number().finite().min(MIN_TRADE_BTC).max(MAX_TRADE_BTC),
})
.strict()
.openapi("MaxTradeControlBody");
export const ConfigPatchSchema = z
.object({
minNetProfitPct: z
.number()
.finite()
.min(MIN_PROFIT_PCT)
.max(MAX_PROFIT_PCT)
.optional(),
maxTradeBtc: z
.number()
.finite()
.min(MIN_TRADE_BTC)
.max(MAX_TRADE_BTC)
.optional(),
flickerConfirmMs: z.number().int().min(0).max(MAX_FLICKER_MS).optional(),
activeExchanges: z.record(ExchangeIdSchema, z.boolean()).optional(),
})
.strict()
.openapi("ConfigPatch");
+42
View File
@@ -0,0 +1,42 @@
import { successEnvelope, SuccessEnvelopeNoDataSchema } from "./common.js";
import {
HealthDataSchema,
PublicConfigSchema,
StateSnapshotSchema,
} from "./domain.js";
import { z } from "./common.js";
export const HealthResponseSchema = successEnvelope(
HealthDataSchema,
"HealthResponse",
);
export const StateResponseSchema = successEnvelope(
StateSnapshotSchema,
"StateResponse",
);
export const ConfigResponseSchema = successEnvelope(
PublicConfigSchema,
"ConfigResponse",
);
export const DemoModeResponseSchema = successEnvelope(
z.object({ demoMode: z.boolean() }).strict(),
"DemoModeResponse",
);
export const RecordFeedResponseSchema = successEnvelope(
z.object({ recordFeed: z.boolean() }).strict(),
"RecordFeedResponse",
);
export const ThresholdResponseSchema = successEnvelope(
z.object({ minNetProfitPct: z.number() }).strict(),
"ThresholdResponse",
);
export const MaxTradeResponseSchema = successEnvelope(
z.object({ maxTradeBtc: z.number() }).strict(),
"MaxTradeResponse",
);
export { SuccessEnvelopeNoDataSchema };
+18
View File
@@ -0,0 +1,18 @@
import type { Request, Response } from "express";
import type { ZodType } from "zod";
export function parseBody<T>(
schema: ZodType<T>,
req: Request,
res: Response,
): T | null {
const result = schema.safeParse(req.body ?? {});
if (!result.success) {
const message = result.error.issues
.map((issue) => issue.message)
.join("; ");
res.status(400).json({ success: false, error: message });
return null;
}
return result.data;
}