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
9 changes: 9 additions & 0 deletions lib/services/express.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ const initSwagger = (app) => {
return;
}

// Inject runtime-resolved metadata from config so each downstream project
// advertises its own title, description, and public domain in the spec.
spec.info = {
...(spec.info || {}),
title: config.app.title,
description: config.app.description,
};
spec.servers = [{ url: config.domain || 'http://localhost:3000' }];

// Merge per-module markdown guides into info.description so Scalar
// renders them in its sidebar alongside the OpenAPI reference.
const guides = guidesHelper.loadGuides(config.files.guides || []);
Expand Down
2 changes: 0 additions & 2 deletions modules/core/doc/index.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
openapi: '3.0.0'
info:
version: '1.0.0'
servers:
- url: 'http://localhost:3000'
components:
securitySchemes:
bearerAuth:
Expand Down
18 changes: 11 additions & 7 deletions modules/core/tests/core.integration.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -300,13 +300,17 @@ describe('Core integration tests:', () => {
expect(res.body.openapi).toBe('3.0.0');
expect(res.body.info).toBeDefined();
expect(typeof res.body.info.version).toBe('string');
// The stack ships zero public API guides — downstream projects own the
// content rendered in their /api/docs via their own modules' doc/guides/*.md.
// `info.description` is therefore optional (string when any module provides
// a guide, undefined/empty otherwise).
if (res.body.info.description !== undefined) {
expect(typeof res.body.info.description).toBe('string');
}
// title and description are injected from config at runtime so every
// downstream project advertises its own identity instead of empty/hardcoded values.
expect(typeof res.body.info.title).toBe('string');
expect(res.body.info.title.length).toBeGreaterThan(0);
expect(typeof res.body.info.description).toBe('string');
expect(res.body.info.description.trim().length).toBeGreaterThan(0);
// servers[0].url is sourced from config.domain (fallback http://localhost:3000).
expect(Array.isArray(res.body.servers)).toBe(true);
expect(res.body.servers).toHaveLength(1);
expect(typeof res.body.servers[0].url).toBe('string');
expect(res.body.servers[0].url.length).toBeGreaterThan(0);
});

it('should serve the Scalar API reference page on /api/docs', async () => {
Expand Down
72 changes: 72 additions & 0 deletions modules/core/tests/core.unit.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,78 @@ describe('Core unit tests:', () => {
}
});

it('should inject info.title, info.description and servers from config', () => {
config.swagger = { enable: true };
config.files = { ...config.files, swagger: [path.join(process.cwd(), 'modules/core/doc/index.yml')], guides: [] };
const originalDomain = config.domain;
config.domain = 'https://api.example.test';
try {
const mockGet = jest.fn();
const mockUse = jest.fn();
const mockApp = { get: mockGet, use: mockUse };
expressService.initSwagger(mockApp);
const handler = mockGet.mock.calls.find((c) => c[0] === '/api/spec.json')[1];
const mockRes = { json: jest.fn() };
handler({}, mockRes);
const served = mockRes.json.mock.calls[0][0];
expect(served.info.title).toBe(config.app.title);
expect(served.info.description).toBe(config.app.description);
expect(served.servers).toEqual([{ url: 'https://api.example.test' }]);
} finally {
config.domain = originalDomain;
}
});

it('should fall back to localhost in servers when config.domain is empty', () => {
config.swagger = { enable: true };
config.files = { ...config.files, swagger: [path.join(process.cwd(), 'modules/core/doc/index.yml')], guides: [] };
const originalDomain = config.domain;
config.domain = '';
try {
const mockGet = jest.fn();
const mockUse = jest.fn();
const mockApp = { get: mockGet, use: mockUse };
expressService.initSwagger(mockApp);
const handler = mockGet.mock.calls.find((c) => c[0] === '/api/spec.json')[1];
const mockRes = { json: jest.fn() };
handler({}, mockRes);
const served = mockRes.json.mock.calls[0][0];
expect(served.servers).toEqual([{ url: 'http://localhost:3000' }]);
} finally {
config.domain = originalDomain;
}
});

it('should preserve config description then append guides on top', async () => {
const { default: fsMod } = await import('fs');
const tmpDir = path.join('/tmp', `guides-inject-${Date.now()}`);
fsMod.mkdirSync(tmpDir, { recursive: true });
const tmpGuide = path.join(tmpDir, 'welcome.md');
fsMod.writeFileSync(tmpGuide, 'Welcome guide content for description merge.\n');
try {
config.swagger = { enable: true };
config.files = {
...config.files,
swagger: [path.join(process.cwd(), 'modules/core/doc/index.yml')],
guides: [tmpGuide],
};
const mockGet = jest.fn();
const mockUse = jest.fn();
const mockApp = { get: mockGet, use: mockUse };
expressService.initSwagger(mockApp);
const handler = mockGet.mock.calls.find((c) => c[0] === '/api/spec.json')[1];
const mockRes = { json: jest.fn() };
handler({}, mockRes);
const served = mockRes.json.mock.calls[0][0];
// Config description must be first, guide content appended after
expect(served.info.description.startsWith(config.app.description)).toBe(true);
expect(served.info.description).toContain('Welcome guide content for description merge.');
} finally {
fsMod.unlinkSync(tmpGuide);
fsMod.rmdirSync(tmpDir);
}
});

it('should warn and skip registration when all YAML files produce an empty spec', async () => {
// Write a temp YAML file that parses to a scalar — after filter(Boolean), contents is empty → spec is {}
const { default: fsMod } = await import('fs');
Expand Down
Loading