001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.config.store;
018
019import static java.nio.file.StandardOpenOption.*;
020import static java.nio.file.StandardWatchEventKinds.*;
021import static org.apache.juneau.commons.utils.AssertionUtils.*;
022import static org.apache.juneau.commons.utils.CollectionUtils.*;
023import static org.apache.juneau.commons.utils.FileUtils.*;
024import static org.apache.juneau.commons.utils.StringUtils.*;
025import static org.apache.juneau.commons.utils.ThrowableUtils.*;
026import static org.apache.juneau.commons.utils.Utils.*;
027
028import java.io.*;
029import java.lang.annotation.*;
030import java.nio.*;
031import java.nio.channels.*;
032import java.nio.charset.*;
033import java.nio.file.*;
034import java.util.concurrent.*;
035
036import org.apache.juneau.*;
037import org.apache.juneau.commons.collections.*;
038import org.apache.juneau.commons.collections.FluentMap;
039
040/**
041 * Filesystem-based storage location for configuration files.
042 *
043 * <p>
044 * Points to a file system directory containing configuration files.
045 *
046 * <h5 class='section'>Notes:</h5><ul>
047 *    <li class='note'>This class is thread safe and reusable.
048 * </ul>
049 */
050@SuppressWarnings("resource")
051public class FileStore extends ConfigStore {
052   /**
053    * Builder class.
054    */
055   public static class Builder extends ConfigStore.Builder {
056
057      private Charset charset;
058      private boolean enableWatcher;
059      private boolean updateOnWrite;
060      private String directory;
061      private String extensions;
062      private WatcherSensitivity watcherSensitivity;
063
064      /**
065       * Constructor, default settings.
066       */
067      protected Builder() {
068         charset = env("ConfigFileStore.charset").map(x -> Charset.forName(x)).orElse(Charset.defaultCharset());
069         directory = env("ConfigFileStore.directory", ".");
070         enableWatcher = env("ConfigFileStore.enableWatcher", false);
071         extensions = env("ConfigFileStore.extensions", "cfg");
072         updateOnWrite = env("ConfigFileStore.updateOnWrite", false);
073         watcherSensitivity = env("ConfigFileStore.watcherSensitivity", WatcherSensitivity.MEDIUM);
074      }
075
076      /**
077       * Copy constructor.
078       *
079       * @param copyFrom The builder to copy from.
080       *    <br>Cannot be <jk>null</jk>.
081       */
082      protected Builder(Builder copyFrom) {
083         super(assertArgNotNull("copyFrom", copyFrom));
084         charset = copyFrom.charset;
085         directory = copyFrom.directory;
086         enableWatcher = copyFrom.enableWatcher;
087         extensions = copyFrom.extensions;
088         updateOnWrite = copyFrom.updateOnWrite;
089         watcherSensitivity = copyFrom.watcherSensitivity;
090      }
091
092      /**
093       * Copy constructor.
094       *
095       * @param copyFrom The bean to copy from.
096       *    <br>Cannot be <jk>null</jk>.
097       */
098      protected Builder(FileStore copyFrom) {
099         super(assertArgNotNull("copyFrom", copyFrom));
100         type(copyFrom.getClass());
101         charset = copyFrom.charset;
102         directory = copyFrom.directory;
103         enableWatcher = copyFrom.enableWatcher;
104         extensions = copyFrom.extensions;
105         updateOnWrite = copyFrom.updateOnWrite;
106         watcherSensitivity = copyFrom.watcherSensitivity;
107      }
108
109      @Override /* Overridden from Builder */
110      public Builder annotations(Annotation...values) {
111         super.annotations(values);
112         return this;
113      }
114
115      @Override /* Overridden from Builder */
116      public Builder apply(AnnotationWorkList work) {
117         super.apply(work);
118         return this;
119      }
120
121      @Override /* Overridden from Builder */
122      public Builder applyAnnotations(Class<?>...from) {
123         super.applyAnnotations(from);
124         return this;
125      }
126
127      @Override /* Overridden from Builder */
128      public Builder applyAnnotations(Object...from) {
129         super.applyAnnotations(from);
130         return this;
131      }
132
133      @Override /* Overridden from Context.Builder */
134      public FileStore build() {
135         return build(FileStore.class);
136      }
137
138      @Override /* Overridden from Builder */
139      public Builder cache(Cache<HashKey,? extends org.apache.juneau.Context> value) {
140         super.cache(value);
141         return this;
142      }
143
144      /**
145       * Charset for external files.
146       *
147       * <p>
148       * Identifies the charset of external files.
149       *
150       * @param value
151       *    The new value for this property.
152       *    <br>The default is the first value found:
153       *    <ul>
154       *       <li>System property <js>"ConfigFileStore.charset"
155       *       <li>Environment variable <js>"CONFIGFILESTORE_CHARSET"
156       *       <li>{@link Charset#defaultCharset()}
157       *    </ul>
158       *    <br>Cannot be <jk>null</jk>.
159       * @return This object.
160       */
161      public Builder charset(Charset value) {
162         charset = assertArgNotNull("value", value);
163         return this;
164      }
165
166      @Override /* Overridden from Context.Builder */
167      public Builder copy() {
168         return new Builder(this);
169      }
170
171      @Override /* Overridden from Builder */
172      public Builder debug() {
173         super.debug();
174         return this;
175      }
176
177      @Override /* Overridden from Builder */
178      public Builder debug(boolean value) {
179         super.debug(value);
180         return this;
181      }
182
183      /**
184       * Local file system directory.
185       *
186       * <p>
187       * Identifies the path of the directory containing the configuration files.
188       *
189       * @param value
190       *    The new value for this property.
191       *    <br>The default is the first value found:
192       *    <ul>
193       *       <li>System property <js>"ConfigFileStore.directory"
194       *       <li>Environment variable <js>"CONFIGFILESTORE_DIRECTORY"
195       *       <li><js>"."</js>.
196       *    </ul>
197       *    <br>Cannot be <jk>null</jk>.
198       * @return This object.
199       */
200      public Builder directory(File value) {
201         directory = assertArgNotNull("value", value).getAbsolutePath();
202         return this;
203      }
204
205      /**
206       * Local file system directory.
207       *
208       * <p>
209       * Identifies the path of the directory containing the configuration files.
210       *
211       * @param value
212       *    The new value for this property.
213       *    <br>The default is the first value found:
214       *    <ul>
215       *       <li>System property <js>"ConfigFileStore.directory"
216       *       <li>Environment variable <js>"CONFIGFILESTORE_DIRECTORY"
217       *       <li><js>"."</js>
218       *    </ul>
219       *    <br>Cannot be <jk>null</jk>.
220       * @return This object.
221       */
222      public Builder directory(String value) {
223         directory = assertArgNotNull("value", value);
224         return this;
225      }
226
227      /**
228       * Use watcher.
229       *
230       * <p>
231       * Use a file system watcher for file system changes.
232       *
233       * <h5 class='section'>Notes:</h5><ul>
234       *    <li class='note'>Calling {@link FileStore#close()} closes the watcher.
235       * </ul>
236       *
237       * <p>
238       *    The default is the first value found:
239       *    <ul>
240       *       <li>System property <js>"ConfigFileStore.enableWatcher"
241       *       <li>Environment variable <js>"CONFIGFILESTORE_ENABLEWATCHER"
242       *       <li><jk>false</jk>.
243       *    </ul>
244       *
245       * @return This object.
246       */
247      public Builder enableWatcher() {
248         enableWatcher = true;
249         return this;
250      }
251
252      /**
253       * File extensions.
254       *
255       * <p>
256       * Defines what file extensions to search for when the config name does not have an extension.
257       *
258       * @param value
259       *    The new value for this property.
260       *    The default is the first value found:
261       *    <ul>
262       *       <li>System property <js>"ConfigFileStore.extensions"
263       *       <li>Environment variable <js>"CONFIGFILESTORE_EXTENSIONS"
264       *       <li><js>"cfg"</js>
265       *    </ul>
266       *    <br>Cannot be <jk>null</jk>.
267       * @return This object.
268       */
269      public Builder extensions(String value) {
270         extensions = assertArgNotNull("value", value);
271         return this;
272      }
273
274      @Override /* Overridden from Builder */
275      public Builder impl(Context value) {
276         super.impl(value);
277         return this;
278      }
279
280      @Override /* Overridden from Builder */
281      public Builder type(Class<? extends org.apache.juneau.Context> value) {
282         super.type(value);
283         return this;
284      }
285
286      /**
287       * Update-on-write.
288       *
289       * <p>
290       * When enabled, the {@link FileStore#update(String, String)} method will be called immediately following
291       * calls to {@link FileStore#write(String, String, String)} when the contents are changing.
292       * <br>This allows for more immediate responses to configuration changes on file systems that use
293       * polling watchers.
294       * <br>This may cause double-triggering of {@link ConfigStoreListener ConfigStoreListeners}.
295       *
296       * <p>
297       *    The default is the first value found:
298       *    <ul>
299       *       <li>System property <js>"ConfigFileStore.updateOnWrite"
300       *       <li>Environment variable <js>"CONFIGFILESTORE_UPDATEONWRITE"
301       *       <li><jk>false</jk>.
302       *    </ul>
303       *
304       * @return This object.
305       */
306      public Builder updateOnWrite() {
307         updateOnWrite = true;
308         return this;
309      }
310
311      /**
312       * Watcher sensitivity.
313       *
314       * <p>
315       * Determines how frequently the file system is polled for updates.
316       *
317       * <h5 class='section'>Notes:</h5><ul>
318       *    <li class='note'>This relies on internal Sun packages and may not work on all JVMs.
319       * </ul>
320       *
321       * @param value
322       *    The new value for this property.
323       *    <br>The default is the first value found:
324       *    <ul>
325       *       <li>System property <js>"ConfigFileStore.watcherSensitivity"
326       *       <li>Environment variable <js>"CONFIGFILESTORE_WATCHERSENSITIVITY"
327       *       <li>{@link WatcherSensitivity#MEDIUM}
328       *    </ul>
329       *    <br>Cannot be <jk>null</jk>.
330       * @return This object.
331       */
332      public Builder watcherSensitivity(WatcherSensitivity value) {
333         watcherSensitivity = assertArgNotNull("value", value);
334         return this;
335      }
336   }
337
338   class WatcherThread extends Thread {
339      private final WatchService watchService;
340
341      WatcherThread(File dir, WatcherSensitivity s) throws Exception {
342         watchService = FileSystems.getDefault().newWatchService();
343         var kinds = a(ENTRY_CREATE, ENTRY_DELETE, ENTRY_MODIFY);
344         var modifier = lookupModifier(s);
345         dir.toPath().register(watchService, kinds, modifier);
346      }
347
348      @Override /* Overridden from Thread */
349      public void interrupt() {
350         try {
351            watchService.close();
352         } catch (IOException e) {
353            throw toRex(e);
354         } finally {
355            super.interrupt();
356         }
357      }
358
359      @SuppressWarnings("unchecked")
360      @Override /* Overridden from Thread */
361      public void run() {
362         try {
363            WatchKey key;
364            while (nn(key = watchService.take())) {
365               for (var event : key.pollEvents()) {
366                  var kind = event.kind();
367                  if (kind != OVERFLOW)
368                     FileStore.this.onFileEvent(((WatchEvent<Path>)event));
369               }
370               if (! key.reset())
371                  break;
372            }
373         } catch (Exception e) {
374            throw toRex(e);
375         }
376      }
377
378      private WatchEvent.Modifier lookupModifier(WatcherSensitivity s) {
379         try {
380            return switch (s) {
381               case LOW -> com.sun.nio.file.SensitivityWatchEventModifier.LOW;
382               case MEDIUM -> com.sun.nio.file.SensitivityWatchEventModifier.MEDIUM;
383               case HIGH -> com.sun.nio.file.SensitivityWatchEventModifier.HIGH;
384            };
385         } catch (@SuppressWarnings("unused") Exception e) {
386            /* Ignore */
387         }
388         return null;
389      }
390   }
391
392   /** Default file store, all default values.*/
393   public static final FileStore DEFAULT = FileStore.create().build();
394
395   /**
396    * Creates a new builder for this object.
397    *
398    * @return A new builder.
399    */
400   public static Builder create() {
401      return new Builder();
402   }
403
404   protected final boolean enableWatcher;
405   protected final boolean updateOnWrite;
406   protected final Charset charset;
407   protected final String directory;
408   protected final String extensions;
409   protected final WatcherSensitivity watcherSensitivity;
410
411   private final ConcurrentHashMap<String,String> cache = new ConcurrentHashMap<>();
412   private final ConcurrentHashMap<String,String> nameCache = new ConcurrentHashMap<>();
413   private final File dir;
414   private final String[] exts;
415   private final WatcherThread watcher;
416
417   /**
418    * Constructor.
419    *
420    * @param builder The builder for this object.
421    */
422   public FileStore(Builder builder) {
423      super(builder);
424      directory = builder.directory;
425      extensions = builder.extensions;
426      charset = builder.charset;
427      enableWatcher = builder.enableWatcher;
428      updateOnWrite = builder.updateOnWrite;
429      watcherSensitivity = builder.watcherSensitivity;
430      try {
431         dir = new File(directory).getCanonicalFile();
432         dir.mkdirs();
433         exts = split(extensions).toArray(String[]::new);
434         watcher = enableWatcher ? new WatcherThread(dir, watcherSensitivity) : null;
435         if (nn(watcher))
436            watcher.start();
437      } catch (Exception e) {
438         throw toRex(e);
439      }
440   }
441
442   @Override /* Overridden from Closeable */
443   public synchronized void close() {
444      if (nn(watcher))
445         watcher.interrupt();
446   }
447
448   @Override /* Overridden from Context */
449   public Builder copy() {
450      return new Builder(this);
451   }
452
453   @Override /* Overridden from ConfigStore */
454   public synchronized boolean exists(String name) {
455      return Files.exists(resolveFile(name));
456   }
457
458   @Override /* Overridden from ConfigStore */
459   public synchronized String read(String name) throws IOException {
460      name = resolveName(name);
461
462      var p = resolveFile(name);
463      name = p.getFileName().toString();
464
465      var s = cache.get(name);
466      if (nn(s))
467         return s;
468
469      dir.mkdirs();
470
471      // If file doesn't exist, don't trigger creation.
472      if (! Files.exists(p))
473         return "";
474
475      var isWritable = isWritable(p);
476      var oo = isWritable ? a(READ, WRITE, CREATE) : a(READ);
477
478      try (var fc = FileChannel.open(p, oo)) {
479         try (var lock = isWritable ? fc.lock() : null) {
480            var buf = ByteBuffer.allocate(1024);
481            var sb = new StringBuilder();
482            while (fc.read(buf) != -1) {
483               sb.append(charset.decode((buf.flip()))); // Fixes Java 11 issue involving overridden flip method.
484               buf.clear();
485            }
486            s = sb.toString();
487            cache.put(name, s);
488         }
489      }
490
491      return cache.get(name);
492   }
493
494   @Override /* Overridden from ConfigStore */
495   public synchronized FileStore update(String name, String newContents) {
496      cache.put(name, newContents);
497      super.update(name, newContents);
498      return this;
499   }
500
501   @Override /* Overridden from ConfigStore */
502   public synchronized String write(String name, String expectedContents, String newContents) throws IOException {
503      name = resolveName(name);
504
505      // This is a no-op.
506      if (eq(expectedContents, newContents))
507         return null;
508
509      dir.mkdirs();
510
511      var p = resolveFile(name);
512      name = p.getFileName().toString();
513
514      var exists = Files.exists(p);
515
516      // Don't create the file if we're not going to match.
517      if ((! exists) && ne(expectedContents))
518         return "";
519
520      if (isWritable(p)) {
521         if (newContents == null)
522            Files.delete(p);
523         else {
524            try (var fc = FileChannel.open(p, READ, WRITE, CREATE)) {
525               try (var lock = fc.lock()) {
526                  var currentContents = "";
527                  if (exists) {
528                     var buf = ByteBuffer.allocate(1024);
529                     var sb = new StringBuilder();
530                     while (fc.read(buf) != -1) {
531                        sb.append(charset.decode(buf.flip()));
532                        buf.clear();
533                     }
534                     currentContents = sb.toString();
535                  }
536                  if (nn(expectedContents) && neq(currentContents, expectedContents)) {
537                     if (currentContents == null)
538                        cache.remove(name);
539                     else
540                        cache.put(name, currentContents);
541                     return currentContents;
542                  }
543                  fc.position(0);
544                  fc.write(charset.encode(newContents));
545               }
546            }
547         }
548      }
549
550      if (updateOnWrite)
551         update(name, newContents);
552      else
553         cache.remove(name);  // Invalidate the cache.
554
555      return null;
556   }
557
558   private synchronized boolean isWritable(Path p) {
559      try {
560         if (! Files.exists(p)) {
561            Files.createDirectories(p.getParent());
562            if (! Files.exists(p) && ! p.toFile().createNewFile()) {
563               throw ioex("Could not create file: {0}", p);
564            }
565         }
566      } catch (@SuppressWarnings("unused") IOException e) {
567         return false;
568      }
569      return Files.isWritable(p);
570   }
571
572   private Path resolveFile(String name) {
573      return dir.toPath().resolve(resolveName(name));
574   }
575
576   /**
577    * Gets called when the watcher service on this store is triggered with a file system change.
578    *
579    * @param e The file system event.
580    * @throws IOException Thrown by underlying stream.
581    */
582   protected synchronized void onFileEvent(WatchEvent<Path> e) throws IOException {
583      var fn = e.context().getFileName().toString();
584
585      var oldContents = cache.get(fn);
586      cache.remove(fn);
587      var newContents = read(fn);
588
589      if (neq(oldContents, newContents)) {
590         update(fn, newContents);
591      }
592   }
593
594   @Override /* Overridden from ConfigStore */
595   protected FluentMap<String,Object> properties() {
596      return super.properties()
597         .a("charset", charset)
598         .a("extensions", extensions)
599         .a("updateOnWrite", updateOnWrite);
600   }
601
602   @Override
603   protected String resolveName(String name) {
604      if (! nameCache.containsKey(name)) {
605         var n = (String)null;
606
607         // Does file exist as-is?
608         if (fileExists(dir, name))
609            n = name;
610
611         // Does name already have an extension?
612         if (n == null) {
613            for (var ext : exts) {
614               if (hasExtension(name, ext)) {
615                  n = name;
616                  break;
617               }
618            }
619         }
620
621         // Find file with the correct extension.
622         if (n == null) {
623            for (var ext : exts) {
624               if (fileExists(dir, name + '.' + ext)) {
625                  n = name + '.' + ext;
626                  break;
627               }
628            }
629         }
630
631         // If file not found, use the default which is the name with the first extension.
632         if (n == null)
633            n = exts.length == 0 ? name : (name + "." + exts[0]);
634
635         nameCache.put(name, n);
636      }
637      return nameCache.get(name);
638   }
639}