Repository navigation
feat: promote i18n to core built-in service with in-memory fallback #908
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. | ||
|
|
||
| /** | ||
| * In-memory i18n service fallback. | ||
| * | ||
| * Implements the II18nService contract with basic translate/load/getLocales | ||
| * operations. Used by ObjectKernel as an automatic fallback when no real | ||
| * i18n plugin (e.g. I18nServicePlugin) is registered. | ||
| * | ||
| * Supports runtime translation loading and locale management. | ||
| * Does not load files from disk — operates purely in-memory. | ||
| */ | ||
| export function createMemoryI18n() { | ||
| const translations = new Map<string, Record<string, unknown>>(); | ||
| let defaultLocale = 'en'; | ||
|
|
||
| /** | ||
| * Resolve a dot-notation key from a nested object. | ||
| */ | ||
| function resolveKey(data: Record<string, unknown>, key: string): string | undefined { | ||
| const parts = key.split('.'); | ||
| let current: unknown = data; | ||
| for (const part of parts) { | ||
| if (current == null || typeof current !== 'object') return undefined; | ||
| current = (current as Record<string, unknown>)[part]; | ||
| } | ||
| return typeof current === 'string' ? current : undefined; | ||
| } | ||
|
|
||
| return { | ||
| _fallback: true, _serviceName: 'i18n', | ||
|
|
||
| t(key: string, locale: string, params?: Record<string, unknown>): string { | ||
| const data = translations.get(locale) ?? translations.get(defaultLocale); | ||
| const value = data ? resolveKey(data, key) : undefined; | ||
| if (value == null) return key; | ||
| if (!params) return value; | ||
| // Interpolation format: {{paramName}} — matches FileI18nAdapter convention | ||
| return value.replace(/\{\{(\w+)\}\}/g, (_, name) => String(params[name] ?? `{{${name}}}`)); | ||
| }, | ||
|
|
||
| getTranslations(locale: string): Record<string, unknown> { | ||
| return translations.get(locale) ?? {}; | ||
| }, | ||
|
|
||
| loadTranslations(locale: string, data: Record<string, unknown>): void { | ||
| const existing = translations.get(locale) ?? {}; | ||
| translations.set(locale, { ...existing, ...data }); | ||
| }, | ||
|
|
||
| getLocales(): string[] { | ||
| return [...translations.keys()]; | ||
| }, | ||
|
|
||
| getDefaultLocale(): string { | ||
| return defaultLocale; | ||
| }, | ||
|
|
||
| setDefaultLocale(locale: string): void { | ||
| defaultLocale = locale; | ||
| }, | ||
| }; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -44,7 +44,7 @@ const SERVICE_CONFIG: Record<string, { route: string; plugin: string }> = { | |
| realtime: { route: '/api/v1/realtime', plugin: 'plugin-realtime' }, | ||
| notification: { route: '/api/v1/notifications', plugin: 'plugin-notifications' }, | ||
| ai: { route: '/api/v1/ai', plugin: 'plugin-ai' }, | ||
| i18n: { route: '/api/v1/i18n', plugin: 'plugin-i18n' }, | ||
| i18n: { route: '/api/v1/i18n', plugin: 'service-i18n' }, | ||
| graphql: { route: '/graphql', plugin: 'plugin-graphql' }, // GraphQL uses /graphql by convention (not versioned REST) | ||
|
Comment on lines
46
to
48
|
||
| 'file-storage': { route: '/api/v1/storage', plugin: 'plugin-storage' }, | ||
| search: { route: '/api/v1/search', plugin: 'plugin-search' }, | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -19,6 +19,7 @@ type NormalizedRestServerConfig = { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| enableUi: boolean; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| enableBatch: boolean; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| enableDiscovery: boolean; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| enableI18n: boolean; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| documentation: RestApiConfig['documentation']; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| responseFormat: RestApiConfig['responseFormat']; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -126,6 +127,7 @@ 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, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -210,6 +212,11 @@ export class RestServer { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (this.config.api.enableBatch) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.registerBatchEndpoints(basePath); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // i18n endpoints | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (this.config.api.enableI18n) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.registerI18nEndpoints(basePath); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -671,6 +678,57 @@ 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', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+682
to
+712
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const translations = await this.protocol.getTranslations({ locale: req.params.locale }); | |
| const locale = req.params.locale; | |
| const { namespace, keys } = req.query ?? {}; | |
| let parsedKeys: string[] | undefined; | |
| if (Array.isArray(keys)) { | |
| parsedKeys = keys.map((k) => String(k)); | |
| } else if (typeof keys === 'string' && keys.length > 0) { | |
| let jsonParsed: unknown; | |
| try { | |
| jsonParsed = JSON.parse(keys); | |
| } catch { | |
| jsonParsed = undefined; | |
| } | |
| if (Array.isArray(jsonParsed)) { | |
| parsedKeys = jsonParsed.map((k) => String(k)); | |
| } else { | |
| parsedKeys = keys | |
| .split(',') | |
| .map((k) => k.trim()) | |
| .filter((k) => k.length > 0); | |
| } | |
| } | |
| const request: any = { locale }; | |
| if (typeof namespace === 'string' && namespace.length > 0) { | |
| request.namespace = namespace; | |
| } | |
| if (parsedKeys && parsedKeys.length > 0) { | |
| request.keys = parsedKeys; | |
| } | |
| const translations = await this.protocol.getTranslations(request); |
Copilot
AI
Mar 11, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The translations route is registered as /i18n/translations/:locale, but the ObjectStack client uses /i18n/translations?locale=... (and runtime HttpDispatcher supports both). As-is, RestServer-based deployments will 404 for the client’s default request shape. Consider registering the query-style route (and optionally keep /:locale as an alias) and accept locale from req.query when not present in the path.
| // 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 }); | |
| } | |
| }, | |
| const translationsHandler = async (req: any, res: any) => { | |
| try { | |
| const localeFromParams = req && req.params ? req.params.locale : undefined; | |
| const localeFromQuery = req && req.query ? (req.query.locale as string | undefined) : undefined; | |
| const locale = localeFromParams ?? localeFromQuery; | |
| if (!locale) { | |
| res.status(400).json({ error: 'Missing required "locale" parameter' }); | |
| return; | |
| } | |
| if (this.protocol.getTranslations) { | |
| const translations = await this.protocol.getTranslations({ 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 }); | |
| } | |
| }; | |
| // GET /i18n/translations?locale=... - Get translations for a locale (query style, default client shape) | |
| this.routeManager.register({ | |
| method: 'GET', | |
| path: `${i18nPath}/translations`, | |
| handler: translationsHandler, | |
| metadata: { | |
| summary: 'Get translations for a locale', | |
| tags: ['i18n'], | |
| }, | |
| }); | |
| // GET /i18n/translations/:locale - Get translations for a locale (path parameter alias) | |
| this.routeManager.register({ | |
| method: 'GET', | |
| path: `${i18nPath}/translations/:locale`, | |
| handler: translationsHandler, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This section lists
getFieldLabelsas an i18n service method, butII18nServicedoes not definegetFieldLabels(it’s only present on the HTTP/protocol surface, and even there it’s optional/derivable). Update the doc to avoid implying the kernel i18n service must implementgetFieldLabels(e.g., mark it as optional or move it under protocol endpoints).