Skip to content

Commit d0ccc44

Browse files
author
gitlab
committed
Merge branch 'feature/errorcode-i18n-simplify' into '5.5.12'
<feature>[errorcode]: simplify i18n — guarantee message is never null See merge request zstackio/zstack!9354
2 parents 8f31252 + f32fb96 commit d0ccc44

6 files changed

Lines changed: 134 additions & 64 deletions

File tree

core/src/main/java/org/zstack/core/Platform.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import org.zstack.core.db.DatabaseGlobalProperty;
1818
import org.zstack.core.encrypt.EncryptRSA;
1919
import org.zstack.core.errorcode.ErrorFacade;
20+
import org.zstack.core.errorcode.GlobalErrorCodeI18nService;
2021
import org.zstack.core.propertyvalidator.ValidatorTool;
2122
import org.zstack.core.search.SearchGlobalProperty;
2223
import org.zstack.core.statemachine.StateMachine;
@@ -988,6 +989,23 @@ public static ErrorCode err(String globalErrorCode, Enum errCode, ErrorCode caus
988989
.toArray(String[]::new));
989990
}
990991

992+
// populate message at creation time with default locale;
993+
// RestServer will override with client's Accept-Language if different
994+
try {
995+
ComponentLoader currentLoader = loader;
996+
if (currentLoader != null) {
997+
GlobalErrorCodeI18nService i18nService = currentLoader.getComponent(GlobalErrorCodeI18nService.class);
998+
if (i18nService != null) {
999+
i18nService.localizeErrorCode(result, org.zstack.core.errorcode.LocaleUtils.DEFAULT_LOCALE);
1000+
}
1001+
}
1002+
} catch (Exception e) {
1003+
// i18n service not initialized during early startup
1004+
}
1005+
if (result.getMessage() == null) {
1006+
result.setMessage(details != null ? details : result.getDescription());
1007+
}
1008+
9911009
return result;
9921010
}
9931011

