Skip to content

Commit d1abc86

Browse files
authored
Migrate /translations route to core (#83280)
* move i18n route to core * add FTR test for endpoint
1 parent 66def09 commit d1abc86

File tree

11 files changed

+255
-96
lines changed

11 files changed

+255
-96
lines changed

src/core/server/i18n/i18n_service.test.mocks.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,8 @@ export const initTranslationsMock = jest.fn();
2626
jest.doMock('./init_translations', () => ({
2727
initTranslations: initTranslationsMock,
2828
}));
29+
30+
export const registerRoutesMock = jest.fn();
31+
jest.doMock('./routes', () => ({
32+
registerRoutes: registerRoutesMock,
33+
}));

src/core/server/i18n/i18n_service.test.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,18 @@
1717
* under the License.
1818
*/
1919

20-
import { getKibanaTranslationFilesMock, initTranslationsMock } from './i18n_service.test.mocks';
20+
import {
21+
getKibanaTranslationFilesMock,
22+
initTranslationsMock,
23+
registerRoutesMock,
24+
} from './i18n_service.test.mocks';
2125

2226
import { BehaviorSubject } from 'rxjs';
2327
import { I18nService } from './i18n_service';
2428

2529
import { configServiceMock } from '../config/mocks';
2630
import { mockCoreContext } from '../core_context.mock';
31+
import { httpServiceMock } from '../http/http_service.mock';
2732

2833
const getConfigService = (locale = 'en') => {
2934
const configService = configServiceMock.create();
@@ -41,21 +46,24 @@ const getConfigService = (locale = 'en') => {
4146
describe('I18nService', () => {
4247
let service: I18nService;
4348
let configService: ReturnType<typeof configServiceMock.create>;
49+
let http: ReturnType<typeof httpServiceMock.createInternalSetupContract>;
4450

4551
beforeEach(() => {
4652
jest.clearAllMocks();
4753
configService = getConfigService();
4854

4955
const coreContext = mockCoreContext.create({ configService });
5056
service = new I18nService(coreContext);
57+
58+
http = httpServiceMock.createInternalSetupContract();
5159
});
5260

5361
describe('#setup', () => {
5462
it('calls `getKibanaTranslationFiles` with the correct parameters', async () => {
5563
getKibanaTranslationFilesMock.mockResolvedValue([]);
5664

5765
const pluginPaths = ['/pathA', '/pathB'];
58-
await service.setup({ pluginPaths });
66+
await service.setup({ pluginPaths, http });
5967

6068
expect(getKibanaTranslationFilesMock).toHaveBeenCalledTimes(1);
6169
expect(getKibanaTranslationFilesMock).toHaveBeenCalledWith('en', pluginPaths);
@@ -65,17 +73,27 @@ describe('I18nService', () => {
6573
const translationFiles = ['/path/to/file', 'path/to/another/file'];
6674
getKibanaTranslationFilesMock.mockResolvedValue(translationFiles);
6775

68-
await service.setup({ pluginPaths: [] });
76+
await service.setup({ pluginPaths: [], http });
6977

7078
expect(initTranslationsMock).toHaveBeenCalledTimes(1);
7179
expect(initTranslationsMock).toHaveBeenCalledWith('en', translationFiles);
7280
});
7381

82+
it('calls `registerRoutesMock` with the correct parameters', async () => {
83+
await service.setup({ pluginPaths: [], http });
84+
85+
expect(registerRoutesMock).toHaveBeenCalledTimes(1);
86+
expect(registerRoutesMock).toHaveBeenCalledWith({
87+
locale: 'en',
88+
router: expect.any(Object),
89+
});
90+
});
91+
7492
it('returns accessors for locale and translation files', async () => {
7593
const translationFiles = ['/path/to/file', 'path/to/another/file'];
7694
getKibanaTranslationFilesMock.mockResolvedValue(translationFiles);
7795

78-
const { getLocale, getTranslationFiles } = await service.setup({ pluginPaths: [] });
96+
const { getLocale, getTranslationFiles } = await service.setup({ pluginPaths: [], http });
7997

8098
expect(getLocale()).toEqual('en');
8199
expect(getTranslationFiles()).toEqual(translationFiles);

src/core/server/i18n/i18n_service.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,14 @@ import { take } from 'rxjs/operators';
2121
import { Logger } from '../logging';
2222
import { IConfigService } from '../config';
2323
import { CoreContext } from '../core_context';
24+
import { InternalHttpServiceSetup } from '../http';
2425
import { config as i18nConfigDef, I18nConfigType } from './i18n_config';
2526
import { getKibanaTranslationFiles } from './get_kibana_translation_files';
2627
import { initTranslations } from './init_translations';
28+
import { registerRoutes } from './routes';
2729

2830
interface SetupDeps {
31+
http: InternalHttpServiceSetup;
2932
pluginPaths: string[];
3033
}
3134

@@ -53,7 +56,7 @@ export class I18nService {
5356
this.configService = coreContext.configService;
5457
}
5558

56-
public async setup({ pluginPaths }: SetupDeps): Promise<I18nServiceSetup> {
59+
public async setup({ pluginPaths, http }: SetupDeps): Promise<I18nServiceSetup> {
5760
const i18nConfig = await this.configService
5861
.atPath<I18nConfigType>(i18nConfigDef.path)
5962
.pipe(take(1))
@@ -67,6 +70,9 @@ export class I18nService {
6770
this.log.debug(`Using translation files: [${translationFiles.join(', ')}]`);
6871
await initTranslations(locale, translationFiles);
6972

73+
const router = http.createRouter('');
74+
registerRoutes({ router, locale });
75+
7076
return {
7177
getLocale: () => locale,
7278
getTranslationFiles: () => translationFiles,
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { IRouter } from '../../http';
21+
import { registerTranslationsRoute } from './translations';
22+
23+
export const registerRoutes = ({ router, locale }: { router: IRouter; locale: string }) => {
24+
registerTranslationsRoute(router, locale);
25+
};
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
import { createHash } from 'crypto';
21+
import { i18n } from '@kbn/i18n';
22+
import { schema } from '@kbn/config-schema';
23+
import { IRouter } from '../../http';
24+
25+
interface TranslationCache {
26+
translations: string;
27+
hash: string;
28+
}
29+
30+
export const registerTranslationsRoute = (router: IRouter, locale: string) => {
31+
let translationCache: TranslationCache;
32+
33+
router.get(
34+
{
35+
path: '/translations/{locale}.json',
36+
validate: {
37+
params: schema.object({
38+
locale: schema.string(),
39+
}),
40+
},
41+
options: {
42+
authRequired: false,
43+
},
44+
},
45+
(ctx, req, res) => {
46+
if (req.params.locale.toLowerCase() !== locale.toLowerCase()) {
47+
return res.notFound({
48+
body: `Unknown locale: ${req.params.locale}`,
49+
});
50+
}
51+
if (!translationCache) {
52+
const translations = JSON.stringify(i18n.getTranslation());
53+
const hash = createHash('sha1').update(translations).digest('hex');
54+
translationCache = {
55+
translations,
56+
hash,
57+
};
58+
}
59+
return res.ok({
60+
headers: {
61+
'content-type': 'application/json',
62+
'cache-control': 'must-revalidate',
63+
etag: translationCache.hash,
64+
},
65+
body: translationCache.translations,
66+
});
67+
}
68+
);
69+
};

src/core/server/server.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,6 @@ export class Server {
131131
await ensureValidConfiguration(this.configService, legacyConfigSetup);
132132
}
133133

134-
// setup i18n prior to any other service, to have translations ready
135-
const i18nServiceSetup = await this.i18n.setup({ pluginPaths });
136-
137134
const contextServiceSetup = this.context.setup({
138135
// We inject a fake "legacy plugin" with dependencies on every plugin so that legacy plugins:
139136
// 1) Can access context from any KP plugin
@@ -149,6 +146,9 @@ export class Server {
149146
context: contextServiceSetup,
150147
});
151148

149+
// setup i18n prior to any other service, to have translations ready
150+
const i18nServiceSetup = await this.i18n.setup({ http: httpSetup, pluginPaths });
151+
152152
const capabilitiesSetup = this.capabilities.setup({ http: httpSetup });
153153

154154
const elasticsearchServiceSetup = await this.elasticsearch.setup({

src/legacy/ui/ui_render/ui_render_mixin.js

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,7 @@
1717
* under the License.
1818
*/
1919

20-
import { createHash } from 'crypto';
2120
import Boom from '@hapi/boom';
22-
import { i18n } from '@kbn/i18n';
2321
import * as UiSharedDeps from '@kbn/ui-shared-deps';
2422
import { KibanaRequest } from '../../../core/server';
2523
import { AppBootstrap } from './bootstrap';
@@ -37,36 +35,6 @@ import { getApmConfig } from '../apm';
3735
* @param {KbnServer['config']} config
3836
*/
3937
export function uiRenderMixin(kbnServer, server, config) {
40-
const translationsCache = { translations: null, hash: null };
41-
server.route({
42-
path: '/translations/{locale}.json',
43-
method: 'GET',
44-
config: { auth: false },
45-
handler(request, h) {
46-
// Kibana server loads translations only for a single locale
47-
// that is specified in `i18n.locale` config value.
48-
const { locale } = request.params;
49-
if (i18n.getLocale() !== locale.toLowerCase()) {
50-
throw Boom.notFound(`Unknown locale: ${locale}`);
51-
}
52-
53-
// Stringifying thousands of labels and calculating hash on the resulting
54-
// string can be expensive so it makes sense to do it once and cache.
55-
if (translationsCache.translations == null) {
56-
translationsCache.translations = JSON.stringify(i18n.getTranslation());
57-
translationsCache.hash = createHash('sha1')
58-
.update(translationsCache.translations)
59-
.digest('hex');
60-
}
61-
62-
return h
63-
.response(translationsCache.translations)
64-
.header('cache-control', 'must-revalidate')
65-
.header('content-type', 'application/json')
66-
.etag(translationsCache.hash);
67-
},
68-
});
69-
7038
const authEnabled = !!server.auth.settings.default;
7139
server.route({
7240
path: '/bootstrap.js',
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Licensed to Elasticsearch B.V. under one or more contributor
3+
* license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright
5+
* ownership. Elasticsearch B.V. licenses this file to you under
6+
* the Apache License, Version 2.0 (the "License"); you may
7+
* not use this file except in compliance with the License.
8+
* You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
import expect from '@kbn/expect';
20+
import { FtrProviderContext } from '../../ftr_provider_context';
21+
22+
export default function ({ getService }: FtrProviderContext) {
23+
const supertest = getService('supertest');
24+
25+
describe('compression', () => {
26+
it(`uses compression when there isn't a referer`, async () => {
27+
await supertest
28+
.get('/app/kibana')
29+
.set('accept-encoding', 'gzip')
30+
.then((response) => {
31+
expect(response.header).to.have.property('content-encoding', 'gzip');
32+
});
33+
});
34+
35+
it(`uses compression when there is a whitelisted referer`, async () => {
36+
await supertest
37+
.get('/app/kibana')
38+
.set('accept-encoding', 'gzip')
39+
.set('referer', 'https://some-host.com')
40+
.then((response) => {
41+
expect(response.header).to.have.property('content-encoding', 'gzip');
42+
});
43+
});
44+
45+
it(`doesn't use compression when there is a non-whitelisted referer`, async () => {
46+
await supertest
47+
.get('/app/kibana')
48+
.set('accept-encoding', 'gzip')
49+
.set('referer', 'https://other.some-host.com')
50+
.then((response) => {
51+
expect(response.header).not.to.have.property('content-encoding');
52+
});
53+
});
54+
});
55+
}

test/api_integration/apis/core/index.js

Lines changed: 0 additions & 56 deletions
This file was deleted.

0 commit comments

Comments
 (0)