Skip to content

Commit 219aaf5

Browse files
Copilothotlong
andcommitted
refactor: move i18n route registration from RestServer to I18nServicePlugin
- Remove `enableI18n` from RestApiConfigSchema (rest-server.zod.ts) - Remove `registerI18nEndpoints()` method from RestServer - Remove `enableI18n` from NormalizedRestServerConfig - Add self-registration of i18n REST routes in I18nServicePlugin following the AuthPlugin kernel:ready hook pattern - Update rest.test.ts to verify RestServer no longer registers i18n routes - Add comprehensive tests for I18nServicePlugin route self-registration Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent 2818bae commit 219aaf5

5 files changed

Lines changed: 414 additions & 83 deletions

File tree

packages/rest/src/rest-server.ts

Lines changed: 0 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ type NormalizedRestServerConfig = {
1919
enableUi: boolean;
2020
enableBatch: boolean;
2121
enableDiscovery: boolean;
22-
enableI18n: boolean;
2322
documentation: RestApiConfig['documentation'];
2423
responseFormat: RestApiConfig['responseFormat'];
2524
};
@@ -127,7 +126,6 @@ export class RestServer {
127126
enableUi: api.enableUi ?? true,
128127
enableBatch: api.enableBatch ?? true,
129128
enableDiscovery: api.enableDiscovery ?? true,
130-
enableI18n: api.enableI18n ?? true,
131129
documentation: api.documentation,
132130
responseFormat: api.responseFormat,
133131
},
@@ -212,11 +210,6 @@ export class RestServer {
212210
if (this.config.api.enableBatch) {
213211
this.registerBatchEndpoints(basePath);
214212
}
215-
216-
// i18n endpoints
217-
if (this.config.api.enableI18n) {
218-
this.registerI18nEndpoints(basePath);
219-
}
220213
}
221214

222215
/**
@@ -679,56 +672,6 @@ export class RestServer {
679672
}
680673
}
681674

682-
/**
683-
* Register i18n endpoints for locale and translation operations
684-
*/
685-
private registerI18nEndpoints(basePath: string): void {
686-
const i18nPath = `${basePath}/i18n`;
687-
688-
// GET /i18n/locales - List available locales
689-
this.routeManager.register({
690-
method: 'GET',
691-
path: `${i18nPath}/locales`,
692-
handler: async (_req: any, res: any) => {
693-
try {
694-
if (this.protocol.getLocales) {
695-
const locales = await this.protocol.getLocales({});
696-
res.json(locales);
697-
} else {
698-
res.status(501).json({ error: 'i18n service not available in protocol' });
699-
}
700-
} catch (error: any) {
701-
res.status(500).json({ error: error.message });
702-
}
703-
},
704-
metadata: {
705-
summary: 'Get available locales',
706-
tags: ['i18n'],
707-
},
708-
});
709-
710-
// GET /i18n/translations/:locale - Get translations for a locale
711-
this.routeManager.register({
712-
method: 'GET',
713-
path: `${i18nPath}/translations/:locale`,
714-
handler: async (req: any, res: any) => {
715-
try {
716-
if (this.protocol.getTranslations) {
717-
const translations = await this.protocol.getTranslations({ locale: req.params.locale });
718-
res.json(translations);
719-
} else {
720-
res.status(501).json({ error: 'i18n service not available in protocol' });
721-
}
722-
} catch (error: any) {
723-
res.status(500).json({ error: error.message });
724-
}
725-
},
726-
metadata: {
727-
summary: 'Get translations for a locale',
728-
tags: ['i18n'],
729-
},
730-
});
731-
}
732675

