Skip to content

Commit

Permalink
feat: add translation file request handler (#18680)
Browse files Browse the repository at this point in the history
This PR adds a request handler to serve translation properties files. For the request to be handled by this handler, the request type should be i18n. There is one optional parameter to construct the locale: langtag. If a translation properties file that corresponds to the locale, the response contains the translation entries in JSON format. Otherwise, it contains the default file or returns a not found response. The default file is the bundle for the root locale. The system default locale is not considered to be the default, and is not used for fallback. The response header also contains information regarding the retrieved file locale.

Fixes #18641
  • Loading branch information
ugur-vaadin authored Mar 1, 2024
1 parent 84da3de commit 3fec9e5
Show file tree
Hide file tree
Showing 7 changed files with 632 additions and 3 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,23 @@ public String getTranslation(String key, Locale locale, Object... params) {

private ResourceBundle getBundle(Locale locale) {
try {
return ResourceBundle.getBundle(BUNDLE_PREFIX, locale,
I18NUtil.getClassLoader());
return getBundle(locale, null);
} catch (final MissingResourceException e) {
getLogger().warn("Missing resource bundle for " + BUNDLE_PREFIX
+ " and locale " + locale.getDisplayName(), e);
}
return null;
}

ResourceBundle getBundle(Locale locale, ResourceBundle.Control control) {
if (control == null) {
return ResourceBundle.getBundle(BUNDLE_PREFIX, locale,
I18NUtil.getClassLoader());
}
return ResourceBundle.getBundle(BUNDLE_PREFIX, locale,
I18NUtil.getClassLoader(), control);
}

static Logger getLogger() {
return LoggerFactory.getLogger(DefaultI18NProvider.class);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
/*
* Copyright 2000-2024 Vaadin Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
* use this file except in compliance with the License. You may obtain a copy of
* the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations under
* the License.
*/
package com.vaadin.flow.i18n;

import com.vaadin.flow.server.HandlerHelper;
import com.vaadin.flow.server.HttpStatusCode;
import com.vaadin.flow.server.SynchronizedRequestHandler;
import com.vaadin.flow.server.VaadinRequest;
import com.vaadin.flow.server.VaadinResponse;
import com.vaadin.flow.server.VaadinSession;
import elemental.json.Json;
import elemental.json.JsonObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.util.Locale;
import java.util.MissingResourceException;
import java.util.Objects;
import java.util.Optional;
import java.util.ResourceBundle;
import java.util.Set;

/**
* Handles translation file requests. Translation file requests are internal
* requests sent by the client-side to retrieve the translation file for the
* specified language tag. The response contains the translations in JSON
* format. Also, the language tag of the retrieved translation file is included
* as a header with the name {@code X-Vaadin-Retrieved-Locale}. The language tag
* parameter {@code langtag} supports both dash and underscore as separators.
* <p>
* The translation file to return is determined by matching the requested locale
* to the available bundles with the following prioritization order:
* <ul>
* <li>Exact match</li>
* <li>Language and country match</li>
* <li>Language match</li>
* <li>Default bundle (root bundle)</li>
* </ul>
* <p>
* For internal use only. May be renamed or removed in a future release.
*
* @author Vaadin Ltd
* @since 24.4
*/
public class TranslationFileRequestHandler extends SynchronizedRequestHandler {

static final String LANGUAGE_TAG_PARAMETER_NAME = "langtag";

static final String RETRIEVED_LOCALE_HEADER_NAME = "X-Vaadin-Retrieved-Locale";

private static final Locale FALLBACK_LOCALE = Locale.ROOT;

private final DefaultI18NProvider i18NProvider;

private final boolean hasFallbackBundle;

public TranslationFileRequestHandler(I18NProvider i18NProvider) {
boolean hasDefaultI18NProvider = i18NProvider != null
&& DefaultI18NProvider.class.equals(i18NProvider.getClass());
this.i18NProvider = hasDefaultI18NProvider
? (DefaultI18NProvider) i18NProvider
: null;
this.hasFallbackBundle = hasFallbackBundle();
}

@Override
public boolean synchronizedHandleRequest(VaadinSession session,
VaadinRequest request, VaadinResponse response) throws IOException {
if (i18NProvider == null) {
handleCustomI18NProvider(session, response);
return true;
}
Locale locale = getLocale(request);
ResourceBundle translationPropertyFile = getTranslationPropertyFile(
locale);
if (translationPropertyFile == null) {
handleNotFound(response);
} else {
handleFound(response, translationPropertyFile);
}
return true;
}

@Override
protected boolean canHandleRequest(VaadinRequest request) {
return HandlerHelper.isRequestType(request,
HandlerHelper.RequestType.TRANSLATION_FILE);
}

private void handleFound(VaadinResponse response,
ResourceBundle translationPropertyFile) throws IOException {
response.setStatus(HttpStatusCode.OK.getCode());
response.setHeader(RETRIEVED_LOCALE_HEADER_NAME,
translationPropertyFile.getLocale().toLanguageTag());
writeFileToResponse(response, translationPropertyFile);
}

private void handleNotFound(VaadinResponse response) {
response.setStatus(HttpStatusCode.NOT_FOUND.getCode());
}

private void handleCustomI18NProvider(VaadinSession session,
VaadinResponse response) throws IOException {
String errorMessage = "Loading translations is not supported when using a custom i18n provider.";
if (session.getService().getDeploymentConfiguration()
.isProductionMode()) {
response.setStatus(HttpStatusCode.NOT_FOUND.getCode());
} else {
response.sendError(HttpStatusCode.NOT_IMPLEMENTED.getCode(),
errorMessage);
}
getLogger().debug(errorMessage);
}

private void writeFileToResponse(VaadinResponse response,
ResourceBundle translationPropertyFile) throws IOException {
JsonObject json = Json.createObject();
translationPropertyFile.keySet().forEach(
key -> json.put(key, translationPropertyFile.getString(key)));
response.getWriter().write(json.toJson());
}

private Locale getLocale(VaadinRequest request) {
String languageTag = Objects.requireNonNullElse(
request.getParameter(LANGUAGE_TAG_PARAMETER_NAME), "");
if (languageTag.contains("_")) {
String[] tokens = languageTag.split("_");
String language = tokens[0];
String country = tokens.length > 1 ? tokens[1] : "";
String variant = tokens.length > 2 ? tokens[2] : "";
return new Locale(language, country, variant);
}
return Locale.forLanguageTag(languageTag);
}

private ResourceBundle getTranslationPropertyFile(Locale locale) {
Locale bestMatchLocale = getBestMatchLocale(locale);
if (bestMatchLocale == null) {
if (FALLBACK_LOCALE.equals(locale)) {
getLogger().debug(
"Missing the requested default bundle for {}.",
DefaultI18NProvider.BUNDLE_PREFIX);
} else {
getLogger().debug(
"Missing resource bundles for {}, both the requested locale {} and the default bundle.",
DefaultI18NProvider.BUNDLE_PREFIX,
locale.getDisplayName());
}
return null;
}
if (!locale.equals(bestMatchLocale)) {
if (FALLBACK_LOCALE.equals(bestMatchLocale)) {
getLogger().debug(
"Missing resource bundle for {} and locale {}. Using the default bundle.",
DefaultI18NProvider.BUNDLE_PREFIX,
locale.getDisplayName());
} else {
getLogger().debug(
"Missing resource bundle for {} and locale {}. Using the best match locale {}.",
DefaultI18NProvider.BUNDLE_PREFIX,
locale.getDisplayName(),
bestMatchLocale.getDisplayName());
}
}
return i18NProvider.getBundle(bestMatchLocale,
ResourceBundle.Control.getNoFallbackControl(
ResourceBundle.Control.FORMAT_PROPERTIES));
}

private Locale getBestMatchLocale(Locale locale) {
Set<Locale> providedLocales = Set
.copyOf(i18NProvider.getProvidedLocales());
if (providedLocales.contains(locale)) {
return locale;
}
Optional<Locale> languageAndCountryMatch = providedLocales.stream()
.filter(providedLocale -> providedLocale.getLanguage()
.equals(locale.getLanguage())
&& providedLocale.getCountry()
.equals(locale.getCountry()))
.findAny();
if (languageAndCountryMatch.isPresent()) {
return languageAndCountryMatch.get();
}
Optional<Locale> languageMatch = providedLocales.stream()
.filter(providedLocale -> providedLocale.getLanguage()
.equals(locale.getLanguage()))
.findAny();
if (languageMatch.isPresent()) {
return languageMatch.get();
}
if (hasFallbackBundle) {
return FALLBACK_LOCALE;
}
return null;
}

private boolean hasFallbackBundle() {
if (this.i18NProvider != null) {
try {
this.i18NProvider.getBundle(FALLBACK_LOCALE,
ResourceBundle.Control.getNoFallbackControl(
ResourceBundle.Control.FORMAT_PROPERTIES));
return true;
} catch (MissingResourceException e) {
// NO-OP
}
}
return false;
}

private Logger getLogger() {
return LoggerFactory.getLogger(TranslationFileRequestHandler.class);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,12 @@ public enum RequestType {
/**
* Page showing that the browser is unsupported.
*/
BROWSER_TOO_OLD("oldbrowser");
BROWSER_TOO_OLD("oldbrowser"),

/**
* Translation properties file requests.
*/
TRANSLATION_FILE(ApplicationConstants.REQUEST_TYPE_TRANSLATION_FILE);

private final String identifier;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import com.vaadin.flow.di.Lookup;
import com.vaadin.flow.function.DeploymentConfiguration;
import com.vaadin.flow.i18n.I18NProvider;
import com.vaadin.flow.i18n.TranslationFileRequestHandler;
import com.vaadin.flow.internal.CurrentInstance;
import com.vaadin.flow.internal.LocaleUtil;
import com.vaadin.flow.internal.UsageStatistics;
Expand Down Expand Up @@ -333,6 +334,8 @@ protected List<RequestHandler> createRequestHandlers()
handlers.add(new UnsupportedBrowserHandler());
handlers.add(new StreamRequestHandler());
handlers.add(new PwaHandler(() -> getPwaRegistry()));
handlers.add(new TranslationFileRequestHandler(
getInstantiator().getI18NProvider()));

handlers.add(new WebComponentBootstrapHandler());
handlers.add(new WebComponentProvider());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,12 @@ public class ApplicationConstants implements Serializable {
*/
public static final String REQUEST_TYPE_WEBCOMPONENT_RESYNC = "webcomponent-resync";

/**
* Request type parameter value indicating a translation properties file
* request.
*/
public static final String REQUEST_TYPE_TRANSLATION_FILE = "i18n";

/**
* Attribute name for marking internal router link anchors.
*/
Expand Down
Loading

0 comments on commit 3fec9e5

Please sign in to comment.