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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,28 @@ GET /api/v1/stats — protocol stats
GET /health — service health
WS /ws — real-time intent feed
GET /docs — Swagger / OpenAPI docs
GET /docs/v1 — OpenAPI UI for API v1
GET /docs/v2 — OpenAPI UI for API v2
GET /api/v1/chain/health — Soroban RPC health (read-only)
GET /api/v1/chain/ledger — latest Soroban ledger
GET /api/v1/chain/network — Soroban network info
GET /api/v1/chain/account/:key — Stellar account lookup
```

### API version lifecycle

HTTP API versions use Nest URI versioning and retain the `/api/vN/...` URL
shape. `/docs/v1-json` and `/docs/v2-json` serve version-specific OpenAPI
documents; the legacy `/docs-json` remains available as the combined document.
New controller versions are selected with Nest's controller `version`
metadata.

Deprecation headers are enabled per version by setting `API_V1_DEPRECATED_AT`
to an ISO-8601 date. `API_V1_SUNSET_AT` adds the corresponding `Sunset` header,
and `API_V1_DEPRECATION_LINK` adds a `Link` header with `rel="deprecation"`.
These headers are omitted until configured. HTTP request counters and latency
histograms include a `version` label (`v1`, `v2`, or `unversioned`).

---

## Local Development
Expand Down
10 changes: 9 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,15 @@
"viem": "^2.44.4",
"winston": "^3.13.0",
"ws": "^8.18.0",
"joi": "^18.2.9",
"zod": "^4.6.5",
"@msgpack/msgpack": "^3.0.0",
"joi": "^18.2.9"
},
"devDependencies": {
"@commitlint/cli": "^21.2.3",
"@commitlint/config-conventional": "^21.2.3",
"@nestjs/cli": "^11.0.24",
"@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^12.1.0",
"@stryker-mutator/core": "^8.0.0",
"@stryker-mutator/jest-runner": "^8.0.0",
Expand Down
2 changes: 1 addition & 1 deletion src/abuse/abuse.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { AbuseScoreService } from "./abuse-score.service";
import { AbuseAuditEvent } from "./abuse.types";

@ApiTags("abuse")
@Controller("api/v1/abuse")
@Controller({ path: "abuse", version: "1" })
export class AbuseController {
constructor(private readonly scorer: AbuseScoreService) {}

Expand Down
2 changes: 1 addition & 1 deletion src/analytics/analytics.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ANALYTICS_METRICS, AnalyticsMetric, AnalyticsQuery } from "./analytics.
import { AnalyticsQueryDto } from "./dto/analytics-query.dto";

@ApiTags("analytics")
@Controller("api/v1/analytics")
@Controller({ path: "analytics", version: "1" })
export class AnalyticsController {
constructor(private readonly analyticsService: AnalyticsService) {}

Expand Down
2 changes: 1 addition & 1 deletion src/auth/api-keys/api-key.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { RotateApiKeyDto } from "./dto/rotate-api-key.dto";
* creation/rotation and is never persisted or logged.
*/
@ApiTags("admin")
@Controller("api/v1/admin/api-keys")
@Controller({ path: "admin/api-keys", version: "1" })
@UseGuards(AdminGuard)
@RequireAdminRole("admin")
export class ApiKeyController {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { SolverJwtGuard } from "./solver-jwt.guard";
* Credentials can only be minted for the authenticated solver.
*/
@ApiTags("solvers")
@Controller("api/v1/solvers/:address/credentials")
@Controller({ path: "solvers/:address/credentials", version: "1" })
@UseGuards(SolverJwtGuard)
export class SolverCredentialController {
constructor(private readonly credentials: SolverCredentialService) {}
Expand Down
118 changes: 118 additions & 0 deletions src/common/api-versioning.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { Controller, Get, INestApplication, Module } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { Request, Response } from "express";
import request from "supertest";
import {
createApiDeprecationHeadersMiddleware,
enableApiVersioning,
getApiVersionFromUrl,
openApiDocumentForVersion,
} from "./api-versioning";

@Controller({ path: "resource", version: "1" })
class VersionedTestController {
@Get()
get() {
return { ok: true };
}
}

@Module({ controllers: [VersionedTestController] })
class VersionedTestModule {}

describe("API versioning helpers", () => {
it("extracts a URI version without treating unversioned paths as v1", () => {
expect(getApiVersionFromUrl("/api/v1/intents?limit=10")).toBe("1");
expect(getApiVersionFromUrl("/api/v2/intents")).toBe("2");
expect(getApiVersionFromUrl("/metrics")).toBe("unversioned");
});

it("filters OpenAPI paths to the requested URI version", () => {
const document = {
openapi: "3.0.0",
info: { title: "Vortex", version: "0.1.0" },
paths: {
"/api/v1/intents": {},
"/api/v2/intents": {},
"/health": {},
},
} as never;

const v1Document = openApiDocumentForVersion(document, "1");
const v2Document = openApiDocumentForVersion(document, "2");

expect(Object.keys(v1Document.paths)).toEqual(["/api/v1/intents"]);
expect(v1Document.info.version).toBe("1");
expect(Object.keys(v2Document.paths)).toEqual(["/api/v2/intents"]);
});

it("adds configured Deprecation, Sunset, and deprecation Link headers", () => {
const setHeader = jest.fn();
const next = jest.fn();
const middleware = createApiDeprecationHeadersMiddleware({
"1": {
deprecatedAt: "2026-01-01T00:00:00Z",
sunsetAt: "2027-01-01T00:00:00Z",
deprecationLink: "https://example.test/deprecations/v1",
},
});

middleware(
{ path: "/api/v1/intents" } as Request,
{ setHeader } as unknown as Response,
next,
);

expect(setHeader).toHaveBeenNthCalledWith(1, "Deprecation", "@1767225600");
expect(setHeader).toHaveBeenNthCalledWith(2, "Sunset", "Fri, 01 Jan 2027 00:00:00 GMT");
expect(setHeader).toHaveBeenNthCalledWith(
3,
"Link",
'<https://example.test/deprecations/v1>; rel="deprecation"',
);
expect(next).toHaveBeenCalledTimes(1);
});

it("leaves unversioned routes and invalid lifecycle dates untouched", () => {
const setHeader = jest.fn();
const next = jest.fn();
const middleware = createApiDeprecationHeadersMiddleware({
"1": { deprecatedAt: "not-a-date", sunsetAt: "also-not-a-date" },
});

middleware(
{ path: "/metrics" } as Request,
{ setHeader } as unknown as Response,
next,
);

expect(setHeader).not.toHaveBeenCalled();
expect(next).toHaveBeenCalledTimes(1);
});

it("routes controller metadata under api/vN and generates matching OpenAPI paths", async () => {
const app: INestApplication = await NestFactory.create(VersionedTestModule, { logger: false });
enableApiVersioning(app);
app.use(
createApiDeprecationHeadersMiddleware({
"1": { deprecatedAt: "2026-01-01T00:00:00Z" },
}),
);
await app.init();

try {
const response = await request(app.getHttpServer()).get("/api/v1/resource").expect(200);
expect(response.body).toEqual({ ok: true });
expect(response.headers.deprecation).toBe("@1767225600");
await request(app.getHttpServer()).get("/api/v2/resource").expect(404);

const document = SwaggerModule.createDocument(app, new DocumentBuilder().build());
const v1Document = openApiDocumentForVersion(document, "1");
expect(v1Document.paths["/api/v1/resource"]).toBeDefined();
expect(v1Document.paths["/api/v2/resource"]).toBeUndefined();
} finally {
await app.close();
}
});
});
76 changes: 76 additions & 0 deletions src/common/api-versioning.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { INestApplication, VersioningType } from "@nestjs/common";
import { OpenAPIObject } from "@nestjs/swagger";
import { Request, RequestHandler, Response } from "express";

export const API_VERSION_PREFIX = "api/v";
export const API_VERSIONS = ["1", "2"] as const;

export interface ApiVersionLifecycle {
deprecatedAt?: string;
sunsetAt?: string;
deprecationLink?: string;
}

export function enableApiVersioning(app: INestApplication): void {
app.enableVersioning({
type: VersioningType.URI,
prefix: API_VERSION_PREFIX,
});
}

export function getApiVersionFromUrl(url: string): string {
return url.match(/^\/api\/v(\d+)(?:\/|\?|$)/)?.[1] ?? "unversioned";
}

export function openApiDocumentForVersion(document: OpenAPIObject, version: string): OpenAPIObject {
const versionPath = `/api/v${version}`;
const paths = Object.fromEntries(
Object.entries(document.paths).filter(
([path]) => path === versionPath || path.startsWith(`${versionPath}/`),
),
);

return {
...document,
info: { ...document.info, version },
paths,
};
}

function readVersionLifecycleFromEnvironment(): Record<string, ApiVersionLifecycle> {
return {
"1": {
deprecatedAt: process.env.API_V1_DEPRECATED_AT,
sunsetAt: process.env.API_V1_SUNSET_AT,
deprecationLink: process.env.API_V1_DEPRECATION_LINK,
},
};
}

function parseDate(value?: string): Date | undefined {
if (!value) return undefined;
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? undefined : new Date(timestamp);
}

export function createApiDeprecationHeadersMiddleware(
lifecycle: Record<string, ApiVersionLifecycle> = readVersionLifecycleFromEnvironment(),
): RequestHandler {
return (request: Request, response: Response, next) => {
const version = getApiVersionFromUrl(request.path);
const policy = lifecycle[version];
if (!policy) return next();

const deprecatedAt = parseDate(policy.deprecatedAt);
const sunsetAt = parseDate(policy.sunsetAt);
if (deprecatedAt) {
response.setHeader("Deprecation", `@${Math.floor(deprecatedAt.getTime() / 1000)}`);
}
if (sunsetAt) response.setHeader("Sunset", sunsetAt.toUTCString());
if (policy.deprecationLink) {
response.setHeader("Link", `<${policy.deprecationLink}>; rel="deprecation"`);
}

next();
};
}
2 changes: 1 addition & 1 deletion src/datasets/datasets.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { DatasetsService } from "./datasets.service";

@ApiTags("datasets")
@Controller("api/v1/datasets")
@Controller({ path: "datasets", version: "1" })
export class DatasetsController {
constructor(private readonly datasetsService: DatasetsService) {}

Expand Down
2 changes: 1 addition & 1 deletion src/disputes/disputes.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { SubmitDisputeDto } from "./dto/submit-dispute.dto";
import { DecideDisputeDto } from "./dto/decide-dispute.dto";

@ApiTags("disputes")
@Controller("api/v1/solvers/disputes")
@Controller({ path: "solvers/disputes", version: "1" })
export class DisputesController {
constructor(
private readonly disputesService: DisputesService,
Expand Down
2 changes: 1 addition & 1 deletion src/governance/guardian.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export class GuardianOverrideDto {
}

@ApiTags("governance")
@Controller("api/v1/governance/guardian")
@Controller({ path: "governance/guardian", version: "1" })
export class GuardianController {
constructor(private readonly guardian: GuardianService) {}

Expand Down
2 changes: 1 addition & 1 deletion src/governance/params.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { ProtocolParamsService, ParamsApiResponse } from "./params.service";
* @see ProtocolParamsService
*/
@ApiTags("governance")
@Controller("api/v1/params")
@Controller({ path: "params", version: "1" })
export class ParamsController {
constructor(private readonly paramsService: ProtocolParamsService) {}

Expand Down
2 changes: 1 addition & 1 deletion src/intents/intents-sse.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { logger } from "../common/logger";
*
* RFQ remains WebSocket-only — this endpoint is a read-only intent feed.
*/
@Controller("api/v1/stream")
@Controller({ path: "stream", version: "1" })
export class IntentsSseController {
constructor(
private readonly feed: IntentFeedService,
Expand Down
2 changes: 1 addition & 1 deletion src/intents/intents.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ import { AppConfig } from "../config/configuration";
import { isCanaryIntent } from "../common/canary";

@ApiTags("intents")
@Controller("api/v1/intents")
@Controller({ path: "intents", version: "1" })
export class IntentsController {
constructor(
private readonly intentsService: IntentsService,
Expand Down
4 changes: 2 additions & 2 deletions src/intents/slashes.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { SlashingPipelineService } from "./slashing-pipeline.service";
* solver's fill-proof challenge.
*/
@ApiTags("slashes")
@Controller("api/v1/slashes")
@Controller({ path: "slashes", version: "1" })
export class SlashesController {
constructor(private readonly pipeline: SlashingPipelineService) {}

Expand Down Expand Up @@ -50,7 +50,7 @@ export class SlashesController {
*/
@ApiTags("admin")
@ApiHeader({ name: "x-admin-key", required: true })
@Controller("api/v1/admin/slashes")
@Controller({ path: "admin/slashes", version: "1" })
@UseGuards(AdminGuard)
@RequireAdminRole("admin")
export class AdminSlashesController {
Expand Down
2 changes: 1 addition & 1 deletion src/killswitch/killswitch.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { OperatorGuard, OperatorRequest } from "./operator.guard";
*/
@ApiTags("ops/killswitch")
@UseGuards(OperatorGuard)
@Controller("api/v1/ops/killswitch")
@Controller({ path: "ops/killswitch", version: "1" })
export class KillSwitchController {
constructor(private readonly killSwitch: KillSwitchService) {}

Expand Down
Loading
Loading