733676
/**
734677
* Get the route manager

packages/rest/src/rest.test.ts

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -399,25 +399,10 @@ describe('RestServer', () => {
399399
expect(uiRoutes.length).toBeGreaterThan(0);
400400
});
401401

402-
it('should register i18n endpoints by default', () => {
402+
it('should not register i18n endpoints (i18n routes are self-registered by service-i18n)', () => {
403403
const rest = new RestServer(server as any, protocol as any);
404404
rest.registerRoutes();
405405

406-
const i18nRoutes = rest.getRoutes().filter((r) =>
407-
r.metadata?.tags?.includes('i18n'),
408-
);
409-
expect(i18nRoutes.length).toBeGreaterThan(0);
410-
const paths = i18nRoutes.map((r) => r.path);
411-
expect(paths).toContain('/api/v1/i18n/locales');
412-
expect(paths).toContain('/api/v1/i18n/translations/:locale');
413-
});
414-
415-
it('should skip i18n routes when enableI18n is false', () => {
416-
const rest = new RestServer(server as any, protocol as any, {
417-
api: { enableI18n: false },
418-
} as any);
419-
rest.registerRoutes();
420-
421406
const i18nRoutes = rest.getRoutes().filter((r) =>
422407
r.metadata?.tags?.includes('i18n'),
423408
);
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, vi, beforeEach } from 'vitest';
4+
import { I18nServicePlugin } from './i18n-service-plugin';
5+
import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts';
6+
7+
// ---------------------------------------------------------------------------
8+
// Mocks
9+
// ---------------------------------------------------------------------------
10+
11+
function createMockHttpServer() {
12+
const routes = new Map<string, RouteHandler>();
13+
return {
14+
get: vi.fn((path: string, handler: RouteHandler) => { routes.set(`GET:${path}`, handler); }),
15+
post: vi.fn(),
16+
put: vi.fn(),
17+
delete: vi.fn(),
18+
patch: vi.fn(),
19+
use: vi.fn(),
20+
listen: vi.fn().mockResolvedValue(undefined),
21+
close: vi.fn().mockResolvedValue(undefined),
22+
/** Test helper: retrieve a registered handler */
23+
_getHandler(method: string, path: string): RouteHandler | undefined {
24+
return routes.get(`${method}:${path}`);
25+
},
26+
};
27+
}
28+
29+
function createMockPluginContext(services: Record<string, any> = {}) {
30+
const hooks = new Map<string, Array<(...args: any[]) => Promise<void>>>();
31+
return {
32+
registerService: vi.fn(),
33+
getService: vi.fn((name: string) => {
34+
if (services[name]) return services[name];
35+
throw new Error(`Service '${name}' not found`);
36+
}),
37+
getServices: vi.fn(() => new Map(Object.entries(services))),
38+
hook: vi.fn((name: string, handler: (...args: any[]) => Promise<void>) => {
39+
if (!hooks.has(name)) hooks.set(name, []);
40+
hooks.get(name)!.push(handler);
41+
}),
42+
trigger: vi.fn(async (name: string, ...args: any[]) => {
43+
const handlers = hooks.get(name) ?? [];
44+
for (const h of handlers) await h(...args);
45+
}),
46+
logger: {
47+
info: vi.fn(),
48+
warn: vi.fn(),
49+
error: vi.fn(),
50+
debug: vi.fn(),
51+
},
52+
getKernel: vi.fn(),
53+
replaceService: vi.fn(),
54+
};
55+
}
56+
57+
function createMockReq(overrides: Partial<IHttpRequest> = {}): IHttpRequest {
58+
return {
59+
params: {},
60+
query: {},
61+
headers: {},
62+
method: 'GET',
63+
path: '/',
64+
...overrides,
65+
};
66+
}
67+
68+
function createMockRes(): IHttpResponse & { _data: any; _status: number } {
69+
const res: any = {
70+
_data: null,
71+
_status: 200,
72+
json(data: any) { res._data = data; },
73+
send(data: string) { res._data = data; },
74+
status(code: number) { res._status = code; return res; },
75+
header() { return res; },
76+
};
77+
return res;
78+
}
79+
80+
// ---------------------------------------------------------------------------
81+
// Tests
82+
// ---------------------------------------------------------------------------
83+
84+
describe('I18nServicePlugin', () => {
85+
let httpServer: ReturnType<typeof createMockHttpServer>;
86+
let ctx: ReturnType<typeof createMockPluginContext>;
87+
88+
beforeEach(() => {
89+
httpServer = createMockHttpServer();
90+
ctx = createMockPluginContext({ 'http-server': httpServer });
91+
});
92+
93+
// -- Service registration -------------------------------------------------
94+
95+
describe('init', () => {
96+
it('should register i18n service during init', async () => {
97+
const plugin = new I18nServicePlugin();
98+
await plugin.init!(ctx as any);
99+
100+
expect(ctx.registerService).toHaveBeenCalledWith('i18n', expect.any(Object));
101+
});
102+
103+
it('should pass options to the FileI18nAdapter', async () => {
104+
const plugin = new I18nServicePlugin({ defaultLocale: 'zh-CN' });
105+
await plugin.init!(ctx as any);
106+
107+
const registeredService = ctx.registerService.mock.calls[0][1];
108+
expect(registeredService.getDefaultLocale()).toBe('zh-CN');
109+
});
110+
});
111+
112+
// -- Route self-registration ----------------------------------------------
113+
114+
describe('route self-registration', () => {
115+
it('should register a kernel:ready hook during start', async () => {
116+
const plugin = new I18nServicePlugin();
117+
await plugin.init!(ctx as any);
118+
await plugin.start!(ctx as any);
119+
120+
expect(ctx.hook).toHaveBeenCalledWith('kernel:ready', expect.any(Function));
121+
});
122+
123+
it('should register i18n routes when http-server is available', async () => {
124+
const plugin = new I18nServicePlugin();
125+
await plugin.init!(ctx as any);
126+
await plugin.start!(ctx as any);
127+
128+
// Simulate kernel:ready
129+
await ctx.trigger('kernel:ready');
130+
131+
expect(httpServer.get).toHaveBeenCalledWith('/api/v1/i18n/locales', expect.any(Function));
132+
expect(httpServer.get).toHaveBeenCalledWith('/api/v1/i18n/translations/:locale', expect.any(Function));
133+
expect(httpServer.get).toHaveBeenCalledWith('/api/v1/i18n/labels/:object/:locale', expect.any(Function));
134+
});
135+
136+
it('should respect custom basePath', async () => {
137+
const plugin = new I18nServicePlugin({ basePath: '/custom/i18n' });
138+
await plugin.init!(ctx as any);
139+
await plugin.start!(ctx as any);
140+
141+
await ctx.trigger('kernel:ready');
142+
143+
expect(httpServer.get).toHaveBeenCalledWith('/custom/i18n/locales', expect.any(Function));
144+
expect(httpServer.get).toHaveBeenCalledWith('/custom/i18n/translations/:locale', expect.any(Function));
145+
expect(httpServer.get).toHaveBeenCalledWith('/custom/i18n/labels/:object/:locale', expect.any(Function));
146+
});
147+
148+
it('should skip route registration when registerRoutes is false', async () => {
149+
const plugin = new I18nServicePlugin({ registerRoutes: false });
150+
await plugin.init!(ctx as any);
151+
await plugin.start!(ctx as any);
152+
153+
expect(ctx.hook).not.toHaveBeenCalled();
154+
});
155+
156+
it('should gracefully skip routes when http-server is not available', async () => {
157+
const ctxNoHttp = createMockPluginContext({}); // no http-server
158+
const plugin = new I18nServicePlugin();
159+
await plugin.init!(ctxNoHttp as any);
160+
await plugin.start!(ctxNoHttp as any);
161+
162+
await ctxNoHttp.trigger('kernel:ready');
163+
164+
expect(ctxNoHttp.logger.warn).toHaveBeenCalledWith(
165+
expect.stringContaining('No HTTP server available'),
166+
);
167+
});
168+
});
169+
170+
// -- Route handler behavior -----------------------------------------------
171+
172+
describe('route handlers', () => {
173+
async function setupPlugin(options: ConstructorParameters<typeof I18nServicePlugin>[0] = {}) {
174+
const plugin = new I18nServicePlugin(options);
175+
await plugin.init!(ctx as any);
176+
// Load some translations after init so the service has data
177+
const i18n = ctx.registerService.mock.calls[0][1];
178+
i18n.loadTranslations('en', { greeting: 'Hello', 'o.account.fields.name': 'Account Name' });
179+
i18n.loadTranslations('zh-CN', { greeting: '你好', 'o.account.fields.name': '账户名称' });
180+
await plugin.start!(ctx as any);
181+
await ctx.trigger('kernel:ready');
182+
return { plugin, i18n };
183+
}
184+
185+
it('GET /locales should return all available locales', async () => {
186+
await setupPlugin();
187+
188+
const handler = httpServer._getHandler('GET', '/api/v1/i18n/locales')!;
189+
expect(handler).toBeDefined();
190+
191+
const req = createMockReq();
192+
const res = createMockRes();
193+
await handler(req, res);
194+
195+
expect(res._data).toEqual({
196+
data: {
197+
locales: [
198+
{ code: 'en', label: 'en', isDefault: true },
199+
{ code: 'zh-CN', label: 'zh-CN', isDefault: false },
200+
],
201+
},
202+
});
203+
});
204+
205+
it('GET /translations/:locale should return translations for the given locale', async () => {
206+
await setupPlugin();
207+
208+
const handler = httpServer._getHandler('GET', '/api/v1/i18n/translations/:locale')!;
209+
expect(handler).toBeDefined();
210+
211+
const req = createMockReq({ params: { locale: 'en' } });
212+
const res = createMockRes();
213+
await handler(req, res);
214+
215+
expect(res._data).toEqual({
216+
data: {
217+
locale: 'en',
218+
translations: { greeting: 'Hello', 'o.account.fields.name': 'Account Name' },
219+
},
220+
});
221+
});
222+
223+
it('GET /translations/:locale should return 400 when locale is missing', async () => {
224+
await setupPlugin();
225+
226+
const handler = httpServer._getHandler('GET', '/api/v1/i18n/translations/:locale')!;
227+
const req = createMockReq({ params: {} });
228+
const res = createMockRes();
229+
await handler(req, res);
230+
231+
expect(res._status).toBe(400);
232+
expect(res._data).toEqual({ error: 'Missing locale parameter' });
233+
});
234+
235+
it('GET /labels/:object/:locale should derive field labels from translation bundle', async () => {
236+
await setupPlugin();
237+
238+
const handler = httpServer._getHandler('GET', '/api/v1/i18n/labels/:object/:locale')!;
239+
expect(handler).toBeDefined();
240+
241+
const req = createMockReq({ params: { object: 'account', locale: 'en' } });
242+
const res = createMockRes();
243+
await handler(req, res);
244+
245+
expect(res._data).toEqual({
246+
data: {
247+
object: 'account',
248+
locale: 'en',
249+
labels: { name: 'Account Name' },
250+
},
251+
});
252+
});
253+
254+
it('GET /labels/:object/:locale should return 400 when params are missing', async () => {
255+
await setupPlugin();
256+
257+
const handler = httpServer._getHandler('GET', '/api/v1/i18n/labels/:object/:locale')!;
258+
const req = createMockReq({ params: {} });
259+
const res = createMockRes();
260+
await handler(req, res);
261+
262+
expect(res._status).toBe(400);
263+
expect(res._data).toEqual({ error: 'Missing object or locale parameter' });
264+
});
265+
});
266+
267+
// -- Plugin metadata -------------------------------------------------------
268+
269+
describe('plugin metadata', () => {
270+
it('should have correct plugin name', () => {
271+
const plugin = new I18nServicePlugin();
272+
expect(plugin.name).toBe('com.objectstack.service.i18n');
273+
});
274+
275+
it('should have version', () => {
276+
const plugin = new I18nServicePlugin();
277+
expect(plugin.version).toBe('1.0.0');
278+
});
279+
});
280+
});

0 commit comments

Comments
 (0)