diff --git a/package-lock.json b/package-lock.json index acf9fcb..6087b51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2.0.1", "license": "MIT", "dependencies": { + "accept-language-parser": "^1.5.0", "body-parser": "^1.20.1", "cookie-parser": "^1.4.7", "csp-header": "^5.2.1", @@ -22,6 +23,7 @@ "@gravity-ui/eslint-config": "^3.2.0", "@gravity-ui/prettier-config": "^1.1.0", "@gravity-ui/tsconfig": "^1.0.0", + "@types/accept-language-parser": "^1.5.6", "@types/cookie-parser": "^1.4.3", "@types/express": "^4.17.21", "@types/jest": "^29.2.3", @@ -39,7 +41,7 @@ "typescript": "^5.6.2" }, "peerDependencies": { - "@gravity-ui/nodekit": "^1.5.0" + "@gravity-ui/nodekit": "^1.6.0" } }, "node_modules/@ampproject/remapping": { @@ -1125,9 +1127,9 @@ } }, "node_modules/@gravity-ui/nodekit": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@gravity-ui/nodekit/-/nodekit-1.5.0.tgz", - "integrity": "sha512-2grnX83oilMMIJ9Vj/Xp3Mmeh6Am2W9xPWb+SBVMmTk188Fz0myXpJiVm0mzP4phWOZYDUenWzgS1zppLciy0w==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@gravity-ui/nodekit/-/nodekit-1.6.0.tgz", + "integrity": "sha512-ZvGFKqGFbvNC57CN0+JQSI1pOXlzVLDG/ZFYKCjES538pvyc7LpBQydIcwV3ayXBqGMIRvqiWmRPgJaT+8EfcQ==", "peer": true, "dependencies": { "dotenv": "^16.0.3", @@ -1834,6 +1836,12 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "node_modules/@types/accept-language-parser": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/accept-language-parser/-/accept-language-parser-1.5.6.tgz", + "integrity": "sha512-lhSQUsAhAtbKjYgaw3f0c4EQKNQHFXhX87+OXUIqDHMkycvHGaqGskSRtnzysIUiqHPqNJ4BqI5SE++drsxx6A==", + "dev": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2359,6 +2367,11 @@ "node": ">=6.5" } }, + "node_modules/accept-language-parser": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/accept-language-parser/-/accept-language-parser-1.5.0.tgz", + "integrity": "sha512-QhyTbMLYo0BBGg1aWbeMG4ekWtds/31BrEU+DONOg/7ax23vxpL03Pb7/zBmha2v7vdD3AyzZVWBVGEZxKOXWw==" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", diff --git a/package.json b/package.json index d214fde..197214a 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@gravity-ui/eslint-config": "^3.2.0", "@gravity-ui/prettier-config": "^1.1.0", "@gravity-ui/tsconfig": "^1.0.0", + "@types/accept-language-parser": "^1.5.6", "@types/cookie-parser": "^1.4.3", "@types/express": "^4.17.21", "@types/jest": "^29.2.3", @@ -45,6 +46,7 @@ "typescript": "^5.6.2" }, "dependencies": { + "accept-language-parser": "^1.5.0", "body-parser": "^1.20.1", "cookie-parser": "^1.4.7", "csp-header": "^5.2.1", @@ -53,7 +55,7 @@ "uuid": "^9.0.0" }, "peerDependencies": { - "@gravity-ui/nodekit": "^1.5.0" + "@gravity-ui/nodekit": "^1.6.0" }, "nano-staged": { "*.{js,ts}": [ diff --git a/src/expresskit.ts b/src/expresskit.ts index 7fe76d1..a476f31 100644 --- a/src/expresskit.ts +++ b/src/expresskit.ts @@ -10,6 +10,7 @@ import {setupBaseMiddleware} from './base-middleware'; import {setupParsers} from './parsers'; import {setupRoutes} from './router'; import type {AppRoutes} from './types'; +import {setupLangMiddleware} from './lang/lang-middleware'; const DEFAULT_PORT = 3030; @@ -34,6 +35,7 @@ export class ExpressKit { this.express.get('/__version', (_, res) => res.send({version: this.config.appVersion})); setupBaseMiddleware(this.nodekit.ctx, this.express); + setupLangMiddleware(this.nodekit.ctx, this.express); setupParsers(this.nodekit.ctx, this.express); setupRoutes(this.nodekit.ctx, this.express, routes); diff --git a/src/lang/lang-middleware.ts b/src/lang/lang-middleware.ts new file mode 100644 index 0000000..4e7c603 --- /dev/null +++ b/src/lang/lang-middleware.ts @@ -0,0 +1,53 @@ +import {AppContext} from '@gravity-ui/nodekit'; +import acceptLanguage from 'accept-language-parser'; +import type {Express} from 'express'; +import {setLang} from './set-lang'; + +export function setupLangMiddleware(appCtx: AppContext, expressApp: Express) { + const config = appCtx.config; + + const {appDefaultLang, appAllowedLangs, appLangQueryParamName} = config; + if (!(appAllowedLangs && appAllowedLangs.length > 0 && appDefaultLang)) { + return; + } + expressApp.use((req, _res, next) => { + const langQuery = appLangQueryParamName && req.query[appLangQueryParamName]; + if (langQuery && typeof langQuery === 'string' && appAllowedLangs.includes(langQuery)) { + setLang({lang: langQuery, ctx: req.ctx}); + return next(); + } + + setLang({lang: appDefaultLang, ctx: req.ctx}); + + if (config.appGetLangByHostname) { + const langByHostname = config.appGetLangByHostname(req.hostname); + + if (langByHostname) { + setLang({lang: langByHostname, ctx: req.ctx}); + } + } else { + const tld = req.hostname.split('.').pop(); + const langByTld = tld && config.appLangByTld ? config.appLangByTld[tld] : undefined; + + if (langByTld) { + setLang({lang: langByTld, ctx: req.ctx}); + } + } + + if (req.headers['accept-language']) { + const langByHeader = acceptLanguage.pick( + appAllowedLangs, + req.headers['accept-language'], + { + loose: true, + }, + ); + + if (langByHeader) { + setLang({lang: langByHeader, ctx: req.ctx}); + } + } + + return next(); + }); +} diff --git a/src/lang/set-lang.ts b/src/lang/set-lang.ts new file mode 100644 index 0000000..d6816fa --- /dev/null +++ b/src/lang/set-lang.ts @@ -0,0 +1,12 @@ +import type {AppContext} from '@gravity-ui/nodekit'; +import {USER_LANGUAGE_PARAM_NAME} from '@gravity-ui/nodekit'; + +export const setLang = ({lang, ctx}: {lang: string; ctx: AppContext}) => { + const config = ctx.config; + if (!config.appAllowedLangs || config.appAllowedLangs.includes(lang)) { + ctx.set(USER_LANGUAGE_PARAM_NAME, lang); + return true; + } + + return false; +}; diff --git a/src/tests/lang-detect.test.ts b/src/tests/lang-detect.test.ts new file mode 100644 index 0000000..e6140e3 --- /dev/null +++ b/src/tests/lang-detect.test.ts @@ -0,0 +1,126 @@ +import {ExpressKit, Request, Response} from '..'; +import {NodeKit, USER_LANGUAGE_PARAM_NAME} from '@gravity-ui/nodekit'; +import request from 'supertest'; + +const setupApp = (langConfig: NodeKit['config'] = {}) => { + const nodekit = new NodeKit({ + config: { + appDefaultLang: 'ru', + appAllowedLangs: ['ru', 'en'], + ...langConfig, + }, + }); + const routes = { + 'GET /test': { + handler: (req: Request, res: Response) => { + res.status(200); + const lang = req.ctx.get(USER_LANGUAGE_PARAM_NAME); + res.send({lang}); + }, + }, + }; + + const app = new ExpressKit(nodekit, routes); + + return app; +}; + +describe('langMiddleware with default options', () => { + it('should set default lang if no hostname or accept-language header', async () => { + const app = setupApp(); + const res = await request.agent(app.express).get('/test'); + + expect(res.text).toBe('{"lang":"ru"}'); + expect(res.status).toBe(200); + }); + + it('should set lang for en domains by tld', async () => { + const app = setupApp({appLangByTld: {com: 'en', ru: 'ru'}}); + const res = await request.agent(app.express).host('www.foo.com').get('/test'); + + expect(res.text).toBe('{"lang":"en"}'); + expect(res.status).toBe(200); + }); + + it('should set lang for ru domains by tld ', async () => { + const app = setupApp({appLangByTld: {com: 'en', ru: 'ru'}}); + const res = await request.agent(app.express).host('www.foo.ru').get('/test'); + + expect(res.text).toBe('{"lang":"ru"}'); + expect(res.status).toBe(200); + }); + + it('should set default lang for other domains by tld ', async () => { + const app = setupApp({appLangByTld: {com: 'en', ru: 'ru'}}); + const res = await request.agent(app.express).host('www.foo.jp').get('/test'); + + expect(res.text).toBe('{"lang":"ru"}'); + expect(res.status).toBe(200); + }); +}); + +describe('langMiddleware with getLangByHostname is set', () => { + it('should set lang by known hostname if getLangByHostname is set', async () => { + const app = setupApp({ + appGetLangByHostname: (hostname) => (hostname === 'www.foo.com' ? 'en' : undefined), + }); + const res = await request.agent(app.express).host('www.foo.com').get('/test'); + + expect(res.text).toBe('{"lang":"en"}'); + expect(res.status).toBe(200); + }); + it("shouldn't set default lang for unknown hostname if getLangByHostname is set", async () => { + const app = setupApp({ + appGetLangByHostname: (hostname) => (hostname === 'www.foo.com' ? 'en' : undefined), + }); + const res = await request.agent(app.express).host('www.bar.com').get('/test'); + + expect(res.text).toBe('{"lang":"ru"}'); + expect(res.status).toBe(200); + }); +}); + +describe('langMiddleware with accept-language header', () => { + it('should set lang if known accept-language', async () => { + const app = setupApp({ + appGetLangByHostname: (hostname) => (hostname === 'www.foo.com' ? 'en' : undefined), + }); + const res = await request + .agent(app.express) + .host('www.foo.com') + .set('accept-language', 'ru-RU, ru;q=0.9, en-US;q=0.8, en;q=0.7, fr;q=0.6') + .get('/test'); + + expect(res.text).toBe('{"lang":"ru"}'); + expect(res.status).toBe(200); + }); + it('should set tld lang for unknown accept-language', async () => { + const app = setupApp({ + appGetLangByHostname: (hostname) => (hostname === 'www.foo.com' ? 'en' : undefined), + }); + const res = await request + .agent(app.express) + .host('www.foo.com') + .set('accept-language', 'fr;q=0.6') + .get('/test'); + + expect(res.text).toBe('{"lang":"en"}'); + expect(res.status).toBe(200); + }); +}); + +describe('langMiddleware with lang query param', () => { + it('should set lang if known accept-language', async () => { + const app = setupApp({ + appLangQueryParamName: '_lang', + }); + const res = await request + .agent(app.express) + .host('www.foo.com') + .set('accept-language', 'ru-RU, ru;q=0.9, en-US;q=0.8, en;q=0.7, fr;q=0.6') + .get('/test?_lang=en'); + + expect(res.text).toBe('{"lang":"en"}'); + expect(res.status).toBe(200); + }); +}); diff --git a/src/types.ts b/src/types.ts index 563a3d2..1cf7a90 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,6 +68,12 @@ declare module '@gravity-ui/nodekit' { expressCspReportOnly?: boolean; expressCspReportTo?: CSPMiddlewareParams['reportTo']; expressCspReportUri?: CSPMiddlewareParams['reportUri']; + + appAllowedLangs?: string[]; + appDefaultLang?: string; + appLangQueryParamName?: string; + appLangByTld?: Record; + appGetLangByHostname?: (hostname: string) => string | undefined; } }