Skip to content

Commit 51c2a37

Browse files
authored
feat(#5183): add language header interceptor to include UI language in requests (#5192)
1 parent 640ae3b commit 51c2a37

File tree

6 files changed

+195
-15
lines changed

6 files changed

+195
-15
lines changed

spring-boot-admin-server-ui/src/main/frontend/login.css

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
* See the License for the specific language governing permissions and
1414
* limitations under the License.
1515
*/
16-
@import "./theme.css";
16+
@import './theme.css';
1717

1818
:root {
1919
--bg-color-start: #71e69c;

spring-boot-admin-server-ui/src/main/frontend/services/application.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ import { AxiosInstance } from 'axios';
1717
import { sortBy } from 'lodash-es';
1818
import { Observable, concat, from, ignoreElements } from 'rxjs';
1919

20-
import axios, { redirectOn401 } from '../utils/axios';
20+
import axios, {
21+
addLanguageHeaderInterceptor,
22+
redirectOn401,
23+
} from '../utils/axios';
2124
import waitForPolyfill from '../utils/eventsource-polyfill';
2225
import uri from '../utils/uri';
2326
import Instance, { DOWN_STATES, UNKNOWN_STATES, UP_STATES } from './instance';
@@ -100,6 +103,7 @@ class Application {
100103
'X-SBA-REQUEST': true,
101104
},
102105
});
106+
this.axios.interceptors.request.use(addLanguageHeaderInterceptor);
103107
this.axios.interceptors.response.use(
104108
(response) => response,
105109
redirectOn401(),

spring-boot-admin-server-ui/src/main/frontend/services/instance.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import saveAs from 'file-saver';
1818
import { Observable, concat, from, ignoreElements } from 'rxjs';
1919

2020
import axios, {
21+
addLanguageHeaderInterceptor,
2122
redirectOn401,
2223
registerErrorToastInterceptor,
2324
} from '../utils/axios';
@@ -63,6 +64,7 @@ class Instance {
6364
baseURL: uri`instances/${this.id}`,
6465
headers: { Accept: actuatorMimeTypes.join(',') },
6566
});
67+
this.axios.interceptors.request.use(addLanguageHeaderInterceptor);
6668
this.axios.interceptors.response.use(
6769
(response) => response,
6870
redirectOn401(

spring-boot-admin-server-ui/src/main/frontend/theme.css

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,20 @@
1414
* limitations under the License.
1515
*/
1616

17-
@import "tailwindcss";
17+
@import 'tailwindcss';
1818
@custom-variant dark (&:where(.dark, .dark *));
1919
@plugin "@tailwindcss/forms";
2020
@plugin "@tailwindcss/typography";
2121

2222
@theme {
23-
--color-sba-50: var(--main-50);
24-
--color-sba-100: var(--main-100);
25-
--color-sba-200: var(--main-200);
26-
--color-sba-300: var(--main-300);
27-
--color-sba-400: var(--main-400);
28-
--color-sba-500: var(--main-500);
29-
--color-sba-600: var(--main-600);
30-
--color-sba-700: var(--main-700);
31-
--color-sba-800: var(--main-800);
32-
--color-sba-900: var(--main-900);
23+
--color-sba-50: var(--main-50);
24+
--color-sba-100: var(--main-100);
25+
--color-sba-200: var(--main-200);
26+
--color-sba-300: var(--main-300);
27+
--color-sba-400: var(--main-400);
28+
--color-sba-500: var(--main-500);
29+
--color-sba-600: var(--main-600);
30+
--color-sba-700: var(--main-700);
31+
--color-sba-800: var(--main-800);
32+
--color-sba-900: var(--main-900);
3333
}

spring-boot-admin-server-ui/src/main/frontend/utils/axios.spec.ts

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ import axios, { AxiosError } from 'axios';
1717
import MockAdapter from 'axios-mock-adapter';
1818
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
1919

20-
import { redirectOn401, registerErrorToastInterceptor } from './axios';
20+
import {
21+
addLanguageHeaderInterceptor,
22+
redirectOn401,
23+
registerErrorToastInterceptor,
24+
} from './axios';
2125

2226
// Initialize errorSpy BEFORE any mocks or imports
2327
globalThis.errorSpy = vi.fn();
@@ -100,6 +104,125 @@ describe('redirectOn401', () => {
100104
});
101105
});
102106

