diff --git a/src/changes/changes.xml b/src/changes/changes.xml index d86c76dd8..6221c90e7 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -8,6 +8,11 @@ + + + Allow DateTimeZone Provider and NameProvider implementations to be discovered using service provider files. + + DateTimeZone data updated to version 2026cgtz. diff --git a/src/main/java/org/joda/time/DateTimeZone.java b/src/main/java/org/joda/time/DateTimeZone.java index 549522d55..fcf38217f 100644 --- a/src/main/java/org/joda/time/DateTimeZone.java +++ b/src/main/java/org/joda/time/DateTimeZone.java @@ -92,10 +92,12 @@ * However, the factory that accepts a TimeZone will attempt to convert from * the old short id to a suitable long id. *

- * There are four approaches to loading time-zone data, which are tried in this order: + * There are five approaches to loading time-zone data, which are tried in this order: *

    *
  1. load the specific {@link Provider} specified by the system property * {@code org.joda.time.DateTimeZone.Provider}. + *
  2. load a {@link Provider} declared in + * {@code META-INF/services/org.joda.time.tz.Provider}. *
  3. load {@link ZoneInfoProvider} using the data in the filing system folder * pointed to by system property {@code org.joda.time.DateTimeZone.Folder}. *
  4. load {@link ZoneInfoProvider} using the data in the classpath location @@ -103,7 +105,9 @@ *
  5. load {@link UTCProvider} *
*

- * Unless you override the standard behaviour, the default if the third approach. + * Service provider declarations that cannot be loaded, created or validated are ignored. + *

+ * Unless you override the standard behaviour, the default is the fourth approach. *

