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 055 /** 056 * Creates a new component with its channel set to the given 057 * channel and the given file. The component handles 058 * {@link ConfigurationUpdate} events and {@link FileChanged} 059 * events for the configuration file (see 060 * @link #NightConfigStore(Channel, File, boolean, boolean)} 061 * 062 * @param componentChannel the channel 063 * @param file the file used to store the configuration 064 * @throws IOException 065 */ 066 @Deprecated 067 public NightConfigStore(Channel componentChannel, File file) 068 throws IOException { 069 this(componentChannel, file, true, true); 070 } 071 072 /** 073 * Creates a new component with its channel set to the given 074 * channel and the given file. The component handles 075 * {@link FileChanged} events for the configuration file (see 076 * @link #NightConfigStore(Channel, File, boolean, boolean)} 077 * 078 * If `update` is `true`, the configuration file is updated 079 * when {@link ConfigurationUpdate} events are received. 080 * 081 * @param componentChannel the channel 082 * @param file the file used to store the configuration 083 * @param update if the configuration file is to be updated 084 * @throws IOException Signals that an I/O exception has occurred. 085 */ 086 @Deprecated 087 @SuppressWarnings("PMD.ShortVariable") 088 public NightConfigStore(Channel componentChannel, File file, 089 boolean update) throws IOException { 090 this(componentChannel, file, update, true); 091 } 092 093 /** 094 * Creates a new component with its channel set to the given 095 * channel and the given file. 096 * 097 * If `update` is `true`, the configuration file is updated 098 * when {@link ConfigurationUpdate} events are received. 099 * 100 * If `watch` is `true`, {@link FileChanged} events are processed 101 * and the configuration file is reloaded when it changes. Note 102 * that the generation of the {@link FileChanged} events must 103 * be configured independently (see {@link FileSystemWatcher}). 104 * 105 * @param componentChannel the channel 106 * @param file the file used to store the configuration 107 * @param update if the configuration file is to be updated 108 * @param watch if {@link FileChanged} events are to be processed 109 * @throws IOException Signals that an I/O exception has occurred. 110 */ 111 @SuppressWarnings("PMD.ShortVariable") 112 public NightConfigStore(Channel componentChannel, File file, 113 boolean update, boolean watch) throws IOException { 114 super(componentChannel); 115 if (update) { 116 Handler.Evaluator.add(this, "onConfigurationUpdate", 117 channel().defaultCriterion()); 118 } 119 if (watch) { 120 Handler.Evaluator.add(this, "onFileChanged", 121 channel().defaultCriterion()); 122 } 123 if (!file.exists()) { 124 file.createNewFile(); 125 } 126 } 127 128 /** 129 * If watching the configuration file is enabled, fire 130 * a {@link ConfigurationUpdate} event with the complete 131 * configuration when the file changes. 132 * 133 * @param event the event 134 */ 135 @Handler(dynamic = true) 136 public void onFileChanged(FileChanged event) { 137 if (config.getNioPath().equals(event.path()) 138 && event.change() == FileChanged.Kind.MODIFIED) { 139 config.load(); 140 ConfigurationUpdate updEvt = new ConfigurationUpdate(); 141 addPrefs(updEvt, "/", config); 142 fire(updEvt); 143 } 144 } 145 146 @Override 147 @SuppressWarnings({ "PMD.AvoidLiteralsInIfCondition", 148 "PMD.AvoidBranchingStatementAsLastInLoop" }) 149 public Optional<Map<String, Object>> structured(String path) { 150 if (!path.startsWith("/")) { 151 throw new IllegalArgumentException("Path must start with \"/\"."); 152 } 153 154 // Walk down to node. 155 var segs = new StringTokenizer(path, "/"); 156 @SuppressWarnings("PMD.CloseResource") 157 Config cur = config; 158 while (segs.hasMoreTokens()) { 159 var nextSeg = segs.nextToken(); 160 Object next = Optional.ofNullable(cur.get("_" + nextSeg)) 161 .orElse(cur.get("/" + nextSeg)); 162 if (next instanceof Config) { 163 cur = (Config) next; 164 continue; 165 } 166 return Optional.empty(); 167 } 168 return Optional.of(toValueMap(cur)); 169 } 170 171 private Map<String, Object> toValueMap(Config config) { 172 @SuppressWarnings("PMD.UseConcurrentHashMap") 173 Map<String, Object> result = new HashMap<>(); 174 for (var entry : config.entrySet()) { 175 if (isNode(entry.getKey())) { 176 continue; 177 } 178 if (entry.getValue() instanceof Config) { 179 result.put(entry.getKey(), toValueMap(entry.getValue())); 180 continue; 181 } 182 if (entry.getValue() instanceof List<?> values) { 183 result.put(entry.getKey(), convertList(values)); 184 continue; 185 } 186 result.put(entry.getKey(), entry.getValue()); 187 } 188 return result; 189 } 190 191 private List<Object> convertList(List<?> values) { 192 List<Object> copy = new ArrayList<>(); 193 for (var element : values) { 194 if (element instanceof Config cfg) { 195 copy.add(toValueMap(cfg)); 196 continue; 197 } 198 copy.add(element); 199 } 200 return copy; 201 } 202 203 /** 204 * Checks if the name is an entry for a node. 205 * 206 * @param name the name 207 * @return true, if is node 208 */ 209 protected boolean isNode(String name) { 210 if (name == null || name.length() < 1) { 211 return false; 212 } 213 char first = name.charAt(0); 214 return first == '_' || first == '/'; 215 } 216 217 /** 218 * Intercepts the {@link Start} event and fires a 219 * {@link ConfigurationUpdate} event. 220 * 221 * @param event the event 222 * @throws BackingStoreException the backing store exception 223 * @throws InterruptedException the interrupted exception 224 */ 225 @Handler(priority = 999_999, channels = Channel.class) 226 @SuppressWarnings("PMD.CognitiveComplexity") 227 public void onStart(Start event) 228 throws BackingStoreException, InterruptedException { 229 ConfigurationUpdate updEvt = new InitialConfiguration(); 230 addPrefs(updEvt, "/", config); 231 newEventPipeline().fire(updEvt, event.channels()).get(); 232 } 233 234 private void addPrefs(ConfigurationUpdate updEvt, String path, 235 Config config) { 236 @SuppressWarnings("PMD.UseConcurrentHashMap") 237 Map<String, Object> atPath = new HashMap<>(); 238 for (var e : config.entrySet()) { 239 if (isNode(e.getKey()) && e.getValue() instanceof Config) { 240 addPrefs(updEvt, ("/".equals(path) ? "" : path) 241 + "/" + e.getKey().substring(1), e.getValue()); 242 continue; 243 } 244 if (e.getValue() instanceof Config) { 245 atPath.put(e.getKey(), toValueMap(e.getValue())); 246 } else { 247 atPath.put(e.getKey(), e.getValue()); 248 } 249 } 250 if (!atPath.isEmpty()) { 251 updEvt.set(path, atPath); 252 } 253 } 254 255 /** 256 * Merges and saves configuration updates. 257 * 258 * @param event the event 259 * @throws IOException Signals that an I/O exception has occurred. 260 */ 261 @Handler(dynamic = true) 262 @SuppressWarnings({ "PMD.CognitiveComplexity", "PMD.NPathComplexity", 263 "PMD.AvoidLiteralsInIfCondition", 264 "PMD.AvoidInstantiatingObjectsInLoops" }) 265 public void onConfigurationUpdate(ConfigurationUpdate event) 266 throws IOException { 267 if (event instanceof InitialConfiguration) { 268 return; 269 } 270 271 boolean changed = false; 272 for (String path : event.paths()) { 273 if ("/".equals(path) && event.values(path).isEmpty()) { 274 // Special case, "remove root", i.e. all configuration data 275 config.clear(); 276 changed = true; 277 continue; 278 } 279 if (handleSegment(config, new StringTokenizer(path, "/"), 280 event.structured(path).map(ConfigurationStore::flatten))) { 281 changed = true; 282 } 283 } 284 if (changed) { 285 config.save(); 286 } 287 } 288 289 @SuppressWarnings("PMD.DataflowAnomalyAnalysis") 290 private boolean handleSegment(Config config, 291 StringTokenizer tokenizer, Optional<Map<String, Object>> values) { 292 if (!tokenizer.hasMoreTokens()) { 293 // "Leaf" map 294 return mergeValues(config, values.get()); 295 } 296 boolean changed = false; 297 var nextSeg = tokenizer.nextToken(); 298 var usSel = List.of("_" + nextSeg); 299 var slashSel = List.of("/" + nextSeg); 300 if (!tokenizer.hasMoreTokens() && values.isEmpty()) { 301 // Selected is last segment from path and we must remove 302 for (var sel : List.of(usSel, slashSel)) { 303 if (config.get(sel) != null) { 304 // Delete sub-map. 305 config.remove(sel); 306 changed = true; 307 } 308 } 309 return changed; 310 } 311 // Check if sub config exists 312 Object subConfig = Optional.ofNullable(config.get(usSel)) 313 .orElse(config.get(slashSel)); 314 if (!(subConfig instanceof Config)) { 315 // Doesn't exist or is of wrong type, new sub-map 316 changed = true; 317 subConfig = config.createSubConfig(); 318 config.set(usSel, subConfig); 319 } 320 // Continue with sub-map 321 return handleSegment((Config) subConfig, tokenizer, values) || changed; 322 } 323 324 @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis", 325 "PMD.CognitiveComplexity" }) 326 private boolean mergeValues(Config config, Map<String, Object> values) { 327 boolean changed = false; 328 Map<String, Object> curValues = flatten(toValueMap(config)); 329 for (var e : values.entrySet()) { 330 if (e.getValue() == null) { 331 // Delete from map (and config) 332 if (curValues.containsKey(e.getKey())) { 333 curValues.remove(e.getKey()); 334 changed = true; 335 } 336 continue; 337 } 338 Object oldValue = curValues.get(e.getKey()); 339 if (oldValue == null || !e.getValue().equals(oldValue)) { 340 curValues.put(e.getKey(), e.getValue()); 341 changed = true; 342 } 343 } 344 if (changed) { 345 for (var itr = config.entrySet().iterator(); itr.hasNext();) { 346 if (!isNode(itr.next().getKey())) { 347 itr.remove(); 348 } 349 } 350 addToConfig(config, structure(curValues)); 351 } 352 return changed; 353 } 354 355 @SuppressWarnings("unchecked") 356 private void addToConfig(Config config, Map<String, Object> map) { 357 for (var e : map.entrySet()) { 358 var selector = List.of(e.getKey()); 359 if (e.getValue() instanceof Map) { 360 Config subConfig = config.get(selector); 361 if (subConfig == null) { 362 subConfig = config.createSubConfig(); 363 config.set(selector, subConfig); 364 } 365 addToConfig(subConfig, (Map<String, Object>) e.getValue()); 366 } else if (e.getValue() instanceof Collection) { 367 config.set(selector, 368 checkCollection((Collection<?>) e.getValue())); 369 } else { 370 config.set(selector, e.getValue()); 371 } 372 } 373 } 374 375 @SuppressWarnings("unchecked") 376 private Collection<Object> checkCollection(Collection<?> items) { 377 var checked = new ArrayList<>(); 378 for (var item : items) { 379 if (item instanceof Map) { 380 Config subConfig = config.createSubConfig(); 381 addToConfig(subConfig, (Map<String, Object>) item); 382 checked.add(subConfig); 383 } else if (item instanceof Collection) { 384 checked.add(checkCollection((Collection<?>) item)); 385 } else { 386 checked.add(item); 387 } 388 } 389 return checked; 390 } 391 392}