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
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
---

use a supported base language for API error messages when the requested regional language is unavailable
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