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
115 changes: 115 additions & 0 deletions packages/runtime/src/http-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,4 +684,119 @@ describe('HttpDispatcher', () => {
);
});
});

// ═══════════════════════════════════════════════════════════════
// handleI18n — i18n route dispatching
// ═══════════════════════════════════════════════════════════════

describe('handleI18n', () => {
let mockI18nService: any;

beforeEach(() => {
mockI18nService = {
getLocales: vi.fn().mockReturnValue(['en', 'zh-CN', 'ja']),
getTranslations: vi.fn().mockReturnValue({ 'o.account.label': '客户', 'o.account.fields.name': '名称' }),
getFieldLabels: vi.fn().mockReturnValue({ name: '名称', industry: '行业' }),
};

(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
if (name === 'i18n') return mockI18nService;
return null;
});
});

it('should list locales via GET /locales', async () => {
const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} });
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(200);
expect(result.response?.body?.data?.locales).toEqual(['en', 'zh-CN', 'ja']);
expect(mockI18nService.getLocales).toHaveBeenCalled();
});

it('should get translations via GET /translations/:locale', async () => {
const result = await dispatcher.handleI18n('/translations/zh-CN', 'GET', {}, { request: {} });
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(200);
expect(result.response?.body?.data?.locale).toBe('zh-CN');
expect(result.response?.body?.data?.translations).toEqual({ 'o.account.label': '客户', 'o.account.fields.name': '名称' });
expect(mockI18nService.getTranslations).toHaveBeenCalledWith('zh-CN');
});

it('should get translations via GET /translations?locale=zh-CN (query param)', async () => {
const result = await dispatcher.handleI18n('/translations', 'GET', { locale: 'zh-CN' }, { request: {} });
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(200);
expect(result.response?.body?.data?.locale).toBe('zh-CN');
expect(mockI18nService.getTranslations).toHaveBeenCalledWith('zh-CN');
});

it('should return 400 when translations requested without locale', async () => {
const result = await dispatcher.handleI18n('/translations', 'GET', {}, { request: {} });
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(400);
expect(result.response?.body?.error?.message).toBe('Missing locale parameter');
});

it('should get field labels via GET /labels/:object/:locale', async () => {
const result = await dispatcher.handleI18n('/labels/account/zh-CN', 'GET', {}, { request: {} });
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(200);
expect(result.response?.body?.data?.object).toBe('account');
expect(result.response?.body?.data?.locale).toBe('zh-CN');
expect(result.response?.body?.data?.labels).toEqual({ name: '名称', industry: '行业' });
expect(mockI18nService.getFieldLabels).toHaveBeenCalledWith('account', 'zh-CN');
});

it('should get field labels via GET /labels/:object?locale=zh-CN (query param)', async () => {
const result = await dispatcher.handleI18n('/labels/account', 'GET', { locale: 'zh-CN' }, { request: {} });
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(200);
expect(result.response?.body?.data?.object).toBe('account');
expect(mockI18nService.getFieldLabels).toHaveBeenCalledWith('account', 'zh-CN');
});

it('should return 400 when labels requested without locale', async () => {
const result = await dispatcher.handleI18n('/labels/account', 'GET', {}, { request: {} });
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(400);
expect(result.response?.body?.error?.message).toBe('Missing locale parameter');
});

it('should fallback to deriving labels from translations when getFieldLabels is missing', async () => {
delete mockI18nService.getFieldLabels;
mockI18nService.getTranslations.mockReturnValue({
'o.contact.fields.first_name': 'First Name',
'o.contact.fields.email': 'Email',
'o.contact.label': 'Contact',
});

const result = await dispatcher.handleI18n('/labels/contact/en', 'GET', {}, { request: {} });
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(200);
expect(result.response?.body?.data?.labels).toEqual({
first_name: 'First Name',
email: 'Email',
});
});
Comment on lines +765 to +780

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

The fallback test for missing getFieldLabels uses flat translation keys like o.contact.fields.first_name and expects string values, but the default TranslationDataSchema / FileI18nAdapter returns nested objects (e.g. translations.objects.contact.fields.first_name.label). As written, this test can pass while the production fallback remains broken. Adjust the test fixture to use the real nested structure and assert the expected response shape.

Copilot uses AI. Check for mistakes.

