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
10 changes: 10 additions & 0 deletions .changeset/30563-driver-catalog-requester-language.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@objectstack/service-datasource': patch
---

fix(service-datasource): the datasource driver catalog serves its words in the requester's language, so the New datasource dialog reads in one language

Clause-②: no
Metadata-protocol: none

`GET /api/v1/datasources/drivers` now serves each driver's label and description, and each connection field's `title` and `description` (Filename, Auto-migrate schema, Host, Port, …), in the language the request names: the top `Accept-Language` tag, else `?locale=`. The code is read through the platform locale table (`platformLocaleTagOf`, shortened one subtag at a time), so `zh-TW` and `zh-HK` get Traditional Chinese, `zh`, `zh-CN` and `zh-SG` Simplified, `pt` Brazilian Portuguese and `fr-CA` French. Every language on the table is translated for every offered driver: Simplified and Traditional Chinese, Spanish, Arabic, French, Brazilian Portuguese, Russian, German, Japanese and Korean. English requests, codes the table does not name, and product names (SQLite, PostgreSQL, MySQL / MariaDB, MongoDB) are served the English text, never a key. `DRIVER_CATALOG` stays the English catalog, and the new `driverCatalogFor(locale)` in the package source builds the localized one; ids, icons, types and defaults are unchanged in every language.
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,47 @@ describe('registerDatasourceAdminRoutes (real HonoHttpServer)', () => {
expect(sqlite!.configSchema?.properties?.filename?.type).toBe('string');
});

