From 6147fdabed2e89f99c6cc36a7244ee2e4ceb315a Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Mon, 13 Apr 2026 14:05:03 +0200 Subject: [PATCH 1/2] docs(api): wire app.title/description and domain into OpenAPI spec The spec served at /api/spec.json was advertising empty info.title and localhost as the server URL for every downstream project, so Scalar rendered the reference with no title and its Try-It samples all pointed to http://localhost:3000 regardless of environment. The correct values already lived in config (app.title, app.description, domain) and were even wired into app.locals. Inject them into the spec in initSwagger right before the guides helper appends the markdown sidebar, so info.description remains the concatenation of config + guides. Drop the redundant servers: block from modules/core/doc/index.yml so the code remains the single source of truth. Closes #3461 --- lib/services/express.js | 9 +++ modules/core/doc/index.yml | 2 - modules/core/tests/core.integration.tests.js | 17 +++-- modules/core/tests/core.unit.tests.js | 72 ++++++++++++++++++++ 4 files changed, 91 insertions(+), 9 deletions(-) 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..bf8392fa7 100644 --- a/modules/core/tests/core.integration.tests.js +++ b/modules/core/tests/core.integration.tests.js @@ -300,13 +300,16 @@ 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'); + // 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..85f491233 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')] }; + 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')] }; + 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'); From 241143e6b9a7a5958f3ebcc46f7c4099b0db6c0e Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Mon, 13 Apr 2026 14:10:29 +0200 Subject: [PATCH 2/2] test(core): harden swagger spec assertions per CodeRabbit - integration: assert info.description is a non-empty string, not just typed - unit: explicitly set guides: [] in the two new injection tests so the description equality assertion stays deterministic regardless of any default config.files.guides --- modules/core/tests/core.integration.tests.js | 1 + modules/core/tests/core.unit.tests.js | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/modules/core/tests/core.integration.tests.js b/modules/core/tests/core.integration.tests.js index bf8392fa7..1362898fd 100644 --- a/modules/core/tests/core.integration.tests.js +++ b/modules/core/tests/core.integration.tests.js @@ -305,6 +305,7 @@ describe('Core integration tests:', () => { 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); diff --git a/modules/core/tests/core.unit.tests.js b/modules/core/tests/core.unit.tests.js index 85f491233..f2be6b022 100644 --- a/modules/core/tests/core.unit.tests.js +++ b/modules/core/tests/core.unit.tests.js @@ -644,7 +644,7 @@ 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')] }; + 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 { @@ -666,7 +666,7 @@ describe('Core unit tests:', () => { 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')] }; + config.files = { ...config.files, swagger: [path.join(process.cwd(), 'modules/core/doc/index.yml')], guides: [] }; const originalDomain = config.domain; config.domain = ''; try {