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.annotation.*;
028import org.apache.juneau.internal.*;
029
030/**
031 * Filesystem-based storage location for configuration files.
032 *
033 * <p>
034 * Points to a file system directory containing configuration files.
035 */
036@ConfigurableContext
037public class ConfigFileStore extends ConfigStore {
038
039   //-------------------------------------------------------------------------------------------------------------------
040   // Configurable properties
041   //-------------------------------------------------------------------------------------------------------------------
042
043   static final String PREFIX = "ConfigFileStore";
044
045   /**
046    * Configuration property:  Local file system directory.
047    *
048    * <h5 class='section'>Property:</h5>
049    * <ul>
050    *    <li><b>Name:</b>  <js>"ConfigFileStore.directory.s"</js>
051    *    <li><b>Data type:</b>  <c>String</c>
052    *    <li><b>Default:</b>  <js>"."</js>
053    *    <li><b>Methods:</b>
054    *       <ul>
055    *          <li class='jm'>{@link ConfigFileStoreBuilder#directory(String)}
056    *          <li class='jm'>{@link ConfigFileStoreBuilder#directory(File)}
057    *       </ul>
058    * </ul>
059    *
060    * <h5 class='section'>Description:</h5>
061    * <p>
062    * Identifies the path of the directory containing the configuration files.
063    */
064   public static final String FILESTORE_directory = PREFIX + ".directory.s";
065
066   /**
067    * Configuration property:  Charset.
068    *
069    * <h5 class='section'>Property:</h5>
070    * <ul>
071    *    <li><b>Name:</b>  <js>"ConfigFileStore.charset.s"</js>
072    *    <li><b>Data type:</b>  {@link Charset}
073    *    <li><b>Default:</b>  {@link Charset#defaultCharset()}
074    *    <li><b>Methods:</b>
075    *       <ul>
076    *          <li class='jm'>{@link ConfigFileStoreBuilder#charset(String)}
077    *          <li class='jm'>{@link ConfigFileStoreBuilder#charset(Charset)}
078    *       </ul>
079    * </ul>
080    *
081    * <h5 class='section'>Description:</h5>
082    * <p>
083    * Identifies the charset of external files.
084    */
085   public static final String FILESTORE_charset = PREFIX + ".charset.s";
086
087   /**
088    * Configuration property:  Use watcher.
089    *
090    * <h5 class='section'>Property:</h5>
091    * <ul>
092    *    <li><b>Name:</b>  <js>"ConfigFileStore.useWatcher.b"</js>
093    *    <li><b>Data type:</b>  <c>Boolean</c>
094    *    <li><b>Default:</b>  <jk>false</jk>
095    *    <li><b>Methods:</b>
096    *       <ul>
097    *          <li class='jm'>{@link ConfigFileStoreBuilder#useWatcher()}
098    *       </ul>
099    * </ul>
100    *
101    * <h5 class='section'>Description:</h5>
102    * <p>
103    * Use a file system watcher for file system changes.
104    *
105    * <ul class='notes'>
106    *    <li>Calling {@link #close()} on this object closes the watcher.
107    * </ul>
108    */
109   public static final String FILESTORE_useWatcher = PREFIX + ".useWatcher.s";
110
111   /**
112    * Configuration property:  Watcher sensitivity.
113    *
114    * <h5 class='section'>Property:</h5>
115    * <ul>
116    *    <li><b>Name:</b>  <js>"ConfigFileStore.watcherSensitivity.s"</js>
117    *    <li><b>Data type:</b>  {@link WatcherSensitivity}
118    *    <li><b>Default:</b>  {@link WatcherSensitivity#MEDIUM}
119    *    <li><b>Methods:</b>
120    *       <ul>
121    *          <li class='jm'>{@link ConfigFileStoreBuilder#watcherSensitivity(WatcherSensitivity)}
122    *          <li class='jm'>{@link ConfigFileStoreBuilder#watcherSensitivity(String)}
123    *       </ul>
124    * </ul>
125    *
126    * <h5 class='section'>Description:</h5>
127    * <p>
128    * Determines how frequently the file system is polled for updates.
129    *
130    * <ul class='notes'>
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>  <c>Boolean</c>
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.s"</js>
166    *    <li><b>Data type:</b>  <c>String</c> (comma-delimited list)
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.s";
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 = getCdlProperty(FILESTORE_extensions, "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 Thrown by underlying stream.
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
482   //-----------------------------------------------------------------------------------------------------------------
483   // Other methods.
484   //-----------------------------------------------------------------------------------------------------------------
485
486   @Override /* Context */
487   public ObjectMap toMap() {
488      return super.toMap()
489         .append("ConfigFileStore", new DefaultFilteringObjectMap()
490            .append("charset", charset)
491            .append("extensions", extensions)
492            .append("updateOnWrite", updateOnWrite)
493         );
494   }
495}