core/src/main/java/org/zstack/core/errorcode/GlobalErrorCodeI18nServiceImpl.java

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ private void loadAllJsonFiles() {
5656
String content = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
5757
@SuppressWarnings("unchecked")
5858
Map<String, String> messages = JSONObjectUtil.toObject(content, LinkedHashMap.class);
59-
localeMessages.put(locale, messages);
59+
localeMessages.put(locale, Collections.unmodifiableMap(messages));
6060
logger.info(String.format("loaded %d i18n error messages for locale [%s]",
6161
messages.size(), locale));
6262
} catch (Exception e) {
@@ -125,26 +125,34 @@ private String formatTemplate(String template, String[] formatArgs) {
125125

126126
@Override
127127
public void localizeErrorCode(ErrorCode error, String locale) {
128-
if (error == null || locale == null) {
128+
if (error == null) {
129129
return;
130130
}
131131

132+
String resolvedLocale = locale != null ? locale : LocaleUtils.DEFAULT_LOCALE;
133+
132134
if (error.getGlobalErrorCode() != null) {
133-
String message = getLocalizedMessage(error.getGlobalErrorCode(), locale, error.getFormatArgs());
135+
String message = getLocalizedMessage(error.getGlobalErrorCode(), resolvedLocale, error.getFormatArgs());
134136
if (message != null) {
135137
error.setMessage(message);
136138
}
137139
}
138140

141+
// guarantee: message is never null
142+
if (error.getMessage() == null) {
143+
String fallback = error.getDetails() != null ? error.getDetails() : error.getDescription();
144+
error.setMessage(fallback != null ? fallback : (error.getCode() != null ? error.getCode() : ""));
145+
}
146+
139147
if (error.getCause() != null) {
140-
localizeErrorCode(error.getCause(), locale);
148+
localizeErrorCode(error.getCause(), resolvedLocale);
141149
}
142150

143151
if (error instanceof ErrorCodeList) {
144152
List<ErrorCode> causes = ((ErrorCodeList) error).getCauses();
145153
if (causes != null) {
146154
for (ErrorCode cause : causes) {
147-
localizeErrorCode(cause, locale);
155+
localizeErrorCode(cause, resolvedLocale);
148156
}
149157
}
150158
}

core/src/main/java/org/zstack/core/errorcode/LocaleUtils.java

Lines changed: 17 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ public class LocaleUtils {
3030
* Parse Accept-Language header and return the best matching locale key
3131
* from the set of available locales.
3232
*
33+
* Uses {@link Locale.LanguageRange#parse(String)} for RFC 7231 compliant
34+
* parsing with proper q-value priority sorting.
35+
*
3336
* @param acceptLanguage the Accept-Language header value
3437
* @param availableLocales the set of locale keys loaded from JSON files
3538
* @return the best matching locale key, or en_US as fallback
@@ -39,18 +42,27 @@ public static String resolveLocale(String acceptLanguage, Set<String> availableL
3942
return DEFAULT_LOCALE;
4043
}
4144

42-
List<LocaleEntry> entries = parseAcceptLanguage(acceptLanguage);
43-
for (LocaleEntry entry : entries) {
44-
if (entry.quality <= 0) {
45+
List<Locale.LanguageRange> ranges;
46+
try {
47+
ranges = Locale.LanguageRange.parse(acceptLanguage);
48+
} catch (IllegalArgumentException e) {
49+
logger.debug(String.format("failed to parse Accept-Language [%s]: %s", acceptLanguage, e.getMessage()));
50+
return DEFAULT_LOCALE;
51+
}
52+
53+
// ranges are already sorted by q-value descending
54+
for (Locale.LanguageRange range : ranges) {
55+
if (range.getWeight() <= 0) {
4556
continue;
4657
}
4758

48-
String normalized = normalizeTag(entry.tag);
59+
String tag = range.getRange();
60+
String normalized = normalizeTag(tag);
4961
if (availableLocales.contains(normalized)) {
5062
return normalized;
5163
}
5264

53-
String lang = entry.tag.split("[-_]")[0].toLowerCase();
65+
String lang = tag.split("[-_]")[0].toLowerCase();
5466
String mapped = LANGUAGE_TO_LOCALE.get(lang);
5567
if (mapped != null && availableLocales.contains(mapped)) {
5668
return mapped;
@@ -78,48 +90,4 @@ static String normalizeTag(String tag) {
7890
}
7991
return tag;
8092
}
81-
82-
private static List<LocaleEntry> parseAcceptLanguage(String header) {
83-
List<LocaleEntry> entries = new ArrayList<>();
84-
String[] parts = header.split(",");
85-
for (String part : parts) {
86-
part = part.trim();
87-
if (part.isEmpty()) {
88-
continue;
89-
}
90-
String[] tagAndParams = part.split(";");
91-
if (tagAndParams.length == 0) {
92-
continue;
93-
}
94-
String tag = tagAndParams[0].trim();
95-
if (tag.isEmpty()) {
96-
continue;
97-
}
98-
double quality = 1.0;
99-
for (int i = 1; i < tagAndParams.length; i++) {
100-
String param = tagAndParams[i].trim();
101-
if (param.startsWith("q=")) {
102-
try {
103-
quality = Double.parseDouble(param.substring(2).trim());
104-
} catch (NumberFormatException e) {
105-
logger.debug(String.format("failed to parse quality value [%s]: %s", param, e.getMessage()));
106-
quality = 0;
107-
}
108-
}
109-
}
110-
entries.add(new LocaleEntry(tag, quality));
111-
}
112-
entries.sort((a, b) -> Double.compare(b.quality, a.quality));
113-
return entries;
114-
}
115-
116-
private static class LocaleEntry {
117-
final String tag;
118-
final double quality;
119-
120-
LocaleEntry(String tag, double quality) {
121-
this.tag = tag;
122-
this.quality = quality;
123-
}
124-
}
12593
}

rest/src/main/java/org/zstack/rest/RestServer.java

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -408,8 +408,11 @@ private void callWebHook(RequestData d) throws IllegalAccessException, NoSuchMet
408408

409409
writeResponse(response, w, ret.getResult());
410410
} else {
411+
// localize with webhook caller's locale (message already populated by Platform.err)
411412
String locale = resolveLocale();
412-
i18nService.localizeErrorCode(evt.getError(), locale);
413+
if (!LocaleUtils.DEFAULT_LOCALE.equals(locale)) {
414+
i18nService.localizeErrorCode(evt.getError(), locale);
415+
}
413416
response.setError(evt.getError());
414417
}
415418

@@ -917,14 +920,18 @@ private void handleJobQuery(HttpServletRequest req, HttpServletResponse rsp) thr
917920
writeResponse(response, w, ret.getResult());
918921
sendResponse(HttpStatus.OK.value(), response, rsp);
919922
} else {
920-
String locale = resolveLocaleFromRequest(req);
921-
i18nService.localizeErrorCode(evt.getError(), locale);
922923
response.setError(evt.getError());
923924
sendResponse(HttpStatus.SERVICE_UNAVAILABLE.value(), response, rsp);
924925
}
925926
}
926927

927928
private void sendResponse(int statusCode, ApiResponse response, HttpServletResponse rsp) throws IOException {
929+
// centralized localization: override message with client's preferred locale
930+
if (response.getError() != null) {
931+
String locale = resolveLocale();
932+
i18nService.localizeErrorCode(response.getError(), locale);
933+
}
934+
928935
RequestInfo info = requestInfo.get();
929936
if (requestLogger.isTraceEnabled() && needLog(info)) {
930937
String body = CloudBusGson.toJson(response);
@@ -1428,18 +1435,16 @@ private String resolveLocale() {
14281435
return LocaleUtils.resolveLocale(acceptLanguage, i18nService.getAvailableLocales());
14291436
}
14301437

1431-
private String resolveLocaleFromRequest(HttpServletRequest req) {
1432-
String acceptLanguage = req.getHeader("Accept-Language");
1433-
return LocaleUtils.resolveLocale(acceptLanguage, i18nService.getAvailableLocales());
1434-
}
1435-
14361438
private void sendReplyResponse(MessageReply reply, Api api, HttpServletResponse rsp) throws IOException, IllegalAccessException, NoSuchMethodException, InvocationTargetException {
14371439
ApiResponse response = new ApiResponse();
14381440

14391441
if (!reply.isSuccess()) {
14401442
String locale = resolveLocale();
14411443
i18nService.localizeErrorCode(reply.getError(), locale);
14421444
response.setError(reply.getError());
1445+
// use JSONObjectUtil (which disables HTML escaping) to keep the same
1446+
// serialization behavior as before; CloudBusGson.httpGson escapes '\'' to
1447+
// '\u0027' which breaks SDK-side string assertions (ZSTAC-71075 etc.)
14431448
sendResponse(HttpStatus.SERVICE_UNAVAILABLE.value(), JSONObjectUtil.toJsonString(response), rsp);
14441449
return;
14451450
}

test/src/test/groovy/org/zstack/test/integration/core/ErrorCodeI18nCase.groovy

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package org.zstack.test.integration.core
22

3+
import org.zstack.core.errorcode.GlobalErrorCodeI18nServiceImpl
34
import org.zstack.core.errorcode.LocaleUtils
45
import org.zstack.header.errorcode.ErrorCode
6+
import org.zstack.header.errorcode.ErrorCodeList
57
import org.zstack.testlib.SubCase
68

79
class ErrorCodeI18nCase extends SubCase {
@@ -24,8 +26,12 @@ class ErrorCodeI18nCase extends SubCase {
2426
testLocaleUtilsNoMatch()
2527
testLocaleUtilsCaseInsensitive()
2628
testLocaleUtilsMalformedHeader()
29+
testLocaleUtilsComplexBrowserHeader()
2730
testErrorCodeCopyConstructor()
2831
testErrorCodeCopyConstructorWithNulls()
32+
testMessageGuaranteeFallbackToDetails()
33+
testMessageGuaranteeFallbackToDescription()
34+
testMessageGuaranteeOnCauseChain()
2935
}
3036

3137
@Override
@@ -78,6 +84,14 @@ class ErrorCodeI18nCase extends SubCase {
7884
assert LocaleUtils.resolveLocale(";;;,,,", available) == "en_US"
7985
}
8086

87+
void testLocaleUtilsComplexBrowserHeader() {
88+
def available = ["zh_CN", "en_US"] as Set
89+
// real Chrome header: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
90+
assert LocaleUtils.resolveLocale("zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6", available) == "zh_CN"
91+
// q=0 means "not acceptable" — should be skipped
92+
assert LocaleUtils.resolveLocale("zh-CN;q=0,en-US;q=1.0", available) == "en_US"
93+
}
94+
8195
// ---- ErrorCode copy constructor ----
8296

8397
void testErrorCodeCopyConstructor() {
@@ -127,4 +141,42 @@ class ErrorCodeI18nCase extends SubCase {
127141
assert copy.formatArgs == null
128142
}
129143

144+
// ---- message guarantee (localizeErrorCode always populates message) ----
145+
146+
void testMessageGuaranteeFallbackToDetails() {
147+
def i18n = new GlobalErrorCodeI18nServiceImpl()
148+
// no i18n JSON loaded, so getLocalizedMessage returns null
149+
// localizeErrorCode should fall back to details
150+
def error = new ErrorCode("SYS.1000", "System Error", "disk full on /dev/sda1")
151+
assert error.getMessage() == null
152+
153+
i18n.localizeErrorCode(error, "en_US")
154+
155+
assert error.getMessage() == "disk full on /dev/sda1"
156+
}
157+
158+
void testMessageGuaranteeFallbackToDescription() {
159+
def i18n = new GlobalErrorCodeI18nServiceImpl()
160+
// no details, should fall back to description
161+
def error = new ErrorCode("SYS.1000", "System Error")
162+
assert error.getMessage() == null
163+
164+
i18n.localizeErrorCode(error, "en_US")
165+
166+
assert error.getMessage() == "System Error"
167+
}
168+
169+
void testMessageGuaranteeOnCauseChain() {
170+
def i18n = new GlobalErrorCodeI18nServiceImpl()
171+
def root = new ErrorCode("INTERNAL.1001", "Internal Error", "root cause detail")
172+
def mid = new ErrorCode("SYS.1000", "System Error")
173+
mid.setCause(root)
174+
175+
i18n.localizeErrorCode(mid, "en_US")
176+
177+
// both should have message populated
178+
assert mid.getMessage() == "System Error"
179+
assert root.getMessage() == "root cause detail"
180+
}
181+
130182
}

test/src/test/java/org/zstack/test/core/errorcode/TestGlobalErrorCodeI18n.java

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,8 +128,27 @@ public void testLocalizeErrorCodeList() {
128128
@Test
129129
public void testNoGlobalErrorCode() {
130130
ErrorCode error = new ErrorCode("SYS.1000", "test error");
131-
// no globalErrorCode set
131+
// no globalErrorCode set — message should fall back to description
132132
i18nService.localizeErrorCode(error, "zh_CN");
133-
Assert.assertNull("message should remain null", error.getMessage());
133+
Assert.assertEquals("message should fall back to description",
134+
"test error", error.getMessage());
135+
}
136+
137+
@Test
138+
public void testMessageGuaranteeFallbackToDetails() {
139+
ErrorCode error = new ErrorCode("SYS.1000", "System Error", "disk full on /dev/sda1");
140+
i18nService.localizeErrorCode(error, "en_US");
141+
Assert.assertEquals("message should fall back to details",
142+
"disk full on /dev/sda1", error.getMessage());
143+
}
144+
145+
@Test
146+
public void testMessageNeverNull() {
147+
ErrorCode error = new ErrorCode("SYS.1000", "System Error");
148+
error.setDetails(null);
149+
i18nService.localizeErrorCode(error, "en_US");
150+
Assert.assertNotNull("message must never be null after localizeErrorCode",
151+
error.getMessage());
152+
Assert.assertEquals("System Error", error.getMessage());
134153
}
135154
}

0 commit comments

Comments
 (0)