Skip to content

feat: add /i18n route dispatch to HttpDispatcher#861

Merged
hotlong merged 3 commits into
mainfrom
copilot/fix-httpdispatcher-i18n-path
Mar 3, 2026
Merged

feat: add /i18n route dispatch to HttpDispatcher#861
hotlong merged 3 commits into
mainfrom
copilot/fix-httpdispatcher-i18n-path

Conversation

Copilot AI commented Mar 3, 2026

Copy link
Copy Markdown
Contributor

HttpDispatcher.dispatch() had no /i18n prefix routing, so all i18n API requests (/api/v1/i18n/translations/:locale, etc.) fell through to 404. The route was already advertised in discovery info and defined in the API protocol spec, but never wired up.

Changes

  • dispatch() method — Added /i18n prefix check alongside existing /analytics, /packages, etc.
  • New handleI18n() handler — Routes three endpoints per the API protocol:
    • GET /localesi18nService.getLocales()
    • GET /translations/:localei18nService.getTranslations(locale)
    • GET /labels/:object/:localei18nService.getFieldLabels(object, locale) with fallback that derives field labels from the full translation bundle when getFieldLabels is not implemented
  • Supports both path params (/translations/zh-CN) and query params (/translations?locale=zh-CN) for client SDK compatibility
  • 11 new tests covering all routes, param variants, error cases (400/501), fallback logic, and end-to-end dispatch integration
// Now dispatched correctly instead of 404
if (cleanPath.startsWith('/i18n')) {
     return this.handleI18n(cleanPath.substring(5), method, query, context);
}
Original prompt

This section details on the original issue you should resolve

<issue_title>HttpDispatcher 未分发 /i18n 路径导致 i18n 相关 API 实现不一致(cross-repo)</issue_title>
<issue_description>## 问题描述
objectstack-ai/objectui 仓库的 Console/前端已经升级 i18n API 请求为 /api/v1/i18n/translations/:locale,但根据 objectstack-ai/spec 的 packages/runtime/src/http-dispatcher.ts 源码,HttpDispatcher 的 dispatch 方法并未分发 /i18n 路径。导致真实后端环境下所有 /i18n 相关 API(如 translations/zh)都无法获得正确处理,页面国际化内容无法被加载,API 404。

影响

  • 任何使用标准 i18n 路径的应用,在未启用 MSW mock 或连接真实服务端时都无法获取 i18n API 响应。
  • 破坏了与 ObjectStack Spec 协议、文档定义的一致性。
  • 影响跨仓库联动,如 objectui/console 和其他 i18n consumer。

详细排查

  • HttpDispatcher 当前仅实现了 /auth、/meta、/data、/storage、/ui、/automation、/analytics、/packages 等分发分支
  • 没有 /i18n 路径分发,标准 API 如 /api/v1/i18n/translations/:locale 获取不到任何内容
  • 上游 issue: objectstack-ai/objectui#986

建议修复

  1. 在 HttpDispatcher.dispatch 方法中,添加 /i18n 路径分发,并调用 i18n plugin/service 的注册接口,实现 locale translations 相关路由处理。
  2. 补充 i18n 相关的路由用例和测试。
  3. 协议、文档与实现保持一致。

参考实现/修复方向

  • 参照 /data 路径的分发写法,依赖 getService('i18n'),根据 methods 匹配切换 i18n API 分支。
  • 接口实现:(getLocales, getTranslations, getFieldLabels)

标签:cross-repo、dispatcher、routing、i18n、bug</issue_description>

Comments on the Issue (you are @copilot in this section)


🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.

@vercel

vercel Bot commented Mar 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
objectstack-play Ready Ready Preview, Comment Mar 3, 2026 5:21am
spec Ready Ready Preview, Comment Mar 3, 2026 5:21am

Request Review

Copilot AI and others added 2 commits March 3, 2026 05:03
Add handleI18n method supporting:
- GET /i18n/locales → getLocales()
- GET /i18n/translations/:locale → getTranslations(locale)
- GET /i18n/labels/:object/:locale → getFieldLabels(object, locale)

Includes fallback for deriving field labels from translations when
getFieldLabels is not available on the service. Supports both path
params and query params for locale.

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix HttpDispatcher for i18n path dispatch feat: add /i18n route dispatch to HttpDispatcher Mar 3, 2026
@hotlong

hotlong commented Mar 3, 2026

Copy link
Copy Markdown
Collaborator

@copilot 仔细检查是否已经完成了所有的开发并评估还有没有类似的问题

Copilot AI commented Mar 3, 2026

Copy link
Copy Markdown
Contributor Author

@copilot 仔细检查是否已经完成了所有的开发并评估还有没有类似的问题

i18n 路由分发已完成(2f2e3d5),包含 handleI18n 方法和 11 个测试用例,覆盖 GET /localesGET /translations/:localeGET /labels/:object/:locale 三个端点,以及错误情况和 fallback 逻辑。

经过全面检查 dispatch() 方法 vs getDiscoveryInfo() 路由表 vs DEFAULT_DISPATCHER_ROUTES 定义 vs Client SDK getRoute() 调用,发现还有 4 个类似的缺失分发路由:

路由 协议定义 Client SDK dispatch() handler
/workflow DEFAULT_WORKFLOW_ROUTES (5 endpoints) client.workflow.* ❌ 缺失
/realtime DEFAULT_REALTIME_ROUTES (6 endpoints) client.realtime.* ❌ 缺失
/notifications DEFAULT_NOTIFICATION_ROUTES (7 endpoints) client.notifications.* ❌ 缺失
/ai DEFAULT_AI_ROUTES (4 endpoints) client.ai.* ❌ 缺失

这 4 个与 i18n 属于同类问题(discovery 中已广播、协议中有定义、客户端有调用,但 dispatcher 未实现),建议分别创建 cross-repo issue 跟踪。

@hotlong
hotlong marked this pull request as ready for review March 3, 2026 05:34
Copilot AI review requested due to automatic review settings March 3, 2026 05:34
@hotlong
hotlong merged commit c8f1183 into main Mar 3, 2026
4 of 5 checks passed

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds /i18n routing support to HttpDispatcher so i18n API requests no longer fall through to 404, and introduces a dedicated handleI18n() handler plus new test coverage for the i18n routing behavior.

Changes:

  • Added /i18n prefix routing in HttpDispatcher.dispatch().
  • Implemented handleI18n() with routes for locales, translations, and field labels (including a fallback when getFieldLabels is missing).
  • Added tests covering handleI18n() and dispatch integration for /i18n.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
packages/runtime/src/http-dispatcher.ts Adds /i18n dispatch routing and implements handleI18n() route handler.
packages/runtime/src/http-dispatcher.test.ts Adds a new handleI18n test suite covering routes, error cases, fallback behavior, and dispatch integration.

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

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.
Comment on lines +765 to +780
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',
});
});

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.
Comment on lines +523 to +529
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;
}
}

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

HttpDispatcher 未分发 /i18n 路径导致 i18n 相关 API 实现不一致(cross-repo)

3 participants