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.config.store;
014
015import static java.nio.file.StandardWatchEventKinds.*;
016import static java.nio.file.StandardOpenOption.*;
017import static org.apache.juneau.internal.StringUtils.*;
018
019import java.io.*;
020import java.nio.*;
021import java.nio.channels.*;
022import java.nio.charset.*;
023import java.nio.file.*;
024import java.util.concurrent.*;
025
026import org.apache.juneau.*;
027import org.apache.juneau.internal.*;
028
029/**
030 * Filesystem-based storage location for configuration files.
031 *
032 * <p>
033 * Points to a file system directory containing configuration files.
034 */
035public class ConfigFileStore extends ConfigStore {
036
037   //-------------------------------------------------------------------------------------------------------------------
038   // Configurable properties
039   //-------------------------------------------------------------------------------------------------------------------
040
041   private static final String PREFIX = "ConfigFileStore.";
042
043   /**
044    * Configuration property:  Local file system directory.
045    *
046    * <h5 class='section'>Property:</h5>
047    * <ul>
048    *    <li><b>Name:</b>  <js>"ConfigFileStore.directory.s"</js>
049    *    <li><b>Data type:</b>  <code>String</code>
050    *    <li><b>Default:</b>  <js>"."</js>
051    *    <li><b>Methods:</b>
052    *       <ul>
053    *          <li class='jm'>{@link ConfigFileStoreBuilder#directory(String)}
054    *          <li class='jm'>{@link ConfigFileStoreBuilder#directory(File)}
055    *       </ul>
056    * </ul>
057    *
058    * <h5 class='section'>Description:</h5>
059    * <p>
060    * Identifies the path of the directory containing the configuration files.
061    */
062   public static final String FILESTORE_directory = PREFIX + "directory.s";
063
064   /**
065    * Configuration property:  Charset.
066    *
067    * <h5 class='section'>Property:</h5>
068    * <ul>
069    *    <li><b>Name:</b>  <js>"ConfigFileStore.charset.s"</js>
070    *    <li><b>Data type:</b>  {@link Charset}
071    *    <li><b>Default:</b>  {@link Charset#defaultCharset()}
072    *    <li><b>Methods:</b>
073    *       <ul>
074    *          <li class='jm'>{@link ConfigFileStoreBuilder#charset(String)}
075    *          <li class='jm'>{@link ConfigFileStoreBuilder#charset(Charset)}
076    *       </ul>
077    * </ul>
078    *
079    * <h5 class='section'>Description:</h5>
080    * <p>
081    * Identifies the charset of external files.
082    */
083   public static final String FILESTORE_charset = PREFIX + "charset.s";
084
085   /**
086    * Configuration property:  Use watcher.
087    *
088    * <h5 class='section'>Property:</h5>
089    * <ul>
090    *    <li><b>Name:</b>  <js>"ConfigFileStore.useWatcher.b"</js>
091    *    <li><b>Data type:</b>  <code>Boolean</code>
092    *    <li><b>Default:</b>  <jk>false</jk>
093    *    <li><b>Methods:</b>
094    *       <ul>
095    *          <li class='jm'>{@link ConfigFileStoreBuilder#useWatcher()}
096    *       </ul>
097    * </ul>
098    *
099    * <h5 class='section'>Description:</h5>
100    * <p>
101    * Use a file system watcher for file system changes.
102    *
103    * <h5 class='section'>Notes:</h5>
104    * <ul class='spaced-list'>
105    *    <li>Calling {@link #close()} on this object closes the watcher.
106    * </ul>
107    */
108   public static final String FILESTORE_useWatcher = PREFIX + "useWatcher.s";
109
110   /**
111    * Configuration property:  Watcher sensitivity.
112    *
113    * <h5 class='section'>Property:</h5>
114    * <ul>
115    *    <li><b>Name:</b>  <js>"ConfigFileStore.watcherSensitivity.s"</js>
116    *    <li><b>Data type:</b>  {@link WatcherSensitivity}
117    *    <li><b>Default:</b>  {@link WatcherSensitivity#MEDIUM}
118    *    <li><b>Methods:</b>
119    *       <ul>
120    *          <li class='jm'>{@link ConfigFileStoreBuilder#watcherSensitivity(WatcherSensitivity)}
121    *          <li class='jm'>{@link ConfigFileStoreBuilder#watcherSensitivity(String)}
122    *       </ul>
123    * </ul>
124    *
125    * <h5 class='section'>Description:</h5>
126    * <p>
127    * Determines how frequently the file system is polled for updates.
128    *
129    * <h5 class='section'>Notes:</h5>
130    * <ul class='spaced-list'>
131    *    <li>This relies on internal Sun packages and may not work on all JVMs.
132    * </ul>
133    */
134   public static final String FILESTORE_watcherSensitivity = PREFIX + "watcherSensitivity.s";
135
136   /**
137    * Configuration property:  Update-on-write.
138    *
139    * <h5 class='section'>Property:</h5>
140    * <ul>
141    *    <li><b>Name:</b>  <js>"ConfigFileStore.updateOnWrite.b"</js>
142    *    <li><b>Data type:</b>  <code>Boolean</code>
143    *    <li><b>Default:</b>  <jk>false</jk>
144    *    <li><b>Methods:</b>
145    *       <ul>
146    *          <li class='jm'>{@link ConfigFileStoreBuilder#updateOnWrite()}
147    *       </ul>
148    * </ul>
149    *
150    * <h5 class='section'>Description:</h5>
151    * <p>
152    * When enabled, the {@link #update(String, String)} method will be called immediately following
153    * calls to {@link #write(String, String, String)} when the contents are changing.
154    * <br>This allows for more immediate responses to configuration changes on file systems that use
155    * polling watchers.
156    * <br>This may cause double-triggering of {@link ConfigStoreListener ConfigStoreListeners}.
157    */
158   public static final String FILESTORE_updateOnWrite = PREFIX + "updateOnWrite.b";
159
160   /**
161    * Configuration property:  File extensions.
162    *
163    * <h5 class='section'>Property:</h5>
164    * <ul>
165    *    <li><b>Name:</b>  <js>"ConfigFileStore.extensions.ls"</js>
166    *    <li><b>Data type:</b>  <code>String[]</code>
167    *    <li><b>Default:</b>  <jk>"cfg"</jk>
168    *    <li><b>Methods:</b>
169    *       <ul>
170    *          <li class='jm'>{@link ConfigFileStoreBuilder#extensions(String...)}
171    *       </ul>
172    * </ul>
173    *
174    * <h5 class='section'>Description:</h5>
175    * <p>
176    * Defines what file extensions to search for when the config name does not have an extension.
177    */
178   public static final String FILESTORE_extensions = PREFIX + "extensions.ls";
179
180   //-------------------------------------------------------------------------------------------------------------------
181   // Predefined instances
182   //-------------------------------------------------------------------------------------------------------------------
183
184   /** Default file store, all default values.*/
185   public static final ConfigFileStore DEFAULT = ConfigFileStore.create().build();
186
187
188   //-------------------------------------------------------------------------------------------------------------------
189   // Instance
190   //-------------------------------------------------------------------------------------------------------------------
191
192   /**
193    * Create a new builder for this object.
194    *
195    * @return A new builder for this object.
196    */
197   public static ConfigFileStoreBuilder create() {
198      return new ConfigFileStoreBuilder();
199   }
200
201   @Override /* Context */
202   public ConfigFileStoreBuilder builder() {
203      return new ConfigFileStoreBuilder(getPropertyStore());
204   }
205
206   private final File dir;
207   private final Charset charset;
208   private final WatcherThread watcher;
209   private final boolean updateOnWrite;
210   private final ConcurrentHashMap<String,String> cache = new ConcurrentHashMap<>();
211   private final ConcurrentHashMap<String,String> nameCache = new ConcurrentHashMap<>();
212   private final String[] extensions;
213
214   /**
215    * Constructor.
216    *
217    * @param ps The settings for this content store.
218    */
219   protected ConfigFileStore(PropertyStore ps) {
220      super(ps);
221      try {
222         dir = new File(getStringProperty(FILESTORE_directory, ".")).getCanonicalFile();
223         dir.mkdirs();
224         charset = getProperty(FILESTORE_charset, Charset.class, Charset.defaultCharset());
225         updateOnWrite = getBooleanProperty(FILESTORE_updateOnWrite, false);
226         extensions = getArrayProperty(FILESTORE_extensions, String.class, new String[]{"cfg"});
227         WatcherSensitivity ws = getProperty(FILESTORE_watcherSensitivity, WatcherSensitivity.class, WatcherSensitivity.MEDIUM);
228         watcher = getBooleanProperty(FILESTORE_useWatcher, false) ? new WatcherThread(dir, ws) : null;
229         if (watcher != null)
230            watcher.start();
231
232      } catch (Exception e) {
233         throw new RuntimeException(e);
234      }
235   }
236
237   @Override /* ConfigStore */
238   public synchronized String read(String name) throws IOException {
239      name = resolveName(name);
240
241      Path p = resolveFile(name);
242      name = p.getFileName().toString();
243
244      String s = cache.get(name);
245      if (s != null)
246         return s;
247
248      dir.mkdirs();
249
250      // If file doesn't exist, don't trigger creation.
251      if (! Files.exists(p))
252         return "";
253
254      boolean isWritable = isWritable(p);
255      OpenOption[] oo = isWritable ? new OpenOption[]{READ,WRITE,CREATE} : new OpenOption[]{READ};
256
257      try (FileChannel fc = FileChannel.open(p, oo)) {
258         try (FileLock lock = isWritable ? fc.lock() : null) {
259            ByteBuffer buf = ByteBuffer.allocate(1024);
260            StringBuilder sb = new StringBuilder();
261            while (fc.read(buf) != -1) {
262               sb.append(charset.decode((ByteBuffer)(buf.flip())));
263               buf.clear();
264            }
265            s = sb.toString();
266            cache.put(name, s);
267         }
268      }
269
270      return cache.get(name);
271   }
272
273   @Override /* ConfigStore */
274   public synchronized String write(String name, String expectedContents, String newContents) throws IOException {
275      name = resolveName(name);
276
277      // This is a no-op.
278      if (isEquals(expectedContents, newContents))
279         return null;
280
281      dir.mkdirs();
282
283      Path p = resolveFile(name);
284      name = p.getFileName().toString();
285
286      boolean exists = Files.exists(p);
287
288      // Don't create the file if we're not going to match.
289      if ((!exists) && isNotEmpty(expectedContents))
290         return "";
291
292      if (isWritable(p)) {
293         if (newContents == null)
294            Files.delete(p);
295         else {
296            try (FileChannel fc = FileChannel.open(p, READ, WRITE, CREATE)) {
297               try (FileLock lock = fc.lock()) {
298                  String currentContents = "";
299                  if (exists) {
300                     ByteBuffer buf = ByteBuffer.allocate(1024);
301                     StringBuilder sb = new StringBuilder();
302                     while (fc.read(buf) != -1) {
303                        sb.append(charset.decode((ByteBuffer)(buf.flip())));
304                        buf.clear();
305                     }
306                     currentContents = sb.toString();
307                  }
308                  if (expectedContents != null && ! isEquals(currentContents, expectedContents)) {
309                     if (currentContents == null)
310                        cache.remove(name);
311                     else
312                        cache.put(name, currentContents);
313                     return currentContents;
314                  }
315                  fc.position(0);
316                  fc.write(charset.encode(newContents));
317               }
318            }
319         }
320      }
321
322      if (updateOnWrite)
323         update(name, newContents);
324      else
325         cache.remove(name);  // Invalidate the cache.
326
327      return null;
328   }
329
330   @Override /* ConfigStore */
331   public synchronized boolean exists(String name) {
332      return Files.exists(resolveFile(name));
333   }
334
335   private Path resolveFile(String name) {
336      return dir.toPath().resolve(resolveName(name));
337   }
338
339   @Override
340   protected String resolveName(String name) {
341      if (! nameCache.containsKey(name)) {
342         String n = null;
343
344         // Does file exist as-is?
345         if (FileUtils.exists(dir, name))
346            n = name;
347
348         // Does name already have an extension?
349         if (n == null) {
350            for (String ext : extensions) {
351               if (FileUtils.hasExtension(name, ext)) {
352                  n = name;
353                  break;
354               }
355            }
356         }
357
358         // Find file with the correct extension.
359         if (n == null) {
360            for (String ext : extensions) {
361               if (FileUtils.exists(dir, name + '.' + ext)) {
362                  n = name + '.' + ext;
363                  break;
364               }
365            }
366         }
367
368         // If file not found, use the default which is the name with the first extension.
369         if (n == null)
370            n = extensions.length == 0 ? name : (name + "." + extensions[0]);
371
372         nameCache.put(name, n);
373      }
374      return nameCache.get(name);
375   }
376
377   private synchronized boolean isWritable(Path p) {
378      try {
379         if (! Files.exists(p)) {
380            Files.createDirectories(p.getParent());
381            if (! Files.exists(p))
382               p.toFile().createNewFile();
383         }
384      } catch (IOException e) {
385         return false;
386      }
387      return Files.isWritable(p);
388   }
389
390   @Override /* ConfigStore */
391   public synchronized ConfigFileStore update(String name, String newContents) {
392      cache.put(name, newContents);
393      super.update(name, newContents);
394      return this;
395   }
396
397   @Override /* Closeable */
398   public synchronized void close() {
399      if (watcher != null)
400         watcher.interrupt();
401   }
402
403
404   //---------------------------------------------------------------------------------------------
405   // WatcherThread
406   //---------------------------------------------------------------------------------------------
407
408   final class WatcherThread extends Thread {
409      private final WatchService watchService;
410
411      WatcherThread(File dir, WatcherSensitivity s) throws Exception {
412         watchService = FileSystems.getDefault().newWatchService();
413         WatchEvent.Kind<?>[] kinds = new WatchEvent.Kind[]{ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY};
414         WatchEvent.Modifier modifier = lookupModifier(s);
415         dir.toPath().register(watchService, kinds, modifier);
416      }
417
418      @SuppressWarnings("restriction")
419      private WatchEvent.Modifier lookupModifier(WatcherSensitivity s) {
420         try {
421            switch(s) {
422               case LOW: return com.sun.nio.file.SensitivityWatchEventModifier.LOW;
423               case MEDIUM: return com.sun.nio.file.SensitivityWatchEventModifier.MEDIUM;
424               case HIGH: return com.sun.nio.file.SensitivityWatchEventModifier.HIGH;
425            }
426         } catch (Exception e) {
427            /* Ignore */
428         }
429         return null;
430
431      }
432
433      @SuppressWarnings("unchecked")
434      @Override /* Thread */
435      public void run() {
436          try {
437            WatchKey key;
438            while ((key = watchService.take()) != null) {
439                for (WatchEvent<?> event : key.pollEvents()) {
440                    WatchEvent.Kind<?> kind = event.kind();
441                    if (kind != OVERFLOW)
442                        ConfigFileStore.this.onFileEvent(((WatchEvent<Path>)event));
443                }
444                if (! key.reset())
445                     break;
446            }
447         } catch (Exception e) {
448            e.printStackTrace();
449            throw new RuntimeException(e);
450         }
451      };
452
453      @Override /* Thread */
454      public void interrupt() {
455         try {
456            watchService.close();
457         } catch (IOException e) {
458            throw new RuntimeException(e);
459         } finally {
460            super.interrupt();
461         }
462      }
463   }
464
465   /**
466    * Gets called when the watcher service on this store is triggered with a file system change.
467    *
468    * @param e The file system event.
469    * @throws IOException
470    */
471   protected synchronized void onFileEvent(WatchEvent<Path> e) throws IOException {
472      String fn = e.context().getFileName().toString();
473
474      String oldContents = cache.get(fn);
475      cache.remove(fn);
476      String newContents = read(fn);
477      if (! isEquals(oldContents, newContents)) {
478         update(fn, newContents);
479      }
480   }
481}