107+
describe('addLanguageHeaderInterceptor', () => {
108+
beforeEach(() => {
109+
// Clear globalThis.SBA before each test
110+
if (globalThis.SBA) {
111+
delete globalThis.SBA;
112+
}
113+
});
114+
115+
it('should include selected language with highest priority', () => {
116+
globalThis.SBA = {
117+
useI18n: () => ({ locale: { value: 'de' } }),
118+
} as any;
119+
120+
const config = {
121+
url: '/api/test',
122+
headers: {},
123+
};
124+
125+
const result = addLanguageHeaderInterceptor(config);
126+
127+
expect(result.headers['Accept-Language']).toContain('de;q=1.0');
128+
});
129+
130+
it('should include navigator language with lower priority', () => {
131+
globalThis.SBA = {
132+
useI18n: () => ({ locale: { value: 'de' } }),
133+
} as any;
134+
135+
// Mock navigator.language
136+
Object.defineProperty(navigator, 'language', {
137+
value: 'en-US',
138+
configurable: true,
139+
});
140+
141+
const config = {
142+
url: '/api/test',
143+
headers: {},
144+
};
145+
146+
const result = addLanguageHeaderInterceptor(config);
147+
148+
expect(result.headers['Accept-Language']).toContain('de;q=1.0');
149+
expect(result.headers['Accept-Language']).toContain('en-US;q=0.9');
150+
});
151+
152+
it('should not duplicate navigator language if it matches selected language', () => {
153+
globalThis.SBA = {
154+
useI18n: () => ({ locale: { value: 'de' } }),
155+
} as any;
156+
157+
// Mock navigator.language to match selected language
158+
Object.defineProperty(navigator, 'language', {
159+
value: 'de',
160+
configurable: true,
161+
});
162+
163+
const config = {
164+
url: '/api/test',
165+
headers: {},
166+
};
167+
168+
const result = addLanguageHeaderInterceptor(config);
169+
170+
// Should only include de once
171+
const languageHeader = result.headers['Accept-Language'];
172+
const deCount = (languageHeader.match(/de;q=1\.0/g) || []).length;
173+
expect(deCount).toBe(1);
174+
});
175+
176+
it('should include wildcard fallback', () => {
177+
globalThis.SBA = {
178+
useI18n: () => ({ locale: { value: 'fr' } }),
179+
} as any;
180+
181+
const config = {
182+
url: '/api/test',
183+
headers: {},
184+
};
185+
186+
const result = addLanguageHeaderInterceptor(config);
187+
188+
expect(result.headers['Accept-Language']).toContain('*;q=0.8');
189+
});
190+
191+
it('should handle missing globalThis.SBA gracefully', () => {
192+
// Don't set globalThis.SBA - it should handle this gracefully
193+
const config = {
194+
url: '/api/test',
195+
headers: {},
196+
};
197+
198+
const result = addLanguageHeaderInterceptor(config);
199+
200+
// Should return the config unchanged if SBA is not available
201+
expect(result).toBe(config);
202+
expect(result.headers['Accept-Language']).toBeUndefined();
203+
});
204+
205+
it('should preserve existing headers when adding Accept-Language', () => {
206+
globalThis.SBA = {
207+
useI18n: () => ({ locale: { value: 'fr' } }),
208+
} as any;
209+
210+
const config = {
211+
url: '/api/test',
212+
headers: {
213+
Authorization: 'Bearer token',
214+
'Content-Type': 'application/json',
215+
},
216+
};
217+
218+
const result = addLanguageHeaderInterceptor(config);
219+
220+
expect(result.headers['Authorization']).toBe('Bearer token');
221+
expect(result.headers['Content-Type']).toBe('application/json');
222+
expect(result.headers['Accept-Language']).toContain('fr;q=1.0');
223+
});
224+
});
225+
103226
describe('registerErrorToastInterceptor', () => {
104227
let axiosInstance;
105228
let mock;

spring-boot-admin-server-ui/src/main/frontend/utils/axios.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@
1414
* limitations under the License.
1515
*/
1616
import { useNotificationCenter } from '@stekoe/vue-toast-notificationcenter';
17-
import axios, { type AxiosError, AxiosInstance } from 'axios';
17+
import axios, {
18+
type AxiosError,
19+
AxiosInstance,
20+
type InternalAxiosRequestConfig,
21+
} from 'axios';
1822

1923
import sbaConfig from '../sba-config';
2024

@@ -38,6 +42,53 @@ export const redirectOn401 =
3842

3943
axios.defaults.withCredentials = true;
4044
axios.defaults.headers.common['Accept'] = 'application/json';
45+
46+
/**
47+
* Adds Accept-Language header to requests with user's selected UI language preference.
48+
* Format: selected-language;q=1.0, navigator-language;q=0.9, *;q=0.8
49+
*
50+
* @param config The axios request configuration
51+
* @returns The modified request configuration
52+
*/
53+
export const addLanguageHeaderInterceptor = (
54+
config: InternalAxiosRequestConfig,
55+
): InternalAxiosRequestConfig => {
56+
try {
57+
const i18n = globalThis.SBA?.useI18n?.();
58+
if (i18n?.locale.value) {
59+
const selectedLanguage = i18n.locale.value;
60+
const navigatorLanguage = navigator.language;
61+
62+
// Build Accept-Language header with selected UI language as primary preference
63+
const acceptLanguageParts = [
64+
`${selectedLanguage};q=1.0`, // Selected UI language - highest priority
65+
];
66+
67+
// Add navigator language if it's different from selected language
68+
if (
69+
navigatorLanguage !== selectedLanguage &&
70+
!navigatorLanguage.startsWith(selectedLanguage)
71+
) {
72+
acceptLanguageParts.push(`${navigatorLanguage};q=0.9`);
73+
}
74+
75+
// Add wildcard fallback for any other language
76+
acceptLanguageParts.push('*;q=0.8');
77+
78+
config.headers['Accept-Language'] = acceptLanguageParts.join(', ');
79+
}
80+
} catch (error) {
81+
// Log in development mode for debugging
82+
if (process.env.NODE_ENV === 'development') {
83+
console.error('Failed to add language header:', error);
84+
}
85+
// Silently fail in production if i18n is not yet initialized
86+
}
87+
return config;
88+
};
89+
90+
axios.interceptors.request.use(addLanguageHeaderInterceptor);
91+
4192
axios.interceptors.response.use((response) => response, redirectOn401());
4293

4394
export default axios;

0 commit comments

Comments
 (0)