mirror of
https://github.com/logto-io/logto.git
synced 2025-03-03 22:15:32 -05:00
Merge pull request #104 from logto-io/gao-log-45
feat(core): detect language from querystring and header
This commit is contained in:
commit
211a5a1760
15 changed files with 156 additions and 52 deletions
|
@ -42,8 +42,8 @@
|
|||
"zod": "^3.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@logto/eslint-config": "^0.1.2",
|
||||
"@logto/ts-config": "^0.1.2",
|
||||
"@logto/eslint-config": "^0.1.3",
|
||||
"@logto/ts-config": "^0.1.3",
|
||||
"@types/jest": "^27.0.1",
|
||||
"@types/koa": "^2.13.3",
|
||||
"@types/koa-logger": "^3.1.1",
|
||||
|
|
|
@ -6,6 +6,7 @@ import koaLogger from 'koa-logger';
|
|||
|
||||
import { port } from '@/env/consts';
|
||||
import koaErrorHandler from '@/middleware/koa-error-handler';
|
||||
import koaI18next from '@/middleware/koa-i18next';
|
||||
import koaUIProxy from '@/middleware/koa-ui-proxy';
|
||||
import initOidc from '@/oidc/init';
|
||||
import initRouter from '@/routes/init';
|
||||
|
@ -13,6 +14,7 @@ import initRouter from '@/routes/init';
|
|||
export default async function initApp(app: Koa): Promise<void> {
|
||||
app.use(koaErrorHandler());
|
||||
app.use(koaLogger());
|
||||
app.use(koaI18next());
|
||||
|
||||
const provider = await initOidc(app);
|
||||
initRouter(app, provider);
|
||||
|
|
53
packages/core/src/i18n/detect-language.ts
Normal file
53
packages/core/src/i18n/detect-language.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { IncomingHttpHeaders } from 'http';
|
||||
|
||||
import { Optional } from '@logto/essentials';
|
||||
import { ParameterizedContext } from 'koa';
|
||||
import { IRouterParamContext } from 'koa-router';
|
||||
|
||||
/**
|
||||
* Resolve language and its q value from string.
|
||||
* @param languageString The language string in header, e.g. 'en-GB;q=0.8', 'zh-CN'
|
||||
* @returns `[language, q]`, e.g. `['en-GB', 0.8]`; `undefined` if no language is detected.
|
||||
*/
|
||||
const resolveLanguage = (languageString: string): Optional<[string, number]> => {
|
||||
// Edited from https://github.com/lxzxl/koa-i18next-detector/blob/master/src/lookups/header.js
|
||||
const [language, ...rest] = languageString.split(';');
|
||||
|
||||
if (!language) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of rest) {
|
||||
const [key, value] = item.split('=');
|
||||
if (key === 'q' && !Number.isNaN(value)) {
|
||||
return [language, Number(value)];
|
||||
}
|
||||
}
|
||||
|
||||
return [language, 1];
|
||||
};
|
||||
|
||||
const normalizeValueToStringArray = (value?: string | string[]): string[] => {
|
||||
if (value) {
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const detectLanguageFromHeaders = (headers: IncomingHttpHeaders): string[] =>
|
||||
headers['accept-language']
|
||||
?.split(',')
|
||||
.map((string) => resolveLanguage(string))
|
||||
.filter((value): value is NonNullable<typeof value> => Boolean(value))
|
||||
.sort((a, b) => b[1] - a[1]) // LOG-81: `.sort()` is a mutation, consider ban it later
|
||||
.map(([locale]) => locale) ?? [];
|
||||
|
||||
const detectLanguage = <StateT, ContextT extends IRouterParamContext, ResponseBodyT>(
|
||||
ctx: ParameterizedContext<StateT, ContextT, ResponseBodyT>
|
||||
): string[] => [
|
||||
...normalizeValueToStringArray(ctx.query.locale),
|
||||
...detectLanguageFromHeaders(ctx.headers),
|
||||
];
|
||||
|
||||
export default detectLanguage;
|
|
@ -3,7 +3,8 @@ import i18next from 'i18next';
|
|||
|
||||
export default async function initI18n() {
|
||||
await i18next.init({
|
||||
lng: 'en',
|
||||
fallbackLng: 'en',
|
||||
supportedLngs: Object.keys(resources),
|
||||
resources,
|
||||
});
|
||||
}
|
||||
|
|
34
packages/core/src/middleware/koa-i18next.ts
Normal file
34
packages/core/src/middleware/koa-i18next.ts
Normal file
|
@ -0,0 +1,34 @@
|
|||
import i18next from 'i18next';
|
||||
import { MiddlewareType } from 'koa';
|
||||
import { IRouterParamContext } from 'koa-router';
|
||||
|
||||
import detectLanguage from '@/i18n/detect-language';
|
||||
|
||||
interface LanguageUtils {
|
||||
formatLanguageCode(code: string): string;
|
||||
isSupportedCode(code: string): boolean;
|
||||
}
|
||||
|
||||
export type WithI18nContext<ContextT extends IRouterParamContext = IRouterParamContext> =
|
||||
ContextT & {
|
||||
locale: string;
|
||||
};
|
||||
|
||||
export default function koaI18next<
|
||||
StateT,
|
||||
ContextT extends IRouterParamContext,
|
||||
ResponseBodyT
|
||||
>(): 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
|
||||
const languageUtils = i18next.services.languageUtils as LanguageUtils;
|
||||
const foundLanguage = languages
|
||||
.map((code) => languageUtils.formatLanguageCode(code))
|
||||
.find((code) => languageUtils.isSupportedCode(code));
|
||||
|
||||
await i18next.changeLanguage(foundLanguage);
|
||||
ctx.locale = i18next.language;
|
||||
return next();
|
||||
};
|
||||
}
|
|
@ -1,5 +1,4 @@
|
|||
import { Applications } from '@logto/schemas';
|
||||
import Router from 'koa-router';
|
||||
import { object, string } from 'zod';
|
||||
|
||||
import koaGuard from '@/middleware/koa-guard';
|
||||
|
@ -11,9 +10,11 @@ import {
|
|||
} from '@/queries/application';
|
||||
import { buildIdGenerator } from '@/utils/id';
|
||||
|
||||
import { AuthedRouter } from './types';
|
||||
|
||||
const applicationId = buildIdGenerator(21);
|
||||
|
||||
export default function applicationRoutes<StateT, ContextT>(router: Router<StateT, ContextT>) {
|
||||
export default function applicationRoutes<T extends AuthedRouter>(router: T) {
|
||||
router.post(
|
||||
'/application',
|
||||
koaGuard({
|
||||
|
|
|
@ -3,20 +3,22 @@ import mount from 'koa-mount';
|
|||
import Router from 'koa-router';
|
||||
import { Provider } from 'oidc-provider';
|
||||
|
||||
import koaAuth, { WithAuthContext } from '@/middleware/koa-auth';
|
||||
import koaAuth from '@/middleware/koa-auth';
|
||||
import applicationRoutes from '@/routes/application';
|
||||
import sessionRoutes from '@/routes/session';
|
||||
import swaggerRoutes from '@/routes/swagger';
|
||||
import userRoutes from '@/routes/user';
|
||||
|
||||
import { AnonymousRouter, AuthedRouter } from './types';
|
||||
|
||||
const createRouters = (provider: Provider) => {
|
||||
const anonymousRouter = new Router();
|
||||
const anonymousRouter: AnonymousRouter = new Router();
|
||||
|
||||
sessionRoutes(anonymousRouter, provider);
|
||||
userRoutes(anonymousRouter);
|
||||
swaggerRoutes(anonymousRouter);
|
||||
|
||||
const router = new Router<unknown, WithAuthContext>();
|
||||
const router: AuthedRouter = new Router();
|
||||
router.use(koaAuth());
|
||||
applicationRoutes(router);
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { conditional } from '@logto/essentials';
|
||||
import { LogtoErrorCode } from '@logto/phrases';
|
||||
import Router from 'koa-router';
|
||||
import { Provider } from 'oidc-provider';
|
||||
import { object, string } from 'zod';
|
||||
|
||||
|
@ -10,7 +9,9 @@ import { findUserByUsername } from '@/queries/user';
|
|||
import assert from '@/utils/assert';
|
||||
import { encryptPassword } from '@/utils/password';
|
||||
|
||||
export default function sessionRoutes(router: Router, provider: Provider) {
|
||||
import { AnonymousRouter } from './types';
|
||||
|
||||
export default function sessionRoutes<T extends AnonymousRouter>(router: T, provider: Provider) {
|
||||
router.post(
|
||||
'/session',
|
||||
koaGuard({ body: object({ username: string().optional(), password: string().optional() }) }),
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import Router, { IMiddleware } from 'koa-router';
|
||||
import { IMiddleware } from 'koa-router';
|
||||
import { OpenAPIV3 } from 'openapi-types';
|
||||
|
||||
import { isGuardMiddleware, WithGuardConfig } from '@/middleware/koa-guard';
|
||||
import { toTitle } from '@/utils/string';
|
||||
import { zodTypeToSwagger } from '@/utils/zod';
|
||||
|
||||
export default function swaggerRoutes(router: Router) {
|
||||
import { AnonymousRouter } from './types';
|
||||
|
||||
export default function swaggerRoutes<T extends AnonymousRouter>(router: T) {
|
||||
router.get('/swagger.json', async (ctx, next) => {
|
||||
const routes = ctx.router.stack.map(({ path, stack, methods }) => {
|
||||
const guard = stack.find((function_): function_ is WithGuardConfig<IMiddleware> =>
|
||||
|
|
7
packages/core/src/routes/types.ts
Normal file
7
packages/core/src/routes/types.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import Router from 'koa-router';
|
||||
|
||||
import { WithAuthContext } from '@/middleware/koa-auth';
|
||||
import { WithI18nContext } from '@/middleware/koa-i18next';
|
||||
|
||||
export type AnonymousRouter = Router<unknown, WithI18nContext>;
|
||||
export type AuthedRouter = Router<unknown, WithAuthContext<WithI18nContext>>;
|
|
@ -1,5 +1,4 @@
|
|||
import { PasswordEncryptionMethod } from '@logto/schemas';
|
||||
import Router from 'koa-router';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { object, string } from 'zod';
|
||||
|
||||
|
@ -9,6 +8,8 @@ import { hasUser, hasUserWithId, insertUser } from '@/queries/user';
|
|||
import { buildIdGenerator } from '@/utils/id';
|
||||
import { encryptPassword } from '@/utils/password';
|
||||
|
||||
import { AnonymousRouter } from './types';
|
||||
|
||||
const userId = buildIdGenerator(12);
|
||||
|
||||
const generateUserId = async (maxRetries = 500) => {
|
||||
|
@ -23,7 +24,7 @@ const generateUserId = async (maxRetries = 500) => {
|
|||
throw new Error('Cannot generate user ID in reasonable retries');
|
||||
};
|
||||
|
||||
export default function userRoutes(router: Router) {
|
||||
export default function userRoutes<T extends AnonymousRouter>(router: T) {
|
||||
router.post(
|
||||
'/user',
|
||||
koaGuard({
|
||||
|
|
|
@ -25,8 +25,8 @@
|
|||
"url": "https://github.com/logto-io/logto/issues"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@logto/eslint-config": "^0.1.2",
|
||||
"@logto/ts-config": "^0.1.2",
|
||||
"@logto/eslint-config": "^0.1.3",
|
||||
"@logto/ts-config": "^0.1.3",
|
||||
"eslint": "^7.32.0",
|
||||
"lint-staged": "^11.1.1",
|
||||
"prettier": "^2.3.2",
|
||||
|
|
|
@ -21,9 +21,9 @@
|
|||
"node": ">=14.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@logto/eslint-config": "^0.1.2",
|
||||
"@logto/eslint-config": "^0.1.3",
|
||||
"@logto/essentials": "^1.1.0-rc.2",
|
||||
"@logto/ts-config": "^0.1.2",
|
||||
"@logto/ts-config": "^0.1.3",
|
||||
"@types/lodash.uniq": "^4.5.6",
|
||||
"@types/node": "14",
|
||||
"@types/pluralize": "^0.0.29",
|
||||
|
|
|
@ -31,10 +31,10 @@
|
|||
"devDependencies": {
|
||||
"@babel/core": "^7.14.6",
|
||||
"@jest/types": "^27.0.6",
|
||||
"@logto/eslint-config": "^0.1.2",
|
||||
"@logto/eslint-config-react": "^0.1.2",
|
||||
"@logto/ts-config": "^0.1.2",
|
||||
"@logto/ts-config-react": "^0.1.2",
|
||||
"@logto/eslint-config": "^0.1.3",
|
||||
"@logto/eslint-config-react": "^0.1.3",
|
||||
"@logto/ts-config": "^0.1.3",
|
||||
"@logto/ts-config-react": "^0.1.3",
|
||||
"@testing-library/react": "^12.0.0",
|
||||
"@types/jest": "^26.0.24",
|
||||
"@types/react": "^17.0.14",
|
||||
|
|
60
pnpm-lock.yaml
generated
60
pnpm-lock.yaml
generated
|
@ -20,11 +20,11 @@ importers:
|
|||
|
||||
packages/core:
|
||||
specifiers:
|
||||
'@logto/eslint-config': ^0.1.2
|
||||
'@logto/eslint-config': ^0.1.3
|
||||
'@logto/essentials': ^1.1.0-rc.2
|
||||
'@logto/phrases': ^0.1.0
|
||||
'@logto/schemas': ^0.1.0
|
||||
'@logto/ts-config': ^0.1.2
|
||||
'@logto/ts-config': ^0.1.3
|
||||
'@types/jest': ^27.0.1
|
||||
'@types/koa': ^2.13.3
|
||||
'@types/koa-logger': ^3.1.1
|
||||
|
@ -90,8 +90,8 @@ importers:
|
|||
slonik-interceptor-preset: 1.2.10
|
||||
zod: 3.8.1
|
||||
devDependencies:
|
||||
'@logto/eslint-config': 0.1.2_aff669e8eb0d21fc4e2068e6112ef4d0
|
||||
'@logto/ts-config': 0.1.2_typescript@4.3.5
|
||||
'@logto/eslint-config': 0.1.3_aff669e8eb0d21fc4e2068e6112ef4d0
|
||||
'@logto/ts-config': 0.1.3_typescript@4.3.5
|
||||
'@types/jest': 27.0.1
|
||||
'@types/koa': 2.13.4
|
||||
'@types/koa-logger': 3.1.1
|
||||
|
@ -113,15 +113,15 @@ importers:
|
|||
|
||||
packages/phrases:
|
||||
specifiers:
|
||||
'@logto/eslint-config': ^0.1.2
|
||||
'@logto/ts-config': ^0.1.2
|
||||
'@logto/eslint-config': ^0.1.3
|
||||
'@logto/ts-config': ^0.1.3
|
||||
eslint: ^7.32.0
|
||||
lint-staged: ^11.1.1
|
||||
prettier: ^2.3.2
|
||||
typescript: ^4.3.5
|
||||
devDependencies:
|
||||
'@logto/eslint-config': 0.1.2_aff669e8eb0d21fc4e2068e6112ef4d0
|
||||
'@logto/ts-config': 0.1.2_typescript@4.3.5
|
||||
'@logto/eslint-config': 0.1.3_aff669e8eb0d21fc4e2068e6112ef4d0
|
||||
'@logto/ts-config': 0.1.3_typescript@4.3.5
|
||||
eslint: 7.32.0
|
||||
lint-staged: 11.1.1
|
||||
prettier: 2.3.2
|
||||
|
@ -129,10 +129,10 @@ importers:
|
|||
|
||||
packages/schemas:
|
||||
specifiers:
|
||||
'@logto/eslint-config': ^0.1.2
|
||||
'@logto/eslint-config': ^0.1.3
|
||||
'@logto/essentials': ^1.1.0-rc.2
|
||||
'@logto/phrases': ^0.1.0
|
||||
'@logto/ts-config': ^0.1.2
|
||||
'@logto/ts-config': ^0.1.3
|
||||
'@types/lodash.uniq': ^4.5.6
|
||||
'@types/node': '14'
|
||||
'@types/pluralize': ^0.0.29
|
||||
|
@ -148,9 +148,9 @@ importers:
|
|||
dependencies:
|
||||
'@logto/phrases': link:../phrases
|
||||
devDependencies:
|
||||
'@logto/eslint-config': 0.1.2_aff669e8eb0d21fc4e2068e6112ef4d0
|
||||
'@logto/eslint-config': 0.1.3_aff669e8eb0d21fc4e2068e6112ef4d0
|
||||
'@logto/essentials': 1.1.0-rc.2
|
||||
'@logto/ts-config': 0.1.2_typescript@4.3.5
|
||||
'@logto/ts-config': 0.1.3_typescript@4.3.5
|
||||
'@types/lodash.uniq': 4.5.6
|
||||
'@types/node': 14.17.6
|
||||
'@types/pluralize': 0.0.29
|
||||
|
@ -168,12 +168,12 @@ importers:
|
|||
specifiers:
|
||||
'@babel/core': ^7.14.6
|
||||
'@jest/types': ^27.0.6
|
||||
'@logto/eslint-config': ^0.1.2
|
||||
'@logto/eslint-config-react': ^0.1.2
|
||||
'@logto/eslint-config': ^0.1.3
|
||||
'@logto/eslint-config-react': ^0.1.3
|
||||
'@logto/phrases': ^0.1.0
|
||||
'@logto/schemas': ^0.1.0
|
||||
'@logto/ts-config': ^0.1.2
|
||||
'@logto/ts-config-react': ^0.1.2
|
||||
'@logto/ts-config': ^0.1.3
|
||||
'@logto/ts-config-react': ^0.1.3
|
||||
'@testing-library/react': ^12.0.0
|
||||
'@types/jest': ^26.0.24
|
||||
'@types/react': ^17.0.14
|
||||
|
@ -218,10 +218,10 @@ importers:
|
|||
devDependencies:
|
||||
'@babel/core': 7.14.8
|
||||
'@jest/types': 27.0.6
|
||||
'@logto/eslint-config': 0.1.2_aff669e8eb0d21fc4e2068e6112ef4d0
|
||||
'@logto/eslint-config-react': 0.1.2_8e322dd0e62beacbfb7b944fe3d15c43
|
||||
'@logto/ts-config': 0.1.2_typescript@4.3.5
|
||||
'@logto/ts-config-react': 0.1.2_typescript@4.3.5
|
||||
'@logto/eslint-config': 0.1.3_aff669e8eb0d21fc4e2068e6112ef4d0
|
||||
'@logto/eslint-config-react': 0.1.3_8e322dd0e62beacbfb7b944fe3d15c43
|
||||
'@logto/ts-config': 0.1.3_typescript@4.3.5
|
||||
'@logto/ts-config-react': 0.1.3_typescript@4.3.5
|
||||
'@testing-library/react': 12.0.0_react-dom@17.0.2+react@17.0.2
|
||||
'@types/jest': 26.0.24
|
||||
'@types/react': 17.0.15
|
||||
|
@ -2792,12 +2792,12 @@ packages:
|
|||
write-file-atomic: 3.0.3
|
||||
dev: true
|
||||
|
||||
/@logto/eslint-config-react/0.1.2_8e322dd0e62beacbfb7b944fe3d15c43:
|
||||
resolution: {integrity: sha512-8bBRmfv6zg+W9vFLhBidX5qTFxtz2eKaCNCz5+Ax3KGPKQ+aBNOjULZ8ZO5Xt28+EvQktEMUBEm6P5Pq5vlboQ==}
|
||||
/@logto/eslint-config-react/0.1.3_8e322dd0e62beacbfb7b944fe3d15c43:
|
||||
resolution: {integrity: sha512-TGQ10/SPpT18KgL9oqX1YDeS3JPnaUdAVViXps+CaDNfuoIkStytnLKKeNqt4Jb9YeEo69345zsk0pdFoERoMw==}
|
||||
peerDependencies:
|
||||
stylelint: ^13.13.1
|
||||
dependencies:
|
||||
'@logto/eslint-config': 0.1.2_aff669e8eb0d21fc4e2068e6112ef4d0
|
||||
'@logto/eslint-config': 0.1.3_aff669e8eb0d21fc4e2068e6112ef4d0
|
||||
eslint-config-xo-react: 0.25.0_34cd3168eeae4de23db8343b5dfd9fdd
|
||||
eslint-plugin-react: 7.25.0_eslint@7.32.0
|
||||
eslint-plugin-react-hooks: 4.2.0_eslint@7.32.0
|
||||
|
@ -2810,8 +2810,8 @@ packages:
|
|||
- typescript
|
||||
dev: true
|
||||
|
||||
/@logto/eslint-config/0.1.2_aff669e8eb0d21fc4e2068e6112ef4d0:
|
||||
resolution: {integrity: sha512-RnyBvzZjm6osGkKPMwZ30mZCd0jsOKCg43dMeoQsrnZIQAbdNKn0TiKx5VHQITOyq2gwVnugWIKtKU7vK5Vt7g==}
|
||||
/@logto/eslint-config/0.1.3_aff669e8eb0d21fc4e2068e6112ef4d0:
|
||||
resolution: {integrity: sha512-Pv7sZHX/blbACaCzeg5ZxD6QNPg0J8wirwhyONVvAtiVPvIhCuIuygJF4xqhsuXUTl4ebEycbHPhbJHQGZRTTQ==}
|
||||
engines: {node: '>=14.15.0'}
|
||||
peerDependencies:
|
||||
eslint: ^7.32.0
|
||||
|
@ -2846,18 +2846,18 @@ packages:
|
|||
lodash.orderby: 4.6.0
|
||||
lodash.pick: 4.4.0
|
||||
|
||||
/@logto/ts-config-react/0.1.2_typescript@4.3.5:
|
||||
resolution: {integrity: sha512-Ivwce+4w5mOi1FdfUdgVElAd19iieD6r9LKUqgePipuCc9jJpVxeLc9xHVxxhaAohJdSd0rLoRICJhCzqZBwlA==}
|
||||
/@logto/ts-config-react/0.1.3_typescript@4.3.5:
|
||||
resolution: {integrity: sha512-whl0l8jRwSSBfoJ6kV1Kx0zvxvkPlf3BhHlC3u68HsxmK2Z0VJi+aIM/uaJ6pQfsMTTayEVmVAE2mZBHV1ez3g==}
|
||||
engines: {node: '>=14.15.0'}
|
||||
peerDependencies:
|
||||
typescript: ^4.3.5
|
||||
dependencies:
|
||||
'@logto/ts-config': 0.1.2_typescript@4.3.5
|
||||
'@logto/ts-config': 0.1.3_typescript@4.3.5
|
||||
typescript: 4.3.5
|
||||
dev: true
|
||||
|
||||
/@logto/ts-config/0.1.2_typescript@4.3.5:
|
||||
resolution: {integrity: sha512-aJZAhaQIVKxG1Ixexj/ksUTNhJVcE10EZHczF47bwsNGlRYR4FBHATca3S5bgEVnfRIJnR3uPwpbKnJu7TvM+w==}
|
||||
/@logto/ts-config/0.1.3_typescript@4.3.5:
|
||||
resolution: {integrity: sha512-AN3RBa2P0zVtRhwvLfHbUdco1II421iwHYAnshlqpKSbnu++lnYGfA+EYg6p5F6bKaQ8beYRakKqBUlVDtyGug==}
|
||||
engines: {node: '>=14.15.0'}
|
||||
peerDependencies:
|
||||
typescript: ^4.3.5
|
||||
|
|
Loading…
Add table
Reference in a new issue