001/*
002 * JGrapes Event Driven Framework
003 * Copyright (C) 2017-2018 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 java.io.File;
022import java.io.IOException;
023import java.io.InputStreamReader;
024import java.io.OutputStreamWriter;
025import java.io.Reader;
026import java.io.Writer;
027import java.nio.file.Files;
028import java.util.HashMap;
029import java.util.Map;
030import java.util.Optional;
031import java.util.StringTokenizer;
032import java.util.prefs.BackingStoreException;
033
034import org.jdrupes.json.JsonBeanDecoder;
035import org.jdrupes.json.JsonBeanEncoder;
036import org.jdrupes.json.JsonDecodeException;
037import org.jgrapes.core.Channel;
038import org.jgrapes.core.Component;
039import org.jgrapes.core.EventPipeline;
040import org.jgrapes.core.annotation.Handler;
041import org.jgrapes.core.events.Start;
042import org.jgrapes.util.events.ConfigurationUpdate;
043import org.jgrapes.util.events.InitialPreferences;
044
045/**
046 * This component provides a store for an application's configuration
047 * backed by a JSON file. The JSON object described by this file 
048 * represents the root directory. If an entry does not start with a
049 * slash, it represents the key of a key value pair. If it does
050 * starts with a slash, the value is another JSON object that
051 * describes the respective subdirectory.
052 * 
053 * The component reads the initial values from {@link File} passed
054 * to the constructor. During application bootstrap, it 
055 * intercepts the {@link Start} event using a handler with  priority 
056 * 999999. When receiving this event, it fires all known preferences 
057 * values on the channels of the start event as a 
058 * {@link InitialPreferences} event, using a new {@link EventPipeline}
059 * and waiting for its completion. Then, allows the intercepted 
060 * {@link Start} event to continue. 
061 * 
062 * Components that depend on configuration values define handlers
063 * for {@link ConfigurationUpdate} events and adapt themselves to the values 
064 * received. Note that due to the intercepted {@link Start} event, the initial
065 * preferences values are received before the {@link Start} event, so
066 * components' configurations can be rearranged before they actually
067 * start doing something.
068 *
069 * Besides initially publishing the stored preferences values,
070 * the component also listens for {@link ConfigurationUpdate} events
071 * on its channel and updates the JSON file (may be suppressed).
072 */
073public class JsonConfigurationStore extends Component {
074
075    private File file;
076    private Map<String, Object> cache;
077
078    /**
079     * Creates a new component with its channel set to the given 
080     * channel and the given file.
081     * 
082     * @param componentChannel the channel 
083     * @param file the file used to store the JSON
084     * @throws JsonDecodeException
085     */
086    public JsonConfigurationStore(Channel componentChannel, File file)
087            throws IOException {
088        this(componentChannel, file, true);
089    }
090
091    /**
092     * Creates a new component with its channel set to the given 
093     * channel and the given file.
094     * 
095     * @param componentChannel the channel 
096     * @param file the file used to store the JSON
097     * @throws JsonDecodeException
098     */
099    @SuppressWarnings("PMD.ShortVariable")
100    public JsonConfigurationStore(Channel componentChannel, File file,
101            boolean update) throws IOException {
102        super(componentChannel);
103        if (update) {
104            Handler.Evaluator.add(this, "onConfigurationUpdate",
105                channel().defaultCriterion());
106        }
107        this.file = file;
108        if (!file.exists()) {
109            cache = new HashMap<>();
110        }
111        try (Reader in = new InputStreamReader(
112            Files.newInputStream(file.toPath()), "utf-8")) {
113            @SuppressWarnings("unchecked")
114            Map<String, Object> confCache
115                = (Map<String, Object>) JsonBeanDecoder.create(in).readObject();
116            cache = confCache;
117        } catch (JsonDecodeException e) {
118            throw new IOException(e);
119        }
120    }
121
122    /**
123     * Intercepts the {@link Start} event and fires a
124     * {@link ConfigurationUpdate} event.
125     *
126     * @param event the event
127     * @throws BackingStoreException the backing store exception
128     * @throws InterruptedException the interrupted exception
129     */
130    @Handler(priority = 999999, channels = Channel.class)
131    public void onStart(Start event)
132            throws BackingStoreException, InterruptedException {
133        ConfigurationUpdate updEvt = new ConfigurationUpdate();
134        addPrefs(updEvt, "/", cache);
135        newEventPipeline().fire(updEvt, event.channels()).get();
136    }
137
138    private void addPrefs(
139            ConfigurationUpdate updEvt, String path, Map<String, ?> map) {
140        for (Map.Entry<String, ?> e : map.entrySet()) {
141            if (e.getValue() instanceof Map) {
142                @SuppressWarnings("unchecked")
143                Map<String, ?> value = (Map<String, ?>) e.getValue();
144                addPrefs(updEvt, ("/".equals(path) ? "" : path)
145                    + e.getKey(), value);
146                continue;
147            }
148            updEvt.add(path, e.getKey(), e.getValue().toString());
149        }
150    }
151
152    /**
153     * Merges and saves configuration updates.
154     *
155     * @param event the event
156     * @throws IOException Signals that an I/O exception has occurred.
157     */
158    @Handler(dynamic = true)
159    @SuppressWarnings({ "PMD.DataflowAnomalyAnalysis",
160        "PMD.AvoidLiteralsInIfCondition",
161        "PMD.AvoidInstantiatingObjectsInLoops" })
162    public void onConfigurationUpdate(ConfigurationUpdate event)
163            throws IOException {
164        boolean changed = false;
165        for (String path : event.paths()) {
166            if ("/".equals(path) && !event.values(path).isPresent()) {
167                // Special case, "remove root", i.e. all configuration data
168                cache.clear();
169                changed = true;
170            }
171            changed = changed || handleSegment(cache,
172                new StringTokenizer(path, "/"), event.values(path));
173        }
174        if (changed) {
175            try (Writer out = new OutputStreamWriter(
176                Files.newOutputStream(file.toPath()), "utf-8");
177                    JsonBeanEncoder enc = JsonBeanEncoder.create(out)) {
178                enc.writeObject(cache);
179            }
180        }
181    }
182
183    @SuppressWarnings("PMD.DataflowAnomalyAnalysis")
184    private boolean handleSegment(Map<String, Object> currentMap,
185            StringTokenizer tokenizer, Optional<Map<String, String>> values) {
186        if (!tokenizer.hasMoreTokens()) {
187            // "Leave" map
188            return mergeValues(currentMap, values.get());
189        }
190        boolean changed = false;
191        String nextSegment = "/" + tokenizer.nextToken();
192        if (!tokenizer.hasMoreTokens() && !values.isPresent()) {
193            // Next segment is last segment from path and we must remove
194            if (currentMap.containsKey(nextSegment)) {
195                // Delete sub-map.
196                currentMap.remove(nextSegment);
197                changed = true;
198            }
199            return changed;
200        }
201        // Check if next map exists
202        @SuppressWarnings("unchecked")
203        Map<String, Object> nextMap
204            = (Map<String, Object>) currentMap.get(nextSegment);
205        if (nextMap == null) {
206            // Doesn't exist, new sub-map
207            changed = true;
208            nextMap = new HashMap<>();
209            currentMap.put(nextSegment, nextMap);
210        }
211        // Continue with sub-map
212        return changed || handleSegment(nextMap, tokenizer, values);
213    }
214
215    @SuppressWarnings("PMD.DataflowAnomalyAnalysis")
216    private boolean mergeValues(Map<String, Object> currentMap,
217            Map<String, String> values) {
218        boolean changed = false;
219        for (Map.Entry<String, String> e : values.entrySet()) {
220            if (e.getValue() == null) {
221                // Delete from map
222                if (currentMap.containsKey(e.getKey())) {
223                    currentMap.remove(e.getKey());
224                    changed = true;
225                }
226                continue;
227            }
228            String oldValue = Optional.ofNullable(currentMap.get(e.getKey()))
229                .map(val -> val.toString()).orElse(null);
230            if (oldValue == null || !e.getValue().equals(oldValue)) {
231                currentMap.put(e.getKey(), e.getValue());
232                changed = true;
233            }
234        }
235        return changed;
236    }
237}