Skip to content

Commit 3e0081b

Browse files
feat(docs): replace swagger-ui-express with Scalar + /api/spec.json (#3388)
* feat(docs): replace swagger-ui-express with Scalar + /api/spec.json (#3385) - Remove swagger-ui-express, install @scalar/express-api-reference - Rewrite initSwagger(): serve merged OpenAPI spec at GET /api/spec.json, mount Scalar UI at /api/docs - Remove file write to ./public/swagger.yml and unused swagger config options - Add 3 unit tests for initSwagger (enabled, spec JSON, disabled) * docs(migrations): add Scalar replaces swagger-ui-express entry
1 parent af0cc3f commit 3e0081b

7 files changed

Lines changed: 225 additions & 254 deletions

File tree

MIGRATIONS.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,28 @@ Breaking changes and upgrade notes for downstream projects.
44

55
---
66

7+
## Scalar replaces swagger-ui-express (2026-04-04)
8+
9+
`swagger-ui-express` has been removed. The API documentation UI is now powered by [Scalar](https://scalar.com/) via `@scalar/express-api-reference`.
10+
11+
### What changed
12+
13+
- `initSwagger()` in `lib/services/express.js` no longer writes `./public/swagger.yml` to disk
14+
- New endpoint `GET /api/spec.json` serves the merged OpenAPI spec as JSON
15+
- `/api/docs` now serves the Scalar UI instead of Swagger UI
16+
- Removed unused swagger config options: `swaggerUrl`, `explore`
17+
- Removed dependency: `swagger-ui-express`
18+
- Added dependency: `@scalar/express-api-reference`
19+
20+
### Action for downstream
21+
22+
1. Run `/update-stack` to pull the change
23+
2. Remove any references to `./public/swagger.yml` — it is no longer generated
24+
3. If you customized swagger options (e.g. `swaggerUrl`, `explore`), remove them — they are no longer used
25+
4. The `/api/docs` and `/api/spec.json` routes are available as before
26+
27+
---
28+
729
## Module Activation Config (2026-04-05)
830

931
Per-module `activated: true/false` config flag. When `activated: false`, the module's routes, policies, models, and swagger YAML are excluded from the app entirely.

config/defaults/development.config.js

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,6 @@ const config = {
88
},
99
swagger: {
1010
enable: true,
11-
options: {
12-
swaggerUrl: '/api/docs/swagger.yml',
13-
explore: true,
14-
},
1511
},
1612
api: {
1713
protocol: 'http',

lib/services/express.js

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import cors from 'cors';
1515
import morgan from 'morgan';
1616
import fs from 'fs';
1717
import YAML from 'js-yaml';
18-
import swaggerUi from 'swagger-ui-express';
18+
import { apiReference } from '@scalar/express-api-reference';
1919

2020
import config from '../../config/index.js';
2121
import logger from './logger.js';
@@ -25,25 +25,26 @@ import AnalyticsService from './analytics.js';
2525
import analyticsMiddleware from '../middlewares/analytics.js';
2626

2727
/**
28-
* Initialize Swagger
28+
* Initialize API documentation (Scalar UI + JSON spec endpoint)
2929
*/
3030
const initSwagger = (app) => {
3131
if (config.swagger.enable) {
32-
// Merge files.
33-
try {
34-
const contents = config.files.swagger.map((filePath) => YAML.load(fs.readFileSync(filePath).toString()));
35-
const merged = contents.reduce(_.merge);
36-
fs.writeFile('./public/swagger.yml', YAML.dump(merged), (error) => {
37-
if (error) {
38-
throw error;
39-
}
40-
});
41-
} catch (e) {
42-
throw new Error(e);
43-
}
32+
// Merge all module OpenAPI YAML files into a single spec
33+
const contents = config.files.swagger.map((filePath) => YAML.load(fs.readFileSync(filePath).toString()));
34+
const spec = contents.reduce(_.merge);
35+
36+
// Serve the merged spec as JSON
37+
app.get('/api/spec.json', (req, res) => {
38+
res.json(spec);
39+
});
4440

45-
app.use(config.swagger.options.swaggerUrl, express.static('./public/swagger.yml'));
46-
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(null, config.swagger.options));
41+
// Mount Scalar API reference UI
42+
app.use(
43+
'/api/docs',
44+
apiReference({
45+
spec: { content: spec },
46+
}),
47+
);
4748
}
4849
};
4950

modules/audit/tests/audit.middleware.unit.tests.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ describe('Audit middleware unit tests:', () => {
8383
const middleware = createAuditMiddleware();
8484
const next = jest.fn();
8585

86-
const prefixes = ['/public/file.js', '/favicon.ico', '/api/docs/swagger', '/api/health'];
86+
const prefixes = ['/public/file.js', '/favicon.ico', '/api/docs', '/api/health'];
8787
for (const url of prefixes) {
8888
const req = createReq({ method: 'POST', originalUrl: url });
8989
const res = createRes();

modules/core/tests/core.unit.tests.js

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -466,6 +466,58 @@ describe('Core unit tests:', () => {
466466
});
467467

468468
describe('Express service', () => {
469+
describe('initSwagger', () => {
470+
let originalSwagger;
471+
let originalFiles;
472+
473+
beforeEach(() => {
474+
originalSwagger = config.swagger;
475+
originalFiles = config.files;
476+
});
477+
478+
afterEach(() => {
479+
config.swagger = originalSwagger;
480+
config.files = originalFiles;
481+
});
482+
483+
it('should register /api/spec.json and /api/docs when swagger is enabled', () => {
484+
config.swagger = { enable: true };
485+
config.files = { ...config.files, swagger: [path.join(process.cwd(), 'modules/core/doc/index.yml')] };
486+
const mockGet = jest.fn();
487+
const mockUse = jest.fn();
488+
const mockApp = { get: mockGet, use: mockUse };
489+
expressService.initSwagger(mockApp);
490+
expect(mockGet).toHaveBeenCalledWith('/api/spec.json', expect.any(Function));
491+
expect(mockUse).toHaveBeenCalledWith('/api/docs', expect.any(Function));
492+
});
493+
494+
it('should serve merged spec as JSON from /api/spec.json handler', () => {
495+
config.swagger = { enable: true };
496+
config.files = { ...config.files, swagger: [path.join(process.cwd(), 'modules/core/doc/index.yml')] };
497+
const mockGet = jest.fn();
498+
const mockUse = jest.fn();
499+
const mockApp = { get: mockGet, use: mockUse };
500+
expressService.initSwagger(mockApp);
501+
// Extract the handler registered for /api/spec.json
502+
const handler = mockGet.mock.calls.find((c) => c[0] === '/api/spec.json')[1];
503+
const mockRes = { json: jest.fn() };
504+
handler({}, mockRes);
505+
expect(mockRes.json).toHaveBeenCalledWith(
506+
expect.objectContaining({ openapi: '3.0.0' }),
507+
);
508+
});
509+
510+
it('should not register routes when swagger is disabled', () => {
511+
config.swagger = { enable: false };
512+
const mockGet = jest.fn();
513+
const mockUse = jest.fn();
514+
const mockApp = { get: mockGet, use: mockUse };
515+
expressService.initSwagger(mockApp);
516+
expect(mockGet).not.toHaveBeenCalled();
517+
expect(mockUse).not.toHaveBeenCalled();
518+
});
519+
});
520+
469521
describe('trust proxy', () => {
470522
let originalTrust;
471523

0 commit comments

Comments
 (0)