it('GET /api/v1/datasources/drivers serves the words in the requester\'s language', async () => {
const app = mount({});
type Served = { data: { drivers: Array<{ id: string; label: string; description?: string; configSchema: any }> } };
const read = async (init?: RequestInit, path = '/api/v1/datasources/drivers') => {
const res = await app.fetch(json(path, init));
expect(res.status).toBe(200);
const drivers = ((await res.json()) as Served).data.drivers;
return (id: string) => drivers.find((d) => d.id === id)!;
};

// The console sends its language as `Accept-Language` on every API call.
const zh = await read({ headers: { 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8' } });
expect(zh('sqlite').configSchema.properties.filename.title).toBe('文件名');
expect(zh('sqlite').configSchema.properties.autoMigrate.title).toBe('自动迁移表结构');
expect(zh('postgres').configSchema.properties.host.title).toBe('主机');
expect(zh('postgres').configSchema.properties.port.title).toBe('端口');
expect(zh('mysql').description).toBe('MySQL 或 MariaDB 连接。');
// A word the locale does not translate is the English one, not a key.
expect(zh('sqlite').label).toBe('SQLite');

// `?locale=` when no header names a language.
const query = await read(undefined, '/api/v1/datasources/drivers?locale=zh-CN');
expect(query('sqlite').configSchema.properties.filename.title).toBe('文件名');

// A Traditional Chinese reader gets Traditional, never the Simplified text.
const zhTW = await read({ headers: { 'accept-language': 'zh-TW,zh;q=0.9' } });
expect(zhTW('sqlite').configSchema.properties.filename.title).toBe('檔案名稱');
expect(zhTW('postgres').configSchema.properties.port.title).toBe('連接埠');

// Every other language on the platform table is served too.
const fr = await read({ headers: { 'accept-language': 'fr-FR,fr;q=0.9' } });
expect(fr('sqlite').configSchema.properties.filename.title).toBe('Nom de fichier');

// English, and a language the platform table does not name, are served the English words.
for (const lang of ['en-US', 'it-IT']) {
const en = await read({ headers: { 'accept-language': lang } });
expect(en('sqlite').configSchema.properties.filename.title, lang).toBe('Filename');
expect(en('postgres').configSchema.properties.host.title, lang).toBe('Host');
}
});

it('GET /api/v1/datasources/:name/remote-tables lists remote tables', async () => {
const listRemoteTables = vi.fn().mockResolvedValue([{ name: 'customers', columnCount: 4 }]);
const app = mount({ listRemoteTables });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ import {
type BuiltinDriverId,
} from '@objectstack/spec/data';

import { DRIVER_CATALOG } from '../driver-catalog.js';
import { PLATFORM_LOCALE_TAGS } from '@objectstack/spec/system';

import { DRIVER_CATALOG, driverCatalogFor, driverCatalogLocaleOf } from '../driver-catalog.js';
import { DRIVER_CATALOG_TRANSLATIONS } from '../driver-catalog-translations.js';

describe('DRIVER_CATALOG', () => {
it('serves the spec projection for every offered driver', () => {
Expand Down Expand Up @@ -82,3 +85,172 @@ describe('DRIVER_CATALOG', () => {
}
});
});

type JsonProp = { title?: string; description?: string; type?: string };
const propsOf = (entry: { configSchema: Record<string, unknown> }) =>
(entry.configSchema as { properties: Record<string, JsonProp> }).properties;
const entryOf = (catalog: typeof DRIVER_CATALOG, id: string) => {
const entry = catalog.find((d) => d.id === id);
if (!entry) throw new Error(`no ${id} entry`);
return entry;
};
const filenameTitle = (tag: string | undefined) => propsOf(entryOf(driverCatalogFor(tag), 'sqlite')).filename.title;

/** The labels that are product names, served as written in every language. */
const PRODUCT_NAME_LABELS = new Set(['sqlite', 'postgres', 'mysql', 'mongodb']);

/**
* The catalog's words in the requester's language. The New datasource dialog
* draws exactly what the drivers route serves and has no key of its own for
* these words, so a Chinese console read `Filename`, `Auto-migrate schema`,
* `Host` and `Port` in English until the route served them translated.
*/
describe('driverCatalogFor', () => {
it('serves Simplified Chinese words for SQLite and the network drivers', () => {
const zh = driverCatalogFor('zh-CN');

const sqlite = entryOf(zh, 'sqlite');
expect(sqlite.description).toBe('基于文件(或内存)的 SQL 数据库,适合本地开发和小型部署。');
expect(propsOf(sqlite).filename.title).toBe('文件名');
expect(propsOf(sqlite).filename.description).toBe('数据库文件路径,或填 ":memory:" 使用临时内存数据库');
expect(propsOf(sqlite).autoMigrate.title).toBe('自动迁移表结构');

const postgres = entryOf(zh, 'postgres');
expect(propsOf(postgres).host).toMatchObject({ title: '主机', description: '主机地址' });
expect(propsOf(postgres).port).toMatchObject({ title: '端口', description: '端口号' });
expect(propsOf(postgres).password.title).toBe('密码');

expect(propsOf(entryOf(zh, 'mysql')).host.title).toBe('主机');
expect(propsOf(entryOf(zh, 'mongodb')).port.title).toBe('端口');
expect(entryOf(zh, 'memory').label).toBe('内存');
});

it('serves a word of its own in every language on the platform table', () => {
const expected: Record<string, string> = {
'zh-Hans': '文件名',
'zh-Hant': '檔案名稱',
es: 'Nombre de archivo',
ar: 'اسم الملف',
fr: 'Nom de fichier',
'pt-BR': 'Nome do arquivo',
ru: 'Имя файла',
de: 'Dateiname',
ja: 'ファイル名',
ko: '파일 이름',
};
expect(Object.keys(expected).sort()).toEqual(PLATFORM_LOCALE_TAGS.filter((t) => t !== 'en').sort());
for (const [tag, title] of Object.entries(expected)) expect(filenameTitle(tag), tag).toBe(title);
});

it('reads Traditional Chinese codes as Traditional, never Simplified', () => {
for (const tag of ['zh-TW', 'zh-HK', 'zh-MO', 'zh-Hant', 'zh-hant', 'zh-Hant-TW', 'zh-Hant-HK']) {
expect(driverCatalogLocaleOf(tag), tag).toBe('zh-Hant');
expect(filenameTitle(tag), tag).toBe('檔案名稱');
}
for (const tag of ['zh', 'zh-CN', 'zh-cn', 'zh-SG', 'zh-Hans', 'zh-Hans-CN']) {
expect(driverCatalogLocaleOf(tag), tag).toBe('zh-Hans');
expect(filenameTitle(tag), tag).toBe('文件名');
}
});

it('reads aliases and regional codes through the platform table', () => {
const cases: Array<[string, string]> = [
['ja-JP', 'ja'], ['es-ES', 'es'], ['es-MX', 'es'], ['pt', 'pt-BR'], ['pt-PT', 'pt-BR'],
['fr-CA', 'fr'], ['de-AT', 'de'], ['ko-KR', 'ko'], ['ar-EG', 'ar'], ['ru-RU', 'ru'],
];
for (const [code, tag] of cases) expect(driverCatalogLocaleOf(code), code).toBe(tag);
});

it('keeps everything but the words: ids, icons, types and defaults are the English entry\'s', () => {
for (const tag of PLATFORM_LOCALE_TAGS) {
const served = driverCatalogFor(tag);
expect(served.map((d) => [d.id, d.icon]), tag).toEqual(DRIVER_CATALOG.map((d) => [d.id, d.icon]));
for (const entry of served) {
const english = propsOf(entryOf(DRIVER_CATALOG, entry.id));
const translated = propsOf(entry);
expect(Object.keys(translated), `${tag}.${entry.id}`).toEqual(Object.keys(english));
for (const key of Object.keys(english)) {
const { title: _t, description: _d, ...rest } = translated[key];
const { title: _et, description: _ed, ...englishRest } = english[key];
expect(rest, `${tag}.${entry.id}.${key}`).toEqual(englishRest);
}
}
}
});

it('serves English for no locale, English, or a code the platform table does not name', () => {
for (const tag of [undefined, '', 'en', 'en-US', 'en-GB', 'it', 'nl-NL', 'constructor', '__proto__']) {
expect(driverCatalogFor(tag), String(tag)).toBe(DRIVER_CATALOG);
}
expect(propsOf(entryOf(DRIVER_CATALOG, 'sqlite')).filename.title).toBe('Filename');
expect(propsOf(entryOf(DRIVER_CATALOG, 'postgres')).host.title).toBe('Host');
});

it('falls back to the English word where a language has none, never to a key', () => {
const zh = driverCatalogFor('zh-CN');
// Product names are not translated: the English label is served.
expect(entryOf(zh, 'sqlite').label).toBe('SQLite');
expect(entryOf(zh, 'postgres').label).toBe('PostgreSQL');
// The memory driver's fields carry a description and no title in English;
// the translation adds no title, so the form still names the field by key.
expect(propsOf(entryOf(zh, 'memory')).strictMode.title).toBeUndefined();
expect(propsOf(entryOf(zh, 'memory')).strictMode.description).toBe('记录不存在时抛出错误,而不是返回 null');
});

it('never writes the spec projection it overlays', () => {
for (const tag of PLATFORM_LOCALE_TAGS) driverCatalogFor(tag);
const sqlite = getDriverConfigJsonSchemaById('sqlite') as { properties: Record<string, JsonProp> };
expect(sqlite.properties.filename.title).toBe('Filename');
expect(propsOf(entryOf(DRIVER_CATALOG, 'sqlite')).filename.title).toBe('Filename');
expect(entryOf(DRIVER_CATALOG, 'memory').label).toBe('In-Memory');
});
});

/**
* Every language on the platform locale table against the served shape. It
* walks `PLATFORM_LOCALE_TAGS`, not the translation table's own keys, so a
* language the table lacks goes red here: the platform has one list of
* languages, each complete. A driver or field a language names must exist, so
* a renamed field cannot leave a translation that silently stops applying; and
* every word the English form shows must be translated, so a field the spec
* adds goes red here rather than reading English in a translated dialog.
*/
describe('DRIVER_CATALOG_TRANSLATIONS coverage', () => {
const offered = new Set(DRIVER_CATALOG.map((d) => d.id));
const languages = PLATFORM_LOCALE_TAGS.filter((tag) => tag !== 'en');

it('carries every non-English language on the platform table, and nothing else', () => {
expect(PLATFORM_LOCALE_TAGS).toContain('en');
expect(Object.keys(DRIVER_CATALOG_TRANSLATIONS).sort()).toEqual([...languages].sort());
});

for (const locale of languages) {
it(`${locale}: names only drivers and fields the catalog serves`, () => {
const table = DRIVER_CATALOG_TRANSLATIONS[locale];
expect(table, locale).toBeDefined();
for (const [id, text] of Object.entries(table ?? {})) {
expect(offered.has(id), `${locale}.${id}`).toBe(true);
const props = propsOf(entryOf(DRIVER_CATALOG, id));
for (const key of Object.keys(text?.fields ?? {})) {
expect(Object.prototype.hasOwnProperty.call(props, key), `${locale}.${id}.fields.${key}`).toBe(true);
}
}
});

it(`${locale}: translates every word the English form shows`, () => {
const table = DRIVER_CATALOG_TRANSLATIONS[locale];
for (const entry of DRIVER_CATALOG) {
const text = table?.[entry.id as keyof typeof table];
expect(text?.description, `${locale}.${entry.id}.description`).toBeTruthy();
if (!PRODUCT_NAME_LABELS.has(entry.id)) expect(text?.label, `${locale}.${entry.id}.label`).toBeTruthy();
for (const [key, prop] of Object.entries(propsOf(entry))) {
const field = text?.fields?.[key];
if (prop.title !== undefined) expect(field?.title, `${locale}.${entry.id}.${key}.title`).toBeTruthy();
if (prop.description !== undefined) {
expect(field?.description, `${locale}.${entry.id}.${key}.description`).toBeTruthy();
}
}
}
});
}
});
19 changes: 17 additions & 2 deletions packages/services/service-datasource/src/admin-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
} from '@objectstack/core';
import type { TenancyPosture } from '@objectstack/spec/security';
import type { ErrorCode } from '@objectstack/spec/api';
import { preferredLocaleFromHeader } from '@objectstack/spec/system';
// The slots this module resolves, by their declared contracts. Erasing a
// lookup to `any` is banned (#4127/#4176/#4202/#4251) and the ban is right
// here: `IAuthService` is what declares BOTH shapes of the session accessor
Expand All @@ -44,7 +45,7 @@ import type { ErrorCode } from '@objectstack/spec/api';
import type { IAuthService, IDataEngine, IHttpServer } from '@objectstack/spec/contracts';
// The declared envelope is written in ONE place for the whole platform (#3973).
import { inProcessSessionReadInput, sendOk, sendError } from '@objectstack/types';
import { DRIVER_CATALOG } from './driver-catalog.js';
import { driverCatalogFor } from './driver-catalog.js';
import { DRIVER_CATALOG_SEGMENT } from './datasource-route-reserved-names.js';
// [#27618] The package's one "no such datasource" refusal: GET answers an
// unknown name in its words, as every other by-name door does.
Expand Down Expand Up @@ -201,6 +202,19 @@ function toWebHeaders(raw: unknown): Headers | undefined {
return undefined;
}

/**
* The requester's display language, read the way the REST metadata door and
* the settings routes read it: the top `Accept-Language` tag, else a string
* `?locale=`. The console sends its own language as `Accept-Language` on every
* API call. It picks the language of served words only and authorizes nothing.
*/
function requestLocaleOf(req: { headers?: unknown; query?: Record<string, unknown> } | undefined): string | undefined {
const preferred = preferredLocaleFromHeader(toWebHeaders(req?.headers)?.get('accept-language'));
if (preferred) return preferred;
const query = req?.query?.locale;
return typeof query === 'string' && query.length > 0 ? query : undefined;
}

/**
* A `getSession(headers)` bound to the kernel's `auth` service, or `undefined`
* when this deployment registers none.
Expand Down Expand Up @@ -705,9 +719,10 @@ export function registerDatasourceAdminRoutes(
// Catalog of connection drivers + their JSON-Schema config (drives the
// Studio connection form). Static metadata — no service dependency, so it
// is always available even before any datasource-admin service is wired.
// Its words are served in the requester's language (`driverCatalogFor`).
server.get(`${root}/${DRIVER_CATALOG_SEGMENT}`, async (req: any, res: any) => {
if (await requireDatasourceAdmin(req, res)) return;
sendOk(res, { drivers: DRIVER_CATALOG });
sendOk(res, { drivers: driverCatalogFor(requestLocaleOf(req)) });
});

// Read-only schema introspection for the Studio "sync objects" flow.
Expand Down
Loading
Loading