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
22 changes: 18 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed
- **i18n route self-registration** — Moved i18n REST endpoint registration from `RestServer` to
`I18nServicePlugin` (and kernel fallback). The i18n plugin now self-registers `/api/v1/i18n/*`
routes via the `kernel:ready` hook, following the same autonomous plugin pattern used by
`AuthPlugin`, `WorkflowPlugin`, and other service plugins. `RestServer` no longer registers or
manages any i18n endpoints, keeping it strictly a protocol-driven gateway.
- Removed `enableI18n` flag from `RestApiConfig` schema (`rest-server.zod.ts`) — i18n endpoints
are now controlled by the i18n service plugin's own `registerRoutes` option (default: `true`).
- Removed `registerI18nEndpoints()` method from `RestServer` class.
- `I18nServicePlugin` now accepts `registerRoutes` and `basePath` options for HTTP route control.
- i18n endpoints now work independently of `RestServer`, enabling MSW/mock test environments
to serve i18n routes without any REST API gateway dependency.
- **Dispatcher i18n bridge routes** — `createDispatcherPlugin()` now registers i18n HTTP route
bridges (`GET /i18n/locales`, `GET /i18n/translations/:locale`, `GET /i18n/labels/:object/:locale`)
via `HttpDispatcher.handleI18n()`, ensuring i18n endpoints work even when only the kernel's
memory fallback i18n is active (no explicit `I18nServicePlugin` loaded). This is consistent with
how auth, analytics, packages, storage, and automation services are bridged.

### Added
- **i18n as core built-in service** — The i18n service is now a `core` criticality service with
automatic in-memory fallback. When no plugin (e.g. `I18nServicePlugin`) registers an i18n service,
the kernel auto-injects `createMemoryI18n` (in-memory Map-backed II18nService implementation)
during `validateSystemRequirements()`. This ensures `/api/v1/i18n/*` routes and discovery always
report i18n as available, even without `plugin-i18n` installed.
- **REST i18n route auto-registration** — `RestServer.registerRoutes()` now automatically registers
`/api/v1/i18n/locales` and `/api/v1/i18n/translations/:locale` endpoints, controlled by the new
`enableI18n` config flag (default: `true`).
- `createMemoryI18n` fallback factory in `@objectstack/core` (packages/core/src/fallbacks/memory-i18n.ts)
implementing `II18nService` contract with translation loading, dot-notation key resolution, parameter
interpolation, and locale management.
- `enableI18n` flag in `RestApiConfig` schema (`rest-server.zod.ts`) for toggling i18n API endpoints.

