001/* 002 * JGrapes Event Driven Framework 003 * Copyright (C) 2022 Michael N. Lipp 004 * 005 * This program is free software; you can redistribute it and/or modify it 006 * under the terms of the GNU Affero General Public License as published by 007 * the Free Software Foundation; either version 3 of the License, or 008 * (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, but 011 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 012 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License 013 * for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License along 016 * with this program; if not, see <http://www.gnu.org/licenses/>. 017 */ 018 019package org.jgrapes.util; 020 021import com.electronwill.nightconfig.core.Config; 022import com.electronwill.nightconfig.core.file.FileConfig; 023import java.io.File; 024import java.io.IOException; 025import java.util.ArrayList; 026import java.util.Collection; 027import java.util.HashMap; 028import java.util.List; 029import java.util.Map; 030import java.util.Optional; 031import java.util.StringTokenizer; 032import java.util.logging.Logger; 033import java.util.prefs.BackingStoreException; 034import org.jgrapes.core.Channel; 035import org.jgrapes.core.annotation.Handler; 036import org.jgrapes.core.events.Start; 037import org.jgrapes.util.events.ConfigurationUpdate; 038import org.jgrapes.util.events.FileChanged; 039import org.jgrapes.util.events.InitialConfiguration; 040 041/** 042 * A base class for configuration stored based on the 043 * [night config library](https://github.com/TheElectronWill/night-config). 044 */ 045@SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", "PMD.AvoidDuplicateLiterals", 046 "PMD.GodClass" }) 047public abstract class NightConfigStore extends ConfigurationStore { 048 049 @SuppressWarnings("PMD.FieldNamingConventions") 050 protected static final Logger logger 051 = Logger.getLogger(NightConfigStore.class.getName()); 052 053 protected FileConfig config; 054 protected Channel[] initialChannels; 055 056 /** 057 * Creates a new component with its channel set to the given 058 * channel and the given file. The component handles 059 * {@link ConfigurationUpdate} events and {@link FileChanged} 060 * events for the configuration file (see 061 * @link #NightConfigStore(Channel, File, boolean, boolean)} 062 * 063 * @param componentChannel the channel 064 * @param file the file used to store the configuration 065 * @throws IOException 066 */ 067 @Deprecated 068 public NightConfigStore(Channel componentChannel, File file) 069 throws IOException { 070 this(componentChannel, file, true, true); 071 } 072 073 /** 074 * Creates a new component with its channel set to the given 075 * channel and the given file. The component handles 076 * {@link FileChanged} events for the configuration file (see 077 * @link #NightConfigStore(Channel, File, boolean, boolean)} 078 * 079 * If `update` is `true`, the configuration file is updated 080 * when {@link ConfigurationUpdate} events are received. 081 * 082 * @param componentChannel the channel 083 * @param file the file used to store the configuration 084 * @param update if the configuration file is to be updated 085 * @throws IOException Signals that an I/O exception has occurred. 086 */ 087 @Deprecated 088 @SuppressWarnings("PMD.ShortVariable") 089 public NightConfigStore(Channel componentChannel, File file, 090 boolean update) throws IOException { 091 this(componentChannel, file, update, true); 092 } 093 094 /** 095 * Creates a new component with its channel set to the given 096 * channel and the given file. 097 * 098 * If `update` is `true`, the configuration file is updated 099 * when {@link ConfigurationUpdate} events are received. 100 * 101 * If `watch` is `true`, {@link FileChanged} events are processed 102 * and the configuration file is reloaded when it changes. Note 103 * that the generation of the {@link FileChanged} events must 104 * be configured independently (see {@link FileSystemWatcher}). 105 * 106 * @param componentChannel the channel 107 * @param file the file used to store the configuration 108 * @param update if the configuration file is to be updated 109 * @param watch if {@link FileChanged} events are to be processed 110 * @throws IOException Signals that an I/O exception has occurred. 111 */ 112 @SuppressWarnings("PMD.ShortVariable") 113 public NightConfigStore(Channel componentChannel, File file, 114 boolean update, boolean watch) throws IOException { 115 super(componentChannel); 116 if (update) { 117 Handler.Evaluator.add(this, "onConfigurationUpdate", 118 channel().defaultCriterion()); 119 } 120 if (watch) { 121 Handler.Evaluator.add(this, "onFileChanged", 122 channel().defaultCriterion()); 123 } 124 if (!file.exists()) { 125 file.createNewFile(); 126 } 127 } 128 129 /** 130 * If watching the configuration file is enabled, fire 131 * a {@link ConfigurationUpdate} event with the complete 132 * configuration when the file changes. The event is fired 133 * on the same channel(s) as the {@link InitialConfiguration} 134 * event. 135 * 136 * @param event the event 137 */ 138 @Handler(dynamic = true) 139 public void onFileChanged(FileChanged event) { 140 if (config.getNioPath().equals(event.path()) 141 && event.change() == FileChanged.Kind.MODIFIED 142 && initialChannels != null) { 143 config.load(); 144 ConfigurationUpdate updEvt = new ConfigurationUpdate(); 145 addPrefs(updEvt, "/", config); 146 fire(updEvt, initialChannels); 147 } 148 } 149 150 @Override 151 @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", 152 "PMD.AvoidBranchingStatementAsLastInLoop", "PMD.CloseResource" }) 153 public Optional<Map<String, Object>> structured(String path) { 154 if (!path.startsWith("/")) { 155 throw new IllegalArgumentException("Path must start with \"/\"."); 156 } 157 158 // Walk down to node. 159 var segs = new StringTokenizer(path, "/"); 160 @SuppressWarnings("PMD.CloseResource") 161 Config cur = config; 162 while (segs.hasMoreTokens()) { 163 var nextSeg = segs.nextToken(); 164 Object next = Optional.ofNullable(cur.get("_" + nextSeg)) 165 .orElse(cur.get("/" + nextSeg)); 166 if (next instanceof Config) { 167 cur = (Config) next; 168 continue; 169 } 170 return Optional.empty(); 171 } 172 return Optional.of(toValueMap(cur)); 173 } 174 175 private Map<String, Object> toValueMap(Config config) { 176 @SuppressWarnings("PMD.UseConcurrentHashMap") 177 Map<String, Object> result = new HashMap<>(); 178 for (var entry : config.entrySet()) { 179 if (isNode(entry.getKey())) { 180 continue; 181 } 182 if (entry.getValue() instanceof Config) { 183 result.put(entry.getKey(), toValueMap(entry.getValue())); 184 continue; 185 } 186 if (entry.getValue() instanceof List<?> values) { 187 result.put(entry.getKey(), convertList(values)); 188 continue; 189 } 190 result.put(entry.getKey(), entry.getValue()); 191 } 192 return result; 193 } 194 195 private List<Object> convertList(List<?> values) { 196 List<Object> copy = new ArrayList<>(); 197 for (var element : values) { 198 if (element instanceof Config cfg) { 199 copy.add(toValueMap(cfg)); 200 continue; 201 } 202 copy.add(element); 203 } 204 return copy; 205 } 206 207 /** 208 * Checks if the name is an entry for a node. 209 * 210 * @param name the name 211 * @return true, if is node 212 */ 213 protected boolean isNode(String name) { 214 if (name == null || name.length() < 1) { 215 return false; 216 } 217 char first = name.charAt(0); 218 return first == '_' || first == '/'; 219 } 220 221 /** 222 * Intercepts the {@link Start} event and fires a 223 * {@link ConfigurationUpdate} event. 224 * 225 * @param event the event 226 * @throws BackingStoreException the backing store exception 227 * @throws InterruptedException the interrupted exception 228 */ 229 @Handler(priority = 999_999, channels = Channel.class) 230 @SuppressWarnings("PMD.CognitiveComplexity") 231 public void onStart(Start event) 232 throws BackingStoreException, InterruptedException { 233 ConfigurationUpdate updEvt = new InitialConfiguration(); 234 addPrefs(updEvt, "/", config); 235 initialChannels = event.channels(); 236 newEventPipeline().fire(updEvt, initialChannels).get(); 237 } 238 239 private void addPrefs(ConfigurationUpdate updEvt, String path, 240 Config config) { 241 @SuppressWarnings("PMD.UseConcurrentHashMap") 242 Map<String, Object> atPath = new HashMap<>(); 243 for (var e : config.entrySet()) { 244 if (isNode(e.getKey()) && e.getValue() instanceof Config) { 245 addPrefs(updEvt, ("/".equals(path) ? "" : path) 246 + "/" + e.getKey().substring(1), e.getValue()); 247 continue; 248 } 249 if (e.getValue() instanceof Config) { 250 atPath.put(e.getKey(), toValueMap(e.getValue())); 251 } else if (e.getValue() instanceof List<?> values) { 252 atPath.put(e.getKey(), convertList(values)); 253 } else { 254 atPath.put(e.getKey(), e.getValue()); 255 } 256 } 257 if (!atPath.isEmpty()) { 258 updEvt.set(path, atPath); 259 } 260 } 261 262 /** 263 * Merges and saves configuration updates. 264 * 265 * @param event the event 266 * @throws IOException Signals that an I/O exception has occurred. 267 */ 268 @Handler(dynamic = true) 269 @SuppressWarnings({ "PMD.CognitiveComplexity", "PMD.NPathComplexity", 270 "PMD.AvoidLiteralsInIfCondition", 271 "PMD.AvoidInstantiatingObjectsInLoops" }) 272 public void onConfigurationUpdate(ConfigurationUpdate event) 273 throws IOException { 274 if (event instanceof InitialConfiguration) { 275 return; 276 } 277 278 boolean changed = false; 279 for (String path : event.paths()) { 280 if ("/".equals(path) && event.values(path).isEmpty()) { 281 // Special case, "remove root", i.e. all configuration data 282 config.clear(); 283 changed = true; 284 continue; 285 } 286 if (handleSegment(config, new StringTokenizer(path, "/"), 287 event.structured(path).map(ConfigurationStore::flatten))) { 288 changed = true; 289 } 290 } 291 if (changed) { 292 config.save(); 293 } 294 } 295 296 @SuppressWarnings("PMD.DataflowAnomalyAnalysis") 297 private boolean handleSegment(Config config, 298 StringTokenizer tokenizer, Optional<Map<String, Object>> values) { 299 if (!tokenizer.hasMoreTokens()) { 300 // "Leaf" map 301 return mergeValues(config, values.get()); 302 } 303 boolean changed = false; 304 var nextSeg = tokenizer.nextToken(); 305 var usSel = List.of("_" + nextSeg); 306 var slashSel = List.of("/" + nextSeg); 307 if (!tokenizer.hasMoreTokens() && values.isEmpty()) { 308 // Selected is last segment from path and we must remove 309 for (var sel : List.of(usSel, slashSel)) { 310 if (config.get(sel) != null) { 311 // Delete sub-map. 312 config.remove(sel); 313 changed = true; 314 } 315 } 316 return changed; 317 } 318 // Check if sub config exists 319 Object subConfig = Optional.ofNullable(config.get(usSel)) 320 .orElse(config.get(slashSel)); 321 if (!(subConfig instanceof Config)) { 322 // Doesn't exist or is of wrong type, new sub-map 323 changed = true; 324 subConfig = config.createSubConfig(); 325 config.set(usSel, subConfig); 326 } 327 // Continue with sub-map 328 return handleSegment((Config) subConfig, tokenizer, values) || changed; 329 } 330 331 @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", 332 "PMD.CognitiveComplexity" }) 333 private boolean mergeValues(Config config, Map<String, Object> values) { 334 boolean changed = false; 335 Map<String, Object> curValues = flatten(toValueMap(config)); 336 for (var e : values.entrySet()) { 337 if (e.getValue() == null) { 338 // Delete from map (and config) 339 if (curValues.containsKey(e.getKey())) { 340 curValues.remove(e.getKey()); 341 changed = true; 342 } 343 continue; 344 } 345 Object oldValue = curValues.get(e.getKey()); 346 if (oldValue == null || !e.getValue().equals(oldValue)) { 347 curValues.put(e.getKey(), e.getValue()); 348 changed = true; 349 } 350 } 351 if (changed) { 352 for (var itr = config.entrySet().iterator(); itr.hasNext();) { 353 if (!isNode(itr.next().getKey())) { 354 itr.remove(); 355 } 356 } 357 addToConfig(config, structure(curValues)); 358 } 359 return changed; 360 } 361 362 @SuppressWarnings("unchecked") 363 private void addToConfig(Config config, Map<String, Object> map) { 364 for (var e : map.entrySet()) { 365 var selector = List.of(e.getKey()); 366 if (e.getValue() instanceof Map) { 367 Config subConfig = config.get(selector); 368 if (subConfig == null) { 369 subConfig = config.createSubConfig(); 370 config.set(selector, subConfig); 371 } 372 addToConfig(subConfig, (Map<String, Object>) e.getValue()); 373 } else if (e.getValue() instanceof Collection) { 374 config.set(selector, 375 checkCollection((Collection<?>) e.getValue())); 376 } else { 377 config.set(selector, e.getValue()); 378 } 379 } 380 } 381 382 @SuppressWarnings("unchecked") 383 private Collection<Object> checkCollection(Collection<?> items) { 384 var checked = new ArrayList<>(); 385 for (var item : items) { 386 if (item instanceof Map) { 387 Config subConfig = config.createSubConfig(); 388 addToConfig(subConfig, (Map<String, Object>) item); 389 checked.add(subConfig); 390 } else if (item instanceof Collection) { 391 checked.add(checkCollection((Collection<?>) item)); 392 } else { 393 checked.add(item); 394 } 395 } 396 return checked; 397 } 398 399}