Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ jobs:
printf 'install-dependencies\tnpm ci\tnpm-ci.log\n' >> "$COMMAND_LOG_DIR/COMMANDS.tsv"
npm ci 2>&1 | tee "$COMMAND_LOG_DIR/npm-ci.log"

- name: Validate API compatibility contract
run: npm run check:api-contract

- name: Lint backend
run: |
set -o pipefail
Expand Down
99 changes: 99 additions & 0 deletions backend/src/api/compatibility/contracts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { createHash } from "node:crypto";

export type ApiVersion = "v1";

export interface ApiContract {
version: ApiVersion;
mediaType: string;
status: "current" | "deprecated";
releasedAt: string;
sunsetAt?: string;
pagination: {
style: "page-limit";
defaultLimit: number;
maximumLimit: number;
totalType: "integer";
};
errors: {
contentType: string;
fields: readonly string[];
};
timestamps: {
format: "RFC3339";
timezone: "UTC";
};
numericPrecision: {
jsonNumbers: "IEEE-754 double";
exactValues: "decimal strings";
};
capabilities: Readonly<Record<string, boolean>>;
}

export const API_CONTRACTS: readonly ApiContract[] = [
{
version: "v1",
mediaType: "application/vnd.bridge-watch.v1+json",
status: "current",
releasedAt: "2026-01-01T00:00:00Z",
pagination: {
style: "page-limit",
defaultLimit: 20,
maximumLimit: 100,
totalType: "integer",
},
errors: {
contentType: "application/json",
fields: ["error", "message", "statusCode"],
},
timestamps: { format: "RFC3339", timezone: "UTC" },
numericPrecision: {
jsonNumbers: "IEEE-754 double",
exactValues: "decimal strings",
},
capabilities: {
"pagination.pageLimit": true,
"errors.statusCode": true,
"timestamps.rfc3339": true,
"numeric.decimalStrings": true,
"migration.dualReadWrite": true,
},
},
];

export function getContract(version: string | undefined): ApiContract | undefined {
return API_CONTRACTS.find((contract) => contract.version === version);
}

export function getCurrentContract(): ApiContract {
return API_CONTRACTS.find((contract) => contract.status === "current") ?? API_CONTRACTS[0];
}

function stableJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`)
.join(",")}}`;
}
return JSON.stringify(value);
}

export function getContractFingerprint(contract: ApiContract): string {
return createHash("sha256").update(stableJson(contract)).digest("hex");
}

export function parseRequestedVersion(headers: {
"x-api-version"?: string;
accept?: string;
}): ApiVersion | undefined {
const explicitVersion = headers["x-api-version"]?.trim();
if (explicitVersion) return explicitVersion as ApiVersion;

const mediaVersion = headers.accept?.match(/application\/vnd\.bridge-watch\.(v\d+)\+json/i)?.[1];
return mediaVersion as ApiVersion | undefined;
}

export function isVendorMediaType(accept: string | undefined, contract: ApiContract): boolean {
return accept?.split(",").some((value) => value.trim().split(";")[0] === contract.mediaType) ?? false;
}
49 changes: 49 additions & 0 deletions backend/src/api/compatibility/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import {
getContract,
getContractFingerprint,
getCurrentContract,
isVendorMediaType,
parseRequestedVersion,
} from "./contracts.js";

declare module "fastify" {
interface FastifyRequest {
apiContract: ReturnType<typeof getCurrentContract>;
}
}

function applyContractHeaders(reply: FastifyReply, request: FastifyRequest): void {
const contract = request.apiContract;
reply.header("X-API-Version", contract.version);
reply.header("X-API-Contract", getContractFingerprint(contract));
reply.header("Vary", "Accept, X-API-Version");
if (isVendorMediaType(request.headers.accept, contract)) {
reply.type(contract.mediaType);
}
if (contract.status === "deprecated") {
reply.header("Deprecation", "true");
if (contract.sunsetAt) reply.header("Sunset", contract.sunsetAt);
}
}

