diff --git a/metarParser-commons/src/main/java/io/github/mivek/internationalization/Messages.java b/metarParser-commons/src/main/java/io/github/mivek/internationalization/Messages.java index 66438c04..f48ed438 100644 --- a/metarParser-commons/src/main/java/io/github/mivek/internationalization/Messages.java +++ b/metarParser-commons/src/main/java/io/github/mivek/internationalization/Messages.java @@ -5,7 +5,7 @@ import java.util.ResourceBundle; /** - * Messages class for internationalization. + * Messages class for internationalization. Thread-safe via ThreadLocal. * * @author mivek */ @@ -14,15 +14,14 @@ public final class Messages { private static final Messages INSTANCE = new Messages(); /** Name of the bundle. */ private static final String BUNDLE_NAME = "internationalization.messages"; - /** Bundle variable. */ - private ResourceBundle fResourceBundle; + /** Per-thread bundle holder — thread-safe, no global Locale.setDefault(). */ + private final ThreadLocal bundleHolder = + ThreadLocal.withInitial(() -> ResourceBundle.getBundle(BUNDLE_NAME)); /** * Private constructor. */ - private Messages() { - fResourceBundle = ResourceBundle.getBundle(BUNDLE_NAME); - } + private Messages() {} /** * @return the Messages instance. @@ -32,14 +31,22 @@ public static Messages getInstance() { } /** - * Sets the locale of the bundle. + * Sets the locale of the bundle for the current thread. * * @param locale the locale to set. */ public void setLocale(final Locale locale) { - Locale.setDefault(locale); - ResourceBundle.clearCache(); - fResourceBundle = ResourceBundle.getBundle(BUNDLE_NAME, locale); + bundleHolder.set(ResourceBundle.getBundle(BUNDLE_NAME, locale)); + } + + /** + * Clears the locale for the current thread, resetting it to the JVM default. + * + *

Must be called in thread-pool environments (e.g., servlets, Spring) + * after each request to prevent locale leakage between tasks on the same thread. + */ + public void clearLocale() { + bundleHolder.remove(); } /** @@ -47,7 +54,7 @@ public void setLocale(final Locale locale) { * @return the translation of message */ public String getString(final String message) { - return fResourceBundle.getString(message); + return bundleHolder.get().getString(message); } /** diff --git a/metarParser-commons/src/main/java/io/github/mivek/utils/Converter.java b/metarParser-commons/src/main/java/io/github/mivek/utils/Converter.java index b6323229..d58ff674 100644 --- a/metarParser-commons/src/main/java/io/github/mivek/utils/Converter.java +++ b/metarParser-commons/src/main/java/io/github/mivek/utils/Converter.java @@ -24,6 +24,9 @@ public final class Converter { **/ private static final Double SM_TO_KM = 1.609344; + /** Pattern to parse a visibility string composed of a numeric value and a unit. */ + private static final Pattern VISIBILITY_PATTERN = Pattern.compile("(\\d+)([a-z,A-Z]+)"); + /** * Private constructor. */ @@ -125,7 +128,7 @@ public static float convertTemperature(final String sign, final String temperatu * @return The visibility in km as a double */ public static Double convertVisibilityToKM(final String visibility) { - final Matcher matcher = Pattern.compile("(\\d+)([a-z,A-Z]+)").matcher(visibility.replace(">", "")); + final Matcher matcher = VISIBILITY_PATTERN.matcher(visibility.replace(">", "")); if (!matcher.find()) { return null; } diff --git a/metarParser-commons/src/main/resources/internationalization/messages_fr.properties b/metarParser-commons/src/main/resources/internationalization/messages_fr.properties index 4f9ccfc4..bc43c7ba 100644 --- a/metarParser-commons/src/main/resources/internationalization/messages_fr.properties +++ b/metarParser-commons/src/main/resources/internationalization/messages_fr.properties @@ -202,7 +202,7 @@ Converter.NNE=Nord Nord Est Converter.NNW=Nord Nord Ouest Converter.NSC=Aucun changement significatif Converter.NW=Nord Ouest -Converter.S=Est +Converter.S=Sud Converter.SE=Sud Est Converter.SSE=Sud Sud Est Converter.SSW=Sud Sud Ouest diff --git a/metarParser-commons/src/test/java/io/github/mivek/internationalization/MessagesTest.java b/metarParser-commons/src/test/java/io/github/mivek/internationalization/MessagesTest.java index 2459dd43..efb96531 100644 --- a/metarParser-commons/src/test/java/io/github/mivek/internationalization/MessagesTest.java +++ b/metarParser-commons/src/test/java/io/github/mivek/internationalization/MessagesTest.java @@ -1,10 +1,21 @@ package io.github.mivek.internationalization; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; import java.util.Locale; +import java.util.Properties; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertTrue; class MessagesTest { @@ -19,4 +30,36 @@ void testSetLocale() { assertEquals("few", Messages.getInstance().getString("CloudQuantity.FEW")); assertEquals("ceiling varying between 5 and 15 feet", Messages.getInstance().getString("Remark.Ceiling.Height", 5, 15)); } + + @Test + void testClearLocale() { + Messages.getInstance().setLocale(Locale.FRENCH); + assertEquals("peu", Messages.getInstance().getString("CloudQuantity.FEW")); + Messages.getInstance().clearLocale(); + // After clearing, the JVM default locale is used; the key must still be resolvable. + assertDoesNotThrow(() -> Messages.getInstance().getString("CloudQuantity.FEW")); + } + + @ParameterizedTest + @ValueSource(strings = {"messages_de", "messages_es", "messages_fr", "messages_it", + "messages_pl_PL", "messages_ru_RU", "messages_tr_TR", "messages_zh_CN"}) + @Disabled("Requires all locale bundles to be complete and up-to-date with the base bundle") + void testLocaleContainsAllBaseKeys(final String bundleName) throws IOException { + Properties base = loadProperties("internationalization/messages.properties"); + Properties locale = loadProperties("internationalization/" + bundleName + ".properties"); + Set baseKeys = base.keySet(); + for (Object key : baseKeys) { + assertTrue(locale.containsKey(key), + "Locale bundle '" + bundleName + "' is missing key: " + key); + } + } + + private Properties loadProperties(final String resourcePath) throws IOException { + Properties props = new Properties(); + try (InputStream is = getClass().getClassLoader().getResourceAsStream(resourcePath); + InputStreamReader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) { + props.load(reader); + } + return props; + } } diff --git a/metarParser-entities/src/main/java/io/github/mivek/enums/Descriptive.java b/metarParser-entities/src/main/java/io/github/mivek/enums/Descriptive.java index 26297edd..11486e4c 100644 --- a/metarParser-entities/src/main/java/io/github/mivek/enums/Descriptive.java +++ b/metarParser-entities/src/main/java/io/github/mivek/enums/Descriptive.java @@ -1,6 +1,7 @@ package io.github.mivek.enums; import io.github.mivek.internationalization.Messages; +import java.util.regex.Pattern; /** * Enumeration for descriptive. The first attribute is the code used in the @@ -28,6 +29,8 @@ public enum Descriptive { /** The descriptive's shortcut. */ private final String shortcut; + /** Pre-compiled pattern used to detect this descriptive in a weather token. */ + private final Pattern pattern; /** * Constructor. @@ -35,6 +38,7 @@ public enum Descriptive { */ Descriptive(final String shortcut) { this.shortcut = shortcut; + this.pattern = Pattern.compile("(" + shortcut + ")"); } /** @@ -44,6 +48,15 @@ public String getShortcut() { return this.shortcut; } + /** + * Returns the pre-compiled pattern used to match this descriptive in a weather token. + * + * @return the compiled {@link Pattern}. + */ + public Pattern getPattern() { + return pattern; + } + @Override public String toString() { return Messages.getInstance().getString("Descriptive." + shortcut); diff --git a/metarParser-entities/src/main/java/io/github/mivek/enums/Phenomenon.java b/metarParser-entities/src/main/java/io/github/mivek/enums/Phenomenon.java index d919e379..7e0ea34b 100644 --- a/metarParser-entities/src/main/java/io/github/mivek/enums/Phenomenon.java +++ b/metarParser-entities/src/main/java/io/github/mivek/enums/Phenomenon.java @@ -1,6 +1,7 @@ package io.github.mivek.enums; import io.github.mivek.internationalization.Messages; +import java.util.regex.Pattern; /** * Enumeration for phenomenon. @@ -57,6 +58,8 @@ public enum Phenomenon { /** Shortcut of the phenomenon. */ private final String shortcut; + /** Pre-compiled pattern used to match this phenomenon at the start of a weather token. */ + private final Pattern pattern; /** * Constructor. @@ -65,6 +68,7 @@ public enum Phenomenon { */ Phenomenon(final String shortcut) { this.shortcut = shortcut; + this.pattern = Pattern.compile("^" + shortcut); } @Override @@ -80,4 +84,13 @@ public String toString() { public String getShortcut() { return shortcut; } + + /** + * Returns the pre-compiled pattern used to match this phenomenon at the start of a weather token. + * + * @return the compiled {@link Pattern}. + */ + public Pattern getPattern() { + return pattern; + } } diff --git a/metarParser-parsers/src/main/java/io/github/mivek/parser/AbstractWeatherContainerParser.java b/metarParser-parsers/src/main/java/io/github/mivek/parser/AbstractWeatherContainerParser.java index 47ac8252..16ea273a 100644 --- a/metarParser-parsers/src/main/java/io/github/mivek/parser/AbstractWeatherContainerParser.java +++ b/metarParser-parsers/src/main/java/io/github/mivek/parser/AbstractWeatherContainerParser.java @@ -63,7 +63,7 @@ WeatherCondition parseWeatherCondition(final String weatherPart) { weatherPartCopy = weatherPartCopy.substring(i.getShortcut().length()); } for (Descriptive des : Descriptive.values()) { - if (Regex.findString(Pattern.compile("(" + des.getShortcut() + ")"), weatherPart) != null) { + if (Regex.findString(des.getPattern(), weatherPart) != null) { wc.setDescriptive(des); weatherPartCopy = weatherPartCopy.substring(des.getShortcut().length()); break; @@ -74,7 +74,7 @@ WeatherCondition parseWeatherCondition(final String weatherPart) { while (!weatherPartCopy.isEmpty() && !weatherPartCopy.equals(previousToken)) { previousToken = weatherPartCopy; for (Phenomenon phenom: Phenomenon.values()) { - if (Regex.find(Pattern.compile("^" + phenom.getShortcut()), weatherPartCopy)) { + if (Regex.find(phenom.getPattern(), weatherPartCopy)) { wc.addPhenomenon(phenom); weatherPartCopy = weatherPartCopy.substring(phenom.getShortcut().length()); } diff --git a/metarParser-spi/src/main/java/io/github/mivek/provider/airport/impl/DefaultAirportProvider.java b/metarParser-spi/src/main/java/io/github/mivek/provider/airport/impl/DefaultAirportProvider.java index 11d6104f..c2f87921 100644 --- a/metarParser-spi/src/main/java/io/github/mivek/provider/airport/impl/DefaultAirportProvider.java +++ b/metarParser-spi/src/main/java/io/github/mivek/provider/airport/impl/DefaultAirportProvider.java @@ -7,6 +7,7 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Objects; @@ -23,28 +24,31 @@ public final class DefaultAirportProvider implements AirportProvider { private static final String AIRPORTS_RESOURCE = "data/airports.dat"; private static final String COUNTRIES_RESOURCE = "data/countries.dat"; - private volatile Map countries; - private volatile Map airports; + /** Whether the data has been loaded. Set to true only after airports map is fully populated. */ + private volatile boolean initialized; + /** Map of airports keyed by ICAO code. */ + private Map airports; - /** private lock to avoid exposing the monitor. */ + /** Private lock to avoid exposing the monitor. */ private final Object loadLock = new Object(); - /** * Ensure the airport and country data have been loaded. * - *

This method is safe to call from multiple threads. It performs a double-checked - * locking pattern using {@code loadLock} to initialize the data only once. + *

This method is safe to call from multiple threads. It uses a double-checked + * locking pattern on a single {@code volatile boolean} flag so that a thread + * never observes a partially-initialized state. */ private void ensureLoaded() { - if (airports != null && countries != null) { + if (initialized) { return; } synchronized (loadLock) { - if (airports != null && countries != null) { + if (initialized) { return; } loadResources(); + initialized = true; } } @@ -98,22 +102,20 @@ private void loadResources() { throw new IllegalStateException(e); } - this.countries = localCountries; this.airports = localAirports; } /** - * Returns the map of loaded airports keyed by ICAO code. + * Returns an unmodifiable view of the loaded airports keyed by ICAO code. * *

When this method is called the first time, it triggers loading of the underlying - * country and airport resources. Subsequent calls return the cached map. The returned - * map is the internal map instance (not a defensive copy). + * country and airport resources. Subsequent calls return the cached map. * - * @return the map of ICAO -> Airport + * @return an unmodifiable map of ICAO -> Airport */ @Override public Map getAirports() { ensureLoaded(); - return airports; + return Collections.unmodifiableMap(airports); } } diff --git a/metarParser-spi/src/main/java/io/github/mivek/provider/airport/impl/OurAirportsAirportProvider.java b/metarParser-spi/src/main/java/io/github/mivek/provider/airport/impl/OurAirportsAirportProvider.java index 47036c8c..7cb54945 100644 --- a/metarParser-spi/src/main/java/io/github/mivek/provider/airport/impl/OurAirportsAirportProvider.java +++ b/metarParser-spi/src/main/java/io/github/mivek/provider/airport/impl/OurAirportsAirportProvider.java @@ -13,6 +13,7 @@ import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.time.Duration; +import java.util.Collections; import java.util.HashMap; import java.util.Map; import org.apache.commons.csv.CSVFormat; @@ -113,7 +114,7 @@ public void buildAirport() throws URISyntaxException, IOException, InterruptedEx @Override public Map getAirports() { - return airports; + return Collections.unmodifiableMap(airports); } }