Skip to content

Commit 7ae3664

Browse files
feat(express): derive api.<bare-domain> for OpenAPI servers.url (#3770)
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.<bare>) 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.
1 parent 5604011 commit 7ae3664

2 files changed

Lines changed: 98 additions & 1 deletion

File tree

lib/services/express.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,18 @@ import errorTracker from './errorTracker.js';
2626
import AnalyticsService from './analytics.js';
2727
import analyticsMiddleware from '../middlewares/analytics.js';
2828

29+
/**
30+
* Compute the OpenAPI `servers.url` from `config.domain`.
31+
* - If `domain` already has http(s):// → return verbatim (backward-compatible).
32+
* - Otherwise prepend `https://api.` (api subdomain convention for split frontend/backend topologies).
33+
* @param {string} domain - config.domain value
34+
* @returns {string} OpenAPI servers.url
35+
*/
36+
export const computeOpenApiServerUrl = (domain) => {
37+
const d = domain || 'http://localhost:3000';
38+
return /^https?:\/\//.test(d) ? d : `https://api.${d}`;
39+
};
40+
2941
/**
3042
* Default Redoc theme — Inter + JetBrains Mono, tighter sidebar, refined right panel.
3143
* No hardcoded brand color. Downstream projects override via config.docs.redocTheme
@@ -116,7 +128,7 @@ const initSwagger = (app) => {
116128
description: config.app.description,
117129
...(xLogo ? { 'x-logo': xLogo } : {}),
118130
};
119-
spec.servers = [{ url: config.domain || 'http://localhost:3000' }];
131+
spec.servers = [{ url: computeOpenApiServerUrl(config.domain) }];
120132

121133
// Merge per-module markdown guides into info.description so Redoc
122134
// renders them in its sidebar alongside the OpenAPI reference.
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
2+
3+
/**
4+
* Unit tests for computeOpenApiServerUrl — pure function, no Express/config side-effects.
5+
* express.js has transitive deps (logger, config) so we mock all module-level deps
6+
* before importing to isolate the pure computation.
7+
*/
8+
describe('lib/services/express — OpenAPI servers.url derivation:', () => {
9+
beforeEach(() => jest.resetModules());
10+
11+
const getComputeOpenApiServerUrl = async () => {
12+
jest.unstable_mockModule('../../../config/index.js', () => ({
13+
default: {
14+
domain: 'http://localhost:3000',
15+
swagger: { enable: false },
16+
app: { title: 'test', description: 'd' },
17+
files: { swagger: [], guides: [], routes: [], configs: [], policies: [], preRoutes: [] },
18+
bodyParser: {},
19+
cors: { origin: [], credentials: false },
20+
csrf: {},
21+
posthog: {},
22+
analytics: {},
23+
trust: {},
24+
log: { fileLogger: { directoryPath: '/tmp', fileName: 'test.log' } },
25+
},
26+
}));
27+
jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
28+
default: {
29+
warn: jest.fn(),
30+
error: jest.fn(),
31+
info: jest.fn(),
32+
debug: jest.fn(),
33+
getLogFormat: jest.fn().mockReturnValue('dev'),
34+
getMorganOptions: jest.fn().mockReturnValue({}),
35+
},
36+
}));
37+
jest.unstable_mockModule('../../../lib/services/errorTracker.js', () => ({
38+
default: { setupExpressErrorHandler: jest.fn(), init: jest.fn() },
39+
}));
40+
jest.unstable_mockModule('../../../lib/services/analytics.js', () => ({
41+
default: { init: jest.fn().mockResolvedValue(undefined), isConfigured: jest.fn().mockReturnValue(false) },
42+
}));
43+
jest.unstable_mockModule('../../../lib/helpers/guides.js', () => ({
44+
default: { loadGuides: jest.fn().mockReturnValue([]), mergeGuidesIntoSpec: jest.fn() },
45+
}));
46+
jest.unstable_mockModule('../../../lib/middlewares/requestId.js', () => ({ default: jest.fn() }));
47+
jest.unstable_mockModule('../../../lib/middlewares/analytics.js', () => ({ default: jest.fn() }));
48+
jest.unstable_mockModule('../../../lib/middlewares/policy.js', () => ({
49+
default: { discoverPolicies: jest.fn().mockResolvedValue(undefined) },
50+
}));
51+
jest.unstable_mockModule('../../../lib/middlewares/posthog-context.middleware.js', () => ({
52+
posthogContextMiddleware: jest.fn(),
53+
}));
54+
55+
const mod = await import('../../../lib/services/express.js');
56+
return mod.computeOpenApiServerUrl;
57+
};
58+
59+
test('keeps full URL when domain already has http:// scheme', async () => {
60+
const fn = await getComputeOpenApiServerUrl();
61+
expect(fn('http://localhost:3000')).toBe('http://localhost:3000');
62+
});
63+
64+
test('keeps full URL when domain already has https:// scheme with custom subdomain', async () => {
65+
const fn = await getComputeOpenApiServerUrl();
66+
expect(fn('https://app.example.com')).toBe('https://app.example.com');
67+
});
68+
69+
test('prepends https://api. when domain is a bare host (no scheme)', async () => {
70+
const fn = await getComputeOpenApiServerUrl();
71+
expect(fn('trawl.me')).toBe('https://api.trawl.me');
72+
});
73+
74+
test('prepends https://api. for any bare host', async () => {
75+
const fn = await getComputeOpenApiServerUrl();
76+
expect(fn('example.com')).toBe('https://api.example.com');
77+
});
78+
79+
test('falls back to http://localhost:3000 when domain is falsy', async () => {
80+
const fn = await getComputeOpenApiServerUrl();
81+
expect(fn('')).toBe('http://localhost:3000');
82+
expect(fn(null)).toBe('http://localhost:3000');
83+
expect(fn(undefined)).toBe('http://localhost:3000');
84+
});
85+
});

0 commit comments

Comments
 (0)