001// ***************************************************************************************************************************
002// * Licensed to the Apache Software Foundation (ASF) under one or more contributor license agreements.  See the NOTICE file *
003// * distributed with this work for additional information regarding copyright ownership.  The ASF licenses this file        *
004// * to you under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance            *
005// * with the License.  You may obtain a copy of the License at                                                              *
006// *                                                                                                                         *
007// *  http://www.apache.org/licenses/LICENSE-2.0                                                                             *
008// *                                                                                                                         *
009// * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an  *
010// * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the License for the        *
011// * specific language governing permissions and limitations under the License.                                              *
012// ***************************************************************************************************************************
013package org.apache.juneau.utils;
014
015import static org.apache.juneau.internal.StringUtils.*;
016import static org.apache.juneau.internal.ThrowableUtils.*;
017
018import java.text.*;
019import java.util.*;
020import java.util.concurrent.*;
021
022import org.apache.juneau.*;
023
024/**
025 * Wraps a {@link ResourceBundle} to provide some useful additional functionality.
026 *
027 * <ul class='spaced-list'>
028 *    <li>
029 *       Instead of throwing {@link MissingResourceException}, the {@link #getString(String)} method
030 *       will return <js>"{!!key}"</js> if the bundle was not found, and <js>"{!key}"</js> if bundle
031 *       was found but the key is not in the bundle.
032 *    <li>
033 *       A client locale can be set as a {@link ThreadLocal} object using the static {@link #setClientLocale(Locale)}
034 *       so that client localized messages can be retrieved using the {@link #getClientString(String, Object...)}
035 *       method on all instances of this class.
036 *    <li>
037 *       Resource bundles on parent classes can be added to the search path for this class by using the
038 *       {@link #addSearchPath(Class, String)} method.
039 *       This allows messages to be retrieved from the resource bundles of parent classes.
040 *    <li>
041 *       Locale-specific bundles can be retrieved by using the {@link #getBundle(Locale)} method.
042 *    <li>
043 *       The {@link #getString(Locale, String, Object...)} method can be used to retrieve locale-specific messages.
044 *    <li>
045 *       Messages in the resource bundle can optionally be prefixed with the simple class name.
046 *       For example, if the class is <c>MyClass</c> and the properties file contains <js>"MyClass.myMessage"</js>,
047 *       the message can be retrieved using <code>getString(<js>"myMessage"</js>)</code>.
048 * </ul>
049 *
050 * <ul class='notes'>
051 *    <li>
052 *       This class is thread-safe.
053 * </ul>
054 */
055public class MessageBundle extends ResourceBundle {
056
057   private static final ThreadLocal<Locale> clientLocale = new ThreadLocal<>();
058
059   private final ResourceBundle rb;
060   private final String bundlePath, className;
061   private final Class<?> forClass;
062   private final long creationThreadId;
063
064   // A map that contains all keys [shortKeyName->keyName] and [keyName->keyName], where shortKeyName
065   // refers to keys prefixed and stripped of the class name (e.g. "foobar"->"MyClass.foobar")
066   private final Map<String,String> keyMap = new ConcurrentHashMap<>();
067
068   // Contains all keys present in all bundles in searchBundles.
069   private final ConcurrentSkipListSet<String> allKeys = new ConcurrentSkipListSet<>();
070
071   // Bundles to search through to find properties.
072   // Typically this will be a list of resource bundles for each class up the class hierarchy chain.
073   private final CopyOnWriteArrayList<MessageBundle> searchBundles = new CopyOnWriteArrayList<>();
074
075   // Cache of message bundles per locale.
076   private final ConcurrentHashMap<Locale,MessageBundle> localizedBundles = new ConcurrentHashMap<>();
077
078   /**
079    * Sets the locale for this thread so that calls to {@link #getClientString(String, Object...)} return messages in
080    * that locale.
081    *
082    * @param locale The new client locale.
083    */
084   public static void setClientLocale(Locale locale) {
085      MessageBundle.clientLocale.set(locale);
086   }
087
088   /**
089    * Constructor.
090    *
091    * <p>
092    * When this method is used, the bundle path is determined by searching for the resource bundle
093    * in the following locations:
094    * <ul>
095    *    <li><c>[package].ForClass.properties</c>
096    *    <li><c>[package].nls.ForClass.properties</c>
097    *    <li><c>[package].i18n.ForClass.properties</c>
098    * </ul>
099    *
100    * @param forClass The class
101    * @return A new message bundle belonging to the class.
102    */
103   public static final MessageBundle create(Class<?> forClass) {
104      return create(forClass, findBundlePath(forClass));
105   }
106
107   /**
108    * Constructor.
109    *
110    * <p>
111    * A shortcut for calling <c>new MessageBundle(forClass, bundlePath)</c>.
112    *
113    * @param forClass The class
114    * @param bundlePath The location of the resource bundle.
115    * @return A new message bundle belonging to the class.
116    */
117   public static final MessageBundle create(Class<?> forClass, String bundlePath) {
118      return new MessageBundle(forClass, bundlePath);
119   }
120
121   private static final String findBundlePath(Class<?> forClass) {
122      String path = forClass.getName();
123      if (tryBundlePath(forClass, path))
124         return path;
125      path = forClass.getPackage().getName() + ".nls." + forClass.getSimpleName();
126      if (tryBundlePath(forClass, path))
127         return path;
128      path = forClass.getPackage().getName() + ".i18n." + forClass.getSimpleName();
129      if (tryBundlePath(forClass, path))
130         return path;
131      return null;
132   }
133
134   private static final boolean tryBundlePath(Class<?> c, String path) {
135      try {
136         path = c.getName();
137         ResourceBundle.getBundle(path, Locale.getDefault(), c.getClassLoader());
138         return true;
139      } catch (MissingResourceException e) {
140         return false;
141      }
142   }
143
144   /**
145    * Constructor.
146    *
147    * @param forClass The class using this resource bundle.
148    * @param bundlePath
149    *    The path of the resource bundle to wrap.
150    *    This can be an absolute path (e.g. <js>"com.foo.MyMessages"</js>) or a path relative to the package of the
151    *    <l>forClass</l> (e.g. <js>"MyMessages"</js> if <l>forClass</l> is <js>"com.foo.MyClass"</js>).
152    */
153   public MessageBundle(Class<?> forClass, String bundlePath) {
154      this(forClass, bundlePath, Locale.getDefault());
155   }
156
157   private MessageBundle(Class<?> forClass, String bundlePath, Locale locale) {
158      this.forClass = forClass;
159      this.className = forClass.getSimpleName();
160      if (bundlePath == null)
161         throw new RuntimeException("Bundle path was null.");
162      if (bundlePath.endsWith(".properties"))
163         throw new RuntimeException("Bundle path should not end with '.properties'");
164      this.bundlePath = bundlePath;
165      this.creationThreadId = Thread.currentThread().getId();
166      ClassLoader cl = forClass.getClassLoader();
167      ResourceBundle trb = null;
168      try {
169         trb = ResourceBundle.getBundle(bundlePath, locale, cl);
170      } catch (MissingResourceException e) {
171         try {
172            trb = ResourceBundle.getBundle(forClass.getPackage().getName() + '.' + bundlePath, locale, cl);
173         } catch (MissingResourceException e2) {
174         }
175      }
176      this.rb = trb;
177      if (rb != null) {
178
179         // Populate keyMap with original mappings.
180         for (Enumeration<String> e = getKeys(); e.hasMoreElements();) {
181            String key = e.nextElement();
182            keyMap.put(key, key);
183         }
184
185         // Override/augment with shortname mappings (e.g. "foobar"->"MyClass.foobar")
186         String c = className + '.';
187         for (Enumeration<String> e = getKeys(); e.hasMoreElements();) {
188            String key = e.nextElement();
189            if (key.startsWith(c)) {
190               String shortKey = key.substring(className.length() + 1);
191               keyMap.put(shortKey, key);
192            }
193         }
194
195         allKeys.addAll(keyMap.keySet());
196      }
197      searchBundles.add(this);
198   }
199
200
201   /**
202    * Add another bundle path to this resource bundle.
203    *
204    * <p>
205    * Order of property lookup is first-to-last.
206    *
207    * <p>
208    * This method must be called from the same thread as the call to the constructor.
209    * This eliminates the need for synchronization.
210    *
211    * @param forClass The class using this resource bundle.
212    * @param bundlePath The bundle path.
213    * @return This object (for method chaining).
214    */
215   public MessageBundle addSearchPath(Class<?> forClass, String bundlePath) {
216      assertSameThread(creationThreadId, "This method can only be called from the same thread that created the object.");
217      MessageBundle srb = new MessageBundle(forClass, bundlePath);
218      if (srb.rb != null) {
219         allKeys.addAll(srb.keySet());
220         searchBundles.add(srb);
221      }
222      return this;
223   }
224
225   @Override /* ResourceBundle */
226   public boolean containsKey(String key) {
227      return allKeys.contains(key);
228   }
229
230   /**
231    * Similar to {@link ResourceBundle#getString(String)} except allows you to pass in {@link MessageFormat} objects.
232    *
233    * @param key The resource bundle key.
234    * @param args Optional {@link MessageFormat}-style arguments.
235    * @return
236    *    The resolved value.  Never <jk>null</jk>.
237    *    <js>"{!!key}"</js> if the bundle is missing.
238    *    <js>"{!key}"</js> if the key is missing.
239    */
240   public String getString(String key, Object...args) {
241      String s = getString(key);
242      if (s.length() > 0 && s.charAt(0) == '{')
243         return s;
244      return format(s, args);
245   }
246
247   /**
248    * Same as {@link #getString(String, Object...)} but allows you to specify the locale.
249    *
250    * @param locale The locale of the resource bundle to retrieve message from.
251    * @param key The resource bundle key.
252    * @param args Optional {@link MessageFormat}-style arguments.
253    * @return
254    *    The resolved value.  Never <jk>null</jk>.
255    *    <js>"{!!key}"</js> if the bundle is missing.
256    *    <js>"{!key}"</js> if the key is missing.
257    */
258   public String getString(Locale locale, String key, Object...args) {
259      if (locale == null)
260         return getString(key, args);
261      return getBundle(locale).getString(key, args);
262   }
263
264   /**
265    * Same as {@link #getString(String, Object...)} but uses the locale specified on the call to {@link #setClientLocale(Locale)}.
266    *
267    * @param key The resource bundle key.
268    * @param args Optional {@link MessageFormat}-style arguments.
269    * @return
270    *    The resolved value.  Never <jk>null</jk>.
271    *    <js>"{!!key}"</js> if the bundle is missing.
272    *    <js>"{!key}"</js> if the key is missing.
273    */
274   public String getClientString(String key, Object...args) {
275      return getString(clientLocale.get(), key, args);
276   }
277
278   /**
279    * Looks for all the specified keys in the resource bundle and returns the first value that exists.
280    *
281    * @param keys The list of possible keys.
282    * @return The resolved value, or <jk>null</jk> if no value is found or the resource bundle is missing.
283    */
284   public String findFirstString(String...keys) {
285      if (rb == null)
286         return null;
287      for (String k : keys) {
288         if (containsKey(k))
289            return getString(k);
290      }
291      return null;
292   }
293
294   /**
295    * Same as {@link #findFirstString(String...)}, but uses the specified locale.
296    *
297    * @param locale The locale of the resource bundle to retrieve message from.
298    * @param keys The list of possible keys.
299    * @return The resolved value, or <jk>null</jk> if no value is found or the resource bundle is missing.
300    */
301   public String findFirstString(Locale locale, String...keys) {
302      MessageBundle srb = getBundle(locale);
303      return srb.findFirstString(keys);
304   }
305
306   @Override /* ResourceBundle */
307   public Set<String> keySet() {
308      return Collections.unmodifiableSet(allKeys);
309   }
310
311   /**
312    * Returns all keys in this resource bundle with the specified prefix.
313    *
314    * @param prefix The prefix.
315    * @return The set of all keys in the resource bundle with the prefix.
316    */
317   public Set<String> keySet(String prefix) {
318      Set<String> set = new HashSet<>();
319      for (String s : keySet()) {
320         if (s.equals(prefix) || (s.startsWith(prefix) && s.charAt(prefix.length()) == '.'))
321            set.add(s);
322      }
323      return set;
324   }
325
326   @Override /* ResourceBundle */
327   public Enumeration<String> getKeys() {
328      if (rb == null)
329         return new Vector<String>(0).elements();
330      return rb.getKeys();
331   }
332
333   @Override /* ResourceBundle */
334   protected Object handleGetObject(String key) {
335      for (MessageBundle srb : searchBundles) {
336         if (srb.rb != null) {
337            String key2 = srb.keyMap.get(key);
338            if (key2 != null) {
339               try {
340                  return srb.rb.getObject(key2);
341               } catch (Exception e) {
342                  return "{!"+key+"}";
343               }
344            }
345         }
346      }
347      if (rb == null)
348         return "{!!"+key+"}";
349      return "{!"+key+"}";
350   }
351
352   /**
353    * Returns this resource bundle as an {@link ObjectMap}.
354    *
355    * <p>
356    * Useful for debugging purposes.
357    * Note that any class that implements a <c>swap()</c> method will automatically be serialized by
358    * calling this method and serializing the result.
359    *
360    * <p>
361    * This method always constructs a new {@link ObjectMap} on each call.
362    *
363    * @return A new map containing all the keys and values in this bundle.
364    */
365   public ObjectMap swap() {
366      ObjectMap om = new ObjectMap();
367      for (String k : allKeys)
368         om.put(k, getString(k));
369      return om;
370   }
371
372   /**
373    * Returns the resource bundle for the specified locale.
374    *
375    * @param locale The client locale.
376    * @return The resource bundle for the specified locale.  Never <jk>null</jk>.
377    */
378   public MessageBundle getBundle(Locale locale) {
379      MessageBundle mb = localizedBundles.get(locale);
380      if (mb != null)
381         return mb;
382      mb = new MessageBundle(forClass, bundlePath, locale);
383      List<MessageBundle> l = new ArrayList<>(searchBundles.size()-1);
384      for (int i = 1; i < searchBundles.size(); i++) {
385         MessageBundle srb = searchBundles.get(i);
386         srb = new MessageBundle(srb.forClass, srb.bundlePath, locale);
387         l.add(srb);
388         mb.allKeys.addAll(srb.keySet());
389      }
390      mb.searchBundles.addAll(l);
391      localizedBundles.putIfAbsent(locale, mb);
392      return localizedBundles.get(locale);
393   }
394}