diff --git a/lib/services/express.js b/lib/services/express.js index 7bcd37187..c0943c021 100644 --- a/lib/services/express.js +++ b/lib/services/express.js @@ -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 || []); diff --git a/modules/core/doc/index.yml b/modules/core/doc/index.yml index fcc8a2000..5219efff2 100644 --- a/modules/core/doc/index.yml +++ b/modules/core/doc/index.yml @@ -1,8 +1,6 @@ openapi: '3.0.0' info: version: '1.0.0' -servers: - - url: 'http://localhost:3000' components: securitySchemes: bearerAuth: diff --git a/modules/core/tests/core.integration.tests.js b/modules/core/tests/core.integration.tests.js index fead04043..1362898fd 100644 --- a/modules/core/tests/core.integration.tests.js +++ b/modules/core/tests/core.integration.tests.js @@ -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 () => { diff --git a/modules/core/tests/core.unit.tests.js b/modules/core/tests/core.unit.tests.js index aa40dcbfd..f2be6b022 100644 --- a/modules/core/tests/core.unit.tests.js +++ b/modules/core/tests/core.unit.tests.js @@ -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');