export async function registerCompatibilityMiddleware(server: FastifyInstance): Promise<void> {
server.addHook("onRequest", async (request, reply) => {
if (!request.url.startsWith("/api/")) return;

const requestedVersion = parseRequestedVersion(request.headers);
const contract = requestedVersion ? getContract(requestedVersion) : getCurrentContract();

if (!contract) {
reply.header("Vary", "Accept, X-API-Version");
return reply.code(406).send({
error: "UNSUPPORTED_API_VERSION",
message: "The requested API version is not supported.",
supportedVersions: [getCurrentContract().version],
});
}

request.apiContract = contract;
applyContractHeaders(reply, request);
});
}
8 changes: 8 additions & 0 deletions backend/src/api/compatibility/migration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function dualRead<T>(source: Record<string, unknown>, legacyField: string, currentField: string): T | undefined {
return (source[currentField] ?? source[legacyField]) as T | undefined;
}

export function dualWrite<T>(target: Record<string, unknown>, value: T, legacyField: string, currentField: string): void {
target[legacyField] = value;
target[currentField] = value;
}
67 changes: 67 additions & 0 deletions backend/src/api/compatibility/routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { FastifyInstance, FastifyReply } from "fastify";
import { API_CONTRACTS, getContractFingerprint, getCurrentContract } from "./contracts.js";

export async function compatibilityRoutes(server: FastifyInstance): Promise<void> {
server.get("/contract", {
schema: {
tags: ["Compatibility"],
summary: "Inspect a negotiated API response contract",
querystring: {
type: "object",
properties: { version: { type: "string", pattern: "^v[0-9]+$" } },
},
response: { 200: { type: "object", additionalProperties: true } },
},
}, async (request, reply) => {
const version = (request.query as { version?: string }).version ?? request.apiContract.version;
const contract = API_CONTRACTS.find((item) => item.version === version);
if (!contract) return unsupportedVersion(reply);
return { ...contract, fingerprint: getContractFingerprint(contract) };
});

server.get("/capabilities", {
schema: {
tags: ["Compatibility"],
summary: "List fields and features supported by an API version",
querystring: {
type: "object",
properties: { version: { type: "string", pattern: "^v[0-9]+$" } },
},
response: { 200: { type: "object", additionalProperties: true } },
},
}, async (request, reply) => {
const version = (request.query as { version?: string }).version ?? request.apiContract.version;
const contract = API_CONTRACTS.find((item) => item.version === version);
if (!contract) return unsupportedVersion(reply);
return {
version: contract.version,
fingerprint: getContractFingerprint(contract),
capabilities: contract.capabilities,
};
});

server.get("/versions", {
schema: {
tags: ["Compatibility"],
summary: "List supported API versions",
response: { 200: { type: "object", additionalProperties: true } },
},
}, async () => ({
current: getCurrentContract().version,
versions: API_CONTRACTS.map((contract) => ({
version: contract.version,
mediaType: contract.mediaType,
status: contract.status,
fingerprint: getContractFingerprint(contract),
sunsetAt: contract.sunsetAt ?? null,
})),
}));
}

function unsupportedVersion(reply: FastifyReply) {
return reply.code(406).send({
error: "UNSUPPORTED_API_VERSION",
message: "The requested API version is not supported.",
supportedVersions: API_CONTRACTS.map((item) => item.version),
});
}
4 changes: 4 additions & 0 deletions backend/src/api/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { registerSourceRoutes } from "./route-groups/source-routes.js";
import { registerAnomalyRoutes } from "./route-groups/anomaly-routes.js";
import { registerAutomationRoutes } from "./route-groups/automation-routes.js";
import { registerUtilityRoutes } from "./route-groups/utility-routes.js";
import { registerCompatibilityRoutes } from "./route-groups/compatibility-routes.js";