* DateTimeZone is thread-safe and immutable, and all subclasses must be as * well. @@ -517,10 +521,12 @@ private static Provider validateProvider(Provider provider) { /** * Gets the default zone provider. *

- * This tries four approaches to loading data: + * This tries five approaches to loading data: *

    *
  1. loads the provider identifier by the system property * org.joda.time.DateTimeZone.Provider. + *
  2. loads a provider declared in + * META-INF/services/org.joda.time.tz.Provider. *
  3. load ZoneInfoProvider using the data in the filing system folder * pointed to by system property org.joda.time.DateTimeZone.Folder. *
  4. loads ZoneInfoProvider using the data in the classpath location @@ -528,31 +534,29 @@ private static Provider validateProvider(Provider provider) { *
  5. loads UTCProvider. *
*

- * Unless you override the standard behaviour, the default if the third approach. + * Unless you override the standard behaviour, the default is the fourth approach. * * @return the default name provider */ private static Provider getDefaultProvider() { - // approach 1 - try { - String providerClass = System.getProperty("org.joda.time.DateTimeZone.Provider"); - if (providerClass != null) { - try { - // do not initialize the class until the type has been checked - Class cls = Class.forName(providerClass, false, DateTimeZone.class.getClassLoader()); - if (!Provider.class.isAssignableFrom(cls)) { - throw new IllegalArgumentException("System property referred to class that does not implement " + Provider.class); + // approaches 1 and 2 + Provider loadedProvider = ServiceProviderLoader.load( + "org.joda.time.DateTimeZone.Provider", + Provider.class, + new ServiceProviderLoader.Predicate() { + public boolean test(Provider provider) { + try { + validateProvider(provider); + return true; + } catch (IllegalArgumentException ex) { + return false; + } } - Provider provider = cls.asSubclass(Provider.class).getConstructor().newInstance(); - return validateProvider(provider); - } catch (Exception ex) { - throw new RuntimeException(ex); - } - } - } catch (SecurityException ex) { - // ignored + }); + if (loadedProvider != null) { + return loadedProvider; } - // approach 2 + // approach 3 try { String dataFolder = System.getProperty("org.joda.time.DateTimeZone.Folder"); if (dataFolder != null) { @@ -566,14 +570,14 @@ private static Provider getDefaultProvider() { } catch (SecurityException ex) { // ignored } - // approach 3 + // approach 4 try { Provider provider = new ZoneInfoProvider(DEFAULT_TZ_DATA_PATH); return validateProvider(provider); } catch (Exception ex) { ex.printStackTrace(); } - // approach 4 + // approach 5 return new UTCProvider(); } @@ -621,30 +625,16 @@ public static void setNameProvider(NameProvider nameProvider) throws SecurityExc /** * Gets the default name provider. *

- * Tries the system property org.joda.time.DateTimeZone.NameProvider. - * Then uses DefaultNameProvider. + * Tries the system property org.joda.time.DateTimeZone.NameProvider, + * then service providers declared in + * META-INF/services/org.joda.time.tz.NameProvider, + * then uses DefaultNameProvider. * * @return the default name provider */ private static NameProvider getDefaultNameProvider() { - NameProvider nameProvider = null; - try { - String providerClass = System.getProperty("org.joda.time.DateTimeZone.NameProvider"); - if (providerClass != null) { - try { - // do not initialize the class until the type has been checked - Class cls = Class.forName(providerClass, false, DateTimeZone.class.getClassLoader()); - if (!NameProvider.class.isAssignableFrom(cls)) { - throw new IllegalArgumentException("System property referred to class that does not implement " + NameProvider.class); - } - nameProvider = cls.asSubclass(NameProvider.class).getConstructor().newInstance(); - } catch (Exception ex) { - throw new RuntimeException(ex); - } - } - } catch (SecurityException ex) { - // ignore - } + NameProvider nameProvider = ServiceProviderLoader.load( + "org.joda.time.DateTimeZone.NameProvider", NameProvider.class, null); if (nameProvider == null) { nameProvider = new DefaultNameProvider(); diff --git a/src/main/java/org/joda/time/ServiceProviderLoader.java b/src/main/java/org/joda/time/ServiceProviderLoader.java new file mode 100644 index 000000000..5bc57f56e --- /dev/null +++ b/src/main/java/org/joda/time/ServiceProviderLoader.java @@ -0,0 +1,222 @@ +/* + * Copyright 2026 Stephen Colebourne + * + * 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 org.joda.time; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.UnsupportedEncodingException; +import java.net.URL; +import java.util.Collection; +import java.util.Enumeration; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * A utility for looking up service provider implementations. + * + * @author Brett Okken + * @since 2.15 + */ +final class ServiceProviderLoader { + + private static final Logger LOGGER = Logger.getLogger(ServiceProviderLoader.class.getName()); + + /** + * Tests whether a service provider is valid for use. + * + * @param the service provider type + */ + static interface Predicate { + + /** + * Tests the service provider. + * + * @param value the service provider, not null + * @return true if the service provider is valid + */ + boolean test(T value); + } + + /** + * Loads the configured service provider, or the first discovered service provider that can be + * created and validated. A configured provider failure is fatal, while a discovered provider + * failure is ignored so that the next provider can be attempted. + * + * @param the service provider type + * @param property the system property used to configure the provider, not null + * @param service the service provider type, not null + * @param predicate the predicate used to validate providers, null to accept all + * @return the configured provider or first valid discovered provider, null if none + * @throws RuntimeException if a configured provider cannot be loaded, created or validated + */ + static T load(String property, Class service, Predicate predicate) { + String propertyClass = null; + try { + propertyClass = System.getProperty(property); + } catch (SecurityException ex) { + // ignored + } + if (propertyClass != null) { + try { + ClassLoader loader = service.getClassLoader(); + Class cls = Class.forName(propertyClass, false, loader); + if (!service.isAssignableFrom(cls)) { + throw new IllegalArgumentException( + "System property referred to class that does not implement " + service); + } + T provider = cls.asSubclass(service).getConstructor().newInstance(); + if (!test(predicate, provider)) { + throw new IllegalArgumentException("System property referred to an invalid provider"); + } + return provider; + } catch (Exception ex) { + throw new RuntimeException(ex); + } + } + + for (Class providerClass : lookupServiceProviders(service)) { + try { + T provider = providerClass.getConstructor().newInstance(); + if (test(predicate, provider)) { + return provider; + } + LOGGER.log(Level.FINE, "Service provider failed validation " + providerClass.getName()); + } catch (Exception ex) { + LOGGER.log(Level.FINE, "Unable to create service provider " + providerClass.getName(), ex); + } catch (LinkageError ex) { + LOGGER.log(Level.FINE, "Unable to create service provider " + providerClass.getName(), ex); + } + } + return null; + } + + private static boolean test(Predicate predicate, T provider) { + return predicate == null || predicate.test(provider); + } + + private static Collection> lookupServiceProviders(Class service) { + String serviceName = service.getName(); + String resourceName = "META-INF/services/" + serviceName; + ClassLoader loader = getClassLoader(service); + Set> providers = new LinkedHashSet>(); + + if (loader == null) { + return providers; + } + try { + Enumeration resources = loader.getResources(resourceName); + while (resources.hasMoreElements()) { + loadResource(service, loader, resources.nextElement(), providers); + } + } catch (IOException ex) { + LOGGER.log(Level.FINE, "Unable to find service provider files for " + serviceName, ex); + } catch (SecurityException ex) { + LOGGER.log(Level.FINE, "Unable to find service provider files for " + serviceName, ex); + } + return providers; + } + + private static void loadResource( + Class service, ClassLoader loader, URL resource, Set> providers) { + InputStream in = null; + try { + in = resource.openStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(in, "UTF-8")); + String line; + while ((line = reader.readLine()) != null) { + int comment = line.indexOf('#'); + if (comment >= 0) { + line = line.substring(0, comment); + } + line = line.trim(); + if (line.length() > 0) { + loadClass(service, loader, resource, line, providers); + } + } + } catch (UnsupportedEncodingException ex) { + throw new AssertionError(ex); + } catch (IOException ex) { + LOGGER.log(Level.FINE, "Unable to read service provider file " + resource, ex); + } catch (SecurityException ex) { + LOGGER.log(Level.FINE, "Unable to read service provider file " + resource, ex); + } finally { + if (in != null) { + try { + in.close(); + } catch (IOException ex) { + LOGGER.log(Level.FINE, "Unable to close service provider file " + resource, ex); + } + } + } + } + + private static void loadClass( + Class service, ClassLoader loader, URL resource, String className, + Set> providers) { + try { + Class provider = Class.forName(className, false, loader); + if (service.isAssignableFrom(provider)) { + providers.add(provider.asSubclass(service)); + } else { + LOGGER.log( + Level.FINE, + className + " in " + resource + " does not implement " + service.getName()); + } + } catch (ClassNotFoundException ex) { + LOGGER.log(Level.FINE, "Unable to load service provider " + className + " from " + resource, ex); + } catch (LinkageError ex) { + LOGGER.log(Level.FINE, "Unable to load service provider " + className + " from " + resource, ex); + } catch (SecurityException ex) { + LOGGER.log(Level.FINE, "Unable to load service provider " + className + " from " + resource, ex); + } + } + + private static ClassLoader getClassLoader(Class service) { + ClassLoader loader = null; + try { + loader = Thread.currentThread().getContextClassLoader(); + } catch (SecurityException ex) { + LOGGER.log(Level.FINE, "Unable to access the context class loader", ex); + } + if (loader == null) { + try { + loader = service.getClassLoader(); + } catch (SecurityException ex) { + LOGGER.log(Level.FINE, "Unable to access the service class loader", ex); + } + } + if (loader == null) { + try { + loader = ServiceProviderLoader.class.getClassLoader(); + } catch (SecurityException ex) { + LOGGER.log(Level.FINE, "Unable to access the service provider loader class loader", ex); + } + } + if (loader == null) { + try { + loader = ClassLoader.getSystemClassLoader(); + } catch (SecurityException ex) { + LOGGER.log(Level.FINE, "Unable to access the system class loader", ex); + } + } + return loader; + } + +} diff --git a/src/test/java/org/joda/time/TestAll.java b/src/test/java/org/joda/time/TestAll.java index 3a579c755..890bdb210 100644 --- a/src/test/java/org/joda/time/TestAll.java +++ b/src/test/java/org/joda/time/TestAll.java @@ -121,6 +121,7 @@ public static Test suite() { suite.addTest(TestDateTimeConstants.suite()); suite.addTest(TestDateTimeUtils.suite()); suite.addTest(TestDateTimeZone.suite()); + suite.addTest(TestServiceProviderLoader.suite()); suite.addTest(TestDateTimeZoneCutover.suite()); suite.addTest(TestPeriodType.suite()); // suite.addTest(TestParseISO.suite()); diff --git a/src/test/java/org/joda/time/TestServiceProviderLoader.java b/src/test/java/org/joda/time/TestServiceProviderLoader.java new file mode 100644 index 000000000..aa21f060f --- /dev/null +++ b/src/test/java/org/joda/time/TestServiceProviderLoader.java @@ -0,0 +1,205 @@ +/* + * Copyright 2026 Stephen Colebourne + * + * 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 org.joda.time; + +import java.io.File; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.Collections; +import java.util.Locale; +import java.util.Set; + +import org.joda.time.tz.DefaultNameProvider; +import org.joda.time.tz.NameProvider; +import org.joda.time.tz.Provider; +import org.joda.time.tz.UTCProvider; +import org.joda.time.tz.ZoneInfoProvider; + +import junit.framework.TestCase; +import junit.framework.TestSuite; + +/** + * Tests service provider discovery for DateTimeZone. + */ +public class TestServiceProviderLoader extends TestCase { + + private static final String PROVIDER_PROPERTY = "org.joda.time.DateTimeZone.Provider"; + private static final String NAME_PROVIDER_PROPERTY = "org.joda.time.DateTimeZone.NameProvider"; + + private ClassLoader originalClassLoader; + private String originalProviderProperty; + private String originalNameProviderProperty; + + public TestServiceProviderLoader(String name) { + super(name); + } + + public static TestSuite suite() { + return new TestSuite(TestServiceProviderLoader.class); + } + + @Override + protected void setUp() throws Exception { + originalClassLoader = Thread.currentThread().getContextClassLoader(); + originalProviderProperty = System.getProperty(PROVIDER_PROPERTY); + originalNameProviderProperty = System.getProperty(NAME_PROVIDER_PROPERTY); + System.getProperties().remove(PROVIDER_PROPERTY); + System.getProperties().remove(NAME_PROVIDER_PROPERTY); + } + + @Override + protected void tearDown() throws Exception { + Thread.currentThread().setContextClassLoader(originalClassLoader); + restoreProperty(PROVIDER_PROPERTY, originalProviderProperty); + restoreProperty(NAME_PROVIDER_PROPERTY, originalNameProviderProperty); + DateTimeZone.setProvider(null); + DateTimeZone.setNameProvider(null); + } + + public void testProviderUsesFirstUsableSpiDeclaration() throws Exception { + useSpiDirectory("valid"); + + DateTimeZone.setProvider(null); + + assertEquals(FirstProvider.class, DateTimeZone.getProvider().getClass()); + } + + public void testNameProviderUsesFirstUsableSpiDeclaration() throws Exception { + useSpiDirectory("valid"); + + DateTimeZone.setNameProvider(null); + + assertEquals(FirstNameProvider.class, DateTimeZone.getNameProvider().getClass()); + } + + public void testSpiFailuresFallBackToBuiltInProviders() throws Exception { + useSpiDirectory("unusable"); + + DateTimeZone.setProvider(null); + DateTimeZone.setNameProvider(null); + + assertEquals(ZoneInfoProvider.class, DateTimeZone.getProvider().getClass()); + assertEquals(DefaultNameProvider.class, DateTimeZone.getNameProvider().getClass()); + } + + public void testSystemPropertiesTakePrecedenceOverSpi() throws Exception { + useSpiDirectory("valid"); + System.setProperty(PROVIDER_PROPERTY, UTCProvider.class.getName()); + System.setProperty(NAME_PROVIDER_PROPERTY, DefaultNameProvider.class.getName()); + + DateTimeZone.setProvider(null); + DateTimeZone.setNameProvider(null); + + assertEquals(UTCProvider.class, DateTimeZone.getProvider().getClass()); + assertEquals(DefaultNameProvider.class, DateTimeZone.getNameProvider().getClass()); + } + + public void testBadSystemPropertiesDoNotFallBackToSpi() throws Exception { + useSpiDirectory("valid"); + System.setProperty(PROVIDER_PROPERTY, InvalidProvider.class.getName()); + try { + DateTimeZone.setProvider(null); + fail(); + } catch (RuntimeException ex) { + // expected + } + + System.getProperties().remove(PROVIDER_PROPERTY); + System.setProperty(NAME_PROVIDER_PROPERTY, String.class.getName()); + try { + DateTimeZone.setNameProvider(null); + fail(); + } catch (RuntimeException ex) { + // expected + } + } + + private void useSpiDirectory(String directory) throws Exception { + URL url = new File("src/test/resources/spi/" + directory).toURI().toURL(); + ClassLoader loader = new URLClassLoader(new URL[] {url}, originalClassLoader); + Thread.currentThread().setContextClassLoader(loader); + } + + private static void restoreProperty(String name, String value) { + if (value == null) { + System.getProperties().remove(name); + } else { + System.setProperty(name, value); + } + } + + public static class UncreatableProvider implements Provider { + public UncreatableProvider(String ignored) { + } + + public Set getAvailableIDs() { + return Collections.singleton("UTC"); + } + + public DateTimeZone getZone(String id) { + return DateTimeZone.UTC; + } + } + + public static class InvalidProvider implements Provider { + public Set getAvailableIDs() { + return Collections.emptySet(); + } + + public DateTimeZone getZone(String id) { + return null; + } + } + + public static class FirstProvider implements Provider { + public Set getAvailableIDs() { + return Collections.singleton("UTC"); + } + + public DateTimeZone getZone(String id) { + return DateTimeZone.UTC; + } + } + + public static class SecondProvider extends FirstProvider { + } + + public static class UncreatableNameProvider implements NameProvider { + public UncreatableNameProvider(String ignored) { + } + + public String getShortName(Locale locale, String id, String nameKey) { + return null; + } + + public String getName(Locale locale, String id, String nameKey) { + return null; + } + } + + public static class FirstNameProvider implements NameProvider { + public String getShortName(Locale locale, String id, String nameKey) { + return "first"; + } + + public String getName(Locale locale, String id, String nameKey) { + return "first"; + } + } + + public static class SecondNameProvider extends FirstNameProvider { + } +} diff --git a/src/test/resources/spi/unusable/META-INF/services/org.joda.time.tz.NameProvider b/src/test/resources/spi/unusable/META-INF/services/org.joda.time.tz.NameProvider new file mode 100644 index 000000000..6d594a317 --- /dev/null +++ b/src/test/resources/spi/unusable/META-INF/services/org.joda.time.tz.NameProvider @@ -0,0 +1,3 @@ +invalid.MissingNameProvider +java.lang.String +org.joda.time.TestServiceProviderLoader$UncreatableNameProvider diff --git a/src/test/resources/spi/unusable/META-INF/services/org.joda.time.tz.Provider b/src/test/resources/spi/unusable/META-INF/services/org.joda.time.tz.Provider new file mode 100644 index 000000000..a2e274699 --- /dev/null +++ b/src/test/resources/spi/unusable/META-INF/services/org.joda.time.tz.Provider @@ -0,0 +1,4 @@ +invalid.MissingProvider +java.lang.String +org.joda.time.TestServiceProviderLoader$UncreatableProvider +org.joda.time.TestServiceProviderLoader$InvalidProvider diff --git a/src/test/resources/spi/valid/META-INF/services/org.joda.time.tz.NameProvider b/src/test/resources/spi/valid/META-INF/services/org.joda.time.tz.NameProvider new file mode 100644 index 000000000..12bcea48d --- /dev/null +++ b/src/test/resources/spi/valid/META-INF/services/org.joda.time.tz.NameProvider @@ -0,0 +1,4 @@ +# The first provider cannot be created, so the second is used. +org.joda.time.TestServiceProviderLoader$UncreatableNameProvider +org.joda.time.TestServiceProviderLoader$FirstNameProvider +org.joda.time.TestServiceProviderLoader$SecondNameProvider diff --git a/src/test/resources/spi/valid/META-INF/services/org.joda.time.tz.Provider b/src/test/resources/spi/valid/META-INF/services/org.joda.time.tz.Provider new file mode 100644 index 000000000..2839220db --- /dev/null +++ b/src/test/resources/spi/valid/META-INF/services/org.joda.time.tz.Provider @@ -0,0 +1,8 @@ +# Missing, incompatible, uncreatable, and invalid providers are skipped. +invalid.MissingProvider +java.lang.String +org.joda.time.TestServiceProviderLoader$UncreatableProvider +org.joda.time.TestServiceProviderLoader$InvalidProvider +org.joda.time.TestServiceProviderLoader$FirstProvider +org.joda.time.TestServiceProviderLoader$SecondProvider +org.joda.time.TestServiceProviderLoader$FirstProvider