Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 5 additions & 0 deletions .changeset/lucky-spoons-itch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@logto/core": patch
---

fix API error messages falling back to English instead of a supported base language for regional language tags
Comment thread
charIeszhao marked this conversation as resolved.
Outdated
83 changes: 67 additions & 16 deletions packages/core/src/middleware/koa-i18next.test.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,82 @@
import { pickDefault, createMockUtils } from '@logto/shared/esm';
import resources from '@logto/phrases';

import RequestError from '#src/errors/RequestError/index.js';
import initI18n from '#src/i18n/init.js';
import { i18next } from '#src/utils/i18n.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';

const { jest } = import.meta;
const { mockEsmDefault } = createMockUtils(jest);

const mockLanguage = () => ['zh-cn'];
mockEsmDefault('#src/i18n/detect-language.js', () => mockLanguage);
import koaI18next from './koa-i18next.js';

const initI18n = await pickDefault(import('#src/i18n/init.js'));
const koaI18next = await pickDefault(import('./koa-i18next.js'));
const cloneInstanceSpy = jest.spyOn(i18next, 'cloneInstance');
const { jest } = import.meta;

describe('koaI18next', () => {
const next = jest.fn();

it('detect language', async () => {
beforeAll(async () => {
await initI18n();
});

afterEach(() => {
jest.clearAllMocks();
});

it.each([
['zh-cn', 'zh-CN'],
['es-MX', 'es'],
['fr-CA', 'fr'],
['pt-BR', 'pt-BR'],
['pt-PT', 'pt-PT'],
['pl', 'pl-PL'],
['en;q=0.5,es-MX;q=0.9', 'es'],
['es-MX;q=0.5,fr;q=0.9', 'fr'],
['xx-XX,fr-CA;q=0.9', 'fr'],
['xx-XX', 'en'],
['*', 'en'],
['', 'en'],
] as const)('localizes API errors for Accept-Language %s to %s', async (language, expected) => {
const ctx = {
...createContextWithRouteParameters(),
...createContextWithRouteParameters({ headers: { 'accept-language': language } }),
query: {},
i18n: i18next,
locale: '',
};
await initI18n();

await koaI18next()(ctx, next);

expect(ctx.locale).toEqual(expected);
expect(new RequestError('session.not_found').toBody(ctx.i18n)).toMatchObject({
code: 'session.not_found',
message: resources[expected].errors?.session?.not_found,
});
expect(next).toHaveBeenCalledTimes(1);
});

it('prioritizes the locale query over Accept-Language', async () => {
const ctx = {
...createContextWithRouteParameters({ headers: { 'accept-language': 'es' } }),
query: { locale: 'fr-CA' },
};

await koaI18next()(ctx, next);
expect(ctx.locale).toEqual('zh-CN');
expect(cloneInstanceSpy).toBeCalled();

expect(ctx.locale).toEqual('fr');
});

it('keeps the language scoped to each request', async () => {
const spanishContext = {
...createContextWithRouteParameters({ headers: { 'accept-language': 'es-MX' } }),
query: {},
};
const frenchContext = {
...createContextWithRouteParameters({ headers: { 'accept-language': 'fr-CA' } }),
query: {},
};

await koaI18next()(spanishContext, next);
await koaI18next()(frenchContext, next);

expect(spanishContext.i18n.language).toEqual('es');
expect(frenchContext.i18n.language).toEqual('fr');
expect(spanishContext.i18n).not.toBe(frenchContext.i18n);
expect(spanishContext.i18n).not.toBe(i18next);
expect(i18next.language).toEqual('en');
});
});
14 changes: 3 additions & 11 deletions packages/core/src/middleware/koa-i18next.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import { findSupportedLanguageTag } from '@logto/language-kit';
import { builtInLanguages } from '@logto/phrases';
import type { i18n } from 'i18next';
import type { MiddlewareType } from 'koa';
import type { IRouterParamContext } from 'koa-router';

import detectLanguage from '#src/i18n/detect-language.js';
import { i18next } from '#src/utils/i18n.js';

type LanguageUtils = {
formatLanguageCode(code: string): string;
isSupportedCode(code: string): boolean;
};

export type WithI18nContext<ContextT extends IRouterParamContext = IRouterParamContext> =
ContextT & {
locale: string;
Expand All @@ -23,12 +20,7 @@ export default function koaI18next<
>(): MiddlewareType<StateT, WithI18nContext<ContextT>, ResponseBodyT> {
return async (ctx, next) => {
const languages = detectLanguage(ctx);
// Cannot patch type def directly, see https://github.com/microsoft/TypeScript/issues/36146
// eslint-disable-next-line no-restricted-syntax
const languageUtils = i18next.services.languageUtils as LanguageUtils;
const foundLanguage = languages
.map((code) => languageUtils.formatLanguageCode(code))
.find((code) => languageUtils.isSupportedCode(code));
const foundLanguage = findSupportedLanguageTag(languages, builtInLanguages);

// Async requests may change the language, so we need to clone a new instance instead of directly updating
// the global i18next instance. Keep the i18n context scoped to the request.
Expand Down
Loading