export async function registerRoutes(server: FastifyInstance): Promise<void> {
// Core routes: health, websocket, config, preferences, caching
Expand Down Expand Up @@ -64,4 +65,7 @@ export async function registerRoutes(server: FastifyInstance): Promise<void> {

// Utility routes: exports, metadata, cleanup
await registerUtilityRoutes(server);

// API compatibility: negotiated contracts, capabilities, and versions
await registerCompatibilityRoutes(server);
}
6 changes: 6 additions & 0 deletions backend/src/api/routes/route-groups/compatibility-routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { FastifyInstance } from "fastify";
import { compatibilityRoutes } from "../../compatibility/routes.js";

export async function registerCompatibilityRoutes(server: FastifyInstance): Promise<void> {
server.register(compatibilityRoutes, { prefix: "/api/v1/compatibility" });
}
2 changes: 2 additions & 0 deletions backend/src/config/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function resolveTagFromPath(url: string): string {
if (url.startsWith("/api/v1/balances")) return "Assets";
if (url.startsWith("/api/v1/webhooks")) return "Alerts";
if (url.startsWith("/api/v1/admin")) return "Config";
if (url.startsWith("/api/v1/compatibility")) return "Compatibility";
if (url.startsWith("/api/v1/health") || url.startsWith("/health")) return "Health";
return "Config";
}
Expand Down Expand Up @@ -168,6 +169,7 @@ least 90 days after a new version is released.
{ name: "Config", description: "Runtime configuration and feature flags" },
{ name: "Cache", description: "Redis cache inspection and invalidation" },
{ name: "Circuit Breaker", description: "Automated circuit-breaker pause controls" },
{ name: "Compatibility", description: "Negotiated API contracts, capabilities, and versions" },
],
},
transform: ({ schema, url, route }) => {
Expand Down
3 changes: 3 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { registerCorrelationMiddleware } from "./api/middleware/correlation.midd
import { registerRequestLoggingMiddleware } from "./api/middleware/logging.middleware.js";
import { registerTracing } from "./api/middleware/tracing.js";
import { getTelegramBotService } from "./services/telegram.bot.service.js";
import { registerCompatibilityMiddleware } from "./api/compatibility/middleware.js";

export async function buildServer() {
const server = Fastify({
Expand Down Expand Up @@ -92,6 +93,8 @@ export async function buildServer() {
credentials: true,
});

await registerCompatibilityMiddleware(server);

// OpenAPI / Swagger — must be registered before routes so schemas are collected
await server.register(swagger, swaggerOptions);
await server.register(swaggerUi, swaggerUiOptions);
Expand Down
57 changes: 57 additions & 0 deletions backend/tests/api/compatibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import Fastify from "fastify";
import {
API_CONTRACTS,
getContractFingerprint,
getCurrentContract,
parseRequestedVersion,
} from "../../src/api/compatibility/contracts.js";
import { dualRead, dualWrite } from "../../src/api/compatibility/migration.js";
import { registerCompatibilityMiddleware } from "../../src/api/compatibility/middleware.js";
import { compatibilityRoutes } from "../../src/api/compatibility/routes.js";

describe("API compatibility contracts", () => {
it("selects an explicit version before the Accept media type", () => {
expect(parseRequestedVersion({
"x-api-version": "v1",
accept: "application/vnd.bridge-watch.v1+json",
})).toBe("v1");
});

it("creates stable fingerprints for the shared contract", () => {
expect(getContractFingerprint(getCurrentContract())).toMatch(/^[a-f0-9]{64}$/);
expect(getContractFingerprint(API_CONTRACTS[0])).toBe(getContractFingerprint(API_CONTRACTS[0]));
});

it("supports dual-read and dual-write migrations", () => {
const target: Record<string, unknown> = {};
dualWrite(target, "42.50", "oldAmount", "amount");
expect(dualRead<string>(target, "oldAmount", "amount")).toBe("42.50");
expect(target).toEqual({ oldAmount: "42.50", amount: "42.50" });
});

it("negotiates response contracts and rejects unsupported versions", async () => {
const server = Fastify();
await registerCompatibilityMiddleware(server);
await server.register(compatibilityRoutes, { prefix: "/api/v1/compatibility" });

const supported = await server.inject({
method: "GET",
url: "/api/v1/compatibility/capabilities",
headers: { accept: "application/vnd.bridge-watch.v1+json" },
});
expect(supported.statusCode).toBe(200);
expect(supported.headers["x-api-version"]).toBe("v1");
expect(supported.headers["x-api-contract"]).toMatch(/^[a-f0-9]{64}$/);

const unsupported = await server.inject({
method: "GET",
url: "/api/v1/compatibility/contract",
headers: { "x-api-version": "v99" },
});
expect(unsupported.statusCode).toBe(406);
expect(unsupported.json().error).toBe("UNSUPPORTED_API_VERSION");

await server.close();
});
});
11 changes: 11 additions & 0 deletions contracts/api-compatibility.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"current": "v1",
"versions": [
{
"version": "v1",
"mediaType": "application/vnd.bridge-watch.v1+json",
"status": "current",
"fingerprintSource": "backend/src/api/compatibility/contracts.ts"
}
]
}
Loading
Loading