From c5c7848a8381af0783b6a90154a772b2e2803d21 Mon Sep 17 00:00:00 2001 From: Obiajulu-gif Date: Wed, 27 May 2026 12:01:47 +0100 Subject: [PATCH] Mount Swagger docs endpoints --- backend/README.md | 6 +- backend/src/__tests__/swaggerDocs.test.ts | 77 +++++++++++++++++++++ backend/src/app.ts | 3 + backend/src/config/swagger.ts | 83 ++++++++++++++++++++++- 4 files changed, 165 insertions(+), 4 deletions(-) create mode 100644 backend/src/__tests__/swaggerDocs.test.ts diff --git a/backend/README.md b/backend/README.md index ac6867b6..c3f4e619 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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: diff --git a/backend/src/__tests__/swaggerDocs.test.ts b/backend/src/__tests__/swaggerDocs.test.ts new file mode 100644 index 00000000..68b6a9d0 --- /dev/null +++ b/backend/src/__tests__/swaggerDocs.test.ts @@ -0,0 +1,77 @@ +import { jest } from "@jest/globals"; +import request from "supertest"; + +jest.unstable_mockModule("../db/connection.js", () => ({ + default: { + query: jest + .fn<() => Promise>() + .mockResolvedValue({ rows: [], rowCount: 0 }), + }, + query: jest + .fn<() => Promise>() + .mockResolvedValue({ rows: [], rowCount: 0 }), + getClient: jest.fn(), + withTransaction: jest.fn(), +})); + +jest.unstable_mockModule("../services/cacheService.js", () => ({ + cacheService: { + ping: jest.fn<() => Promise>().mockResolvedValue("ok"), + }, +})); + +jest.unstable_mockModule("../services/sorobanService.js", () => ({ + sorobanService: { + ping: jest.fn<() => Promise>().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); + }); +}); diff --git a/backend/src/app.ts b/backend/src/app.ts index 5b33a565..bfe7d2c8 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -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"; @@ -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") { diff --git a/backend/src/config/swagger.ts b/backend/src/config/swagger.ts index 3a2fa28b..3ced0328 100644 --- a/backend/src/config/swagger.ts +++ b/backend/src/config/swagger.ts @@ -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.", + }, + 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); + }); +}