it('should return 501 when i18n service is not available', async () => {
(kernel as any).getService = vi.fn().mockResolvedValue(null);
(kernel as any).services = new Map();

const result = await dispatcher.handleI18n('/locales', 'GET', {}, { request: {} });
expect(result.handled).toBe(true);
expect(result.response?.status).toBe(501);
});

it('should return unhandled for non-GET methods', async () => {
const result = await dispatcher.handleI18n('/locales', 'POST', {}, { request: {} });
expect(result.handled).toBe(false);
});

it('should dispatch /i18n routes via dispatch()', async () => {
const result = await dispatcher.dispatch('GET', '/i18n/locales', undefined, {}, { request: {} });
expect(result.handled).toBe(true);
expect(result.response?.body?.data?.locales).toEqual(['en', 'zh-CN', 'ja']);
});
});
});
62 changes: 62 additions & 0 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,64 @@ export class HttpDispatcher {
return { handled: false };
}

/**
* Handles i18n requests
* path: sub-path after /i18n/
*
* Routes:
* GET /locales → getLocales
* GET /translations/:locale → getTranslations (locale from path)
* GET /translations?locale=xx → getTranslations (locale from query)
* GET /labels/:object/:locale → getFieldLabels (both from path)
* GET /labels/:object?locale=xx → getFieldLabels (locale from query)
*/
async handleI18n(path: string, method: string, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
const i18nService = await this.getService(CoreServiceName.enum.i18n);
if (!i18nService) return { handled: true, response: this.error('i18n service not available', 501) };

const m = method.toUpperCase();
const parts = path.replace(/^\/+/, '').split('/').filter(Boolean);

if (m !== 'GET') return { handled: false };

// GET /i18n/locales
if (parts[0] === 'locales' && parts.length === 1) {
const locales = i18nService.getLocales();
return { handled: true, response: this.success({ locales }) };
}
Comment on lines +498 to +502

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

PR description says the handler routes endpoints “per the API protocol spec”, but the response shapes here don’t match the spec schemas: e.g. GetLocalesResponseSchema expects locales with metadata objects ({code,label,isDefault}), while this returns the raw string[] from II18nService.getLocales(). Either align the handler output to the spec response types (and update the service contract if needed) or adjust the PR description to reflect the actual contract used by runtime/client.

Copilot uses AI. Check for mistakes.

// GET /i18n/translations/:locale OR /i18n/translations?locale=xx
if (parts[0] === 'translations') {
const locale = parts[1] ? decodeURIComponent(parts[1]) : query?.locale;
if (!locale) return { handled: true, response: this.error('Missing locale parameter', 400) };
const translations = i18nService.getTranslations(locale);

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

handleI18n currently ignores the namespace and keys query params defined by GetTranslationsRequestSchema and used by the client (keys is sent as a comma-separated list). This means callers can’t request a scoped subset of translations and will always receive the full locale payload. Consider parsing query.namespace / query.keys and filtering the returned translation data accordingly (or returning 400 if an unsupported option is provided).

Suggested change
const translations = i18nService.getTranslations(locale);
// Base translation bundle for the requested locale
const fullTranslations = i18nService.getTranslations(locale) || {};
// Optional filtering via namespace / keys query params
const rawNamespace = query?.namespace;
const rawKeys = query?.keys;
// Validate basic types for namespace
if (rawNamespace !== undefined && typeof rawNamespace !== 'string' && !Array.isArray(rawNamespace)) {
return { handled: true, response: this.error('Invalid namespace parameter', 400) };
}
// Validate basic types for keys
if (rawKeys !== undefined && typeof rawKeys !== 'string' && !Array.isArray(rawKeys)) {
return { handled: true, response: this.error('Invalid keys parameter', 400) };
}
// Normalize namespace(s) to a string array
const namespaces: string[] = [];
if (typeof rawNamespace === 'string') {
for (const ns of rawNamespace.split(',')) {
const trimmed = ns.trim();
if (trimmed) namespaces.push(trimmed);
}
} else if (Array.isArray(rawNamespace)) {
for (const ns of rawNamespace) {
if (typeof ns === 'string') {
const trimmed = ns.trim();
if (trimmed) namespaces.push(trimmed);
}
}
}
// Normalize keys to a string array (client sends comma-separated list)
const keyFilters: string[] = [];
if (typeof rawKeys === 'string') {
for (const k of rawKeys.split(',')) {
const trimmed = k.trim();
if (trimmed) keyFilters.push(trimmed);
}
} else if (Array.isArray(rawKeys)) {
for (const k of rawKeys) {
if (typeof k === 'string') {
const trimmed = k.trim();
if (trimmed) keyFilters.push(trimmed);
}
}
}
// If both filters are supplied, treat as unsupported to avoid ambiguity
if (namespaces.length > 0 && keyFilters.length > 0) {
return { handled: true, response: this.error('Cannot combine namespace and keys filters', 400) };
}
let translations = fullTranslations;
if (namespaces.length > 0) {
const filtered: Record<string, any> = {};
for (const [key, value] of Object.entries(fullTranslations)) {
for (const ns of namespaces) {
if (
key === ns ||
key.startsWith(ns + '.') ||
key.startsWith(ns + ':')
) {
filtered[key] = value;
break;
}
}
}
translations = filtered;
} else if (keyFilters.length > 0) {
const filtered: Record<string, any> = {};
for (const k of keyFilters) {
if (Object.prototype.hasOwnProperty.call(fullTranslations, k)) {
filtered[k] = (fullTranslations as any)[k];
}
}
translations = filtered;
}

Copilot uses AI. Check for mistakes.
return { handled: true, response: this.success({ locale, translations }) };
}

// GET /i18n/labels/:object/:locale OR /i18n/labels/:object?locale=xx
if (parts[0] === 'labels' && parts.length >= 2) {
const objectName = decodeURIComponent(parts[1]);
const locale = parts[2] ? decodeURIComponent(parts[2]) : query?.locale;
if (!locale) return { handled: true, response: this.error('Missing locale parameter', 400) };
if (typeof i18nService.getFieldLabels === 'function') {
const labels = i18nService.getFieldLabels(objectName, locale);
return { handled: true, response: this.success({ object: objectName, locale, labels }) };
}
// Fallback: derive field labels from full translation bundle
const translations = i18nService.getTranslations(locale);
const prefix = `o.${objectName}.fields.`;
const labels: Record<string, string> = {};
for (const [key, value] of Object.entries(translations)) {
if (key.startsWith(prefix)) {
labels[key.substring(prefix.length)] = value as string;
}
}
Comment on lines +523 to +529

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

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

The /labels fallback derives labels by scanning for flat keys with prefix o.${objectName}.fields. and casting values to string. This won’t work with the repo’s TranslationDataSchema / FileI18nAdapter, where translations are nested under objects.{object}.fields.{field} and field values are objects (label/help/options). In production, this likely returns an empty labels map or incorrect values; consider extracting from translations.objects?.[objectName]?.fields and shaping the response accordingly.

Suggested change
const prefix = `o.${objectName}.fields.`;
const labels: Record<string, string> = {};
for (const [key, value] of Object.entries(translations)) {
if (key.startsWith(prefix)) {
labels[key.substring(prefix.length)] = value as string;
}
}
const labels: Record<string, string> = {};
// Preferred: TranslationDataSchema / FileI18nAdapter nested structure:
// translations.objects?.[objectName]?.fields?.[fieldName]?.label
const objectTranslations = (translations as any)?.objects?.[objectName];
const fieldTranslations = objectTranslations && typeof objectTranslations === 'object'
? (objectTranslations as any).fields
: undefined;
if (fieldTranslations && typeof fieldTranslations === 'object') {
for (const [fieldName, fieldTranslation] of Object.entries(fieldTranslations)) {
const label = (fieldTranslation as any)?.label;
if (typeof label === 'string') {
labels[fieldName] = label;
}
}
} else {
// Legacy fallback: flat keys like "o.${objectName}.fields.${field}"
const prefix = `o.${objectName}.fields.`;
for (const [key, value] of Object.entries(translations as any)) {
if (key.startsWith(prefix) && typeof value === 'string') {
labels[key.substring(prefix.length)] = value;
}
}
}

Copilot uses AI. Check for mistakes.
return { handled: true, response: this.success({ object: objectName, locale, labels }) };
}

return { handled: false };
}

/**
* Handles Package Management requests
*
Expand Down Expand Up @@ -944,6 +1002,10 @@ export class HttpDispatcher {
return this.handlePackages(cleanPath.substring(9), method, body, query, context);
}

if (cleanPath.startsWith('/i18n')) {
return this.handleI18n(cleanPath.substring(5), method, query, context);
}

// OpenAPI Specification
if (cleanPath === '/openapi.json' && method === 'GET') {
const broker = this.ensureBroker();
Expand Down