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
66 changes: 51 additions & 15 deletions api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import cors from 'cors';
import rateLimit, { MemoryStore } from 'express-rate-limit';
import { config } from './config';
import { bodySizeLimitMiddleware } from './middleware/bodySizeLimit';
// Versioned domain route imports (v1)
import v1Routes from './routes/v1';

// Legacy route imports for backward compatibility
import lendingRoutes from './routes/lending.routes';
import healthRoutes from './routes/health.routes';
import protocolRoutes from './routes/protocol.routes';
Expand All @@ -19,10 +23,15 @@ import configRoutes from './routes/config.routes';
import analyticsRoutes from './routes/analytics.routes';
import developerRoutes from './routes/developer.routes';
import mevRoutes from './routes/mev.routes';

import { errorHandler } from './middleware/errorHandler';
import { idempotencyMiddleware } from './middleware/idempotency';
import { resetSensitiveRateLimits, sensitiveOperationRateLimiter } from './middleware/rate-limit';
import { swaggerSpec } from './config/swagger';
import { swaggerSpec, versionListHandler, v1Spec } from './config/swagger';
import {
versionMiddleware,
legacyCompatibilityMiddleware,
} from './middleware/versioning';
import logger from './utils/logger';
import { requestIdMiddleware } from './middleware/requestId';
import { requestLogger } from './middleware/requestLogger';
Expand Down Expand Up @@ -122,31 +131,58 @@ app.use('/api/docs', (req: Request, res: Response, next: NextFunction) => {
.catch(next);
});

// ─── API Version listing ──────────────────────────────────────────────────
app.get('/api/versions', versionListHandler);

// ─── OpenAPI specs per version ────────────────────────────────────────────
app.get('/api/v1/openapi.json', (_req, res) => {
res.json(v1Spec);
});

app.get('/api/openapi.json', (_req, res) => {
// Legacy: return the v1 spec with deprecation notice
res.setHeader('X-API-Deprecated', 'true');
res.setHeader('X-API-Migrate-To', '/api/v1/openapi.json');
res.json(swaggerSpec);
});

app.use('/api/developer', developerRoutes);
app.use('/api/health', healthRoutes);
app.use('/api/protocol', protocolRoutes);
// ─── Versioned v1 domain routes ──────────────────────────────────────────
// All v1 routes are mounted under /api/v1 with version headers
app.use('/api/v1', versionMiddleware({ version: 'v1' }), v1Routes);

// ─── Legacy route compatibility (deprecated) ─────────────────────────────
// These routes are preserved for backward compatibility.
// Clients receive deprecation headers and should migrate to /api/v1/* paths.

const legacyLendingCompat = legacyCompatibilityMiddleware('/api/v1/lending');
const legacyProtocolCompat = legacyCompatibilityMiddleware('/api/v1/protocol');
const legacyGovernanceCompat = legacyCompatibilityMiddleware('/api/v1/governance');
const legacyAccountCompat = legacyCompatibilityMiddleware('/api/v1/account');
const legacySystemCompat = legacyCompatibilityMiddleware('/api/v1/system');
const legacySecurityCompat = legacyCompatibilityMiddleware('/api/v1/security');

app.use('/api/developer', legacySystemCompat, developerRoutes);
app.use('/api/health', legacySystemCompat, healthRoutes);
app.use('/api/protocol', legacyProtocolCompat, protocolRoutes);
app.use(
'/api/lending',
legacyLendingCompat,
idempotencyMiddleware,
userRateLimiter,
sensitiveOperationRateLimiter,
lendingRoutes
);
app.use('/api/subscriptions', subscriptionRoutes);
app.use('/api/portfolio', portfolioRoutes);
app.use('/api/gas', userRateLimiter, gasRoutes);
app.use('/api/staking', stakingRoutes);
app.use('/api/transactions', transactionRoutes);
app.use('/api/merkle', merkleRoutes);
app.use('/api/zk', zkProofRoutes);
app.use('/api/verification', verificationRoutes);
app.use('/api/config', configRoutes);
app.use('/api/analytics', analyticsRoutes);
app.use('/api/mev', mevRoutes);
app.use('/api/subscriptions', legacyAccountCompat, subscriptionRoutes);
app.use('/api/portfolio', legacyAccountCompat, portfolioRoutes);
app.use('/api/gas', legacyLendingCompat, userRateLimiter, gasRoutes);
app.use('/api/staking', legacyGovernanceCompat, stakingRoutes);
app.use('/api/transactions', legacyAccountCompat, transactionRoutes);
app.use('/api/merkle', legacySecurityCompat, merkleRoutes);
app.use('/api/zk', legacySecurityCompat, zkProofRoutes);
app.use('/api/verification', legacySecurityCompat, verificationRoutes);
app.use('/api/config', legacySystemCompat, configRoutes);
app.use('/api/analytics', legacySystemCompat, analyticsRoutes);
app.use('/api/mev', legacySecurityCompat, mevRoutes);

app.use(errorHandler);

Expand Down
232 changes: 94 additions & 138 deletions api/src/config/swagger.ts
Original file line number Diff line number Diff line change
@@ -1,144 +1,100 @@
import swaggerJsdoc from 'swagger-jsdoc';

