Skip to content

Commit 5a71ba1

Browse files
authored
Merge pull request #911 from objectstack-ai/copilot/fix-i18n-route-registration
2 parents a9ab296 + 6ea9935 commit 5a71ba1

8 files changed

Lines changed: 468 additions & 89 deletions

File tree

CHANGELOG.md

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
- **i18n route self-registration** — Moved i18n REST endpoint registration from `RestServer` to
12+
`I18nServicePlugin` (and kernel fallback). The i18n plugin now self-registers `/api/v1/i18n/*`
13+
routes via the `kernel:ready` hook, following the same autonomous plugin pattern used by
14+
`AuthPlugin`, `WorkflowPlugin`, and other service plugins. `RestServer` no longer registers or
15+
manages any i18n endpoints, keeping it strictly a protocol-driven gateway.
16+
- Removed `enableI18n` flag from `RestApiConfig` schema (`rest-server.zod.ts`) — i18n endpoints
17+
are now controlled by the i18n service plugin's own `registerRoutes` option (default: `true`).
18+
- Removed `registerI18nEndpoints()` method from `RestServer` class.
19+
- `I18nServicePlugin` now accepts `registerRoutes` and `basePath` options for HTTP route control.
20+
- i18n endpoints now work independently of `RestServer`, enabling MSW/mock test environments
21+
to serve i18n routes without any REST API gateway dependency.
22+
- **Dispatcher i18n bridge routes**`createDispatcherPlugin()` now registers i18n HTTP route
23+
bridges (`GET /i18n/locales`, `GET /i18n/translations/:locale`, `GET /i18n/labels/:object/:locale`)
24+
via `HttpDispatcher.handleI18n()`, ensuring i18n endpoints work even when only the kernel's
25+
memory fallback i18n is active (no explicit `I18nServicePlugin` loaded). This is consistent with
26+
how auth, analytics, packages, storage, and automation services are bridged.
27+
1028
### Added
1129
- **i18n as core built-in service** — The i18n service is now a `core` criticality service with
1230
automatic in-memory fallback. When no plugin (e.g. `I18nServicePlugin`) registers an i18n service,
1331
the kernel auto-injects `createMemoryI18n` (in-memory Map-backed II18nService implementation)
1432
during `validateSystemRequirements()`. This ensures `/api/v1/i18n/*` routes and discovery always
1533
report i18n as available, even without `plugin-i18n` installed.
16-
- **REST i18n route auto-registration**`RestServer.registerRoutes()` now automatically registers
17-
`/api/v1/i18n/locales` and `/api/v1/i18n/translations/:locale` endpoints, controlled by the new
18-
`enableI18n` config flag (default: `true`).
1934
- `createMemoryI18n` fallback factory in `@objectstack/core` (packages/core/src/fallbacks/memory-i18n.ts)
2035
implementing `II18nService` contract with translation loading, dot-notation key resolution, parameter
2136
interpolation, and locale management.
22-
- `enableI18n` flag in `RestApiConfig` schema (`rest-server.zod.ts`) for toggling i18n API endpoints.
2337

2438
### Changed
2539
- `ServiceRequirementDef.i18n` upgraded from `'optional'` to `'core'` — kernel now warns (instead

ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -842,7 +842,7 @@ Final polish and advanced features.
842842
| 19 | Automation Service | `IAutomationService` || `@objectstack/service-automation` | DAG engine + HTTP API CRUD + Client SDK + typed returns (67 tests) |
843843
| 20 | Workflow Service | `IWorkflowService` || `@objectstack/service-workflow` (planned) | Spec only |
844844
| 21 | GraphQL Service | `IGraphQLService` || `@objectstack/service-graphql` (planned) | Spec only |
845-
| 22 | i18n Service | `II18nService` || `@objectstack/service-i18n` | File-based locale loading |
845+
| 22 | i18n Service | `II18nService` || `@objectstack/service-i18n` | File-based locale loading, self-registered REST routes |
846846
| 23 | UI Service | `IUIService` | ⚠️ || **Deprecated** — merged into `IMetadataService` |
847847
| 24 | Schema Driver | `ISchemaDriver` ||| Spec only |
848848
| 25 | Startup Orchestrator | `IStartupOrchestrator` ||| Kernel handles basics |

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
);

packages/runtime/src/dispatcher-plugin.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ function errorResponse(err: any, res: any): void {
5353
* - /graphql (GraphQL)
5454
* - /analytics (BI queries)
5555
* - /packages (package management)
56-
56+
* - /i18n (internationalization — locales, translations, field labels)
5757
* - /storage (file storage)
5858
* - /automation (CRUD + triggers + runs)
5959
*
@@ -237,6 +237,36 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
237237
}
238238
});
239239

240+
// ── i18n ────────────────────────────────────────────────────
241+
// Bridges to HttpDispatcher.handleI18n() which resolves the i18n
242+
// service from the kernel (either I18nServicePlugin or memory fallback).
243+
server.get(`${prefix}/i18n/locales`, async (req: any, res: any) => {
244+
try {
245+
const result = await dispatcher.handleI18n('/locales', 'GET', req.query, { request: req });
246+
sendResult(result, res);
247+
} catch (err: any) {
248+
errorResponse(err, res);
249+
}
250+
});
251+
252+
server.get(`${prefix}/i18n/translations/:locale`, async (req: any, res: any) => {
253+
try {
254+
const result = await dispatcher.handleI18n(`/translations/${req.params.locale}`, 'GET', req.query, { request: req });
255+
sendResult(result, res);
256+
} catch (err: any) {
257+
errorResponse(err, res);
258+
}
259+
});
260+
261+
server.get(`${prefix}/i18n/labels/:object/:locale`, async (req: any, res: any) => {
262+
try {
263+
const result = await dispatcher.handleI18n(`/labels/${req.params.object}/${req.params.locale}`, 'GET', req.query, { request: req });
264+
sendResult(result, res);
265+
} catch (err: any) {
266+
errorResponse(err, res);
267+
}
268+
});
269+
240270
// ── Automation ──────────────────────────────────────────────
241271
server.get(`${prefix}/automation`, async (req: any, res: any) => {
242272
try {

0 commit comments

Comments
 (0)