From 9c65788f0fa2a4c88cee03b13d7345792689ed34 Mon Sep 17 00:00:00 2001 From: Pierre Brisorgueil Date: Mon, 1 Jun 2026 21:22:08 +0200 Subject: [PATCH] feat(express): derive api. for OpenAPI servers.url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When config.domain is a bare host (e.g. trawl.me), the OpenAPI servers.url previously pointed at the frontend host verbatim. Extracts computeOpenApiServerUrl() — a pure named export that prepends https://api. when the domain has no scheme, and returns the value verbatim when it already starts with http(s)://. Backward-compatible for all existing downstreams that set a full URL domain. Promote-up from trawl_node (production-validated). Every devkit downstream with a split frontend/backend topology (bare domain → api.) now gets the correct OpenAPI host for free. Tests: 5 unit cases covering bare host, https URL, http URL, custom-subdomain URL, and falsy fallback. --- lib/services/express.js | 14 ++- .../express.openapi-servers.unit.tests.js | 85 +++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 lib/services/tests/express.openapi-servers.unit.tests.js diff --git a/lib/services/express.js b/lib/services/express.js index fbdf36542..eda3edab6 100644 --- a/lib/services/express.js +++ b/lib/services/express.js @@ -26,6 +26,18 @@ import errorTracker from './errorTracker.js'; import AnalyticsService from './analytics.js'; import analyticsMiddleware from '../middlewares/analytics.js'; +/** + * Compute the OpenAPI `servers.url` from `config.domain`. + * - If `domain` already has http(s):// → return verbatim (backward-compatible). + * - Otherwise prepend `https://api.` (api subdomain convention for split frontend/backend topologies). + * @param {string} domain - config.domain value + * @returns {string} OpenAPI servers.url + */ +export const computeOpenApiServerUrl = (domain) => { + const d = domain || 'http://localhost:3000'; + return /^https?:\/\//.test(d) ? d : `https://api.${d}`; +}; + /** * Default Redoc theme — Inter + JetBrains Mono, tighter sidebar, refined right panel. * No hardcoded brand color. Downstream projects override via config.docs.redocTheme @@ -116,7 +128,7 @@ const initSwagger = (app) => { description: config.app.description, ...(xLogo ? { 'x-logo': xLogo } : {}), }; - spec.servers = [{ url: config.domain || 'http://localhost:3000' }]; + spec.servers = [{ url: computeOpenApiServerUrl(config.domain) }]; // Merge per-module markdown guides into info.description so Redoc // renders them in its sidebar alongside the OpenAPI reference. diff --git a/lib/services/tests/express.openapi-servers.unit.tests.js b/lib/services/tests/express.openapi-servers.unit.tests.js new file mode 100644 index 000000000..0df52435e --- /dev/null +++ b/lib/services/tests/express.openapi-servers.unit.tests.js @@ -0,0 +1,85 @@ +import { jest, describe, test, expect, beforeEach } from '@jest/globals'; + +/** + * Unit tests for computeOpenApiServerUrl — pure function, no Express/config side-effects. + * express.js has transitive deps (logger, config) so we mock all module-level deps + * before importing to isolate the pure computation. + */ +describe('lib/services/express — OpenAPI servers.url derivation:', () => { + beforeEach(() => jest.resetModules()); + + const getComputeOpenApiServerUrl = async () => { + jest.unstable_mockModule('../../../config/index.js', () => ({ + default: { + domain: 'http://localhost:3000', + swagger: { enable: false }, + app: { title: 'test', description: 'd' }, + files: { swagger: [], guides: [], routes: [], configs: [], policies: [], preRoutes: [] }, + bodyParser: {}, + cors: { origin: [], credentials: false }, + csrf: {}, + posthog: {}, + analytics: {}, + trust: {}, + log: { fileLogger: { directoryPath: '/tmp', fileName: 'test.log' } }, + }, + })); + jest.unstable_mockModule('../../../lib/services/logger.js', () => ({ + default: { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + getLogFormat: jest.fn().mockReturnValue('dev'), + getMorganOptions: jest.fn().mockReturnValue({}), + }, + })); + jest.unstable_mockModule('../../../lib/services/errorTracker.js', () => ({ + default: { setupExpressErrorHandler: jest.fn(), init: jest.fn() }, + })); + jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({ + default: { init: jest.fn().mockResolvedValue(undefined), isConfigured: jest.fn().mockReturnValue(false) }, + })); + jest.unstable_mockModule('../../../lib/helpers/guides.js', () => ({ + default: { loadGuides: jest.fn().mockReturnValue([]), mergeGuidesIntoSpec: jest.fn() }, + })); + jest.unstable_mockModule('../../../lib/middlewares/requestId.js', () => ({ default: jest.fn() })); + jest.unstable_mockModule('../../../lib/middlewares/analytics.js', () => ({ default: jest.fn() })); + jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({ + default: { discoverPolicies: jest.fn().mockResolvedValue(undefined) }, + })); + jest.unstable_mockModule('../../../lib/middlewares/posthog-context.middleware.js', () => ({ + posthogContextMiddleware: jest.fn(), + })); + + const mod = await import('../../../lib/services/express.js'); + return mod.computeOpenApiServerUrl; + }; + + test('keeps full URL when domain already has http:// scheme', async () => { + const fn = await getComputeOpenApiServerUrl(); + expect(fn('http://localhost:3000')).toBe('http://localhost:3000'); + }); + + test('keeps full URL when domain already has https:// scheme with custom subdomain', async () => { + const fn = await getComputeOpenApiServerUrl(); + expect(fn('https://app.example.com')).toBe('https://app.example.com'); + }); + + test('prepends https://api. when domain is a bare host (no scheme)', async () => { + const fn = await getComputeOpenApiServerUrl(); + expect(fn('trawl.me')).toBe('https://api.trawl.me'); + }); + + test('prepends https://api. for any bare host', async () => { + const fn = await getComputeOpenApiServerUrl(); + expect(fn('example.com')).toBe('https://api.example.com'); + }); + + test('falls back to http://localhost:3000 when domain is falsy', async () => { + const fn = await getComputeOpenApiServerUrl(); + expect(fn('')).toBe('http://localhost:3000'); + expect(fn(null)).toBe('http://localhost:3000'); + expect(fn(undefined)).toBe('http://localhost:3000'); + }); +});