From 8a340ef82dc07a36692e027ce3863d5a71d5b18b Mon Sep 17 00:00:00 2001 From: 0xApana Date: Sat, 22 Aug 2026 09:01:14 +0100 Subject: [PATCH] feat: add version negotiated API compatibility gateway --- .github/workflows/ci.yml | 3 + backend/src/api/compatibility/contracts.ts | 99 +++++++++++++++++++ backend/src/api/compatibility/middleware.ts | 49 +++++++++ backend/src/api/compatibility/migration.ts | 8 ++ backend/src/api/compatibility/routes.ts | 67 +++++++++++++ backend/src/api/routes/index.ts | 4 + .../route-groups/compatibility-routes.ts | 6 ++ backend/src/config/openapi.ts | 2 + backend/src/index.ts | 3 + backend/tests/api/compatibility.test.ts | 57 +++++++++++ contracts/api-compatibility.json | 11 +++ docs/api-compatibility.md | 46 +++++++++ frontend/src/services/api.ts | 26 +++++ .../src/test/fixtures/api-compatibility.json | 11 +++ package.json | 2 + scripts/check-api-contract.mjs | 34 +++++++ sdk/src/client.ts | 23 +++++ sdk/src/compatibility.ts | 41 ++++++++ sdk/src/fixtures/api-compatibility.json | 11 +++ sdk/src/index.ts | 1 + sdk/src/types.ts | 1 + 21 files changed, 505 insertions(+) create mode 100644 backend/src/api/compatibility/contracts.ts create mode 100644 backend/src/api/compatibility/middleware.ts create mode 100644 backend/src/api/compatibility/migration.ts create mode 100644 backend/src/api/compatibility/routes.ts create mode 100644 backend/src/api/routes/route-groups/compatibility-routes.ts create mode 100644 backend/tests/api/compatibility.test.ts create mode 100644 contracts/api-compatibility.json create mode 100644 docs/api-compatibility.md create mode 100644 frontend/src/test/fixtures/api-compatibility.json create mode 100644 scripts/check-api-contract.mjs create mode 100644 sdk/src/compatibility.ts create mode 100644 sdk/src/fixtures/api-compatibility.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8521c99..891a8819 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/backend/src/api/compatibility/contracts.ts b/backend/src/api/compatibility/contracts.ts new file mode 100644 index 00000000..18cddbd7 --- /dev/null +++ b/backend/src/api/compatibility/contracts.ts @@ -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>; +} + +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) + .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; +} diff --git a/backend/src/api/compatibility/middleware.ts b/backend/src/api/compatibility/middleware.ts new file mode 100644 index 00000000..f806f2f6 --- /dev/null +++ b/backend/src/api/compatibility/middleware.ts @@ -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; + } +} + +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 { + 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); + }); +} diff --git a/backend/src/api/compatibility/migration.ts b/backend/src/api/compatibility/migration.ts new file mode 100644 index 00000000..b6d9955a --- /dev/null +++ b/backend/src/api/compatibility/migration.ts @@ -0,0 +1,8 @@ +export function dualRead(source: Record, legacyField: string, currentField: string): T | undefined { + return (source[currentField] ?? source[legacyField]) as T | undefined; +} + +export function dualWrite(target: Record, value: T, legacyField: string, currentField: string): void { + target[legacyField] = value; + target[currentField] = value; +} diff --git a/backend/src/api/compatibility/routes.ts b/backend/src/api/compatibility/routes.ts new file mode 100644 index 00000000..1aa5554c --- /dev/null +++ b/backend/src/api/compatibility/routes.ts @@ -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 { + 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), + }); +} diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index 4e304ccd..da23bff8 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -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 { // Core routes: health, websocket, config, preferences, caching @@ -64,4 +65,7 @@ export async function registerRoutes(server: FastifyInstance): Promise { // Utility routes: exports, metadata, cleanup await registerUtilityRoutes(server); + + // API compatibility: negotiated contracts, capabilities, and versions + await registerCompatibilityRoutes(server); } diff --git a/backend/src/api/routes/route-groups/compatibility-routes.ts b/backend/src/api/routes/route-groups/compatibility-routes.ts new file mode 100644 index 00000000..1fb87a0a --- /dev/null +++ b/backend/src/api/routes/route-groups/compatibility-routes.ts @@ -0,0 +1,6 @@ +import type { FastifyInstance } from "fastify"; +import { compatibilityRoutes } from "../../compatibility/routes.js"; + +export async function registerCompatibilityRoutes(server: FastifyInstance): Promise { + server.register(compatibilityRoutes, { prefix: "/api/v1/compatibility" }); +} diff --git a/backend/src/config/openapi.ts b/backend/src/config/openapi.ts index 77a07629..3e039b31 100644 --- a/backend/src/config/openapi.ts +++ b/backend/src/config/openapi.ts @@ -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"; } @@ -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 }) => { diff --git a/backend/src/index.ts b/backend/src/index.ts index fe0a01d3..c150f7c2 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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({ @@ -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); diff --git a/backend/tests/api/compatibility.test.ts b/backend/tests/api/compatibility.test.ts new file mode 100644 index 00000000..ff06c069 --- /dev/null +++ b/backend/tests/api/compatibility.test.ts @@ -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 = {}; + dualWrite(target, "42.50", "oldAmount", "amount"); + expect(dualRead(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(); + }); +}); diff --git a/contracts/api-compatibility.json b/contracts/api-compatibility.json new file mode 100644 index 00000000..b4c0fdcc --- /dev/null +++ b/contracts/api-compatibility.json @@ -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" + } + ] +} diff --git a/docs/api-compatibility.md b/docs/api-compatibility.md new file mode 100644 index 00000000..b252bcb1 --- /dev/null +++ b/docs/api-compatibility.md @@ -0,0 +1,46 @@ +# API Compatibility Gateway + +Bridge-Watch clients can pin and inspect their response contract independently of the URL version. The URL remains `/api/v1`, while contract negotiation supports either header below: + +```http +Accept: application/vnd.bridge-watch.v1+json +X-API-Version: v1 +``` + +`X-API-Version` takes precedence when both headers are present. Requests without either header receive the current contract. Unsupported versions return `406 Not Acceptable` with `UNSUPPORTED_API_VERSION` and the supported version list. + +Every API response includes: + +```http +X-API-Version: v1 +X-API-Contract: +Vary: Accept, X-API-Version +``` + +Deprecated contracts additionally include `Deprecation: true` and an RFC 7231 `Sunset` timestamp. Lifecycle dates are defined once in `backend/src/api/compatibility/contracts.ts`, ensuring all routes emit the same policy. + +## Discovery + +- `GET /api/v1/compatibility/versions` lists supported contracts and fingerprints. +- `GET /api/v1/compatibility/contract?version=v1` returns the complete contract. +- `GET /api/v1/compatibility/capabilities?version=v1` returns field and migration capabilities. + +The fingerprint is a SHA-256 digest of a recursively key-sorted contract. Clients can compare it with fixtures without depending on JSON property order. + +## Stable Formats + +Version `v1` uses page/limit pagination. The default limit is 20, the maximum is 100, and totals are JSON integers. + +Errors use JSON objects with `error`, `message`, and optional `statusCode` fields. New optional fields are additive; removing or changing an existing field is incompatible. + +Timestamps use RFC 3339 in UTC. Producers should emit a `Z` suffix, and consumers must not infer a local timezone. + +Ordinary measurements use JSON numbers with IEEE-754 double precision. Values that require exact decimal or integer precision, including asset amounts and ledger-scale identifiers, use decimal strings. + +## Contract Checks + +`contracts/api-compatibility.json` is the source for generated frontend and SDK fixtures. Run `npm run generate:api-contract` after changing it. `npm run check:api-contract` ensures every supported fixture exists in the backend registry with the same media type and that both generated copies are current. CI runs this check before builds and tests, so an incompatible removal, stale fixture, or media-type change fails the pull request. + +## Breaking Migrations + +For renamed domain fields, use `dualRead` and `dualWrite` from `backend/src/api/compatibility/migration.ts` during the published deprecation window. Writes populate both representations; reads prefer the current field and fall back to the legacy field. Remove the legacy representation only after its contract sunset date. diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 3e9f9f9c..c9acfdb1 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -37,6 +37,32 @@ import type { import type { LiquidityConcentrationData } from "../types/liquidity"; const API_BASE_URL = "/api/v1"; +export type ApiVersion = "v1"; +export interface ApiContractSummary { + version: ApiVersion; + mediaType: string; + status: "current" | "deprecated"; + fingerprint: string; + sunsetAt: string | null; +} +export interface ApiCapabilities { + version: ApiVersion; + fingerprint: string; + capabilities: Record; +} + +export async function getApiContract(version?: ApiVersion) { + return fetchApi>(`/compatibility/contract${version ? `?version=${version}` : ""}`); +} + +export async function getApiCapabilities(version?: ApiVersion): Promise { + return fetchApi(`/compatibility/capabilities${version ? `?version=${version}` : ""}`); +} + +export async function getApiVersions(): Promise<{ current: ApiVersion; versions: ApiContractSummary[] }> { + return fetchApi<{ current: ApiVersion; versions: ApiContractSummary[] }>("/compatibility/versions"); +} + async function fetchApi( endpoint: string, init?: RequestInit, diff --git a/frontend/src/test/fixtures/api-compatibility.json b/frontend/src/test/fixtures/api-compatibility.json new file mode 100644 index 00000000..b4c0fdcc --- /dev/null +++ b/frontend/src/test/fixtures/api-compatibility.json @@ -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" + } + ] +} diff --git a/package.json b/package.json index 8726ad2b..1dfc91cd 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "test:e2e:headed": "playwright test --headed", "test:e2e:ui": "playwright test --ui", "test:e2e:report": "playwright show-report", + "check:api-contract": "node scripts/check-api-contract.mjs", + "generate:api-contract": "node scripts/check-api-contract.mjs --write", "lint": "npm run lint --workspaces --if-present", "setup": "bash scripts/setup.sh", "setup:quick": "bash scripts/setup.sh --skip-contracts --skip-ide -y", diff --git a/scripts/check-api-contract.mjs b/scripts/check-api-contract.mjs new file mode 100644 index 00000000..91dca70c --- /dev/null +++ b/scripts/check-api-contract.mjs @@ -0,0 +1,34 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; + +const manifestText = readFileSync("contracts/api-compatibility.json", "utf8"); +const manifest = JSON.parse(manifestText); +const source = readFileSync("backend/src/api/compatibility/contracts.ts", "utf8"); +const fixturePaths = [ + "frontend/src/test/fixtures/api-compatibility.json", + "sdk/src/fixtures/api-compatibility.json", +]; + +for (const version of manifest.versions) { + if (!source.includes(`version: "${version.version}"`)) { + throw new Error(`API contract ${version.version} is missing from the backend registry`); + } + if (!source.includes(`mediaType: "${version.mediaType}"`)) { + throw new Error(`Media type ${version.mediaType} is missing from the backend registry`); + } +} + +if (!source.includes(`version: "${manifest.current}"`)) { + throw new Error(`Current API contract ${manifest.current} is not registered`); +} + +for (const fixturePath of fixturePaths) { + if (process.argv.includes("--write")) { + mkdirSync(dirname(fixturePath), { recursive: true }); + writeFileSync(fixturePath, manifestText); + } else if (readFileSync(fixturePath, "utf8") !== manifestText) { + throw new Error(`${fixturePath} is stale; run npm run generate:api-contract`); + } +} + +console.log(`${process.argv.includes("--write") ? "Generated" : "Validated"} ${manifest.versions.length} API contract version(s)`); diff --git a/sdk/src/client.ts b/sdk/src/client.ts index 96233e0e..6e995da3 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -13,6 +13,8 @@ import type { QueryContractParams, SdkHealth, } from "./types"; +import type { ApiCapabilities, ApiContract, ApiContractSummary, ApiVersion } from "./compatibility"; +import { compatibilityHeaders } from "./compatibility"; export class BridgeWatchContractSdk { private readonly config: Required; @@ -25,6 +27,7 @@ export class BridgeWatchContractSdk { defaultFee: "100000", defaultTimeoutSeconds: 30, ...config, + apiUrl: config.apiUrl ?? config.rpcUrl, }; this.server = new StellarSdk.rpc.Server(this.config.rpcUrl, { @@ -32,6 +35,26 @@ export class BridgeWatchContractSdk { }); } + async getApiContract(version?: ApiVersion): Promise { + return this.fetchCompatibility(`/contract${version ? `?version=${version}` : ""}`, version); + } + + async getApiCapabilities(version?: ApiVersion): Promise { + return this.fetchCompatibility("/capabilities", version); + } + + async getApiVersions(): Promise<{ current: ApiVersion; versions: ApiContractSummary[] }> { + return this.fetchCompatibility<{ current: ApiVersion; versions: ApiContractSummary[] }>("/versions"); + } + + private async fetchCompatibility(path: string, version?: ApiVersion): Promise { + const response = await fetch(`${this.config.apiUrl.replace(/\/$/, "")}/api/v1/compatibility${path}`, { + headers: compatibilityHeaders(version), + }); + if (!response.ok) throw new BridgeWatchConnectionError(`Compatibility request failed: ${response.status}`); + return response.json() as Promise; + } + async connect(): Promise { try { const latestLedger = await this.getLatestLedger(); diff --git a/sdk/src/compatibility.ts b/sdk/src/compatibility.ts new file mode 100644 index 00000000..d8f601ee --- /dev/null +++ b/sdk/src/compatibility.ts @@ -0,0 +1,41 @@ +export type ApiVersion = "v1"; + +export interface ApiContractSummary { + version: ApiVersion; + mediaType: string; + status: "current" | "deprecated"; + fingerprint: string; + sunsetAt: string | null; +} + +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>; + fingerprint: string; +} + +export interface ApiCapabilities { + version: ApiVersion; + fingerprint: string; + capabilities: Record; +} + +export function compatibilityHeaders(version: ApiVersion = "v1"): Headers { + const headers = new Headers(); + headers.set("X-API-Version", version); + headers.set("Accept", `application/vnd.bridge-watch.${version}+json`); + return headers; +} diff --git a/sdk/src/fixtures/api-compatibility.json b/sdk/src/fixtures/api-compatibility.json new file mode 100644 index 00000000..b4c0fdcc --- /dev/null +++ b/sdk/src/fixtures/api-compatibility.json @@ -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" + } + ] +} diff --git a/sdk/src/index.ts b/sdk/src/index.ts index 36a96a81..c8491ba0 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -3,3 +3,4 @@ export * from "./errors"; export * from "./client"; export * from "./contract"; export * from "./testing"; +export * from "./compatibility"; diff --git a/sdk/src/types.ts b/sdk/src/types.ts index 73045416..9180c63d 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -2,6 +2,7 @@ import type * as StellarSdk from "@stellar/stellar-sdk"; export interface BridgeWatchSdkConfig { rpcUrl: string; + apiUrl?: string; contractId: string; networkPassphrase: string; allowHttp?: boolean;