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
6 changes: 4 additions & 2 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,9 +236,11 @@ Simulate remittance history for testing purposes.

## API Documentation

Interactive API documentation is available via Swagger UI when the server is running:
Interactive API documentation is available via Swagger UI when the server is running outside production, or in production when `ENABLE_SWAGGER=true` is set:

**URL**: [http://localhost:3001/api-docs](http://localhost:3001/api-docs)
**URL**: [http://localhost:3001/docs](http://localhost:3001/docs)

The raw OpenAPI document is available at [http://localhost:3001/docs.json](http://localhost:3001/docs.json) under the same environment gate.

The Swagger documentation provides:

Expand Down
77 changes: 77 additions & 0 deletions backend/src/__tests__/swaggerDocs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { jest } from "@jest/globals";
import request from "supertest";

jest.unstable_mockModule("../db/connection.js", () => ({
default: {
query: jest
.fn<() => Promise<any>>()
.mockResolvedValue({ rows: [], rowCount: 0 }),
},
query: jest
.fn<() => Promise<any>>()
.mockResolvedValue({ rows: [], rowCount: 0 }),
getClient: jest.fn(),
withTransaction: jest.fn(),
}));

jest.unstable_mockModule("../services/cacheService.js", () => ({
cacheService: {
ping: jest.fn<() => Promise<string>>().mockResolvedValue("ok"),
},
}));

jest.unstable_mockModule("../services/sorobanService.js", () => ({
sorobanService: {
ping: jest.fn<() => Promise<string>>().mockResolvedValue("ok"),
getScoreConfig: jest.fn(() => ({
repaymentDelta: 20,
defaultPenalty: 50,
})),
},
}));

const { default: app } = await import("../app.js");

describe("Swagger docs", () => {
const originalNodeEnv = process.env.NODE_ENV;
const originalEnableSwagger = process.env.ENABLE_SWAGGER;

afterEach(() => {
process.env.NODE_ENV = originalNodeEnv;
if (originalEnableSwagger === undefined) {
delete process.env.ENABLE_SWAGGER;
} else {
process.env.ENABLE_SWAGGER = originalEnableSwagger;
}
});

it("serves Swagger UI and raw OpenAPI JSON when enabled", async () => {
process.env.NODE_ENV = "test";
delete process.env.ENABLE_SWAGGER;

const docsResponse = await request(app).get("/docs/");
expect(docsResponse.status).toBe(200);
expect(docsResponse.text).toContain("Swagger UI");

const jsonResponse = await request(app).get("/docs.json");
expect(jsonResponse.status).toBe(200);
expect(jsonResponse.body.openapi).toBe("3.0.0");
expect(jsonResponse.body.components.schemas.ErrorResponse).toBeDefined();
});

it("returns 404 for docs endpoints in production unless explicitly enabled", async () => {
process.env.NODE_ENV = "production";
delete process.env.ENABLE_SWAGGER;

await request(app).get("/docs/").expect(404);
await request(app).get("/docs.json").expect(404);
});

it("allows docs in production when ENABLE_SWAGGER=true", async () => {
process.env.NODE_ENV = "production";
process.env.ENABLE_SWAGGER = "true";

await request(app).get("/docs/").expect(200);
await request(app).get("/docs.json").expect(200);
});
});
3 changes: 3 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import compression from "compression";
import helmet from "helmet";
import dotenv from "dotenv";
import { Sentry } from "./config/sentry.js";
import { mountSwaggerDocs } from "./config/swagger.js";

dotenv.config();
import pool from "./db/connection.js";
Expand Down Expand Up @@ -178,6 +179,8 @@ app.use("/api/v1/admin", adminRoutes);
app.use("/api/v1/auth", authRoutes);
app.use("/api/v1/remittances", remittanceRoutes);

mountSwaggerDocs(app);

// ── Diagnostic / Test Routes ─────────────────────────────────────
// Only exposed in test environment to verify centralized error handling.
if (process.env.NODE_ENV === "test") {
Expand Down
83 changes: 81 additions & 2 deletions backend/src/config/swagger.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,81 @@
// Only export a dummy swaggerSpec. Import the real one from './swagger.esm.ts' in production code (e.g., app.ts)
export const swaggerSpec = {};
import path from "node:path";
import type { Express, NextFunction, Request, Response } from "express";
import { Router } from "express";
import swaggerJSDoc from "swagger-jsdoc";
import swaggerUi from "swagger-ui-express";
import { swaggerSchemas } from "./swaggerSchemas.js";

export function isSwaggerEnabled(): boolean {
return (
process.env.NODE_ENV !== "production" ||
process.env.ENABLE_SWAGGER?.toLowerCase() === "true"
);
}

const cwd = process.cwd();

export const swaggerSpec = swaggerJSDoc({
definition: {
openapi: "3.0.0",
info: {
title: "RemitLend API",
version: "1.0.0",
description: "Backend API for RemitLend lending, scoring, remittance, and indexer flows.",

Check failure on line 23 in backend/src/config/swagger.ts

View workflow job for this annotation

GitHub Actions / backend

Insert `⏎·······`
},
servers: [
{
url: "/api",
description: "Legacy API base path",
},
{
url: "/api/v1",
description: "Versioned API base path",
},
],
components: {
securitySchemes: {
ApiKeyAuth: {
type: "apiKey",
in: "header",
name: "x-api-key",
},
BearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
},
},
schemas: swaggerSchemas,
},
},
apis: [
path.join(cwd, "src/routes/**/*.{ts,js}"),
path.join(cwd, "src/controllers/**/*.{ts,js}"),
path.join(cwd, "dist/src/routes/**/*.js"),
path.join(cwd, "dist/src/controllers/**/*.js"),
],
});

export function mountSwaggerDocs(app: Express): void {
const docsRouter = Router();
docsRouter.use(...swaggerUi.serve);
docsRouter.get("/", swaggerUi.setup(swaggerSpec));

app.use("/docs", (req: Request, res: Response, next: NextFunction) => {
if (!isSwaggerEnabled()) {
next();
return;
}

docsRouter(req, res, next);
});

app.get("/docs.json", (req: Request, res: Response, next: NextFunction) => {
if (!isSwaggerEnabled()) {
next();
return;
}

res.json(swaggerSpec);
});
}
Loading