const options: swaggerJsdoc.Options = {
definition: {
openapi: '3.0.3',
info: {
title: 'StellarLend API',
version: '1.0.0',
description: 'REST API for StellarLend core lending operations on Stellar/Soroban',
license: {
name: 'MIT',
},
},
servers: [
{
url: '/api',
description: 'API base path',
/**
* Per-version OpenAPI specification configuration.
* Each version can have its own info block, servers, and route paths.
*/

export interface VersionedOpenApiConfig {
version: string;
title: string;
description: string;
routeGlob: string;
/** Additional glob(s) for route files containing @openapi annotations */
legacyRouteGlob?: string;
deprecated?: boolean;
sunset?: string;
}

const versionConfigs: Record<string, VersionedOpenApiConfig> = {
v1: {
version: '1.0.0',
title: 'StellarLend API v1',
description: 'REST API v1 for StellarLend core lending operations on Stellar/Soroban',
routeGlob: './src/routes/v1/**/*.ts',
// Also scan original route files for @openapi JSDoc annotations
legacyRouteGlob: './src/routes/*.ts',
},
};

/**
* Build an OpenAPI spec for a specific API version.
*/
export function buildVersionedSpec(apiVersion: string): object {
const vConfig = versionConfigs[apiVersion];
if (!vConfig) {
throw new Error(`Unknown API version: ${apiVersion}. Available: ${Object.keys(versionConfigs).join(', ')}`);
}

const options: swaggerJsdoc.Options = {
definition: {
openapi: '3.0.3',
info: {
title: vConfig.title,
version: vConfig.version,
description: vConfig.description,
license: { name: 'MIT' },
},
],
components: {
schemas: {
PrepareResponse: {
type: 'object',
properties: {
unsignedXdr: { type: 'string', description: 'Unsigned transaction XDR' },
operation: {
type: 'string',
enum: ['deposit', 'borrow', 'repay', 'withdraw'],
},
expiresAt: {
type: 'string',
format: 'date-time',
description: 'XDR expiration timestamp',
},
},
required: ['unsignedXdr', 'operation', 'expiresAt'],
},
SubmitRequest: {
type: 'object',
properties: {
signedXdr: { type: 'string', description: 'Signed transaction XDR' },
},
required: ['signedXdr'],
},
TransactionResponse: {
type: 'object',
properties: {
success: { type: 'boolean' },
transactionHash: { type: 'string' },
status: {
type: 'string',
enum: ['pending', 'success', 'failed', 'cancelled'],
},
message: { type: 'string' },
error: { type: 'string' },
ledger: { type: 'integer' },
details: { description: 'Optional raw provider payload for debugging' },
},
required: ['success', 'status'],
},
HealthCheckResponse: {
type: 'object',
properties: {
status: { type: 'string', enum: ['healthy', 'unhealthy'] },
timestamp: { type: 'string', format: 'date-time' },
services: {
type: 'object',
properties: {
horizon: { type: 'boolean' },
sorobanRpc: { type: 'boolean' },
},
required: ['horizon', 'sorobanRpc'],
},
},
required: ['status', 'timestamp', 'services'],
},
ProtocolStatsResponse: {
type: 'object',
properties: {
totalDeposits: { type: 'string' },
totalBorrows: { type: 'string' },
utilizationRate: {
type: 'string',
description: 'Borrowed-to-deposited ratio expressed as a decimal string',
example: '0.50',
},
numberOfUsers: { type: 'integer' },
tvl: { type: 'string' },
},
required: ['totalDeposits', 'totalBorrows', 'utilizationRate', 'numberOfUsers', 'tvl'],
},
ErrorResponse: {
type: 'object',
properties: {
success: { type: 'boolean', example: false },
error: { type: 'string' },
},
required: ['success', 'error'],
servers: [
{
url: `/api/${apiVersion}`,
description: `${apiVersion.toUpperCase()} base path`,
},
PaginationMeta: {
type: 'object',
properties: {
cursor: { type: ['string', 'null'], nullable: true },
hasMore: { type: 'boolean' },
limit: { type: 'integer' },
},
required: ['cursor', 'hasMore', 'limit'],
},
PaginatedResponseTransactionHistory: {
type: 'object',
properties: {
data: {
type: 'array',
items: {
$ref: '#/components/schemas/TransactionHistoryItem',
},
},
pagination: {
$ref: '#/components/schemas/PaginationMeta',
},
},
required: ['data', 'pagination'],
},
TransactionHistoryItem: {
type: 'object',
properties: {
transactionHash: { type: 'string' },
type: { type: 'string', enum: ['deposit', 'borrow', 'repay', 'withdraw'] },
amount: { type: 'string' },
assetAddress: { type: 'string' },
timestamp: { type: 'string', format: 'date-time' },
status: { type: 'string', enum: ['success', 'failed', 'pending'] },
ledger: { type: 'integer' },
memo: { type: 'string' },
},
required: ['transactionHash', 'type', 'amount', 'timestamp', 'status'],
},
},
],
},
},
apis: ['./src/routes/*.ts'],
};
apis: [vConfig.routeGlob, vConfig.legacyRouteGlob].filter((g): g is string => Boolean(g)),
};

return swaggerJsdoc(options);
}

/**
* Returns the current (latest stable) API version.
*/
export function getCurrentVersion(): string {
return 'v1';
}

/**
* Build the current/latest OpenAPI spec (legacy compatibility).
*/
export function buildCurrentSpec(): object {
return buildVersionedSpec(getCurrentVersion());
}

// Legacy swagger spec for backward compatibility
export const swaggerSpec = buildCurrentSpec();

// Version-specific specs
export const v1Spec = buildVersionedSpec('v1');

/**
* List all available API versions with deprecation status.
*/
export function listVersions(): Array<{ version: string; deprecated: boolean; sunset?: string }> {
return Object.entries(versionConfigs).map(([version, config]) => ({
version,
deprecated: config.deprecated ?? false,
sunset: config.sunset,
}));
}

export const swaggerSpec = swaggerJsdoc(options);
/**
* Request handler to serve version list.
*/
export function versionListHandler(_req: any, res: any): void {
res.json({
versions: listVersions(),
current: getCurrentVersion(),
});
}
Loading
Loading