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