### Changed
- `ServiceRequirementDef.i18n` upgraded from `'optional'` to `'core'` — kernel now warns (instead
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -842,7 +842,7 @@ Final polish and advanced features.
| 19 | Automation Service | `IAutomationService` | ✅ | `@objectstack/service-automation` | DAG engine + HTTP API CRUD + Client SDK + typed returns (67 tests) |
| 20 | Workflow Service | `IWorkflowService` | ❌ | `@objectstack/service-workflow` (planned) | Spec only |
| 21 | GraphQL Service | `IGraphQLService` | ❌ | `@objectstack/service-graphql` (planned) | Spec only |
| 22 | i18n Service | `II18nService` | ✅ | `@objectstack/service-i18n` | File-based locale loading |
| 22 | i18n Service | `II18nService` | ✅ | `@objectstack/service-i18n` | File-based locale loading, self-registered REST routes |
| 23 | UI Service | `IUIService` | ⚠️ | — | **Deprecated** — merged into `IMetadataService` |
| 24 | Schema Driver | `ISchemaDriver` | ❌ | — | Spec only |
| 25 | Startup Orchestrator | `IStartupOrchestrator` | ❌ | — | Kernel handles basics |
Expand Down
57 changes: 0 additions & 57 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ type NormalizedRestServerConfig = {
enableUi: boolean;
enableBatch: boolean;
enableDiscovery: boolean;
enableI18n: boolean;
documentation: RestApiConfig['documentation'];
responseFormat: RestApiConfig['responseFormat'];
};
Expand Down Expand Up @@ -127,7 +126,6 @@ export class RestServer {
enableUi: api.enableUi ?? true,
enableBatch: api.enableBatch ?? true,
enableDiscovery: api.enableDiscovery ?? true,
enableI18n: api.enableI18n ?? true,
documentation: api.documentation,
responseFormat: api.responseFormat,
},
Expand Down Expand Up @@ -212,11 +210,6 @@ export class RestServer {
if (this.config.api.enableBatch) {
this.registerBatchEndpoints(basePath);
}

// i18n endpoints
if (this.config.api.enableI18n) {
this.registerI18nEndpoints(basePath);
}
}

/**
Expand Down Expand Up @@ -679,56 +672,6 @@ export class RestServer {
}
}

/**
* Register i18n endpoints for locale and translation operations
*/
private registerI18nEndpoints(basePath: string): void {
const i18nPath = `${basePath}/i18n`;

// GET /i18n/locales - List available locales
this.routeManager.register({
method: 'GET',
path: `${i18nPath}/locales`,
handler: async (_req: any, res: any) => {
try {
if (this.protocol.getLocales) {
const locales = await this.protocol.getLocales({});
res.json(locales);
} else {
res.status(501).json({ error: 'i18n service not available in protocol' });
}
} catch (error: any) {
res.status(500).json({ error: error.message });
}
},
metadata: {
summary: 'Get available locales',
tags: ['i18n'],
},
});

// GET /i18n/translations/:locale - Get translations for a locale
this.routeManager.register({
method: 'GET',
path: `${i18nPath}/translations/:locale`,
handler: async (req: any, res: any) => {
try {
if (this.protocol.getTranslations) {
const translations = await this.protocol.getTranslations({ locale: req.params.locale });
res.json(translations);
} else {
res.status(501).json({ error: 'i18n service not available in protocol' });
}
} catch (error: any) {
res.status(500).json({ error: error.message });
}
},
metadata: {
summary: 'Get translations for a locale',
tags: ['i18n'],
},
});
}

/**
* Get the route manager
Expand Down
17 changes: 1 addition & 16 deletions packages/rest/src/rest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,25 +399,10 @@ describe('RestServer', () => {
expect(uiRoutes.length).toBeGreaterThan(0);
});

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

const i18nRoutes = rest.getRoutes().filter((r) =>
r.metadata?.tags?.includes('i18n'),
);
expect(i18nRoutes.length).toBeGreaterThan(0);
const paths = i18nRoutes.map((r) => r.path);
expect(paths).toContain('/api/v1/i18n/locales');
expect(paths).toContain('/api/v1/i18n/translations/:locale');
});

it('should skip i18n routes when enableI18n is false', () => {
const rest = new RestServer(server as any, protocol as any, {
api: { enableI18n: false },
} as any);
rest.registerRoutes();

const i18nRoutes = rest.getRoutes().filter((r) =>
r.metadata?.tags?.includes('i18n'),
);
Expand Down
32 changes: 31 additions & 1 deletion packages/runtime/src/dispatcher-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ function errorResponse(err: any, res: any): void {
* - /graphql (GraphQL)
* - /analytics (BI queries)
* - /packages (package management)

* - /i18n (internationalization — locales, translations, field labels)
* - /storage (file storage)
* - /automation (CRUD + triggers + runs)
*
Expand Down Expand Up @@ -237,6 +237,36 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu
}
});

// ── i18n ────────────────────────────────────────────────────
// Bridges to HttpDispatcher.handleI18n() which resolves the i18n
// service from the kernel (either I18nServicePlugin or memory fallback).
server.get(`${prefix}/i18n/locales`, async (req: any, res: any) => {
try {
const result = await dispatcher.handleI18n('/locales', 'GET', req.query, { request: req });
sendResult(result, res);
} catch (err: any) {
errorResponse(err, res);
}
});

server.get(`${prefix}/i18n/translations/:locale`, async (req: any, res: any) => {
try {
const result = await dispatcher.handleI18n(`/translations/${req.params.locale}`, 'GET', req.query, { request: req });
sendResult(result, res);
} catch (err: any) {
errorResponse(err, res);
}
});
Comment on lines +252 to +259

Copilot AI Mar 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dispatcher i18n bridges only expose /i18n/translations/:locale and /i18n/labels/:object/:locale. HttpDispatcher.handleI18n explicitly supports (and the TS client uses) /i18n/translations?locale=... and /i18n/labels/:object?locale=..., so those client calls will still 404 even after this change. Add bridge routes for the query-param forms (and keep behavior consistent across both).

Copilot uses AI. Check for mistakes.

server.get(`${prefix}/i18n/labels/:object/:locale`, async (req: any, res: any) => {
try {
const result = await dispatcher.handleI18n(`/labels/${req.params.object}/${req.params.locale}`, 'GET', req.query, { request: req });
sendResult(result, res);
} catch (err: any) {
errorResponse(err, res);
}
});

// ── Automation ──────────────────────────────────────────────
server.get(`${prefix}/automation`, async (req: any, res: any) => {
try {
Expand Down
Loading
Loading