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
14 changes: 13 additions & 1 deletion lib/services/express.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
85 changes: 85 additions & 0 deletions lib/services/tests/express.openapi-servers.unit.tests.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading